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