Skip to main content

standout_render/
output.rs

1//! Output mode control: ANSI, plain text, or structured data.
2//!
3//! [`OutputMode`] controls how rendering behaves, from terminal colors to
4//! JSON serialization. This is the mechanism behind the `--output` CLI flag.
5//!
6//! ## Output Mode Categories
7//!
8//! | Category | Modes | Template? | ANSI? |
9//! |----------|-------|-----------|-------|
10//! | Templated | Auto, Term, Text | Yes | Varies |
11//! | Debug | TermDebug | Yes | Tags kept as `[name]...[/name]` |
12//! | Structured | Json, Yaml, Xml, Csv | No — serializes directly | No |
13//!
14//! ## How Modes Are Selected
15//!
16//! 1. Default: `Auto` — detects terminal capabilities at render time
17//! 2. CLI flag: `--output=json` overrides to structured mode
18//! 3. Programmatic: Pass explicit mode to render functions
19//!
20//! ## Auto Mode Resolution
21//!
22//! `Auto` queries terminal color capability via
23//! [`detect_color_capability`](crate::detect_color_capability) (which by
24//! default wraps the `console` crate):
25//! - TTY with color support → behaves like `Term` (ANSI codes applied)
26//! - Piped output or no color support → behaves like `Text` (tags stripped)
27//!
28//! This detection happens at render time, not startup. Tests can override
29//! the result via
30//! [`set_color_capability_detector`](crate::set_color_capability_detector)
31//! — see the [`environment`](crate::environment) module.
32//!
33//! ## Structured Modes
34//!
35//! JSON, YAML, XML, and CSV modes skip template rendering entirely.
36//! Handler data is serialized directly, which means:
37//! - Template content is ignored
38//! - Style tags never apply
39//! - Context injection is skipped
40//!
41//! Use [`render_auto`](crate::render_auto) to automatically dispatch between
42//! templated and structured rendering based on output mode.
43
44use crate::environment::detect_color_capability;
45use std::io::Write;
46
47/// Destination for rendered output.
48///
49/// Determines where the output should be written.
50#[derive(Debug, Clone, PartialEq, Eq)]
51pub enum OutputDestination {
52    /// Write to standard output
53    Stdout,
54    /// Write to a specific file
55    File(std::path::PathBuf),
56}
57
58/// Validates that a file path is safe to write to.
59///
60/// Returns an error if the parent directory doesn't exist.
61fn validate_path(path: &std::path::Path) -> std::io::Result<()> {
62    if let Some(parent) = path.parent() {
63        if !parent.as_os_str().is_empty() && !parent.exists() {
64            return Err(std::io::Error::new(
65                std::io::ErrorKind::NotFound,
66                format!("Parent directory does not exist: {}", parent.display()),
67            ));
68        }
69    }
70    Ok(())
71}
72
73/// Writes text content to the specified destination.
74///
75/// - `Stdout`: Writes to stdout with a newline
76/// - `File`: Writes to the file (overwriting)
77pub fn write_output(content: &str, dest: &OutputDestination) -> std::io::Result<()> {
78    match dest {
79        OutputDestination::Stdout => {
80            // Use println! logic (writeln to stdout)
81            let stdout = std::io::stdout();
82            let mut handle = stdout.lock();
83            writeln!(handle, "{}", content)
84        }
85        OutputDestination::File(path) => {
86            validate_path(path)?;
87            std::fs::write(path, content)
88        }
89    }
90}
91
92/// Writes binary content to the specified destination.
93///
94/// - `Stdout`: Writes raw bytes to stdout
95/// - `File`: Writes to the file (overwriting)
96pub fn write_binary_output(content: &[u8], dest: &OutputDestination) -> std::io::Result<()> {
97    match dest {
98        OutputDestination::Stdout => {
99            let stdout = std::io::stdout();
100            let mut handle = stdout.lock();
101            handle.write_all(content)
102        }
103        OutputDestination::File(path) => {
104            validate_path(path)?;
105            std::fs::write(path, content)
106        }
107    }
108}
109
110/// Controls how output is rendered.
111///
112/// This determines whether ANSI escape codes are included in the output,
113/// or whether to output structured data formats like JSON.
114///
115/// # Variants
116///
117/// - `Auto` - Detect terminal capabilities automatically (default behavior)
118/// - `Term` - Always include ANSI escape codes (for terminal output)
119/// - `Text` - Never include ANSI escape codes (plain text)
120/// - `TermDebug` - Render style names as bracket tags for debugging
121/// - `Json` - Serialize data as JSON (skips template rendering)
122///
123/// # Example
124///
125/// ```rust
126/// use standout_render::{render_with_output, Theme, OutputMode};
127/// use console::Style;
128/// use serde::Serialize;
129///
130/// #[derive(Serialize)]
131/// struct Data { message: String }
132///
133/// let theme = Theme::new().add("ok", Style::new().green());
134/// let data = Data { message: "Hello".into() };
135///
136/// // Auto-detect (default)
137/// let auto = render_with_output(
138///     r#"[ok]{{ message }}[/ok]"#,
139///     &data,
140///     &theme,
141///     OutputMode::Auto,
142/// ).unwrap();
143///
144/// // Force plain text
145/// let plain = render_with_output(
146///     r#"[ok]{{ message }}[/ok]"#,
147///     &data,
148///     &theme,
149///     OutputMode::Text,
150/// ).unwrap();
151/// assert_eq!(plain, "Hello");
152///
153/// // Debug mode - renders bracket tags
154/// let debug = render_with_output(
155///     r#"[ok]{{ message }}[/ok]"#,
156///     &data,
157///     &theme,
158///     OutputMode::TermDebug,
159/// ).unwrap();
160/// assert_eq!(debug, "[ok]Hello[/ok]");
161/// ```
162#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
163pub enum OutputMode {
164    /// Auto-detect terminal capabilities
165    #[default]
166    Auto,
167    /// Always use ANSI escape codes (terminal output)
168    Term,
169    /// Never use ANSI escape codes (plain text)
170    Text,
171    /// Debug mode: render style names as bracket tags `[name]text[/name]`
172    TermDebug,
173    /// Structured output: serialize data as JSON (skips template rendering)
174    Json,
175    /// Structured output: serialize data as YAML (skips template rendering)
176    Yaml,
177    /// Structured output: serialize data as XML (skips template rendering)
178    Xml,
179    /// Structured output: serialize flattened data as CSV (skips template rendering)
180    Csv,
181}
182
183impl OutputMode {
184    /// Resolves the output mode to a concrete decision about whether to use color.
185    ///
186    /// - `Auto` checks terminal capabilities
187    /// - `Term` always returns `true`
188    /// - `Text` always returns `false`
189    /// - `TermDebug` returns `false` (handled specially by apply methods)
190    /// - `Json` returns `false` (structured output, no ANSI codes)
191    pub fn should_use_color(&self) -> bool {
192        match self {
193            OutputMode::Auto => detect_color_capability(),
194            OutputMode::Term => true,
195            OutputMode::Text => false,
196            OutputMode::TermDebug => false, // Handled specially
197            OutputMode::Json => false,      // Structured output
198            OutputMode::Yaml => false,      // Structured output
199            OutputMode::Xml => false,       // Structured output
200            OutputMode::Csv => false,       // Structured output
201        }
202    }
203
204    /// Returns true if this is debug mode (bracket tags instead of ANSI).
205    pub fn is_debug(&self) -> bool {
206        matches!(self, OutputMode::TermDebug)
207    }
208
209    /// Returns true if this is a structured output mode (JSON, etc.).
210    ///
211    /// Structured modes serialize data directly instead of rendering templates.
212    pub fn is_structured(&self) -> bool {
213        matches!(
214            self,
215            OutputMode::Json | OutputMode::Yaml | OutputMode::Xml | OutputMode::Csv
216        )
217    }
218}
219
220#[cfg(test)]
221mod tests {
222    use super::*;
223
224    #[test]
225    fn test_output_mode_term_should_use_color() {
226        assert!(OutputMode::Term.should_use_color());
227    }
228
229    #[test]
230    fn test_output_mode_text_should_not_use_color() {
231        assert!(!OutputMode::Text.should_use_color());
232    }
233
234    #[test]
235    fn test_output_mode_default_is_auto() {
236        assert_eq!(OutputMode::default(), OutputMode::Auto);
237    }
238
239    #[test]
240    fn test_output_mode_term_debug_is_debug() {
241        assert!(OutputMode::TermDebug.is_debug());
242        assert!(!OutputMode::Auto.is_debug());
243        assert!(!OutputMode::Term.is_debug());
244        assert!(!OutputMode::Text.is_debug());
245        assert!(!OutputMode::Json.is_debug());
246    }
247
248    #[test]
249    fn test_output_mode_term_debug_should_not_use_color() {
250        assert!(!OutputMode::TermDebug.should_use_color());
251    }
252
253    #[test]
254    fn test_output_mode_json_should_not_use_color() {
255        assert!(!OutputMode::Json.should_use_color());
256    }
257
258    #[test]
259    fn test_output_mode_json_is_structured() {
260        assert!(OutputMode::Json.is_structured());
261    }
262
263    #[test]
264    fn test_output_mode_non_json_not_structured() {
265        assert!(!OutputMode::Auto.is_structured());
266        assert!(!OutputMode::Term.is_structured());
267        assert!(!OutputMode::Text.is_structured());
268        assert!(!OutputMode::TermDebug.is_structured());
269    }
270
271    #[test]
272    fn test_output_mode_json_not_debug() {
273        assert!(!OutputMode::Json.is_debug());
274    }
275
276    #[test]
277    fn test_write_output_file() {
278        let temp_dir = tempfile::tempdir().unwrap();
279        let file_path = temp_dir.path().join("output.txt");
280        let dest = OutputDestination::File(file_path.clone());
281
282        write_output("hello", &dest).unwrap();
283
284        let content = std::fs::read_to_string(file_path).unwrap();
285        assert_eq!(content, "hello");
286    }
287
288    #[test]
289    fn test_write_output_file_overwrite() {
290        let temp_dir = tempfile::tempdir().unwrap();
291        let file_path = temp_dir.path().join("output.txt");
292        std::fs::write(&file_path, "initial").unwrap();
293
294        let dest = OutputDestination::File(file_path.clone());
295        write_output("new", &dest).unwrap();
296
297        let content = std::fs::read_to_string(file_path).unwrap();
298        assert_eq!(content, "new");
299    }
300
301    #[test]
302    fn test_write_output_binary_file() {
303        let temp_dir = tempfile::tempdir().unwrap();
304        let file_path = temp_dir.path().join("output.bin");
305        let dest = OutputDestination::File(file_path.clone());
306
307        write_binary_output(&[1, 2, 3], &dest).unwrap();
308
309        let content = std::fs::read(&file_path).unwrap();
310        assert_eq!(content, vec![1, 2, 3]);
311    }
312
313    #[test]
314    fn test_write_output_invalid_path() {
315        let temp_dir = tempfile::tempdir().unwrap();
316        let file_path = temp_dir.path().join("missing").join("output.txt");
317        let dest = OutputDestination::File(file_path);
318
319        let result = write_output("hello", &dest);
320        assert!(result.is_err());
321    }
322}