1use padlock_core::findings::{Finding, Report, Severity, SkippedStruct, StructReport};
4
5pub 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 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 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
75fn render_grouped(out: &mut String, report: &Report) {
77 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 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 out.push_str(&render_struct_with_embed(sr, false, &report.embedded_in));
100 out.push('\n');
101 }
102 }
103 }
104}
105
106pub fn render_struct(sr: &StructReport, show_filename: bool) -> String {
111 render_struct_with_embed(sr, show_filename, &std::collections::HashMap::new())
112}
113
114fn 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 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
203fn 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
224fn render_skipped(skipped: &[SkippedStruct], show_all: bool) -> String {
230 let n = skipped.len();
231 let mut out = String::new();
232
233 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 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 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#[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 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 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 assert!(out.contains("saves"), "savings clause missing");
455 }
456
457 #[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}