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}