commit d5aaed12621292f487d1465e3f03fbe537e8bcb5
parent 26cdd84e134cfdadef4f24d764a9c25be7cd205c
Author: triesap <tyson@radroots.org>
Date: Mon, 27 Apr 2026 08:46:37 +0000
cli: add human output renderer
- render default human output as concise status text
- preserve structured error codes and messages in human failures
- document json and ndjson output mode semantics
- add process coverage for human success and failure output
Diffstat:
2 files changed, 71 insertions(+), 1 deletion(-)
diff --git a/src/main.rs b/src/main.rs
@@ -446,7 +446,10 @@ fn render_envelope(
let stdout = std::io::stdout();
let mut handle = stdout.lock();
match format {
- TargetOutputFormat::Human | TargetOutputFormat::Json => {
+ TargetOutputFormat::Human => {
+ render_human_envelope(&mut handle, envelope)?;
+ }
+ TargetOutputFormat::Json => {
serde_json::to_writer_pretty(&mut handle, envelope)?;
}
TargetOutputFormat::Ndjson => {
@@ -461,6 +464,41 @@ fn render_envelope(
Ok(())
}
+fn render_human_envelope(
+ handle: &mut impl Write,
+ envelope: &OutputEnvelope,
+) -> Result<(), runtime::RuntimeError> {
+ writeln!(
+ handle,
+ "{}: {}",
+ envelope.operation_id,
+ human_envelope_status(envelope)
+ )?;
+ writeln!(handle, "request_id: {}", envelope.request_id)?;
+ if let Some(error) = envelope.errors.first() {
+ writeln!(handle, "error: {}", error.code)?;
+ writeln!(handle, "message: {}", error.message)?;
+ }
+ Ok(())
+}
+
+fn human_envelope_status(envelope: &OutputEnvelope) -> &str {
+ if !envelope.errors.is_empty() {
+ return "error";
+ }
+ if let Some(state) = envelope
+ .result
+ .get("state")
+ .and_then(|value| value.as_str())
+ {
+ return state;
+ }
+ if envelope.dry_run {
+ return "dry_run";
+ }
+ "ok"
+}
+
fn envelope_exit_code(envelope: &OutputEnvelope) -> ExitCode {
envelope
.errors
diff --git a/tests/target_cli.rs b/tests/target_cli.rs
@@ -171,6 +171,38 @@ fn target_command_outputs_standard_json_envelope() {
}
#[test]
+fn default_human_output_is_concise_and_not_json() {
+ let output = radroots()
+ .args(["workspace", "get"])
+ .output()
+ .expect("run workspace get");
+
+ assert!(output.status.success());
+ let stdout = String::from_utf8(output.stdout).expect("utf8 stdout");
+
+ assert!(stdout.starts_with("workspace.get: ok\n"));
+ assert!(stdout.contains("request_id: req_workspace_get_"));
+ assert!(serde_json::from_str::<Value>(&stdout).is_err());
+}
+
+#[test]
+fn human_failure_output_preserves_error_code_and_message() {
+ let output = radroots()
+ .args(["--format", "human", "order", "submit"])
+ .output()
+ .expect("run order submit");
+
+ assert_eq!(output.status.code(), Some(6));
+ let stdout = String::from_utf8(output.stdout).expect("utf8 stdout");
+
+ assert!(stdout.starts_with("order.submit: error\n"));
+ assert!(stdout.contains("request_id: req_order_submit_"));
+ assert!(stdout.contains("error: approval_required"));
+ assert!(stdout.contains("message: missing required `approval_token` input"));
+ assert!(serde_json::from_str::<Value>(&stdout).is_err());
+}
+
+#[test]
fn request_ids_are_invocation_unique_and_preserve_caller_fields() {
let first = radroots()
.args([