Skip to main content

agentshield/output/
console.rs

1use crate::rules::policy::PolicyVerdict;
2use crate::rules::{Finding, Severity};
3
4// ANSI color codes
5const RESET: &str = "\x1b[0m";
6const BOLD: &str = "\x1b[1m";
7const DIM: &str = "\x1b[2m";
8const RED: &str = "\x1b[31m";
9const GREEN: &str = "\x1b[32m";
10const YELLOW: &str = "\x1b[33m";
11const BLUE: &str = "\x1b[34m";
12const MAGENTA: &str = "\x1b[35m";
13const CYAN: &str = "\x1b[36m";
14
15/// Render findings as colored console output, grouped by severity then file path.
16pub fn render(findings: &[Finding], verdict: &PolicyVerdict) -> String {
17    let use_color = std::env::var("NO_COLOR").is_err();
18    let mut output = String::new();
19
20    if findings.is_empty() {
21        if use_color {
22            output.push_str(&format!(
23                "\n  {GREEN}{BOLD}No security findings detected.{RESET}\n\n"
24            ));
25        } else {
26            output.push_str("\n  No security findings detected.\n\n");
27        }
28        return output;
29    }
30
31    // Sort by severity (critical first), then by file path
32    let mut sorted: Vec<&Finding> = findings.iter().collect();
33    sorted.sort_by(|a, b| {
34        b.severity.cmp(&a.severity).then_with(|| {
35            let a_file = a.location.as_ref().map(|l| &l.file);
36            let b_file = b.location.as_ref().map(|l| &l.file);
37            a_file.cmp(&b_file)
38        })
39    });
40
41    output.push_str(&format!(
42        "\n  {bold}{} finding(s) detected:{reset}\n\n",
43        findings.len(),
44        bold = if use_color { BOLD } else { "" },
45        reset = if use_color { RESET } else { "" },
46    ));
47
48    for finding in &sorted {
49        let severity_tag = if use_color {
50            match finding.severity {
51                Severity::Critical => format!("{RED}{BOLD}[CRITICAL]{RESET}"),
52                Severity::High => format!("{MAGENTA}{BOLD}[HIGH]    {RESET}"),
53                Severity::Medium => format!("{YELLOW}{BOLD}[MEDIUM]  {RESET}"),
54                Severity::Low => format!("{BLUE}[LOW]     {RESET}"),
55                Severity::Info => format!("{DIM}[INFO]    {RESET}"),
56            }
57        } else {
58            match finding.severity {
59                Severity::Critical => "[CRITICAL]".into(),
60                Severity::High => "[HIGH]    ".into(),
61                Severity::Medium => "[MEDIUM]  ".into(),
62                Severity::Low => "[LOW]     ".into(),
63                Severity::Info => "[INFO]    ".into(),
64            }
65        };
66
67        let location = finding
68            .location
69            .as_ref()
70            .map(|l| format!("{}:{}", l.file.display(), l.line))
71            .unwrap_or_else(|| "-".into());
72
73        output.push_str(&format!(
74            "  {} {bold}{}{reset} {}\n",
75            severity_tag,
76            finding.rule_id,
77            finding.message,
78            bold = if use_color { BOLD } else { "" },
79            reset = if use_color { RESET } else { "" },
80        ));
81        output.push_str(&format!(
82            "           {dim}at {}{reset}\n",
83            location,
84            dim = if use_color { DIM } else { "" },
85            reset = if use_color { RESET } else { "" },
86        ));
87        if let Some(remediation) = &finding.remediation {
88            output.push_str(&format!(
89                "           {cyan}fix: {}{reset}\n",
90                remediation,
91                cyan = if use_color { CYAN } else { "" },
92                reset = if use_color { RESET } else { "" },
93            ));
94        }
95        output.push('\n');
96    }
97
98    // Verdict
99    let (status, status_color) = if verdict.pass {
100        ("PASS", if use_color { GREEN } else { "" })
101    } else {
102        ("FAIL", if use_color { RED } else { "" })
103    };
104    output.push_str(&format!(
105        "  Result: {sc}{bold}{}{reset} (threshold: {}, highest: {})\n\n",
106        status,
107        verdict.fail_threshold,
108        verdict
109            .highest_severity
110            .map(|s| s.to_string())
111            .unwrap_or_else(|| "none".into()),
112        sc = status_color,
113        bold = if use_color { BOLD } else { "" },
114        reset = if use_color { RESET } else { "" },
115    ));
116
117    output
118}