diff --git a/apps/solvecraft-cli/src/main.rs b/apps/solvecraft-cli/src/main.rs index 0f748220..da7d5e75 100644 --- a/apps/solvecraft-cli/src/main.rs +++ b/apps/solvecraft-cli/src/main.rs @@ -53,8 +53,17 @@ fn main() -> ExitCode { Some("snapshot") => cmd_snapshot(&args[1..]), Some("exec") => cmd_exec(&args[1..]), Some("commands") => { + use std::io::Write as _; let mut s = Session::default(); - s.execute("engine.commands", &json!({})).map(|v| println!("{}", pretty(&v))).map_err(|e| e.to_string()) + s.execute("engine.commands", &json!({})).map_err(|e| e.to_string()).and_then(|v| { + // A pipeline consumer may stop early (e.g. `commands | head`). + // Unlike println!, writing explicitly lets us handle that without a panic. + match writeln!(std::io::stdout().lock(), "{}", pretty(&v)) { + Ok(()) => Ok(()), + Err(e) if e.kind() == std::io::ErrorKind::BrokenPipe => Ok(()), + Err(e) => Err(format!("stdout: {e}")), + } + }) } Some("recipe") => args.get(1).ok_or_else(|| USAGE.to_string()).and_then(|p| { let v = read_json(p)?; diff --git a/apps/solvecraft-cli/tests/commands_output.rs b/apps/solvecraft-cli/tests/commands_output.rs new file mode 100644 index 00000000..7637ba88 --- /dev/null +++ b/apps/solvecraft-cli/tests/commands_output.rs @@ -0,0 +1,26 @@ +//! The registry remains valid JSON and a closed pipeline reader must not cause a panic. + +use std::process::{Command, Stdio}; + +#[test] +fn registry_output_is_complete_json() { + let output = Command::new(env!("CARGO_BIN_EXE_solvecraft-cli")).arg("commands").output().unwrap(); + assert!(output.status.success(), "{}", String::from_utf8_lossy(&output.stderr)); + let registry: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap(); + let commands = registry.as_array().unwrap(); + assert!(commands.iter().any(|command| command["id"] == "solid.box")); + assert!(commands.iter().any(|command| command["id"] == "engine.commands")); + assert!(output.stdout.ends_with(b"\n")); + assert!(output.stderr.is_empty()); +} + +#[test] +fn closed_registry_pipe_exits_without_panicking() { + let mut child = Command::new(env!("CARGO_BIN_EXE_solvecraft-cli")).arg("commands").stdout(Stdio::piped()).stderr(Stdio::piped()).spawn().unwrap(); + // Close our read end before waiting. No sleeps or timing threshold are needed: + // the registry is larger than the pipe buffer, so it cannot all be written first. + drop(child.stdout.take()); + let output = child.wait_with_output().unwrap(); + assert!(output.status.success(), "status: {}; stderr: {}", output.status, String::from_utf8_lossy(&output.stderr)); + assert!(output.stderr.is_empty(), "{}", String::from_utf8_lossy(&output.stderr)); +}