Skip to main content

padlock_output/
summary.rs

1// padlock-output/src/summary.rs
2
3use padlock_core::findings::{Finding, Report, Severity, SkippedStruct, StructReport};
4
5/// Render a full report as a human-readable multi-line string.
6///
7/// `show_skipped`: when `true`, list every skipped type; when `false`, show only
8/// a count + category breakdown with a hint to pass `--show-skipped`.
9pub fn render_report(report: &Report, show_skipped: bool) -> String {
10    let mut out = String::new();
11    let multi_file = report.analyzed_paths.len() > 1;
12
13    // Coverage suffix: shown when any types were skipped so the engineer knows
14    // what fraction of the type surface padlock could actually see.
15    let coverage_suffix = if !report.skipped.is_empty() {
16        let total = report.total_structs + report.skipped.len();
17        let pct = report.total_structs * 100 / total;
18        if pct < 70 {
19            format!(
20                " [{} of {} types, {}% source coverage — consider binary analysis for the rest]",
21                report.total_structs, total, pct
22            )
23        } else {
24            format!(
25                " [{} of {} types, {}% source coverage]",
26                report.total_structs, total, pct
27            )
28        }
29    } else {
30        String::new()
31    };
32
33    // Header line
34    if multi_file {
35        out.push_str(&format!("Analyzed {} files, ", report.analyzed_paths.len()));
36        out.push_str(&format!(
37            "{} struct{}",
38            report.total_structs,
39            if report.total_structs == 1 { "" } else { "s" }
40        ));
41    } else {
42        out.push_str(&format!(
43            "Analyzed {} struct{}",
44            report.total_structs,
45            if report.total_structs == 1 { "" } else { "s" }
46        ));
47    }
48
49    if report.total_wasted_bytes > 0 {
50        out.push_str(&format!(
51            " — {} bytes wasted across all structs{}\n",
52            report.total_wasted_bytes, coverage_suffix
53        ));
54    } else {
55        out.push_str(&format!(" — no padding waste found{}\n", coverage_suffix));
56    }
57
58    if multi_file {
59        render_grouped(&mut out, report);
60    } else {
61        out.push('\n');
62        for sr in &report.structs {
63            out.push_str(&render_struct_with_embed(sr, true, &report.embedded_in));
64            out.push('\n');
65        }
66    }
67
68    if !report.skipped.is_empty() {
69        out.push_str(&render_skipped(&report.skipped, show_skipped));
70    }
71
72    out
73}
74
75/// Render structs grouped by source file with a `── file ──` separator header.
76fn render_grouped(out: &mut String, report: &Report) {
77    // Collect distinct source files in encounter order, preserving struct order.
78    let mut file_order: Vec<Option<String>> = Vec::new();
79    let mut groups: std::collections::HashMap<Option<String>, Vec<&StructReport>> =
80        std::collections::HashMap::new();
81
82    for sr in &report.structs {
83        let key = sr.source_file.clone();
84        if !groups.contains_key(&key) {
85            file_order.push(key.clone());
86        }
87        groups.entry(key).or_default().push(sr);
88    }
89
90    for key in &file_order {
91        // File separator header
92        let label = key.as_deref().unwrap_or("<binary>");
93        let bar = "─".repeat(60usize.saturating_sub(label.len() + 4));
94        out.push_str(&format!("\n── {label} {bar}\n\n"));
95
96        if let Some(structs) = groups.get(key) {
97            for sr in structs {
98                // Within a group, suppress the filename (show only line number).
99                out.push_str(&render_struct_with_embed(sr, false, &report.embedded_in));
100                out.push('\n');
101            }
102        }
103    }
104}
105
106/// Render one struct report (public API, no embedding hints).
107///
108/// `show_filename`: when `true`, the `source_file` is included in the location hint;
109/// when `false` (inside a file-grouped section), only the line number is shown.
110pub fn render_struct(sr: &StructReport, show_filename: bool) -> String {
111    render_struct_with_embed(sr, show_filename, &std::collections::HashMap::new())
112}
113
114/// Render one struct report with optional embedding-context hints.
115fn render_struct_with_embed(
116    sr: &StructReport,
117    show_filename: bool,
118    embedded_in: &std::collections::HashMap<String, Vec<String>>,
119) -> String {
120    let mut out = String::new();
121
122    let score_label = match sr.score as u32 {
123        90..=100 => "✓",
124        60..=89 => "~",
125        _ => "✗",
126    };
127
128    let location = if show_filename {
129        match (&sr.source_file, sr.source_line) {
130            (Some(f), Some(l)) => format!(" ({}:{})", f, l),
131            (Some(f), None) => format!(" ({})", f),
132            _ => String::new(),
133        }
134    } else {
135        match sr.source_line {
136            Some(l) => format!(" :{l}"),
137            None => String::new(),
138        }
139    };
140
141    let holes_hint = if sr.num_holes > 0 {
142        format!("  holes={}", sr.num_holes)
143    } else {
144        String::new()
145    };
146
147    out.push_str(&format!(
148        "[{score_label}] {name}{location}  {size}B  fields={fields}{holes}  score={score:.0}\n",
149        name = sr.struct_name,
150        size = sr.total_size,
151        fields = sr.num_fields,
152        holes = holes_hint,
153        score = sr.score,
154    ));
155
156    for finding in &sr.findings {
157        out.push_str(&format!("    {}\n", render_finding(finding)));
158    }
159
160    if sr.findings.is_empty() {
161        out.push_str("    (no issues found)\n");
162    }
163
164    if sr.is_repr_rust && !sr.findings.is_empty() {
165        out.push_str(
166            "    note: repr(Rust) — compiler may reorder fields; \
167             use binary analysis for actual layout\n",
168        );
169    }
170
171    if !sr.uncertain_fields.is_empty() {
172        let fields = sr.uncertain_fields.join(", ");
173        out.push_str(&format!(
174            "    note: uncertain field size(s): {fields} — \
175             use binary analysis (DWARF/BTF) or provide type info for accurate results\n"
176        ));
177    }
178
179    // Embedding context: if this struct has padding waste and is embedded in
180    // other structs, note that fixing this struct would shrink those too.
181    let has_waste = sr
182        .findings
183        .iter()
184        .any(|f| matches!(f, Finding::PaddingWaste { .. }));
185    if has_waste
186        && let Some(outer_structs) = embedded_in.get(&sr.struct_name)
187        && !outer_structs.is_empty()
188    {
189        let mut names = outer_structs.clone();
190        names.sort();
191        names.dedup();
192        out.push_str(&format!(
193            "    note: embedded in [{}] — fixing layout would reduce size of each\n",
194            names.join(", ")
195        ));
196    }
197
198    out
199}
200
201const SKIPPED_INLINE_LIMIT: usize = 10;
202
203/// Categorise a skipped entry into a short human-readable label.
204fn skip_category(s: &SkippedStruct) -> &'static str {
205    let r = s.reason.as_str();
206    if r.starts_with("C++ template") {
207        "C++ template"
208    } else if r.starts_with("comptime-generic") {
209        "Zig comptime-generic"
210    } else if r.starts_with("generic enum") {
211        "Rust generic enum"
212    } else if r.starts_with("generic struct") {
213        let is_go = s
214            .source_file
215            .as_deref()
216            .map(|f| f.ends_with(".go"))
217            .unwrap_or(false);
218        if is_go { "Go generic" } else { "Rust generic" }
219    } else {
220        "other"
221    }
222}
223
224/// Render the skipped-types section.
225///
226/// Always shows a count + breakdown by category.  When `show_all` is false,
227/// lists at most `SKIPPED_INLINE_LIMIT` entries and appends a hint; when true,
228/// lists all entries.
229fn render_skipped(skipped: &[SkippedStruct], show_all: bool) -> String {
230    let n = skipped.len();
231    let mut out = String::new();
232
233    // Build category counts (sorted for stable output).
234    let mut counts: std::collections::BTreeMap<&str, usize> = std::collections::BTreeMap::new();
235    for s in skipped {
236        *counts.entry(skip_category(s)).or_insert(0) += 1;
237    }
238    let breakdown: Vec<String> = counts
239        .iter()
240        .map(|(cat, cnt)| format!("{cnt} {cat}"))
241        .collect();
242
243    out.push_str(&format!(
244        "note: {n} type{} skipped (layout cannot be determined from source alone): {}\n",
245        if n == 1 { "" } else { "s" },
246        breakdown.join(", "),
247    ));
248
249    let limit = if show_all { n } else { SKIPPED_INLINE_LIMIT };
250    for s in skipped.iter().take(limit) {
251        let loc = s
252            .source_file
253            .as_deref()
254            .map(|f| format!(" ({f})"))
255            .unwrap_or_default();
256        out.push_str(&format!("  skipped '{}'{loc}: {}\n", s.name, s.reason));
257    }
258
259    if !show_all && n > SKIPPED_INLINE_LIMIT {
260        out.push_str(&format!(
261            "  … and {} more (use --show-skipped to list all, or --json for full data)\n",
262            n - SKIPPED_INLINE_LIMIT,
263        ));
264    }
265
266    out
267}
268
269fn render_finding(f: &Finding) -> String {
270    let sev = match f.severity() {
271        Severity::High => "HIGH",
272        Severity::Medium => "MEDIUM",
273        Severity::Low => "LOW",
274    };
275    match f {
276        Finding::PaddingWaste {
277            wasted_bytes,
278            waste_pct,
279            gaps,
280            ..
281        } => {
282            // Show up to 3 gap locations so the engineer knows exactly where to look.
283            let gap_detail: Vec<String> = gaps
284                .iter()
285                .take(3)
286                .map(|g| {
287                    format!(
288                        "{}B after `{}` (offset {})",
289                        g.bytes, g.after_field, g.at_offset
290                    )
291                })
292                .collect();
293            let detail = if gaps.len() > 3 {
294                format!("{} … and {} more", gap_detail.join(", "), gaps.len() - 3)
295            } else {
296                gap_detail.join(", ")
297            };
298            format!("[{sev}] Padding waste: {wasted_bytes}B ({waste_pct:.0}%) — {detail}")
299        }
300        Finding::ReorderSuggestion {
301            savings,
302            original_size,
303            optimized_size,
304            suggested_order,
305            severity,
306            ..
307        } => {
308            let base = format!(
309                "[{sev}] Reorder fields: {original_size}B → {optimized_size}B (saves {savings}B): {}",
310                suggested_order.join(", ")
311            );
312            if *severity == Severity::High {
313                format!("{base}  (~{savings} MB/1M instances)")
314            } else {
315                base
316            }
317        }
318        Finding::FalseSharing {
319            conflicts,
320            is_inferred,
321            ..
322        } => {
323            // Show the field names involved so the engineer knows what to look at.
324            let field_lists: Vec<String> = conflicts
325                .iter()
326                .map(|c| format!("cache line {}: [{}]", c.cache_line, c.fields.join(", ")))
327                .collect();
328            let inferred_note = if *is_inferred {
329                "  (inferred from type names — add guard annotations or verify with profiling)"
330            } else {
331                ""
332            };
333            format!(
334                "[{sev}] False sharing: {}{}",
335                field_lists.join("; "),
336                inferred_note
337            )
338        }
339        Finding::LocalityIssue {
340            hot_fields,
341            cold_fields,
342            is_inferred,
343            ..
344        } => {
345            let inferred_note = if *is_inferred {
346                "  (inferred from type names — verify with profiling)"
347            } else {
348                ""
349            };
350            format!(
351                "[{sev}] Locality: hot [{}] mixed with cold [{}] on same cache line(s){}",
352                hot_fields.join(", "),
353                cold_fields.join(", "),
354                inferred_note
355            )
356        }
357    }
358}
359
360// ── tests ─────────────────────────────────────────────────────────────────────
361
362#[cfg(test)]
363mod tests {
364    use super::*;
365    use padlock_core::findings::Report;
366    use padlock_core::ir::test_fixtures::{connection_layout, packed_layout};
367
368    #[test]
369    fn render_report_contains_struct_name() {
370        let report = Report::from_layouts(&[connection_layout()]);
371        let out = render_report(&report, false);
372        assert!(out.contains("Connection"));
373    }
374
375    #[test]
376    fn render_report_mentions_wasted_bytes() {
377        let report = Report::from_layouts(&[connection_layout()]);
378        let out = render_report(&report, false);
379        assert!(out.contains("waste") || out.contains("Padding"));
380    }
381
382    #[test]
383    fn render_report_shows_reorder_suggestion() {
384        let report = Report::from_layouts(&[connection_layout()]);
385        let out = render_report(&report, false);
386        assert!(out.contains("Reorder") || out.contains("saves"));
387    }
388
389    #[test]
390    fn render_report_no_issues_on_packed() {
391        let report = Report::from_layouts(&[packed_layout()]);
392        let out = render_report(&report, false);
393        assert!(out.contains("no issues"));
394    }
395
396    #[test]
397    fn render_struct_shows_hole_count_when_nonzero() {
398        let report = Report::from_layouts(&[connection_layout()]);
399        let out = render_struct(&report.structs[0], true);
400        assert!(out.contains("holes=2"));
401    }
402
403    #[test]
404    fn render_struct_omits_holes_when_zero() {
405        let report = Report::from_layouts(&[packed_layout()]);
406        let out = render_struct(&report.structs[0], true);
407        assert!(!out.contains("holes="));
408    }
409
410    #[test]
411    fn render_struct_shows_field_count() {
412        let report = Report::from_layouts(&[connection_layout()]);
413        let out = render_struct(&report.structs[0], true);
414        assert!(out.contains("fields=4"));
415    }
416
417    #[test]
418    fn render_report_multi_file_header() {
419        let mut report = Report::from_layouts(&[connection_layout()]);
420        report.analyzed_paths = vec!["a.rs".into(), "b.rs".into()];
421        let out = render_report(&report, false);
422        assert!(out.contains("2 files"));
423    }
424
425    #[test]
426    fn high_reorder_finding_shows_mb_hint() {
427        // Connection saves 8B (High severity) → should show MB/1M hint
428        let report = Report::from_layouts(&[connection_layout()]);
429        let out = render_report(&report, false);
430        assert!(out.contains("MB/1M instances"));
431    }
432
433    #[test]
434    fn mb_hint_absent_for_packed_struct() {
435        let report = Report::from_layouts(&[packed_layout()]);
436        let out = render_report(&report, false);
437        assert!(!out.contains("MB/1M instances"));
438    }
439
440    #[test]
441    fn padding_waste_shows_gap_locations() {
442        let report = Report::from_layouts(&[connection_layout()]);
443        let out = render_report(&report, false);
444        // Should show "XB after `field` (offset N)" for each gap.
445        assert!(out.contains("after `"), "gap location detail missing");
446        assert!(out.contains("offset "), "gap offset missing");
447    }
448
449    #[test]
450    fn reorder_shows_before_and_after_sizes() {
451        let report = Report::from_layouts(&[connection_layout()]);
452        let out = render_report(&report, false);
453        // New format: "NB → NB (saves NB)"
454        assert!(out.contains("saves"), "savings clause missing");
455    }
456
457    // ── uncertain_fields note ─────────────────────────────────────────────────
458
459    #[test]
460    fn uncertain_fields_note_shown_when_non_empty() {
461        let mut layout = connection_layout();
462        layout.uncertain_fields = vec!["connector".to_string()];
463
464        let report = Report::from_layouts(&[layout]);
465        let out = render_struct(&report.structs[0], true);
466        assert!(
467            out.contains("uncertain field size"),
468            "uncertain_fields note must appear in output: {out}"
469        );
470        assert!(
471            out.contains("connector"),
472            "uncertain field name must appear in output: {out}"
473        );
474        assert!(
475            out.contains("DWARF/BTF"),
476            "output must mention binary analysis: {out}"
477        );
478    }
479
480    #[test]
481    fn uncertain_fields_note_absent_when_empty() {
482        let report = Report::from_layouts(&[connection_layout()]);
483        let out = render_struct(&report.structs[0], true);
484        assert!(
485            !out.contains("uncertain field size"),
486            "uncertain_fields note must not appear when fields are empty"
487        );
488    }
489}