Distinguish resolved blockers from open ones in show output

Amolith created

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.

Change summary

src/cmd/show.rs        | 28 +++++++++++---
src/db.rs              | 29 +++++++++++++++
tests/cli_list_show.rs | 82 ++++++++++++++++++++++++++++++++++++++++++++
3 files changed, 132 insertions(+), 7 deletions(-)

Detailed changes

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<String> = 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);
         }
     }
 

src/db.rs 🔗

@@ -132,6 +132,35 @@ pub fn load_blockers(conn: &Connection, task_id: &str) -> Result<Vec<String>> {
     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<String>, Vec<String>)> {
+    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::<rusqlite::Result<_>>()?;
+    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

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]"));
+}