Print empty-state note on list commands
List commands (task, backlog, comment, tag) printed nothing when a
query matched no rows, so a successful run was indistinguishable from
silence. output::print_list now takes an empty-state note and prints
it to stderr when human output is empty, keeping stdout clean for
pipes; --json still prints []. task list echoes the applied filters
(state, tag, backlog) in the note.

Co-Authored-By: Claude <noreply@anthropic.com>
change zrkkqlzpmmurlmnuzrnxzqoxrwpyuytl
commit 15e2c170e6a3bd3771d391856c7d1bce98c27543
author Alpha Chen <alpha@kejadlen.dev>
date
parent uyvuruqq
diff --git a/src/bin/ranger/commands/backlog.rs b/src/bin/ranger/commands/backlog.rs
index 1f0a99c..ea98333 100644
--- a/src/bin/ranger/commands/backlog.rs
+++ b/src/bin/ranger/commands/backlog.rs
@@ -64,7 +64,7 @@ pub async fn run(
         }
         BacklogCommands::List => {
             let backlogs = ops::backlog::list(&mut conn).await?;
-            output::print_list(&backlogs, json, print_backlog);
+            output::print_list(&backlogs, json, "No backlogs.", print_backlog);
         }
         BacklogCommands::Delete { name, yes } => {
             if !yes {
diff --git a/src/bin/ranger/commands/comment.rs b/src/bin/ranger/commands/comment.rs
index 01cdbcc..3e5b4f8 100644
--- a/src/bin/ranger/commands/comment.rs
+++ b/src/bin/ranger/commands/comment.rs
@@ -46,7 +46,8 @@ pub async fn run(
         CommentCommands::List { task } => {
             let t = ops::task::get_by_key_prefix(&mut conn, &task, backlog_scope).await?;
             let comments = ops::comment::list(&mut conn, t.id).await?;
-            output::print_list(&comments, json, |c| {
+            let note = format!("No comments on task {}.", t.key);
+            output::print_list(&comments, json, &note, |c| {
                 println!("[{}] {}", c.created_at, c.body);
             });
         }
diff --git a/src/bin/ranger/commands/tag.rs b/src/bin/ranger/commands/tag.rs
index faf86a5..9f066d9 100644
--- a/src/bin/ranger/commands/tag.rs
+++ b/src/bin/ranger/commands/tag.rs
@@ -65,18 +65,14 @@ pub async fn run(pool: &SqlitePool, command: TagCommands, json: bool) -> Result<
             } else {
                 ops::tag::list_all(&mut conn).await?
             };
-            output::print_list(&tags, json, |t| println!("{}", t.name));
+            output::print_list(&tags, json, "No tags.", |t| println!("{}", t.name));
         }
         TagCommands::Prune { apply } => {
             let pruned = ops::tag::prune(&mut conn, !apply).await?;
-            if pruned.is_empty() {
-                if !json {
-                    println!("No unused tags to remove.");
-                }
-            } else {
-                let label = if apply { "Removed" } else { "Would remove" };
-                output::print_list(&pruned, json, |t| println!("{}: {}", label, t.name));
-            }
+            let label = if apply { "Removed" } else { "Would remove" };
+            output::print_list(&pruned, json, "No unused tags to remove.", |t| {
+                println!("{}: {}", label, t.name)
+            });
         }
     }
     Ok(())
diff --git a/src/bin/ranger/commands/task.rs b/src/bin/ranger/commands/task.rs
index 73064d2..8c50151 100644
--- a/src/bin/ranger/commands/task.rs
+++ b/src/bin/ranger/commands/task.rs
@@ -228,7 +228,8 @@ pub async fn run(pool: &SqlitePool, command: TaskCommands, json: bool) -> Result
                 let backlog_keys = ops::task::keys_for_backlog(&mut conn, bl.id).await?;
                 let prefixes = key::unique_prefix_lengths(&backlog_keys);
                 let tasks = ops::task::list(&mut conn, bl.id, &filter).await?;
-                output::print_list(&tasks, json, |t| print_task(t, &prefixes));
+                let note = empty_task_note(&filter, Some(backlog_name));
+                output::print_list(&tasks, json, &note, |t| print_task(t, &prefixes));
             } else {
                 // List all tasks (no backlog filter)
                 let all_keys = ops::task::all_keys(&mut conn).await?;
@@ -243,7 +244,8 @@ pub async fn run(pool: &SqlitePool, command: TaskCommands, json: bool) -> Result
                         }
                     }
                 }
-                output::print_list(&all_tasks, json, |t| print_task(t, &prefixes));
+                let note = empty_task_note(&filter, None);
+                output::print_list(&all_tasks, json, &note, |t| print_task(t, &prefixes));
             }
         }
         TaskCommands::Show { key } => {
@@ -402,6 +404,27 @@ pub async fn run(pool: &SqlitePool, command: TaskCommands, json: bool) -> Result
     Ok(())
 }
 
+/// Describe an empty `task list` result, echoing the filters that were applied.
+fn empty_task_note(filter: &ListFilter, backlog: Option<&str>) -> String {
+    let mut note = String::from("No ");
+    if let Some(state) = &filter.state {
+        note.push_str(&state.as_str().replace('_', "-"));
+        note.push(' ');
+    }
+    note.push_str("tasks");
+    if let Some(tag) = &filter.tag {
+        note.push_str(" tagged #");
+        note.push_str(tag);
+    }
+    if let Some(name) = backlog {
+        note.push_str(" in backlog '");
+        note.push_str(name);
+        note.push('\'');
+    }
+    note.push('.');
+    note
+}
+
 fn print_task(t: &Task, prefixes: &HashMap<String, usize>) {
     println!(
         "{} [{}] {}",
diff --git a/src/bin/ranger/output.rs b/src/bin/ranger/output.rs
index a961efc..ce2dcfa 100644
--- a/src/bin/ranger/output.rs
+++ b/src/bin/ranger/output.rs
@@ -53,9 +53,17 @@ pub fn print<T: Serialize + std::fmt::Debug>(value: &T, json: bool, human: impl
     }
 }
 
-pub fn print_list<T: Serialize + std::fmt::Debug>(values: &[T], json: bool, human: impl Fn(&T)) {
+pub fn print_list<T: Serialize + std::fmt::Debug>(
+    values: &[T],
+    json: bool,
+    empty_note: &str,
+    human: impl Fn(&T),
+) {
     if json {
         println!("{}", serde_json::to_string_pretty(values).unwrap());
+    } else if values.is_empty() {
+        // Stderr so a pipe still sees an empty stdout; `--json` keeps printing `[]`.
+        eprintln!("{empty_note}");
     } else {
         for v in values {
             human(v);
diff --git a/tests/cli.rs b/tests/cli.rs
index 42e8445..830b71d 100644
--- a/tests/cli.rs
+++ b/tests/cli.rs
@@ -671,3 +671,151 @@ fn full_workflow() {
         .stdout(predicates::str::contains("ranger "))
         .stdout(predicates::str::is_match(r"ranger \S+").unwrap());
 }
+
+#[test]
+fn empty_list_prints_note_on_stderr() {
+    let dir = tempdir().unwrap();
+    let db = dir.path().join("test.db");
+    let db_path = db.to_str().unwrap();
+
+    // A backlog with no tasks exercises the task-list empty cases.
+    ranger(db_path)
+        .args(["backlog", "create", "Empty"])
+        .assert()
+        .success();
+
+    // Plain empty: note on stderr, stdout stays clean for pipes.
+    let output = ranger(db_path)
+        .args(["task", "list", "--backlog", "Empty"])
+        .output()
+        .unwrap();
+    assert!(output.status.success());
+    assert!(
+        String::from_utf8(output.stdout).unwrap().is_empty(),
+        "stdout must stay clean when the list is empty"
+    );
+    assert_eq!(
+        String::from_utf8(output.stderr).unwrap().trim(),
+        "No tasks in backlog 'Empty'."
+    );
+
+    // State filter: adjective form, with snake_case rendered as hyphenated.
+    let output = ranger(db_path)
+        .args(["task", "list", "--backlog", "Empty", "--state", "ready"])
+        .output()
+        .unwrap();
+    assert_eq!(
+        String::from_utf8(output.stderr).unwrap().trim(),
+        "No ready tasks in backlog 'Empty'."
+    );
+    let output = ranger(db_path)
+        .args([
+            "task",
+            "list",
+            "--backlog",
+            "Empty",
+            "--state",
+            "in_progress",
+        ])
+        .output()
+        .unwrap();
+    assert_eq!(
+        String::from_utf8(output.stderr).unwrap().trim(),
+        "No in-progress tasks in backlog 'Empty'."
+    );
+
+    // Tag filter only.
+    let output = ranger(db_path)
+        .args(["task", "list", "--backlog", "Empty", "--tag", "bug"])
+        .output()
+        .unwrap();
+    assert_eq!(
+        String::from_utf8(output.stderr).unwrap().trim(),
+        "No tasks tagged #bug in backlog 'Empty'."
+    );
+
+    // State + tag together.
+    let output = ranger(db_path)
+        .args([
+            "task",
+            "list",
+            "--backlog",
+            "Empty",
+            "--state",
+            "ready",
+            "--tag",
+            "bug",
+        ])
+        .output()
+        .unwrap();
+    assert_eq!(
+        String::from_utf8(output.stderr).unwrap().trim(),
+        "No ready tasks tagged #bug in backlog 'Empty'."
+    );
+
+    // JSON mode keeps printing [] and emits no note.
+    let output = ranger(db_path)
+        .args(["task", "list", "--backlog", "Empty", "--json"])
+        .output()
+        .unwrap();
+    assert!(output.status.success());
+    assert_eq!(String::from_utf8(output.stdout).unwrap().trim(), "[]");
+    assert!(String::from_utf8(output.stderr).unwrap().is_empty());
+
+    // The all-tasks path (no backlog filter) on a DB whose only backlog is empty.
+    let output = ranger(db_path)
+        .env_remove("RANGER_DEFAULT_BACKLOG")
+        .args(["task", "list"])
+        .output()
+        .unwrap();
+    assert_eq!(
+        String::from_utf8(output.stderr).unwrap().trim(),
+        "No tasks."
+    );
+}
+
+#[test]
+fn empty_lists_other_commands() {
+    let dir = tempdir().unwrap();
+    let db = dir.path().join("test.db");
+    let db_path = db.to_str().unwrap();
+
+    ranger(db_path)
+        .args(["backlog", "create", "Ranger"])
+        .assert()
+        .success();
+    let t1 = ranger(db_path)
+        .args(["task", "create", "Lonely task", "--json"])
+        .output()
+        .unwrap();
+    let t1_key: String = serde_json::from_slice::<serde_json::Value>(&t1.stdout).unwrap()["key"]
+        .as_str()
+        .unwrap()
+        .to_string();
+
+    // comment list on a task with no comments.
+    let output = ranger(db_path)
+        .args(["comment", "list", &t1_key])
+        .output()
+        .unwrap();
+    assert!(output.status.success());
+    assert!(String::from_utf8(output.stdout).unwrap().is_empty());
+    let stderr = String::from_utf8(output.stderr).unwrap();
+    assert!(stderr.starts_with("No comments on task "), "got: {stderr}");
+
+    // tag prune with no unused tags.
+    let output = ranger(db_path).args(["tag", "prune"]).output().unwrap();
+    assert!(output.status.success());
+    assert!(String::from_utf8(output.stdout).unwrap().is_empty());
+    assert_eq!(
+        String::from_utf8(output.stderr).unwrap().trim(),
+        "No unused tags to remove."
+    );
+
+    // tag list with no tags anywhere.
+    let output = ranger(db_path)
+        .args(["tag", "list", "--all"])
+        .output()
+        .unwrap();
+    assert_eq!(String::from_utf8(output.stderr).unwrap().trim(), "No tags.");
+}