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