From f587294dd456078f2cba718eb9f4c91a5875b7f4 Mon Sep 17 00:00:00 2001 From: Amolith Date: Wed, 25 Feb 2026 20:44:30 +0000 Subject: [PATCH] Distinguish resolved blockers from open ones in show output show displays all blocker edges regardless of status, making tasks appear blocked when their blockers are closed. Split the display into open blockers (actually blocking) and resolved ones. --- src/cmd/show.rs | 28 +++++++++++---- src/db.rs | 29 +++++++++++++++ tests/cli_list_show.rs | 82 ++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 132 insertions(+), 7 deletions(-) diff --git a/src/cmd/show.rs b/src/cmd/show.rs index 25cc9f714208c6d7d10395ccc267d5e64038ac7f..755e9cbf4946f08fc409986b5b392905e4601ec2 100644 --- a/src/cmd/show.rs +++ b/src/cmd/show.rs @@ -50,13 +50,27 @@ pub fn run(root: &Path, id: &str, json: bool) -> Result<()> { detail.labels.join(",") ); } - if !detail.blockers.is_empty() { - println!( - "{} blockers{} = {}", - c.bold, - c.reset, - detail.blockers.join(",") - ); + let (open_blockers, closed_blockers) = db::load_blockers_partitioned(&conn, &t.id)?; + let total = open_blockers.len() + closed_blockers.len(); + if total > 0 { + let label = if total == 1 { "blocker" } else { "blockers" }; + let all_closed = open_blockers.is_empty(); + let mut ids: Vec = Vec::new(); + for id in &open_blockers { + ids.push(id.clone()); + } + for id in &closed_blockers { + ids.push(format!("{id} [closed]")); + } + + let value = if all_closed { + format!("[all closed] {}", ids.join(", ")) + } else { + ids.join(", ") + }; + + // Right-align the label to match other fields (12 chars wide). + println!("{}{label:>12}{} = {value}", c.bold, c.reset); } } diff --git a/src/db.rs b/src/db.rs index 59ea9ada8b361084e547557977cdbd6f77c86ae2..fa019d8cc8d59988ec7a3fbff3529a0480184091 100644 --- a/src/db.rs +++ b/src/db.rs @@ -132,6 +132,35 @@ pub fn load_blockers(conn: &Connection, task_id: &str) -> Result> { Ok(blockers) } +/// Load blockers for a task, partitioned by whether they are resolved. +/// +/// Returns `(open, resolved)` where open blockers have a non-closed status +/// and resolved blockers are closed. +pub fn load_blockers_partitioned( + conn: &Connection, + task_id: &str, +) -> Result<(Vec, Vec)> { + let mut stmt = conn.prepare( + "SELECT b.blocker_id, COALESCE(t.status, 'open') + FROM blockers b + LEFT JOIN tasks t ON b.blocker_id = t.id + WHERE b.task_id = ?1", + )?; + let mut open = Vec::new(); + let mut resolved = Vec::new(); + let rows: Vec<(String, String)> = stmt + .query_map([task_id], |r| Ok((r.get(0)?, r.get(1)?)))? + .collect::>()?; + for (id, status) in rows { + if status == "closed" { + resolved.push(id); + } else { + open.push(id); + } + } + Ok((open, resolved)) +} + /// Check whether `from` can reach `to` by following blocker edges. /// /// Returns `true` if there is a path from `from` to `to` in the blocker diff --git a/tests/cli_list_show.rs b/tests/cli_list_show.rs index f1e6d3f6bd39b3e14ed1309a84f4fc88ffb81395..347d4f8bd8c38db4fccafc01f8b45e8d123a0bb8 100644 --- a/tests/cli_list_show.rs +++ b/tests/cli_list_show.rs @@ -201,3 +201,85 @@ fn show_nonexistent_task_fails() { .failure() .stderr(predicate::str::contains("not found")); } + +#[test] +fn show_annotates_closed_blockers() { + let tmp = init_tmp(); + let task = create_task(&tmp, "Blocked task"); + let open_blocker = create_task(&tmp, "Still open"); + let closed_blocker = create_task(&tmp, "Will close"); + + td().args(["dep", "add", &task, &open_blocker]) + .current_dir(&tmp) + .assert() + .success(); + td().args(["dep", "add", &task, &closed_blocker]) + .current_dir(&tmp) + .assert() + .success(); + td().args(["done", &closed_blocker]) + .current_dir(&tmp) + .assert() + .success(); + + // Plural label, open blocker bare, closed one annotated. + td().args(["show", &task]) + .current_dir(&tmp) + .assert() + .success() + .stdout(predicate::str::contains("blockers")) + .stdout(predicate::str::contains(&open_blocker)) + .stdout(predicate::str::contains(&format!( + "{closed_blocker} [closed]" + ))); +} + +#[test] +fn show_all_closed_blockers_prefixed() { + let tmp = init_tmp(); + let task = create_task(&tmp, "Was blocked"); + let blocker = create_task(&tmp, "Done now"); + + td().args(["dep", "add", &task, &blocker]) + .current_dir(&tmp) + .assert() + .success(); + td().args(["done", &blocker]) + .current_dir(&tmp) + .assert() + .success(); + + // Singular label, [all closed] prefix. + td().args(["show", &task]) + .current_dir(&tmp) + .assert() + .success() + .stdout(predicate::str::contains("blocker")) + .stdout(predicate::str::contains("[all closed]")) + .stdout(predicate::str::contains(&blocker)); +} + +#[test] +fn show_single_open_blocker_singular_label() { + let tmp = init_tmp(); + let task = create_task(&tmp, "Blocked"); + let blocker = create_task(&tmp, "Blocking"); + + td().args(["dep", "add", &task, &blocker]) + .current_dir(&tmp) + .assert() + .success(); + + let out = td() + .args(["show", &task]) + .current_dir(&tmp) + .output() + .unwrap(); + let stdout = String::from_utf8_lossy(&out.stdout); + + // Singular "blocker", no "blockers". + assert!(stdout.contains("blocker")); + assert!(stdout.contains(&blocker)); + // Should not contain [closed] or [all closed]. + assert!(!stdout.contains("[closed]")); +}