Skip to main content

chant/
output.rs

1//! Structured output abstraction for chant.
2//!
3//! Provides a unified interface for outputting messages in different modes:
4//! - Human: Colored emoji-prefixed output for terminal display
5//! - Json: Structured JSON events for programmatic consumption
6//! - Quiet: Only errors are emitted
7//!
8//! The Output struct auto-detects TTY for color support and can be injected
9//! with a custom writer for test capture.
10
11use colored::{Color, Colorize};
12use serde_json::json;
13use std::io::{self, Write};
14use std::sync::{Arc, Mutex};
15
16/// Output mode selection
17#[derive(Debug, Clone, Copy, PartialEq, Eq)]
18pub enum OutputMode {
19    /// Human-readable colored output with emoji prefixes
20    Human,
21    /// JSON-formatted structured output
22    Json,
23    /// Silent mode - only errors
24    Quiet,
25}
26
27/// Output abstraction with mode-aware formatting
28#[derive(Clone)]
29pub struct Output {
30    mode: OutputMode,
31    writer: Arc<Mutex<Box<dyn Write + Send>>>,
32    is_tty: bool,
33}
34
35impl Output {
36    /// Create a new Output writing to stdout
37    pub fn new(mode: OutputMode) -> Self {
38        let is_tty = atty::is(atty::Stream::Stdout);
39        Self {
40            mode,
41            writer: Arc::new(Mutex::new(Box::new(io::stdout()))),
42            is_tty,
43        }
44    }
45
46    /// Create an Output with a custom writer (for testing)
47    pub fn with_writer(mode: OutputMode, writer: Box<dyn Write + Send>) -> Self {
48        Self {
49            mode,
50            writer: Arc::new(Mutex::new(writer)),
51            is_tty: false, // Assume non-TTY for custom writers
52        }
53    }
54
55    /// Output a step message: "→ {msg}" in cyan
56    pub fn step(&self, msg: &str) {
57        match self.mode {
58            OutputMode::Human => {
59                let prefix = if self.is_tty {
60                    "→".cyan().to_string()
61                } else {
62                    "→".to_string()
63                };
64                self.write_line(&format!("{} {}", prefix, msg));
65            }
66            OutputMode::Json => {
67                self.write_json("step", msg, None);
68            }
69            OutputMode::Quiet => {}
70        }
71    }
72
73    /// Output a success message: "✓ {msg}" in green
74    pub fn success(&self, msg: &str) {
75        match self.mode {
76            OutputMode::Human => {
77                let prefix = if self.is_tty {
78                    "✓".green().to_string()
79                } else {
80                    "✓".to_string()
81                };
82                self.write_line(&format!("{} {}", prefix, msg));
83            }
84            OutputMode::Json => {
85                self.write_json("success", msg, None);
86            }
87            OutputMode::Quiet => {}
88        }
89    }
90
91    /// Output a warning message: "⚠ {msg}" in yellow
92    pub fn warn(&self, msg: &str) {
93        match self.mode {
94            OutputMode::Human => {
95                let prefix = if self.is_tty {
96                    "⚠".yellow().to_string()
97                } else {
98                    "⚠".to_string()
99                };
100                self.write_line(&format!("{} {}", prefix, msg));
101            }
102            OutputMode::Json => {
103                self.write_json("warning", msg, None);
104            }
105            OutputMode::Quiet => {}
106        }
107    }
108
109    /// Output an error message: "✗ {msg}" in red
110    pub fn error(&self, msg: &str) {
111        match self.mode {
112            OutputMode::Human => {
113                let prefix = if self.is_tty {
114                    "✗".red().to_string()
115                } else {
116                    "✗".to_string()
117                };
118                self.write_line(&format!("{} {}", prefix, msg));
119            }
120            OutputMode::Json => {
121                self.write_json("error", msg, None);
122            }
123            OutputMode::Quiet => {
124                // Errors always output, even in quiet mode
125                self.write_line(&format!("✗ {}", msg));
126            }
127        }
128    }
129
130    /// Output plain info text (no prefix)
131    pub fn info(&self, msg: &str) {
132        match self.mode {
133            OutputMode::Human => {
134                self.write_line(msg);
135            }
136            OutputMode::Json => {
137                self.write_json("info", msg, None);
138            }
139            OutputMode::Quiet => {}
140        }
141    }
142
143    /// Output detail text (indented, for subordinate info)
144    pub fn detail(&self, msg: &str) {
145        match self.mode {
146            OutputMode::Human => {
147                self.write_line(&format!("  {}", msg));
148            }
149            OutputMode::Json => {
150                self.write_json("detail", msg, None);
151            }
152            OutputMode::Quiet => {}
153        }
154    }
155
156    /// Output a colored message with a custom prefix and color
157    pub fn colored(&self, prefix: &str, msg: &str, color: Color) {
158        match self.mode {
159            OutputMode::Human => {
160                let formatted_prefix = if self.is_tty {
161                    prefix.color(color).to_string()
162                } else {
163                    prefix.to_string()
164                };
165                self.write_line(&format!("{} {}", formatted_prefix, msg));
166            }
167            OutputMode::Json => {
168                self.write_json("message", msg, Some(("prefix", prefix)));
169            }
170            OutputMode::Quiet => {}
171        }
172    }
173
174    /// Output a structured JSON event
175    pub fn json(&self, value: &serde_json::Value) {
176        if let Ok(mut writer) = self.writer.lock() {
177            let _ = writeln!(writer, "{}", value);
178        }
179    }
180
181    /// Write a line to the output
182    fn write_line(&self, line: &str) {
183        if let Ok(mut writer) = self.writer.lock() {
184            let _ = writeln!(writer, "{}", line);
185        }
186    }
187
188    /// Write a JSON-formatted log line
189    fn write_json(&self, level: &str, msg: &str, extra: Option<(&str, &str)>) {
190        if let Ok(mut writer) = self.writer.lock() {
191            let mut obj = json!({
192                "level": level,
193                "msg": msg,
194            });
195
196            if let Some((key, value)) = extra {
197                obj[key] = json!(value);
198            }
199
200            let _ = writeln!(writer, "{}", obj);
201        }
202    }
203
204    /// Get the current output mode
205    pub fn mode(&self) -> OutputMode {
206        self.mode
207    }
208
209    /// Check if running in TTY
210    pub fn is_tty(&self) -> bool {
211        self.is_tty
212    }
213}
214
215#[cfg(test)]
216mod tests {
217    use super::*;
218    use std::sync::{Arc, Mutex};
219
220    // Test-specific writer that wraps Arc<Mutex<Vec<u8>>>
221    struct TestWriter {
222        buffer: Arc<Mutex<Vec<u8>>>,
223    }
224
225    impl TestWriter {
226        fn new() -> (Self, Arc<Mutex<Vec<u8>>>) {
227            let buffer = Arc::new(Mutex::new(Vec::new()));
228            (
229                Self {
230                    buffer: buffer.clone(),
231                },
232                buffer,
233            )
234        }
235    }
236
237    impl Write for TestWriter {
238        fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
239            self.buffer.lock().unwrap().write(buf)
240        }
241
242        fn flush(&mut self) -> io::Result<()> {
243            self.buffer.lock().unwrap().flush()
244        }
245    }
246
247    #[test]
248    fn test_human_mode_output() {
249        let (writer, buffer) = TestWriter::new();
250        let output = Output::with_writer(OutputMode::Human, Box::new(writer));
251
252        output.step("Starting");
253        output.success("Done");
254        output.warn("Warning");
255        output.error("Error");
256        output.info("Info");
257        output.detail("Detail");
258
259        let data = buffer.lock().unwrap();
260        let result = String::from_utf8(data.clone()).unwrap();
261        assert!(result.contains("→ Starting"));
262        assert!(result.contains("✓ Done"));
263        assert!(result.contains("⚠ Warning"));
264        assert!(result.contains("✗ Error"));
265        assert!(result.contains("Info"));
266        assert!(result.contains("  Detail"));
267    }
268
269    #[test]
270    fn test_json_mode_output() {
271        let (writer, buffer) = TestWriter::new();
272        let output = Output::with_writer(OutputMode::Json, Box::new(writer));
273
274        output.step("Starting");
275        output.success("Done");
276
277        let data = buffer.lock().unwrap();
278        let result = String::from_utf8(data.clone()).unwrap();
279        assert!(result.contains(r#""level":"step""#));
280        assert!(result.contains(r#""msg":"Starting""#));
281        assert!(result.contains(r#""level":"success""#));
282        assert!(result.contains(r#""msg":"Done""#));
283    }
284
285    #[test]
286    fn test_quiet_mode_only_errors() {
287        let (writer, buffer) = TestWriter::new();
288        let output = Output::with_writer(OutputMode::Quiet, Box::new(writer));
289
290        output.step("Starting");
291        output.success("Done");
292        output.warn("Warning");
293        output.error("Error");
294        output.info("Info");
295
296        let data = buffer.lock().unwrap();
297        let result = String::from_utf8(data.clone()).unwrap();
298        // Only error should be present
299        assert!(result.contains("✗ Error"));
300        assert!(!result.contains("Starting"));
301        assert!(!result.contains("Done"));
302        assert!(!result.contains("Warning"));
303        assert!(!result.contains("Info"));
304    }
305
306    #[test]
307    fn test_mode_getter() {
308        let output = Output::new(OutputMode::Json);
309        assert_eq!(output.mode(), OutputMode::Json);
310    }
311}