Skip to main content

fallow_cli/report/
sarif.rs

1use std::path::Path;
2use std::process::ExitCode;
3
4use fallow_config::{RulesConfig, Severity};
5use fallow_core::duplicates::DuplicationReport;
6use fallow_core::results::{AnalysisResults, UnusedDependency, UnusedExport, UnusedMember};
7
8use super::relative_uri;
9
10/// Intermediate fields extracted from an issue for SARIF result construction.
11struct SarifFields {
12    rule_id: &'static str,
13    level: &'static str,
14    message: String,
15    uri: String,
16    region: Option<(u32, u32)>,
17    properties: Option<serde_json::Value>,
18}
19
20const fn severity_to_sarif_level(s: Severity) -> &'static str {
21    match s {
22        Severity::Error => "error",
23        Severity::Warn | Severity::Off => "warning",
24    }
25}
26
27/// Build a single SARIF result object.
28///
29/// When `region` is `Some((line, col))`, a `region` block with 1-based
30/// `startLine` and `startColumn` is included in the physical location.
31fn sarif_result(
32    rule_id: &str,
33    level: &str,
34    message: &str,
35    uri: &str,
36    region: Option<(u32, u32)>,
37) -> serde_json::Value {
38    let mut physical_location = serde_json::json!({
39        "artifactLocation": { "uri": uri }
40    });
41    if let Some((line, col)) = region {
42        physical_location["region"] = serde_json::json!({
43            "startLine": line,
44            "startColumn": col
45        });
46    }
47    serde_json::json!({
48        "ruleId": rule_id,
49        "level": level,
50        "message": { "text": message },
51        "locations": [{ "physicalLocation": physical_location }]
52    })
53}
54
55/// Append SARIF results for a slice of items using a closure to extract fields.
56fn push_sarif_results<T>(
57    sarif_results: &mut Vec<serde_json::Value>,
58    items: &[T],
59    extract: impl Fn(&T) -> SarifFields,
60) {
61    for item in items {
62        let fields = extract(item);
63        let mut result = sarif_result(
64            fields.rule_id,
65            fields.level,
66            &fields.message,
67            &fields.uri,
68            fields.region,
69        );
70        if let Some(props) = fields.properties {
71            result["properties"] = props;
72        }
73        sarif_results.push(result);
74    }
75}
76
77pub fn build_sarif(
78    results: &AnalysisResults,
79    root: &Path,
80    rules: &RulesConfig,
81) -> serde_json::Value {
82    let mut sarif_results = Vec::new();
83
84    push_sarif_results(&mut sarif_results, &results.unused_files, |file| {
85        SarifFields {
86            rule_id: "fallow/unused-file",
87            level: severity_to_sarif_level(rules.unused_files),
88            message: "File is not reachable from any entry point".to_string(),
89            uri: relative_uri(&file.path, root),
90            region: None,
91            properties: None,
92        }
93    });
94
95    let sarif_export = |export: &UnusedExport,
96                        rule_id: &'static str,
97                        level: &'static str,
98                        kind: &str,
99                        re_kind: &str|
100     -> SarifFields {
101        let label = if export.is_re_export { re_kind } else { kind };
102        SarifFields {
103            rule_id,
104            level,
105            message: format!(
106                "{} '{}' is never imported by other modules",
107                label, export.export_name
108            ),
109            uri: relative_uri(&export.path, root),
110            region: Some((export.line, export.col + 1)),
111            properties: if export.is_re_export {
112                Some(serde_json::json!({ "is_re_export": true }))
113            } else {
114                None
115            },
116        }
117    };
118
119    push_sarif_results(&mut sarif_results, &results.unused_exports, |export| {
120        sarif_export(
121            export,
122            "fallow/unused-export",
123            severity_to_sarif_level(rules.unused_exports),
124            "Export",
125            "Re-export",
126        )
127    });
128
129    push_sarif_results(&mut sarif_results, &results.unused_types, |export| {
130        sarif_export(
131            export,
132            "fallow/unused-type",
133            severity_to_sarif_level(rules.unused_types),
134            "Type export",
135            "Type re-export",
136        )
137    });
138
139    let sarif_dep = |dep: &UnusedDependency,
140                     rule_id: &'static str,
141                     level: &'static str,
142                     section: &str|
143     -> SarifFields {
144        SarifFields {
145            rule_id,
146            level,
147            message: format!(
148                "Package '{}' is in {} but never imported",
149                dep.package_name, section
150            ),
151            uri: relative_uri(&dep.path, root),
152            region: None,
153            properties: None,
154        }
155    };
156
157    push_sarif_results(&mut sarif_results, &results.unused_dependencies, |dep| {
158        sarif_dep(
159            dep,
160            "fallow/unused-dependency",
161            severity_to_sarif_level(rules.unused_dependencies),
162            "dependencies",
163        )
164    });
165
166    push_sarif_results(
167        &mut sarif_results,
168        &results.unused_dev_dependencies,
169        |dep| {
170            sarif_dep(
171                dep,
172                "fallow/unused-dev-dependency",
173                severity_to_sarif_level(rules.unused_dev_dependencies),
174                "devDependencies",
175            )
176        },
177    );
178
179    let sarif_member = |member: &UnusedMember,
180                        rule_id: &'static str,
181                        level: &'static str,
182                        kind: &str|
183     -> SarifFields {
184        SarifFields {
185            rule_id,
186            level,
187            message: format!(
188                "{} member '{}.{}' is never referenced",
189                kind, member.parent_name, member.member_name
190            ),
191            uri: relative_uri(&member.path, root),
192            region: Some((member.line, member.col + 1)),
193            properties: None,
194        }
195    };
196
197    push_sarif_results(&mut sarif_results, &results.unused_enum_members, |member| {
198        sarif_member(
199            member,
200            "fallow/unused-enum-member",
201            severity_to_sarif_level(rules.unused_enum_members),
202            "Enum",
203        )
204    });
205
206    push_sarif_results(
207        &mut sarif_results,
208        &results.unused_class_members,
209        |member| {
210            sarif_member(
211                member,
212                "fallow/unused-class-member",
213                severity_to_sarif_level(rules.unused_class_members),
214                "Class",
215            )
216        },
217    );
218
219    push_sarif_results(&mut sarif_results, &results.unresolved_imports, |import| {
220        SarifFields {
221            rule_id: "fallow/unresolved-import",
222            level: severity_to_sarif_level(rules.unresolved_imports),
223            message: format!("Import '{}' could not be resolved", import.specifier),
224            uri: relative_uri(&import.path, root),
225            region: Some((import.line, import.col + 1)),
226            properties: None,
227        }
228    });
229
230    push_sarif_results(&mut sarif_results, &results.unlisted_dependencies, |dep| {
231        SarifFields {
232            rule_id: "fallow/unlisted-dependency",
233            level: severity_to_sarif_level(rules.unlisted_dependencies),
234            message: format!(
235                "Package '{}' is imported but not listed in package.json",
236                dep.package_name
237            ),
238            uri: "package.json".to_string(),
239            region: None,
240            properties: None,
241        }
242    });
243
244    // Duplicate exports: one result per location (SARIF 2.1.0 section 3.27.12)
245    for dup in &results.duplicate_exports {
246        for loc_path in &dup.locations {
247            sarif_results.push(sarif_result(
248                "fallow/duplicate-export",
249                severity_to_sarif_level(rules.duplicate_exports),
250                &format!("Export '{}' appears in multiple modules", dup.export_name),
251                &relative_uri(loc_path, root),
252                None,
253            ));
254        }
255    }
256
257    push_sarif_results(
258        &mut sarif_results,
259        &results.circular_dependencies,
260        |cycle| {
261            let chain: Vec<String> = cycle.files.iter().map(|p| relative_uri(p, root)).collect();
262            let mut display_chain = chain.clone();
263            if let Some(first) = chain.first() {
264                display_chain.push(first.clone());
265            }
266            let first_uri = chain.first().map_or_else(String::new, Clone::clone);
267            SarifFields {
268                rule_id: "fallow/circular-dependency",
269                level: severity_to_sarif_level(rules.circular_dependencies),
270                message: format!("Circular dependency: {}", display_chain.join(" \u{2192} ")),
271                uri: first_uri,
272                region: None,
273                properties: None,
274            }
275        },
276    );
277
278    serde_json::json!({
279        "$schema": "https://json.schemastore.org/sarif-2.1.0.json",
280        "version": "2.1.0",
281        "runs": [{
282            "tool": {
283                "driver": {
284                    "name": "fallow",
285                    "version": env!("CARGO_PKG_VERSION"),
286                    "informationUri": "https://github.com/fallow-rs/fallow",
287                    "rules": [
288                        {
289                            "id": "fallow/unused-file",
290                            "shortDescription": { "text": "File is not reachable from any entry point" },
291                            "defaultConfiguration": { "level": severity_to_sarif_level(rules.unused_files) }
292                        },
293                        {
294                            "id": "fallow/unused-export",
295                            "shortDescription": { "text": "Export is never imported" },
296                            "defaultConfiguration": { "level": severity_to_sarif_level(rules.unused_exports) }
297                        },
298                        {
299                            "id": "fallow/unused-type",
300                            "shortDescription": { "text": "Type export is never imported" },
301                            "defaultConfiguration": { "level": severity_to_sarif_level(rules.unused_types) }
302                        },
303                        {
304                            "id": "fallow/unused-dependency",
305                            "shortDescription": { "text": "Dependency listed but never imported" },
306                            "defaultConfiguration": { "level": severity_to_sarif_level(rules.unused_dependencies) }
307                        },
308                        {
309                            "id": "fallow/unused-dev-dependency",
310                            "shortDescription": { "text": "Dev dependency listed but never imported" },
311                            "defaultConfiguration": { "level": severity_to_sarif_level(rules.unused_dev_dependencies) }
312                        },
313                        {
314                            "id": "fallow/unused-enum-member",
315                            "shortDescription": { "text": "Enum member is never referenced" },
316                            "defaultConfiguration": { "level": severity_to_sarif_level(rules.unused_enum_members) }
317                        },
318                        {
319                            "id": "fallow/unused-class-member",
320                            "shortDescription": { "text": "Class member is never referenced" },
321                            "defaultConfiguration": { "level": severity_to_sarif_level(rules.unused_class_members) }
322                        },
323                        {
324                            "id": "fallow/unresolved-import",
325                            "shortDescription": { "text": "Import could not be resolved" },
326                            "defaultConfiguration": { "level": severity_to_sarif_level(rules.unresolved_imports) }
327                        },
328                        {
329                            "id": "fallow/unlisted-dependency",
330                            "shortDescription": { "text": "Dependency used but not in package.json" },
331                            "defaultConfiguration": { "level": severity_to_sarif_level(rules.unlisted_dependencies) }
332                        },
333                        {
334                            "id": "fallow/duplicate-export",
335                            "shortDescription": { "text": "Export name appears in multiple modules" },
336                            "defaultConfiguration": { "level": severity_to_sarif_level(rules.duplicate_exports) }
337                        },
338                        {
339                            "id": "fallow/circular-dependency",
340                            "shortDescription": { "text": "Circular dependency chain detected" },
341                            "defaultConfiguration": { "level": severity_to_sarif_level(rules.circular_dependencies) }
342                        }
343                    ]
344                }
345            },
346            "results": sarif_results
347        }]
348    })
349}
350
351pub(super) fn print_sarif(results: &AnalysisResults, root: &Path, rules: &RulesConfig) -> ExitCode {
352    let sarif = build_sarif(results, root, rules);
353    match serde_json::to_string_pretty(&sarif) {
354        Ok(json) => {
355            println!("{json}");
356            ExitCode::SUCCESS
357        }
358        Err(e) => {
359            eprintln!("Error: failed to serialize SARIF output: {e}");
360            ExitCode::from(2)
361        }
362    }
363}
364
365pub(super) fn print_duplication_sarif(report: &DuplicationReport, root: &Path) -> ExitCode {
366    let mut sarif_results = Vec::new();
367
368    for (i, group) in report.clone_groups.iter().enumerate() {
369        for instance in &group.instances {
370            sarif_results.push(sarif_result(
371                "fallow/code-duplication",
372                "warning",
373                &format!(
374                    "Code clone group {} ({} lines, {} instances)",
375                    i + 1,
376                    group.line_count,
377                    group.instances.len()
378                ),
379                &relative_uri(&instance.file, root),
380                Some((instance.start_line as u32, (instance.start_col + 1) as u32)),
381            ));
382        }
383    }
384
385    let sarif = serde_json::json!({
386        "$schema": "https://json.schemastore.org/sarif-2.1.0.json",
387        "version": "2.1.0",
388        "runs": [{
389            "tool": {
390                "driver": {
391                    "name": "fallow",
392                    "version": env!("CARGO_PKG_VERSION"),
393                    "informationUri": "https://github.com/fallow-rs/fallow",
394                    "rules": [{
395                        "id": "fallow/code-duplication",
396                        "shortDescription": { "text": "Duplicated code block" },
397                        "defaultConfiguration": { "level": "warning" }
398                    }]
399                }
400            },
401            "results": sarif_results
402        }]
403    });
404
405    match serde_json::to_string_pretty(&sarif) {
406        Ok(json) => {
407            println!("{json}");
408            ExitCode::SUCCESS
409        }
410        Err(e) => {
411            eprintln!("Error: failed to serialize SARIF output: {e}");
412            ExitCode::from(2)
413        }
414    }
415}
416
417#[cfg(test)]
418mod tests {
419    use super::*;
420    use fallow_core::extract::MemberKind;
421    use fallow_core::results::*;
422    use std::path::PathBuf;
423
424    /// Helper: build an `AnalysisResults` populated with one issue of every type.
425    fn sample_results(root: &Path) -> AnalysisResults {
426        let mut r = AnalysisResults::default();
427
428        r.unused_files.push(UnusedFile {
429            path: root.join("src/dead.ts"),
430        });
431        r.unused_exports.push(UnusedExport {
432            path: root.join("src/utils.ts"),
433            export_name: "helperFn".to_string(),
434            is_type_only: false,
435            line: 10,
436            col: 4,
437            span_start: 120,
438            is_re_export: false,
439        });
440        r.unused_types.push(UnusedExport {
441            path: root.join("src/types.ts"),
442            export_name: "OldType".to_string(),
443            is_type_only: true,
444            line: 5,
445            col: 0,
446            span_start: 60,
447            is_re_export: false,
448        });
449        r.unused_dependencies.push(UnusedDependency {
450            package_name: "lodash".to_string(),
451            location: DependencyLocation::Dependencies,
452            path: root.join("package.json"),
453        });
454        r.unused_dev_dependencies.push(UnusedDependency {
455            package_name: "jest".to_string(),
456            location: DependencyLocation::DevDependencies,
457            path: root.join("package.json"),
458        });
459        r.unused_enum_members.push(UnusedMember {
460            path: root.join("src/enums.ts"),
461            parent_name: "Status".to_string(),
462            member_name: "Deprecated".to_string(),
463            kind: MemberKind::EnumMember,
464            line: 8,
465            col: 2,
466        });
467        r.unused_class_members.push(UnusedMember {
468            path: root.join("src/service.ts"),
469            parent_name: "UserService".to_string(),
470            member_name: "legacyMethod".to_string(),
471            kind: MemberKind::ClassMethod,
472            line: 42,
473            col: 4,
474        });
475        r.unresolved_imports.push(UnresolvedImport {
476            path: root.join("src/app.ts"),
477            specifier: "./missing-module".to_string(),
478            line: 3,
479            col: 0,
480        });
481        r.unlisted_dependencies.push(UnlistedDependency {
482            package_name: "chalk".to_string(),
483            imported_from: vec![root.join("src/cli.ts")],
484        });
485        r.duplicate_exports.push(DuplicateExport {
486            export_name: "Config".to_string(),
487            locations: vec![root.join("src/config.ts"), root.join("src/types.ts")],
488        });
489        r.circular_dependencies.push(CircularDependency {
490            files: vec![root.join("src/a.ts"), root.join("src/b.ts")],
491            length: 2,
492        });
493
494        r
495    }
496
497    #[test]
498    fn sarif_has_required_top_level_fields() {
499        let root = PathBuf::from("/project");
500        let results = AnalysisResults::default();
501        let sarif = build_sarif(&results, &root, &RulesConfig::default());
502
503        assert_eq!(
504            sarif["$schema"],
505            "https://json.schemastore.org/sarif-2.1.0.json"
506        );
507        assert_eq!(sarif["version"], "2.1.0");
508        assert!(sarif["runs"].is_array());
509    }
510
511    #[test]
512    fn sarif_has_tool_driver_info() {
513        let root = PathBuf::from("/project");
514        let results = AnalysisResults::default();
515        let sarif = build_sarif(&results, &root, &RulesConfig::default());
516
517        let driver = &sarif["runs"][0]["tool"]["driver"];
518        assert_eq!(driver["name"], "fallow");
519        assert!(driver["version"].is_string());
520        assert_eq!(
521            driver["informationUri"],
522            "https://github.com/fallow-rs/fallow"
523        );
524    }
525
526    #[test]
527    fn sarif_declares_all_rules() {
528        let root = PathBuf::from("/project");
529        let results = AnalysisResults::default();
530        let sarif = build_sarif(&results, &root, &RulesConfig::default());
531
532        let rules = sarif["runs"][0]["tool"]["driver"]["rules"]
533            .as_array()
534            .expect("rules should be an array");
535        assert_eq!(rules.len(), 11);
536
537        let rule_ids: Vec<&str> = rules.iter().map(|r| r["id"].as_str().unwrap()).collect();
538        assert!(rule_ids.contains(&"fallow/unused-file"));
539        assert!(rule_ids.contains(&"fallow/unused-export"));
540        assert!(rule_ids.contains(&"fallow/unused-type"));
541        assert!(rule_ids.contains(&"fallow/unused-dependency"));
542        assert!(rule_ids.contains(&"fallow/unused-dev-dependency"));
543        assert!(rule_ids.contains(&"fallow/unused-enum-member"));
544        assert!(rule_ids.contains(&"fallow/unused-class-member"));
545        assert!(rule_ids.contains(&"fallow/unresolved-import"));
546        assert!(rule_ids.contains(&"fallow/unlisted-dependency"));
547        assert!(rule_ids.contains(&"fallow/duplicate-export"));
548        assert!(rule_ids.contains(&"fallow/circular-dependency"));
549    }
550
551    #[test]
552    fn sarif_empty_results_no_results_entries() {
553        let root = PathBuf::from("/project");
554        let results = AnalysisResults::default();
555        let sarif = build_sarif(&results, &root, &RulesConfig::default());
556
557        let sarif_results = sarif["runs"][0]["results"]
558            .as_array()
559            .expect("results should be an array");
560        assert!(sarif_results.is_empty());
561    }
562
563    #[test]
564    fn sarif_unused_file_result() {
565        let root = PathBuf::from("/project");
566        let mut results = AnalysisResults::default();
567        results.unused_files.push(UnusedFile {
568            path: root.join("src/dead.ts"),
569        });
570
571        let sarif = build_sarif(&results, &root, &RulesConfig::default());
572        let entries = sarif["runs"][0]["results"].as_array().unwrap();
573        assert_eq!(entries.len(), 1);
574
575        let entry = &entries[0];
576        assert_eq!(entry["ruleId"], "fallow/unused-file");
577        // Default severity is "error" per RulesConfig::default()
578        assert_eq!(entry["level"], "error");
579        assert_eq!(
580            entry["locations"][0]["physicalLocation"]["artifactLocation"]["uri"],
581            "src/dead.ts"
582        );
583    }
584
585    #[test]
586    fn sarif_unused_export_includes_region() {
587        let root = PathBuf::from("/project");
588        let mut results = AnalysisResults::default();
589        results.unused_exports.push(UnusedExport {
590            path: root.join("src/utils.ts"),
591            export_name: "helperFn".to_string(),
592            is_type_only: false,
593            line: 10,
594            col: 4,
595            span_start: 120,
596            is_re_export: false,
597        });
598
599        let sarif = build_sarif(&results, &root, &RulesConfig::default());
600        let entry = &sarif["runs"][0]["results"][0];
601        assert_eq!(entry["ruleId"], "fallow/unused-export");
602
603        let region = &entry["locations"][0]["physicalLocation"]["region"];
604        assert_eq!(region["startLine"], 10);
605        // SARIF columns are 1-based, code adds +1 to the 0-based col
606        assert_eq!(region["startColumn"], 5);
607    }
608
609    #[test]
610    fn sarif_unresolved_import_is_error_level() {
611        let root = PathBuf::from("/project");
612        let mut results = AnalysisResults::default();
613        results.unresolved_imports.push(UnresolvedImport {
614            path: root.join("src/app.ts"),
615            specifier: "./missing".to_string(),
616            line: 1,
617            col: 0,
618        });
619
620        let sarif = build_sarif(&results, &root, &RulesConfig::default());
621        let entry = &sarif["runs"][0]["results"][0];
622        assert_eq!(entry["ruleId"], "fallow/unresolved-import");
623        assert_eq!(entry["level"], "error");
624    }
625
626    #[test]
627    fn sarif_unlisted_dependency_is_error_level() {
628        let root = PathBuf::from("/project");
629        let mut results = AnalysisResults::default();
630        results.unlisted_dependencies.push(UnlistedDependency {
631            package_name: "chalk".to_string(),
632            imported_from: vec![],
633        });
634
635        let sarif = build_sarif(&results, &root, &RulesConfig::default());
636        let entry = &sarif["runs"][0]["results"][0];
637        assert_eq!(entry["ruleId"], "fallow/unlisted-dependency");
638        assert_eq!(entry["level"], "error");
639        assert_eq!(
640            entry["locations"][0]["physicalLocation"]["artifactLocation"]["uri"],
641            "package.json"
642        );
643    }
644
645    #[test]
646    fn sarif_dependency_issues_point_to_package_json() {
647        let root = PathBuf::from("/project");
648        let mut results = AnalysisResults::default();
649        results.unused_dependencies.push(UnusedDependency {
650            package_name: "lodash".to_string(),
651            location: DependencyLocation::Dependencies,
652            path: root.join("package.json"),
653        });
654        results.unused_dev_dependencies.push(UnusedDependency {
655            package_name: "jest".to_string(),
656            location: DependencyLocation::DevDependencies,
657            path: root.join("package.json"),
658        });
659
660        let sarif = build_sarif(&results, &root, &RulesConfig::default());
661        let entries = sarif["runs"][0]["results"].as_array().unwrap();
662        for entry in entries {
663            assert_eq!(
664                entry["locations"][0]["physicalLocation"]["artifactLocation"]["uri"],
665                "package.json"
666            );
667        }
668    }
669
670    #[test]
671    fn sarif_duplicate_export_emits_one_result_per_location() {
672        let root = PathBuf::from("/project");
673        let mut results = AnalysisResults::default();
674        results.duplicate_exports.push(DuplicateExport {
675            export_name: "Config".to_string(),
676            locations: vec![root.join("src/a.ts"), root.join("src/b.ts")],
677        });
678
679        let sarif = build_sarif(&results, &root, &RulesConfig::default());
680        let entries = sarif["runs"][0]["results"].as_array().unwrap();
681        // One SARIF result per location, not one per DuplicateExport
682        assert_eq!(entries.len(), 2);
683        assert_eq!(entries[0]["ruleId"], "fallow/duplicate-export");
684        assert_eq!(entries[1]["ruleId"], "fallow/duplicate-export");
685        assert_eq!(
686            entries[0]["locations"][0]["physicalLocation"]["artifactLocation"]["uri"],
687            "src/a.ts"
688        );
689        assert_eq!(
690            entries[1]["locations"][0]["physicalLocation"]["artifactLocation"]["uri"],
691            "src/b.ts"
692        );
693    }
694
695    #[test]
696    fn sarif_all_issue_types_produce_results() {
697        let root = PathBuf::from("/project");
698        let results = sample_results(&root);
699        let sarif = build_sarif(&results, &root, &RulesConfig::default());
700
701        let entries = sarif["runs"][0]["results"].as_array().unwrap();
702        // 11 issues but duplicate_exports has 2 locations => 12 SARIF results
703        assert_eq!(entries.len(), 12);
704
705        let rule_ids: Vec<&str> = entries
706            .iter()
707            .map(|e| e["ruleId"].as_str().unwrap())
708            .collect();
709        assert!(rule_ids.contains(&"fallow/unused-file"));
710        assert!(rule_ids.contains(&"fallow/unused-export"));
711        assert!(rule_ids.contains(&"fallow/unused-type"));
712        assert!(rule_ids.contains(&"fallow/unused-dependency"));
713        assert!(rule_ids.contains(&"fallow/unused-dev-dependency"));
714        assert!(rule_ids.contains(&"fallow/unused-enum-member"));
715        assert!(rule_ids.contains(&"fallow/unused-class-member"));
716        assert!(rule_ids.contains(&"fallow/unresolved-import"));
717        assert!(rule_ids.contains(&"fallow/unlisted-dependency"));
718        assert!(rule_ids.contains(&"fallow/duplicate-export"));
719    }
720
721    #[test]
722    fn sarif_serializes_to_valid_json() {
723        let root = PathBuf::from("/project");
724        let results = sample_results(&root);
725        let sarif = build_sarif(&results, &root, &RulesConfig::default());
726
727        let json_str = serde_json::to_string_pretty(&sarif).expect("SARIF should serialize");
728        let reparsed: serde_json::Value =
729            serde_json::from_str(&json_str).expect("SARIF output should be valid JSON");
730        assert_eq!(reparsed, sarif);
731    }
732
733    #[test]
734    fn sarif_file_write_produces_valid_sarif() {
735        let root = PathBuf::from("/project");
736        let results = sample_results(&root);
737        let sarif = build_sarif(&results, &root, &RulesConfig::default());
738        let json_str = serde_json::to_string_pretty(&sarif).expect("SARIF should serialize");
739
740        let dir = std::env::temp_dir().join("fallow-test-sarif-file");
741        let _ = std::fs::create_dir_all(&dir);
742        let sarif_path = dir.join("results.sarif");
743        std::fs::write(&sarif_path, &json_str).expect("should write SARIF file");
744
745        let contents = std::fs::read_to_string(&sarif_path).expect("should read SARIF file");
746        let parsed: serde_json::Value =
747            serde_json::from_str(&contents).expect("file should contain valid JSON");
748
749        assert_eq!(parsed["version"], "2.1.0");
750        assert_eq!(
751            parsed["$schema"],
752            "https://json.schemastore.org/sarif-2.1.0.json"
753        );
754        let sarif_results = parsed["runs"][0]["results"]
755            .as_array()
756            .expect("results should be an array");
757        assert!(!sarif_results.is_empty());
758
759        // Clean up
760        let _ = std::fs::remove_file(&sarif_path);
761        let _ = std::fs::remove_dir(&dir);
762    }
763}