Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 21 additions & 9 deletions crates/runbox-cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1855,6 +1855,8 @@ struct RunnableInfo {
tags: String,
#[serde(skip_serializing_if = "Option::is_none")]
repo_url: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
playlist_name: Option<String>,
}

/// Safely truncate a string to max_chars characters, adding "..." if truncated.
Expand Down Expand Up @@ -2048,21 +2050,28 @@ fn cmd_list(
}

// Collect info for output
let show_repo = all_repos || verbose;
let infos: Vec<RunnableInfo> = filtered
.iter()
.map(|r| {
let repo_url = if verbose {
let repo_url = if show_repo {
storage.get_runnable_repo_url(r)
} else {
None
};
let playlist_name = if show_repo {
storage.get_runnable_playlist_name(r)
} else {
None
};
RunnableInfo {
short_id: r.short_id(),
runnable_type: r.type_label().to_string(),
source: r.source_label(),
name: storage.get_runnable_display_name(r),
tags: r.tags_label(),
repo_url,
playlist_name,
}
})
.collect();
Expand All @@ -2076,33 +2085,36 @@ fn cmd_list(
}
} else {
// Table output
if verbose {
if show_repo {
println!(
"{:<10} {:<10} {:<16} {:<24} {:<6} {}",
"SHORT", "TYPE", "SOURCE", "NAME", "TAGS", "REPO"
"{:<10} {:<10} {:<16} {:<24} {:<6} {:<24} {}",
"SHORT", "TYPE", "SOURCE", "NAME", "TAGS", "REPO", "PLAYLIST"
);
} else {
println!(
"{:<10} {:<10} {:<16} {:<24} {}",
"SHORT", "TYPE", "SOURCE", "NAME", "TAGS"
);
}
println!("{}", "─".repeat(if verbose { 90 } else { 70 }));
println!("{}", "─".repeat(if show_repo { 110 } else { 70 }));

for info in &infos {
let name_truncated = truncate_string(&info.name, 24);

if verbose {
if show_repo {
let repo_display = info.repo_url.as_deref().unwrap_or("-");
let repo_truncated = truncate_string(repo_display, 20);
let repo_truncated = truncate_string(repo_display, 24);
let playlist_display = info.playlist_name.as_deref().unwrap_or("-");
let playlist_truncated = truncate_string(playlist_display, 20);
println!(
"{:<10} {:<10} {:<16} {:<24} {:<6} {}",
"{:<10} {:<10} {:<16} {:<24} {:<6} {:<24} {}",
info.short_id,
info.runnable_type,
info.source,
name_truncated,
info.tags,
repo_truncated
repo_truncated,
playlist_truncated
);
} else {
println!(
Expand Down
151 changes: 114 additions & 37 deletions crates/runbox-cli/tests/list.rs
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ fn create_playlist(home: &std::path::Path, id: &str, name: &str, items: &[(&str,
}
})
.collect();

let playlist = serde_json::json!({
"playlist_id": id,
"name": name,
Expand All @@ -85,7 +85,7 @@ fn create_playlist(home: &std::path::Path, id: &str, name: &str, items: &[(&str,
fn test_list_empty() {
let home = setup_home();
let output = runbox(&["list", "--all-repos"], home.path());

assert!(output.status.success());
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(stdout.contains("No runnables found"));
Expand All @@ -96,9 +96,9 @@ fn test_list_templates_only() {
let home = setup_home();
create_template(home.path(), "tpl_echo", "Echo Command");
create_template(home.path(), "tpl_train", "Train Model");

let output = runbox(&["list", "--type", "template", "--all-repos"], home.path());

assert!(output.status.success());
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(stdout.contains("template"));
Expand All @@ -112,9 +112,9 @@ fn test_list_replays_only() {
let home = setup_home();
create_run(home.path(), "run_550e8400-e29b-41d4-a716-446655440000");
create_run(home.path(), "run_a1b2c3d4-e5f6-7890-abcd-ef1234567890");

let output = runbox(&["list", "--type", "replay", "--all-repos"], home.path());

assert!(output.status.success());
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(stdout.contains("replay"));
Expand All @@ -127,13 +127,18 @@ fn test_list_replays_only() {
fn test_list_playlist_items() {
let home = setup_home();
create_template(home.path(), "tpl_echo", "Echo Command");
create_playlist(home.path(), "pl_daily", "Daily Tasks", &[
("tpl_echo", Some("Morning Echo")),
("tpl_echo", Some("Evening Echo")),
]);

create_playlist(
home.path(),
"pl_daily",
"Daily Tasks",
&[
("tpl_echo", Some("Morning Echo")),
("tpl_echo", Some("Evening Echo")),
],
);

let output = runbox(&["list", "--type", "playlist", "--all-repos"], home.path());

assert!(output.status.success());
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(stdout.contains("playlist"));
Expand All @@ -149,12 +154,15 @@ fn test_list_all_types() {
let home = setup_home();
create_template(home.path(), "tpl_echo", "Echo Command");
create_run(home.path(), "run_550e8400-e29b-41d4-a716-446655440000");
create_playlist(home.path(), "pl_daily", "Daily Tasks", &[
("tpl_echo", Some("Morning Echo")),
]);

create_playlist(
home.path(),
"pl_daily",
"Daily Tasks",
&[("tpl_echo", Some("Morning Echo"))],
);

let output = runbox(&["list", "--all-repos"], home.path());

assert!(output.status.success());
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(stdout.contains("template"));
Expand All @@ -167,15 +175,21 @@ fn test_list_all_types() {
fn test_list_filter_by_playlist() {
let home = setup_home();
create_template(home.path(), "tpl_echo", "Echo Command");
create_playlist(home.path(), "pl_daily", "Daily Tasks", &[
("tpl_echo", Some("Morning Echo")),
]);
create_playlist(home.path(), "pl_weekly", "Weekly Tasks", &[
("tpl_echo", Some("Weekly Report")),
]);

create_playlist(
home.path(),
"pl_daily",
"Daily Tasks",
&[("tpl_echo", Some("Morning Echo"))],
);
create_playlist(
home.path(),
"pl_weekly",
"Weekly Tasks",
&[("tpl_echo", Some("Weekly Report"))],
);

let output = runbox(&["list", "--playlist", "daily", "--all-repos"], home.path());

assert!(output.status.success());
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(stdout.contains("Morning Echo"));
Expand All @@ -187,12 +201,12 @@ fn test_list_filter_by_playlist() {
fn test_list_json_output() {
let home = setup_home();
create_template(home.path(), "tpl_echo", "Echo Command");

let output = runbox(&["list", "--json", "--all-repos"], home.path());

assert!(output.status.success());
let stdout = String::from_utf8_lossy(&output.stdout);

// Should be valid JSON
let json: serde_json::Value = serde_json::from_str(&stdout).expect("Should be valid JSON");
assert!(json.is_array());
Expand All @@ -207,12 +221,12 @@ fn test_list_short_output() {
let home = setup_home();
create_template(home.path(), "tpl_echo", "Echo Command");
create_template(home.path(), "tpl_train", "Train Model");

let output = runbox(&["list", "--short", "--all-repos"], home.path());

assert!(output.status.success());
let stdout = String::from_utf8_lossy(&output.stdout);

// Should have exactly 2 lines, each with 8-char hex short ID
let lines: Vec<&str> = stdout.trim().lines().collect();
assert_eq!(lines.len(), 2);
Expand All @@ -228,9 +242,9 @@ fn test_list_limit() {
create_template(home.path(), "tpl_a", "Template A");
create_template(home.path(), "tpl_b", "Template B");
create_template(home.path(), "tpl_c", "Template C");

let output = runbox(&["list", "--limit", "2", "--all-repos"], home.path());

assert!(output.status.success());
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(stdout.contains("2 runnables"));
Expand All @@ -240,9 +254,9 @@ fn test_list_limit() {
fn test_list_verbose_shows_repo() {
let home = setup_home();
create_template(home.path(), "tpl_echo", "Echo Command");

let output = runbox(&["list", "--verbose", "--all-repos"], home.path());

assert!(output.status.success());
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(stdout.contains("REPO"));
Expand All @@ -252,10 +266,73 @@ fn test_list_verbose_shows_repo() {
#[test]
fn test_list_invalid_type() {
let home = setup_home();

let output = runbox(&["list", "--type", "invalid", "--all-repos"], home.path());

assert!(!output.status.success());
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(stderr.contains("Invalid runnable type") || stderr.contains("invalid"));
}

#[test]
fn test_list_all_repos_shows_repo_and_playlist_columns() {
let home = setup_home();
create_template(home.path(), "tpl_echo", "Echo Command");
create_playlist(
home.path(),
"pl_game_run",
"Card Game 2026",
&[("tpl_echo", Some("Game Echo"))],
);

let output = runbox(&["list", "--all-repos"], home.path());

assert!(output.status.success());
let stdout = String::from_utf8_lossy(&output.stdout);

assert!(
stdout.contains("REPO"),
"Should show REPO column header when --all-repos"
);
assert!(
stdout.contains("PLAYLIST"),
"Should show PLAYLIST column header when --all-repos"
);
assert!(
stdout.contains("Card Game 2026"),
"Should show playlist display name"
);
}

#[test]
fn test_list_all_repos_json_includes_playlist_name() {
let home = setup_home();
create_template(home.path(), "tpl_echo", "Echo Command");
create_playlist(
home.path(),
"pl_game_run",
"Card Game 2026",
&[("tpl_echo", Some("Game Echo"))],
);

let output = runbox(&["list", "--all-repos", "--json"], home.path());

assert!(output.status.success());
let stdout = String::from_utf8_lossy(&output.stdout);

let json: serde_json::Value = serde_json::from_str(&stdout).expect("Should be valid JSON");
let arr = json.as_array().unwrap();

let playlist_item = arr
.iter()
.find(|v| v["type"] == "playlist")
.expect("Should have playlist item");
assert_eq!(
playlist_item["playlist_name"], "Card Game 2026",
"Should include playlist display name in JSON"
);
assert!(
playlist_item["repo_url"].is_string(),
"Should include repo_url in JSON"
);
}
Loading