Skip to main content

padlock_output/
project_summary.rs

1// padlock-output/src/project_summary.rs
2//
3// Renders a project-level health summary designed for large codebases.
4// Shows aggregate score, severity distribution with a bar chart, worst files,
5// and worst structs — all fitting in one terminal screen.
6
7use padlock_core::findings::{Report, Severity, SkippedStruct, StructReport};
8
9/// Input for the project summary renderer.
10pub struct SummaryInput<'a> {
11    pub report: &'a Report,
12    /// Number of worst files and structs to show (default 5).
13    pub top: usize,
14}
15
16/// Render a project health summary to a `String`.
17pub 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    // ── aggregate score (weighted by struct size) ──────────────────────────────
27    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    // ── severity counts ────────────────────────────────────────────────────────
43    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    // ── file scores ───────────────────────────────────────────────────────────
63    // Group structs by source file and compute per-file aggregate score.
64    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    // Sort worst first (lowest score, then most high findings)
100    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    // ── worst structs (by score, then wasted bytes) ───────────────────────────
107    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    // ── render ────────────────────────────────────────────────────────────────
116    let mut out = String::new();
117    let bar_width = 20usize;
118    let divider = "━".repeat(57);
119
120    // Coverage: fraction of the type surface padlock could analyze from source.
121    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    // Header line
130    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    // Severity distribution bar chart
138    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    // Worst files
173    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    // Worst structs
189    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    // Next-step hint
211    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    // Skipped types — count + breakdown only; summary is designed to fit one screen.
218    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// ── tests ─────────────────────────────────────────────────────────────────────
297
298#[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        // Annotate source files for file grouping
307        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        // Grade must be one of A-F
330        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}