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 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 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 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 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 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 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 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 let _ = std::fs::remove_file(&sarif_path);
761 let _ = std::fs::remove_dir(&dir);
762 }
763}