Skip to main content

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}