1use padlock_core::findings::{Report, Severity, SkippedStruct, StructReport};
8
9pub struct SummaryInput<'a> {
11 pub report: &'a Report,
12 pub top: usize,
14}
15
16pub fn render_summary(input: &SummaryInput<'_>) -> String {
18 let report = input.report;
19 let top = input.top.max(1);
20
21 let total = report.structs.len();
22 if total == 0 {
23 return "No structs found.\n".to_string();
24 }
25
26 let total_weight: f64 = report
28 .structs
29 .iter()
30 .map(|s| s.total_size as f64)
31 .sum::<f64>()
32 .max(1.0);
33 let weighted_score: f64 = report
34 .structs
35 .iter()
36 .map(|s| s.score * s.total_size as f64)
37 .sum::<f64>()
38 / total_weight;
39 let score_int = weighted_score.round() as usize;
40 let grade = letter_grade(score_int);
41
42 let mut n_high = 0usize;
44 let mut n_medium = 0usize;
45 let mut n_low = 0usize;
46 let mut n_clean = 0usize;
47
48 for sr in &report.structs {
49 let worst = sr
50 .findings
51 .iter()
52 .map(|f| f.severity())
53 .max_by_key(|s| severity_rank(s));
54 match worst {
55 Some(s) if *s == Severity::High => n_high += 1,
56 Some(s) if *s == Severity::Medium => n_medium += 1,
57 Some(_) => n_low += 1,
58 None => n_clean += 1,
59 }
60 }
61
62 let mut file_map: std::collections::HashMap<String, Vec<&StructReport>> =
65 std::collections::HashMap::new();
66 for sr in &report.structs {
67 let file = sr
68 .source_file
69 .clone()
70 .unwrap_or_else(|| "<unknown>".to_string());
71 file_map.entry(file).or_default().push(sr);
72 }
73
74 let mut file_scores: Vec<(String, f64, usize, usize)> = file_map
75 .iter()
76 .map(|(file, structs)| {
77 let w: f64 = structs
78 .iter()
79 .map(|s| s.total_size as f64)
80 .sum::<f64>()
81 .max(1.0);
82 let score = structs
83 .iter()
84 .map(|s| s.score * s.total_size as f64)
85 .sum::<f64>()
86 / w;
87 let high_count = structs
88 .iter()
89 .filter(|s| {
90 s.findings
91 .iter()
92 .any(|f| matches!(f.severity(), Severity::High))
93 })
94 .count();
95 let wasted: usize = structs.iter().map(|s| s.wasted_bytes).sum();
96 (file.clone(), score, high_count, wasted)
97 })
98 .collect();
99 file_scores.sort_by(|a, b| {
101 a.1.partial_cmp(&b.1)
102 .unwrap_or(std::cmp::Ordering::Equal)
103 .then(b.2.cmp(&a.2))
104 });
105
106 let mut worst_structs: Vec<&StructReport> = report.structs.iter().collect();
108 worst_structs.sort_by(|a, b| {
109 a.score
110 .partial_cmp(&b.score)
111 .unwrap_or(std::cmp::Ordering::Equal)
112 .then(b.wasted_bytes.cmp(&a.wasted_bytes))
113 });
114
115 let mut out = String::new();
117 let bar_width = 20usize;
118 let divider = "━".repeat(57);
119
120 let coverage_part = if !report.skipped.is_empty() {
122 let total_seen = total + report.skipped.len();
123 let pct = total * 100 / total_seen;
124 format!(" · {pct}% coverage")
125 } else {
126 String::new()
127 };
128
129 out.push_str(&format!(
131 "{divider}\n Score {score_int} / 100 {grade} {} structs · {} files · {}B wasted{coverage_part}\n{divider}\n\n",
132 total,
133 file_scores.len(),
134 report.total_wasted_bytes
135 ));
136
137 let bar = |n: usize| {
139 let filled = (n * bar_width)
140 .checked_div(total)
141 .unwrap_or(0)
142 .min(bar_width);
143 let empty = bar_width - filled;
144 format!("{}{}", "█".repeat(filled), "░".repeat(empty))
145 };
146
147 out.push_str(&format!(
148 " 🔴 High {} {:>4} ({:.0}%)\n",
149 bar(n_high),
150 n_high,
151 pct(n_high, total)
152 ));
153 out.push_str(&format!(
154 " 🟡 Medium {} {:>4} ({:.0}%)\n",
155 bar(n_medium),
156 n_medium,
157 pct(n_medium, total)
158 ));
159 out.push_str(&format!(
160 " 🔵 Low {} {:>4} ({:.0}%)\n",
161 bar(n_low),
162 n_low,
163 pct(n_low, total)
164 ));
165 out.push_str(&format!(
166 " ✅ Clean {} {:>4} ({:.0}%)\n",
167 bar(n_clean),
168 n_clean,
169 pct(n_clean, total)
170 ));
171
172 if !file_scores.is_empty() {
174 out.push_str(&format!(
175 "\n {:<44} {:>5} {:>5} {}\n",
176 "Worst files", "score", "High", "wasted"
177 ));
178 out.push_str(&format!(" {}\n", "─".repeat(68)));
179 for (file, score, high, wasted) in file_scores.iter().take(top) {
180 let name = truncate(file, 44);
181 out.push_str(&format!(
182 " {:<44} {:>5.0} {:>5} {}B\n",
183 name, score, high, wasted
184 ));
185 }
186 }
187
188 if !worst_structs.is_empty() {
190 out.push_str(&format!(
191 "\n {:<30} {:>5} {}\n",
192 "Worst structs", "score", "location"
193 ));
194 out.push_str(&format!(" {}\n", "─".repeat(68)));
195 for sr in worst_structs.iter().take(top) {
196 let loc = match (&sr.source_file, sr.source_line) {
197 (Some(f), Some(l)) => format!("{f}:{l}"),
198 (Some(f), None) => f.clone(),
199 _ => String::new(),
200 };
201 out.push_str(&format!(
202 " {:<30} {:>5.0} {}\n",
203 truncate(&sr.struct_name, 30),
204 sr.score,
205 loc
206 ));
207 }
208 }
209
210 if let Some((worst_file, _, _, _)) = file_scores.first() {
212 out.push_str(&format!(
213 "\n Run `padlock analyze {worst_file}` for full detail.\n"
214 ));
215 }
216
217 if !report.skipped.is_empty() {
219 let breakdown = skipped_breakdown(&report.skipped);
220 out.push_str(&format!(
221 "\n note: {} type{} skipped: {} (use `padlock analyze --show-skipped` for full list)\n",
222 report.skipped.len(),
223 if report.skipped.len() == 1 { "" } else { "s" },
224 breakdown,
225 ));
226 }
227
228 out
229}
230
231fn skipped_breakdown(skipped: &[SkippedStruct]) -> String {
232 let mut counts: std::collections::BTreeMap<&str, usize> = std::collections::BTreeMap::new();
233 for s in skipped {
234 let cat = if s.reason.starts_with("C++ template") {
235 "C++ template"
236 } else if s.reason.starts_with("comptime-generic") {
237 "Zig comptime-generic"
238 } else if s.reason.starts_with("generic enum") {
239 "Rust generic enum"
240 } else if s.reason.starts_with("generic struct") {
241 if s.source_file
242 .as_deref()
243 .map(|f| f.ends_with(".go"))
244 .unwrap_or(false)
245 {
246 "Go generic"
247 } else {
248 "Rust generic"
249 }
250 } else {
251 "other"
252 };
253 *counts.entry(cat).or_insert(0) += 1;
254 }
255 counts
256 .iter()
257 .map(|(cat, cnt)| format!("{cnt} {cat}"))
258 .collect::<Vec<_>>()
259 .join(", ")
260}
261
262fn letter_grade(score: usize) -> &'static str {
263 match score {
264 90..=100 => "A",
265 80..=89 => "B",
266 70..=79 => "C",
267 60..=69 => "D",
268 _ => "F",
269 }
270}
271
272fn severity_rank(s: &Severity) -> u8 {
273 match s {
274 Severity::Low => 1,
275 Severity::Medium => 2,
276 Severity::High => 3,
277 }
278}
279
280fn pct(n: usize, total: usize) -> f64 {
281 if total == 0 {
282 0.0
283 } else {
284 n as f64 / total as f64 * 100.0
285 }
286}
287
288fn truncate(s: &str, max: usize) -> String {
289 if s.len() <= max {
290 s.to_string()
291 } else {
292 format!("{}…", &s[..max - 1])
293 }
294}
295
296#[cfg(test)]
299mod tests {
300 use super::*;
301 use padlock_core::findings::Report;
302 use padlock_core::ir::test_fixtures::{connection_layout, packed_layout};
303
304 fn make_report() -> Report {
305 let mut r = Report::from_layouts(&[connection_layout(), packed_layout()]);
306 r.structs[0].source_file = Some("src/conn.rs".to_string());
308 r.structs[1].source_file = Some("src/packed.rs".to_string());
309 r
310 }
311
312 #[test]
313 fn summary_contains_score() {
314 let report = make_report();
315 let out = render_summary(&SummaryInput {
316 report: &report,
317 top: 5,
318 });
319 assert!(out.contains("/ 100"), "must show score out of 100");
320 }
321
322 #[test]
323 fn summary_contains_grade() {
324 let report = make_report();
325 let out = render_summary(&SummaryInput {
326 report: &report,
327 top: 5,
328 });
329 assert!(
331 out.contains('A')
332 || out.contains('B')
333 || out.contains('C')
334 || out.contains('D')
335 || out.contains('F'),
336 "must contain a letter grade"
337 );
338 }
339
340 #[test]
341 fn summary_contains_severity_bars() {
342 let report = make_report();
343 let out = render_summary(&SummaryInput {
344 report: &report,
345 top: 5,
346 });
347 assert!(out.contains("High"), "must show High severity");
348 assert!(out.contains("Medium"), "must show Medium severity");
349 assert!(out.contains("Clean"), "must show Clean count");
350 }
351
352 #[test]
353 fn summary_contains_worst_file() {
354 let report = make_report();
355 let out = render_summary(&SummaryInput {
356 report: &report,
357 top: 5,
358 });
359 assert!(
360 out.contains("src/conn.rs") || out.contains("src/packed.rs"),
361 "must show at least one file"
362 );
363 }
364
365 #[test]
366 fn summary_contains_struct_names() {
367 let report = make_report();
368 let out = render_summary(&SummaryInput {
369 report: &report,
370 top: 5,
371 });
372 assert!(out.contains("Connection") || out.contains("Packed"));
373 }
374
375 #[test]
376 fn summary_empty_report() {
377 let report = Report::from_layouts(&[]);
378 let out = render_summary(&SummaryInput {
379 report: &report,
380 top: 5,
381 });
382 assert!(out.contains("No structs"));
383 }
384
385 #[test]
386 fn letter_grade_boundaries() {
387 assert_eq!(letter_grade(100), "A");
388 assert_eq!(letter_grade(90), "A");
389 assert_eq!(letter_grade(89), "B");
390 assert_eq!(letter_grade(80), "B");
391 assert_eq!(letter_grade(79), "C");
392 assert_eq!(letter_grade(70), "C");
393 assert_eq!(letter_grade(69), "D");
394 assert_eq!(letter_grade(60), "D");
395 assert_eq!(letter_grade(59), "F");
396 assert_eq!(letter_grade(0), "F");
397 }
398}