Skip to main content

covguard_render/
lib.rs

1//! Rendering utilities for covguard reports.
2//!
3//! This crate provides renderers that convert a `Report` into various output formats:
4//! - Markdown for PR comments
5//! - GitHub workflow annotation commands
6//! - SARIF (Static Analysis Results Interchange Format)
7//!
8//! # Example
9//!
10//! ```rust
11//! use covguard_render::{render_markdown, render_annotations, render_sarif};
12//! use covguard_types::Report;
13//!
14//! let report = Report::default();
15//! let markdown = render_markdown(&report, 10);
16//! let annotations = render_annotations(&report, 25);
17//! let sarif = render_sarif(&report, 1000);
18//! ```
19
20use covguard_types::{CODE_REGISTRY, CodeInfo, Report, Severity, VerdictStatus};
21use serde::Serialize;
22
23/// Default maximum number of lines to show in markdown table.
24pub const DEFAULT_MAX_LINES: usize = 10;
25
26/// Default maximum number of GitHub annotations to emit.
27pub const DEFAULT_MAX_ANNOTATIONS: usize = 25;
28
29/// Returns an emoji representing the verdict status.
30///
31/// # Examples
32///
33/// ```rust
34/// use covguard_render::status_emoji;
35/// use covguard_types::VerdictStatus;
36///
37/// assert_eq!(status_emoji(&VerdictStatus::Pass), "\u{2705}");
38/// assert_eq!(status_emoji(&VerdictStatus::Fail), "\u{274C}");
39/// ```
40pub fn status_emoji(status: &VerdictStatus) -> &'static str {
41    match status {
42        VerdictStatus::Pass => "\u{2705}",
43        VerdictStatus::Warn => "\u{26A0}\u{FE0F}",
44        VerdictStatus::Fail => "\u{274C}",
45        VerdictStatus::Skip => "\u{23ED}\u{FE0F}",
46    }
47}
48
49/// Returns a human-readable status label.
50fn status_label(status: &VerdictStatus) -> &'static str {
51    match status {
52        VerdictStatus::Pass => "pass",
53        VerdictStatus::Warn => "warn",
54        VerdictStatus::Fail => "fail",
55        VerdictStatus::Skip => "skip",
56    }
57}
58
59/// Renders the report as a Markdown comment for pull requests.
60///
61/// # Arguments
62///
63/// * `report` - The coverage report to render.
64/// * `max_lines` - Maximum number of uncovered lines to show in the table (default 10).
65///
66/// # Returns
67///
68/// A Markdown-formatted string suitable for a PR comment.
69///
70/// # Example Output
71///
72/// ````markdown
73/// ## covguard: Diff Coverage Report
74///
75/// **Status**: [emoji] [status]
76///
77/// ### Summary
78/// - **Diff coverage**: X.X%
79/// - **Changed lines**: N
80/// - **Covered**: N
81/// - **Uncovered**: N
82///
83/// ### Uncovered Lines
84///
85/// | File | Line | Hits |
86/// |------|------|------|
87/// | src/lib.rs | 1 | 0 |
88///
89/// *Showing N of M uncovered lines*
90///
91/// <details>
92/// <summary>Reproduce locally</summary>
93///
94/// ```bash
95/// covguard check --diff-file <file> --lcov <lcov>
96/// ```
97///
98/// `</details>`
99/// ````
100pub fn render_markdown(report: &Report, max_lines: usize) -> String {
101    let mut output = String::new();
102
103    // Header
104    output.push_str("## covguard: Diff Coverage Report\n\n");
105
106    // Status line
107    let emoji = status_emoji(&report.verdict.status);
108    let label = status_label(&report.verdict.status);
109    output.push_str(&format!("**Status**: {} {}\n\n", emoji, label));
110
111    // Summary section
112    output.push_str("### Summary\n");
113    output.push_str(&format!(
114        "- **Diff coverage**: {:.1}%\n",
115        report.data.diff_coverage_pct
116    ));
117    output.push_str(&format!(
118        "- **Changed lines**: {}\n",
119        report.data.changed_lines_total
120    ));
121    output.push_str(&format!("- **Covered**: {}\n", report.data.covered_lines));
122    output.push_str(&format!(
123        "- **Uncovered**: {}\n",
124        report.data.uncovered_lines
125    ));
126
127    // Uncovered lines table (only if there are findings with locations)
128    let uncovered_findings: Vec<_> = report
129        .findings
130        .iter()
131        .filter(|f| f.location.is_some())
132        .collect();
133
134    if !uncovered_findings.is_empty() {
135        output.push_str("\n### Uncovered Lines\n\n");
136        output.push_str("| File | Line | Hits |\n");
137        output.push_str("|------|------|------|\n");
138
139        let total_findings = uncovered_findings.len();
140        let shown = total_findings.min(max_lines);
141
142        for finding in uncovered_findings.iter().take(max_lines) {
143            let location = finding
144                .location
145                .as_ref()
146                .expect("filtered to only findings with locations");
147            let line_str = location
148                .line
149                .map(|l| l.to_string())
150                .unwrap_or_else(|| "-".to_string());
151
152            // Extract hits from finding data
153            let hits = finding
154                .data
155                .as_ref()
156                .and_then(|d| d.get("hits"))
157                .and_then(|v| v.as_u64())
158                .unwrap_or(0);
159
160            output.push_str(&format!(
161                "| {} | {} | {} |\n",
162                location.path, line_str, hits
163            ));
164        }
165
166        if total_findings > max_lines {
167            output.push('\n');
168            output.push_str(&format!(
169                "*Showing {} of {} uncovered lines*\n",
170                shown, total_findings
171            ));
172        }
173    }
174
175    // Reproduce locally section
176    output.push_str("\n<details>\n");
177    output.push_str("<summary>Reproduce locally</summary>\n\n");
178    output.push_str("```bash\n");
179
180    // Build the command based on inputs
181    let inputs = &report.data.inputs;
182    let mut cmd_parts = vec!["covguard check".to_string()];
183
184    if let Some(diff_file) = &inputs.diff_file {
185        cmd_parts.push(format!("--diff-file {}", diff_file));
186    } else if inputs.base.is_some() || inputs.head.is_some() {
187        if let Some(base) = &inputs.base {
188            cmd_parts.push(format!("--base {}", base));
189        }
190        if let Some(head) = &inputs.head {
191            cmd_parts.push(format!("--head {}", head));
192        }
193    } else {
194        cmd_parts.push("--diff-file <file>".to_string());
195    }
196
197    if inputs.lcov_paths.is_empty() {
198        cmd_parts.push("--lcov <lcov>".to_string());
199    } else {
200        for lcov_path in &inputs.lcov_paths {
201            cmd_parts.push(format!("--lcov {}", lcov_path));
202        }
203    }
204
205    output.push_str(&cmd_parts.join(" \\\n  "));
206    output.push_str("\n```\n\n");
207    output.push_str("</details>\n");
208
209    output
210}
211
212/// Renders the report as GitHub workflow annotation commands.
213///
214/// # Arguments
215///
216/// * `report` - The coverage report to render.
217/// * `max_annotations` - Maximum number of annotations to emit (default 25).
218///
219/// # Returns
220///
221/// A string containing newline-separated GitHub workflow commands.
222///
223/// # Example Output
224///
225/// ```text
226/// ::warning file=src/lib.rs,line=1::Uncovered changed line (hits=0)
227/// ::error file=src/lib.rs,line=2::Uncovered changed line (hits=0)
228/// ```
229pub fn render_annotations(report: &Report, max_annotations: usize) -> String {
230    let mut output = String::new();
231
232    for finding in report.findings.iter().take(max_annotations) {
233        if let Some(location) = &finding.location {
234            let level = match finding.severity {
235                Severity::Error => "error",
236                Severity::Warn => "warning",
237                Severity::Info => "notice",
238            };
239
240            // Build location parameters
241            let mut params = vec![format!("file={}", location.path)];
242
243            if let Some(line) = location.line {
244                params.push(format!("line={}", line));
245            }
246
247            if let Some(col) = location.col {
248                params.push(format!("col={}", col));
249            }
250
251            // Extract hits for the message
252            let hits = finding
253                .data
254                .as_ref()
255                .and_then(|d| d.get("hits"))
256                .and_then(|v| v.as_u64())
257                .unwrap_or(0);
258
259            let message = format!("Uncovered changed line (hits={})", hits);
260
261            output.push_str(&format!("::{} {}::{}\n", level, params.join(","), message));
262        }
263    }
264
265    output
266}
267
268// ============================================================================
269// SARIF Types
270// ============================================================================
271
272/// Default maximum number of SARIF results to emit.
273pub const DEFAULT_MAX_SARIF_RESULTS: usize = 1000;
274
275/// SARIF report version 2.1.0
276#[derive(Debug, Clone, Serialize)]
277pub struct SarifReport {
278    #[serde(rename = "$schema")]
279    pub schema: String,
280    pub version: String,
281    pub runs: Vec<SarifRun>,
282}
283
284/// A single SARIF run
285#[derive(Debug, Clone, Serialize)]
286pub struct SarifRun {
287    pub tool: SarifTool,
288    pub results: Vec<SarifResult>,
289}
290
291/// SARIF tool information
292#[derive(Debug, Clone, Serialize)]
293pub struct SarifTool {
294    pub driver: SarifDriver,
295}
296
297/// SARIF tool driver (main component)
298#[derive(Debug, Clone, Serialize)]
299pub struct SarifDriver {
300    pub name: String,
301    pub version: String,
302    #[serde(rename = "informationUri")]
303    pub information_uri: String,
304    pub rules: Vec<SarifRule>,
305}
306
307/// SARIF rule definition
308#[derive(Debug, Clone, Serialize)]
309pub struct SarifRule {
310    pub id: String,
311    pub name: String,
312    #[serde(rename = "shortDescription")]
313    pub short_description: SarifMessage,
314    #[serde(rename = "fullDescription")]
315    pub full_description: SarifMessage,
316    #[serde(rename = "helpUri")]
317    pub help_uri: String,
318    #[serde(rename = "defaultConfiguration")]
319    pub default_configuration: SarifRuleConfiguration,
320}
321
322/// SARIF rule configuration
323#[derive(Debug, Clone, Serialize)]
324pub struct SarifRuleConfiguration {
325    pub level: String,
326}
327
328/// SARIF message
329#[derive(Debug, Clone, Serialize)]
330pub struct SarifMessage {
331    pub text: String,
332}
333
334/// SARIF result (a finding)
335#[derive(Debug, Clone, Serialize)]
336pub struct SarifResult {
337    #[serde(rename = "ruleId")]
338    pub rule_id: String,
339    #[serde(rename = "ruleIndex")]
340    pub rule_index: usize,
341    pub level: String,
342    pub message: SarifMessage,
343    pub locations: Vec<SarifLocation>,
344}
345
346/// SARIF location
347#[derive(Debug, Clone, Serialize)]
348pub struct SarifLocation {
349    #[serde(rename = "physicalLocation")]
350    pub physical_location: SarifPhysicalLocation,
351}
352
353/// SARIF physical location
354#[derive(Debug, Clone, Serialize)]
355pub struct SarifPhysicalLocation {
356    #[serde(rename = "artifactLocation")]
357    pub artifact_location: SarifArtifactLocation,
358    #[serde(skip_serializing_if = "Option::is_none")]
359    pub region: Option<SarifRegion>,
360}
361
362/// SARIF artifact location
363#[derive(Debug, Clone, Serialize)]
364pub struct SarifArtifactLocation {
365    pub uri: String,
366    #[serde(rename = "uriBaseId")]
367    pub uri_base_id: String,
368}
369
370/// SARIF region
371#[derive(Debug, Clone, Serialize)]
372pub struct SarifRegion {
373    #[serde(rename = "startLine")]
374    pub start_line: u32,
375    #[serde(rename = "startColumn", skip_serializing_if = "Option::is_none")]
376    pub start_column: Option<u32>,
377}
378
379// ============================================================================
380// SARIF Rendering
381// ============================================================================
382
383/// Renders the report as a SARIF 2.1.0 JSON document.
384///
385/// # Arguments
386///
387/// * `report` - The coverage report to render.
388/// * `max_results` - Maximum number of results to emit (default 1000).
389///
390/// # Returns
391///
392/// A JSON string containing the SARIF report.
393pub fn render_sarif(report: &Report, max_results: usize) -> String {
394    let sarif = build_sarif_report(report, max_results);
395    serde_json::to_string_pretty(&sarif).unwrap_or_else(|_| "{}".to_string())
396}
397
398/// Build a SARIF report from a covguard report.
399fn build_sarif_report(report: &Report, max_results: usize) -> SarifReport {
400    let rules: Vec<SarifRule> = CODE_REGISTRY.iter().map(codeinfo_to_rule).collect();
401
402    // Build rule index map
403    let rule_index_map: std::collections::HashMap<&str, usize> = rules
404        .iter()
405        .enumerate()
406        .map(|(i, r)| (r.id.as_str(), i))
407        .collect();
408
409    // Convert findings to SARIF results
410    let results: Vec<SarifResult> = report
411        .findings
412        .iter()
413        .take(max_results)
414        .filter_map(|finding| {
415            let rule_index = rule_index_map.get(finding.code.as_str()).copied()?;
416
417            let level = match finding.severity {
418                Severity::Error => "error",
419                Severity::Warn => "warning",
420                Severity::Info => "note",
421            };
422
423            let locations = if let Some(ref loc) = finding.location {
424                vec![SarifLocation {
425                    physical_location: SarifPhysicalLocation {
426                        artifact_location: SarifArtifactLocation {
427                            uri: loc.path.clone(),
428                            uri_base_id: "%SRCROOT%".to_string(),
429                        },
430                        region: loc.line.map(|line| SarifRegion {
431                            start_line: line,
432                            start_column: loc.col,
433                        }),
434                    },
435                }]
436            } else {
437                vec![]
438            };
439
440            Some(SarifResult {
441                rule_id: finding.code.clone(),
442                rule_index,
443                level: level.to_string(),
444                message: SarifMessage {
445                    text: finding.message.clone(),
446                },
447                locations,
448            })
449        })
450        .collect();
451
452    SarifReport {
453        schema: "https://raw.githubusercontent.com/oasis-tcs/sarif-spec/master/Schemata/sarif-schema-2.1.0.json".to_string(),
454        version: "2.1.0".to_string(),
455        runs: vec![SarifRun {
456            tool: SarifTool {
457                driver: SarifDriver {
458                    name: "covguard".to_string(),
459                    version: report.tool.version.clone(),
460                    information_uri: "https://github.com/covguard/covguard".to_string(),
461                    rules,
462                },
463            },
464            results,
465        }],
466    }
467}
468
469fn codeinfo_to_rule(info: &CodeInfo) -> SarifRule {
470    SarifRule {
471        id: info.code.to_string(),
472        name: info.name.to_string(),
473        short_description: SarifMessage {
474            text: info.short_description.to_string(),
475        },
476        full_description: SarifMessage {
477            text: info.full_description.to_string(),
478        },
479        help_uri: info.help_uri.to_string(),
480        default_configuration: SarifRuleConfiguration {
481            level: "error".to_string(),
482        },
483    }
484}
485
486// ============================================================================
487// Tests
488// ============================================================================
489
490#[cfg(test)]
491mod tests {
492    use super::*;
493    use covguard_types::{
494        Finding, Inputs, ReportData, Run, Severity, Tool, Verdict, VerdictCounts, VerdictStatus,
495    };
496
497    fn make_test_report(
498        status: VerdictStatus,
499        findings: Vec<Finding>,
500        covered: u32,
501        uncovered: u32,
502    ) -> Report {
503        Report {
504            schema: "covguard.report.v1".to_string(),
505            tool: Tool {
506                name: "covguard".to_string(),
507                version: "0.1.0".to_string(),
508                commit: None,
509            },
510            run: Run {
511                started_at: "2026-02-02T00:00:00Z".to_string(),
512                ended_at: None,
513                duration_ms: None,
514                capabilities: None,
515            },
516            verdict: Verdict {
517                status,
518                counts: VerdictCounts {
519                    info: 0,
520                    warn: findings
521                        .iter()
522                        .filter(|f| f.severity == Severity::Warn)
523                        .count() as u32,
524                    error: findings
525                        .iter()
526                        .filter(|f| f.severity == Severity::Error)
527                        .count() as u32,
528                },
529                reasons: if uncovered > 0 {
530                    vec!["uncovered_lines".to_string()]
531                } else {
532                    Vec::new()
533                },
534            },
535            findings,
536            data: ReportData {
537                scope: "added".to_string(),
538                threshold_pct: 80.0,
539                changed_lines_total: covered + uncovered,
540                covered_lines: covered,
541                uncovered_lines: uncovered,
542                missing_lines: 0,
543                ignored_lines_count: 0,
544                excluded_files_count: 0,
545                diff_coverage_pct: if covered + uncovered > 0 {
546                    (covered as f64 / (covered + uncovered) as f64) * 100.0
547                } else {
548                    100.0
549                },
550                inputs: Inputs {
551                    diff_source: "diff-file".to_string(),
552                    diff_file: Some("fixtures/diff/simple_added.patch".to_string()),
553                    base: None,
554                    head: None,
555                    lcov_paths: vec!["fixtures/lcov/uncovered.info".to_string()],
556                },
557                debug: None,
558                truncation: None,
559            },
560        }
561    }
562
563    // ========================================================================
564    // status_emoji tests
565    // ========================================================================
566
567    #[test]
568    fn test_status_emoji_pass() {
569        assert_eq!(status_emoji(&VerdictStatus::Pass), "\u{2705}");
570    }
571
572    #[test]
573    fn test_status_emoji_warn() {
574        assert_eq!(status_emoji(&VerdictStatus::Warn), "\u{26A0}\u{FE0F}");
575    }
576
577    #[test]
578    fn test_status_emoji_fail() {
579        assert_eq!(status_emoji(&VerdictStatus::Fail), "\u{274C}");
580    }
581
582    #[test]
583    fn test_status_emoji_skip() {
584        assert_eq!(status_emoji(&VerdictStatus::Skip), "\u{23ED}\u{FE0F}");
585    }
586
587    // ========================================================================
588    // render_markdown tests
589    // ========================================================================
590
591    #[test]
592    fn test_markdown_header_present() {
593        let report = make_test_report(VerdictStatus::Pass, vec![], 5, 0);
594        let md = render_markdown(&report, 10);
595        assert!(md.contains("## covguard: Diff Coverage Report"));
596    }
597
598    #[test]
599    fn test_markdown_status_pass() {
600        let report = make_test_report(VerdictStatus::Pass, vec![], 5, 0);
601        let md = render_markdown(&report, 10);
602        assert!(md.contains("**Status**: \u{2705} pass"));
603    }
604
605    #[test]
606    fn test_markdown_status_fail() {
607        let report = make_test_report(VerdictStatus::Fail, vec![], 0, 5);
608        let md = render_markdown(&report, 10);
609        assert!(md.contains("**Status**: \u{274C} fail"));
610    }
611
612    #[test]
613    fn test_markdown_status_warn() {
614        let report = make_test_report(VerdictStatus::Warn, vec![], 3, 2);
615        let md = render_markdown(&report, 10);
616        assert!(md.contains("**Status**: \u{26A0}\u{FE0F} warn"));
617    }
618
619    #[test]
620    fn test_markdown_status_skip() {
621        let report = make_test_report(VerdictStatus::Skip, vec![], 0, 0);
622        let md = render_markdown(&report, 10);
623        assert!(md.contains("**Status**: \u{23ED}\u{FE0F} skip"));
624    }
625
626    #[test]
627    fn test_markdown_summary_section() {
628        let report = make_test_report(VerdictStatus::Fail, vec![], 7, 3);
629        let md = render_markdown(&report, 10);
630
631        assert!(md.contains("### Summary"));
632        assert!(md.contains("- **Diff coverage**: 70.0%"));
633        assert!(md.contains("- **Changed lines**: 10"));
634        assert!(md.contains("- **Covered**: 7"));
635        assert!(md.contains("- **Uncovered**: 3"));
636    }
637
638    #[test]
639    fn test_markdown_uncovered_lines_table() {
640        let findings = vec![
641            Finding::uncovered_line("src/lib.rs", 1, 0),
642            Finding::uncovered_line("src/lib.rs", 2, 0),
643        ];
644        let report = make_test_report(VerdictStatus::Fail, findings, 0, 2);
645        let md = render_markdown(&report, 10);
646
647        assert!(md.contains("### Uncovered Lines"));
648        assert!(md.contains("| File | Line | Hits |"));
649        assert!(md.contains("| src/lib.rs | 1 | 0 |"));
650        assert!(md.contains("| src/lib.rs | 2 | 0 |"));
651    }
652
653    #[test]
654    fn test_markdown_no_uncovered_lines_table_when_empty() {
655        let report = make_test_report(VerdictStatus::Pass, vec![], 5, 0);
656        let md = render_markdown(&report, 10);
657
658        // Table header should not be present when there are no findings
659        assert!(!md.contains("### Uncovered Lines"));
660        assert!(!md.contains("| File | Line | Hits |"));
661    }
662
663    #[test]
664    fn test_markdown_truncation() {
665        let findings: Vec<_> = (1..=15)
666            .map(|i| Finding::uncovered_line("src/lib.rs", i, 0))
667            .collect();
668        let report = make_test_report(VerdictStatus::Fail, findings, 0, 15);
669        let md = render_markdown(&report, 10);
670
671        // Should only show 10 lines
672        assert!(md.contains("| src/lib.rs | 10 | 0 |"));
673        assert!(!md.contains("| src/lib.rs | 11 | 0 |"));
674
675        // Should show truncation message
676        assert!(md.contains("*Showing 10 of 15 uncovered lines*"));
677    }
678
679    #[test]
680    fn test_markdown_no_truncation_message_when_under_limit() {
681        let findings = vec![
682            Finding::uncovered_line("src/lib.rs", 1, 0),
683            Finding::uncovered_line("src/lib.rs", 2, 0),
684        ];
685        let report = make_test_report(VerdictStatus::Fail, findings, 0, 2);
686        let md = render_markdown(&report, 10);
687
688        // Should not show truncation message
689        assert!(!md.contains("*Showing"));
690    }
691
692    #[test]
693    fn test_markdown_reproduce_section_with_diff_file() {
694        let report = make_test_report(VerdictStatus::Pass, vec![], 5, 0);
695        let md = render_markdown(&report, 10);
696
697        assert!(md.contains("<details>"));
698        assert!(md.contains("<summary>Reproduce locally</summary>"));
699        assert!(md.contains("covguard check"));
700        assert!(md.contains("--diff-file fixtures/diff/simple_added.patch"));
701        assert!(md.contains("--lcov fixtures/lcov/uncovered.info"));
702        assert!(md.contains("</details>"));
703    }
704
705    #[test]
706    fn test_markdown_reproduce_section_with_git_refs() {
707        let mut report = make_test_report(VerdictStatus::Pass, vec![], 5, 0);
708        report.data.inputs.diff_file = None;
709        report.data.inputs.diff_source = "git-refs".to_string();
710        report.data.inputs.base = Some("abc123".to_string());
711        report.data.inputs.head = Some("def456".to_string());
712
713        let md = render_markdown(&report, 10);
714
715        assert!(md.contains("--base abc123"));
716        assert!(md.contains("--head def456"));
717    }
718
719    #[test]
720    fn test_markdown_reproduce_section_placeholder_when_no_inputs() {
721        let mut report = make_test_report(VerdictStatus::Pass, vec![], 5, 0);
722        report.data.inputs.diff_file = None;
723        report.data.inputs.lcov_paths = vec![];
724
725        let md = render_markdown(&report, 10);
726
727        assert!(md.contains("--diff-file <file>"));
728        assert!(md.contains("--lcov <lcov>"));
729    }
730
731    #[test]
732    fn test_markdown_output_format() {
733        let findings = vec![Finding::uncovered_line("src/lib.rs", 1, 0)];
734        let report = make_test_report(VerdictStatus::Fail, findings, 2, 1);
735        let md = render_markdown(&report, 10);
736
737        // Check overall structure
738        let expected_sections = [
739            "## covguard: Diff Coverage Report",
740            "**Status**:",
741            "### Summary",
742            "### Uncovered Lines",
743            "<details>",
744            "</details>",
745        ];
746
747        for section in expected_sections {
748            assert!(md.contains(section));
749        }
750    }
751
752    // ========================================================================
753    // render_annotations tests
754    // ========================================================================
755
756    #[test]
757    fn test_annotations_error_level() {
758        let findings = vec![Finding::uncovered_line("src/lib.rs", 42, 0)];
759        let report = make_test_report(VerdictStatus::Fail, findings, 0, 1);
760        let annotations = render_annotations(&report, 25);
761
762        assert!(
763            annotations
764                .contains("::error file=src/lib.rs,line=42::Uncovered changed line (hits=0)")
765        );
766    }
767
768    #[test]
769    fn test_annotations_warning_level() {
770        let mut finding = Finding::uncovered_line("src/lib.rs", 42, 0);
771        finding.severity = Severity::Warn;
772
773        let report = make_test_report(VerdictStatus::Warn, vec![finding], 0, 1);
774        let annotations = render_annotations(&report, 25);
775
776        assert!(annotations.contains("::warning file=src/lib.rs,line=42::"));
777    }
778
779    #[test]
780    fn test_annotations_info_level() {
781        let mut finding = Finding::uncovered_line("src/lib.rs", 42, 0);
782        finding.severity = Severity::Info;
783
784        let report = make_test_report(VerdictStatus::Pass, vec![finding], 0, 1);
785        let annotations = render_annotations(&report, 25);
786
787        assert!(annotations.contains("::notice file=src/lib.rs,line=42::"));
788    }
789
790    #[test]
791    fn test_annotations_multiple_findings() {
792        let findings = vec![
793            Finding::uncovered_line("src/lib.rs", 1, 0),
794            Finding::uncovered_line("src/lib.rs", 2, 0),
795            Finding::uncovered_line("src/main.rs", 10, 0),
796        ];
797        let report = make_test_report(VerdictStatus::Fail, findings, 0, 3);
798        let annotations = render_annotations(&report, 25);
799
800        let lines: Vec<_> = annotations.lines().collect();
801        assert_eq!(lines.len(), 3);
802        assert!(lines[0].contains("src/lib.rs,line=1"));
803        assert!(lines[1].contains("src/lib.rs,line=2"));
804        assert!(lines[2].contains("src/main.rs,line=10"));
805    }
806
807    #[test]
808    fn test_annotations_truncation() {
809        let findings: Vec<_> = (1..=30)
810            .map(|i| Finding::uncovered_line("src/lib.rs", i, 0))
811            .collect();
812        let report = make_test_report(VerdictStatus::Fail, findings, 0, 30);
813        let annotations = render_annotations(&report, 25);
814
815        let lines: Vec<_> = annotations.lines().collect();
816        assert_eq!(lines.len(), 25);
817        assert!(lines.last().unwrap().contains("line=25"));
818    }
819
820    #[test]
821    fn test_annotations_empty_when_no_findings() {
822        let report = make_test_report(VerdictStatus::Pass, vec![], 5, 0);
823        let annotations = render_annotations(&report, 25);
824
825        assert!(annotations.is_empty());
826    }
827
828    #[test]
829    fn test_annotations_skip_findings_without_location() {
830        let finding_without_location = Finding {
831            severity: Severity::Error,
832            check_id: "diff.coverage_below_threshold".to_string(),
833            code: "covguard.diff.coverage_below_threshold".to_string(),
834            message: "Coverage below threshold".to_string(),
835            location: None,
836            data: None,
837            fingerprint: None,
838        };
839
840        let findings = vec![
841            finding_without_location,
842            Finding::uncovered_line("src/lib.rs", 1, 0),
843        ];
844        let report = make_test_report(VerdictStatus::Fail, findings, 0, 1);
845        let annotations = render_annotations(&report, 25);
846
847        let lines: Vec<_> = annotations.lines().collect();
848        // Should only have one annotation (the one with location)
849        assert_eq!(lines.len(), 1);
850        assert!(lines[0].contains("src/lib.rs,line=1"));
851    }
852
853    #[test]
854    fn test_annotations_with_column() {
855        let mut finding = Finding::uncovered_line("src/lib.rs", 42, 0);
856        finding.location.as_mut().unwrap().col = Some(10);
857
858        let report = make_test_report(VerdictStatus::Fail, vec![finding], 0, 1);
859        let annotations = render_annotations(&report, 25);
860
861        assert!(annotations.contains("file=src/lib.rs,line=42,col=10"));
862    }
863
864    #[test]
865    fn test_annotations_format_correctness() {
866        let findings = vec![Finding::uncovered_line("src/lib.rs", 1, 0)];
867        let report = make_test_report(VerdictStatus::Fail, findings, 0, 1);
868        let annotations = render_annotations(&report, 25);
869
870        // GitHub annotation format: ::level file=path,line=num::message
871        let line = annotations.lines().next().unwrap();
872        assert!(line.starts_with("::error "));
873        assert!(line.contains("file="));
874        assert!(line.contains("line="));
875        assert!(line.contains("::Uncovered changed line"));
876    }
877
878    // ========================================================================
879    // Integration-style tests
880    // ========================================================================
881
882    #[test]
883    fn test_full_report_rendering() {
884        let findings = vec![
885            Finding::uncovered_line("src/lib.rs", 1, 0),
886            Finding::uncovered_line("src/lib.rs", 2, 0),
887            Finding::uncovered_line("src/lib.rs", 3, 0),
888        ];
889
890        let report = Report {
891            schema: "covguard.report.v1".to_string(),
892            tool: Tool {
893                name: "covguard".to_string(),
894                version: "0.1.0".to_string(),
895                commit: None,
896            },
897            run: Run {
898                started_at: "2026-02-02T00:00:00Z".to_string(),
899                ended_at: None,
900                duration_ms: None,
901                capabilities: None,
902            },
903            verdict: Verdict {
904                status: VerdictStatus::Fail,
905                counts: VerdictCounts {
906                    info: 0,
907                    warn: 0,
908                    error: 3,
909                },
910                reasons: vec!["uncovered_lines".to_string()],
911            },
912            findings,
913            data: ReportData {
914                scope: "added".to_string(),
915                threshold_pct: 80.0,
916                changed_lines_total: 3,
917                covered_lines: 0,
918                uncovered_lines: 3,
919                missing_lines: 0,
920                ignored_lines_count: 0,
921                excluded_files_count: 0,
922                diff_coverage_pct: 0.0,
923                inputs: Inputs {
924                    diff_source: "diff-file".to_string(),
925                    diff_file: Some("fixtures/diff/simple_added.patch".to_string()),
926                    base: None,
927                    head: None,
928                    lcov_paths: vec!["fixtures/lcov/uncovered.info".to_string()],
929                },
930                debug: None,
931                truncation: None,
932            },
933        };
934
935        let md = render_markdown(&report, 10);
936        let annotations = render_annotations(&report, 25);
937
938        // Verify markdown
939        assert!(md.contains("## covguard: Diff Coverage Report"));
940        assert!(md.contains("**Status**: \u{274C} fail"));
941        assert!(md.contains("- **Diff coverage**: 0.0%"));
942        assert!(md.contains("- **Changed lines**: 3"));
943        assert!(md.contains("- **Uncovered**: 3"));
944        assert!(md.contains("| src/lib.rs | 1 | 0 |"));
945        assert!(md.contains("| src/lib.rs | 2 | 0 |"));
946        assert!(md.contains("| src/lib.rs | 3 | 0 |"));
947
948        // Verify annotations
949        let ann_lines: Vec<_> = annotations.lines().collect();
950        assert_eq!(ann_lines.len(), 3);
951        assert!(ann_lines.iter().all(|l| l.starts_with("::error ")));
952    }
953
954    #[test]
955    fn test_default_constants() {
956        assert_eq!(DEFAULT_MAX_LINES, 10);
957        assert_eq!(DEFAULT_MAX_ANNOTATIONS, 25);
958        assert_eq!(DEFAULT_MAX_SARIF_RESULTS, 1000);
959    }
960
961    // ========================================================================
962    // SARIF tests
963    // ========================================================================
964
965    #[test]
966    fn test_sarif_basic_structure() {
967        let findings = vec![Finding::uncovered_line("src/lib.rs", 1, 0)];
968        let report = make_test_report(VerdictStatus::Fail, findings, 0, 1);
969        let sarif = render_sarif(&report, 1000);
970
971        // Parse as JSON to verify structure
972        let parsed: serde_json::Value = serde_json::from_str(&sarif).unwrap();
973
974        assert_eq!(parsed["version"], "2.1.0");
975        assert!(
976            parsed["$schema"]
977                .as_str()
978                .unwrap()
979                .contains("sarif-schema-2.1.0")
980        );
981        assert_eq!(parsed["runs"].as_array().unwrap().len(), 1);
982    }
983
984    #[test]
985    fn test_sarif_tool_info() {
986        let report = make_test_report(VerdictStatus::Pass, vec![], 5, 0);
987        let sarif = render_sarif(&report, 1000);
988
989        let parsed: serde_json::Value = serde_json::from_str(&sarif).unwrap();
990        let driver = &parsed["runs"][0]["tool"]["driver"];
991
992        assert_eq!(driver["name"], "covguard");
993        assert_eq!(driver["version"], "0.1.0");
994        assert!(driver["informationUri"].as_str().is_some());
995    }
996
997    #[test]
998    fn test_sarif_rules() {
999        let report = make_test_report(VerdictStatus::Pass, vec![], 5, 0);
1000        let sarif = render_sarif(&report, 1000);
1001
1002        let parsed: serde_json::Value = serde_json::from_str(&sarif).unwrap();
1003        let rules = parsed["runs"][0]["tool"]["driver"]["rules"]
1004            .as_array()
1005            .unwrap();
1006
1007        // Should have a rule for each code
1008        assert_eq!(rules.len(), CODE_REGISTRY.len());
1009
1010        // Check uncovered_line rule
1011        let rule = &rules[0];
1012        assert_eq!(rule["id"], "covguard.diff.uncovered_line");
1013        assert_eq!(rule["name"], "UncoveredLine");
1014        assert!(rule["shortDescription"]["text"].as_str().is_some());
1015        assert!(rule["helpUri"].as_str().is_some());
1016    }
1017
1018    #[test]
1019    fn test_sarif_results_with_findings() {
1020        let findings = vec![
1021            Finding::uncovered_line("src/lib.rs", 1, 0),
1022            Finding::uncovered_line("src/lib.rs", 2, 0),
1023            Finding::uncovered_line("src/main.rs", 10, 0),
1024        ];
1025        let report = make_test_report(VerdictStatus::Fail, findings, 0, 3);
1026        let sarif = render_sarif(&report, 1000);
1027
1028        let parsed: serde_json::Value = serde_json::from_str(&sarif).unwrap();
1029        let results = parsed["runs"][0]["results"].as_array().unwrap();
1030
1031        assert_eq!(results.len(), 3);
1032
1033        // Check first result
1034        let result = &results[0];
1035        assert_eq!(result["ruleId"], "covguard.diff.uncovered_line");
1036        assert_eq!(result["level"], "error");
1037        assert!(result["message"]["text"].as_str().is_some());
1038
1039        // Check location
1040        let loc = &result["locations"][0]["physicalLocation"];
1041        assert_eq!(loc["artifactLocation"]["uri"], "src/lib.rs");
1042        assert_eq!(loc["region"]["startLine"], 1);
1043    }
1044
1045    #[test]
1046    fn test_sarif_empty_when_no_findings() {
1047        let report = make_test_report(VerdictStatus::Pass, vec![], 5, 0);
1048        let sarif = render_sarif(&report, 1000);
1049
1050        let parsed: serde_json::Value = serde_json::from_str(&sarif).unwrap();
1051        let results = parsed["runs"][0]["results"].as_array().unwrap();
1052
1053        assert!(results.is_empty());
1054    }
1055
1056    #[test]
1057    fn test_sarif_truncation() {
1058        // Create 15 findings
1059        let findings: Vec<_> = (1..=15)
1060            .map(|i| Finding::uncovered_line("src/lib.rs", i, 0))
1061            .collect();
1062        let report = make_test_report(VerdictStatus::Fail, findings, 0, 15);
1063
1064        // Limit to 10 results
1065        let sarif = render_sarif(&report, 10);
1066
1067        let parsed: serde_json::Value = serde_json::from_str(&sarif).unwrap();
1068        let results = parsed["runs"][0]["results"].as_array().unwrap();
1069
1070        // Should only have 10 results
1071        assert_eq!(results.len(), 10);
1072    }
1073
1074    #[test]
1075    fn test_sarif_severity_levels() {
1076        let mut error_finding = Finding::uncovered_line("src/lib.rs", 1, 0);
1077        error_finding.severity = Severity::Error;
1078
1079        let mut warn_finding = Finding::uncovered_line("src/lib.rs", 2, 0);
1080        warn_finding.severity = Severity::Warn;
1081
1082        let mut info_finding = Finding::uncovered_line("src/lib.rs", 3, 0);
1083        info_finding.severity = Severity::Info;
1084
1085        let findings = vec![error_finding, warn_finding, info_finding];
1086        let report = make_test_report(VerdictStatus::Fail, findings, 0, 3);
1087        let sarif = render_sarif(&report, 1000);
1088
1089        let parsed: serde_json::Value = serde_json::from_str(&sarif).unwrap();
1090        let results = parsed["runs"][0]["results"].as_array().unwrap();
1091
1092        assert_eq!(results[0]["level"], "error");
1093        assert_eq!(results[1]["level"], "warning");
1094        assert_eq!(results[2]["level"], "note");
1095    }
1096
1097    #[test]
1098    fn test_sarif_finding_without_location() {
1099        let finding_without_location = Finding {
1100            severity: Severity::Error,
1101            check_id: "diff.coverage_below_threshold".to_string(),
1102            code: "covguard.diff.coverage_below_threshold".to_string(),
1103            message: "Coverage 50% is below threshold 80%".to_string(),
1104            location: None,
1105            data: None,
1106            fingerprint: None,
1107        };
1108
1109        let findings = vec![
1110            finding_without_location,
1111            Finding::uncovered_line("src/lib.rs", 1, 0),
1112        ];
1113        let report = make_test_report(VerdictStatus::Fail, findings, 0, 1);
1114        let sarif = render_sarif(&report, 1000);
1115
1116        let parsed: serde_json::Value = serde_json::from_str(&sarif).unwrap();
1117        let results = parsed["runs"][0]["results"].as_array().unwrap();
1118
1119        // Both findings should be included (one without location, one with)
1120        assert_eq!(results.len(), 2);
1121
1122        // First result (coverage_below_threshold) should have empty locations
1123        assert!(results[0]["locations"].as_array().unwrap().is_empty());
1124
1125        // Second result should have a location
1126        assert_eq!(results[1]["locations"].as_array().unwrap().len(), 1);
1127    }
1128
1129    // ========================================================================
1130    // Insta Snapshot Tests
1131    // ========================================================================
1132
1133    #[test]
1134    fn test_snapshot_markdown_fail() {
1135        let findings = vec![
1136            Finding::uncovered_line("src/lib.rs", 1, 0),
1137            Finding::uncovered_line("src/lib.rs", 2, 0),
1138            Finding::uncovered_line("src/lib.rs", 3, 0),
1139        ];
1140        let report = make_test_report(VerdictStatus::Fail, findings, 0, 3);
1141        let md = render_markdown(&report, 10);
1142        insta::assert_snapshot!("markdown_fail", md);
1143    }
1144
1145    #[test]
1146    fn test_snapshot_markdown_pass() {
1147        let report = make_test_report(VerdictStatus::Pass, vec![], 3, 0);
1148        let md = render_markdown(&report, 10);
1149        insta::assert_snapshot!("markdown_pass", md);
1150    }
1151
1152    #[test]
1153    fn test_snapshot_markdown_warn() {
1154        let mut finding = Finding::uncovered_line("src/lib.rs", 1, 0);
1155        finding.severity = Severity::Warn;
1156        let report = make_test_report(VerdictStatus::Warn, vec![finding], 2, 1);
1157        let md = render_markdown(&report, 10);
1158        insta::assert_snapshot!("markdown_warn", md);
1159    }
1160
1161    #[test]
1162    fn test_snapshot_markdown_truncated() {
1163        let findings: Vec<_> = (1..=15)
1164            .map(|i| Finding::uncovered_line("src/lib.rs", i, 0))
1165            .collect();
1166        let report = make_test_report(VerdictStatus::Fail, findings, 0, 15);
1167        let md = render_markdown(&report, 10);
1168        insta::assert_snapshot!("markdown_truncated", md);
1169    }
1170
1171    #[test]
1172    fn test_snapshot_annotations_multiple() {
1173        let findings = vec![
1174            Finding::uncovered_line("src/lib.rs", 1, 0),
1175            Finding::uncovered_line("src/lib.rs", 2, 0),
1176            Finding::uncovered_line("src/main.rs", 10, 0),
1177        ];
1178        let report = make_test_report(VerdictStatus::Fail, findings, 0, 3);
1179        let annotations = render_annotations(&report, 25);
1180        insta::assert_snapshot!("annotations_multiple", annotations);
1181    }
1182
1183    #[test]
1184    fn test_snapshot_annotations_empty() {
1185        let report = make_test_report(VerdictStatus::Pass, vec![], 5, 0);
1186        let annotations = render_annotations(&report, 25);
1187        insta::assert_snapshot!("annotations_empty", annotations);
1188    }
1189
1190    #[test]
1191    fn test_snapshot_sarif_with_findings() {
1192        let findings = vec![
1193            Finding::uncovered_line("src/lib.rs", 1, 0),
1194            Finding::uncovered_line("src/lib.rs", 2, 0),
1195            Finding::uncovered_line("src/main.rs", 10, 0),
1196        ];
1197        let report = make_test_report(VerdictStatus::Fail, findings, 0, 3);
1198        let sarif = render_sarif(&report, 1000);
1199        let sarif_value: serde_json::Value = serde_json::from_str(&sarif).unwrap();
1200        insta::assert_json_snapshot!("sarif_with_findings", sarif_value);
1201    }
1202
1203    #[test]
1204    fn test_snapshot_sarif_empty() {
1205        let report = make_test_report(VerdictStatus::Pass, vec![], 5, 0);
1206        let sarif = render_sarif(&report, 1000);
1207        let sarif_value: serde_json::Value = serde_json::from_str(&sarif).unwrap();
1208        insta::assert_json_snapshot!("sarif_empty", sarif_value);
1209    }
1210
1211    #[test]
1212    fn test_snapshot_sarif_mixed_severity() {
1213        let mut error_finding = Finding::uncovered_line("src/lib.rs", 1, 0);
1214        error_finding.severity = Severity::Error;
1215
1216        let mut warn_finding = Finding::uncovered_line("src/lib.rs", 2, 0);
1217        warn_finding.severity = Severity::Warn;
1218
1219        let mut info_finding = Finding::uncovered_line("src/lib.rs", 3, 0);
1220        info_finding.severity = Severity::Info;
1221
1222        let findings = vec![error_finding, warn_finding, info_finding];
1223        let report = make_test_report(VerdictStatus::Fail, findings, 0, 3);
1224        let sarif = render_sarif(&report, 1000);
1225        let sarif_value: serde_json::Value = serde_json::from_str(&sarif).unwrap();
1226        insta::assert_json_snapshot!("sarif_mixed_severity", sarif_value);
1227    }
1228
1229    // ========================================================================
1230    // Semantic Validation Tests - Markdown
1231    // ========================================================================
1232
1233    #[test]
1234    fn test_markdown_table_row_count_matches_findings() {
1235        let findings: Vec<_> = (1..=5)
1236            .map(|i| Finding::uncovered_line("src/lib.rs", i, 0))
1237            .collect();
1238        let report = make_test_report(VerdictStatus::Fail, findings, 0, 5);
1239        let md = render_markdown(&report, 100);
1240
1241        // Count table rows (excluding header and separator)
1242        let table_rows: Vec<_> = md.lines().filter(|l| l.starts_with("| src/")).collect();
1243        assert_eq!(table_rows.len(), 5);
1244    }
1245
1246    #[test]
1247    fn test_markdown_coverage_percentage_precision() {
1248        let mut report = make_test_report(VerdictStatus::Fail, vec![], 1, 2);
1249        report.data.diff_coverage_pct = 33.333333333;
1250        let md = render_markdown(&report, 10);
1251
1252        // Should format to one decimal place
1253        assert!(md.contains("33.3%"));
1254        assert!(!md.contains("33.333333333"));
1255    }
1256
1257    #[test]
1258    fn test_markdown_100_percent_coverage_display() {
1259        let mut report = make_test_report(VerdictStatus::Pass, vec![], 10, 0);
1260        report.data.diff_coverage_pct = 100.0;
1261        let md = render_markdown(&report, 10);
1262
1263        assert!(md.contains("100.0%"));
1264    }
1265
1266    #[test]
1267    fn test_markdown_zero_percent_coverage_display() {
1268        let mut report = make_test_report(VerdictStatus::Fail, vec![], 0, 10);
1269        report.data.diff_coverage_pct = 0.0;
1270        let md = render_markdown(&report, 10);
1271
1272        assert!(md.contains("0.0%"));
1273    }
1274
1275    #[test]
1276    fn test_markdown_table_alignment() {
1277        let findings = vec![Finding::uncovered_line("src/lib.rs", 1, 0)];
1278        let report = make_test_report(VerdictStatus::Fail, findings, 0, 1);
1279        let md = render_markdown(&report, 10);
1280
1281        // Verify table separator has correct format
1282        assert!(md.contains("|------|------|------|"));
1283    }
1284
1285    #[test]
1286    fn test_markdown_hits_value_displayed() {
1287        let mut finding = Finding::uncovered_line("src/lib.rs", 1, 0);
1288        finding.data = Some(serde_json::json!({ "hits": 42 }));
1289        let report = make_test_report(VerdictStatus::Fail, vec![finding], 0, 1);
1290        let md = render_markdown(&report, 10);
1291
1292        // Table should show the hits value
1293        assert!(md.contains("| 42 |"));
1294    }
1295
1296    #[test]
1297    fn test_markdown_multiple_lcov_paths_in_reproduce() {
1298        let mut report = make_test_report(VerdictStatus::Pass, vec![], 5, 0);
1299        report.data.inputs.lcov_paths =
1300            vec!["unit.info".to_string(), "integration.info".to_string()];
1301        let md = render_markdown(&report, 10);
1302
1303        assert!(md.contains("--lcov unit.info"));
1304        assert!(md.contains("--lcov integration.info"));
1305    }
1306
1307    #[test]
1308    fn test_markdown_unicode_in_file_path() {
1309        let finding = Finding::uncovered_line("src/日本語/テスト.rs", 1, 0);
1310        let report = make_test_report(VerdictStatus::Fail, vec![finding], 0, 1);
1311        let md = render_markdown(&report, 10);
1312
1313        assert!(md.contains("src/日本語/テスト.rs"));
1314    }
1315
1316    #[test]
1317    fn test_markdown_special_characters_in_path() {
1318        let finding = Finding::uncovered_line("src/path with spaces/file.rs", 1, 0);
1319        let report = make_test_report(VerdictStatus::Fail, vec![finding], 0, 1);
1320        let md = render_markdown(&report, 10);
1321
1322        assert!(md.contains("path with spaces"));
1323    }
1324
1325    #[test]
1326    fn test_markdown_sections_in_order() {
1327        let findings = vec![Finding::uncovered_line("src/lib.rs", 1, 0)];
1328        let report = make_test_report(VerdictStatus::Fail, findings, 2, 1);
1329        let md = render_markdown(&report, 10);
1330
1331        let header_pos = md.find("## covguard: Diff Coverage Report").unwrap();
1332        let status_pos = md.find("**Status**").unwrap();
1333        let summary_pos = md.find("### Summary").unwrap();
1334        let uncovered_pos = md.find("### Uncovered Lines").unwrap();
1335        let details_pos = md.find("<details>").unwrap();
1336
1337        assert!(header_pos < status_pos);
1338        assert!(status_pos < summary_pos);
1339        assert!(summary_pos < uncovered_pos);
1340        assert!(uncovered_pos < details_pos);
1341    }
1342
1343    // ========================================================================
1344    // Semantic Validation Tests - Annotations
1345    // ========================================================================
1346
1347    #[test]
1348    fn test_annotations_format_github_valid() {
1349        let mut error_finding = Finding::uncovered_line("src/lib.rs", 42, 0);
1350        error_finding.severity = Severity::Error;
1351        let mut warn_finding = Finding::uncovered_line("src/lib.rs", 42, 0);
1352        warn_finding.severity = Severity::Warn;
1353        let mut info_finding = Finding::uncovered_line("src/lib.rs", 42, 0);
1354        info_finding.severity = Severity::Info;
1355
1356        for finding in [error_finding, warn_finding, info_finding] {
1357            let report = make_test_report(VerdictStatus::Fail, vec![finding], 0, 1);
1358            let annotations = render_annotations(&report, 25);
1359
1360            // GitHub annotation format: ::level file=path,line=num::message
1361            let line = annotations.lines().next().unwrap();
1362
1363            // Must start with ::
1364            assert!(line.starts_with("::"));
1365
1366            // Must have level (error, warning, notice)
1367            let has_valid_prefix = line.starts_with("::error ")
1368                || line.starts_with("::warning ")
1369                || line.starts_with("::notice ");
1370            assert!(has_valid_prefix);
1371
1372            // Must have file= parameter
1373            assert!(line.contains("file="));
1374
1375            // Must have double colon before message
1376            let parts: Vec<_> = line.split("::").collect();
1377            assert!(parts.len() >= 3);
1378        }
1379    }
1380
1381    #[test]
1382    fn test_annotations_each_on_separate_line() {
1383        let findings: Vec<_> = (1..=5)
1384            .map(|i| Finding::uncovered_line("src/lib.rs", i, 0))
1385            .collect();
1386        let report = make_test_report(VerdictStatus::Fail, findings, 0, 5);
1387        let annotations = render_annotations(&report, 25);
1388
1389        let lines: Vec<_> = annotations.lines().collect();
1390        assert_eq!(lines.len(), 5);
1391
1392        for (i, line) in lines.iter().enumerate() {
1393            assert!(line.contains(&format!("line={}", i + 1)));
1394        }
1395    }
1396
1397    #[test]
1398    fn test_annotations_severity_mapping() {
1399        let mut error_finding = Finding::uncovered_line("src/lib.rs", 1, 0);
1400        error_finding.severity = Severity::Error;
1401
1402        let mut warn_finding = Finding::uncovered_line("src/lib.rs", 2, 0);
1403        warn_finding.severity = Severity::Warn;
1404
1405        let mut info_finding = Finding::uncovered_line("src/lib.rs", 3, 0);
1406        info_finding.severity = Severity::Info;
1407
1408        let report = make_test_report(
1409            VerdictStatus::Fail,
1410            vec![error_finding, warn_finding, info_finding],
1411            0,
1412            3,
1413        );
1414        let annotations = render_annotations(&report, 25);
1415        let lines: Vec<_> = annotations.lines().collect();
1416
1417        assert!(lines[0].starts_with("::error "));
1418        assert!(lines[1].starts_with("::warning "));
1419        assert!(lines[2].starts_with("::notice "));
1420    }
1421
1422    #[test]
1423    fn test_annotations_no_trailing_whitespace() {
1424        let findings = vec![Finding::uncovered_line("src/lib.rs", 1, 0)];
1425        let report = make_test_report(VerdictStatus::Fail, findings, 0, 1);
1426        let annotations = render_annotations(&report, 25);
1427
1428        for line in annotations.lines() {
1429            assert!(!line.ends_with(' '));
1430        }
1431    }
1432
1433    // ========================================================================
1434    // Semantic Validation Tests - SARIF
1435    // ========================================================================
1436
1437    #[test]
1438    fn test_sarif_valid_json() {
1439        let findings = vec![Finding::uncovered_line("src/lib.rs", 1, 0)];
1440        let report = make_test_report(VerdictStatus::Fail, findings, 0, 1);
1441        let sarif = render_sarif(&report, 1000);
1442
1443        // Must be valid JSON
1444        let result: Result<serde_json::Value, _> = serde_json::from_str(&sarif);
1445        assert!(result.is_ok(), "SARIF output must be valid JSON");
1446    }
1447
1448    #[test]
1449    fn test_sarif_version_is_2_1_0() {
1450        let report = make_test_report(VerdictStatus::Pass, vec![], 5, 0);
1451        let sarif = render_sarif(&report, 1000);
1452        let parsed: serde_json::Value = serde_json::from_str(&sarif).unwrap();
1453
1454        assert_eq!(parsed["version"], "2.1.0");
1455    }
1456
1457    #[test]
1458    fn test_sarif_schema_uri_valid() {
1459        let report = make_test_report(VerdictStatus::Pass, vec![], 5, 0);
1460        let sarif = render_sarif(&report, 1000);
1461        let parsed: serde_json::Value = serde_json::from_str(&sarif).unwrap();
1462
1463        let schema = parsed["$schema"].as_str().unwrap();
1464        assert!(schema.contains("sarif-schema-2.1.0"));
1465        assert!(schema.starts_with("https://"));
1466    }
1467
1468    #[test]
1469    fn test_sarif_has_exactly_one_run() {
1470        let report = make_test_report(VerdictStatus::Pass, vec![], 5, 0);
1471        let sarif = render_sarif(&report, 1000);
1472        let parsed: serde_json::Value = serde_json::from_str(&sarif).unwrap();
1473
1474        let runs = parsed["runs"].as_array().unwrap();
1475        assert_eq!(runs.len(), 1);
1476    }
1477
1478    #[test]
1479    fn test_sarif_rule_index_matches_findings() {
1480        let findings = vec![
1481            Finding::uncovered_line("src/lib.rs", 1, 0),
1482            Finding::uncovered_line("src/lib.rs", 2, 0),
1483        ];
1484        let report = make_test_report(VerdictStatus::Fail, findings, 0, 2);
1485        let sarif = render_sarif(&report, 1000);
1486        let parsed: serde_json::Value = serde_json::from_str(&sarif).unwrap();
1487
1488        let rules = parsed["runs"][0]["tool"]["driver"]["rules"]
1489            .as_array()
1490            .unwrap();
1491        let results = parsed["runs"][0]["results"].as_array().unwrap();
1492
1493        for result in results {
1494            let rule_id = result["ruleId"].as_str().unwrap();
1495            let rule_index = result["ruleIndex"].as_u64().unwrap() as usize;
1496
1497            // Verify rule_index points to correct rule
1498            assert!(rule_index < rules.len());
1499            assert_eq!(rules[rule_index]["id"], rule_id);
1500        }
1501    }
1502
1503    #[test]
1504    fn test_sarif_location_uri_is_relative() {
1505        let findings = vec![Finding::uncovered_line("src/lib.rs", 1, 0)];
1506        let report = make_test_report(VerdictStatus::Fail, findings, 0, 1);
1507        let sarif = render_sarif(&report, 1000);
1508        let parsed: serde_json::Value = serde_json::from_str(&sarif).unwrap();
1509
1510        let uri =
1511            parsed["runs"][0]["results"][0]["locations"][0]["physicalLocation"]["artifactLocation"]
1512                ["uri"]
1513                .as_str()
1514                .unwrap();
1515
1516        // URI should be relative (not absolute)
1517        assert!(!uri.starts_with('/'));
1518        assert!(!uri.contains(":\\"));
1519    }
1520
1521    #[test]
1522    fn test_sarif_uri_base_id_is_srcroot() {
1523        let findings = vec![Finding::uncovered_line("src/lib.rs", 1, 0)];
1524        let report = make_test_report(VerdictStatus::Fail, findings, 0, 1);
1525        let sarif = render_sarif(&report, 1000);
1526        let parsed: serde_json::Value = serde_json::from_str(&sarif).unwrap();
1527
1528        let uri_base_id =
1529            parsed["runs"][0]["results"][0]["locations"][0]["physicalLocation"]["artifactLocation"]
1530                ["uriBaseId"]
1531                .as_str()
1532                .unwrap();
1533
1534        assert_eq!(uri_base_id, "%SRCROOT%");
1535    }
1536
1537    #[test]
1538    fn test_sarif_start_line_is_positive() {
1539        let findings = vec![
1540            Finding::uncovered_line("src/lib.rs", 1, 0),
1541            Finding::uncovered_line("src/lib.rs", 100, 0),
1542        ];
1543        let report = make_test_report(VerdictStatus::Fail, findings, 0, 2);
1544        let sarif = render_sarif(&report, 1000);
1545        let parsed: serde_json::Value = serde_json::from_str(&sarif).unwrap();
1546
1547        let results = parsed["runs"][0]["results"].as_array().unwrap();
1548        for result in results {
1549            let locations = result["locations"]
1550                .as_array()
1551                .expect("locations should be an array");
1552            for loc in locations {
1553                let region = loc["physicalLocation"]
1554                    .get("region")
1555                    .expect("region should be present");
1556                let start_line = region["startLine"].as_u64().unwrap();
1557                assert!(start_line > 0);
1558            }
1559        }
1560    }
1561
1562    #[test]
1563    fn test_sarif_level_is_valid() {
1564        let mut error_finding = Finding::uncovered_line("src/lib.rs", 1, 0);
1565        error_finding.severity = Severity::Error;
1566
1567        let mut warn_finding = Finding::uncovered_line("src/lib.rs", 2, 0);
1568        warn_finding.severity = Severity::Warn;
1569
1570        let mut info_finding = Finding::uncovered_line("src/lib.rs", 3, 0);
1571        info_finding.severity = Severity::Info;
1572
1573        let report = make_test_report(
1574            VerdictStatus::Fail,
1575            vec![error_finding, warn_finding, info_finding],
1576            0,
1577            3,
1578        );
1579        let sarif = render_sarif(&report, 1000);
1580        let parsed: serde_json::Value = serde_json::from_str(&sarif).unwrap();
1581
1582        let results = parsed["runs"][0]["results"].as_array().unwrap();
1583        let valid_levels = ["error", "warning", "note", "none"];
1584
1585        for result in results {
1586            let level = result["level"].as_str().unwrap();
1587            assert!(valid_levels.contains(&level));
1588        }
1589    }
1590
1591    #[test]
1592    fn test_sarif_rules_have_required_fields() {
1593        let report = make_test_report(VerdictStatus::Pass, vec![], 5, 0);
1594        let sarif = render_sarif(&report, 1000);
1595        let parsed: serde_json::Value = serde_json::from_str(&sarif).unwrap();
1596
1597        let rules = parsed["runs"][0]["tool"]["driver"]["rules"]
1598            .as_array()
1599            .unwrap();
1600
1601        for rule in rules {
1602            // Required fields per SARIF spec
1603            assert!(rule.get("id").is_some(), "Rule must have id");
1604            assert!(rule.get("shortDescription").is_some());
1605            assert!(rule["shortDescription"].get("text").is_some());
1606        }
1607    }
1608
1609    // ========================================================================
1610    // Edge Case Tests
1611    // ========================================================================
1612
1613    #[test]
1614    fn test_markdown_with_very_long_path() {
1615        let long_path = format!("src/{}/lib.rs", "very_long_directory_name".repeat(10));
1616        let finding = Finding::uncovered_line(&long_path, 1, 0);
1617        let report = make_test_report(VerdictStatus::Fail, vec![finding], 0, 1);
1618        let md = render_markdown(&report, 10);
1619
1620        // Should still contain the full path
1621        assert!(md.contains(&long_path));
1622    }
1623
1624    #[test]
1625    fn test_markdown_max_lines_zero() {
1626        let findings: Vec<_> = (1..=5)
1627            .map(|i| Finding::uncovered_line("src/lib.rs", i, 0))
1628            .collect();
1629        let report = make_test_report(VerdictStatus::Fail, findings, 0, 5);
1630        let md = render_markdown(&report, 0);
1631
1632        // Table header should still be present but no data rows
1633        assert!(md.contains("| File | Line | Hits |"));
1634        assert!(md.contains("*Showing 0 of 5 uncovered lines*"));
1635    }
1636
1637    #[test]
1638    fn test_annotations_max_annotations_zero() {
1639        let findings: Vec<_> = (1..=5)
1640            .map(|i| Finding::uncovered_line("src/lib.rs", i, 0))
1641            .collect();
1642        let report = make_test_report(VerdictStatus::Fail, findings, 0, 5);
1643        let annotations = render_annotations(&report, 0);
1644
1645        assert!(annotations.is_empty());
1646    }
1647
1648    #[test]
1649    fn test_sarif_max_results_zero() {
1650        let findings: Vec<_> = (1..=5)
1651            .map(|i| Finding::uncovered_line("src/lib.rs", i, 0))
1652            .collect();
1653        let report = make_test_report(VerdictStatus::Fail, findings, 0, 5);
1654        let sarif = render_sarif(&report, 0);
1655
1656        let parsed: serde_json::Value = serde_json::from_str(&sarif).unwrap();
1657        let results = parsed["runs"][0]["results"].as_array().unwrap();
1658        assert!(results.is_empty());
1659    }
1660
1661    #[test]
1662    fn test_markdown_line_without_number() {
1663        let mut finding = Finding::uncovered_line("src/lib.rs", 1, 0);
1664        finding.location.as_mut().unwrap().line = None;
1665
1666        let report = make_test_report(VerdictStatus::Fail, vec![finding], 0, 1);
1667        let md = render_markdown(&report, 10);
1668
1669        // Should show "-" for missing line number
1670        assert!(md.contains("| - |"));
1671    }
1672
1673    #[test]
1674    fn test_markdown_finding_without_data() {
1675        let mut finding = Finding::uncovered_line("src/lib.rs", 1, 0);
1676        finding.data = None;
1677
1678        let report = make_test_report(VerdictStatus::Fail, vec![finding], 0, 1);
1679        let md = render_markdown(&report, 10);
1680
1681        // Should show 0 for missing hits data
1682        assert!(md.contains("| 0 |"));
1683    }
1684}