Skip to main content

sentio_cli/
lib.rs

1use sentio_core::{RuleRegistry, ScanResult, Severity};
2use std::collections::BTreeMap;
3use std::fs;
4use std::io::{self, Write};
5
6pub fn render_human_report<W: Write>(
7    result: &ScanResult,
8    registry: &RuleRegistry,
9    mut writer: W,
10    use_color: bool,
11) -> io::Result<()> {
12    if !result.parse_failures.is_empty() {
13        writeln!(
14            writer,
15            "{}",
16            colorize("==============PARSE FAILURES==============", "1;31", use_color)
17        )?;
18        for failure in &result.parse_failures {
19            writeln!(writer, "{}\n  {}", failure.path, failure.message)?;
20        }
21        writeln!(writer)?;
22    }
23
24    if result.findings.is_empty() {
25        if result.parse_failures.is_empty() {
26            writeln!(writer, "No findings.")?;
27        } else {
28            writeln!(writer, "No findings in successfully parsed files.")?;
29        }
30        return Ok(());
31    }
32
33    for (index, finding) in result.findings.iter().enumerate() {
34        let meta = lookup_metadata(registry, &finding.rule_id);
35        let title = meta.map(|item| item.title).unwrap_or("Unknown rule");
36        let description = meta.map(|item| item.description);
37        let guidance = finding
38            .help
39            .as_deref()
40            .or_else(|| meta.map(|item| item.fix_guidance));
41        let severity = severity_label(finding.severity);
42        let severity_color = severity_ansi(finding.severity);
43        let banner = format!(
44            "==============FINDING {}: {} {}==============",
45            index + 1,
46            finding.rule_id,
47            title
48        );
49
50        writeln!(writer, "{}", colorize(&banner, severity_color, use_color))?;
51        writeln!(
52            writer,
53            "{} {}",
54            colorize("Severity:", "1;37", use_color),
55            colorize(severity, severity_color, use_color)
56        )?;
57        writeln!(
58            writer,
59            "{} {}:{}:{}",
60            colorize("Location:", "1;36", use_color),
61            finding.location.path,
62            finding.location.line,
63            finding.location.column
64        )?;
65        writeln!(writer)?;
66
67        if let Some(description) = description {
68            writeln!(writer, "{}", colorize("Rule:", "1;36", use_color))?;
69            writeln!(writer, "  {description}")?;
70            writeln!(writer)?;
71        }
72
73        writeln!(writer, "{}", colorize("Matched Because:", "1;36", use_color))?;
74        writeln!(writer, "  {}", finding.message)?;
75        writeln!(writer)?;
76
77        writeln!(writer, "{}", colorize("Source:", "1;36", use_color))?;
78        match format_source_excerpt(
79            &finding.location.path,
80            finding.location.line,
81            finding.location.column,
82            2,
83            severity_color,
84            use_color,
85        ) {
86            Some(excerpt) => write!(writer, "{excerpt}")?,
87            None => writeln!(writer, "  Source excerpt unavailable.")?,
88        }
89        writeln!(writer)?;
90
91        if let Some(guidance) = guidance {
92            writeln!(writer, "{}", colorize("Guidance:", "1;36", use_color))?;
93            writeln!(writer, "  {guidance}")?;
94            writeln!(writer)?;
95        }
96    }
97
98    write_summary(&mut writer, result, registry, use_color)?;
99    Ok(())
100}
101
102pub fn format_source_excerpt(
103    path: &str,
104    line: usize,
105    column: usize,
106    radius: usize,
107    highlight_color: &str,
108    use_color: bool,
109) -> Option<String> {
110    let source = fs::read_to_string(path).ok()?;
111    let lines: Vec<&str> = source.lines().collect();
112    if lines.is_empty() {
113        return None;
114    }
115
116    let hit_index = line.saturating_sub(1).min(lines.len().saturating_sub(1));
117    let start = hit_index.saturating_sub(radius);
118    let end = (hit_index + radius).min(lines.len().saturating_sub(1));
119    let width = (end + 1).to_string().len();
120    let mut output = String::new();
121
122    for current in start..=end {
123        let marker = if current == hit_index { '>' } else { ' ' };
124        let source_line = format!(
125            " {marker}{:>width$}| {}\n",
126            current + 1,
127            lines[current],
128            width = width
129        );
130
131        if current == hit_index {
132            if use_color {
133                output.push_str(&colorize(&source_line, highlight_color, true));
134            } else {
135                output.push_str(&source_line);
136            }
137            let caret_indent = " ".repeat(column.saturating_sub(1));
138            let caret_line = format!("  {:>width$}| {caret_indent}^\n", "", width = width);
139            if use_color {
140                output.push_str(&colorize(&caret_line, highlight_color, true));
141            } else {
142                output.push_str(&caret_line);
143            }
144        } else {
145            output.push_str(&source_line);
146        }
147    }
148
149    Some(output)
150}
151
152fn write_summary<W: Write>(
153    writer: &mut W,
154    result: &ScanResult,
155    registry: &RuleRegistry,
156    use_color: bool,
157) -> io::Result<()> {
158    let mut rule_counts: BTreeMap<String, usize> = BTreeMap::new();
159    let mut critical = 0usize;
160    let mut high = 0usize;
161    let mut medium = 0usize;
162    let mut low = 0usize;
163
164    for finding in &result.findings {
165        *rule_counts.entry(finding.rule_id.clone()).or_default() += 1;
166        match finding.severity {
167            Severity::Critical => critical += 1,
168            Severity::High => high += 1,
169            Severity::Medium => medium += 1,
170            Severity::Low => low += 1,
171        }
172    }
173
174    writeln!(writer, "{}", colorize("-------- Summary --------", "1;36", use_color))?;
175    writeln!(writer, "Total findings: {}", result.findings.len())?;
176    writeln!(
177        writer,
178        "{} {}",
179        colorize("Critical:", "1;37", use_color),
180        colorize(&critical.to_string(), "1;31", use_color)
181    )?;
182    writeln!(
183        writer,
184        "{} {}",
185        colorize("High:", "1;37", use_color),
186        colorize(&high.to_string(), "31", use_color)
187    )?;
188    writeln!(
189        writer,
190        "{} {}",
191        colorize("Medium:", "1;37", use_color),
192        colorize(&medium.to_string(), "33", use_color)
193    )?;
194    writeln!(
195        writer,
196        "{} {}",
197        colorize("Low:", "1;37", use_color),
198        colorize(&low.to_string(), "32", use_color)
199    )?;
200    writeln!(writer)?;
201    writeln!(writer, "{}", colorize("By rule:", "1;36", use_color))?;
202
203    for (rule_id, count) in rule_counts {
204        let title = lookup_metadata(registry, &rule_id)
205            .map(|item| item.title)
206            .unwrap_or("Unknown rule");
207        writeln!(writer, "  {count}  {rule_id} {title}")?;
208    }
209
210    Ok(())
211}
212
213pub fn lookup_metadata<'a>(
214    registry: &'a RuleRegistry,
215    rule_id: &str,
216) -> Option<&'a sentio_core::RuleMetadata> {
217    registry
218        .all()
219        .iter()
220        .find(|rule| rule.metadata().id.eq_ignore_ascii_case(rule_id))
221        .map(|rule| rule.metadata())
222}
223
224pub fn severity_label(severity: Severity) -> &'static str {
225    match severity {
226        Severity::Low => "low",
227        Severity::Medium => "medium",
228        Severity::High => "high",
229        Severity::Critical => "critical",
230    }
231}
232
233pub fn severity_ansi(severity: Severity) -> &'static str {
234    match severity {
235        Severity::Critical => "1;31",
236        Severity::High => "31",
237        Severity::Medium => "33",
238        Severity::Low => "32",
239    }
240}
241
242pub fn colorize(text: &str, ansi_code: &str, enabled: bool) -> String {
243    if enabled {
244        format!("\x1b[{ansi_code}m{text}\x1b[0m")
245    } else {
246        text.to_string()
247    }
248}