Skip to main content

testgap_core/
reporter.rs

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    // Summary
153    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
321// ── SARIF output ──────────────────────────────────────────────────────
322
323fn 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
402// ── GitHub annotations output ─────────────────────────────────────────
403
404fn 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
429/// Strip project_path prefix to produce a relative path string.
430fn 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        // Should not panic
567        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    // ── SARIF tests ─────────────────────────────────────────────────
645
646    #[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    // ── GitHub annotations tests ────────────────────────────────────
711
712    #[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}