Skip to main content

sbom_tools/pipeline/
output.rs

1//! Output handling for SBOM reports.
2//!
3//! Provides utilities for auto-detecting output format and writing reports.
4
5use crate::reports::ReportFormat;
6use anyhow::{Context, Result};
7use std::io::IsTerminal;
8use std::path::PathBuf;
9
10/// Target for output - either stdout or a file
11#[derive(Debug, Clone)]
12pub enum OutputTarget {
13    /// Write to stdout
14    Stdout,
15    /// Write to a file
16    File(PathBuf),
17}
18
19impl OutputTarget {
20    /// Create output target from optional path
21    pub fn from_option(path: Option<PathBuf>) -> Self {
22        path.map_or(Self::Stdout, Self::File)
23    }
24
25    /// Check if output is to a terminal
26    #[must_use]
27    pub fn is_terminal(&self) -> bool {
28        matches!(self, Self::Stdout) && std::io::stdout().is_terminal()
29    }
30}
31
32/// Auto-detect the output format based on TTY and output target
33///
34/// Returns TUI for interactive terminals (stdout to TTY),
35/// otherwise returns Summary for non-interactive contexts.
36#[must_use]
37pub fn auto_detect_format(format: ReportFormat, target: &OutputTarget) -> ReportFormat {
38    match format {
39        ReportFormat::Auto => {
40            if target.is_terminal() {
41                ReportFormat::Tui
42            } else {
43                ReportFormat::Summary
44            }
45        }
46        other => other,
47    }
48}
49
50/// Determine if color should be used based on flags, environment, and
51/// whether stdout is actually a terminal — piped/redirected output must not
52/// receive ANSI escapes.
53#[must_use]
54pub fn should_use_color(no_color_flag: bool) -> bool {
55    use std::io::IsTerminal;
56    // Per the NO_COLOR convention the variable counts only when present AND
57    // non-empty, which is what the log setup and the TUI theme already do.
58    // `var(..).is_err()` treated `NO_COLOR=` as "set" and stripped color from
59    // reports while the same invocation kept it in logs and the TUI.
60    let no_color_env = std::env::var_os("NO_COLOR").is_some_and(|v| !v.is_empty());
61    !no_color_flag && !no_color_env && std::io::stdout().is_terminal()
62}
63
64/// Write output to the target (stdout or file)
65pub fn write_output(content: &str, target: &OutputTarget, quiet: bool) -> Result<()> {
66    match target {
67        OutputTarget::Stdout => {
68            println!("{content}");
69            Ok(())
70        }
71        OutputTarget::File(path) => {
72            std::fs::write(path, content)
73                .with_context(|| format!("Failed to write output to {}", path.display()))?;
74            if !quiet {
75                tracing::info!("Report written to {:?}", path);
76            }
77            Ok(())
78        }
79    }
80}
81
82#[cfg(test)]
83mod tests {
84    use super::*;
85
86    #[test]
87    fn test_output_target_from_option_none() {
88        let target = OutputTarget::from_option(None);
89        assert!(matches!(target, OutputTarget::Stdout));
90    }
91
92    #[test]
93    fn test_output_target_from_option_some() {
94        let path = PathBuf::from("/tmp/test.json");
95        let target = OutputTarget::from_option(Some(path.clone()));
96        match target {
97            OutputTarget::File(p) => assert_eq!(p, path),
98            _ => panic!("Expected File variant"),
99        }
100    }
101
102    #[test]
103    fn test_auto_detect_format_non_auto() {
104        let target = OutputTarget::Stdout;
105        assert_eq!(
106            auto_detect_format(ReportFormat::Json, &target),
107            ReportFormat::Json
108        );
109        assert_eq!(
110            auto_detect_format(ReportFormat::Sarif, &target),
111            ReportFormat::Sarif
112        );
113    }
114
115    #[test]
116    fn test_auto_detect_format_file_target() {
117        let target = OutputTarget::File(PathBuf::from("/tmp/test.json"));
118        // File targets are never terminals, so Auto -> Summary
119        assert_eq!(
120            auto_detect_format(ReportFormat::Auto, &target),
121            ReportFormat::Summary
122        );
123    }
124
125    #[test]
126    fn test_should_use_color_with_flag() {
127        assert!(!should_use_color(true));
128    }
129
130    #[test]
131    fn test_should_use_color_without_flag() {
132        // Depends on NO_COLOR and whether the test harness stdout is a
133        // terminal — under `cargo test` stdout is captured (not a TTY), so
134        // color must be off regardless of env. The expectation mirrors the
135        // convention the function implements (present AND non-empty), rather
136        // than re-deriving it with `var(..).is_err()`, which would disagree
137        // whenever the suite runs with an empty `NO_COLOR=`.
138        use std::io::IsTerminal;
139        let no_color_env = std::env::var_os("NO_COLOR").is_some_and(|v| !v.is_empty());
140        let expected = !no_color_env && std::io::stdout().is_terminal();
141        assert_eq!(should_use_color(false), expected);
142    }
143}