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
10struct 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
27fn 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
55fn 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 push_sarif_results(
180 &mut sarif_results,
181 &results.unused_optional_dependencies,
182 |dep| {
183 sarif_dep(
184 dep,
185 "fallow/unused-optional-dependency",
186 severity_to_sarif_level(rules.unused_optional_dependencies),
187 "optionalDependencies",
188 )
189 },
190 );
191
192 let sarif_member = |member: &UnusedMember,
193 rule_id: &'static str,
194 level: &'static str,
195 kind: &str|
196 -> SarifFields {
197 SarifFields {
198 rule_id,
199 level,
200 message: format!(
201 "{} member '{}.{}' is never referenced",
202 kind, member.parent_name, member.member_name
203 ),
204 uri: relative_uri(&member.path, root),
205 region: Some((member.line, member.col + 1)),
206 properties: None,
207 }
208 };
209
210 push_sarif_results(&mut sarif_results, &results.unused_enum_members, |member| {
211 sarif_member(
212 member,
213 "fallow/unused-enum-member",
214 severity_to_sarif_level(rules.unused_enum_members),
215 "Enum",
216 )
217 });
218
219 push_sarif_results(
220 &mut sarif_results,
221 &results.unused_class_members,
222 |member| {
223 sarif_member(
224 member,
225 "fallow/unused-class-member",
226 severity_to_sarif_level(rules.unused_class_members),
227 "Class",
228 )
229 },
230 );
231
232 push_sarif_results(&mut sarif_results, &results.unresolved_imports, |import| {
233 SarifFields {
234 rule_id: "fallow/unresolved-import",
235 level: severity_to_sarif_level(rules.unresolved_imports),
236 message: format!("Import '{}' could not be resolved", import.specifier),
237 uri: relative_uri(&import.path, root),
238 region: Some((import.line, import.col + 1)),
239 properties: None,
240 }
241 });
242
243 push_sarif_results(&mut sarif_results, &results.unlisted_dependencies, |dep| {
244 SarifFields {
245 rule_id: "fallow/unlisted-dependency",
246 level: severity_to_sarif_level(rules.unlisted_dependencies),
247 message: format!(
248 "Package '{}' is imported but not listed in package.json",
249 dep.package_name
250 ),
251 uri: "package.json".to_string(),
252 region: None,
253 properties: None,
254 }
255 });
256
257 for dup in &results.duplicate_exports {
259 for loc_path in &dup.locations {
260 sarif_results.push(sarif_result(
261 "fallow/duplicate-export",
262 severity_to_sarif_level(rules.duplicate_exports),
263 &format!("Export '{}' appears in multiple modules", dup.export_name),
264 &relative_uri(loc_path, root),
265 None,
266 ));
267 }
268 }
269
270 push_sarif_results(
271 &mut sarif_results,
272 &results.circular_dependencies,
273 |cycle| {
274 let chain: Vec<String> = cycle.files.iter().map(|p| relative_uri(p, root)).collect();
275 let mut display_chain = chain.clone();
276 if let Some(first) = chain.first() {
277 display_chain.push(first.clone());
278 }
279 let first_uri = chain.first().map_or_else(String::new, Clone::clone);
280 SarifFields {
281 rule_id: "fallow/circular-dependency",
282 level: severity_to_sarif_level(rules.circular_dependencies),
283 message: format!("Circular dependency: {}", display_chain.join(" \u{2192} ")),
284 uri: first_uri,
285 region: None,
286 properties: None,
287 }
288 },
289 );
290
291 serde_json::json!({
292 "$schema": "https://json.schemastore.org/sarif-2.1.0.json",
293 "version": "2.1.0",
294 "runs": [{
295 "tool": {
296 "driver": {
297 "name": "fallow",
298 "version": env!("CARGO_PKG_VERSION"),
299 "informationUri": "https://github.com/fallow-rs/fallow",
300 "rules": [
301 {
302 "id": "fallow/unused-file",
303 "shortDescription": { "text": "File is not reachable from any entry point" },
304 "defaultConfiguration": { "level": severity_to_sarif_level(rules.unused_files) }
305 },
306 {
307 "id": "fallow/unused-export",
308 "shortDescription": { "text": "Export is never imported" },
309 "defaultConfiguration": { "level": severity_to_sarif_level(rules.unused_exports) }
310 },
311 {
312 "id": "fallow/unused-type",
313 "shortDescription": { "text": "Type export is never imported" },
314 "defaultConfiguration": { "level": severity_to_sarif_level(rules.unused_types) }
315 },
316 {
317 "id": "fallow/unused-dependency",
318 "shortDescription": { "text": "Dependency listed but never imported" },
319 "defaultConfiguration": { "level": severity_to_sarif_level(rules.unused_dependencies) }
320 },
321 {
322 "id": "fallow/unused-dev-dependency",
323 "shortDescription": { "text": "Dev dependency listed but never imported" },
324 "defaultConfiguration": { "level": severity_to_sarif_level(rules.unused_dev_dependencies) }
325 },
326 {
327 "id": "fallow/unused-optional-dependency",
328 "shortDescription": { "text": "Optional dependency listed but never imported" },
329 "defaultConfiguration": { "level": severity_to_sarif_level(rules.unused_optional_dependencies) }
330 },
331 {
332 "id": "fallow/unused-enum-member",
333 "shortDescription": { "text": "Enum member is never referenced" },
334 "defaultConfiguration": { "level": severity_to_sarif_level(rules.unused_enum_members) }
335 },
336 {
337 "id": "fallow/unused-class-member",
338 "shortDescription": { "text": "Class member is never referenced" },
339 "defaultConfiguration": { "level": severity_to_sarif_level(rules.unused_class_members) }
340 },
341 {
342 "id": "fallow/unresolved-import",
343 "shortDescription": { "text": "Import could not be resolved" },
344 "defaultConfiguration": { "level": severity_to_sarif_level(rules.unresolved_imports) }
345 },
346 {
347 "id": "fallow/unlisted-dependency",
348 "shortDescription": { "text": "Dependency used but not in package.json" },
349 "defaultConfiguration": { "level": severity_to_sarif_level(rules.unlisted_dependencies) }
350 },
351 {
352 "id": "fallow/duplicate-export",
353 "shortDescription": { "text": "Export name appears in multiple modules" },
354 "defaultConfiguration": { "level": severity_to_sarif_level(rules.duplicate_exports) }
355 },
356 {
357 "id": "fallow/circular-dependency",
358 "shortDescription": { "text": "Circular dependency chain detected" },
359 "defaultConfiguration": { "level": severity_to_sarif_level(rules.circular_dependencies) }
360 }
361 ]
362 }
363 },
364 "results": sarif_results
365 }]
366 })
367}
368
369pub(super) fn print_sarif(results: &AnalysisResults, root: &Path, rules: &RulesConfig) -> ExitCode {
370 let sarif = build_sarif(results, root, rules);
371 match serde_json::to_string_pretty(&sarif) {
372 Ok(json) => {
373 println!("{json}");
374 ExitCode::SUCCESS
375 }
376 Err(e) => {
377 eprintln!("Error: failed to serialize SARIF output: {e}");
378 ExitCode::from(2)
379 }
380 }
381}
382
383pub(super) fn print_duplication_sarif(report: &DuplicationReport, root: &Path) -> ExitCode {
384 let mut sarif_results = Vec::new();
385
386 for (i, group) in report.clone_groups.iter().enumerate() {
387 for instance in &group.instances {
388 sarif_results.push(sarif_result(
389 "fallow/code-duplication",
390 "warning",
391 &format!(
392 "Code clone group {} ({} lines, {} instances)",
393 i + 1,
394 group.line_count,
395 group.instances.len()
396 ),
397 &relative_uri(&instance.file, root),
398 Some((instance.start_line as u32, (instance.start_col + 1) as u32)),
399 ));
400 }
401 }
402
403 let sarif = serde_json::json!({
404 "$schema": "https://json.schemastore.org/sarif-2.1.0.json",
405 "version": "2.1.0",
406 "runs": [{
407 "tool": {
408 "driver": {
409 "name": "fallow",
410 "version": env!("CARGO_PKG_VERSION"),
411 "informationUri": "https://github.com/fallow-rs/fallow",
412 "rules": [{
413 "id": "fallow/code-duplication",
414 "shortDescription": { "text": "Duplicated code block" },
415 "defaultConfiguration": { "level": "warning" }
416 }]
417 }
418 },
419 "results": sarif_results
420 }]
421 });
422
423 match serde_json::to_string_pretty(&sarif) {
424 Ok(json) => {
425 println!("{json}");
426 ExitCode::SUCCESS
427 }
428 Err(e) => {
429 eprintln!("Error: failed to serialize SARIF output: {e}");
430 ExitCode::from(2)
431 }
432 }
433}
434
435#[cfg(test)]
436mod tests {
437 use super::*;
438 use fallow_core::extract::MemberKind;
439 use fallow_core::results::*;
440 use std::path::PathBuf;
441
442 fn sample_results(root: &Path) -> AnalysisResults {
444 let mut r = AnalysisResults::default();
445
446 r.unused_files.push(UnusedFile {
447 path: root.join("src/dead.ts"),
448 });
449 r.unused_exports.push(UnusedExport {
450 path: root.join("src/utils.ts"),
451 export_name: "helperFn".to_string(),
452 is_type_only: false,
453 line: 10,
454 col: 4,
455 span_start: 120,
456 is_re_export: false,
457 });
458 r.unused_types.push(UnusedExport {
459 path: root.join("src/types.ts"),
460 export_name: "OldType".to_string(),
461 is_type_only: true,
462 line: 5,
463 col: 0,
464 span_start: 60,
465 is_re_export: false,
466 });
467 r.unused_dependencies.push(UnusedDependency {
468 package_name: "lodash".to_string(),
469 location: DependencyLocation::Dependencies,
470 path: root.join("package.json"),
471 });
472 r.unused_dev_dependencies.push(UnusedDependency {
473 package_name: "jest".to_string(),
474 location: DependencyLocation::DevDependencies,
475 path: root.join("package.json"),
476 });
477 r.unused_enum_members.push(UnusedMember {
478 path: root.join("src/enums.ts"),
479 parent_name: "Status".to_string(),
480 member_name: "Deprecated".to_string(),
481 kind: MemberKind::EnumMember,
482 line: 8,
483 col: 2,
484 });
485 r.unused_class_members.push(UnusedMember {
486 path: root.join("src/service.ts"),
487 parent_name: "UserService".to_string(),
488 member_name: "legacyMethod".to_string(),
489 kind: MemberKind::ClassMethod,
490 line: 42,
491 col: 4,
492 });
493 r.unresolved_imports.push(UnresolvedImport {
494 path: root.join("src/app.ts"),
495 specifier: "./missing-module".to_string(),
496 line: 3,
497 col: 0,
498 });
499 r.unlisted_dependencies.push(UnlistedDependency {
500 package_name: "chalk".to_string(),
501 imported_from: vec![root.join("src/cli.ts")],
502 });
503 r.duplicate_exports.push(DuplicateExport {
504 export_name: "Config".to_string(),
505 locations: vec![root.join("src/config.ts"), root.join("src/types.ts")],
506 });
507 r.circular_dependencies.push(CircularDependency {
508 files: vec![root.join("src/a.ts"), root.join("src/b.ts")],
509 length: 2,
510 });
511
512 r
513 }
514
515 #[test]
516 fn sarif_has_required_top_level_fields() {
517 let root = PathBuf::from("/project");
518 let results = AnalysisResults::default();
519 let sarif = build_sarif(&results, &root, &RulesConfig::default());
520
521 assert_eq!(
522 sarif["$schema"],
523 "https://json.schemastore.org/sarif-2.1.0.json"
524 );
525 assert_eq!(sarif["version"], "2.1.0");
526 assert!(sarif["runs"].is_array());
527 }
528
529 #[test]
530 fn sarif_has_tool_driver_info() {
531 let root = PathBuf::from("/project");
532 let results = AnalysisResults::default();
533 let sarif = build_sarif(&results, &root, &RulesConfig::default());
534
535 let driver = &sarif["runs"][0]["tool"]["driver"];
536 assert_eq!(driver["name"], "fallow");
537 assert!(driver["version"].is_string());
538 assert_eq!(
539 driver["informationUri"],
540 "https://github.com/fallow-rs/fallow"
541 );
542 }
543
544 #[test]
545 fn sarif_declares_all_rules() {
546 let root = PathBuf::from("/project");
547 let results = AnalysisResults::default();
548 let sarif = build_sarif(&results, &root, &RulesConfig::default());
549
550 let rules = sarif["runs"][0]["tool"]["driver"]["rules"]
551 .as_array()
552 .expect("rules should be an array");
553 assert_eq!(rules.len(), 12);
554
555 let rule_ids: Vec<&str> = rules.iter().map(|r| r["id"].as_str().unwrap()).collect();
556 assert!(rule_ids.contains(&"fallow/unused-file"));
557 assert!(rule_ids.contains(&"fallow/unused-export"));
558 assert!(rule_ids.contains(&"fallow/unused-type"));
559 assert!(rule_ids.contains(&"fallow/unused-dependency"));
560 assert!(rule_ids.contains(&"fallow/unused-dev-dependency"));
561 assert!(rule_ids.contains(&"fallow/unused-optional-dependency"));
562 assert!(rule_ids.contains(&"fallow/unused-enum-member"));
563 assert!(rule_ids.contains(&"fallow/unused-class-member"));
564 assert!(rule_ids.contains(&"fallow/unresolved-import"));
565 assert!(rule_ids.contains(&"fallow/unlisted-dependency"));
566 assert!(rule_ids.contains(&"fallow/duplicate-export"));
567 assert!(rule_ids.contains(&"fallow/circular-dependency"));
568 }
569
570 #[test]
571 fn sarif_empty_results_no_results_entries() {
572 let root = PathBuf::from("/project");
573 let results = AnalysisResults::default();
574 let sarif = build_sarif(&results, &root, &RulesConfig::default());
575
576 let sarif_results = sarif["runs"][0]["results"]
577 .as_array()
578 .expect("results should be an array");
579 assert!(sarif_results.is_empty());
580 }
581
582 #[test]
583 fn sarif_unused_file_result() {
584 let root = PathBuf::from("/project");
585 let mut results = AnalysisResults::default();
586 results.unused_files.push(UnusedFile {
587 path: root.join("src/dead.ts"),
588 });
589
590 let sarif = build_sarif(&results, &root, &RulesConfig::default());
591 let entries = sarif["runs"][0]["results"].as_array().unwrap();
592 assert_eq!(entries.len(), 1);
593
594 let entry = &entries[0];
595 assert_eq!(entry["ruleId"], "fallow/unused-file");
596 assert_eq!(entry["level"], "error");
598 assert_eq!(
599 entry["locations"][0]["physicalLocation"]["artifactLocation"]["uri"],
600 "src/dead.ts"
601 );
602 }
603
604 #[test]
605 fn sarif_unused_export_includes_region() {
606 let root = PathBuf::from("/project");
607 let mut results = AnalysisResults::default();
608 results.unused_exports.push(UnusedExport {
609 path: root.join("src/utils.ts"),
610 export_name: "helperFn".to_string(),
611 is_type_only: false,
612 line: 10,
613 col: 4,
614 span_start: 120,
615 is_re_export: false,
616 });
617
618 let sarif = build_sarif(&results, &root, &RulesConfig::default());
619 let entry = &sarif["runs"][0]["results"][0];
620 assert_eq!(entry["ruleId"], "fallow/unused-export");
621
622 let region = &entry["locations"][0]["physicalLocation"]["region"];
623 assert_eq!(region["startLine"], 10);
624 assert_eq!(region["startColumn"], 5);
626 }
627
628 #[test]
629 fn sarif_unresolved_import_is_error_level() {
630 let root = PathBuf::from("/project");
631 let mut results = AnalysisResults::default();
632 results.unresolved_imports.push(UnresolvedImport {
633 path: root.join("src/app.ts"),
634 specifier: "./missing".to_string(),
635 line: 1,
636 col: 0,
637 });
638
639 let sarif = build_sarif(&results, &root, &RulesConfig::default());
640 let entry = &sarif["runs"][0]["results"][0];
641 assert_eq!(entry["ruleId"], "fallow/unresolved-import");
642 assert_eq!(entry["level"], "error");
643 }
644
645 #[test]
646 fn sarif_unlisted_dependency_is_error_level() {
647 let root = PathBuf::from("/project");
648 let mut results = AnalysisResults::default();
649 results.unlisted_dependencies.push(UnlistedDependency {
650 package_name: "chalk".to_string(),
651 imported_from: vec![],
652 });
653
654 let sarif = build_sarif(&results, &root, &RulesConfig::default());
655 let entry = &sarif["runs"][0]["results"][0];
656 assert_eq!(entry["ruleId"], "fallow/unlisted-dependency");
657 assert_eq!(entry["level"], "error");
658 assert_eq!(
659 entry["locations"][0]["physicalLocation"]["artifactLocation"]["uri"],
660 "package.json"
661 );
662 }
663
664 #[test]
665 fn sarif_dependency_issues_point_to_package_json() {
666 let root = PathBuf::from("/project");
667 let mut results = AnalysisResults::default();
668 results.unused_dependencies.push(UnusedDependency {
669 package_name: "lodash".to_string(),
670 location: DependencyLocation::Dependencies,
671 path: root.join("package.json"),
672 });
673 results.unused_dev_dependencies.push(UnusedDependency {
674 package_name: "jest".to_string(),
675 location: DependencyLocation::DevDependencies,
676 path: root.join("package.json"),
677 });
678
679 let sarif = build_sarif(&results, &root, &RulesConfig::default());
680 let entries = sarif["runs"][0]["results"].as_array().unwrap();
681 for entry in entries {
682 assert_eq!(
683 entry["locations"][0]["physicalLocation"]["artifactLocation"]["uri"],
684 "package.json"
685 );
686 }
687 }
688
689 #[test]
690 fn sarif_duplicate_export_emits_one_result_per_location() {
691 let root = PathBuf::from("/project");
692 let mut results = AnalysisResults::default();
693 results.duplicate_exports.push(DuplicateExport {
694 export_name: "Config".to_string(),
695 locations: vec![root.join("src/a.ts"), root.join("src/b.ts")],
696 });
697
698 let sarif = build_sarif(&results, &root, &RulesConfig::default());
699 let entries = sarif["runs"][0]["results"].as_array().unwrap();
700 assert_eq!(entries.len(), 2);
702 assert_eq!(entries[0]["ruleId"], "fallow/duplicate-export");
703 assert_eq!(entries[1]["ruleId"], "fallow/duplicate-export");
704 assert_eq!(
705 entries[0]["locations"][0]["physicalLocation"]["artifactLocation"]["uri"],
706 "src/a.ts"
707 );
708 assert_eq!(
709 entries[1]["locations"][0]["physicalLocation"]["artifactLocation"]["uri"],
710 "src/b.ts"
711 );
712 }
713
714 #[test]
715 fn sarif_all_issue_types_produce_results() {
716 let root = PathBuf::from("/project");
717 let results = sample_results(&root);
718 let sarif = build_sarif(&results, &root, &RulesConfig::default());
719
720 let entries = sarif["runs"][0]["results"].as_array().unwrap();
721 assert_eq!(entries.len(), 12);
723
724 let rule_ids: Vec<&str> = entries
725 .iter()
726 .map(|e| e["ruleId"].as_str().unwrap())
727 .collect();
728 assert!(rule_ids.contains(&"fallow/unused-file"));
729 assert!(rule_ids.contains(&"fallow/unused-export"));
730 assert!(rule_ids.contains(&"fallow/unused-type"));
731 assert!(rule_ids.contains(&"fallow/unused-dependency"));
732 assert!(rule_ids.contains(&"fallow/unused-dev-dependency"));
733 assert!(rule_ids.contains(&"fallow/unused-enum-member"));
734 assert!(rule_ids.contains(&"fallow/unused-class-member"));
735 assert!(rule_ids.contains(&"fallow/unresolved-import"));
736 assert!(rule_ids.contains(&"fallow/unlisted-dependency"));
737 assert!(rule_ids.contains(&"fallow/duplicate-export"));
738 }
739
740 #[test]
741 fn sarif_serializes_to_valid_json() {
742 let root = PathBuf::from("/project");
743 let results = sample_results(&root);
744 let sarif = build_sarif(&results, &root, &RulesConfig::default());
745
746 let json_str = serde_json::to_string_pretty(&sarif).expect("SARIF should serialize");
747 let reparsed: serde_json::Value =
748 serde_json::from_str(&json_str).expect("SARIF output should be valid JSON");
749 assert_eq!(reparsed, sarif);
750 }
751
752 #[test]
753 fn sarif_file_write_produces_valid_sarif() {
754 let root = PathBuf::from("/project");
755 let results = sample_results(&root);
756 let sarif = build_sarif(&results, &root, &RulesConfig::default());
757 let json_str = serde_json::to_string_pretty(&sarif).expect("SARIF should serialize");
758
759 let dir = std::env::temp_dir().join("fallow-test-sarif-file");
760 let _ = std::fs::create_dir_all(&dir);
761 let sarif_path = dir.join("results.sarif");
762 std::fs::write(&sarif_path, &json_str).expect("should write SARIF file");
763
764 let contents = std::fs::read_to_string(&sarif_path).expect("should read SARIF file");
765 let parsed: serde_json::Value =
766 serde_json::from_str(&contents).expect("file should contain valid JSON");
767
768 assert_eq!(parsed["version"], "2.1.0");
769 assert_eq!(
770 parsed["$schema"],
771 "https://json.schemastore.org/sarif-2.1.0.json"
772 );
773 let sarif_results = parsed["runs"][0]["results"]
774 .as_array()
775 .expect("results should be an array");
776 assert!(!sarif_results.is_empty());
777
778 let _ = std::fs::remove_file(&sarif_path);
780 let _ = std::fs::remove_dir(&dir);
781 }
782}