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}