Skip to main content

tirith_core/
output.rs

1use std::io::Write;
2
3use crate::verdict::{Action, Finding, Severity, Verdict};
4
5const SCHEMA_VERSION: u32 = 1;
6
7/// JSON output wrapper with schema version.
8#[derive(serde::Serialize)]
9pub struct JsonOutput<'a> {
10    pub schema_version: u32,
11    pub action: Action,
12    pub findings: &'a [Finding],
13    pub tier_reached: u8,
14    pub bypass_requested: bool,
15    pub bypass_honored: bool,
16    pub interactive_detected: bool,
17    pub policy_path_used: &'a Option<String>,
18    pub timings_ms: &'a crate::verdict::Timings,
19    #[serde(skip_serializing_if = "Option::is_none")]
20    pub urls_extracted_count: Option<usize>,
21}
22
23/// Write verdict as JSON to the given writer.
24pub fn write_json(verdict: &Verdict, mut w: impl Write) -> std::io::Result<()> {
25    let output = JsonOutput {
26        schema_version: SCHEMA_VERSION,
27        action: verdict.action,
28        findings: &verdict.findings,
29        tier_reached: verdict.tier_reached,
30        bypass_requested: verdict.bypass_requested,
31        bypass_honored: verdict.bypass_honored,
32        interactive_detected: verdict.interactive_detected,
33        policy_path_used: &verdict.policy_path_used,
34        timings_ms: &verdict.timings_ms,
35        urls_extracted_count: verdict.urls_extracted_count,
36    };
37    serde_json::to_writer(&mut w, &output)?;
38    writeln!(w)?;
39    Ok(())
40}
41
42/// Write human-readable verdict to stderr.
43pub fn write_human(verdict: &Verdict, mut w: impl Write) -> std::io::Result<()> {
44    if verdict.findings.is_empty() {
45        return Ok(());
46    }
47
48    let action_str = match verdict.action {
49        Action::Allow => return Ok(()),
50        Action::Warn => "WARNING",
51        Action::Block => "BLOCKED",
52    };
53
54    writeln!(w, "tirith: {action_str}")?;
55
56    for finding in &verdict.findings {
57        let severity_color = match finding.severity {
58            Severity::Critical => "\x1b[91m", // bright red
59            Severity::High => "\x1b[31m",     // red
60            Severity::Medium => "\x1b[33m",   // yellow
61            Severity::Low => "\x1b[36m",      // cyan
62        };
63        let reset = "\x1b[0m";
64
65        writeln!(
66            w,
67            "  {}[{}]{} {} — {}",
68            severity_color, finding.severity, reset, finding.rule_id, finding.title
69        )?;
70        writeln!(w, "    {}", finding.description)?;
71    }
72
73    if verdict.action == Action::Block {
74        writeln!(w, "  Set TIRITH=0 to bypass (use with caution)")?;
75    }
76
77    Ok(())
78}
79
80/// Write human-readable output to stderr, respecting TTY detection.
81/// If stderr is not a TTY, strip ANSI colors.
82pub fn write_human_auto(verdict: &Verdict) -> std::io::Result<()> {
83    let stderr = std::io::stderr();
84    let is_tty = is_terminal::is_terminal(&stderr);
85
86    if is_tty {
87        write_human(verdict, stderr.lock())
88    } else {
89        write_human_no_color(verdict, stderr.lock())
90    }
91}
92
93/// Write human-readable output without ANSI colors.
94fn write_human_no_color(verdict: &Verdict, mut w: impl Write) -> std::io::Result<()> {
95    if verdict.findings.is_empty() {
96        return Ok(());
97    }
98
99    let action_str = match verdict.action {
100        Action::Allow => return Ok(()),
101        Action::Warn => "WARNING",
102        Action::Block => "BLOCKED",
103    };
104
105    writeln!(w, "tirith: {action_str}")?;
106
107    for finding in &verdict.findings {
108        writeln!(
109            w,
110            "  [{}] {} — {}",
111            finding.severity, finding.rule_id, finding.title
112        )?;
113        writeln!(w, "    {}", finding.description)?;
114    }
115
116    if verdict.action == Action::Block {
117        writeln!(w, "  Set TIRITH=0 to bypass (use with caution)")?;
118    }
119
120    Ok(())
121}