openlatch_client/cli/output.rs
1//! Output formatting infrastructure for the `openlatch` CLI.
2//!
3//! [`OutputConfig`] is the single source of truth for how any command should
4//! produce output. It captures format (human vs JSON), verbosity, debug, quiet,
5//! and color settings, and provides helper methods that apply the correct output
6//! strategy for each case.
7//!
8//! ## Design rules
9//!
10//! - D-01: `print_step` emits checkmark-prefixed lines in human mode; silent in JSON mode
11//! - D-04: `print_substep` is indented below a parent step (bullet point)
12//! - CLI-11: Progress spinners write to stderr, not stdout
13//! - T-02-04: `--debug` mode never prints tokens or secrets
14
15use crate::cli::color;
16use crate::error::OlError;
17
18/// Output format selection.
19#[derive(Debug, Clone, PartialEq)]
20pub enum OutputFormat {
21 /// Human-readable, colorized if TTY (default)
22 Human,
23 /// Pure JSON — one object per logical result, to stdout
24 Json,
25}
26
27/// Resolved output configuration for a CLI invocation.
28///
29/// Created once at startup via [`crate::cli::build_output_config`] and passed
30/// down through command handlers.
31#[derive(Debug, Clone)]
32pub struct OutputConfig {
33 /// Output format (human or JSON)
34 pub format: OutputFormat,
35 /// Whether verbose output is enabled (includes `--debug`)
36 pub verbose: bool,
37 /// Whether debug output is enabled (superset of verbose)
38 pub debug: bool,
39 /// Whether quiet mode is active (suppress info/step output)
40 pub quiet: bool,
41 /// Whether ANSI color codes are allowed
42 pub color: bool,
43}
44
45impl OutputConfig {
46 /// Print a step completion line in human mode.
47 ///
48 /// Per D-01: each step that completes prints a checkmark prefix + message.
49 /// In JSON mode, this is a no-op (JSON commands emit a single JSON object at end).
50 /// In quiet mode, this is also suppressed.
51 pub fn print_step(&self, message: &str) {
52 if self.format == OutputFormat::Json || self.quiet {
53 return;
54 }
55 let mark = color::checkmark(self.color);
56 eprintln!("{mark} {message}");
57 }
58
59 /// Print an indented substep line in human mode.
60 ///
61 /// Per D-04: substeps are indented bullet points under a parent step.
62 /// Silent in JSON mode and quiet mode.
63 pub fn print_substep(&self, message: &str) {
64 if self.format == OutputFormat::Json || self.quiet {
65 return;
66 }
67 let dot = color::bullet(self.color);
68 eprintln!("{dot} {message}");
69 }
70
71 /// Print a formatted [`OlError`] to stderr.
72 ///
73 /// In human mode: structured multi-line error with OL code, suggestion, and docs URL.
74 /// In JSON mode: JSON object on stderr with `{"error": {...}}`.
75 pub fn print_error(&self, error: &OlError) {
76 match self.format {
77 OutputFormat::Human => {
78 let prefix = color::red("Error:", self.color);
79 eprintln!("{prefix} {} ({})", error.message, error.code);
80 if error.suggestion.is_some() || error.docs_url.is_some() {
81 eprintln!();
82 if let Some(ref s) = error.suggestion {
83 eprintln!(" Suggestion: {s}");
84 }
85 if let Some(ref url) = error.docs_url {
86 eprintln!(" Docs: {url}");
87 }
88 }
89 }
90 OutputFormat::Json => {
91 let json = serde_json::json!({
92 "error": {
93 "code": error.code,
94 "message": error.message,
95 "suggestion": error.suggestion,
96 "docs_url": error.docs_url,
97 }
98 });
99 eprintln!(
100 "{}",
101 serde_json::to_string_pretty(&json).unwrap_or_default()
102 );
103 }
104 }
105 }
106
107 /// Print an informational message to stderr.
108 ///
109 /// Silent in quiet mode and JSON mode.
110 pub fn print_info(&self, message: &str) {
111 if self.quiet || self.format == OutputFormat::Json {
112 return;
113 }
114 eprintln!("{message}");
115 }
116
117 /// Serialize a value as pretty JSON to stdout.
118 ///
119 /// Used by commands to emit their JSON output. Silently skips serialization
120 /// errors rather than panicking (logs a debug message instead).
121 pub fn print_json<T: serde::Serialize>(&self, value: &T) {
122 match serde_json::to_string_pretty(value) {
123 Ok(s) => println!("{s}"),
124 Err(e) => {
125 // SECURITY: Never log raw event content or token values here
126 eprintln!("Error: failed to serialize JSON output: {e}");
127 }
128 }
129 }
130
131 /// Returns true if quiet mode is active.
132 pub fn is_quiet(&self) -> bool {
133 self.quiet
134 }
135
136 /// Create an indicatif progress spinner writing to stderr.
137 ///
138 /// Per CLI-11: progress spinners write to stderr, not stdout, so they don't
139 /// contaminate machine-parseable stdout output.
140 ///
141 /// Returns `None` if quiet mode or JSON mode is active (no spinners in non-interactive
142 /// or machine-readable contexts).
143 pub fn create_spinner(&self, message: &str) -> Option<indicatif::ProgressBar> {
144 if self.quiet || self.format == OutputFormat::Json {
145 return None;
146 }
147
148 // Write spinner to stderr so stdout stays clean for JSON/piped output
149 let pb = indicatif::ProgressBar::new_spinner();
150 pb.set_draw_target(indicatif::ProgressDrawTarget::stderr());
151 pb.set_message(message.to_string());
152 pb.enable_steady_tick(std::time::Duration::from_millis(80));
153 Some(pb)
154 }
155}
156
157#[cfg(test)]
158mod tests {
159 use super::*;
160
161 fn human_config() -> OutputConfig {
162 OutputConfig {
163 format: OutputFormat::Human,
164 verbose: false,
165 debug: false,
166 quiet: false,
167 color: false,
168 }
169 }
170
171 fn json_config() -> OutputConfig {
172 OutputConfig {
173 format: OutputFormat::Json,
174 verbose: false,
175 debug: false,
176 quiet: false,
177 color: false,
178 }
179 }
180
181 fn quiet_config() -> OutputConfig {
182 OutputConfig {
183 format: OutputFormat::Human,
184 verbose: false,
185 debug: false,
186 quiet: true,
187 color: false,
188 }
189 }
190
191 #[test]
192 fn test_is_quiet_true_when_quiet_flag() {
193 let cfg = quiet_config();
194 assert!(cfg.is_quiet());
195 }
196
197 #[test]
198 fn test_is_quiet_false_in_normal_mode() {
199 let cfg = human_config();
200 assert!(!cfg.is_quiet());
201 }
202
203 #[test]
204 fn test_print_json_writes_valid_json() {
205 // Capture stdout via a temporary buffer to verify JSON output
206 // We verify the method doesn't panic with a valid serializable value
207 let cfg = json_config();
208 let value = serde_json::json!({"status": "ok", "version": "0.0.0"});
209 // This should not panic
210 cfg.print_json(&value);
211 }
212
213 #[test]
214 fn test_create_spinner_returns_none_in_json_mode() {
215 let cfg = json_config();
216 let spinner = cfg.create_spinner("doing work...");
217 assert!(spinner.is_none(), "Spinner should be None in JSON mode");
218 }
219
220 #[test]
221 fn test_create_spinner_returns_none_in_quiet_mode() {
222 let cfg = quiet_config();
223 let spinner = cfg.create_spinner("doing work...");
224 assert!(spinner.is_none(), "Spinner should be None in quiet mode");
225 }
226
227 #[test]
228 fn test_output_format_equality() {
229 assert_eq!(OutputFormat::Human, OutputFormat::Human);
230 assert_eq!(OutputFormat::Json, OutputFormat::Json);
231 assert_ne!(OutputFormat::Human, OutputFormat::Json);
232 }
233
234 /// Verify print_error in JSON mode produces valid JSON structure.
235 ///
236 /// We call print_error with a known error and verify the logic branches work
237 /// without panicking (actual stderr capture requires process-level work).
238 #[test]
239 fn test_print_error_json_mode_no_panic() {
240 let cfg = json_config();
241 let err = OlError::new(crate::error::ERR_UNKNOWN_AGENT, "Unknown agent")
242 .with_suggestion("Use a known agent type")
243 .with_docs("https://docs.openlatch.ai/errors/OL-1001");
244 // Should not panic
245 cfg.print_error(&err);
246 }
247
248 #[test]
249 fn test_print_error_human_mode_no_panic() {
250 let cfg = human_config();
251 let err = OlError::new(crate::error::ERR_PORT_IN_USE, "Port 7443 in use");
252 // Should not panic
253 cfg.print_error(&err);
254 }
255
256 /// Verify that print_step is a no-op in JSON mode by checking no panic occurs.
257 /// The actual "no stdout write" behavior is enforced by the implementation
258 /// (returns early before any write in JSON mode).
259 #[test]
260 fn test_print_step_json_mode_is_silent() {
261 let cfg = json_config();
262 // Should not panic and should write nothing to stdout
263 cfg.print_step("Installing hooks");
264 }
265}