diff --git a/src/cli.rs b/src/cli.rs index e2be9629ac15d6627fa2f9f312d1e8d6778313fe..2951db2e675151ee4a3586c45b40046099242b1c 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -116,6 +116,10 @@ pub enum Command { /// Set task type (e.g. task, bug, feature) #[arg(long = "type")] task_type: Option, + + /// Set parent task ID (pass empty string "" to clear) + #[arg(long)] + parent: Option, }, /// Mark task(s) as closed diff --git a/src/cmd/mod.rs b/src/cmd/mod.rs index db8d1bafc4c1da565abb8f774dacaed934df8780..a169f7c1463539c24f70c26eb5838afc5b3484b1 100644 --- a/src/cmd/mod.rs +++ b/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 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, }, ) diff --git a/src/cmd/update.rs b/src/cmd/update.rs index 6c8c0e51e1514a65a4f9b28c361aa931b44048b7..ea0f8735b34c21092b9df19f0b619aec0896858d 100644 --- a/src/cmd/update.rs +++ b/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>, 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, }, )?; diff --git a/src/cmd/webui/project/mutations.rs b/src/cmd/webui/project/mutations.rs index 0d9489d004786c509106c4cdbf417544134ac970..269133e0e2df3bae13cf60b2cc25f12a99eed639 100644 --- a/src/cmd/webui/project/mutations.rs +++ b/src/cmd/webui/project/mutations.rs @@ -61,6 +61,13 @@ pub(in crate::cmd::webui) struct UpdateForm { effort: Option, #[serde(default)] task_type: Option, + /// 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, + #[serde(default)] + _parent_present: Option, #[serde(default)] redirect: Option, } @@ -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 + }, }, )?; diff --git a/src/cmd/webui/task/mod.rs b/src/cmd/webui/task/mod.rs index 5f376d4e98099db44fcadb56f4788bf6334b727b..366b31e1270850f2b593e17905fbf569b8c5f535 100644 --- a/src/cmd/webui/task/mod.rs +++ b/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 diff --git a/src/cmd/webui/task/views.rs b/src/cmd/webui/task/views.rs index 8c23e53c1ec2e6b6aac4e3b07a339c5ba8b86068..b80fd5de0164ab9438bc2c85621ec791ae499fb9 100644 --- a/src/cmd/webui/task/views.rs +++ b/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, pub(in crate::cmd::webui) logs: Vec, } diff --git a/src/ops.rs b/src/ops.rs index f00ba1b718addf4217ad36e41b9ef1cc08928a81..6c15d636b31d7dfa3b84dbe573f3354db11b7334 100644 --- a/src/ops.rs +++ b/src/ops.rs @@ -77,6 +77,9 @@ pub fn create_task(store: &Store, opts: CreateOpts) -> Result { /// 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, pub priority: Option, @@ -84,12 +87,27 @@ pub struct UpdateOpts { pub title: Option, pub description: Option, pub task_type: Option, + pub parent: Option>, } /// Update task fields and return the refreshed task. pub fn update_task(store: &Store, task_id: &TaskId, opts: UpdateOpts) -> Result { 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 Result { + let tasks = store.list_tasks_unfiltered()?; + let parent_of: HashMap = 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 { let tasks = store.list_tasks_unfiltered()?; let mut graph: HashMap> = HashMap::new(); diff --git a/templates/task.html b/templates/task.html index 2437d2c72df9ab99be7731af1645f7f731409193..ed2f3326de8b4c52c17736f771b40e1692c6e051 100644 --- a/templates/task.html +++ b/templates/task.html @@ -223,6 +223,11 @@ + +