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>
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, ¬e, |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, ¬e, |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, ¬e, |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.");
+}