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::{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}