diff --git a/experimental/silverwood/crates/silverwood-cli/src/main.rs b/experimental/silverwood/crates/silverwood-cli/src/main.rs index b7e342f..d045bbb 100644 --- a/experimental/silverwood/crates/silverwood-cli/src/main.rs +++ b/experimental/silverwood/crates/silverwood-cli/src/main.rs @@ -57,38 +57,9 @@ enum Command { all: bool, }, - /// Show a workstream by id. - Show { - /// The workstream id (from `silverwood ls`). - id: String, - }, - - /// Archive a workstream (tombstone). - Archive { - /// The workstream id (from `silverwood ls`). - id: String, - }, - - /// Remove a workstream: mark it deleted and delete its checked-out code. Refuses - /// unless the workstream is safe to remove (currently: never) — pass `--force`. - Remove { - /// The workstream id (from `silverwood ls`). - id: String, - /// Remove even if the workstream is not deemed safe to remove. - #[arg(long)] - force: bool, - }, - - /// Rename a workstream. - Rename { - /// The workstream id (from `silverwood ls`). - id: String, - /// The new display name. - name: String, - }, - - /// Per-kind workstream management operations. The `` subcommand (e.g. - /// `basic`) is validated against the workstream's actual kind. + /// Operate on one workstream by id: the kind-agnostic verbs (show, archive, remove, + /// rename) plus the per-kind `` namespace (e.g. `basic`), which is validated + /// against the workstream's actual kind. Workstream { /// The workstream id (from `silverwood ls`). id: String, @@ -302,13 +273,33 @@ impl From for CheckoutExtent { } } -/// Per-kind management operations for `silverwood workstream …`. The kind -/// subcommand is validated against the workstream's actual kind at dispatch. Only -/// `basic` today (`WorkstreamKind` is `#[non_exhaustive]`); the kebab name matches the -/// stored `kind` tag. +/// Operations on `silverwood workstream `. Kind-agnostic verbs (show/archive/ +/// remove/rename) run on any workstream; the per-kind `` namespace (only `basic` +/// today, since `WorkstreamKind` is `#[non_exhaustive]`) is validated against the +/// workstream's actual kind at dispatch — its kebab name matches the stored `kind` tag. #[derive(Subcommand)] enum WorkstreamCommand { - /// Operations on a basic workstream. + /// Show the workstream. + Show, + + /// Archive the workstream (tombstone). + Archive, + + /// Remove the workstream: mark it deleted and delete its checked-out code. Refuses + /// unless the workstream is safe to remove (currently: never) — pass `--force`. + Remove { + /// Remove even if the workstream is not deemed safe to remove. + #[arg(long)] + force: bool, + }, + + /// Rename the workstream. + Rename { + /// The new display name. + name: String, + }, + + /// Per-kind operations on a basic workstream. Basic { #[command(subcommand)] op: BasicOp, @@ -519,40 +510,6 @@ fn run(cli: Cli) -> CliResult { Ok(()) } - Command::Show { id } => { - let ws = forest.get(parse_id(&id)?)?; - emit(json, &workstream_json(&ws), || print_workstream(&ws)); - Ok(()) - } - - Command::Archive { id } => { - let id = parse_id(&id)?; - forest.archive(id)?; - let ws = forest.get(id)?; - emit(json, &workstream_json(&ws), || { - println!("archived {}", ws.id) - }); - Ok(()) - } - - Command::Remove { id, force } => { - let id = parse_id(&id)?; - forest.remove(id, force)?; - let ws = forest.get(id)?; - emit(json, &workstream_json(&ws), || { - println!("deleted {}", ws.id) - }); - Ok(()) - } - - Command::Rename { id, name } => { - let id = parse_id(&id)?; - forest.rename(id, &name)?; - let ws = forest.get(id)?; - emit(json, &workstream_json(&ws), || print_workstream(&ws)); - Ok(()) - } - Command::Workstream { id, kind } => run_workstream(&forest, json, &id, kind), Command::Kv(cmd) => run_kv(&forest, json, cmd), @@ -594,21 +551,51 @@ fn run_new(forest: &Forest, json: bool, name: Option, variant: NewVarian fn run_workstream(forest: &Forest, json: bool, id: &str, kind: WorkstreamCommand) -> CliResult { let id = parse_id(id)?; - let WorkstreamCommand::Basic { op } = kind; - - // The `basic` subcommand asserts the workstream's kind; reject a mismatch so the - // per-kind operation only runs on the kind it was written for. - let actual = forest.get(id)?.body.kind.tag(); - if actual != "basic" { - return Err(format!("workstream {id} is not a basic workstream (it is {actual})").into()); - } - - match op { - BasicOp::Checkout => { - let ws = forest.checkout_workstream(id)?; + match kind { + WorkstreamCommand::Show => { + let ws = forest.get(id)?; + emit(json, &workstream_json(&ws), || print_workstream(&ws)); + Ok(()) + } + WorkstreamCommand::Archive => { + forest.archive(id)?; + let ws = forest.get(id)?; + emit(json, &workstream_json(&ws), || { + println!("archived {}", ws.id) + }); + Ok(()) + } + WorkstreamCommand::Remove { force } => { + forest.remove(id, force)?; + let ws = forest.get(id)?; + emit(json, &workstream_json(&ws), || { + println!("deleted {}", ws.id) + }); + Ok(()) + } + WorkstreamCommand::Rename { name } => { + forest.rename(id, &name)?; + let ws = forest.get(id)?; emit(json, &workstream_json(&ws), || print_workstream(&ws)); Ok(()) } + WorkstreamCommand::Basic { op } => { + // The `basic` subcommand asserts the workstream's kind; reject a mismatch so the + // per-kind operation only runs on the kind it was written for. + let actual = forest.get(id)?.body.kind.tag(); + if actual != "basic" { + return Err( + format!("workstream {id} is not a basic workstream (it is {actual})").into(), + ); + } + match op { + BasicOp::Checkout => { + let ws = forest.checkout_workstream(id)?; + emit(json, &workstream_json(&ws), || print_workstream(&ws)); + Ok(()) + } + } + } } } diff --git a/experimental/silverwood/crates/silverwood-cli/tests/cli.rs b/experimental/silverwood/crates/silverwood-cli/tests/cli.rs index 1adabe9..3774be5 100644 --- a/experimental/silverwood/crates/silverwood-cli/tests/cli.rs +++ b/experimental/silverwood/crates/silverwood-cli/tests/cli.rs @@ -238,7 +238,7 @@ fn new_skip_registers_without_provisioning() { // `show` reflects the same deferred state. let id = ws["id"].as_str().unwrap(); assert_eq!( - json(&dir, &["--json", "show", id])["overall_state"], + json(&dir, &["--json", "workstream", id, "show"])["overall_state"], "active - basic.initialized-without-checkout" ); } @@ -247,11 +247,14 @@ fn new_skip_registers_without_provisioning() { fn show_rejects_bad_and_absent_ids() { let dir = forest(); - let stderr = fails(&dir, &["show", "not-a-uuid"]); + let stderr = fails(&dir, &["workstream", "not-a-uuid", "show"]); assert!(stderr.contains("invalid workstream id"), "got: {stderr}"); // Well-formed but absent id → not-found failure. - fails(&dir, &["show", "01999999-0000-7000-8000-000000000000"]); + fails( + &dir, + &["workstream", "01999999-0000-7000-8000-000000000000", "show"], + ); } #[test] @@ -259,15 +262,27 @@ fn remove_rejects_bad_and_absent_ids() { let dir = forest(); // Bad id → parse error, before any forest work. - let stderr = fails(&dir, &["remove", "not-a-uuid"]); + let stderr = fails(&dir, &["workstream", "not-a-uuid", "remove"]); assert!(stderr.contains("invalid workstream id"), "got: {stderr}"); // Well-formed but absent id → not-found, both with and without `--force` (the // workstream is loaded before the safety check / force is consulted). - fails(&dir, &["remove", "01999999-0000-7000-8000-000000000000"]); fails( &dir, - &["remove", "01999999-0000-7000-8000-000000000000", "--force"], + &[ + "workstream", + "01999999-0000-7000-8000-000000000000", + "remove", + ], + ); + fails( + &dir, + &[ + "workstream", + "01999999-0000-7000-8000-000000000000", + "remove", + "--force", + ], ); } diff --git a/experimental/silverwood/crates/silverwood-cli/tests/e2e.rs b/experimental/silverwood/crates/silverwood-cli/tests/e2e.rs index c536b8a..020697c 100644 --- a/experimental/silverwood/crates/silverwood-cli/tests/e2e.rs +++ b/experimental/silverwood/crates/silverwood-cli/tests/e2e.rs @@ -112,7 +112,7 @@ fn new_creates_a_ready_colocated_checkout() { let id = ws["id"].as_str().unwrap(); let listed = json(&dir, &["--json", "ls"]); assert_eq!(listed.as_array().unwrap().len(), 1); - assert_eq!(json(&dir, &["--json", "show", id]), listed[0]); + assert_eq!(json(&dir, &["--json", "workstream", id, "show"]), listed[0]); } /// `--checkout-extent skip` registers without cloning; `workstream basic checkout` @@ -466,7 +466,7 @@ fn kv_and_session_lifecycle() { // Sessions live in the reserved kv namespace, not a top-level `sessions` field; // and writing that namespace directly is rejected. ok(&dir, &["session", "create", "claude-code", &id, "sess-2"]); - let ws = json(&dir, &["--json", "show", &id]); + let ws = json(&dir, &["--json", "workstream", &id, "show"]); assert!(ws.get("sessions").is_none(), "no top-level sessions field"); assert!(ws["kv"]["app.andref.silverwood.session"]["sess-2"].is_string()); fails( @@ -565,8 +565,11 @@ fn kv_and_session_lifecycle() { .is_none()); // workstream rename. - ok(&dir, &["rename", &id, "renamed-work"]); - assert_eq!(json(&dir, &["--json", "show", &id])["name"], "renamed-work"); + ok(&dir, &["workstream", &id, "rename", "renamed-work"]); + assert_eq!( + json(&dir, &["--json", "workstream", &id, "show"])["name"], + "renamed-work" + ); } #[test] @@ -594,14 +597,17 @@ fn archive_tombstones_but_keeps_checkout_and_persists() { .to_string(); assert!(Path::new(&location).join("README.md").is_file()); - ok(&dir, &["archive", &id]); + ok(&dir, &["workstream", &id, "archive"]); // Hidden from `ls`, present in `ls --all`, still shows as archived. assert_eq!(json(&dir, &["--json", "ls"]), serde_json::json!([])); let all = json(&dir, &["--json", "ls", "--all"]); assert_eq!(all.as_array().unwrap().len(), 1); assert_eq!(all[0]["status"], "archived"); - assert_eq!(json(&dir, &["--json", "show", &id])["status"], "archived"); + assert_eq!( + json(&dir, &["--json", "workstream", &id, "show"])["status"], + "archived" + ); // Archive is a tombstone: the checkout working copy is NOT deleted. assert!( @@ -610,7 +616,7 @@ fn archive_tombstones_but_keeps_checkout_and_persists() { ); // Persistence across processes: re-read in yet another invocation. - assert_eq!(json(&dir, &["--json", "show", &id])["id"], id); + assert_eq!(json(&dir, &["--json", "workstream", &id, "show"])["id"], id); } /// `remove` is the delete-like tombstone: unlike archive it discards the checkout, @@ -642,13 +648,19 @@ fn remove_deletes_checkout_but_keeps_deleted_tombstone() { assert!(Path::new(&location).join("README.md").is_file()); // Without --force the safety check (stubbed false) refuses; nothing changes. - fails(&dir, &["remove", &id]); - assert_eq!(json(&dir, &["--json", "show", &id])["status"], "active"); + fails(&dir, &["workstream", &id, "remove"]); + assert_eq!( + json(&dir, &["--json", "workstream", &id, "show"])["status"], + "active" + ); assert!(Path::new(&location).join("README.md").is_file()); // --force soft-deletes: the checkout is gone, but the document survives as deleted. - ok(&dir, &["remove", &id, "--force"]); - assert_eq!(json(&dir, &["--json", "show", &id])["status"], "deleted"); + ok(&dir, &["workstream", &id, "remove", "--force"]); + assert_eq!( + json(&dir, &["--json", "workstream", &id, "show"])["status"], + "deleted" + ); // Hidden from `ls`, present in `ls --all` as deleted. assert_eq!(json(&dir, &["--json", "ls"]), serde_json::json!([]));