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