Allow parent to be changed or cleared after creation

Amolith created

Change summary

src/cli.rs                         |  4 ++
src/cmd/mod.rs                     |  7 ++++
src/cmd/update.rs                  |  9 +++++
src/cmd/webui/project/mutations.rs | 19 ++++++++++++
src/cmd/webui/task/mod.rs          |  1 
src/cmd/webui/task/views.rs        |  2 +
src/ops.rs                         | 50 ++++++++++++++++++++++++++++++++
templates/task.html                |  5 +++
8 files changed, 97 insertions(+)

Detailed changes

src/cli.rs 🔗

@@ -116,6 +116,10 @@ pub enum Command {
         /// Set task type (e.g. task, bug, feature)
         #[arg(long = "type")]
         task_type: Option<String>,
+
+        /// Set parent task ID (pass empty string "" to clear)
+        #[arg(long)]
+        parent: Option<String>,
     },
 
     /// Mark task(s) as closed

src/cmd/mod.rs 🔗

@@ -95,10 +95,16 @@ pub fn dispatch(cli: &Cli) -> Result<()> {
             title,
             desc,
             task_type,
+            parent,
         } => {
             let root = require_root()?;
             let pri = priority.as_deref().map(Priority::parse).transpose()?;
             let eff = effort.as_deref().map(Effort::parse).transpose()?;
+            // --parent "" means clear; --parent <id> means set; absent means don't change.
+            let parent_opt =
+                parent
+                    .as_ref()
+                    .map(|p| if p.is_empty() { None } else { Some(p.as_str()) });
             update::run(
                 &root,
                 id,
@@ -109,6 +115,7 @@ pub fn dispatch(cli: &Cli) -> Result<()> {
                     title: title.as_deref(),
                     desc: desc.as_deref(),
                     task_type: task_type.as_deref(),
+                    parent: parent_opt,
                     json: cli.json,
                 },
             )

src/cmd/update.rs 🔗

@@ -13,6 +13,7 @@ pub struct Opts<'a> {
     pub title: Option<&'a str>,
     pub desc: Option<&'a str>,
     pub task_type: Option<&'a str>,
+    pub parent: Option<Option<&'a str>>,
     pub json: bool,
 }
 
@@ -32,6 +33,7 @@ pub fn run(root: &Path, id: &str, opts: Opts) -> Result<()> {
         && opts.title.is_none()
         && opts.desc.is_none()
         && opts.task_type.is_none()
+        && opts.parent.is_none()
     {
         let interactive = std::env::var("TD_FORCE_EDITOR").is_ok()
             || std::io::IsTerminal::is_terminal(&std::io::stdin());
@@ -66,6 +68,12 @@ pub fn run(root: &Path, id: &str, opts: Opts) -> Result<()> {
         (opts.title, opts.desc)
     };
 
+    let resolved_parent = match opts.parent {
+        Some(Some(id)) => Some(Some(db::resolve_task_id(&store, id, false)?)),
+        Some(None) => Some(None),
+        None => None,
+    };
+
     let task = ops::update_task(
         &store,
         &task_id,
@@ -76,6 +84,7 @@ pub fn run(root: &Path, id: &str, opts: Opts) -> Result<()> {
             title: title_override.map(String::from),
             description: desc_override.map(String::from),
             task_type: opts.task_type.map(String::from),
+            parent: resolved_parent,
         },
     )?;
 

src/cmd/webui/project/mutations.rs 🔗

@@ -61,6 +61,13 @@ pub(in crate::cmd::webui) struct UpdateForm {
     effort: Option<String>,
     #[serde(default)]
     task_type: Option<String>,
+    /// Present and non-empty = set parent; present and empty = clear parent;
+    /// absent = don't change.  The form uses `_parent_present` as a sentinel
+    /// so we can distinguish "field not submitted" from "submitted empty".
+    #[serde(default)]
+    parent: Option<String>,
+    #[serde(default)]
+    _parent_present: Option<String>,
     #[serde(default)]
     redirect: Option<String>,
 }
@@ -200,6 +207,18 @@ pub(in crate::cmd::webui) async fn update_handler(
                 title: form.title.filter(|s| !s.is_empty()),
                 description: form.description,
                 task_type: form.task_type.filter(|s| !s.is_empty()),
+                // Only interpret `parent` when the sentinel field proves the
+                // edit form was the one submitted (not a status-change form).
+                parent: if form._parent_present.is_some() {
+                    match form.parent.as_deref() {
+                        Some(p) if !p.is_empty() => {
+                            Some(Some(db::resolve_task_id(&store, p, false)?))
+                        }
+                        _ => Some(None), // clear parent
+                    }
+                } else {
+                    None // field not submitted — don't change
+                },
             },
         )?;
 

src/cmd/webui/task/mod.rs 🔗

@@ -81,6 +81,7 @@ pub(in crate::cmd::webui) async fn task_handler(
             created_at: task.created_at.clone(),
             updated_at_display: friendly_date(&task.updated_at),
             updated_at: task.updated_at.clone(),
+            parent_id: task.parent.as_ref().map(|p| p.short()).unwrap_or_default(),
             labels: task.labels.clone(),
             logs: task
                 .logs

src/cmd/webui/task/views.rs 🔗

@@ -18,6 +18,8 @@ pub(in crate::cmd::webui) struct TaskView {
     pub(in crate::cmd::webui) created_at_display: String,
     pub(in crate::cmd::webui) updated_at: String,
     pub(in crate::cmd::webui) updated_at_display: String,
+    /// Parent task ID (short form) for display, empty string if none.
+    pub(in crate::cmd::webui) parent_id: String,
     pub(in crate::cmd::webui) labels: Vec<String>,
     pub(in crate::cmd::webui) logs: Vec<LogView>,
 }

src/ops.rs 🔗

@@ -77,6 +77,9 @@ pub fn create_task(store: &Store, opts: CreateOpts) -> Result<Task> {
 
 /// Input for updating an existing task.  All fields are optional; only
 /// populated fields are written.
+///
+/// `parent` uses a nested Option: `None` means "don't change", `Some(None)`
+/// means "clear the parent", `Some(Some(id))` means "set a new parent".
 pub struct UpdateOpts {
     pub status: Option<Status>,
     pub priority: Option<Priority>,
@@ -84,12 +87,27 @@ pub struct UpdateOpts {
     pub title: Option<String>,
     pub description: Option<String>,
     pub task_type: Option<String>,
+    pub parent: Option<Option<TaskId>>,
 }
 
 /// Update task fields and return the refreshed task.
 pub fn update_task(store: &Store, task_id: &TaskId, opts: UpdateOpts) -> Result<Task> {
     let ts = now_utc();
 
+    // Validate parent change before persisting to avoid writing a cycle.
+    if let Some(Some(ref new_parent)) = opts.parent {
+        if new_parent == task_id {
+            bail!("a task cannot be its own parent");
+        }
+        if parent_would_cycle(store, task_id, new_parent)? {
+            bail!(
+                "reparenting {} under {} would create a cycle",
+                task_id.short(),
+                new_parent.short()
+            );
+        }
+    }
+
     store.apply_and_persist(|doc| {
         let tasks = doc.get_map("tasks");
         let task = db::get_task_map(&tasks, task_id)?.ok_or_else(|| anyhow!("task not found"))?;
@@ -112,6 +130,11 @@ pub fn update_task(store: &Store, task_id: &TaskId, opts: UpdateOpts) -> Result<
         if let Some(ref tt) = opts.task_type {
             task.insert("type", tt.as_str())?;
         }
+        match opts.parent {
+            Some(Some(ref p)) => task.insert("parent", p.as_str())?,
+            Some(None) => task.insert("parent", "")?,
+            None => {}
+        }
         task.insert("updated_at", ts.clone())?;
         Ok(())
     })?;
@@ -317,6 +340,33 @@ pub fn soft_delete(store: &Store, ids: &[TaskId], recursive: bool) -> Result<Del
     })
 }
 
+/// Walk the parent chain from `new_parent` upward.  If we reach `child`,
+/// making `child` a subtask of `new_parent` would create a cycle.
+fn parent_would_cycle(store: &Store, child: &TaskId, new_parent: &TaskId) -> Result<bool> {
+    let tasks = store.list_tasks_unfiltered()?;
+    let parent_of: HashMap<String, String> = tasks
+        .iter()
+        .filter_map(|t| {
+            t.parent
+                .as_ref()
+                .map(|p| (t.id.as_str().to_string(), p.as_str().to_string()))
+        })
+        .collect();
+
+    let mut cur = new_parent.as_str().to_string();
+    let mut seen = HashSet::new();
+    while let Some(p) = parent_of.get(&cur) {
+        if p == child.as_str() {
+            return Ok(true);
+        }
+        if !seen.insert(p.clone()) {
+            break; // existing cycle in data — don't loop forever
+        }
+        cur = p.clone();
+    }
+    Ok(false)
+}
+
 fn would_cycle(store: &Store, child: &TaskId, parent: &TaskId) -> Result<bool> {
     let tasks = store.list_tasks_unfiltered()?;
     let mut graph: HashMap<String, HashSet<String>> = HashMap::new();

templates/task.html 🔗

@@ -223,6 +223,11 @@
           </select>
         </div>
       </div>
+      <label data-field>
+        Parent task ID <small class="text-light">(leave empty to clear)</small>
+        <input type="text" name="parent" value="{{ task.parent_id }}" placeholder="td-XXXXXXX" aria-label="Parent task ID">
+      </label>
+      <input type="hidden" name="_parent_present" value="1">
     </div>
     <footer>
       <button type="button" commandfor="dlg-edit-task" command="close" class="outline">Cancel</button>