Skip to main content

padlock_output/
summary.rs

1// padlock-output/src/summary.rs
2
3use padlock_core::findings::{Finding, Report, Severity, StructReport};
4
5/// Render a full report as a human-readable multi-line string.
6pub fn render_report(report: &Report) -> String {
7    let mut out = String::new();
8
9    // When multiple files were analyzed, show the file count first.
10    if report.analyzed_paths.len() > 1 {
11        out.push_str(&format!("Analyzed {} files, ", report.analyzed_paths.len()));
12        out.push_str(&format!(
13            "{} struct{}",
14            report.total_structs,
15            if report.total_structs == 1 { "" } else { "s" }
16        ));
17    } else {
18        out.push_str(&format!(
19            "Analyzed {} struct{}",
20            report.total_structs,
21            if report.total_structs == 1 { "" } else { "s" }
22        ));
23    }
24
25    if report.total_wasted_bytes > 0 {
26        out.push_str(&format!(
27            " — {} bytes wasted across all structs\n",
28            report.total_wasted_bytes
29        ));
30    } else {
31        out.push_str(" — no padding waste found\n");
32    }
33    out.push('\n');
34
35    for sr in &report.structs {
36        out.push_str(&render_struct(sr));
37        out.push('\n');
38    }
39
40    out
41}
42
43pub fn render_struct(sr: &StructReport) -> String {
44    let mut out = String::new();
45
46    let score_label = match sr.score as u32 {
47        90..=100 => "✓",
48        60..=89 => "~",
49        _ => "✗",
50    };
51
52    let location = match (&sr.source_file, sr.source_line) {
53        (Some(f), Some(l)) => format!(" ({}:{})", f, l),
54        (Some(f), None) => format!(" ({})", f),
55        _ => String::new(),
56    };
57
58    let holes_hint = if sr.num_holes > 0 {
59        format!("  holes={}", sr.num_holes)
60    } else {
61        String::new()
62    };
63
64    out.push_str(&format!(
65        "[{score_label}] {name}{location}  {size}B  fields={fields}{holes}  score={score:.0}\n",
66        name = sr.struct_name,
67        size = sr.total_size,
68        fields = sr.num_fields,
69        holes = holes_hint,
70        score = sr.score,
71    ));
72
73    for finding in &sr.findings {
74        out.push_str(&format!("    {}\n", render_finding(finding)));
75    }
76
77    if sr.findings.is_empty() {
78        out.push_str("    (no issues found)\n");
79    }
80
81    out
82}
83
84fn render_finding(f: &Finding) -> String {
85    let sev = match f.severity() {
86        Severity::High => "HIGH",
87        Severity::Medium => "MEDIUM",
88        Severity::Low => "LOW",
89    };
90    match f {
91        Finding::PaddingWaste {
92            wasted_bytes,
93            waste_pct,
94            gaps,
95            ..
96        } => format!(
97            "[{sev}] Padding waste: {wasted_bytes}B ({waste_pct:.0}%) across {} gap(s)",
98            gaps.len()
99        ),
100        Finding::ReorderSuggestion {
101            savings,
102            optimized_size,
103            suggested_order,
104            ..
105        } => format!(
106            "[{sev}] Reorder fields to save {savings}B → {optimized_size}B: {}",
107            suggested_order.join(", ")
108        ),
109        Finding::FalseSharing { conflicts, .. } => format!(
110            "[{sev}] False sharing: {} cache-line conflict(s)",
111            conflicts.len()
112        ),
113        Finding::LocalityIssue {
114            hot_fields,
115            cold_fields,
116            ..
117        } => format!(
118            "[{sev}] Locality: hot [{}] interleaved with cold [{}]",
119            hot_fields.join(", "),
120            cold_fields.join(", ")
121        ),
122    }
123}
124
125// ── tests ─────────────────────────────────────────────────────────────────────
126
127#[cfg(test)]
128mod tests {
129    use super::*;
130    use padlock_core::findings::Report;
131    use padlock_core::ir::test_fixtures::{connection_layout, packed_layout};
132
133    #[test]
134    fn render_report_contains_struct_name() {
135        let report = Report::from_layouts(&[connection_layout()]);
136        let out = render_report(&report);
137        assert!(out.contains("Connection"));
138    }
139
140    #[test]
141    fn render_report_mentions_wasted_bytes() {
142        let report = Report::from_layouts(&[connection_layout()]);
143        let out = render_report(&report);
144        assert!(out.contains("waste") || out.contains("Padding"));
145    }
146
147    #[test]
148    fn render_report_shows_reorder_suggestion() {
149        let report = Report::from_layouts(&[connection_layout()]);
150        let out = render_report(&report);
151        assert!(out.contains("Reorder") || out.contains("save"));
152    }
153
154    #[test]
155    fn render_report_no_issues_on_packed() {
156        let report = Report::from_layouts(&[packed_layout()]);
157        let out = render_report(&report);
158        assert!(out.contains("no issues"));
159    }
160
161    #[test]
162    fn render_struct_shows_hole_count_when_nonzero() {
163        let report = Report::from_layouts(&[connection_layout()]);
164        let out = render_struct(&report.structs[0]);
165        assert!(out.contains("holes=2"));
166    }
167
168    #[test]
169    fn render_struct_omits_holes_when_zero() {
170        let report = Report::from_layouts(&[packed_layout()]);
171        let out = render_struct(&report.structs[0]);
172        assert!(!out.contains("holes="));
173    }
174
175    #[test]
176    fn render_struct_shows_field_count() {
177        let report = Report::from_layouts(&[connection_layout()]);
178        let out = render_struct(&report.structs[0]);
179        assert!(out.contains("fields=4"));
180    }
181
182    #[test]
183    fn render_report_multi_file_header() {
184        let mut report = Report::from_layouts(&[connection_layout()]);
185        report.analyzed_paths = vec!["a.rs".into(), "b.rs".into()];
186        let out = render_report(&report);
187        assert!(out.contains("2 files"));
188    }
189}