layout_audit/output/
sarif.rs

1use crate::analysis::OptimizedLayout;
2use crate::diff::DiffResult;
3use crate::types::{SourceLocation, StructLayout};
4use serde::Serialize;
5use serde_json::{Value, json};
6use std::collections::BTreeSet;
7
8const SARIF_VERSION: &str = "2.1.0";
9const SARIF_SCHEMA: &str = "https://schemastore.azurewebsites.net/schemas/json/sarif-2.1.0.json";
10const TOOL_NAME: &str = "layout-audit";
11const TOOL_URI: &str = "https://github.com/avifenesh/layout-audit";
12
13const RULE_SIZE_INCREASE: &str = "LAYOUT-SIZE-INCREASE";
14const RULE_PADDING_INCREASE: &str = "LAYOUT-PADDING-INCREASE";
15const RULE_BUDGET_SIZE: &str = "LAYOUT-BUDGET-SIZE";
16const RULE_BUDGET_PADDING: &str = "LAYOUT-BUDGET-PADDING";
17const RULE_BUDGET_PADDING_PERCENT: &str = "LAYOUT-BUDGET-PADDING-PERCENT";
18const RULE_BUDGET_FALSE_SHARING: &str = "LAYOUT-BUDGET-FALSE-SHARING";
19const RULE_PADDING: &str = "LAYOUT-PADDING";
20const RULE_FALSE_SHARING: &str = "LAYOUT-FALSE-SHARING";
21const RULE_REORDER_SUGGESTION: &str = "LAYOUT-REORDER-SUGGESTION";
22
23#[derive(Debug, Clone, Copy, Serialize)]
24#[serde(rename_all = "snake_case")]
25pub enum CheckViolationKind {
26    MaxSize,
27    MaxPaddingBytes,
28    MaxPaddingPercent,
29    MaxFalseSharingWarnings,
30}
31
32#[derive(Debug, Clone, Serialize)]
33pub struct CheckViolation {
34    pub struct_name: String,
35    pub kind: CheckViolationKind,
36    pub message: String,
37    #[serde(skip_serializing_if = "Option::is_none")]
38    pub source_location: Option<SourceLocation>,
39}
40
41pub struct SarifFormatter {
42    tool_version: &'static str,
43}
44
45impl SarifFormatter {
46    pub fn new() -> Self {
47        Self { tool_version: env!("CARGO_PKG_VERSION") }
48    }
49
50    pub fn format_diff(&self, diff: &DiffResult, error_on_regression: bool) -> String {
51        let mut results: Vec<Value> = Vec::new();
52        let mut used_rules: BTreeSet<&'static str> = BTreeSet::new();
53        let level = if error_on_regression { "error" } else { "warning" };
54
55        for change in &diff.changed {
56            if change.size_delta > 0 {
57                used_rules.insert(RULE_SIZE_INCREASE);
58                let message = format!(
59                    "Struct {} size increased from {} to {} (+{} bytes)",
60                    change.name, change.old_size, change.new_size, change.size_delta
61                );
62                results.push(make_result(
63                    RULE_SIZE_INCREASE,
64                    level,
65                    message,
66                    change.source_location.as_ref(),
67                    Some(json!({
68                        "struct": change.name,
69                        "old_size": change.old_size,
70                        "new_size": change.new_size,
71                        "delta": change.size_delta,
72                    })),
73                ));
74            }
75
76            if change.padding_delta > 0 {
77                used_rules.insert(RULE_PADDING_INCREASE);
78                let message = format!(
79                    "Struct {} padding increased from {} to {} (+{} bytes)",
80                    change.name, change.old_padding, change.new_padding, change.padding_delta
81                );
82                results.push(make_result(
83                    RULE_PADDING_INCREASE,
84                    level,
85                    message,
86                    change.source_location.as_ref(),
87                    Some(json!({
88                        "struct": change.name,
89                        "old_padding": change.old_padding,
90                        "new_padding": change.new_padding,
91                        "delta": change.padding_delta,
92                    })),
93                ));
94            }
95        }
96
97        let rules = build_rules(&used_rules);
98        render_sarif(self.tool_version, rules, results)
99    }
100
101    pub fn format_check(&self, violations: &[CheckViolation]) -> String {
102        let mut results: Vec<Value> = Vec::new();
103        let mut used_rules: BTreeSet<&'static str> = BTreeSet::new();
104
105        for v in violations {
106            let rule_id = rule_id_for_kind(v.kind);
107            used_rules.insert(rule_id);
108            results.push(make_result(
109                rule_id,
110                "error",
111                v.message.clone(),
112                v.source_location.as_ref(),
113                Some(json!({ "struct": v.struct_name })),
114            ));
115        }
116
117        let rules = build_rules(&used_rules);
118        render_sarif(self.tool_version, rules, results)
119    }
120
121    pub fn format_inspect(&self, layouts: &[StructLayout]) -> String {
122        let mut results: Vec<Value> = Vec::new();
123        let mut used_rules: BTreeSet<&'static str> = BTreeSet::new();
124
125        for layout in layouts {
126            if layout.metrics.padding_bytes > 0 {
127                used_rules.insert(RULE_PADDING);
128                let message = format!(
129                    "Struct {} has {} padding bytes ({:.1}% of {} bytes)",
130                    layout.name,
131                    layout.metrics.padding_bytes,
132                    layout.metrics.padding_percentage,
133                    layout.size
134                );
135                results.push(make_result(
136                    RULE_PADDING,
137                    "warning",
138                    message,
139                    layout.source_location.as_ref(),
140                    Some(json!({
141                        "struct": layout.name,
142                        "size": layout.size,
143                        "padding_bytes": layout.metrics.padding_bytes,
144                        "padding_percent": layout.metrics.padding_percentage,
145                        "cache_lines_spanned": layout.metrics.cache_lines_spanned,
146                    })),
147                ));
148            }
149
150            if let Some(fs) = layout.metrics.false_sharing.as_ref() {
151                if !fs.warnings.is_empty() || !fs.spanning_warnings.is_empty() {
152                    used_rules.insert(RULE_FALSE_SHARING);
153                    let warning_count = fs.warnings.len();
154                    let spanning_count = fs.spanning_warnings.len();
155                    let message = format!(
156                        "Struct {} has {} potential false sharing warning(s) and {} cache-line spanning atomic(s)",
157                        layout.name, warning_count, spanning_count
158                    );
159                    results.push(make_result(
160                        RULE_FALSE_SHARING,
161                        "warning",
162                        message,
163                        layout.source_location.as_ref(),
164                        Some(json!({
165                            "struct": layout.name,
166                            "false_sharing_warnings": warning_count,
167                            "spanning_warnings": spanning_count,
168                        })),
169                    ));
170                }
171            }
172        }
173
174        let rules = build_rules(&used_rules);
175        render_sarif(self.tool_version, rules, results)
176    }
177
178    pub fn format_suggest(
179        &self,
180        suggestions: &[OptimizedLayout],
181        locations: &[Option<SourceLocation>],
182    ) -> String {
183        let mut results: Vec<Value> = Vec::new();
184        let mut used_rules: BTreeSet<&'static str> = BTreeSet::new();
185
186        for (idx, suggestion) in suggestions.iter().enumerate() {
187            if suggestion.savings_bytes == 0 {
188                continue;
189            }
190            let location = locations.get(idx).and_then(|loc| loc.as_ref());
191            used_rules.insert(RULE_REORDER_SUGGESTION);
192            let message = format!(
193                "Struct {} can save {} bytes ({:.1}%) by reordering fields",
194                suggestion.name, suggestion.savings_bytes, suggestion.savings_percent
195            );
196            results.push(make_result(
197                RULE_REORDER_SUGGESTION,
198                "note",
199                message,
200                location,
201                Some(json!({
202                    "struct": suggestion.name,
203                    "original_size": suggestion.original_size,
204                    "optimized_size": suggestion.optimized_size,
205                    "savings_bytes": suggestion.savings_bytes,
206                    "savings_percent": suggestion.savings_percent,
207                })),
208            ));
209        }
210
211        let rules = build_rules(&used_rules);
212        render_sarif(self.tool_version, rules, results)
213    }
214}
215
216impl Default for SarifFormatter {
217    fn default() -> Self {
218        Self::new()
219    }
220}
221
222fn rule_id_for_kind(kind: CheckViolationKind) -> &'static str {
223    match kind {
224        CheckViolationKind::MaxSize => RULE_BUDGET_SIZE,
225        CheckViolationKind::MaxPaddingBytes => RULE_BUDGET_PADDING,
226        CheckViolationKind::MaxPaddingPercent => RULE_BUDGET_PADDING_PERCENT,
227        CheckViolationKind::MaxFalseSharingWarnings => RULE_BUDGET_FALSE_SHARING,
228    }
229}
230
231fn build_rules(rule_ids: &BTreeSet<&'static str>) -> Vec<Value> {
232    rule_ids
233        .iter()
234        .map(|id| {
235            let (name, short) = rule_metadata(id);
236            json!({
237                "id": id,
238                "name": name,
239                "shortDescription": { "text": short },
240            })
241        })
242        .collect()
243}
244
245fn rule_metadata(rule_id: &str) -> (&'static str, &'static str) {
246    match rule_id {
247        RULE_SIZE_INCREASE => {
248            ("Struct size increased", "Struct size increased relative to baseline")
249        }
250        RULE_PADDING_INCREASE => {
251            ("Struct padding increased", "Struct padding increased relative to baseline")
252        }
253        RULE_BUDGET_SIZE => ("Budget: size", "Struct size exceeded budget"),
254        RULE_BUDGET_PADDING => ("Budget: padding bytes", "Struct padding bytes exceeded budget"),
255        RULE_BUDGET_PADDING_PERCENT => {
256            ("Budget: padding percent", "Struct padding percentage exceeded budget")
257        }
258        RULE_BUDGET_FALSE_SHARING => {
259            ("Budget: false sharing", "Struct false sharing warnings exceeded budget")
260        }
261        RULE_PADDING => ("Padding detected", "Struct contains padding bytes"),
262        RULE_FALSE_SHARING => ("Potential false sharing", "Atomic members share cache lines"),
263        RULE_REORDER_SUGGESTION => {
264            ("Reorder suggestion", "Struct can be reordered to reduce padding")
265        }
266        _ => ("Layout issue", "Layout-audit reported an issue"),
267    }
268}
269
270fn make_result(
271    rule_id: &str,
272    level: &str,
273    message: String,
274    source_location: Option<&SourceLocation>,
275    properties: Option<Value>,
276) -> Value {
277    let mut result = json!({
278        "ruleId": rule_id,
279        "level": level,
280        "message": { "text": message },
281    });
282
283    if let Some(location) = source_location {
284        let loc = json!({
285            "physicalLocation": {
286                "artifactLocation": { "uri": location.file },
287                "region": { "startLine": location.line },
288            }
289        });
290        result["locations"] = json!([loc]);
291    }
292
293    if let Some(props) = properties {
294        result["properties"] = props;
295    }
296
297    result
298}
299
300fn render_sarif(tool_version: &str, rules: Vec<Value>, results: Vec<Value>) -> String {
301    let sarif = json!({
302        "version": SARIF_VERSION,
303        "$schema": SARIF_SCHEMA,
304        "runs": [{
305            "tool": {
306                "driver": {
307                    "name": TOOL_NAME,
308                    "version": tool_version,
309                    "informationUri": TOOL_URI,
310                    "rules": rules,
311                }
312            },
313            "results": results,
314        }]
315    });
316
317    serde_json::to_string_pretty(&sarif).unwrap_or_else(|e| format!("{{\"error\": \"{}\"}}", e))
318}
319
320#[cfg(test)]
321mod tests {
322    use super::*;
323    use crate::diff::{DiffResult, MemberChange, MemberChangeKind, StructChange, StructSummary};
324    use crate::types::{
325        CacheLineSpanningWarning, FalseSharingAnalysis, FalseSharingWarning, LayoutMetrics,
326        SourceLocation, StructLayout,
327    };
328
329    fn parse_sarif(s: &str) -> Value {
330        serde_json::from_str(s).expect("valid SARIF JSON")
331    }
332
333    fn basic_layout(name: &str) -> StructLayout {
334        let mut layout = StructLayout::new(name.to_string(), 16, Some(8));
335        layout.metrics = LayoutMetrics::default();
336        layout
337    }
338
339    #[test]
340    fn diff_sarif_empty_results() {
341        let formatter = SarifFormatter::new();
342        let diff = DiffResult {
343            added: Vec::new(),
344            removed: Vec::new(),
345            changed: Vec::new(),
346            unchanged_count: 0,
347        };
348        let sarif = formatter.format_diff(&diff, false);
349        let parsed = parse_sarif(&sarif);
350        let results = parsed["runs"][0]["results"].as_array().unwrap();
351        assert!(results.is_empty());
352    }
353
354    #[test]
355    fn diff_sarif_includes_location_and_rules() {
356        let formatter = SarifFormatter::new();
357        let change = StructChange {
358            name: "Foo".to_string(),
359            old_size: 8,
360            new_size: 16,
361            size_delta: 8,
362            old_padding: 0,
363            new_padding: 4,
364            padding_delta: 4,
365            member_changes: vec![MemberChange {
366                kind: MemberChangeKind::Added,
367                name: "x".to_string(),
368                details: "offset Some(8), size Some(4)".to_string(),
369            }],
370            source_location: Some(SourceLocation { file: "src/foo.c".to_string(), line: 10 }),
371            old_source_location: None,
372        };
373        let diff = DiffResult {
374            added: vec![StructSummary {
375                name: "Bar".to_string(),
376                size: 8,
377                padding_bytes: 0,
378                source_location: None,
379            }],
380            removed: Vec::new(),
381            changed: vec![change],
382            unchanged_count: 0,
383        };
384
385        let sarif = formatter.format_diff(&diff, true);
386        let parsed = parse_sarif(&sarif);
387        let results = parsed["runs"][0]["results"].as_array().unwrap();
388        assert_eq!(results.len(), 2);
389        for result in results {
390            assert!(result["ruleId"].is_string());
391            assert_eq!(result["level"], "error");
392            let locations = result["locations"].as_array().unwrap();
393            assert_eq!(locations[0]["physicalLocation"]["artifactLocation"]["uri"], "src/foo.c");
394        }
395    }
396
397    #[test]
398    fn check_sarif_empty() {
399        let formatter = SarifFormatter::new();
400        let sarif = formatter.format_check(&[]);
401        let parsed = parse_sarif(&sarif);
402        let results = parsed["runs"][0]["results"].as_array().unwrap();
403        assert!(results.is_empty());
404    }
405
406    #[test]
407    fn check_sarif_maps_rules() {
408        let formatter = SarifFormatter::new();
409        let violations = vec![
410            CheckViolation {
411                struct_name: "Foo".to_string(),
412                kind: CheckViolationKind::MaxSize,
413                message: "Foo: size 16 exceeds budget 8 (+8 bytes)".to_string(),
414                source_location: Some(SourceLocation { file: "src/foo.c".to_string(), line: 5 }),
415            },
416            CheckViolation {
417                struct_name: "Bar".to_string(),
418                kind: CheckViolationKind::MaxPaddingPercent,
419                message: "Bar: padding 50.0% exceeds budget 10.0% (+40.0 percentage points)"
420                    .to_string(),
421                source_location: None,
422            },
423        ];
424        let sarif = formatter.format_check(&violations);
425        let parsed = parse_sarif(&sarif);
426        let results = parsed["runs"][0]["results"].as_array().unwrap();
427        assert_eq!(results.len(), 2);
428        assert_eq!(results[0]["ruleId"], RULE_BUDGET_SIZE);
429        assert_eq!(results[1]["ruleId"], RULE_BUDGET_PADDING_PERCENT);
430    }
431
432    #[test]
433    fn inspect_sarif_padding_and_false_sharing() {
434        let formatter = SarifFormatter::new();
435        let mut layout = basic_layout("Foo");
436        layout.metrics.padding_bytes = 4;
437        layout.metrics.padding_percentage = 25.0;
438        layout.metrics.cache_lines_spanned = 1;
439        layout.source_location = Some(SourceLocation { file: "src/foo.c".to_string(), line: 3 });
440        layout.metrics.false_sharing = Some(FalseSharingAnalysis {
441            warnings: vec![FalseSharingWarning {
442                member_a: "a".to_string(),
443                member_b: "b".to_string(),
444                cache_line: 0,
445                gap_bytes: 0,
446            }],
447            spanning_warnings: vec![CacheLineSpanningWarning {
448                member: "a".to_string(),
449                type_name: "AtomicU64".to_string(),
450                offset: 0,
451                size: 8,
452                start_cache_line: 0,
453                end_cache_line: 1,
454                lines_spanned: 2,
455            }],
456            atomic_members: Vec::new(),
457        });
458
459        let sarif = formatter.format_inspect(&[layout]);
460        let parsed = parse_sarif(&sarif);
461        let results = parsed["runs"][0]["results"].as_array().unwrap();
462        assert_eq!(results.len(), 2);
463    }
464
465    #[test]
466    fn suggest_sarif_skips_zero_savings() {
467        let formatter = SarifFormatter::new();
468        let no_savings = OptimizedLayout {
469            name: "NoSavings".to_string(),
470            original_size: 16,
471            optimized_size: 16,
472            savings_bytes: 0,
473            savings_percent: 0.0,
474            struct_alignment: 8,
475            original_members: Vec::new(),
476            optimized_members: Vec::new(),
477            skipped_members: Vec::new(),
478            has_bitfields: false,
479        };
480        let mut savings = no_savings.clone();
481        savings.name = "Savings".to_string();
482        savings.optimized_size = 12;
483        savings.savings_bytes = 4;
484        savings.savings_percent = 25.0;
485
486        let sarif = formatter.format_suggest(
487            &[no_savings, savings],
488            &[None, Some(SourceLocation { file: "src/foo.c".to_string(), line: 12 })],
489        );
490        let parsed = parse_sarif(&sarif);
491        let results = parsed["runs"][0]["results"].as_array().unwrap();
492        assert_eq!(results.len(), 1);
493        assert_eq!(results[0]["ruleId"], RULE_REORDER_SUGGESTION);
494    }
495}