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}