1use crate::config::OutputFormat;
2use crate::types::{AnalysisReport, GapSeverity, TestGap};
3use owo_colors::OwoColorize;
4use std::path::Path;
5
6#[derive(Debug, Clone, Copy, PartialEq, Eq)]
7pub enum ColorMode {
8 Auto,
9 Always,
10 Never,
11}
12
13impl ColorMode {
14 pub fn should_color(self) -> bool {
15 match self {
16 ColorMode::Always => true,
17 ColorMode::Never => false,
18 ColorMode::Auto => {
19 if std::env::var_os("NO_COLOR").is_some() {
20 return false;
21 }
22 supports_color::on(supports_color::Stream::Stdout).is_some()
23 }
24 }
25 }
26}
27
28pub fn print_report(report: &AnalysisReport, format: OutputFormat, color: ColorMode) {
29 match format {
30 OutputFormat::Human => print_human(report, color.should_color()),
31 OutputFormat::Json => print_json(report),
32 OutputFormat::Markdown => print_markdown(report),
33 OutputFormat::Sarif => print_sarif(report),
34 OutputFormat::Github => print_github(report),
35 }
36}
37
38fn coverage_bar(pct: f64, use_color: bool) -> String {
39 const WIDTH: usize = 20;
40 let filled = ((pct / 100.0) * WIDTH as f64).round() as usize;
41 let filled = filled.min(WIDTH);
42 let empty = WIDTH - filled;
43
44 let bar_filled = "\u{2588}".repeat(filled);
45 let bar_empty = "\u{2591}".repeat(empty);
46 let pct_str = format!("{pct:.1}%");
47
48 if use_color {
49 format!(
50 "[{}{}] {}",
51 bar_filled.green(),
52 bar_empty.dimmed(),
53 pct_str.green().bold(),
54 )
55 } else {
56 format!("[{bar_filled}{bar_empty}] {pct_str}")
57 }
58}
59
60fn print_human(report: &AnalysisReport, use_color: bool) {
61 println!();
62 if use_color {
63 println!(
64 " {} {}",
65 "\u{25C8}".bold(),
66 "testgap \u{2014} Test Gap Analysis".bold()
67 );
68 } else {
69 println!(" testgap \u{2014} Test Gap Analysis");
70 }
71 println!(" {}", "\u{2500}".repeat(40));
72 println!(" Project: {}", report.project_path.display());
73 if let Some(ref base) = report.diff_base {
74 println!(" Diff base: {base}");
75 }
76 println!(
77 " Languages: {}",
78 report
79 .languages_analyzed
80 .iter()
81 .map(|l| l.name())
82 .collect::<Vec<_>>()
83 .join(", ")
84 );
85 println!(
86 " Coverage: {}",
87 coverage_bar(report.coverage_percent(), use_color),
88 );
89 println!(
90 " AI: {}",
91 if report.ai_enabled {
92 "enabled"
93 } else {
94 "disabled"
95 }
96 );
97 println!();
98
99 if report.gaps.is_empty() {
100 if use_color {
101 println!(" {} No test gaps found!", "\u{2714}".green().bold());
102 } else {
103 println!(" No test gaps found!");
104 }
105 println!();
106 return;
107 }
108
109 let critical = report.gaps_by_severity(GapSeverity::Critical);
110 let warnings = report.gaps_by_severity(GapSeverity::Warning);
111 let info = report.gaps_by_severity(GapSeverity::Info);
112
113 if !critical.is_empty() {
114 let header = format!("\u{2716} CRITICAL ({}) \u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}", critical.len());
115 if use_color {
116 println!(" {}", header.red().bold());
117 } else {
118 println!(" {header}");
119 }
120 for gap in &critical {
121 print_gap_human(gap, use_color);
122 }
123 println!();
124 }
125
126 if !warnings.is_empty() {
127 let header = format!("\u{25B2} WARNING ({}) \u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}", warnings.len());
128 if use_color {
129 println!(" {}", header.yellow().bold());
130 } else {
131 println!(" {header}");
132 }
133 for gap in &warnings {
134 print_gap_human(gap, use_color);
135 }
136 println!();
137 }
138
139 if !info.is_empty() {
140 let header = format!("\u{25CF} INFO ({}) \u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}", info.len());
141 if use_color {
142 println!(" {}", header.dimmed());
143 } else {
144 println!(" {header}");
145 }
146 for gap in &info {
147 print_gap_human(gap, use_color);
148 }
149 println!();
150 }
151
152 if use_color {
154 println!(
155 " Summary: {} critical, {} warning, {} info",
156 critical.len().to_string().red().bold(),
157 warnings.len().to_string().yellow().bold(),
158 info.len().to_string().dimmed(),
159 );
160 } else {
161 println!(
162 " Summary: {} critical, {} warning, {} info",
163 critical.len(),
164 warnings.len(),
165 info.len()
166 );
167 }
168
169 if let Some(ref usage) = report.token_usage {
170 println!(
171 " Tokens: {} input, {} output",
172 usage.input_tokens, usage.output_tokens
173 );
174 }
175 println!();
176}
177
178fn print_gap_human(gap: &TestGap, use_color: bool) {
179 let f = &gap.function;
180 if use_color {
181 println!(
182 " {} {}",
183 f.name.bold(),
184 format!("{}:{}", f.file_path.display(), f.line_start).dimmed(),
185 );
186 } else {
187 println!(" {} {}:{}", f.name, f.file_path.display(), f.line_start);
188 }
189 println!(" {}", gap.reason);
190 println!(" Signature: {}", truncate(&f.signature, 80));
191 if use_color && f.complexity >= 5 {
192 println!(" Complexity: {}", f.complexity.yellow());
193 } else {
194 println!(" Complexity: {}", f.complexity);
195 }
196
197 if let Some(ref ai) = gap.ai_analysis {
198 println!(" AI Risk: {}", ai.risk_assessment);
199 println!(" Priority: {}/10", ai.priority_score);
200 if !ai.suggested_tests.is_empty() {
201 println!(" Suggested tests:");
202 for test in &ai.suggested_tests {
203 println!(" - {test}");
204 }
205 }
206 }
207 println!();
208}
209
210fn print_json(report: &AnalysisReport) {
211 match serde_json::to_string_pretty(report) {
212 Ok(json) => println!("{json}"),
213 Err(e) => eprintln!("Failed to serialize report: {e}"),
214 }
215}
216
217fn print_markdown(report: &AnalysisReport) {
218 println!("# Test Gap Analysis Report");
219 println!();
220 println!("**Project:** `{}`", report.project_path.display());
221 if let Some(ref base) = report.diff_base {
222 println!("**Diff base:** `{base}`");
223 }
224 println!(
225 "**Languages:** {}",
226 report
227 .languages_analyzed
228 .iter()
229 .map(|l| l.name())
230 .collect::<Vec<_>>()
231 .join(", ")
232 );
233 println!(
234 "**Coverage:** {}/{} functions ({:.1}%)",
235 report.tested_functions,
236 report.total_functions,
237 report.coverage_percent()
238 );
239 println!(
240 "**AI Analysis:** {}",
241 if report.ai_enabled {
242 "enabled"
243 } else {
244 "disabled"
245 }
246 );
247 println!();
248
249 if report.gaps.is_empty() {
250 println!("No test gaps found!");
251 return;
252 }
253
254 let critical = report.gaps_by_severity(GapSeverity::Critical);
255 let warnings = report.gaps_by_severity(GapSeverity::Warning);
256 let info = report.gaps_by_severity(GapSeverity::Info);
257
258 if !critical.is_empty() {
259 println!("## Critical ({count})", count = critical.len());
260 println!();
261 for gap in &critical {
262 print_gap_markdown(gap);
263 }
264 }
265
266 if !warnings.is_empty() {
267 println!("## Warning ({count})", count = warnings.len());
268 println!();
269 for gap in &warnings {
270 print_gap_markdown(gap);
271 }
272 }
273
274 if !info.is_empty() {
275 println!(
276 "<details>\n<summary>Info ({count})</summary>\n",
277 count = info.len()
278 );
279 for gap in &info {
280 print_gap_markdown(gap);
281 }
282 println!("</details>");
283 }
284
285 if let Some(ref usage) = report.token_usage {
286 println!();
287 println!(
288 "---\n*AI tokens used: {} input, {} output*",
289 usage.input_tokens, usage.output_tokens
290 );
291 }
292}
293
294fn print_gap_markdown(gap: &TestGap) {
295 let f = &gap.function;
296 println!(
297 "### `{}` \u{2014} `{}:{}`",
298 f.name,
299 f.file_path.display(),
300 f.line_start
301 );
302 println!();
303 println!("- **Severity:** {}", gap.severity);
304 println!("- **Reason:** {}", gap.reason);
305 println!("- **Signature:** `{}`", truncate(&f.signature, 100));
306 println!("- **Complexity:** {}", f.complexity);
307
308 if let Some(ref ai) = gap.ai_analysis {
309 println!("- **AI Risk:** {}", ai.risk_assessment);
310 println!("- **Priority:** {}/10", ai.priority_score);
311 if !ai.suggested_tests.is_empty() {
312 println!("- **Suggested tests:**");
313 for test in &ai.suggested_tests {
314 println!(" - {test}");
315 }
316 }
317 }
318 println!();
319}
320
321fn print_sarif(report: &AnalysisReport) {
324 let sarif = build_sarif(report);
325 match serde_json::to_string_pretty(&sarif) {
326 Ok(json) => println!("{json}"),
327 Err(e) => eprintln!("Failed to serialize SARIF: {e}"),
328 }
329}
330
331pub fn build_sarif(report: &AnalysisReport) -> serde_json::Value {
332 let rules = serde_json::json!([
333 {
334 "id": "testgap/critical",
335 "shortDescription": { "text": "Critical test gap" },
336 "defaultConfiguration": { "level": "error" }
337 },
338 {
339 "id": "testgap/warning",
340 "shortDescription": { "text": "Warning test gap" },
341 "defaultConfiguration": { "level": "warning" }
342 },
343 {
344 "id": "testgap/info",
345 "shortDescription": { "text": "Informational test gap" },
346 "defaultConfiguration": { "level": "note" }
347 }
348 ]);
349
350 let project_path = &report.project_path;
351
352 let results: Vec<serde_json::Value> = report
353 .gaps
354 .iter()
355 .map(|gap| {
356 let (rule_id, level) = match gap.severity {
357 GapSeverity::Critical => ("testgap/critical", "error"),
358 GapSeverity::Warning => ("testgap/warning", "warning"),
359 GapSeverity::Info => ("testgap/info", "note"),
360 };
361
362 let rel_path = make_relative(&gap.function.file_path, project_path);
363
364 serde_json::json!({
365 "ruleId": rule_id,
366 "level": level,
367 "message": {
368 "text": format!("Untested function `{}`: {}", gap.function.name, gap.reason)
369 },
370 "locations": [{
371 "physicalLocation": {
372 "artifactLocation": {
373 "uri": rel_path,
374 "uriBaseId": "%SRCROOT%"
375 },
376 "region": {
377 "startLine": gap.function.line_start,
378 "endLine": gap.function.line_end
379 }
380 }
381 }]
382 })
383 })
384 .collect();
385
386 serde_json::json!({
387 "$schema": "https://raw.githubusercontent.com/oasis-tcs/sarif-spec/main/sarif-2.1/schema/sarif-schema-2.1.0.json",
388 "version": "2.1.0",
389 "runs": [{
390 "tool": {
391 "driver": {
392 "name": "testgap",
393 "version": env!("CARGO_PKG_VERSION"),
394 "rules": rules
395 }
396 },
397 "results": results
398 }]
399 })
400}
401
402fn print_github(report: &AnalysisReport) {
405 let project_path = &report.project_path;
406 for gap in &report.gaps {
407 println!("{}", format_github_line(gap, project_path));
408 }
409}
410
411pub fn format_github_line(gap: &TestGap, project_path: &Path) -> String {
412 let cmd = match gap.severity {
413 GapSeverity::Critical => "error",
414 GapSeverity::Warning => "warning",
415 GapSeverity::Info => "notice",
416 };
417
418 let f = &gap.function;
419 let rel_path = make_relative(&f.file_path, project_path);
420 format!(
421 "::{cmd} file={rel_path},line={line},endLine={end},title=Untested: {name}::{reason}",
422 line = f.line_start,
423 end = f.line_end,
424 name = f.name,
425 reason = gap.reason,
426 )
427}
428
429fn make_relative(abs: &Path, project_path: &Path) -> String {
431 abs.strip_prefix(project_path)
432 .unwrap_or(abs)
433 .display()
434 .to_string()
435}
436
437fn truncate(s: &str, max: usize) -> String {
438 if s.len() <= max {
439 s.to_string()
440 } else {
441 let limit = max.saturating_sub(3);
442 let boundary = s[..limit]
443 .char_indices()
444 .last()
445 .map(|(i, _)| i)
446 .unwrap_or(0);
447 format!("{}...", &s[..boundary])
448 }
449}
450
451#[cfg(test)]
452mod tests {
453 use super::*;
454 use crate::types::*;
455 use std::path::PathBuf;
456
457 fn make_gap(name: &str, severity: GapSeverity, complexity: u32) -> TestGap {
458 TestGap {
459 function: ExtractedFunction {
460 name: name.to_string(),
461 file_path: PathBuf::from("src/lib.rs"),
462 line_start: 1,
463 line_end: 20,
464 signature: format!("pub fn {name}()"),
465 body: " some body code\n more code\n even more\n end".to_string(),
466 language: Language::Rust,
467 is_public: true,
468 is_test: false,
469 complexity,
470 },
471 severity,
472 reason: format!("Test reason for {name}"),
473 ai_analysis: None,
474 }
475 }
476
477 #[test]
478 fn json_round_trip() {
479 let report = AnalysisReport {
480 project_path: PathBuf::from("/tmp/test-project"),
481 total_functions: 10,
482 tested_functions: 7,
483 gaps: vec![
484 make_gap("untested_critical", GapSeverity::Critical, 8),
485 make_gap("untested_warning", GapSeverity::Warning, 3),
486 make_gap("untested_info", GapSeverity::Info, 2),
487 ],
488 languages_analyzed: vec![Language::Rust, Language::TypeScript],
489 ai_enabled: false,
490 token_usage: None,
491 diff_base: None,
492 };
493
494 let json = serde_json::to_string(&report).expect("should serialize to JSON");
495 let deserialized: AnalysisReport =
496 serde_json::from_str(&json).expect("should deserialize from JSON");
497
498 assert_eq!(deserialized.total_functions, 10);
499 assert_eq!(deserialized.tested_functions, 7);
500 assert_eq!(deserialized.gaps.len(), 3);
501 assert_eq!(deserialized.gaps[0].severity, GapSeverity::Critical);
502 assert_eq!(deserialized.gaps[0].function.name, "untested_critical");
503 assert_eq!(deserialized.gaps[1].severity, GapSeverity::Warning);
504 assert_eq!(deserialized.gaps[2].severity, GapSeverity::Info);
505 assert!(!deserialized.ai_enabled);
506 assert!(deserialized.token_usage.is_none());
507 }
508
509 #[test]
510 fn json_round_trip_with_ai_analysis() {
511 let mut gap = make_gap("risky_func", GapSeverity::Critical, 10);
512 gap.ai_analysis = Some(AiAnalysis {
513 risk_assessment: "High risk due to complex branching".to_string(),
514 suggested_tests: vec![
515 "test boundary conditions".to_string(),
516 "test error paths".to_string(),
517 ],
518 priority_score: 9,
519 reasoning: "Multiple code paths untested".to_string(),
520 });
521
522 let report = AnalysisReport {
523 project_path: PathBuf::from("/tmp/ai-project"),
524 total_functions: 5,
525 tested_functions: 2,
526 gaps: vec![gap],
527 languages_analyzed: vec![Language::Rust],
528 ai_enabled: true,
529 token_usage: Some(TokenUsage {
530 input_tokens: 1500,
531 output_tokens: 300,
532 }),
533 diff_base: None,
534 };
535
536 let json = serde_json::to_string(&report).expect("should serialize");
537 let deserialized: AnalysisReport = serde_json::from_str(&json).expect("should deserialize");
538
539 assert!(deserialized.ai_enabled);
540 assert!(deserialized.token_usage.is_some());
541 let usage = deserialized.token_usage.unwrap();
542 assert_eq!(usage.input_tokens, 1500);
543 assert_eq!(usage.output_tokens, 300);
544
545 let ai = deserialized.gaps[0].ai_analysis.as_ref().unwrap();
546 assert_eq!(ai.priority_score, 9);
547 assert_eq!(ai.suggested_tests.len(), 2);
548 }
549
550 #[test]
551 fn json_does_not_panic_on_long_signature() {
552 let mut gap = make_gap("long_sig_func", GapSeverity::Warning, 3);
553 gap.function.signature = "a".repeat(500);
554
555 let report = AnalysisReport {
556 project_path: PathBuf::from("/tmp/long-sig"),
557 total_functions: 1,
558 tested_functions: 0,
559 gaps: vec![gap],
560 languages_analyzed: vec![Language::Rust],
561 ai_enabled: false,
562 token_usage: None,
563 diff_base: None,
564 };
565
566 let json =
568 serde_json::to_string(&report).expect("should serialize even with long signature");
569 assert!(!json.is_empty());
570
571 let deserialized: AnalysisReport = serde_json::from_str(&json).expect("should deserialize");
572 assert_eq!(deserialized.gaps[0].function.signature.len(), 500);
573 }
574
575 #[test]
576 fn coverage_percent_calculation() {
577 let report = AnalysisReport {
578 project_path: PathBuf::from("/tmp/cov"),
579 total_functions: 10,
580 tested_functions: 7,
581 gaps: vec![],
582 languages_analyzed: vec![Language::Rust],
583 ai_enabled: false,
584 token_usage: None,
585 diff_base: None,
586 };
587
588 let pct = report.coverage_percent();
589 assert!((pct - 70.0).abs() < 0.01, "expected ~70%, got {}", pct);
590 }
591
592 #[test]
593 fn empty_report_json_round_trip() {
594 let report = AnalysisReport {
595 project_path: PathBuf::from("/tmp/empty"),
596 total_functions: 0,
597 tested_functions: 0,
598 gaps: vec![],
599 languages_analyzed: vec![],
600 ai_enabled: false,
601 token_usage: None,
602 diff_base: None,
603 };
604
605 let json = serde_json::to_string(&report).expect("should serialize empty report");
606 let deserialized: AnalysisReport =
607 serde_json::from_str(&json).expect("should deserialize empty report");
608
609 assert_eq!(deserialized.total_functions, 0);
610 assert_eq!(deserialized.gaps.len(), 0);
611 assert_eq!(deserialized.coverage_percent(), 100.0);
612 }
613
614 #[test]
615 fn color_mode_never_disables_color() {
616 assert!(!ColorMode::Never.should_color());
617 }
618
619 #[test]
620 fn color_mode_always_enables_color() {
621 assert!(ColorMode::Always.should_color());
622 }
623
624 #[test]
625 fn coverage_bar_plain() {
626 let bar = coverage_bar(50.0, false);
627 assert!(bar.contains("["));
628 assert!(bar.contains("]"));
629 assert!(bar.contains("50.0%"));
630 }
631
632 #[test]
633 fn coverage_bar_zero() {
634 let bar = coverage_bar(0.0, false);
635 assert!(bar.contains("0.0%"));
636 }
637
638 #[test]
639 fn coverage_bar_hundred() {
640 let bar = coverage_bar(100.0, false);
641 assert!(bar.contains("100.0%"));
642 }
643
644 #[test]
647 fn sarif_schema_version() {
648 let report = AnalysisReport {
649 project_path: PathBuf::from("/tmp/sarif"),
650 total_functions: 1,
651 tested_functions: 0,
652 gaps: vec![make_gap("func_a", GapSeverity::Critical, 5)],
653 languages_analyzed: vec![Language::Rust],
654 ai_enabled: false,
655 token_usage: None,
656 diff_base: None,
657 };
658 let sarif = build_sarif(&report);
659 assert_eq!(sarif["version"], "2.1.0");
660 assert!(sarif["$schema"]
661 .as_str()
662 .unwrap()
663 .contains("sarif-schema-2.1.0"));
664 }
665
666 #[test]
667 fn sarif_severity_mapping() {
668 let report = AnalysisReport {
669 project_path: PathBuf::from("/tmp/sarif"),
670 total_functions: 3,
671 tested_functions: 0,
672 gaps: vec![
673 make_gap("crit", GapSeverity::Critical, 5),
674 make_gap("warn", GapSeverity::Warning, 3),
675 make_gap("info_fn", GapSeverity::Info, 1),
676 ],
677 languages_analyzed: vec![Language::Rust],
678 ai_enabled: false,
679 token_usage: None,
680 diff_base: None,
681 };
682 let sarif = build_sarif(&report);
683 let results = sarif["runs"][0]["results"].as_array().unwrap();
684 assert_eq!(results.len(), 3);
685 assert_eq!(results[0]["level"], "error");
686 assert_eq!(results[0]["ruleId"], "testgap/critical");
687 assert_eq!(results[1]["level"], "warning");
688 assert_eq!(results[1]["ruleId"], "testgap/warning");
689 assert_eq!(results[2]["level"], "note");
690 assert_eq!(results[2]["ruleId"], "testgap/info");
691 }
692
693 #[test]
694 fn sarif_empty_gaps_empty_results() {
695 let report = AnalysisReport {
696 project_path: PathBuf::from("/tmp/sarif"),
697 total_functions: 5,
698 tested_functions: 5,
699 gaps: vec![],
700 languages_analyzed: vec![Language::Rust],
701 ai_enabled: false,
702 token_usage: None,
703 diff_base: None,
704 };
705 let sarif = build_sarif(&report);
706 let results = sarif["runs"][0]["results"].as_array().unwrap();
707 assert!(results.is_empty());
708 }
709
710 #[test]
713 fn github_critical_format() {
714 let gap = make_gap("risky_fn", GapSeverity::Critical, 8);
715 let project = PathBuf::from("");
716 let line = format_github_line(&gap, &project);
717 assert!(
718 line.starts_with("::error "),
719 "expected ::error, got: {line}"
720 );
721 assert!(line.contains("file=src/lib.rs"));
722 assert!(line.contains("title=Untested: risky_fn"));
723 }
724
725 #[test]
726 fn github_warning_format() {
727 let gap = make_gap("warn_fn", GapSeverity::Warning, 3);
728 let project = PathBuf::from("");
729 let line = format_github_line(&gap, &project);
730 assert!(
731 line.starts_with("::warning "),
732 "expected ::warning, got: {line}"
733 );
734 }
735
736 #[test]
737 fn github_info_format() {
738 let gap = make_gap("info_fn", GapSeverity::Info, 1);
739 let project = PathBuf::from("");
740 let line = format_github_line(&gap, &project);
741 assert!(
742 line.starts_with("::notice "),
743 "expected ::notice, got: {line}"
744 );
745 }
746
747 #[test]
748 fn github_strips_absolute_path() {
749 let mut gap = make_gap("func", GapSeverity::Critical, 5);
750 gap.function.file_path = PathBuf::from("/home/user/project/src/lib.rs");
751 let project = PathBuf::from("/home/user/project");
752 let line = format_github_line(&gap, &project);
753 assert!(
754 line.contains("file=src/lib.rs"),
755 "expected relative path, got: {line}"
756 );
757 }
758
759 #[test]
760 fn sarif_uses_relative_paths() {
761 let mut gap = make_gap("func", GapSeverity::Critical, 5);
762 gap.function.file_path = PathBuf::from("/home/user/project/src/lib.rs");
763 let report = AnalysisReport {
764 project_path: PathBuf::from("/home/user/project"),
765 total_functions: 1,
766 tested_functions: 0,
767 gaps: vec![gap],
768 languages_analyzed: vec![Language::Rust],
769 ai_enabled: false,
770 token_usage: None,
771 diff_base: None,
772 };
773 let sarif = build_sarif(&report);
774 let uri = sarif["runs"][0]["results"][0]["locations"][0]["physicalLocation"]
775 ["artifactLocation"]["uri"]
776 .as_str()
777 .unwrap();
778 assert_eq!(uri, "src/lib.rs", "SARIF should use relative paths");
779 }
780}