cli

Command-line interface for Radroots
git clone https://radroots.dev/git/cli.git
Log | Files | Refs | README | LICENSE

commit 11044372b4955bf3f63457700fc4f310d87e925a
parent 871d0b548fb3f25cf0b6d4b45a7ae69c7d470a3f
Author: triesap <tyson@radroots.org>
Date:   Sat, 27 Jun 2026 07:02:14 +0000

cli: implement quiet terminal rendering

- add essential terminal visibility for quiet failure context
- suppress normal fields, sections, warnings, and next actions in quiet mode
- preserve normal, verbose, and trace rendering behavior
- cover quiet terminal success, failure, and JSON output paths

Diffstat:
Msrc/out/terminal/errors.rs | 2+-
Msrc/out/terminal/layout.rs | 9+++++++++
Msrc/out/terminal/renderer.rs | 49+++++++++++++++++++++++++++++++++++++++++++++----
Mtests/target_cli.rs | 47+++++++++++++++++++++++++++++++++++++++++++++++
4 files changed, 102 insertions(+), 5 deletions(-)

diff --git a/src/out/terminal/errors.rs b/src/out/terminal/errors.rs @@ -12,7 +12,7 @@ pub fn terminal_error_document(envelope: &OutputEnvelope) -> TerminalDocument { if let Some(error) = error { document .fields - .push(TerminalField::new("Reason", error.message.clone())); + .push(TerminalField::essential("Reason", error.message.clone())); document .fields .push(TerminalField::verbose("Code", error.reason_code.clone())); diff --git a/src/out/terminal/layout.rs b/src/out/terminal/layout.rs @@ -22,6 +22,7 @@ impl TerminalSymbol { #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum TerminalVisibility { + Essential, Normal, Verbose, Trace, @@ -50,6 +51,14 @@ pub struct TerminalField { } impl TerminalField { + pub fn essential(label: impl Into<String>, value: impl Into<String>) -> Self { + Self { + label: label.into(), + value: value.into(), + visibility: TerminalVisibility::Essential, + } + } + pub fn new(label: impl Into<String>, value: impl Into<String>) -> Self { Self { label: label.into(), diff --git a/src/out/terminal/renderer.rs b/src/out/terminal/renderer.rs @@ -33,9 +33,14 @@ impl Default for TerminalRenderContext { } impl TerminalRenderContext { + pub fn is_quiet(&self) -> bool { + matches!(self.verbosity, TerminalVerbosity::Quiet) + } + pub fn allows(&self, visibility: TerminalVisibility) -> bool { match visibility { - TerminalVisibility::Normal => true, + TerminalVisibility::Essential => true, + TerminalVisibility::Normal => !self.is_quiet(), TerminalVisibility::Verbose => { matches!( self.verbosity, @@ -59,14 +64,16 @@ pub fn render_terminal_document(document: &TerminalDocument, cx: &TerminalRender for section in &document.sections { write_section(&mut output, section, cx); } - if !document.warnings.is_empty() { + if !cx.is_quiet() && !document.warnings.is_empty() { let _ = writeln!(output); let _ = writeln!(output, "Warnings"); for warning in &document.warnings { let _ = writeln!(output, " {}: {}", warning.code, warning.message); } } - write_next(&mut output, &document.next); + if !cx.is_quiet() { + write_next(&mut output, &document.next); + } if let Some(reference) = &document.reference && cx.allows(TerminalVisibility::Verbose) && !reference.is_empty() @@ -192,7 +199,9 @@ fn write_reference(output: &mut String, reference: &TerminalReference) { mod tests { use super::*; use crate::out::terminal::actions::TerminalAction; - use crate::out::terminal::layout::{TerminalField, TerminalHeader, TerminalSymbol}; + use crate::out::terminal::layout::{ + TerminalField, TerminalHeader, TerminalSection, TerminalSymbol, TerminalWarning, + }; #[test] fn renders_canonical_receipt_layout() { @@ -234,4 +243,36 @@ mod tests { }; assert!(render_terminal_document(&document, &cx).contains("Reference")); } + + #[test] + fn quiet_renders_only_header_and_essential_fields() { + let mut document = TerminalDocument::new(TerminalHeader::new( + TerminalSymbol::Failure, + "Command failed", + )) + .with_field(TerminalField::essential("Reason", "missing approval")) + .with_field(TerminalField::new("State", "not implemented")) + .with_section(TerminalSection::lines( + "Details", + vec!["extra context".to_owned()], + )) + .with_next(TerminalAction::command( + "radroots trade status get ord_test", + )); + document.warnings = vec![TerminalWarning::new("warn_test", "suppressed")]; + document.reference = Some(TerminalReference { + request_id: Some("req_test".to_owned()), + ..TerminalReference::default() + }); + + let rendered = render_terminal_document( + &document, + &TerminalRenderContext { + verbosity: TerminalVerbosity::Quiet, + ..TerminalRenderContext::default() + }, + ); + + assert_eq!(rendered, "✕ Command failed\n\n Reason missing approval"); + } } diff --git a/tests/target_cli.rs b/tests/target_cli.rs @@ -2846,6 +2846,53 @@ fn terminal_global_presentation_flags_are_deterministic() { } #[test] +fn quiet_terminal_success_output_is_header_only() { + let output = radroots() + .args(["--quiet", "workspace", "get"]) + .output() + .expect("run quiet workspace get"); + + assert!(output.status.success()); + assert!(output.stderr.is_empty()); + let stdout = String::from_utf8(output.stdout).expect("utf8 stdout"); + + assert_eq!(stdout, "✓ Workspace ready\n"); +} + +#[test] +fn quiet_terminal_failure_keeps_reason_and_suppresses_optional_body() { + let output = radroots() + .args(["--quiet", "trade", "event", "watch", "ord_missing"]) + .output() + .expect("run quiet trade event watch"); + + assert_eq!(output.status.code(), Some(3)); + assert!(output.stdout.is_empty()); + let stderr = String::from_utf8(output.stderr).expect("utf8 stderr"); + + assert!(stderr.starts_with("✕ Command failed\n")); + assert!(stderr.contains("Reason relay-backed trade event watch is not implemented")); + assert!(!stderr.contains("State not implemented")); + assert!(!stderr.contains("Next\n")); + assert!(serde_json::from_str::<Value>(&stderr).is_err()); +} + +#[test] +fn quiet_does_not_change_json_output_contract() { + let output = radroots() + .args(["--format", "json", "--quiet", "workspace", "get"]) + .output() + .expect("run quiet json workspace get"); + + assert!(output.status.success()); + assert!(output.stderr.is_empty()); + let value: Value = serde_json::from_slice(&output.stdout).expect("json envelope"); + + assert_eq!(value["operation_id"], "workspace.get"); + assert_eq!(value["output_format"], "json"); +} + +#[test] fn removed_no_color_flag_is_rejected() { let output = radroots() .args(["--no-color", "workspace", "get"])