diff --git a/Cargo.lock b/Cargo.lock index e395a5f..7bb9895 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -63,6 +63,56 @@ dependencies = [ "equator", ] +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + [[package]] name = "anyhow" version = "1.0.102" @@ -110,6 +160,21 @@ dependencies = [ "stable_deref_trait", ] +[[package]] +name = "assert_cmd" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39bae1d3fa576f7c6519514180a72559268dd7d1fe104070956cb687bc6673bd" +dependencies = [ + "anstyle", + "bstr", + "libc", + "predicates", + "predicates-core", + "predicates-tree", + "wait-timeout", +] + [[package]] name = "async-trait" version = "0.1.89" @@ -215,6 +280,17 @@ dependencies = [ "generic-array", ] +[[package]] +name = "bstr" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63044e1ae8e69f3b5a92c736ca6269b8d12fa7efe39bf34ddb06d102cf0e2cab" +dependencies = [ + "memchr", + "regex-automata", + "serde", +] + [[package]] name = "built" version = "0.8.0" @@ -263,12 +339,59 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "clap" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", + "terminal_size", +] + +[[package]] +name = "clap_derive" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + [[package]] name = "color_quant" version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + [[package]] name = "core-foundation" version = "0.10.1" @@ -400,6 +523,12 @@ dependencies = [ "powerfmt", ] +[[package]] +name = "difflib" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6184e33543162437515c2e2b48714794e37845ec9851711914eec9d308f6ebe8" + [[package]] name = "digest" version = "0.10.7" @@ -551,6 +680,15 @@ dependencies = [ "miniz_oxide", ] +[[package]] +name = "float-cmp" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b09cf3155332e944990140d967ff5eceb70df778b34f77d8075db46e4704e6d8" +dependencies = [ + "num-traits", +] + [[package]] name = "fnv" version = "1.0.7" @@ -837,6 +975,12 @@ dependencies = [ "syn", ] +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + [[package]] name = "itertools" version = "0.12.1" @@ -1082,6 +1226,12 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0676bb32a98c1a483ce53e500a81ad9c3d5b3f7c920c28c24e9cb0980d0b5bc8" +[[package]] +name = "normalize-line-endings" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61807f77802ff30975e01f4f071c8ba10c022052f98b3294119f3e615d13e5be" + [[package]] name = "nu-ansi-term" version = "0.50.3" @@ -1188,6 +1338,12 @@ version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + [[package]] name = "option-ext" version = "0.2.0" @@ -1268,6 +1424,36 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "predicates" +version = "3.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ada8f2932f28a27ee7b70dd6c1c39ea0675c55a36879ab92f3a715eaa1e63cfe" +dependencies = [ + "anstyle", + "difflib", + "float-cmp", + "normalize-line-endings", + "predicates-core", + "regex", +] + +[[package]] +name = "predicates-core" +version = "1.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cad38746f3166b4031b1a0d39ad9f954dd291e7854fcc0eed52ee41a0b50d144" + +[[package]] +name = "predicates-tree" +version = "1.0.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0de1b847b39c8131db0467e9df1ff60e6d0562ab8e9a16e568ad0fdb372e2f2" +dependencies = [ + "predicates-core", + "termtree", +] + [[package]] name = "prettyplease" version = "0.2.37" @@ -1547,6 +1733,18 @@ dependencies = [ "thiserror 1.0.69", ] +[[package]] +name = "regex" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + [[package]] name = "regex-automata" version = "0.4.14" @@ -1806,6 +2004,12 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + [[package]] name = "symlink" version = "0.1.0" @@ -1836,6 +2040,22 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "terminal_size" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "230a1b821ccbd75b185820a1f1ff7b14d21da1e442e22c0863ea5f08771a8874" +dependencies = [ + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "termtree" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f50febec83f5ee1df3015341d8bd429f2d1cc62bcba7ea2076759d315084683" + [[package]] name = "thiserror" version = "1.0.69" @@ -2120,6 +2340,12 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + [[package]] name = "uuid" version = "1.23.1" @@ -2161,6 +2387,24 @@ dependencies = [ "vcli-core", ] +[[package]] +name = "vcli-cli" +version = "0.0.1" +dependencies = [ + "assert_cmd", + "clap", + "predicates", + "serde", + "serde_json", + "tempfile", + "thiserror 2.0.18", + "tokio", + "tokio-test", + "vcli-core", + "vcli-dsl", + "vcli-ipc", +] + [[package]] name = "vcli-core" version = "0.0.1" diff --git a/Cargo.toml b/Cargo.toml index 4c6bcad..b0c73d5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,6 +8,7 @@ members = [ "crates/vcli-input", "crates/vcli-ipc", "crates/vcli-perception", + "crates/vcli-cli", "crates/vcli-runtime", "crates/vcli-store", ] @@ -62,6 +63,11 @@ dashmap = "6" imageproc = "0.25" image = { version = "0.25", default-features = false, features = ["png"] } +# Added by vcli-cli lane +clap = { version = "4", features = ["derive", "wrap_help"] } +assert_cmd = "2" +predicates = "3" + # Dev-only proptest = "1" tempfile = "3" diff --git a/crates/vcli-cli/Cargo.toml b/crates/vcli-cli/Cargo.toml new file mode 100644 index 0000000..181e756 --- /dev/null +++ b/crates/vcli-cli/Cargo.toml @@ -0,0 +1,30 @@ +[package] +name = "vcli-cli" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true +authors.workspace = true +description = "vcli command-line interface — thin client over the framed-JSON IPC protocol." + +[[bin]] +name = "vcli" +path = "src/bin/vcli.rs" + +[dependencies] +vcli-core = { path = "../vcli-core" } +vcli-ipc = { path = "../vcli-ipc" } +vcli-dsl = { path = "../vcli-dsl" } +clap = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +thiserror = { workspace = true } +tokio = { workspace = true, features = ["rt-multi-thread", "macros", "signal", "net", "io-util", "time", "sync"] } + +[dev-dependencies] +tempfile = { workspace = true } +tokio-test = { workspace = true } +assert_cmd = { workspace = true } +predicates = { workspace = true } +tokio = { workspace = true, features = ["rt-multi-thread", "macros", "net", "io-util", "sync", "time", "test-util"] } diff --git a/crates/vcli-cli/src/bin/vcli.rs b/crates/vcli-cli/src/bin/vcli.rs new file mode 100644 index 0000000..ddbeeb5 --- /dev/null +++ b/crates/vcli-cli/src/bin/vcli.rs @@ -0,0 +1,3 @@ +fn main() { + std::process::exit(vcli_cli::run()); +} diff --git a/crates/vcli-cli/src/cli.rs b/crates/vcli-cli/src/cli.rs new file mode 100644 index 0000000..cdd85cb --- /dev/null +++ b/crates/vcli-cli/src/cli.rs @@ -0,0 +1,365 @@ +//! Clap derive definitions. Pure data; execution lives in `crate::commands`. +//! Spec §567–590 is authoritative for every flag. + +use std::path::PathBuf; + +use clap::{Args, Parser, Subcommand, ValueEnum}; + +use vcli_core::ProgramId; + +/// Top-level `vcli` invocation. +#[derive(Debug, Parser)] +#[command( + name = "vcli", + version, + about = "vcli — reactive screen runtime control plane", + long_about = None, +)] +pub struct Cli { + /// Override socket path (default: `$VCLI_SOCKET` → macOS `$TMPDIR/vcli-$UID.sock` + /// → Linux `$XDG_RUNTIME_DIR/vcli.sock` → `/tmp/vcli-$UID.sock`). + #[arg(long, global = true, value_name = "PATH")] + pub socket: Option, + + /// Emit machine-readable JSON. Per spec §589: every command supports this. + #[arg(long, global = true)] + pub json: bool, + + /// The subcommand to execute. + #[command(subcommand)] + pub command: Command, +} + +impl Cli { + /// Which output mode is requested. + #[must_use] + pub fn output_mode(&self) -> OutputMode { + if self.json { + OutputMode::Json + } else { + OutputMode::Pretty + } + } +} + +/// Output rendering backend. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum OutputMode { + /// Human-readable tables / single-line summaries. + Pretty, + /// One JSON object per invocation (or one per line on streams). + Json, +} + +/// Subcommands. Spec §567–590. +#[derive(Debug, Subcommand)] +pub enum Command { + /// Validate locally, submit to the daemon, print the assigned program id. + Submit(SubmitArgs), + + /// List known programs, optionally filtered by state. + List(ListArgs), + + /// Cancel a running program. + Cancel(CancelArgs), + + /// Stream program-scoped events. + Logs(LogsArgs), + + /// Resume a program that failed with `daemon_restart`. + Resume(ResumeArgs), + + /// Daemon lifecycle (start / run / stop / status). + #[command(subcommand)] + Daemon(DaemonCommand), + + /// Print daemon version, uptime, cache sizes. + Health, + + /// Force a GC cycle over the asset store. + Gc, +} + +/// `vcli submit [--watch]` +#[derive(Debug, Args)] +pub struct SubmitArgs { + /// Path to the program JSON file. + pub file: PathBuf, + + /// After submission, stream the program's events until it terminates. + #[arg(long)] + pub watch: bool, +} + +/// State filter for `list`. Values match `vcli_core::ProgramState::as_str`. +#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)] +#[value(rename_all = "snake_case")] +pub enum StateFilter { + /// Waiting for trigger. + Waiting, + /// Running (trigger fired). + Running, + /// Completed successfully. + Completed, + /// Failed. + Failed, + /// Cancelled. + Cancelled, +} + +impl StateFilter { + /// Wire-string form (passed to the daemon in `RequestOp::List.state`). + #[must_use] + pub fn as_wire(self) -> &'static str { + match self { + Self::Waiting => "waiting", + Self::Running => "running", + Self::Completed => "completed", + Self::Failed => "failed", + Self::Cancelled => "cancelled", + } + } +} + +/// `vcli list [--state STATE]` +#[derive(Debug, Args)] +pub struct ListArgs { + /// Filter by program state. + #[arg(long)] + pub state: Option, +} + +/// `vcli cancel [--reason TEXT]` +#[derive(Debug, Args)] +pub struct CancelArgs { + /// Program id (UUID). + pub program_id: ProgramId, + + /// Optional human-readable reason (informational only; daemon echoes it). + #[arg(long)] + pub reason: Option, +} + +/// `vcli logs [--follow] [--since ISO8601]` +#[derive(Debug, Args)] +pub struct LogsArgs { + /// Program id. + pub program_id: ProgramId, + + /// Keep the stream open and push future events. + #[arg(long)] + pub follow: bool, + + /// Drop events strictly older than this timestamp (ISO 8601). + #[arg(long, value_name = "ISO8601")] + pub since: Option, +} + +/// `vcli resume [--from-start]` +#[derive(Debug, Args)] +pub struct ResumeArgs { + /// Program id. + pub program_id: ProgramId, + + /// Ignore `body_cursor` and restart from step 0. + #[arg(long)] + pub from_start: bool, +} + +/// `vcli daemon { start | run | stop | status }` +#[derive(Debug, Subcommand)] +pub enum DaemonCommand { + /// Fork + detach a `vcli-daemon` child. Idempotent. + Start, + /// Foreground daemon for launchd/systemd. Execs `vcli-daemon` directly. + Run, + /// Send `Shutdown` and wait for the socket to disappear. + Stop, + /// Print running/not-running plus pid + socket path. + Status, +} + +#[cfg(test)] +mod tests { + use super::*; + use clap::Parser; + + fn parse(args: &[&str]) -> Cli { + Cli::try_parse_from(args).unwrap() + } + + #[test] + fn submit_without_file_errors() { + let r = Cli::try_parse_from(["vcli", "submit"]); + assert!(r.is_err()); + } + + #[test] + fn submit_with_file_parses() { + let c = parse(&["vcli", "submit", "program.json"]); + match c.command { + Command::Submit(a) => { + assert_eq!(a.file, PathBuf::from("program.json")); + assert!(!a.watch); + } + _ => panic!("wrong command"), + } + assert!(!c.json); + assert!(c.socket.is_none()); + } + + #[test] + fn submit_watch_and_json_flags_parse() { + let c = parse(&["vcli", "--json", "submit", "p.json", "--watch"]); + assert!(c.json); + assert_eq!(c.output_mode(), OutputMode::Json); + match c.command { + Command::Submit(a) => assert!(a.watch), + _ => panic!(), + } + } + + #[test] + fn global_socket_override_parses() { + let c = parse(&["vcli", "--socket", "/tmp/x.sock", "health"]); + assert_eq!(c.socket, Some(PathBuf::from("/tmp/x.sock"))); + assert!(matches!(c.command, Command::Health)); + } + + #[test] + fn list_without_state_filter() { + let c = parse(&["vcli", "list"]); + match c.command { + Command::List(a) => assert!(a.state.is_none()), + _ => panic!(), + } + } + + #[test] + fn list_with_each_state_value() { + for (arg, want) in [ + ("running", StateFilter::Running), + ("waiting", StateFilter::Waiting), + ("completed", StateFilter::Completed), + ("failed", StateFilter::Failed), + ("cancelled", StateFilter::Cancelled), + ] { + let c = parse(&["vcli", "list", "--state", arg]); + match c.command { + Command::List(a) => assert_eq!(a.state, Some(want)), + _ => panic!("{arg}"), + } + } + } + + #[test] + fn list_rejects_unknown_state() { + let r = Cli::try_parse_from(["vcli", "list", "--state", "chilling"]); + assert!(r.is_err()); + } + + #[test] + fn cancel_requires_program_id() { + let r = Cli::try_parse_from(["vcli", "cancel"]); + assert!(r.is_err()); + } + + #[test] + fn cancel_parses_id_and_reason() { + let c = parse(&[ + "vcli", + "cancel", + "12345678-1234-4567-8910-111213141516", + "--reason", + "user abort", + ]); + match c.command { + Command::Cancel(a) => { + assert_eq!( + a.program_id.to_string(), + "12345678-1234-4567-8910-111213141516" + ); + assert_eq!(a.reason.as_deref(), Some("user abort")); + } + _ => panic!(), + } + } + + #[test] + fn cancel_rejects_garbage_id() { + let r = Cli::try_parse_from(["vcli", "cancel", "not-a-uuid"]); + assert!(r.is_err()); + } + + #[test] + fn logs_parses_follow_and_since() { + let c = parse(&[ + "vcli", + "logs", + "12345678-1234-4567-8910-111213141516", + "--follow", + "--since", + "2026-04-16T00:00:00Z", + ]); + match c.command { + Command::Logs(a) => { + assert!(a.follow); + assert_eq!(a.since.as_deref(), Some("2026-04-16T00:00:00Z")); + } + _ => panic!(), + } + } + + #[test] + fn resume_from_start_flag() { + let c = parse(&[ + "vcli", + "resume", + "12345678-1234-4567-8910-111213141516", + "--from-start", + ]); + match c.command { + Command::Resume(a) => assert!(a.from_start), + _ => panic!(), + } + } + + #[test] + fn resume_without_flag_defaults_false() { + let c = parse(&["vcli", "resume", "12345678-1234-4567-8910-111213141516"]); + match c.command { + Command::Resume(a) => assert!(!a.from_start), + _ => panic!(), + } + } + + #[test] + fn daemon_subcommands_all_parse() { + for arg in ["start", "run", "stop", "status"] { + let c = parse(&["vcli", "daemon", arg]); + let ok = matches!( + (arg, &c.command), + ("start", Command::Daemon(DaemonCommand::Start)) + | ("run", Command::Daemon(DaemonCommand::Run)) + | ("stop", Command::Daemon(DaemonCommand::Stop)) + | ("status", Command::Daemon(DaemonCommand::Status)) + ); + assert!(ok, "{arg} did not match expected daemon subcommand"); + } + } + + #[test] + fn health_and_gc_parse() { + assert!(matches!( + parse(&["vcli", "health"]).command, + Command::Health + )); + assert!(matches!(parse(&["vcli", "gc"]).command, Command::Gc)); + } + + #[test] + fn default_output_mode_is_pretty() { + let c = parse(&["vcli", "health"]); + assert_eq!(c.output_mode(), OutputMode::Pretty); + } +} diff --git a/crates/vcli-cli/src/client.rs b/crates/vcli-cli/src/client.rs new file mode 100644 index 0000000..983a8a9 --- /dev/null +++ b/crates/vcli-cli/src/client.rs @@ -0,0 +1,43 @@ +//! Thin wrapper around `vcli_ipc::IpcClient::connect`. The only real job is +//! translating `ENOENT`/`ECONNREFUSED` into a `CliError::DaemonDown` so the +//! `vcli` process exits with code 4 (spec §589). + +use std::path::Path; + +use vcli_ipc::{IpcClient, IpcError}; + +use crate::error::{CliError, CliResult}; + +/// Connect to the daemon at `socket_path`. +/// +/// # Errors +/// - `CliError::DaemonDown` if the socket is missing or refused. +/// - `CliError::Generic` for other transport failures. +pub async fn connect(socket_path: &Path) -> CliResult { + match IpcClient::connect(socket_path).await { + Ok(c) => Ok(c), + Err(IpcError::Io(io_err)) + if matches!( + io_err.kind(), + std::io::ErrorKind::NotFound | std::io::ErrorKind::ConnectionRefused + ) => + { + Err(CliError::DaemonDown(socket_path.display().to_string())) + } + Err(e) => Err(CliError::Generic(e.to_string())), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::error::ExitCode; + + #[tokio::test] + async fn connect_to_missing_socket_returns_daemon_down() { + let p = Path::new("/tmp/vcli-definitely-does-not-exist.sock"); + let e = connect(p).await.unwrap_err(); + assert!(matches!(e, CliError::DaemonDown(_))); + assert_eq!(e.exit_code(), ExitCode::DaemonDown); + } +} diff --git a/crates/vcli-cli/src/commands/cancel.rs b/crates/vcli-cli/src/commands/cancel.rs new file mode 100644 index 0000000..33fc986 --- /dev/null +++ b/crates/vcli-cli/src/commands/cancel.rs @@ -0,0 +1,76 @@ +//! `vcli cancel [--reason TEXT]`. Exit 3 on unknown id (mapped through +//! `CliError::from_payload`). + +use std::path::Path; + +use vcli_ipc::{RequestOp, ResponseBody}; + +use crate::cli::{CancelArgs, OutputMode}; +use crate::client::connect; +use crate::error::{CliError, CliResult}; +use crate::format::render_value; + +/// Run `vcli cancel`. +/// +/// # Errors +/// See [`CliError`]. `unknown_program` → exit 3 via `CliError::NotFound`. +pub async fn run(socket: &Path, mode: OutputMode, args: &CancelArgs) -> CliResult { + let mut client = connect(socket).await?; + let resp = client + .request(RequestOp::Cancel { + program_id: args.program_id, + }) + .await?; + match resp.body { + ResponseBody::Ok { result, .. } => { + let pretty = summary( + &args.program_id.to_string(), + args.reason.as_deref(), + &result, + ); + render_value(mode, &pretty, &result) + } + ResponseBody::Err { error, .. } => Err(CliError::from_payload(&error)), + } +} + +fn summary(id: &str, reason: Option<&str>, result: &serde_json::Value) -> String { + let state = result + .get("state") + .and_then(serde_json::Value::as_str) + .unwrap_or("cancelled"); + match reason { + Some(r) => format!("cancelled {id} → {state} (reason: {r})\n"), + None => format!("cancelled {id} → {state}\n"), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use vcli_core::{ErrorCode, ErrorPayload}; + + #[test] + fn summary_without_reason() { + let s = summary("aaa", None, &serde_json::json!({ "state": "cancelled" })); + assert_eq!(s, "cancelled aaa → cancelled\n"); + } + + #[test] + fn summary_with_reason() { + let s = summary( + "aaa", + Some("user abort"), + &serde_json::json!({ "state": "cancelled" }), + ); + assert!(s.contains("user abort")); + } + + #[test] + fn unknown_program_payload_maps_to_not_found() { + let p = ErrorPayload::simple(ErrorCode::UnknownProgram, "no such program"); + let e = CliError::from_payload(&p); + assert!(matches!(e, CliError::NotFound(_))); + assert_eq!(e.exit_code(), crate::error::ExitCode::NotFound); + } +} diff --git a/crates/vcli-cli/src/commands/daemon.rs b/crates/vcli-cli/src/commands/daemon.rs new file mode 100644 index 0000000..fd10946 --- /dev/null +++ b/crates/vcli-cli/src/commands/daemon.rs @@ -0,0 +1,247 @@ +//! `vcli daemon { start | run | stop | status }`. + +use std::io::Write; +use std::path::{Path, PathBuf}; +use std::process::{Command, Stdio}; +use std::time::Duration; + +use tokio::time::{sleep, Instant}; +use vcli_ipc::{RequestOp, ResponseBody}; + +use crate::cli::{DaemonCommand, OutputMode}; +use crate::client::connect; +use crate::error::{CliError, CliResult}; +use crate::format::render_value; + +const DAEMON_BINARY: &str = "vcli-daemon"; + +/// Top-level dispatcher for `vcli daemon …`. +/// +/// # Errors +/// See [`CliError`]. +pub async fn run( + socket: &Path, + mode: OutputMode, + cmd: &DaemonCommand, + out: &mut W, +) -> CliResult<()> { + match cmd { + DaemonCommand::Status => status(socket, mode, out).await, + DaemonCommand::Stop => stop(socket, mode, out).await, + DaemonCommand::Start => start(socket, mode, out).await, + DaemonCommand::Run => run_foreground(), + } +} + +async fn start(socket: &Path, mode: OutputMode, out: &mut W) -> CliResult<()> { + if socket.exists() { + let json = serde_json::json!({ "started": false, "reason": "already_running" }); + let rendered = render_value(mode, "daemon already running\n", &json)?; + out.write_all(rendered.as_bytes())?; + return Ok(()); + } + spawn_detached(DAEMON_BINARY).map_err(|e| CliError::Generic(format!("spawn daemon: {e}")))?; + wait_for_socket(socket, Duration::from_secs(5)).await?; + let json = serde_json::json!({ "started": true, "socket": socket.display().to_string() }); + let rendered = render_value(mode, "daemon started\n", &json)?; + out.write_all(rendered.as_bytes())?; + Ok(()) +} + +fn run_foreground() -> CliResult<()> { + use std::os::unix::process::CommandExt as _; + let err = Command::new(DAEMON_BINARY).exec(); + Err(CliError::Generic(format!("exec {DAEMON_BINARY}: {err}"))) +} + +fn spawn_detached(bin: &str) -> std::io::Result<()> { + use std::os::unix::process::CommandExt as _; + let mut cmd = Command::new(bin); + cmd.stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .process_group(0); + cmd.spawn()?; + Ok(()) +} + +async fn wait_for_socket(path: &Path, timeout: Duration) -> CliResult<()> { + let deadline = Instant::now() + timeout; + while Instant::now() < deadline { + if path.exists() { + return Ok(()); + } + sleep(Duration::from_millis(50)).await; + } + Err(CliError::Generic(format!( + "timed out waiting for {} to appear", + path.display() + ))) +} + +async fn status(socket: &Path, mode: OutputMode, out: &mut W) -> CliResult<()> { + let exists = socket.exists(); + let (running, detail) = if exists { + match connect(socket).await { + Ok(mut c) => match c.request(RequestOp::Health).await { + Ok(resp) => match resp.body { + ResponseBody::Ok { result, .. } => (true, result), + ResponseBody::Err { error, .. } => { + (true, serde_json::json!({ "error": error.message })) + } + }, + Err(e) => (false, serde_json::json!({ "error": e.to_string() })), + }, + Err(CliError::DaemonDown(_)) => (false, serde_json::Value::Null), + Err(e) => return Err(e), + } + } else { + (false, serde_json::Value::Null) + }; + let json = serde_json::json!({ + "running": running, + "socket": socket.display().to_string(), + "detail": detail, + }); + let pretty = if running { + format!("running\nsocket: {}\n", socket.display()) + } else { + format!("not running\nsocket: {}\n", socket.display()) + }; + let rendered = render_value(mode, &pretty, &json)?; + out.write_all(rendered.as_bytes())?; + if !rendered.ends_with('\n') { + out.write_all(b"\n")?; + } + Ok(()) +} + +async fn stop(socket: &Path, mode: OutputMode, out: &mut W) -> CliResult<()> { + if !socket.exists() { + let json = serde_json::json!({ "stopped": false, "reason": "not_running" }); + let rendered = render_value(mode, "daemon not running\n", &json)?; + out.write_all(rendered.as_bytes())?; + return Ok(()); + } + let mut client = connect(socket).await?; + let _ = client.request(RequestOp::Shutdown).await; + wait_for_socket_gone(socket, Duration::from_secs(5)).await?; + let json = serde_json::json!({ "stopped": true }); + let rendered = render_value(mode, "stopped\n", &json)?; + out.write_all(rendered.as_bytes())?; + Ok(()) +} + +async fn wait_for_socket_gone(path: &Path, timeout: Duration) -> CliResult<()> { + let deadline = Instant::now() + timeout; + while Instant::now() < deadline { + if !path.exists() { + return Ok(()); + } + sleep(Duration::from_millis(50)).await; + } + Err(CliError::Generic(format!( + "timed out waiting for {} to disappear", + path.display() + ))) +} + +// Why: will be called by Task 15 `start` to locate an existing pidfile. +#[allow(dead_code)] +pub(crate) fn pid_file_candidate(socket: &Path) -> Option { + socket.parent().map(|p| p.join("vcli.pid")) +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::tempdir; + + #[tokio::test] + async fn status_reports_not_running_when_socket_missing() { + let dir = tempdir().unwrap(); + let sock = dir.path().join("vcli.sock"); + let mut buf: Vec = Vec::new(); + status(&sock, OutputMode::Pretty, &mut buf).await.unwrap(); + let out = String::from_utf8(buf).unwrap(); + assert!(out.contains("not running"), "{out}"); + } + + #[tokio::test] + async fn status_json_mode_emits_running_field() { + let dir = tempdir().unwrap(); + let sock = dir.path().join("vcli.sock"); + let mut buf: Vec = Vec::new(); + status(&sock, OutputMode::Json, &mut buf).await.unwrap(); + let out = String::from_utf8(buf).unwrap(); + let v: serde_json::Value = serde_json::from_str(out.trim()).unwrap(); + assert_eq!(v["running"], false); + } + + #[tokio::test] + async fn stop_with_no_socket_is_not_running() { + let dir = tempdir().unwrap(); + let sock = dir.path().join("vcli.sock"); + let mut buf: Vec = Vec::new(); + stop(&sock, OutputMode::Pretty, &mut buf).await.unwrap(); + let out = String::from_utf8(buf).unwrap(); + assert!(out.contains("not running"), "{out}"); + } + + #[tokio::test] + async fn wait_for_socket_gone_times_out_when_file_stays() { + let dir = tempdir().unwrap(); + let sock = dir.path().join("vcli.sock"); + std::fs::write(&sock, "").unwrap(); + let err = wait_for_socket_gone(&sock, Duration::from_millis(120)) + .await + .unwrap_err(); + assert!(matches!(err, CliError::Generic(_))); + } + + #[test] + fn pid_file_candidate_is_alongside_socket() { + let p = pid_file_candidate(Path::new("/tmp/foo/vcli.sock")).unwrap(); + assert_eq!(p, PathBuf::from("/tmp/foo/vcli.pid")); + } + + #[tokio::test] + async fn start_is_idempotent_when_socket_already_exists() { + let dir = tempdir().unwrap(); + let sock = dir.path().join("vcli.sock"); + std::fs::write(&sock, "").unwrap(); + let mut buf: Vec = Vec::new(); + start(&sock, OutputMode::Pretty, &mut buf).await.unwrap(); + let out = String::from_utf8(buf).unwrap(); + assert!(out.contains("already running"), "{out}"); + } + + #[tokio::test] + async fn start_reports_timeout_when_daemon_binary_absent() { + // Why: spawn_detached will fail when `vcli-daemon` is not on PATH, + // so start() bubbles up CliError::Generic rather than waiting the full + // 5s. Relies on PATH not containing a real daemon during `cargo test`. + let dir = tempdir().unwrap(); + let sock = dir.path().join("vcli.sock"); + let mut buf: Vec = Vec::new(); + let err = start(&sock, OutputMode::Pretty, &mut buf) + .await + .unwrap_err(); + assert!(matches!(err, CliError::Generic(_))); + } + + #[tokio::test] + async fn wait_for_socket_returns_ok_when_file_appears() { + let dir = tempdir().unwrap(); + let sock = dir.path().join("vcli.sock"); + let sock2 = sock.clone(); + let task = tokio::spawn(async move { + tokio::time::sleep(Duration::from_millis(80)).await; + std::fs::write(&sock2, "").unwrap(); + }); + wait_for_socket(&sock, Duration::from_millis(500)) + .await + .unwrap(); + task.await.unwrap(); + } +} diff --git a/crates/vcli-cli/src/commands/gc.rs b/crates/vcli-cli/src/commands/gc.rs new file mode 100644 index 0000000..4469671 --- /dev/null +++ b/crates/vcli-cli/src/commands/gc.rs @@ -0,0 +1,56 @@ +//! `vcli gc` — one-shot `RequestOp::Gc` → "ok" or JSON. + +use std::path::Path; + +use vcli_ipc::{RequestOp, ResponseBody}; + +use crate::cli::OutputMode; +use crate::client::connect; +use crate::error::{CliError, CliResult}; +use crate::format::render_value; + +/// Run `vcli gc`. +/// +/// # Errors +/// See [`CliError`]. +pub async fn run(socket: &Path, mode: OutputMode) -> CliResult { + let mut client = connect(socket).await?; + let resp = client.request(RequestOp::Gc).await?; + match resp.body { + ResponseBody::Ok { result, .. } => { + let pretty = summary(&result); + render_value(mode, &pretty, &result) + } + ResponseBody::Err { error, .. } => Err(CliError::from_payload(&error)), + } +} + +fn summary(v: &serde_json::Value) -> String { + let removed = v + .get("removed") + .and_then(serde_json::Value::as_u64) + .unwrap_or(0); + let bytes = v + .get("bytes_freed") + .and_then(serde_json::Value::as_u64) + .unwrap_or(0); + format!("gc: ok (removed {removed}, freed {bytes} bytes)\n") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn summary_with_counts() { + let s = summary(&serde_json::json!({ "removed": 5, "bytes_freed": 1024 })); + assert!(s.contains("removed 5")); + assert!(s.contains("1024")); + } + + #[test] + fn summary_with_no_fields_is_still_ok() { + let s = summary(&serde_json::json!({})); + assert!(s.starts_with("gc: ok")); + } +} diff --git a/crates/vcli-cli/src/commands/health.rs b/crates/vcli-cli/src/commands/health.rs new file mode 100644 index 0000000..f4b19c8 --- /dev/null +++ b/crates/vcli-cli/src/commands/health.rs @@ -0,0 +1,72 @@ +//! `vcli health` — one-shot `RequestOp::Health` → pretty or JSON summary. + +use std::path::Path; + +use vcli_ipc::{RequestOp, ResponseBody}; + +use crate::cli::OutputMode; +use crate::client::connect; +use crate::error::{CliError, CliResult}; +use crate::format::render_value; + +/// Run `vcli health`. +/// +/// # Errors +/// See [`CliError`]. +pub async fn run(socket: &Path, mode: OutputMode) -> CliResult { + let mut client = connect(socket).await?; + let resp = client.request(RequestOp::Health).await?; + match resp.body { + ResponseBody::Ok { result, .. } => render_value(mode, &pretty(&result), &result), + ResponseBody::Err { error, .. } => Err(CliError::from_payload(&error)), + } +} + +fn pretty(v: &serde_json::Value) -> String { + use std::fmt::Write as _; + let mut out = String::new(); + out.push_str("daemon: ok\n"); + if let Some(version) = v.get("version").and_then(|x| x.as_str()) { + let _ = writeln!(out, "version: {version}"); + } + if let Some(uptime) = v.get("uptime_ms").and_then(serde_json::Value::as_u64) { + let _ = writeln!(out, "uptime_ms: {uptime}"); + } + if let Some(progs) = v.get("programs") { + let _ = writeln!(out, "programs: {progs}"); + } + if let Some(last_err) = v.get("last_error") { + if !last_err.is_null() { + let _ = writeln!(out, "last_error: {last_err}"); + } + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn pretty_includes_known_fields() { + let v = serde_json::json!({ + "version": "0.0.1", + "uptime_ms": 12_345_u64, + "programs": { "running": 2, "waiting": 0 }, + "last_error": null + }); + let s = pretty(&v); + assert!(s.contains("daemon: ok")); + assert!(s.contains("version: 0.0.1")); + assert!(s.contains("uptime_ms: 12345")); + assert!(s.contains("running")); + assert!(!s.contains("last_error:")); + } + + #[test] + fn pretty_shows_last_error_when_present() { + let v = serde_json::json!({ "last_error": "capture_failed" }); + let s = pretty(&v); + assert!(s.contains("last_error")); + } +} diff --git a/crates/vcli-cli/src/commands/list.rs b/crates/vcli-cli/src/commands/list.rs new file mode 100644 index 0000000..1803978 --- /dev/null +++ b/crates/vcli-cli/src/commands/list.rs @@ -0,0 +1,92 @@ +//! `vcli list [--state STATE]` — tabular list of programs. + +use std::path::Path; + +use vcli_ipc::{RequestOp, ResponseBody}; + +use crate::cli::{ListArgs, OutputMode}; +use crate::client::connect; +use crate::error::{CliError, CliResult}; +use crate::format::{render_value, Row, Table}; + +/// Run `vcli list`. +/// +/// # Errors +/// See [`CliError`]. +pub async fn run(socket: &Path, mode: OutputMode, args: &ListArgs) -> CliResult { + let mut client = connect(socket).await?; + let op = RequestOp::List { + state: args.state.map(|s| s.as_wire().to_string()), + }; + let resp = client.request(op).await?; + match resp.body { + ResponseBody::Ok { result, .. } => render_value(mode, &format_table(&result), &result), + ResponseBody::Err { error, .. } => Err(CliError::from_payload(&error)), + } +} + +fn format_table(v: &serde_json::Value) -> String { + let items = v.get("items").and_then(serde_json::Value::as_array); + let mut t = Table::new(["id", "name", "state", "submitted_at"]); + if let Some(items) = items { + for item in items { + t.push(Row(vec![ + item.get("program_id") + .and_then(serde_json::Value::as_str) + .unwrap_or("?") + .to_string(), + item.get("name") + .and_then(serde_json::Value::as_str) + .unwrap_or("") + .to_string(), + item.get("state") + .and_then(serde_json::Value::as_str) + .unwrap_or("?") + .to_string(), + item.get("submitted_at") + .and_then(serde_json::Value::as_str) + .unwrap_or("") + .to_string(), + ])); + } + } + t.render_pretty() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn empty_list_shows_header_only() { + let s = format_table(&serde_json::json!({ "items": [] })); + assert_eq!(s.lines().count(), 1); + assert!(s.contains("id")); + assert!(s.contains("state")); + } + + #[test] + fn list_with_two_items_prints_both_rows() { + let v = serde_json::json!({ + "items": [ + { "program_id": "aaa", "name": "yt", "state": "running", + "submitted_at": "2026-04-16T19:42:11.123Z" }, + { "program_id": "bbb", "name": "buy", "state": "completed", + "submitted_at": "2026-04-16T19:42:12.000Z" } + ] + }); + let s = format_table(&v); + assert_eq!(s.lines().count(), 3); + assert!(s.contains("aaa")); + assert!(s.contains("buy")); + assert!(s.contains("running")); + assert!(s.contains("completed")); + } + + #[test] + fn missing_fields_do_not_panic() { + let v = serde_json::json!({ "items": [{ "program_id": "x" }] }); + let s = format_table(&v); + assert!(s.contains('x')); + } +} diff --git a/crates/vcli-cli/src/commands/logs.rs b/crates/vcli-cli/src/commands/logs.rs new file mode 100644 index 0000000..d1c9de1 --- /dev/null +++ b/crates/vcli-cli/src/commands/logs.rs @@ -0,0 +1,172 @@ +//! `vcli logs [--follow] [--since ISO8601]` — streams `Event`s scoped to +//! a single program until `end_of_stream` or the user hits Ctrl-C. + +use std::io::Write; +use std::path::Path; + +use vcli_core::Event; +use vcli_ipc::{RequestOp, StreamFrame}; + +use crate::cli::{LogsArgs, OutputMode}; +use crate::client::connect; +use crate::error::{CliError, CliResult}; +use crate::util::format_unix_ms; + +/// Run `vcli logs`. Writes to `out` instead of `stdout` so tests can capture. +/// +/// # Errors +/// See [`CliError`]. +pub async fn run( + socket: &Path, + mode: OutputMode, + args: &LogsArgs, + out: &mut W, +) -> CliResult<()> { + let client = connect(socket).await?; + let mut stream = client + .request_stream(RequestOp::Logs { + program_id: args.program_id, + follow: args.follow, + }) + .await?; + let since_ms = parse_since(args.since.as_deref())?; + while let Some(frame) = stream.next_frame().await? { + if let Some(ev) = frame.event.as_ref() { + if since_ms.is_some_and(|t| ev.at < t) { + continue; + } + write_event(mode, &frame, ev, out)?; + } + } + Ok(()) +} + +fn write_event( + mode: OutputMode, + frame: &StreamFrame, + ev: &Event, + out: &mut W, +) -> CliResult<()> { + match mode { + OutputMode::Json => { + serde_json::to_writer(&mut *out, frame)?; + out.write_all(b"\n")?; + } + OutputMode::Pretty => { + let line = format_event_line(ev); + out.write_all(line.as_bytes())?; + } + } + Ok(()) +} + +fn format_event_line(ev: &Event) -> String { + let ts = format_unix_ms(ev.at); + let raw = serde_json::to_value(&ev.data).unwrap_or(serde_json::Value::Null); + let kind = raw + .get("type") + .and_then(serde_json::Value::as_str) + .unwrap_or("event"); + format!("{ts} {kind} {raw}\n") +} + +fn parse_since(s: Option<&str>) -> CliResult> { + let Some(s) = s else { + return Ok(None); + }; + parse_rfc3339_utc(s) + .map(Some) + .ok_or_else(|| CliError::Generic(format!("--since: unrecognized timestamp: {s}"))) +} + +fn parse_rfc3339_utc(s: &str) -> Option { + if !s.ends_with('Z') { + return None; + } + let core = &s[..s.len() - 1]; + let (date, time) = core.split_once('T')?; + let mut dparts = date.split('-'); + let year: i32 = dparts.next()?.parse().ok()?; + let month: u32 = dparts.next()?.parse().ok()?; + let day: u32 = dparts.next()?.parse().ok()?; + let (time, frac) = match time.split_once('.') { + Some((t, f)) => (t, f), + None => (time, "0"), + }; + let mut tparts = time.split(':'); + let hour: u32 = tparts.next()?.parse().ok()?; + let minute: u32 = tparts.next()?.parse().ok()?; + let sec: u32 = tparts.next()?.parse().ok()?; + let millis: u32 = frac.parse().ok()?; + let days = days_from_civil(year, month, day)?; + Some( + days * 86_400_000 + + i64::from(hour) * 3_600_000 + + i64::from(minute) * 60_000 + + i64::from(sec) * 1_000 + + i64::from(millis), + ) +} + +#[allow(clippy::cast_possible_wrap)] +fn days_from_civil(year: i32, month: u32, day: u32) -> Option { + if !(1..=12).contains(&month) || !(1..=31).contains(&day) { + return None; + } + let year = if month <= 2 { year - 1 } else { year }; + let era = if year >= 0 { year } else { year - 399 } / 400; + let yoe = u32::try_from(year - era * 400).ok()?; + let doy = (153 * if month > 2 { month - 3 } else { month + 9 } + 2) / 5 + day - 1; + let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy; + Some(i64::from(era) * 146_097 + i64::from(doe) - 719_468) +} + +#[cfg(test)] +mod tests { + use super::*; + use vcli_core::{Event, EventData}; + + #[test] + fn format_event_line_has_timestamp_and_kind() { + let ev = Event { + at: 0_i64, + data: EventData::DaemonStarted { + version: "0.0.1".into(), + }, + }; + let s = format_event_line(&ev); + assert!(s.contains("1970-01-01T00:00:00.000Z")); + assert!(s.contains("daemon.started")); + } + + #[test] + fn parse_since_accepts_our_own_iso_format() { + let t = parse_since(Some("1970-01-01T00:00:00.000Z")) + .unwrap() + .unwrap(); + assert_eq!(t, 0); + } + + #[test] + fn parse_since_without_millis_also_works() { + let t = parse_since(Some("1970-01-01T00:00:00Z")).unwrap().unwrap(); + assert_eq!(t, 0); + } + + #[test] + fn parse_since_rejects_missing_z() { + let e = parse_since(Some("1970-01-01T00:00:00")).unwrap_err(); + assert!(matches!(e, CliError::Generic(_))); + } + + #[test] + fn parse_since_rejects_garbage() { + let e = parse_since(Some("not a timestamp")).unwrap_err(); + assert!(matches!(e, CliError::Generic(_))); + } + + #[test] + fn parse_since_none_is_ok_none() { + assert!(parse_since(None).unwrap().is_none()); + } +} diff --git a/crates/vcli-cli/src/commands/mod.rs b/crates/vcli-cli/src/commands/mod.rs new file mode 100644 index 0000000..d61d9bb --- /dev/null +++ b/crates/vcli-cli/src/commands/mod.rs @@ -0,0 +1,12 @@ +//! One module per top-level subcommand. Each exposes an `async fn run(...)` +//! that takes the global flags (`socket`, `output`) plus its typed args and +//! returns `CliResult<()>`. + +pub mod cancel; +pub mod daemon; +pub mod gc; +pub mod health; +pub mod list; +pub mod logs; +pub mod resume; +pub mod submit; diff --git a/crates/vcli-cli/src/commands/resume.rs b/crates/vcli-cli/src/commands/resume.rs new file mode 100644 index 0000000..df738bb --- /dev/null +++ b/crates/vcli-cli/src/commands/resume.rs @@ -0,0 +1,110 @@ +//! `vcli resume [--from-start]`. Per spec §578, this returns exit 2 if +//! the daemon reports `bad_state_transition` (program not in +//! `failed(daemon_restart)`) or `not_resumable` / `resume_precondition_failed` +//! — all are validation-ish failures, not "not found". That mapping is +//! encoded below: `BadStateTransition` stays `Generic` (exit 1) per +//! `CliError::from_payload`, but we override here because the user-facing +//! resume semantics are closer to a precondition check. + +use std::path::Path; + +use vcli_core::{ErrorCode, ErrorPayload}; +use vcli_ipc::{RequestOp, ResponseBody}; + +use crate::cli::{OutputMode, ResumeArgs}; +use crate::client::connect; +use crate::error::{CliError, CliResult}; +use crate::format::render_value; + +/// Run `vcli resume`. +/// +/// # Errors +/// See [`CliError`]. `bad_state_transition` / `not_resumable` / +/// `resume_precondition_failed` → exit 2 (validation-class) per spec §578+§589. +pub async fn run(socket: &Path, mode: OutputMode, args: &ResumeArgs) -> CliResult { + let mut client = connect(socket).await?; + let resp = client + .request(RequestOp::Resume { + program_id: args.program_id, + from_start: args.from_start, + }) + .await?; + match resp.body { + ResponseBody::Ok { result, .. } => { + let pretty = summary(&args.program_id.to_string(), &result); + render_value(mode, &pretty, &result) + } + ResponseBody::Err { error, .. } => Err(map_resume_error(&error)), + } +} + +fn summary(id: &str, result: &serde_json::Value) -> String { + let from_step = result + .get("from_step") + .and_then(serde_json::Value::as_u64) + .unwrap_or(0); + format!("resumed {id} from step {from_step}\n") +} + +fn map_resume_error(p: &ErrorPayload) -> CliError { + match p.code { + ErrorCode::BadStateTransition + | ErrorCode::NotResumable + | ErrorCode::ResumePreconditionFailed => CliError::Validation(format_msg(p)), + _ => CliError::from_payload(p), + } +} + +fn format_msg(p: &ErrorPayload) -> String { + match &p.path { + Some(path) => format!("{} (at {})", p.message, path), + None => p.message.clone(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::error::ExitCode; + + #[test] + fn summary_reports_step() { + let s = summary("abc", &serde_json::json!({ "from_step": 3 })); + assert_eq!(s, "resumed abc from step 3\n"); + } + + #[test] + fn summary_missing_step_defaults_to_zero() { + let s = summary("abc", &serde_json::json!({})); + assert!(s.contains("step 0")); + } + + #[test] + fn bad_state_transition_becomes_validation_exit_2() { + let p = ErrorPayload::simple(ErrorCode::BadStateTransition, "program is running"); + let e = map_resume_error(&p); + assert!(matches!(e, CliError::Validation(_))); + assert_eq!(e.exit_code(), ExitCode::Validation); + } + + #[test] + fn not_resumable_becomes_validation_exit_2() { + let p = ErrorPayload::simple(ErrorCode::NotResumable, "has unresumable watches"); + assert!(matches!(map_resume_error(&p), CliError::Validation(_))); + } + + #[test] + fn resume_precondition_failed_becomes_validation_exit_2() { + let p = ErrorPayload::simple( + ErrorCode::ResumePreconditionFailed, + "step N-1 no longer true", + ); + assert!(matches!(map_resume_error(&p), CliError::Validation(_))); + } + + #[test] + fn unknown_program_still_maps_to_not_found() { + let p = ErrorPayload::simple(ErrorCode::UnknownProgram, "no such id"); + assert!(matches!(map_resume_error(&p), CliError::NotFound(_))); + } +} diff --git a/crates/vcli-cli/src/commands/submit.rs b/crates/vcli-cli/src/commands/submit.rs new file mode 100644 index 0000000..690886b --- /dev/null +++ b/crates/vcli-cli/src/commands/submit.rs @@ -0,0 +1,219 @@ +//! `vcli submit [--watch]`. +//! +//! Flow: +//! 1. Read file (`util::read_program_file`). +//! 2. Local preflight: `vcli_dsl::validate_value`. On failure, exit 2 without +//! touching the daemon. The daemon re-validates authoritatively; this is +//! just a UX early-out (Decision F4). +//! 3. Send `RequestOp::Submit`. On `invalid_program` response, map to exit 2. +//! 4. Print the assigned `program_id`. +//! 5. If `--watch`, open a logs stream and drain events until `end_of_stream` +//! or a terminal `program.completed` / `program.failed` / +//! `program.state_changed → cancelled`. + +use std::io::Write; +use std::path::Path; + +use vcli_core::{Event, EventData, ProgramId, ProgramState}; +use vcli_dsl::validate_value; +use vcli_ipc::{RequestOp, ResponseBody}; + +use crate::cli::{OutputMode, SubmitArgs}; +use crate::client::connect; +use crate::error::{CliError, CliResult}; +use crate::format::render_value; +use crate::util::{format_unix_ms, read_program_file}; + +/// Run `vcli submit`. Writes to `out` so tests can capture stdout. +/// +/// # Errors +/// See [`CliError`]. +pub async fn run( + socket: &Path, + mode: OutputMode, + args: &SubmitArgs, + out: &mut W, +) -> CliResult<()> { + let program = read_program_file(&args.file)?; + if let Err(e) = validate_value(&program) { + return Err(CliError::Validation(format_dsl_error(&e))); + } + + let mut client = connect(socket).await?; + let resp = client.request(RequestOp::Submit { program }).await?; + let program_id = match resp.body { + ResponseBody::Ok { result, .. } => { + let id = extract_program_id(&result)?; + let line = format!("{id}\n"); + let rendered = render_value(mode, &line, &result)?; + out.write_all(rendered.as_bytes())?; + if !rendered.ends_with('\n') { + out.write_all(b"\n")?; + } + id + } + ResponseBody::Err { error, .. } => return Err(CliError::from_payload(&error)), + }; + + if args.watch { + watch_until_terminal(socket, mode, program_id, out).await?; + } + Ok(()) +} + +fn extract_program_id(v: &serde_json::Value) -> CliResult { + let s = v + .get("program_id") + .and_then(serde_json::Value::as_str) + .ok_or_else(|| CliError::Generic(format!("daemon response missing program_id: {v}")))?; + s.parse::() + .map_err(|e| CliError::Generic(format!("daemon returned malformed program_id: {e}"))) +} + +async fn watch_until_terminal( + socket: &Path, + mode: OutputMode, + id: ProgramId, + out: &mut W, +) -> CliResult<()> { + let client = connect(socket).await?; + let mut stream = client + .request_stream(RequestOp::Logs { + program_id: id, + follow: true, + }) + .await?; + while let Some(frame) = stream.next_frame().await? { + if let Some(ev) = frame.event.as_ref() { + emit(mode, ev, out)?; + if is_terminal(ev) { + break; + } + } + } + Ok(()) +} + +fn emit(mode: OutputMode, ev: &Event, out: &mut W) -> CliResult<()> { + match mode { + OutputMode::Json => { + serde_json::to_writer(&mut *out, ev)?; + out.write_all(b"\n")?; + } + OutputMode::Pretty => { + let ts = format_unix_ms(ev.at); + let raw = serde_json::to_value(&ev.data).unwrap_or(serde_json::Value::Null); + let kind = raw + .get("type") + .and_then(serde_json::Value::as_str) + .unwrap_or("event"); + writeln!(out, "{ts} {kind} {raw}")?; + } + } + Ok(()) +} + +fn is_terminal(ev: &Event) -> bool { + match &ev.data { + EventData::ProgramCompleted { .. } | EventData::ProgramFailed { .. } => true, + EventData::ProgramStateChanged { to, .. } => matches!(to, ProgramState::Cancelled), + _ => false, + } +} + +fn format_dsl_error(e: &vcli_dsl::DslError) -> String { + format!("local preflight: {e}") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn extract_program_id_happy_path() { + let v = serde_json::json!({ "program_id": "12345678-1234-4567-8910-111213141516" }); + let id = extract_program_id(&v).unwrap(); + assert_eq!(id.to_string(), "12345678-1234-4567-8910-111213141516"); + } + + #[test] + fn extract_program_id_missing_field_is_generic_error() { + let v = serde_json::json!({}); + let e = extract_program_id(&v).unwrap_err(); + assert!(matches!(e, CliError::Generic(_))); + } + + #[test] + fn extract_program_id_malformed_is_generic_error() { + let v = serde_json::json!({ "program_id": "not-a-uuid" }); + let e = extract_program_id(&v).unwrap_err(); + assert!(matches!(e, CliError::Generic(_))); + } + + #[test] + fn completed_event_is_terminal() { + let ev = Event { + at: 0_i64, + data: EventData::ProgramCompleted { + program_id: ProgramId::new(), + emit: None, + }, + }; + assert!(is_terminal(&ev)); + } + + #[test] + fn failed_event_is_terminal() { + let ev = Event { + at: 0_i64, + data: EventData::ProgramFailed { + program_id: ProgramId::new(), + reason: "boom".into(), + step: None, + emit: None, + }, + }; + assert!(is_terminal(&ev)); + } + + #[test] + fn state_changed_to_cancelled_is_terminal() { + let ev = Event { + at: 0_i64, + data: EventData::ProgramStateChanged { + program_id: ProgramId::new(), + from: ProgramState::Running, + to: ProgramState::Cancelled, + reason: "user".into(), + }, + }; + assert!(is_terminal(&ev)); + } + + #[test] + fn state_changed_to_running_is_not_terminal() { + let ev = Event { + at: 0_i64, + data: EventData::ProgramStateChanged { + program_id: ProgramId::new(), + from: ProgramState::Waiting, + to: ProgramState::Running, + reason: "trigger".into(), + }, + }; + assert!(!is_terminal(&ev)); + } + + #[test] + fn watch_fired_is_not_terminal() { + let ev = Event { + at: 0_i64, + data: EventData::WatchFired { + program_id: ProgramId::new(), + watch_index: 0, + predicate: "inline".into(), + }, + }; + assert!(!is_terminal(&ev)); + } +} diff --git a/crates/vcli-cli/src/error.rs b/crates/vcli-cli/src/error.rs new file mode 100644 index 0000000..4f19cf9 --- /dev/null +++ b/crates/vcli-cli/src/error.rs @@ -0,0 +1,231 @@ +//! CLI-layer errors. Every variant knows its spec-mandated exit code +//! (§589: `0` success, `1` generic, `2` validation, `3` not found, `4` daemon +//! not running). `CliError` bubbles up to `run()` and then to `main` as an +//! `i32`. + +use std::io; + +use thiserror::Error; + +use vcli_core::{ErrorCode, ErrorPayload}; +use vcli_ipc::IpcError; + +/// Process exit code. `i32` repr matches the kernel's view. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[repr(i32)] +pub enum ExitCode { + /// `0` — command completed without error. + Success = 0, + /// `1` — generic error, human-readable message already printed. + Generic = 1, + /// `2` — DSL validation failed before or after submission. + Validation = 2, + /// `3` — program id not found / nothing matches. + NotFound = 3, + /// `4` — could not reach the daemon socket. + DaemonDown = 4, +} + +impl From for i32 { + fn from(c: ExitCode) -> Self { + c as Self + } +} + +/// CLI-visible errors. Display impl is the user-facing message; `exit_code()` +/// is what `run()` returns. +#[derive(Debug, Error)] +pub enum CliError { + /// DSL preflight or daemon-side validation failed. + #[error("validation error: {0}")] + Validation(String), + + /// Daemon returned `unknown_program`. + #[error("not found: {0}")] + NotFound(String), + + /// Daemon socket unreachable (ENOENT / ECONNREFUSED) — per spec §589 this + /// is exit 4, distinct from a generic I/O failure. + #[error("daemon not running (socket: {0})")] + DaemonDown(String), + + /// Catch-all for I/O, malformed JSON on disk, bad arguments, etc. + #[error("{0}")] + Generic(String), +} + +impl CliError { + /// Map to the spec-mandated exit code. + #[must_use] + pub fn exit_code(&self) -> ExitCode { + match self { + Self::Validation(_) => ExitCode::Validation, + Self::NotFound(_) => ExitCode::NotFound, + Self::DaemonDown(_) => ExitCode::DaemonDown, + Self::Generic(_) => ExitCode::Generic, + } + } + + /// Build a `CliError` from a daemon-side `ErrorPayload`. + #[must_use] + pub fn from_payload(p: &ErrorPayload) -> Self { + let text = format_payload(p); + match p.code { + ErrorCode::InvalidProgram => Self::Validation(text), + ErrorCode::UnknownProgram => Self::NotFound(text), + ErrorCode::BadStateTransition + | ErrorCode::PermissionDenied + | ErrorCode::CaptureFailed + | ErrorCode::DaemonBusy + | ErrorCode::NotResumable + | ErrorCode::ResumePreconditionFailed + | ErrorCode::Internal => Self::Generic(text), + } + } +} + +fn format_payload(p: &ErrorPayload) -> String { + use std::fmt::Write as _; + let mut out = p.message.clone(); + if let Some(path) = &p.path { + let _ = write!(out, " (at {path})"); + } + if let (Some(line), Some(col)) = (p.line, p.column) { + let _ = write!(out, " [line {line}, col {col}]"); + } + if let Some(hint) = &p.hint { + let _ = write!(out, " — did you mean `{hint}`?"); + } + out +} + +impl From for CliError { + fn from(e: IpcError) -> Self { + match e { + IpcError::Io(ref io_err) + if matches!( + io_err.kind(), + io::ErrorKind::NotFound | io::ErrorKind::ConnectionRefused + ) => + { + Self::DaemonDown(io_err.to_string()) + } + other => Self::Generic(other.to_string()), + } + } +} + +impl From for CliError { + fn from(e: io::Error) -> Self { + Self::Generic(e.to_string()) + } +} + +impl From for CliError { + fn from(e: serde_json::Error) -> Self { + Self::Generic(format!("json: {e}")) + } +} + +/// Convenience alias for command implementations. +pub type CliResult = Result; + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn exit_code_integers_match_spec() { + assert_eq!(i32::from(ExitCode::Success), 0); + assert_eq!(i32::from(ExitCode::Generic), 1); + assert_eq!(i32::from(ExitCode::Validation), 2); + assert_eq!(i32::from(ExitCode::NotFound), 3); + assert_eq!(i32::from(ExitCode::DaemonDown), 4); + } + + #[test] + fn validation_error_maps_to_exit_2() { + let e = CliError::Validation("bad".into()); + assert_eq!(e.exit_code(), ExitCode::Validation); + } + + #[test] + fn not_found_error_maps_to_exit_3() { + let e = CliError::NotFound("abc".into()); + assert_eq!(e.exit_code(), ExitCode::NotFound); + } + + #[test] + fn daemon_down_maps_to_exit_4() { + let e = CliError::DaemonDown("/tmp/x".into()); + assert_eq!(e.exit_code(), ExitCode::DaemonDown); + } + + #[test] + fn generic_falls_through_to_exit_1() { + let e = CliError::Generic("oops".into()); + assert_eq!(e.exit_code(), ExitCode::Generic); + } + + #[test] + fn from_payload_invalid_program_is_validation() { + let p = ErrorPayload::simple(ErrorCode::InvalidProgram, "unknown predicate"); + let e = CliError::from_payload(&p); + assert!(matches!(e, CliError::Validation(_))); + } + + #[test] + fn from_payload_unknown_program_is_not_found() { + let p = ErrorPayload::simple(ErrorCode::UnknownProgram, "no such id"); + let e = CliError::from_payload(&p); + assert!(matches!(e, CliError::NotFound(_))); + } + + #[test] + fn from_payload_internal_is_generic() { + let p = ErrorPayload::simple(ErrorCode::Internal, "boom"); + let e = CliError::from_payload(&p); + assert!(matches!(e, CliError::Generic(_))); + } + + #[test] + fn from_payload_includes_path_line_col_hint() { + let p = ErrorPayload { + code: ErrorCode::InvalidProgram, + message: "unknown predicate 'skp'".into(), + path: Some("watches[0].when".into()), + line: Some(12), + column: Some(18), + span_len: Some(3), + hint: Some("skip".into()), + }; + let e = CliError::from_payload(&p); + let s = e.to_string(); + assert!(s.contains("watches[0].when"), "{s}"); + assert!(s.contains("line 12"), "{s}"); + assert!(s.contains("col 18"), "{s}"); + assert!(s.contains("skip"), "{s}"); + } + + #[test] + fn ipc_not_found_maps_to_daemon_down() { + let io_err = io::Error::new(io::ErrorKind::NotFound, "no socket"); + let e: CliError = IpcError::Io(io_err).into(); + assert!(matches!(e, CliError::DaemonDown(_))); + assert_eq!(e.exit_code(), ExitCode::DaemonDown); + } + + #[test] + fn ipc_connection_refused_maps_to_daemon_down() { + let io_err = io::Error::new(io::ErrorKind::ConnectionRefused, "refused"); + let e: CliError = IpcError::Io(io_err).into(); + assert!(matches!(e, CliError::DaemonDown(_))); + } + + #[test] + fn ipc_other_io_maps_to_generic() { + let io_err = io::Error::new(io::ErrorKind::PermissionDenied, "denied"); + let e: CliError = IpcError::Io(io_err).into(); + assert!(matches!(e, CliError::Generic(_))); + } +} diff --git a/crates/vcli-cli/src/format.rs b/crates/vcli-cli/src/format.rs new file mode 100644 index 0000000..18bae36 --- /dev/null +++ b/crates/vcli-cli/src/format.rs @@ -0,0 +1,143 @@ +//! Output rendering. Two modes: `Pretty` (tables / single-line summaries) and +//! `Json` (one `serde_json::Value` serialized once). Commands pick by reading +//! `Cli::output_mode()`; the helpers in this file do no I/O — they return +//! `String`s the caller writes to `stdout`. + +use std::fmt::Write as _; + +use crate::cli::OutputMode; +use crate::error::CliError; + +/// Simple table — header + rows. All string data; commands format numbers +/// and timestamps before inserting. +#[derive(Debug, Clone)] +pub struct Table { + headers: Vec, + rows: Vec, +} + +/// One tabular row. +#[derive(Debug, Clone)] +pub struct Row(pub Vec); + +impl Table { + /// Build a table. `headers.len()` is the enforced column count. + #[must_use] + pub fn new(headers: I) -> Self + where + I: IntoIterator, + S: Into, + { + Self { + headers: headers.into_iter().map(Into::into).collect(), + rows: Vec::new(), + } + } + + /// Append one row. Extra cells are truncated; missing cells are filled + /// with empty strings so the output is always rectangular. + pub fn push(&mut self, row: Row) { + let mut cells = row.0; + cells.resize(self.headers.len(), String::new()); + self.rows.push(Row(cells)); + } + + /// Render to a string. Columns are left-aligned, padded to the widest cell. + #[must_use] + pub fn render_pretty(&self) -> String { + let cols = self.headers.len(); + let mut widths = vec![0usize; cols]; + for (i, h) in self.headers.iter().enumerate() { + widths[i] = h.chars().count(); + } + for row in &self.rows { + for (i, cell) in row.0.iter().enumerate() { + widths[i] = widths[i].max(cell.chars().count()); + } + } + let mut out = String::new(); + for (i, h) in self.headers.iter().enumerate() { + let _ = write!(out, "{: Result { + match mode { + OutputMode::Pretty => Ok(pretty.to_string()), + OutputMode::Json => Ok(serde_json::to_string_pretty(json)?), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn empty_table_renders_just_header() { + let t = Table::new(["id", "kind", "state"]); + let s = t.render_pretty(); + assert_eq!(s.lines().count(), 1); + assert!(s.contains("id")); + assert!(s.contains("state")); + } + + #[test] + fn table_columns_align_to_widest_cell() { + let mut t = Table::new(["id", "state"]); + t.push(Row(vec!["abcdef".into(), "running".into()])); + t.push(Row(vec!["xy".into(), "completed".into()])); + let s = t.render_pretty(); + let lines: Vec<_> = s.lines().collect(); + assert_eq!(lines.len(), 3); + let widths: Vec = lines.iter().map(|l| l.chars().count()).collect(); + assert_eq!(widths[0], widths[1]); + assert_eq!(widths[1], widths[2]); + } + + #[test] + fn short_rows_get_padded() { + let mut t = Table::new(["a", "b", "c"]); + t.push(Row(vec!["1".into()])); + let s = t.render_pretty(); + assert!(s.lines().count() == 2); + } + + #[test] + fn render_value_pretty_returns_pretty_arg() { + let v = serde_json::json!({ "x": 1 }); + let s = render_value(OutputMode::Pretty, "human readable", &v).unwrap(); + assert_eq!(s, "human readable"); + } + + #[test] + fn render_value_json_serializes_value() { + let v = serde_json::json!({ "x": 1 }); + let s = render_value(OutputMode::Json, "ignored", &v).unwrap(); + assert!(s.contains(r#""x": 1"#), "{s}"); + } +} diff --git a/crates/vcli-cli/src/lib.rs b/crates/vcli-cli/src/lib.rs new file mode 100644 index 0000000..1bb60c3 --- /dev/null +++ b/crates/vcli-cli/src/lib.rs @@ -0,0 +1,105 @@ +//! vcli-cli — user-facing command-line interface for the vcli daemon. +//! +//! See `docs/superpowers/specs/2026-04-16-vcli-design.md` §567–590 for the +//! authoritative CLI surface. This crate is a thin transport+formatting layer +//! over `vcli-ipc`; the daemon does all real work. + +#![forbid(unsafe_code)] +#![deny(rust_2018_idioms)] +#![warn(missing_docs)] +#![warn(clippy::pedantic)] +#![allow(clippy::module_name_repetitions)] + +pub mod cli; +pub mod client; +pub mod commands; +pub mod error; +pub mod format; +pub mod util; + +pub use client::connect; +pub use format::{render_value, Row, Table}; + +pub use cli::{Cli, Command, DaemonCommand, OutputMode, StateFilter}; +pub use error::{CliError, CliResult, ExitCode}; +pub use util::{format_unix_ms, read_program_file, resolve_socket}; + +/// Entry point used by `src/bin/vcli.rs`. Returns the process exit code. +#[must_use] +pub fn run() -> i32 { + let cli = match ::try_parse() { + Ok(c) => c, + Err(e) => { + let code = if matches!( + e.kind(), + clap::error::ErrorKind::DisplayHelp | clap::error::ErrorKind::DisplayVersion + ) { + ExitCode::Success + } else { + ExitCode::Generic + }; + let _ = e.print(); + return code.into(); + } + }; + + let runtime = match tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + { + Ok(rt) => rt, + Err(e) => { + eprintln!("vcli: tokio runtime init failed: {e}"); + return ExitCode::Generic.into(); + } + }; + + let result = runtime.block_on(run_async(cli)); + match result { + Ok(()) => ExitCode::Success.into(), + Err(e) => { + eprintln!("vcli: {e}"); + e.exit_code().into() + } + } +} + +async fn run_async(cli: Cli) -> CliResult<()> { + use std::io::Write as _; + + let socket = crate::util::resolve_socket(cli.socket.as_deref())?; + let mode = cli.output_mode(); + let stdout = std::io::stdout(); + let mut out = stdout.lock(); + + match &cli.command { + Command::Submit(args) => crate::commands::submit::run(&socket, mode, args, &mut out).await, + Command::List(args) => { + let s = crate::commands::list::run(&socket, mode, args).await?; + out.write_all(s.as_bytes())?; + Ok(()) + } + Command::Cancel(args) => { + let s = crate::commands::cancel::run(&socket, mode, args).await?; + out.write_all(s.as_bytes())?; + Ok(()) + } + Command::Logs(args) => crate::commands::logs::run(&socket, mode, args, &mut out).await, + Command::Resume(args) => { + let s = crate::commands::resume::run(&socket, mode, args).await?; + out.write_all(s.as_bytes())?; + Ok(()) + } + Command::Daemon(sub) => crate::commands::daemon::run(&socket, mode, sub, &mut out).await, + Command::Health => { + let s = crate::commands::health::run(&socket, mode).await?; + out.write_all(s.as_bytes())?; + Ok(()) + } + Command::Gc => { + let s = crate::commands::gc::run(&socket, mode).await?; + out.write_all(s.as_bytes())?; + Ok(()) + } + } +} diff --git a/crates/vcli-cli/src/util.rs b/crates/vcli-cli/src/util.rs new file mode 100644 index 0000000..45474ee --- /dev/null +++ b/crates/vcli-cli/src/util.rs @@ -0,0 +1,143 @@ +//! Grab-bag of helpers — socket resolution honouring `--socket`, ISO-8601 +//! formatting of `UnixMs`, and program-file ingestion with byte-for-byte +//! limits so we don't OOM on an accidental multi-GB file. + +use std::fs; +use std::path::{Path, PathBuf}; + +use vcli_core::clock::UnixMs; +use vcli_ipc::default_socket_path; + +use crate::error::{CliError, CliResult}; + +/// Hard cap on submitted program size. Matches `vcli_ipc::MAX_FRAME_LEN` minus +/// envelope overhead headroom. +pub const MAX_PROGRAM_BYTES: u64 = 2 * 1024 * 1024; + +/// Resolve the socket path. Explicit override (`--socket` / `VCLI_SOCKET`) +/// wins; otherwise falls through `vcli_ipc::default_socket_path`. +/// +/// # Errors +/// Returns `CliError::Generic` if no path is discoverable. +pub fn resolve_socket(explicit: Option<&Path>) -> CliResult { + if let Some(p) = explicit { + return Ok(p.to_path_buf()); + } + default_socket_path() + .map(|s| s.path) + .map_err(|e| CliError::Generic(format!("resolve socket: {e}"))) +} + +/// Read a program JSON file from disk. Enforces `MAX_PROGRAM_BYTES` before +/// allocating — an accidental `vcli submit /dev/urandom` should fail cheaply. +/// +/// # Errors +/// Returns `CliError::Generic` if the file is missing or oversize, and +/// `CliError::Validation` if the bytes aren't valid UTF-8 JSON. +pub fn read_program_file(path: &Path) -> CliResult { + let meta = fs::metadata(path) + .map_err(|e| CliError::Generic(format!("stat {}: {e}", path.display())))?; + if meta.len() > MAX_PROGRAM_BYTES { + return Err(CliError::Generic(format!( + "program file too large: {} bytes (max {MAX_PROGRAM_BYTES})", + meta.len() + ))); + } + let bytes = + fs::read(path).map_err(|e| CliError::Generic(format!("read {}: {e}", path.display())))?; + serde_json::from_slice::(&bytes) + .map_err(|e| CliError::Validation(format!("invalid JSON in {}: {e}", path.display()))) +} + +/// Format a `UnixMs` as an RFC 3339 / ISO 8601 UTC timestamp. +/// +/// Computed directly rather than pulling in `chrono`/`time`; the only use +/// site is display. +#[must_use] +pub fn format_unix_ms(ms: UnixMs) -> String { + let total_secs = ms.div_euclid(1_000); + let sub_ms = ms.rem_euclid(1_000); + + let days = total_secs.div_euclid(86_400); + let secs_of_day = total_secs.rem_euclid(86_400); + let hour = secs_of_day / 3_600; + let minute = (secs_of_day % 3_600) / 60; + let second = secs_of_day % 60; + + let (year, month, day) = civil_from_days(days); + format!("{year:04}-{month:02}-{day:02}T{hour:02}:{minute:02}:{second:02}.{sub_ms:03}Z") +} + +/// Howard Hinnant's days-from-civil inverse: convert days since 1970-01-01 +/// to (year, month, day). Public-domain. +#[allow( + clippy::cast_possible_truncation, + clippy::cast_sign_loss, + clippy::cast_possible_wrap +)] +fn civil_from_days(z: i64) -> (i32, u8, u8) { + let z = z + 719_468; + let era = if z >= 0 { z } else { z - 146_096 } / 146_097; + let doe = (z - era * 146_097) as u64; + let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365; + let y = yoe as i64 + era * 400; + let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); + let mp = (5 * doy + 2) / 153; + let d = doy - (153 * mp + 2) / 5 + 1; + let m = if mp < 10 { mp + 3 } else { mp - 9 }; + let y = if m <= 2 { y + 1 } else { y }; + (y as i32, m as u8, d as u8) +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::tempdir; + + #[test] + fn resolve_socket_honours_explicit_override() { + let p = Path::new("/tmp/explicit.sock"); + let got = resolve_socket(Some(p)).unwrap(); + assert_eq!(got, PathBuf::from("/tmp/explicit.sock")); + } + + #[test] + fn read_program_file_roundtrips_small_json() { + let dir = tempdir().unwrap(); + let path = dir.path().join("p.json"); + fs::write(&path, r#"{"name":"x"}"#).unwrap(); + let v = read_program_file(&path).unwrap(); + assert_eq!(v["name"], "x"); + } + + #[test] + fn read_program_file_reports_missing_as_generic() { + let dir = tempdir().unwrap(); + let path = dir.path().join("nope.json"); + let e = read_program_file(&path).unwrap_err(); + assert!(matches!(e, CliError::Generic(_))); + } + + #[test] + fn read_program_file_reports_bad_json_as_validation() { + let dir = tempdir().unwrap(); + let path = dir.path().join("p.json"); + fs::write(&path, "{not json").unwrap(); + let e = read_program_file(&path).unwrap_err(); + assert!(matches!(e, CliError::Validation(_))); + } + + #[test] + fn format_unix_ms_epoch_is_1970_01_01() { + let s = format_unix_ms(0_i64); + assert_eq!(s, "1970-01-01T00:00:00.000Z"); + } + + #[test] + fn format_unix_ms_known_instant() { + // 2026-04-16 19:42:11.123 UTC. + let ms = 20_559_i64 * 86_400_000 + (19 * 3600 + 42 * 60 + 11) * 1000 + 123; + let s = format_unix_ms(ms); + assert_eq!(s, "2026-04-16T19:42:11.123Z"); + } +} diff --git a/crates/vcli-cli/tests/bin.rs b/crates/vcli-cli/tests/bin.rs new file mode 100644 index 0000000..999b885 --- /dev/null +++ b/crates/vcli-cli/tests/bin.rs @@ -0,0 +1,66 @@ +//! Smoke test: invoke the built `vcli` binary as a subprocess. + +use assert_cmd::Command; +use predicates::prelude::*; + +#[test] +fn help_exits_zero() { + Command::cargo_bin("vcli") + .unwrap() + .arg("--help") + .assert() + .success() + .stdout(predicate::str::contains("vcli")); +} + +#[test] +fn version_exits_zero() { + Command::cargo_bin("vcli") + .unwrap() + .arg("--version") + .assert() + .success(); +} + +#[test] +fn health_without_daemon_exits_4() { + let tmp = tempfile::tempdir().unwrap(); + let sock = tmp.path().join("vcli.sock"); + Command::cargo_bin("vcli") + .unwrap() + .args(["--socket", sock.to_str().unwrap(), "health"]) + .assert() + .code(4); +} + +#[test] +fn cancel_with_garbage_id_is_nonzero() { + Command::cargo_bin("vcli") + .unwrap() + .args(["cancel", "not-a-uuid"]) + .assert() + .failure(); +} + +#[test] +fn gc_without_daemon_exits_4() { + let tmp = tempfile::tempdir().unwrap(); + let sock = tmp.path().join("vcli.sock"); + Command::cargo_bin("vcli") + .unwrap() + .args(["--socket", sock.to_str().unwrap(), "gc"]) + .assert() + .code(4); +} + +#[test] +fn daemon_status_without_socket_exits_zero() { + let tmp = tempfile::tempdir().unwrap(); + let sock = tmp.path().join("vcli.sock"); + Command::cargo_bin("vcli") + .unwrap() + .args(["--socket", sock.to_str().unwrap(), "daemon", "status"]) + .assert() + .success() + .stdout(predicate::str::contains("not running")); +} diff --git a/crates/vcli-cli/tests/common/mod.rs b/crates/vcli-cli/tests/common/mod.rs new file mode 100644 index 0000000..6993951 --- /dev/null +++ b/crates/vcli-cli/tests/common/mod.rs @@ -0,0 +1,52 @@ +//! Shared fake-daemon harness for e2e tests. + +use std::path::PathBuf; +use std::sync::Arc; + +use tempfile::TempDir; +use tokio::sync::oneshot; + +use vcli_ipc::handler::test_double::FakeHandler; +use vcli_ipc::IpcServer; + +pub struct FakeDaemon { + pub socket: PathBuf, + // Why: kept so tests can inspect which ops the handler received. Not all + // suites need it yet, but removing it would force future tests to re-add. + #[allow(dead_code)] + pub handler: Arc, + pub shutdown: Option>, + // Why: TempDir is load-bearing RAII — dropping it here would unlink the + // socket mid-test. The field itself is never read. + #[allow(dead_code)] + pub tmp: TempDir, +} + +impl FakeDaemon { + pub async fn start() -> Self { + let tmp = TempDir::new().unwrap(); + let socket = tmp.path().join("vcli.sock"); + let handler = Arc::new(FakeHandler::default()); + let server = IpcServer::bind(&socket, handler.clone()).unwrap(); + let (tx, rx) = oneshot::channel(); + tokio::spawn(async move { + let _ = server.serve(rx).await; + }); + tokio::task::yield_now().await; + tokio::time::sleep(std::time::Duration::from_millis(20)).await; + Self { + socket, + handler, + shutdown: Some(tx), + tmp, + } + } +} + +impl Drop for FakeDaemon { + fn drop(&mut self) { + if let Some(tx) = self.shutdown.take() { + let _ = tx.send(()); + } + } +} diff --git a/crates/vcli-cli/tests/e2e.rs b/crates/vcli-cli/tests/e2e.rs new file mode 100644 index 0000000..fc5ffa5 --- /dev/null +++ b/crates/vcli-cli/tests/e2e.rs @@ -0,0 +1,251 @@ +//! End-to-end: real IpcServer + FakeHandler, exercised through each command's +//! public `run()` signature. + +mod common; + +use std::path::PathBuf; + +use vcli_cli::cli::{CancelArgs, ListArgs, LogsArgs, OutputMode, ResumeArgs, SubmitArgs}; +use vcli_cli::{commands, error::CliError}; + +use common::FakeDaemon; + +#[tokio::test] +async fn health_command_prints_ok() { + let d = FakeDaemon::start().await; + let s = commands::health::run(&d.socket, OutputMode::Pretty) + .await + .unwrap(); + assert!(s.contains("daemon: ok"), "{s}"); +} + +#[tokio::test] +async fn health_json_mode_returns_valid_json() { + let d = FakeDaemon::start().await; + let s = commands::health::run(&d.socket, OutputMode::Json) + .await + .unwrap(); + let v: serde_json::Value = serde_json::from_str(&s).unwrap(); + assert!(v.is_object(), "{v}"); +} + +#[tokio::test] +async fn gc_command_reports_ok() { + let d = FakeDaemon::start().await; + let s = commands::gc::run(&d.socket, OutputMode::Pretty) + .await + .unwrap(); + assert!(s.starts_with("gc: ok"), "{s}"); +} + +#[tokio::test] +async fn list_command_renders_empty_table() { + let d = FakeDaemon::start().await; + let s = commands::list::run(&d.socket, OutputMode::Pretty, &ListArgs { state: None }) + .await + .unwrap(); + assert!(s.contains("id"), "{s}"); + assert_eq!(s.lines().count(), 1); +} + +#[tokio::test] +async fn cancel_command_roundtrips() { + let d = FakeDaemon::start().await; + let id = "12345678-1234-4567-8910-111213141516".parse().unwrap(); + let s = commands::cancel::run( + &d.socket, + OutputMode::Pretty, + &CancelArgs { + program_id: id, + reason: None, + }, + ) + .await + .unwrap(); + assert!(s.contains("cancelled"), "{s}"); +} + +#[tokio::test] +async fn resume_command_roundtrips() { + let d = FakeDaemon::start().await; + let id = "12345678-1234-4567-8910-111213141516".parse().unwrap(); + let s = commands::resume::run( + &d.socket, + OutputMode::Pretty, + &ResumeArgs { + program_id: id, + from_start: true, + }, + ) + .await + .unwrap(); + assert!(s.starts_with("resumed"), "{s}"); +} + +#[tokio::test] +async fn logs_command_streams_events_and_exits() { + let d = FakeDaemon::start().await; + let id = "12345678-1234-4567-8910-111213141516".parse().unwrap(); + let mut buf: Vec = Vec::new(); + commands::logs::run( + &d.socket, + OutputMode::Pretty, + &LogsArgs { + program_id: id, + follow: false, + since: None, + }, + &mut buf, + ) + .await + .unwrap(); + let out = String::from_utf8(buf).unwrap(); + assert!(out.contains("daemon.started"), "{out}"); + assert!(out.contains("daemon.stopped"), "{out}"); +} + +#[tokio::test] +async fn logs_command_json_mode_emits_one_json_per_line() { + let d = FakeDaemon::start().await; + let id = "12345678-1234-4567-8910-111213141516".parse().unwrap(); + let mut buf: Vec = Vec::new(); + commands::logs::run( + &d.socket, + OutputMode::Json, + &LogsArgs { + program_id: id, + follow: false, + since: None, + }, + &mut buf, + ) + .await + .unwrap(); + let out = String::from_utf8(buf).unwrap(); + let lines: Vec<_> = out.lines().collect(); + assert_eq!(lines.len(), 2); + for line in &lines { + let _: serde_json::Value = serde_json::from_str(line).unwrap(); + } +} + +#[tokio::test] +async fn submit_command_writes_program_id() { + let d = FakeDaemon::start().await; + let tmp = tempfile::tempdir().unwrap(); + let file = tmp.path().join("p.json"); + std::fs::write( + &file, + r#"{ + "version": "0.1", + "name": "t", + "trigger": {"kind":"on_submit"}, + "predicates": {}, + "watches": [], + "body": [] + }"#, + ) + .unwrap(); + let mut buf: Vec = Vec::new(); + commands::submit::run( + &d.socket, + OutputMode::Pretty, + &SubmitArgs { file, watch: false }, + &mut buf, + ) + .await + .unwrap(); + let out = String::from_utf8(buf).unwrap(); + let first_line = out.lines().next().unwrap().trim(); + assert_eq!(first_line.len(), 36, "{out}"); + assert_eq!(first_line.matches('-').count(), 4); +} + +#[tokio::test] +async fn submit_with_invalid_json_file_is_validation_error() { + let d = FakeDaemon::start().await; + let tmp = tempfile::tempdir().unwrap(); + let file = tmp.path().join("bad.json"); + std::fs::write(&file, "{not json").unwrap(); + let mut buf: Vec = Vec::new(); + let err = commands::submit::run( + &d.socket, + OutputMode::Pretty, + &SubmitArgs { file, watch: false }, + &mut buf, + ) + .await + .unwrap_err(); + assert!(matches!(err, CliError::Validation(_))); +} + +#[tokio::test] +async fn submit_with_dsl_rejected_program_is_validation_error() { + let d = FakeDaemon::start().await; + let tmp = tempfile::tempdir().unwrap(); + let file = tmp.path().join("cycle.json"); + std::fs::write( + &file, + r#"{ + "version": "0.1", + "name": "p", + "trigger": {"kind":"on_submit"}, + "predicates": { + "a": {"kind":"not","of":"b"}, + "b": {"kind":"not","of":"a"} + }, + "watches": [], + "body": [] + }"#, + ) + .unwrap(); + let mut buf: Vec = Vec::new(); + let err = commands::submit::run( + &d.socket, + OutputMode::Pretty, + &SubmitArgs { file, watch: false }, + &mut buf, + ) + .await + .unwrap_err(); + assert!(matches!(err, CliError::Validation(_))); +} + +#[tokio::test] +async fn daemon_status_reports_running_against_fake_daemon() { + use vcli_cli::cli::DaemonCommand; + let d = FakeDaemon::start().await; + let mut buf: Vec = Vec::new(); + commands::daemon::run( + &d.socket, + OutputMode::Pretty, + &DaemonCommand::Status, + &mut buf, + ) + .await + .unwrap(); + let out = String::from_utf8(buf).unwrap(); + assert!(out.contains("running"), "{out}"); +} + +#[tokio::test] +async fn daemon_status_without_socket_reports_not_running() { + use vcli_cli::cli::DaemonCommand; + let bogus = PathBuf::from("/tmp/vcli-definitely-does-not-exist.sock"); + let mut buf: Vec = Vec::new(); + commands::daemon::run(&bogus, OutputMode::Pretty, &DaemonCommand::Status, &mut buf) + .await + .unwrap(); + let out = String::from_utf8(buf).unwrap(); + assert!(out.contains("not running"), "{out}"); +} + +#[tokio::test] +async fn missing_daemon_yields_daemon_down_error() { + let bogus = PathBuf::from("/tmp/vcli-definitely-does-not-exist.sock"); + let err = commands::health::run(&bogus, OutputMode::Pretty) + .await + .unwrap_err(); + assert!(matches!(err, CliError::DaemonDown(_))); + assert_eq!(err.exit_code(), vcli_cli::error::ExitCode::DaemonDown); +} diff --git a/crates/vcli-cli/tests/parse.rs b/crates/vcli-cli/tests/parse.rs new file mode 100644 index 0000000..21448fd --- /dev/null +++ b/crates/vcli-cli/tests/parse.rs @@ -0,0 +1,120 @@ +//! Parse-coverage for `vcli`. Does not connect to a daemon; only checks that +//! clap grammar matches spec §567–590. + +use clap::Parser; + +use vcli_cli::{Cli, Command, DaemonCommand, StateFilter}; + +fn ok(args: &[&str]) -> Cli { + Cli::try_parse_from(args).unwrap() +} + +fn err(args: &[&str]) { + assert!( + Cli::try_parse_from(args).is_err(), + "expected err from {args:?}" + ); +} + +#[test] +fn top_level_help_does_not_require_subcommand_to_show() { + let r = Cli::try_parse_from(["vcli", "--help"]); + assert!(r.is_err()); + assert_eq!(r.unwrap_err().kind(), clap::error::ErrorKind::DisplayHelp); +} + +#[test] +fn every_v0_command_parses_minimally() { + let id = "12345678-1234-4567-8910-111213141516"; + ok(&["vcli", "submit", "p.json"]); + ok(&["vcli", "list"]); + ok(&["vcli", "cancel", id]); + ok(&["vcli", "logs", id]); + ok(&["vcli", "resume", id]); + ok(&["vcli", "daemon", "start"]); + ok(&["vcli", "daemon", "run"]); + ok(&["vcli", "daemon", "stop"]); + ok(&["vcli", "daemon", "status"]); + ok(&["vcli", "health"]); + ok(&["vcli", "gc"]); +} + +#[test] +fn json_flag_is_global() { + for args in [ + &["vcli", "--json", "list"][..], + &["vcli", "list", "--json"][..], + &["vcli", "--json", "submit", "p.json"][..], + &["vcli", "submit", "p.json", "--json"][..], + ] { + let c = ok(args); + assert!(c.json, "{args:?}"); + } +} + +#[test] +fn socket_flag_is_global() { + let c = ok(&["vcli", "--socket", "/tmp/x.sock", "health"]); + assert_eq!( + c.socket.as_deref().unwrap().to_str().unwrap(), + "/tmp/x.sock" + ); + let c = ok(&["vcli", "health", "--socket", "/tmp/y.sock"]); + assert_eq!( + c.socket.as_deref().unwrap().to_str().unwrap(), + "/tmp/y.sock" + ); +} + +#[test] +fn list_state_filter_rejects_unknown_value() { + err(&["vcli", "list", "--state", "frobnicating"]); +} + +#[test] +fn list_state_filter_accepts_each_known_value() { + for (arg, want) in [ + ("waiting", StateFilter::Waiting), + ("running", StateFilter::Running), + ("completed", StateFilter::Completed), + ("failed", StateFilter::Failed), + ("cancelled", StateFilter::Cancelled), + ] { + let c = ok(&["vcli", "list", "--state", arg]); + match c.command { + Command::List(a) => assert_eq!(a.state, Some(want)), + _ => panic!(), + } + } +} + +#[test] +fn cancel_rejects_non_uuid() { + err(&["vcli", "cancel", "not-a-uuid"]); +} + +#[test] +fn submit_without_file_errors() { + err(&["vcli", "submit"]); +} + +#[test] +fn daemon_without_subcommand_errors() { + err(&["vcli", "daemon"]); +} + +#[test] +fn daemon_status_parses() { + let c = ok(&["vcli", "daemon", "status"]); + match c.command { + Command::Daemon(DaemonCommand::Status) => {} + other => panic!("{other:?}"), + } +} + +#[test] +fn resume_from_start_flag_can_be_before_or_after_id() { + let id = "12345678-1234-4567-8910-111213141516"; + ok(&["vcli", "resume", id, "--from-start"]); + ok(&["vcli", "resume", "--from-start", id]); +}