Skip to main content

covguard_orchestrator/
lib.rs

1//! Application orchestration for covguard.
2//!
3//! This crate provides the high-level `check` function that orchestrates
4//! the entire diff coverage analysis pipeline:
5//!
6//! 1. Parse the diff to extract changed line ranges
7//! 2. Parse LCOV coverage data
8//! 3. Detect ignore directives in source files
9//! 4. Evaluate coverage against the policy
10//! 5. Build and return a report with markdown and annotations
11//!
12//! # Example
13//!
14//! ```rust,ignore
15//! use covguard_orchestrator::{check, CheckRequest};
16//! use covguard_types::Scope;
17//!
18//! let request = CheckRequest {
19//!     diff_text: "...".to_string(),
20//!     diff_file_path: Some("test.patch".to_string()),
21//!     lcov_texts: vec!["...".to_string()],
22//!     lcov_paths: vec!["coverage.info".to_string()],
23//!     threshold_pct: 80.0,
24//!     scope: Scope::Added,
25//!     ..Default::default()
26//! };
27//!
28//! let result = check(request)?;
29//! println!("Exit code: {}", result.exit_code);
30//! ```
31
32use covguard_adapters_coverage::LcovError;
33use covguard_adapters_diff::DiffError;
34use covguard_config::should_include_path;
35pub use covguard_directives::detect_ignored_lines;
36pub use covguard_domain::MissingBehavior;
37use covguard_domain::{EvalInput, EvalOutput, Policy, evaluate};
38pub use covguard_output::{
39    DEFAULT_ANNOTATION_LIMIT as DEFAULT_MAX_ANNOTATIONS,
40    DEFAULT_MARKDOWN_LINES as DEFAULT_MAX_LINES,
41    DEFAULT_SARIF_RESULTS as DEFAULT_MAX_SARIF_RESULTS, render_annotations,
42    render_annotations_with_limit, render_markdown, render_markdown_with_limit, render_sarif,
43    render_sarif_with_limit,
44};
45use covguard_output_features::OutputFeatureFlags;
46pub use covguard_policy::FailOn;
47pub use covguard_ports::{Clock, CoverageMap, CoverageProvider, DiffProvider, RepoReader};
48#[cfg(test)]
49use covguard_reporting::build_reasons as reporting_build_reasons;
50use covguard_reporting::{
51    ReportContext, build_debug as reporting_build_debug,
52    build_error_report_pair as reporting_build_error_report_pair,
53    build_report as reporting_build_report, build_report_pair as reporting_build_report_pair,
54    build_skip_report_pair as reporting_build_skip_report_pair,
55    is_invalid_diff as reporting_is_invalid_diff,
56};
57#[cfg(test)]
58use covguard_types::{
59    CODE_COVERAGE_BELOW_THRESHOLD, CODE_RUNTIME_ERROR, Finding, InputStatus,
60    REASON_BELOW_THRESHOLD, REASON_DIFF_COVERED, REASON_NO_CHANGED_LINES, REASON_SKIPPED,
61    REASON_TRUNCATED, REASON_UNCOVERED_LINES, SCHEMA_ID, SENSOR_SCHEMA_ID,
62};
63use covguard_types::{
64    CODE_INVALID_DIFF, CODE_INVALID_LCOV, REASON_MISSING_LCOV, Report, Scope, VerdictStatus,
65};
66use std::collections::{BTreeMap, BTreeSet};
67use thiserror::Error;
68
69// ============================================================================
70// Clock Trait
71// ============================================================================
72
73/// System clock implementation that returns the actual current time.
74pub struct SystemClock;
75
76impl Clock for SystemClock {
77    fn now(&self) -> chrono::DateTime<chrono::Utc> {
78        chrono::Utc::now()
79    }
80}
81
82// ============================================================================
83// Request and Result Types
84// ============================================================================
85
86/// A no-op reader that returns None for all lines.
87/// Used when ignore directives are disabled.
88struct NullReader;
89
90impl RepoReader for NullReader {
91    fn read_line(&self, _path: &str, _line_no: u32) -> Option<String> {
92        None
93    }
94}
95
96/// Request for a coverage check operation.
97#[derive(Debug, Clone)]
98pub struct CheckRequest {
99    /// Patch file content (unified diff format).
100    pub diff_text: String,
101    /// Path to the diff file, for report metadata.
102    pub diff_file_path: Option<String>,
103    /// Base git ref, for report metadata (alternative to diff_file_path).
104    pub base_ref: Option<String>,
105    /// Head git ref, for report metadata (alternative to diff_file_path).
106    pub head_ref: Option<String>,
107    /// LCOV coverage file contents (one per input).
108    pub lcov_texts: Vec<String>,
109    /// Paths to LCOV files, for report metadata.
110    pub lcov_paths: Vec<String>,
111    /// Maximum allowed uncovered lines (optional tolerance buffer).
112    pub max_uncovered_lines: Option<u32>,
113    /// How to handle missing coverage lines within files.
114    pub missing_coverage: MissingBehavior,
115    /// How to handle files with no coverage data.
116    pub missing_file: MissingBehavior,
117    /// Glob patterns to include (allowlist).
118    pub include_patterns: Vec<String>,
119    /// Glob patterns to exclude.
120    pub exclude_patterns: Vec<String>,
121    /// Prefixes to strip from LCOV SF paths.
122    pub path_strip: Vec<String>,
123    /// Minimum diff coverage percentage threshold.
124    pub threshold_pct: f64,
125    /// Scope of lines to evaluate.
126    pub scope: Scope,
127    /// Determines when the evaluation should fail.
128    pub fail_on: FailOn,
129    /// Whether to honor `covguard: ignore` directives.
130    pub ignore_directives: bool,
131    /// Pre-computed ignored lines (path -> set of line numbers).
132    /// If provided, these are used directly instead of reading from source.
133    pub ignored_lines: Option<BTreeMap<String, BTreeSet<u32>>>,
134    /// Emit sensor.report.v1 schema with capabilities block.
135    pub sensor_schema: bool,
136    /// Renderer budgets used for markdown/annotations/SARIF output.
137    pub output: OutputFeatureFlags,
138    /// Maximum number of findings to include in the report (truncation).
139    pub max_findings: Option<usize>,
140}
141
142impl Default for CheckRequest {
143    fn default() -> Self {
144        Self {
145            diff_text: String::new(),
146            diff_file_path: None,
147            base_ref: None,
148            head_ref: None,
149            lcov_texts: Vec::new(),
150            lcov_paths: Vec::new(),
151            max_uncovered_lines: None,
152            missing_coverage: MissingBehavior::Warn,
153            missing_file: MissingBehavior::Warn,
154            include_patterns: Vec::new(),
155            exclude_patterns: Vec::new(),
156            path_strip: Vec::new(),
157            threshold_pct: 80.0,
158            scope: Scope::Added,
159            fail_on: FailOn::Error,
160            ignore_directives: true,
161            ignored_lines: None,
162            sensor_schema: false,
163            output: OutputFeatureFlags::default(),
164            max_findings: None,
165        }
166    }
167}
168
169/// Result of a coverage check operation.
170#[derive(Debug, Clone)]
171pub struct CheckResult {
172    /// The domain report (covguard.report.v1, ALL findings, no capabilities).
173    pub report: Report,
174    /// Renderer budgets that were used to build outputs for this result.
175    pub output: OutputFeatureFlags,
176    /// The cockpit receipt (sensor.report.v1, truncated findings, capabilities).
177    /// Only populated when `sensor_schema: true` (cockpit mode).
178    pub cockpit_receipt: Option<Report>,
179    /// Markdown rendering of the report.
180    pub markdown: String,
181    /// GitHub annotations rendering of the report.
182    pub annotations: String,
183    /// SARIF rendering of the report.
184    pub sarif: String,
185    /// Exit code for the CLI.
186    /// - 0: pass or warn
187    /// - 2: policy fail (blocking findings)
188    /// - 1: tool/runtime error (not returned here, only via AppError)
189    pub exit_code: i32,
190}
191
192// ============================================================================
193// Errors
194// ============================================================================
195
196/// Errors that can occur during the check operation.
197#[derive(Debug, Error)]
198pub enum AppError {
199    /// Failed to parse the diff.
200    #[error("Failed to parse diff: {0}")]
201    DiffParse(String),
202
203    /// Failed to parse the LCOV coverage file.
204    #[error("Failed to parse LCOV: {0}")]
205    LcovParse(String),
206
207    /// I/O error.
208    #[error("I/O error: {0}")]
209    Io(String),
210}
211
212impl From<DiffError> for AppError {
213    fn from(e: DiffError) -> Self {
214        AppError::DiffParse(e.to_string())
215    }
216}
217
218impl From<LcovError> for AppError {
219    fn from(e: LcovError) -> Self {
220        AppError::LcovParse(e.to_string())
221    }
222}
223
224// ============================================================================
225// Main Check Function
226// ============================================================================
227
228/// Run a diff coverage check.
229///
230/// This is the main entry point for the covguard analysis. It:
231/// 1. Parses the diff to extract changed line ranges
232/// 2. Parses LCOV coverage data
233/// 3. Evaluates coverage against the policy
234/// 4. Builds and returns a result with report, markdown, annotations, and exit code
235///
236/// # Arguments
237///
238/// * `request` - The check request containing diff, coverage, and options
239///
240/// # Returns
241///
242/// A `CheckResult` containing the report, rendered outputs, and exit code.
243///
244/// # Errors
245///
246/// Returns `AppError` if parsing fails.
247pub fn check(request: CheckRequest) -> Result<CheckResult, AppError> {
248    check_with_clock(request, &SystemClock)
249}
250
251/// Run a diff coverage check with a custom clock.
252///
253/// This allows for deterministic testing with fixed timestamps.
254pub fn check_with_clock<C: Clock>(
255    request: CheckRequest,
256    clock: &C,
257) -> Result<CheckResult, AppError> {
258    check_with_clock_and_reader(request, clock, &NullReader)
259}
260
261/// Run a diff coverage check with a custom clock and repo reader.
262///
263/// The repo reader is used to detect `covguard: ignore` directives in source files.
264pub fn check_with_clock_and_reader<C: Clock, R: RepoReader>(
265    request: CheckRequest,
266    clock: &C,
267    reader: &R,
268) -> Result<CheckResult, AppError> {
269    let diff_provider = covguard_adapters_diff::GitDiffProvider;
270    let coverage_provider = covguard_adapters_coverage::LcovCoverageProvider;
271    check_with_providers_and_reader(request, clock, reader, &diff_provider, &coverage_provider)
272}
273
274/// Run a diff coverage check with pluggable diff and coverage providers.
275///
276/// This enables fully port-driven orchestration while preserving the default
277/// API surface for callers that use built-in adapters.
278pub fn check_with_providers_and_reader<
279    C: Clock,
280    R: RepoReader,
281    D: DiffProvider,
282    P: CoverageProvider,
283>(
284    request: CheckRequest,
285    clock: &C,
286    reader: &R,
287    diff_provider: &D,
288    coverage_provider: &P,
289) -> Result<CheckResult, AppError> {
290    let started_at = clock.now();
291
292    // Determine diff availability
293    let diff_available = !request.diff_text.is_empty()
294        || request.diff_file_path.is_some()
295        || (request.base_ref.is_some() && request.head_ref.is_some());
296
297    // Determine coverage availability
298    let coverage_available =
299        !request.lcov_texts.is_empty() && request.lcov_texts.iter().any(|t| !t.trim().is_empty());
300
301    // In sensor schema mode, return skip result if coverage is unavailable
302    if request.sensor_schema && !coverage_available {
303        return Ok(build_skip_result(
304            &request,
305            started_at,
306            diff_available,
307            false, // coverage unavailable
308            REASON_MISSING_LCOV,
309            clock,
310        ));
311    }
312
313    // Validate diff input (non-empty must include diff markers)
314    if is_invalid_diff(&request.diff_text) {
315        return Ok(build_error_result(
316            &request,
317            started_at,
318            CODE_INVALID_DIFF,
319            "Diff input did not contain any recognized diff markers.",
320            true, // diff was provided (non-empty)
321            coverage_available,
322            clock,
323        ));
324    }
325
326    // Parse the diff
327    let parse_result = match diff_provider.parse_patch(&request.diff_text) {
328        Ok(ranges) => ranges,
329        Err(e) => {
330            return Ok(build_error_result(
331                &request,
332                started_at,
333                CODE_INVALID_DIFF,
334                &format!("Failed to parse diff: {e}"),
335                true, // diff was provided
336                coverage_available,
337                clock,
338            ));
339        }
340    };
341    let changed_ranges = parse_result.changed_ranges;
342    let binary_files = parse_result.binary_files;
343
344    // Parse LCOV coverage (merge multiple inputs)
345    let mut coverage_maps: Vec<CoverageMap> = Vec::new();
346    for lcov_text in &request.lcov_texts {
347        if lcov_text.trim().is_empty() {
348            continue;
349        }
350        if !lcov_text.contains("SF:") {
351            return Ok(build_error_result(
352                &request,
353                started_at,
354                CODE_INVALID_LCOV,
355                "LCOV input contained no SF records.",
356                true, // diff parsed OK
357                true, // coverage was provided (but invalid)
358                clock,
359            ));
360        }
361        match coverage_provider.parse_lcov(lcov_text, &request.path_strip) {
362            Ok(map) => coverage_maps.push(map),
363            Err(e) => {
364                return Ok(build_error_result(
365                    &request,
366                    started_at,
367                    CODE_INVALID_LCOV,
368                    &format!("Failed to parse LCOV: {e}"),
369                    true, // diff parsed OK
370                    true, // coverage was provided (but invalid)
371                    clock,
372                ));
373            }
374        }
375    }
376    let coverage: CoverageMap = coverage_provider.merge_coverage(coverage_maps);
377
378    // Detect ignored lines
379    let mut filtered_ranges = changed_ranges;
380    let mut excluded_files_count = 0u32;
381    filtered_ranges.retain(|path, _| {
382        if should_include_path(path, &request.include_patterns, &request.exclude_patterns) {
383            true
384        } else {
385            excluded_files_count += 1;
386            false
387        }
388    });
389
390    let ignored_lines = if request.ignore_directives {
391        request
392            .ignored_lines
393            .clone()
394            .unwrap_or_else(|| detect_ignored_lines(&filtered_ranges, reader))
395    } else {
396        BTreeMap::new()
397    };
398
399    let domain_scope = request.scope;
400
401    let policy = Policy {
402        scope: domain_scope,
403        threshold_pct: request.threshold_pct,
404        max_uncovered_lines: request.max_uncovered_lines,
405        missing_coverage: request.missing_coverage,
406        missing_file: request.missing_file,
407        fail_on: request.fail_on,
408        ignore_directives_enabled: request.ignore_directives,
409    };
410
411    // Build evaluation input
412    let eval_input = EvalInput {
413        changed_ranges: filtered_ranges,
414        coverage,
415        policy,
416        ignored_lines,
417    };
418
419    // Run evaluation
420    let eval_output = evaluate(eval_input);
421
422    // Build the report pair (domain report + optional cockpit receipt)
423    let debug = build_debug(&binary_files);
424    let ended_at = clock.now();
425    let output = request.output;
426    let (domain_report, cockpit_receipt) = build_report_pair(
427        eval_output.clone(),
428        &request,
429        started_at,
430        ended_at,
431        excluded_files_count,
432        debug,
433    );
434
435    // Render outputs from the domain report (full findings)
436    let markdown = render_markdown_with_limit(&domain_report, output.max_markdown_lines);
437    let annotations = render_annotations_with_limit(&domain_report, output.max_annotations);
438    let sarif = render_sarif_with_limit(&domain_report, output.max_sarif_results);
439
440    // Determine exit code from domain report verdict
441    let exit_code = match domain_report.verdict.status {
442        VerdictStatus::Pass | VerdictStatus::Warn | VerdictStatus::Skip => 0,
443        VerdictStatus::Fail => 2,
444    };
445
446    Ok(CheckResult {
447        report: domain_report,
448        output,
449        cockpit_receipt,
450        markdown,
451        annotations,
452        sarif,
453        exit_code,
454    })
455}
456
457// ============================================================================
458// Report Builder
459// ============================================================================
460
461// truncate_findings moved to `covguard_output` for shared API semantics.
462
463fn build_report_context(request: &CheckRequest) -> ReportContext {
464    ReportContext {
465        threshold_pct: request.threshold_pct,
466        scope: request.scope,
467        sensor_schema: request.sensor_schema,
468        max_findings: request.max_findings,
469        diff_file_path: request.diff_file_path.clone(),
470        base_ref: request.base_ref.clone(),
471        head_ref: request.head_ref.clone(),
472        lcov_paths: request.lcov_paths.clone(),
473    }
474}
475
476/// Build a pair of reports from evaluation output: a domain report and an optional cockpit receipt.
477///
478/// - **Domain report**: Always `covguard.report.v1`, no capabilities, full findings
479///   (standard-mode truncation preserved when `!sensor_schema && max_findings.is_some()`).
480/// - **Cockpit receipt** (only when `sensor_schema: true`): `sensor.report.v1`,
481///   capabilities block, findings truncated to `max_findings`, counts from full set.
482fn build_report_pair(
483    eval: EvalOutput,
484    request: &CheckRequest,
485    started_at: chrono::DateTime<chrono::Utc>,
486    ended_at: chrono::DateTime<chrono::Utc>,
487    excluded_files_count: u32,
488    debug: Option<serde_json::Value>,
489) -> (Report, Option<Report>) {
490    reporting_build_report_pair(
491        eval,
492        &build_report_context(request),
493        started_at,
494        ended_at,
495        excluded_files_count,
496        debug,
497    )
498}
499
500/// Build a Report from evaluation output.
501///
502/// # Arguments
503///
504/// * `eval` - The evaluation output from the domain layer
505/// * `request` - The original check request
506/// * `started_at` - When the check started
507///
508/// # Returns
509///
510/// A fully populated Report struct.
511pub fn build_report(
512    eval: EvalOutput,
513    request: &CheckRequest,
514    started_at: chrono::DateTime<chrono::Utc>,
515    ended_at: chrono::DateTime<chrono::Utc>,
516    excluded_files_count: u32,
517    debug: Option<serde_json::Value>,
518) -> Report {
519    reporting_build_report(
520        eval,
521        &build_report_context(request),
522        started_at,
523        ended_at,
524        excluded_files_count,
525        debug,
526    )
527}
528
529fn build_debug(binary_files: &[String]) -> Option<serde_json::Value> {
530    reporting_build_debug(binary_files)
531}
532
533fn build_error_result<C: Clock>(
534    request: &CheckRequest,
535    started_at: chrono::DateTime<chrono::Utc>,
536    code: &str,
537    message: &str,
538    diff_available: bool,
539    coverage_available: bool,
540    clock: &C,
541) -> CheckResult {
542    let ended_at = clock.now();
543    let (domain_report, cockpit_receipt) = build_error_report_pair(
544        request,
545        started_at,
546        ended_at,
547        code,
548        message,
549        diff_available,
550        coverage_available,
551    );
552    let markdown = render_markdown_with_limit(&domain_report, request.output.max_markdown_lines);
553    let annotations = render_annotations_with_limit(&domain_report, request.output.max_annotations);
554    let sarif = render_sarif_with_limit(&domain_report, request.output.max_sarif_results);
555
556    CheckResult {
557        report: domain_report,
558        output: request.output,
559        cockpit_receipt,
560        markdown,
561        annotations,
562        sarif,
563        exit_code: 1,
564    }
565}
566
567/// Build both domain report and optional cockpit receipt for error cases.
568fn build_error_report_pair(
569    request: &CheckRequest,
570    started_at: chrono::DateTime<chrono::Utc>,
571    ended_at: chrono::DateTime<chrono::Utc>,
572    code: &str,
573    message: &str,
574    diff_available: bool,
575    coverage_available: bool,
576) -> (Report, Option<Report>) {
577    reporting_build_error_report_pair(
578        &build_report_context(request),
579        started_at,
580        ended_at,
581        code,
582        message,
583        diff_available,
584        coverage_available,
585    )
586}
587
588/// Build a skip result when inputs are unavailable (sensor.report.v1 compliance).
589fn build_skip_result<C: Clock>(
590    request: &CheckRequest,
591    started_at: chrono::DateTime<chrono::Utc>,
592    diff_available: bool,
593    coverage_available: bool,
594    reason: &str,
595    clock: &C,
596) -> CheckResult {
597    let ended_at = clock.now();
598    let (domain_report, cockpit_receipt) = build_skip_report_pair(
599        request,
600        started_at,
601        ended_at,
602        diff_available,
603        coverage_available,
604        reason,
605    );
606    let markdown = render_markdown_with_limit(&domain_report, request.output.max_markdown_lines);
607    let annotations = render_annotations_with_limit(&domain_report, request.output.max_annotations);
608    let sarif = render_sarif_with_limit(&domain_report, request.output.max_sarif_results);
609
610    CheckResult {
611        report: domain_report,
612        output: request.output,
613        cockpit_receipt,
614        markdown,
615        annotations,
616        sarif,
617        exit_code: 0, // Skip is not a failure
618    }
619}
620
621/// Build both domain report and cockpit receipt for skip cases.
622fn build_skip_report_pair(
623    request: &CheckRequest,
624    started_at: chrono::DateTime<chrono::Utc>,
625    ended_at: chrono::DateTime<chrono::Utc>,
626    diff_available: bool,
627    coverage_available: bool,
628    reason: &str,
629) -> (Report, Option<Report>) {
630    reporting_build_skip_report_pair(
631        &build_report_context(request),
632        started_at,
633        ended_at,
634        diff_available,
635        coverage_available,
636        reason,
637    )
638}
639
640fn is_invalid_diff(diff_text: &str) -> bool {
641    reporting_is_invalid_diff(diff_text)
642}
643
644/// Build verdict reasons based on evaluation output.
645#[cfg(test)]
646fn build_reasons(output: &EvalOutput) -> Vec<String> {
647    reporting_build_reasons(output)
648}
649
650// ============================================================================
651// Convenience Functions
652// ============================================================================
653
654// Rendering convenience functions are re-exported from `covguard_output`
655// to keep output formatting responsibilities centralized.
656
657// ============================================================================
658// Ignore Directive Detection
659// ============================================================================
660//
661// Detection is delegated to `covguard_directives::detect_ignored_lines` and
662// re-exported from this module for backward-compatible API access.
663
664// ============================================================================
665// Tests
666// ============================================================================
667
668#[cfg(test)]
669mod tests {
670    use super::*;
671    use covguard_domain::Metrics;
672    use std::collections::{BTreeMap, BTreeSet};
673    use std::path::Path;
674
675    /// A test clock that returns a fixed time.
676    struct FixedClock {
677        time: chrono::DateTime<chrono::Utc>,
678    }
679
680    impl FixedClock {
681        fn new(timestamp: &str) -> Self {
682            Self {
683                time: chrono::DateTime::parse_from_rfc3339(timestamp)
684                    .unwrap()
685                    .with_timezone(&chrono::Utc),
686            }
687        }
688    }
689
690    impl Clock for FixedClock {
691        fn now(&self) -> chrono::DateTime<chrono::Utc> {
692            self.time
693        }
694    }
695
696    struct MapReader {
697        lines: BTreeMap<(String, u32), String>,
698    }
699
700    impl MapReader {
701        fn new(entries: Vec<(&str, u32, &str)>) -> Self {
702            let mut lines = BTreeMap::new();
703            for (path, line_no, content) in entries {
704                lines.insert((path.to_string(), line_no), content.to_string());
705            }
706            Self { lines }
707        }
708    }
709
710    impl RepoReader for MapReader {
711        fn read_line(&self, path: &str, line_no: u32) -> Option<String> {
712            self.lines.get(&(path.to_string(), line_no)).cloned()
713        }
714    }
715
716    struct FakeDiffProvider {
717        parsed: covguard_ports::DiffParseResult,
718    }
719
720    impl DiffProvider for FakeDiffProvider {
721        fn parse_patch(&self, _text: &str) -> Result<covguard_ports::DiffParseResult, String> {
722            Ok(self.parsed.clone())
723        }
724
725        fn load_diff_from_git(
726            &self,
727            _base: &str,
728            _head: &str,
729            _repo_root: &Path,
730        ) -> Result<String, String> {
731            Ok(String::new())
732        }
733    }
734
735    struct FakeCoverageProvider {
736        map: CoverageMap,
737    }
738
739    impl CoverageProvider for FakeCoverageProvider {
740        fn parse_lcov(
741            &self,
742            _text: &str,
743            _strip_prefixes: &[String],
744        ) -> Result<CoverageMap, String> {
745            Ok(self.map.clone())
746        }
747
748        fn merge_coverage(&self, maps: Vec<CoverageMap>) -> CoverageMap {
749            let mut merged = BTreeMap::new();
750            for map in maps {
751                for (path, lines) in map {
752                    merged.insert(path, lines);
753                }
754            }
755            merged
756        }
757    }
758
759    // ========================================================================
760    // End-to-end tests with fixtures
761    // ========================================================================
762
763    #[test]
764    fn test_e2e_uncovered() {
765        let diff = r#"diff --git a/src/lib.rs b/src/lib.rs
766new file mode 100644
767index 0000000..1111111
768--- /dev/null
769+++ b/src/lib.rs
770@@ -0,0 +1,3 @@
771+pub fn add(a: i32, b: i32) -> i32 {
772+    a + b
773+}
774"#;
775
776        let lcov = r#"TN:
777SF:src/lib.rs
778DA:1,0
779DA:2,0
780DA:3,0
781end_of_record
782"#;
783
784        let request = CheckRequest {
785            diff_text: diff.to_string(),
786            diff_file_path: Some("fixtures/diff/simple_added.patch".to_string()),
787            base_ref: None,
788            head_ref: None,
789            lcov_texts: vec![lcov.to_string()],
790            lcov_paths: vec!["fixtures/lcov/uncovered.info".to_string()],
791            threshold_pct: 80.0,
792            scope: Scope::Added,
793            ..Default::default()
794        };
795
796        let clock = FixedClock::new("2026-02-02T00:00:00Z");
797        let result = check_with_clock(request, &clock).unwrap();
798
799        // Verify report
800        assert_eq!(result.report.schema, SCHEMA_ID);
801        assert_eq!(result.report.verdict.status, VerdictStatus::Fail);
802        assert_eq!(result.report.data.changed_lines_total, 3);
803        assert_eq!(result.report.data.covered_lines, 0);
804        assert_eq!(result.report.data.uncovered_lines, 3);
805        assert_eq!(result.report.data.diff_coverage_pct, 0.0);
806
807        // Should have 3 uncovered line findings + 1 threshold finding
808        assert_eq!(result.report.findings.len(), 4);
809
810        // Verify exit code
811        assert_eq!(result.exit_code, 2);
812
813        // Verify markdown contains expected content
814        assert!(result.markdown.contains("covguard"));
815        assert!(result.markdown.contains("fail"));
816
817        // Verify annotations are present
818        assert!(!result.annotations.is_empty());
819        assert!(result.annotations.contains("::error"));
820    }
821
822    #[test]
823    fn test_check_with_providers_uses_injected_ports() {
824        let request = CheckRequest {
825            // This would fail with the built-in parser due to malformed hunk header.
826            diff_text: "diff --git a/src/lib.rs b/src/lib.rs\n@@ -1,1 @@\n+line\n".to_string(),
827            lcov_texts: vec!["SF:src/lib.rs\nDA:1,1\nend_of_record\n".to_string()],
828            scope: Scope::Added,
829            ..Default::default()
830        };
831
832        let mut changed_ranges = BTreeMap::new();
833        changed_ranges.insert("src/lib.rs".to_string(), vec![1..=1]);
834        let diff_provider = FakeDiffProvider {
835            parsed: covguard_ports::DiffParseResult {
836                changed_ranges,
837                binary_files: Vec::new(),
838            },
839        };
840
841        let mut coverage_lines = BTreeMap::new();
842        coverage_lines.insert(1, 1);
843        let mut coverage_map = BTreeMap::new();
844        coverage_map.insert("src/lib.rs".to_string(), coverage_lines);
845        let coverage_provider = FakeCoverageProvider { map: coverage_map };
846
847        let clock = FixedClock::new("2026-02-02T00:00:00Z");
848        let result = check_with_providers_and_reader(
849            request,
850            &clock,
851            &NullReader,
852            &diff_provider,
853            &coverage_provider,
854        )
855        .unwrap();
856
857        assert_eq!(result.report.verdict.status, VerdictStatus::Pass);
858        assert_eq!(result.report.data.changed_lines_total, 1);
859        assert_eq!(result.report.data.covered_lines, 1);
860        assert_eq!(result.report.findings.len(), 0);
861    }
862
863    #[test]
864    fn test_e2e_covered() {
865        let diff = r#"diff --git a/src/lib.rs b/src/lib.rs
866new file mode 100644
867index 0000000..1111111
868--- /dev/null
869+++ b/src/lib.rs
870@@ -0,0 +1,3 @@
871+pub fn add(a: i32, b: i32) -> i32 {
872+    a + b
873+}
874"#;
875
876        let lcov = r#"TN:
877SF:src/lib.rs
878DA:1,1
879DA:2,1
880DA:3,1
881end_of_record
882"#;
883
884        let request = CheckRequest {
885            diff_text: diff.to_string(),
886            diff_file_path: Some("fixtures/diff/simple_added.patch".to_string()),
887            base_ref: None,
888            head_ref: None,
889            lcov_texts: vec![lcov.to_string()],
890            lcov_paths: vec!["fixtures/lcov/covered.info".to_string()],
891            threshold_pct: 80.0,
892            scope: Scope::Added,
893            ..Default::default()
894        };
895
896        let clock = FixedClock::new("2026-02-02T00:00:00Z");
897        let result = check_with_clock(request, &clock).unwrap();
898
899        // Verify report
900        assert_eq!(result.report.verdict.status, VerdictStatus::Pass);
901        assert_eq!(result.report.data.changed_lines_total, 3);
902        assert_eq!(result.report.data.covered_lines, 3);
903        assert_eq!(result.report.data.uncovered_lines, 0);
904        assert_eq!(result.report.data.diff_coverage_pct, 100.0);
905
906        // No findings for covered code
907        assert!(result.report.findings.is_empty());
908
909        // Verify exit code
910        assert_eq!(result.exit_code, 0);
911
912        // Verify markdown contains expected content
913        assert!(result.markdown.contains("pass"));
914    }
915
916    // ========================================================================
917    // Error handling tests
918    // ========================================================================
919
920    #[test]
921    fn test_error_bad_diff() {
922        let diff = "not a valid diff at all\nrandom garbage";
923        let lcov = "TN:\nSF:src/lib.rs\nDA:1,1\nend_of_record\n";
924
925        let request = CheckRequest {
926            diff_text: diff.to_string(),
927            diff_file_path: None,
928            base_ref: None,
929            head_ref: None,
930            lcov_texts: vec![lcov.to_string()],
931            lcov_paths: vec![],
932            threshold_pct: 80.0,
933            scope: Scope::Added,
934            ..Default::default()
935        };
936
937        let result = check(request).expect("invalid diff should return error report");
938        assert_eq!(result.exit_code, 1);
939        assert_eq!(result.report.verdict.status, VerdictStatus::Fail);
940        assert!(
941            result
942                .report
943                .findings
944                .iter()
945                .any(|f| f.code == CODE_INVALID_DIFF)
946        );
947        assert!(
948            result
949                .report
950                .findings
951                .iter()
952                .any(|f| f.code == CODE_RUNTIME_ERROR)
953        );
954    }
955
956    #[test]
957    fn test_error_bad_lcov() {
958        let diff = r#"diff --git a/src/lib.rs b/src/lib.rs
959new file mode 100644
960--- /dev/null
961+++ b/src/lib.rs
962@@ -0,0 +1,1 @@
963+fn main() {}
964"#;
965
966        // Invalid LCOV: DA without SF
967        let lcov = "DA:1,1\nend_of_record\n";
968
969        let request = CheckRequest {
970            diff_text: diff.to_string(),
971            diff_file_path: None,
972            base_ref: None,
973            head_ref: None,
974            lcov_texts: vec![lcov.to_string()],
975            lcov_paths: vec![],
976            threshold_pct: 80.0,
977            scope: Scope::Added,
978            ..Default::default()
979        };
980
981        let result = check(request).expect("invalid lcov should return error report");
982        assert_eq!(result.exit_code, 1);
983        assert_eq!(result.report.verdict.status, VerdictStatus::Fail);
984        assert!(
985            result
986                .report
987                .findings
988                .iter()
989                .any(|f| f.code == CODE_INVALID_LCOV)
990        );
991        assert!(
992            result
993                .report
994                .findings
995                .iter()
996                .any(|f| f.code == CODE_RUNTIME_ERROR)
997        );
998    }
999
1000    // ========================================================================
1001    // Unit tests
1002    // ========================================================================
1003
1004    #[test]
1005    fn test_check_request_default() {
1006        let request = CheckRequest::default();
1007        assert_eq!(request.threshold_pct, 80.0);
1008        assert_eq!(request.scope, Scope::Added);
1009        assert!(request.diff_text.is_empty());
1010        assert!(request.lcov_texts.is_empty());
1011    }
1012
1013    #[test]
1014    fn test_clock_trait() {
1015        let clock = SystemClock;
1016        let now = clock.now();
1017        // Just verify it returns a valid time
1018        assert!(now.timestamp() > 0);
1019    }
1020
1021    #[test]
1022    fn test_fixed_clock() {
1023        let clock = FixedClock::new("2026-02-02T12:30:45Z");
1024        let time = clock.now();
1025        assert_eq!(
1026            time.format("%Y-%m-%dT%H:%M:%SZ").to_string(),
1027            "2026-02-02T12:30:45Z"
1028        );
1029    }
1030
1031    #[test]
1032    fn test_empty_diff() {
1033        let request = CheckRequest {
1034            diff_text: String::new(),
1035            diff_file_path: None,
1036            base_ref: None,
1037            head_ref: None,
1038            lcov_texts: vec!["TN:\nSF:src/lib.rs\nDA:1,1\nend_of_record\n".to_string()],
1039            lcov_paths: vec![],
1040            threshold_pct: 80.0,
1041            scope: Scope::Added,
1042            ..Default::default()
1043        };
1044
1045        let result = check(request).unwrap();
1046        assert_eq!(result.report.verdict.status, VerdictStatus::Pass);
1047        assert_eq!(result.report.data.changed_lines_total, 0);
1048        assert_eq!(result.exit_code, 0);
1049    }
1050
1051    #[test]
1052    fn test_exit_codes() {
1053        // Pass case
1054        let request = CheckRequest {
1055            diff_text: String::new(),
1056            diff_file_path: None,
1057            base_ref: None,
1058            head_ref: None,
1059            lcov_texts: vec![String::new()],
1060            lcov_paths: vec![],
1061            threshold_pct: 80.0,
1062            scope: Scope::Added,
1063            ..Default::default()
1064        };
1065        let result = check(request).unwrap();
1066        assert_eq!(result.exit_code, 0);
1067
1068        // Fail case
1069        let diff =
1070            "diff --git a/x.rs b/x.rs\n--- /dev/null\n+++ b/x.rs\n@@ -0,0 +1,1 @@\n+fn x() {}\n";
1071        let lcov = "TN:\nSF:x.rs\nDA:1,0\nend_of_record\n";
1072        let request = CheckRequest {
1073            diff_text: diff.to_string(),
1074            diff_file_path: None,
1075            base_ref: None,
1076            head_ref: None,
1077            lcov_texts: vec![lcov.to_string()],
1078            lcov_paths: vec![],
1079            threshold_pct: 80.0,
1080            scope: Scope::Added,
1081            ..Default::default()
1082        };
1083        let result = check(request).unwrap();
1084        assert_eq!(result.exit_code, 2);
1085    }
1086
1087    #[test]
1088    fn test_build_report_timestamp() {
1089        use chrono::TimeZone;
1090
1091        let eval = EvalOutput {
1092            findings: vec![],
1093            verdict: VerdictStatus::Pass,
1094            metrics: covguard_domain::Metrics::default(),
1095        };
1096
1097        let request = CheckRequest::default();
1098        let started_at = chrono::Utc.with_ymd_and_hms(2026, 2, 2, 10, 30, 0).unwrap();
1099        let ended_at = chrono::Utc.with_ymd_and_hms(2026, 2, 2, 10, 30, 1).unwrap();
1100
1101        let report = build_report(eval, &request, started_at, ended_at, 0, None);
1102
1103        assert_eq!(report.run.started_at, "2026-02-02T10:30:00Z");
1104        assert_eq!(
1105            report.run.ended_at,
1106            Some("2026-02-02T10:30:01Z".to_string())
1107        );
1108        assert_eq!(report.run.duration_ms, Some(1000));
1109    }
1110
1111    #[test]
1112    fn test_build_report_tool_info() {
1113        let eval = EvalOutput {
1114            findings: vec![],
1115            verdict: VerdictStatus::Pass,
1116            metrics: covguard_domain::Metrics::default(),
1117        };
1118
1119        let request = CheckRequest::default();
1120        let started_at = chrono::Utc::now();
1121        let ended_at = started_at;
1122
1123        let report = build_report(eval, &request, started_at, ended_at, 0, None);
1124
1125        assert_eq!(report.tool.name, "covguard");
1126        assert_eq!(report.tool.version, "0.1.0");
1127    }
1128
1129    #[test]
1130    fn test_render_markdown() {
1131        let report = Report::default();
1132        let md = render_markdown(&report);
1133        assert!(md.contains("covguard"));
1134    }
1135
1136    #[test]
1137    fn test_render_annotations_empty() {
1138        let report = Report::default();
1139        let ann = render_annotations(&report);
1140        assert!(ann.is_empty());
1141    }
1142
1143    #[test]
1144    fn test_scope_touched() {
1145        let diff =
1146            "diff --git a/x.rs b/x.rs\n--- /dev/null\n+++ b/x.rs\n@@ -0,0 +1,1 @@\n+fn x() {}\n";
1147        let lcov = "TN:\nSF:x.rs\nDA:1,1\nend_of_record\n";
1148
1149        let request = CheckRequest {
1150            diff_text: diff.to_string(),
1151            diff_file_path: None,
1152            base_ref: None,
1153            head_ref: None,
1154            lcov_texts: vec![lcov.to_string()],
1155            lcov_paths: vec![],
1156            threshold_pct: 80.0,
1157            scope: Scope::Touched,
1158            ..Default::default()
1159        };
1160
1161        let result = check(request).unwrap();
1162        assert_eq!(result.report.data.scope, "touched");
1163    }
1164
1165    #[test]
1166    fn test_git_refs_metadata() {
1167        let diff =
1168            "diff --git a/x.rs b/x.rs\n--- /dev/null\n+++ b/x.rs\n@@ -0,0 +1,1 @@\n+fn x() {}\n";
1169        let lcov = "TN:\nSF:x.rs\nDA:1,1\nend_of_record\n";
1170
1171        let request = CheckRequest {
1172            diff_text: diff.to_string(),
1173            diff_file_path: None,
1174            base_ref: Some("main".to_string()),
1175            head_ref: Some("feature".to_string()),
1176            lcov_texts: vec![lcov.to_string()],
1177            lcov_paths: vec!["coverage.info".to_string()],
1178            threshold_pct: 80.0,
1179            scope: Scope::Added,
1180            ..Default::default()
1181        };
1182
1183        let result = check(request).unwrap();
1184        assert_eq!(result.report.data.inputs.diff_source, "git-refs");
1185        assert_eq!(result.report.data.inputs.base, Some("main".to_string()));
1186        assert_eq!(result.report.data.inputs.head, Some("feature".to_string()));
1187        assert!(result.report.data.inputs.diff_file.is_none());
1188    }
1189
1190    #[test]
1191    fn test_diff_file_metadata() {
1192        let diff =
1193            "diff --git a/x.rs b/x.rs\n--- /dev/null\n+++ b/x.rs\n@@ -0,0 +1,1 @@\n+fn x() {}\n";
1194        let lcov = "TN:\nSF:x.rs\nDA:1,1\nend_of_record\n";
1195
1196        let request = CheckRequest {
1197            diff_text: diff.to_string(),
1198            diff_file_path: Some("my.patch".to_string()),
1199            base_ref: None,
1200            head_ref: None,
1201            lcov_texts: vec![lcov.to_string()],
1202            lcov_paths: vec!["coverage.info".to_string()],
1203            threshold_pct: 80.0,
1204            scope: Scope::Added,
1205            ..Default::default()
1206        };
1207
1208        let result = check(request).unwrap();
1209        assert_eq!(result.report.data.inputs.diff_source, "diff-file");
1210        assert_eq!(
1211            result.report.data.inputs.diff_file,
1212            Some("my.patch".to_string())
1213        );
1214        assert!(result.report.data.inputs.base.is_none());
1215        assert!(result.report.data.inputs.head.is_none());
1216    }
1217
1218    // ========================================================================
1219    // Insta Snapshot Tests
1220    // ========================================================================
1221
1222    #[test]
1223    fn test_snapshot_report_uncovered() {
1224        let diff = r#"diff --git a/src/lib.rs b/src/lib.rs
1225new file mode 100644
1226index 0000000..1111111
1227--- /dev/null
1228+++ b/src/lib.rs
1229@@ -0,0 +1,3 @@
1230+pub fn add(a: i32, b: i32) -> i32 {
1231+    a + b
1232+}
1233"#;
1234
1235        let lcov = r#"TN:
1236SF:src/lib.rs
1237DA:1,0
1238DA:2,0
1239DA:3,0
1240end_of_record
1241"#;
1242
1243        let request = CheckRequest {
1244            diff_text: diff.to_string(),
1245            diff_file_path: Some("fixtures/diff/simple_added.patch".to_string()),
1246            base_ref: None,
1247            head_ref: None,
1248            lcov_texts: vec![lcov.to_string()],
1249            lcov_paths: vec!["fixtures/lcov/uncovered.info".to_string()],
1250            threshold_pct: 80.0,
1251            scope: Scope::Added,
1252            ..Default::default()
1253        };
1254
1255        let clock = FixedClock::new("2026-02-02T00:00:00Z");
1256        let result = check_with_clock(request, &clock).unwrap();
1257
1258        let report_json: serde_json::Value = serde_json::to_value(&result.report).unwrap();
1259        insta::assert_json_snapshot!("report_uncovered", report_json);
1260    }
1261
1262    #[test]
1263    fn test_snapshot_report_covered() {
1264        let diff = r#"diff --git a/src/lib.rs b/src/lib.rs
1265new file mode 100644
1266index 0000000..1111111
1267--- /dev/null
1268+++ b/src/lib.rs
1269@@ -0,0 +1,3 @@
1270+pub fn add(a: i32, b: i32) -> i32 {
1271+    a + b
1272+}
1273"#;
1274
1275        let lcov = r#"TN:
1276SF:src/lib.rs
1277DA:1,1
1278DA:2,1
1279DA:3,1
1280end_of_record
1281"#;
1282
1283        let request = CheckRequest {
1284            diff_text: diff.to_string(),
1285            diff_file_path: Some("fixtures/diff/simple_added.patch".to_string()),
1286            base_ref: None,
1287            head_ref: None,
1288            lcov_texts: vec![lcov.to_string()],
1289            lcov_paths: vec!["fixtures/lcov/covered.info".to_string()],
1290            threshold_pct: 80.0,
1291            scope: Scope::Added,
1292            ..Default::default()
1293        };
1294
1295        let clock = FixedClock::new("2026-02-02T00:00:00Z");
1296        let result = check_with_clock(request, &clock).unwrap();
1297
1298        let report_json: serde_json::Value = serde_json::to_value(&result.report).unwrap();
1299        insta::assert_json_snapshot!("report_covered", report_json);
1300    }
1301
1302    #[test]
1303    fn test_snapshot_report_partial() {
1304        let diff = r#"diff --git a/src/lib.rs b/src/lib.rs
1305new file mode 100644
1306index 0000000..1111111
1307--- /dev/null
1308+++ b/src/lib.rs
1309@@ -0,0 +1,5 @@
1310+pub fn add(a: i32, b: i32) -> i32 {
1311+    a + b
1312+}
1313+pub fn sub(a: i32, b: i32) -> i32 {
1314+    a - b
1315"#;
1316
1317        let lcov = r#"TN:
1318SF:src/lib.rs
1319DA:1,1
1320DA:2,1
1321DA:3,1
1322DA:4,0
1323DA:5,0
1324end_of_record
1325"#;
1326
1327        let request = CheckRequest {
1328            diff_text: diff.to_string(),
1329            diff_file_path: Some("fixtures/diff/partial.patch".to_string()),
1330            base_ref: None,
1331            head_ref: None,
1332            lcov_texts: vec![lcov.to_string()],
1333            lcov_paths: vec!["fixtures/lcov/partial.info".to_string()],
1334            threshold_pct: 80.0,
1335            scope: Scope::Added,
1336            ..Default::default()
1337        };
1338
1339        let clock = FixedClock::new("2026-02-02T00:00:00Z");
1340        let result = check_with_clock(request, &clock).unwrap();
1341
1342        let report_json: serde_json::Value = serde_json::to_value(&result.report).unwrap();
1343        insta::assert_json_snapshot!("report_partial", report_json);
1344    }
1345
1346    #[test]
1347    fn test_snapshot_report_empty_diff() {
1348        let diff = "";
1349        let lcov = r#"TN:
1350SF:src/lib.rs
1351DA:1,1
1352end_of_record
1353"#;
1354
1355        let request = CheckRequest {
1356            diff_text: diff.to_string(),
1357            diff_file_path: None,
1358            base_ref: None,
1359            head_ref: None,
1360            lcov_texts: vec![lcov.to_string()],
1361            lcov_paths: vec!["fixtures/lcov/covered.info".to_string()],
1362            threshold_pct: 80.0,
1363            scope: Scope::Added,
1364            ..Default::default()
1365        };
1366
1367        let clock = FixedClock::new("2026-02-02T00:00:00Z");
1368        let result = check_with_clock(request, &clock).unwrap();
1369
1370        let report_json: serde_json::Value = serde_json::to_value(&result.report).unwrap();
1371        insta::assert_json_snapshot!("report_empty_diff", report_json);
1372    }
1373
1374    #[test]
1375    fn test_snapshot_markdown_uncovered() {
1376        let diff = r#"diff --git a/src/lib.rs b/src/lib.rs
1377new file mode 100644
1378index 0000000..1111111
1379--- /dev/null
1380+++ b/src/lib.rs
1381@@ -0,0 +1,3 @@
1382+pub fn add(a: i32, b: i32) -> i32 {
1383+    a + b
1384+}
1385"#;
1386
1387        let lcov = r#"TN:
1388SF:src/lib.rs
1389DA:1,0
1390DA:2,0
1391DA:3,0
1392end_of_record
1393"#;
1394
1395        let request = CheckRequest {
1396            diff_text: diff.to_string(),
1397            diff_file_path: Some("fixtures/diff/simple_added.patch".to_string()),
1398            base_ref: None,
1399            head_ref: None,
1400            lcov_texts: vec![lcov.to_string()],
1401            lcov_paths: vec!["fixtures/lcov/uncovered.info".to_string()],
1402            threshold_pct: 80.0,
1403            scope: Scope::Added,
1404            ..Default::default()
1405        };
1406
1407        let clock = FixedClock::new("2026-02-02T00:00:00Z");
1408        let result = check_with_clock(request, &clock).unwrap();
1409
1410        insta::assert_snapshot!("full_markdown_uncovered", result.markdown);
1411    }
1412
1413    #[test]
1414    fn test_snapshot_sarif_uncovered() {
1415        let diff = r#"diff --git a/src/lib.rs b/src/lib.rs
1416new file mode 100644
1417index 0000000..1111111
1418--- /dev/null
1419+++ b/src/lib.rs
1420@@ -0,0 +1,3 @@
1421+pub fn add(a: i32, b: i32) -> i32 {
1422+    a + b
1423+}
1424"#;
1425
1426        let lcov = r#"TN:
1427SF:src/lib.rs
1428DA:1,0
1429DA:2,0
1430DA:3,0
1431end_of_record
1432"#;
1433
1434        let request = CheckRequest {
1435            diff_text: diff.to_string(),
1436            diff_file_path: Some("fixtures/diff/simple_added.patch".to_string()),
1437            base_ref: None,
1438            head_ref: None,
1439            lcov_texts: vec![lcov.to_string()],
1440            lcov_paths: vec!["fixtures/lcov/uncovered.info".to_string()],
1441            threshold_pct: 80.0,
1442            scope: Scope::Added,
1443            ..Default::default()
1444        };
1445
1446        let clock = FixedClock::new("2026-02-02T00:00:00Z");
1447        let result = check_with_clock(request, &clock).unwrap();
1448
1449        let sarif_json: serde_json::Value = serde_json::from_str(&result.sarif).unwrap();
1450        insta::assert_json_snapshot!("full_sarif_uncovered", sarif_json);
1451    }
1452
1453    // ========================================================================
1454    // Sensor Schema / Cockpit Mode Tests
1455    // ========================================================================
1456
1457    #[test]
1458    fn test_sensor_schema_skip_on_missing_coverage() {
1459        let diff = r#"diff --git a/src/lib.rs b/src/lib.rs
1460new file mode 100644
1461--- /dev/null
1462+++ b/src/lib.rs
1463@@ -0,0 +1,1 @@
1464+fn main() {}
1465"#;
1466
1467        let request = CheckRequest {
1468            diff_text: diff.to_string(),
1469            diff_file_path: Some("test.patch".to_string()),
1470            base_ref: None,
1471            head_ref: None,
1472            lcov_texts: vec![], // No coverage provided
1473            lcov_paths: vec![],
1474            threshold_pct: 80.0,
1475            scope: Scope::Added,
1476            sensor_schema: true, // Enable sensor schema mode
1477            ..Default::default()
1478        };
1479
1480        let clock = FixedClock::new("2026-02-02T00:00:00Z");
1481        let result = check_with_clock(request, &clock).unwrap();
1482
1483        // Domain report should always use standard schema, no capabilities
1484        assert_eq!(result.report.schema, SCHEMA_ID);
1485        assert!(result.report.run.capabilities.is_none());
1486        assert_eq!(result.report.verdict.status, VerdictStatus::Skip);
1487        assert_eq!(result.exit_code, 0);
1488
1489        // Cockpit receipt should have sensor schema and capabilities
1490        let receipt = result.cockpit_receipt.as_ref().unwrap();
1491        assert_eq!(receipt.schema, SENSOR_SCHEMA_ID);
1492        let capabilities = receipt.run.capabilities.as_ref().unwrap();
1493        assert_eq!(capabilities.inputs.diff.status, InputStatus::Available);
1494        assert_eq!(
1495            capabilities.inputs.coverage.status,
1496            InputStatus::Unavailable
1497        );
1498        assert_eq!(
1499            capabilities.inputs.coverage.reason,
1500            Some("missing_lcov".to_string())
1501        );
1502
1503        // Should have missing_lcov reason
1504        assert!(
1505            receipt
1506                .verdict
1507                .reasons
1508                .contains(&"missing_lcov".to_string())
1509        );
1510
1511        // No findings for skip
1512        assert!(receipt.findings.is_empty());
1513    }
1514
1515    #[test]
1516    fn test_sensor_schema_includes_capabilities_on_success() {
1517        let diff = r#"diff --git a/src/lib.rs b/src/lib.rs
1518new file mode 100644
1519--- /dev/null
1520+++ b/src/lib.rs
1521@@ -0,0 +1,1 @@
1522+fn main() {}
1523"#;
1524
1525        let lcov = "TN:\nSF:src/lib.rs\nDA:1,1\nend_of_record\n";
1526
1527        let request = CheckRequest {
1528            diff_text: diff.to_string(),
1529            diff_file_path: Some("test.patch".to_string()),
1530            base_ref: None,
1531            head_ref: None,
1532            lcov_texts: vec![lcov.to_string()],
1533            lcov_paths: vec!["coverage.info".to_string()],
1534            threshold_pct: 80.0,
1535            scope: Scope::Added,
1536            sensor_schema: true, // Enable sensor schema mode
1537            ..Default::default()
1538        };
1539
1540        let clock = FixedClock::new("2026-02-02T00:00:00Z");
1541        let result = check_with_clock(request, &clock).unwrap();
1542
1543        // Domain report should always use standard schema, no capabilities
1544        assert_eq!(result.report.schema, SCHEMA_ID);
1545        assert!(result.report.run.capabilities.is_none());
1546        assert_eq!(result.report.verdict.status, VerdictStatus::Pass);
1547
1548        // Cockpit receipt should have sensor schema and capabilities
1549        let receipt = result.cockpit_receipt.as_ref().unwrap();
1550        assert_eq!(receipt.schema, SENSOR_SCHEMA_ID);
1551        let capabilities = receipt.run.capabilities.as_ref().unwrap();
1552        assert_eq!(capabilities.inputs.diff.status, InputStatus::Available);
1553        assert_eq!(capabilities.inputs.coverage.status, InputStatus::Available);
1554    }
1555
1556    #[test]
1557    fn test_standard_schema_no_capabilities() {
1558        let diff = r#"diff --git a/src/lib.rs b/src/lib.rs
1559new file mode 100644
1560--- /dev/null
1561+++ b/src/lib.rs
1562@@ -0,0 +1,1 @@
1563+fn main() {}
1564"#;
1565
1566        let lcov = "TN:\nSF:src/lib.rs\nDA:1,1\nend_of_record\n";
1567
1568        let request = CheckRequest {
1569            diff_text: diff.to_string(),
1570            diff_file_path: Some("test.patch".to_string()),
1571            base_ref: None,
1572            head_ref: None,
1573            lcov_texts: vec![lcov.to_string()],
1574            lcov_paths: vec!["coverage.info".to_string()],
1575            threshold_pct: 80.0,
1576            scope: Scope::Added,
1577            sensor_schema: false, // Standard mode
1578            ..Default::default()
1579        };
1580
1581        let clock = FixedClock::new("2026-02-02T00:00:00Z");
1582        let result = check_with_clock(request, &clock).unwrap();
1583
1584        // Should use standard schema
1585        assert_eq!(result.report.schema, SCHEMA_ID);
1586
1587        // Should NOT have capabilities block
1588        assert!(result.report.run.capabilities.is_none());
1589
1590        // No cockpit receipt in standard mode
1591        assert!(result.cockpit_receipt.is_none());
1592    }
1593
1594    #[test]
1595    fn test_snapshot_report_skip_no_coverage() {
1596        let diff = r#"diff --git a/src/lib.rs b/src/lib.rs
1597new file mode 100644
1598--- /dev/null
1599+++ b/src/lib.rs
1600@@ -0,0 +1,1 @@
1601+fn main() {}
1602"#;
1603
1604        let request = CheckRequest {
1605            diff_text: diff.to_string(),
1606            diff_file_path: Some("fixtures/diff/simple.patch".to_string()),
1607            base_ref: None,
1608            head_ref: None,
1609            lcov_texts: vec![],
1610            lcov_paths: vec![],
1611            threshold_pct: 80.0,
1612            scope: Scope::Added,
1613            sensor_schema: true,
1614            ..Default::default()
1615        };
1616
1617        let clock = FixedClock::new("2026-02-02T00:00:00Z");
1618        let result = check_with_clock(request, &clock).unwrap();
1619
1620        // Snapshot the domain report (now covguard.report.v1)
1621        let report_json: serde_json::Value = serde_json::to_value(&result.report).unwrap();
1622        insta::assert_json_snapshot!("report_skip_no_coverage", report_json);
1623    }
1624
1625    // ========================================================================
1626    // Truncation Edge-Case Tests
1627    // ========================================================================
1628
1629    #[test]
1630    fn test_max_findings_zero_produces_truncation_metadata() {
1631        let diff = r#"diff --git a/src/lib.rs b/src/lib.rs
1632new file mode 100644
1633index 0000000..1111111
1634--- /dev/null
1635+++ b/src/lib.rs
1636@@ -0,0 +1,3 @@
1637+pub fn add(a: i32, b: i32) -> i32 {
1638+    a + b
1639+}
1640"#;
1641
1642        let lcov = r#"TN:
1643SF:src/lib.rs
1644DA:1,0
1645DA:2,0
1646DA:3,0
1647end_of_record
1648"#;
1649
1650        let request = CheckRequest {
1651            diff_text: diff.to_string(),
1652            diff_file_path: Some("test.patch".to_string()),
1653            lcov_texts: vec![lcov.to_string()],
1654            lcov_paths: vec!["coverage.info".to_string()],
1655            threshold_pct: 80.0,
1656            scope: Scope::Added,
1657            max_findings: Some(0),
1658            ..Default::default()
1659        };
1660
1661        let clock = FixedClock::new("2026-02-02T00:00:00Z");
1662        let result = check_with_clock(request, &clock).unwrap();
1663
1664        // Findings array should be empty
1665        assert!(result.report.findings.is_empty());
1666
1667        // Truncation metadata should be present
1668        let truncation = result
1669            .report
1670            .data
1671            .truncation
1672            .as_ref()
1673            .expect("truncation metadata should be present");
1674        assert!(truncation.findings_truncated);
1675        assert_eq!(truncation.shown, 0);
1676        assert!(
1677            truncation.total > 0,
1678            "total should reflect pre-truncation count"
1679        );
1680
1681        // Reasons should include "truncated"
1682        let has_trunc_reason = result
1683            .report
1684            .verdict
1685            .reasons
1686            .contains(&REASON_TRUNCATED.to_string());
1687        assert!(has_trunc_reason, "reasons should include 'truncated'");
1688    }
1689
1690    // ========================================================================
1691    // Core Split Tests
1692    // ========================================================================
1693
1694    #[test]
1695    fn test_cockpit_receipt_has_truncated_findings() {
1696        let diff = r#"diff --git a/src/lib.rs b/src/lib.rs
1697new file mode 100644
1698index 0000000..1111111
1699--- /dev/null
1700+++ b/src/lib.rs
1701@@ -0,0 +1,3 @@
1702+pub fn add(a: i32, b: i32) -> i32 {
1703+    a + b
1704+}
1705"#;
1706
1707        let lcov = r#"TN:
1708SF:src/lib.rs
1709DA:1,0
1710DA:2,0
1711DA:3,0
1712end_of_record
1713"#;
1714
1715        let request = CheckRequest {
1716            diff_text: diff.to_string(),
1717            diff_file_path: Some("test.patch".to_string()),
1718            lcov_texts: vec![lcov.to_string()],
1719            lcov_paths: vec!["coverage.info".to_string()],
1720            threshold_pct: 80.0,
1721            scope: Scope::Added,
1722            sensor_schema: true,
1723            max_findings: Some(2),
1724            ..Default::default()
1725        };
1726
1727        let clock = FixedClock::new("2026-02-02T00:00:00Z");
1728        let result = check_with_clock(request, &clock).unwrap();
1729
1730        // Domain report should have ALL findings (no truncation in cockpit mode)
1731        assert_eq!(result.report.findings.len(), 4); // 3 uncovered + 1 threshold
1732        assert!(result.report.data.truncation.is_none());
1733        assert_eq!(result.report.schema, SCHEMA_ID);
1734        assert!(result.report.run.capabilities.is_none());
1735
1736        // Cockpit receipt should have truncated findings
1737        let receipt = result.cockpit_receipt.as_ref().unwrap();
1738        assert_eq!(receipt.findings.len(), 2);
1739        assert_eq!(receipt.schema, SENSOR_SCHEMA_ID);
1740        let truncation = receipt.data.truncation.as_ref().unwrap();
1741        assert!(truncation.findings_truncated);
1742        assert_eq!(truncation.shown, 2);
1743        assert_eq!(truncation.total, 4);
1744    }
1745
1746    #[test]
1747    fn test_cockpit_receipt_counts_reflect_full_set() {
1748        let diff = r#"diff --git a/src/lib.rs b/src/lib.rs
1749new file mode 100644
1750index 0000000..1111111
1751--- /dev/null
1752+++ b/src/lib.rs
1753@@ -0,0 +1,3 @@
1754+pub fn add(a: i32, b: i32) -> i32 {
1755+    a + b
1756+}
1757"#;
1758
1759        let lcov = r#"TN:
1760SF:src/lib.rs
1761DA:1,0
1762DA:2,0
1763DA:3,0
1764end_of_record
1765"#;
1766
1767        let request = CheckRequest {
1768            diff_text: diff.to_string(),
1769            diff_file_path: Some("test.patch".to_string()),
1770            lcov_texts: vec![lcov.to_string()],
1771            lcov_paths: vec!["coverage.info".to_string()],
1772            threshold_pct: 80.0,
1773            scope: Scope::Added,
1774            sensor_schema: true,
1775            max_findings: Some(1),
1776            ..Default::default()
1777        };
1778
1779        let clock = FixedClock::new("2026-02-02T00:00:00Z");
1780        let result = check_with_clock(request, &clock).unwrap();
1781
1782        // Cockpit receipt counts should reflect ALL findings, not just truncated
1783        let receipt = result.cockpit_receipt.as_ref().unwrap();
1784        assert_eq!(receipt.findings.len(), 1); // Truncated to 1
1785        assert_eq!(receipt.verdict.counts.error, 4); // But counts reflect full 4
1786    }
1787
1788    #[test]
1789    fn test_domain_report_never_has_capabilities() {
1790        // Standard mode
1791        let diff =
1792            "diff --git a/x.rs b/x.rs\n--- /dev/null\n+++ b/x.rs\n@@ -0,0 +1,1 @@\n+fn x() {}\n";
1793        let lcov = "TN:\nSF:x.rs\nDA:1,1\nend_of_record\n";
1794
1795        let request = CheckRequest {
1796            diff_text: diff.to_string(),
1797            lcov_texts: vec![lcov.to_string()],
1798            sensor_schema: false,
1799            ..Default::default()
1800        };
1801        let clock = FixedClock::new("2026-02-02T00:00:00Z");
1802        let result = check_with_clock(request, &clock).unwrap();
1803        assert!(result.report.run.capabilities.is_none());
1804
1805        // Cockpit mode
1806        let request = CheckRequest {
1807            diff_text: diff.to_string(),
1808            lcov_texts: vec![lcov.to_string()],
1809            sensor_schema: true,
1810            ..Default::default()
1811        };
1812        let result = check_with_clock(request, &clock).unwrap();
1813        assert!(result.report.run.capabilities.is_none());
1814        assert_eq!(result.report.schema, SCHEMA_ID);
1815
1816        // Cockpit receipt HAS capabilities
1817        let receipt = result.cockpit_receipt.as_ref().unwrap();
1818        assert!(receipt.run.capabilities.is_some());
1819    }
1820
1821    #[test]
1822    fn test_renderers_use_full_findings() {
1823        let diff = r#"diff --git a/src/lib.rs b/src/lib.rs
1824new file mode 100644
1825index 0000000..1111111
1826--- /dev/null
1827+++ b/src/lib.rs
1828@@ -0,0 +1,3 @@
1829+pub fn add(a: i32, b: i32) -> i32 {
1830+    a + b
1831+}
1832"#;
1833
1834        let lcov = r#"TN:
1835SF:src/lib.rs
1836DA:1,0
1837DA:2,0
1838DA:3,0
1839end_of_record
1840"#;
1841
1842        let request = CheckRequest {
1843            diff_text: diff.to_string(),
1844            diff_file_path: Some("test.patch".to_string()),
1845            lcov_texts: vec![lcov.to_string()],
1846            lcov_paths: vec!["coverage.info".to_string()],
1847            threshold_pct: 80.0,
1848            scope: Scope::Added,
1849            sensor_schema: true,
1850            max_findings: Some(1), // Truncate cockpit receipt heavily
1851            ..Default::default()
1852        };
1853
1854        let clock = FixedClock::new("2026-02-02T00:00:00Z");
1855        let result = check_with_clock(request, &clock).unwrap();
1856
1857        // Cockpit receipt only has 1 finding
1858        let receipt = result.cockpit_receipt.as_ref().unwrap();
1859        assert_eq!(receipt.findings.len(), 1);
1860
1861        // But markdown (rendered from domain report) should contain all uncovered lines
1862        // The domain report has 4 findings (3 uncovered + 1 threshold)
1863        assert_eq!(result.report.findings.len(), 4);
1864        // Markdown should mention all 3 uncovered lines
1865        assert!(result.markdown.contains("src/lib.rs"));
1866    }
1867
1868    // ========================================================================
1869    // Helper/Utility Tests
1870    // ========================================================================
1871
1872    #[test]
1873    fn test_build_debug_empty_returns_none() {
1874        assert!(build_debug(&[]).is_none());
1875    }
1876
1877    #[test]
1878    fn test_build_debug_populated() {
1879        let debug = build_debug(&["assets/logo.png".to_string()]).expect("debug");
1880        assert_eq!(debug["binary_files_count"], 1);
1881        assert_eq!(debug["binary_files"][0], "assets/logo.png");
1882    }
1883
1884    #[test]
1885    fn test_is_invalid_diff_detection() {
1886        assert!(!is_invalid_diff(""));
1887        assert!(!is_invalid_diff("   \n\t"));
1888        assert!(!is_invalid_diff("diff --git a/a.rs b/a.rs"));
1889        assert!(!is_invalid_diff("@@ -1 +1 @@"));
1890        assert!(!is_invalid_diff("+++ b/a.rs"));
1891        assert!(!is_invalid_diff("--- a/a.rs"));
1892        assert!(!is_invalid_diff("rename to a.rs"));
1893        assert!(is_invalid_diff("just some random text"));
1894    }
1895
1896    #[test]
1897    fn test_build_reasons_pass_no_changes() {
1898        let output = EvalOutput {
1899            findings: vec![],
1900            verdict: VerdictStatus::Pass,
1901            metrics: Metrics {
1902                changed_lines_total: 0,
1903                ..Metrics::default()
1904            },
1905        };
1906
1907        let reasons = build_reasons(&output);
1908        assert_eq!(reasons, vec![REASON_NO_CHANGED_LINES.to_string()]);
1909    }
1910
1911    #[test]
1912    fn test_build_reasons_pass_with_changes() {
1913        let output = EvalOutput {
1914            findings: vec![],
1915            verdict: VerdictStatus::Pass,
1916            metrics: Metrics {
1917                changed_lines_total: 3,
1918                ..Metrics::default()
1919            },
1920        };
1921
1922        let reasons = build_reasons(&output);
1923        assert_eq!(reasons, vec![REASON_DIFF_COVERED.to_string()]);
1924    }
1925
1926    #[test]
1927    fn test_build_reasons_warn_with_uncovered_and_threshold() {
1928        let finding = Finding {
1929            severity: covguard_types::Severity::Error,
1930            check_id: "diff.coverage_below_threshold".to_string(),
1931            code: CODE_COVERAGE_BELOW_THRESHOLD.to_string(),
1932            message: "below threshold".to_string(),
1933            location: None,
1934            data: None,
1935            fingerprint: None,
1936        };
1937
1938        let output = EvalOutput {
1939            findings: vec![finding],
1940            verdict: VerdictStatus::Warn,
1941            metrics: Metrics {
1942                changed_lines_total: 5,
1943                uncovered_lines: 2,
1944                ..Metrics::default()
1945            },
1946        };
1947
1948        let reasons = build_reasons(&output);
1949        assert!(reasons.contains(&REASON_UNCOVERED_LINES.to_string()));
1950        assert!(reasons.contains(&REASON_BELOW_THRESHOLD.to_string()));
1951    }
1952
1953    #[test]
1954    fn test_build_reasons_skip() {
1955        let output = EvalOutput {
1956            findings: vec![],
1957            verdict: VerdictStatus::Skip,
1958            metrics: Metrics::default(),
1959        };
1960
1961        let reasons = build_reasons(&output);
1962        assert_eq!(reasons, vec![REASON_SKIPPED.to_string()]);
1963    }
1964
1965    #[test]
1966    fn test_detect_ignored_lines_with_reader() {
1967        let mut changed_ranges = BTreeMap::new();
1968        changed_ranges.insert("src/lib.rs".to_string(), vec![1..=3]);
1969        changed_ranges.insert("src/main.rs".to_string(), vec![10..=11]);
1970
1971        let reader = MapReader::new(vec![
1972            ("src/lib.rs", 2, "let x = 1; // covguard: ignore"),
1973            ("src/main.rs", 11, "# covguard: ignore"),
1974        ]);
1975
1976        let ignored = detect_ignored_lines(&changed_ranges, &reader);
1977        assert_eq!(
1978            ignored.get("src/lib.rs").cloned(),
1979            Some(BTreeSet::from([2]))
1980        );
1981        assert_eq!(
1982            ignored.get("src/main.rs").cloned(),
1983            Some(BTreeSet::from([11]))
1984        );
1985    }
1986
1987    #[test]
1988    fn test_detect_ignored_lines_empty_when_no_directives() {
1989        let mut changed_ranges = BTreeMap::new();
1990        changed_ranges.insert("src/lib.rs".to_string(), vec![1..=2]);
1991
1992        let reader = MapReader::new(vec![("src/lib.rs", 1, "let x = 1;")]);
1993        let ignored = detect_ignored_lines(&changed_ranges, &reader);
1994        assert!(ignored.is_empty());
1995    }
1996
1997    #[test]
1998    fn test_app_error_from_diff_error() {
1999        use covguard_adapters_diff::DiffError;
2000
2001        let err = DiffError::InvalidFormat("bad diff".to_string());
2002        let app: AppError = err.into();
2003        assert!(matches!(
2004            app,
2005            AppError::DiffParse(ref msg) if msg.contains("bad diff")
2006        ));
2007    }
2008
2009    #[test]
2010    fn test_app_error_from_lcov_error() {
2011        use covguard_adapters_coverage::LcovError;
2012
2013        let err = LcovError::InvalidFormat("bad lcov".to_string());
2014        let app: AppError = err.into();
2015        assert!(matches!(
2016            app,
2017            AppError::LcovParse(ref msg) if msg.contains("bad lcov")
2018        ));
2019    }
2020
2021    #[test]
2022    fn test_error_bad_diff_parse_branch() {
2023        let diff = r#"diff --git a/src/lib.rs b/src/lib.rs
2024index 1111111..2222222 100644
2025--- a/src/lib.rs
2026+++ b/src/lib.rs
2027@@ -1,1 @@
2028+line
2029"#;
2030
2031        let lcov = "TN:\nSF:src/lib.rs\nDA:1,1\nend_of_record\n";
2032        let request = CheckRequest {
2033            diff_text: diff.to_string(),
2034            lcov_texts: vec![lcov.to_string()],
2035            ..Default::default()
2036        };
2037
2038        let clock = FixedClock::new("2026-02-02T00:00:00Z");
2039        let result = check_with_clock(request, &clock).unwrap();
2040
2041        assert!(
2042            result
2043                .report
2044                .findings
2045                .iter()
2046                .any(|f| f.code == CODE_INVALID_DIFF)
2047        );
2048    }
2049
2050    #[test]
2051    fn test_error_lcov_missing_sf_records() {
2052        let diff = r#"diff --git a/src/lib.rs b/src/lib.rs
2053new file mode 100644
2054--- /dev/null
2055+++ b/src/lib.rs
2056@@ -0,0 +1,1 @@
2057+fn main() {}
2058"#;
2059
2060        let lcov = "TN:\nDA:1,1\nend_of_record\n";
2061        let request = CheckRequest {
2062            diff_text: diff.to_string(),
2063            lcov_texts: vec![lcov.to_string()],
2064            ..Default::default()
2065        };
2066
2067        let clock = FixedClock::new("2026-02-02T00:00:00Z");
2068        let result = check_with_clock(request, &clock).unwrap();
2069        assert!(
2070            result
2071                .report
2072                .findings
2073                .iter()
2074                .any(|f| f.code == CODE_INVALID_LCOV)
2075        );
2076    }
2077
2078    #[test]
2079    fn test_error_lcov_parse_failure_branch() {
2080        let diff = r#"diff --git a/src/lib.rs b/src/lib.rs
2081new file mode 100644
2082--- /dev/null
2083+++ b/src/lib.rs
2084@@ -0,0 +1,1 @@
2085+fn main() {}
2086"#;
2087
2088        let lcov = "TN:\nSF:src/lib.rs\nDA:abc,1\nend_of_record\n";
2089        let request = CheckRequest {
2090            diff_text: diff.to_string(),
2091            lcov_texts: vec![lcov.to_string()],
2092            ..Default::default()
2093        };
2094
2095        let clock = FixedClock::new("2026-02-02T00:00:00Z");
2096        let result = check_with_clock(request, &clock).unwrap();
2097        assert!(
2098            result
2099                .report
2100                .findings
2101                .iter()
2102                .any(|f| f.code == CODE_INVALID_LCOV)
2103        );
2104    }
2105
2106    #[test]
2107    fn test_excluded_files_count_incremented() {
2108        let diff = r#"diff --git a/src/lib.rs b/src/lib.rs
2109new file mode 100644
2110--- /dev/null
2111+++ b/src/lib.rs
2112@@ -0,0 +1,1 @@
2113+fn main() {}
2114"#;
2115        let lcov = "TN:\nSF:src/lib.rs\nDA:1,1\nend_of_record\n";
2116        let request = CheckRequest {
2117            diff_text: diff.to_string(),
2118            lcov_texts: vec![lcov.to_string()],
2119            exclude_patterns: vec!["src/**".to_string()],
2120            ..Default::default()
2121        };
2122
2123        let clock = FixedClock::new("2026-02-02T00:00:00Z");
2124        let result = check_with_clock(request, &clock).unwrap();
2125
2126        assert_eq!(result.report.data.excluded_files_count, 1);
2127    }
2128
2129    #[test]
2130    fn test_truncate_findings_no_truncation_when_under_limit() {
2131        let findings = vec![Finding::uncovered_line("src/lib.rs", 1, 0)];
2132        let (truncated, trunc) = covguard_output::truncate_findings(findings.clone(), Some(5));
2133        assert_eq!(truncated.len(), findings.len());
2134        assert!(trunc.is_none());
2135    }
2136
2137    #[test]
2138    fn test_build_error_report_pair_with_capabilities() {
2139        let request = CheckRequest {
2140            base_ref: Some("main".to_string()),
2141            head_ref: Some("feature".to_string()),
2142            sensor_schema: true,
2143            scope: Scope::Touched,
2144            ..Default::default()
2145        };
2146        let now = chrono::Utc::now();
2147        let (domain, receipt) = build_error_report_pair(
2148            &request,
2149            now,
2150            now,
2151            CODE_INVALID_DIFF,
2152            "bad diff",
2153            true,
2154            false,
2155        );
2156
2157        assert_eq!(domain.data.inputs.diff_source, "git-refs");
2158        let receipt = receipt.expect("receipt should exist");
2159        let capabilities = receipt.run.capabilities.expect("capabilities");
2160        assert_eq!(capabilities.inputs.diff.status, InputStatus::Available);
2161        assert_eq!(
2162            capabilities.inputs.coverage.status,
2163            InputStatus::Unavailable
2164        );
2165    }
2166
2167    #[test]
2168    fn test_build_error_report_pair_diff_missing() {
2169        let request = CheckRequest {
2170            sensor_schema: true,
2171            scope: Scope::Added,
2172            ..Default::default()
2173        };
2174        let now = chrono::Utc::now();
2175        let (_domain, receipt) = build_error_report_pair(
2176            &request,
2177            now,
2178            now,
2179            CODE_INVALID_DIFF,
2180            "bad diff",
2181            false,
2182            true,
2183        );
2184
2185        let receipt = receipt.expect("receipt should exist");
2186        let capabilities = receipt.run.capabilities.expect("capabilities");
2187        assert_eq!(capabilities.inputs.diff.status, InputStatus::Unavailable);
2188        assert_eq!(capabilities.inputs.coverage.status, InputStatus::Available);
2189    }
2190
2191    #[test]
2192    fn test_build_error_report_pair_without_capabilities() {
2193        let request = CheckRequest {
2194            sensor_schema: false,
2195            ..Default::default()
2196        };
2197        let now = chrono::Utc::now();
2198        let (_domain, receipt) = build_error_report_pair(
2199            &request,
2200            now,
2201            now,
2202            CODE_INVALID_DIFF,
2203            "bad diff",
2204            true,
2205            true,
2206        );
2207        assert!(receipt.is_none());
2208    }
2209
2210    #[test]
2211    fn test_build_skip_report_pair_sensor_schema_false_stdin() {
2212        let request = CheckRequest {
2213            sensor_schema: false,
2214            scope: Scope::Touched,
2215            ..Default::default()
2216        };
2217        let now = chrono::Utc::now();
2218        let (domain, receipt) =
2219            build_skip_report_pair(&request, now, now, false, true, REASON_MISSING_LCOV);
2220        assert_eq!(domain.data.inputs.diff_source, "stdin");
2221        assert_eq!(domain.data.scope, "touched");
2222        assert!(receipt.is_none());
2223    }
2224
2225    #[test]
2226    fn test_build_skip_report_pair_diff_present_cov_missing() {
2227        let request = CheckRequest {
2228            base_ref: Some("main".to_string()),
2229            head_ref: Some("feature".to_string()),
2230            sensor_schema: true,
2231            ..Default::default()
2232        };
2233        let now = chrono::Utc::now();
2234        let (_domain, receipt) =
2235            build_skip_report_pair(&request, now, now, true, false, REASON_MISSING_LCOV);
2236        let receipt = receipt.expect("receipt should exist");
2237        let capabilities = receipt.run.capabilities.expect("capabilities");
2238        assert_eq!(capabilities.inputs.diff.status, InputStatus::Available);
2239        assert_eq!(
2240            capabilities.inputs.coverage.status,
2241            InputStatus::Unavailable
2242        );
2243    }
2244
2245    #[test]
2246    fn test_build_skip_report_pair_diff_missing_cov_present() {
2247        let request = CheckRequest {
2248            sensor_schema: true,
2249            ..Default::default()
2250        };
2251        let now = chrono::Utc::now();
2252        let (_domain, receipt) =
2253            build_skip_report_pair(&request, now, now, false, true, REASON_MISSING_LCOV);
2254        let receipt = receipt.expect("receipt should exist");
2255        let capabilities = receipt.run.capabilities.expect("capabilities");
2256        assert_eq!(capabilities.inputs.diff.status, InputStatus::Unavailable);
2257        assert_eq!(capabilities.inputs.coverage.status, InputStatus::Available);
2258    }
2259
2260    #[test]
2261    fn test_render_wrappers_with_limits() {
2262        let report = Report::default();
2263        let md = render_markdown_with_limit(&report, 1);
2264        let annotations = render_annotations_with_limit(&report, 1);
2265        let sarif = render_sarif_with_limit(&report, 1);
2266
2267        assert!(!md.is_empty());
2268        assert!(annotations.is_empty());
2269        assert!(sarif.contains("\"version\""));
2270    }
2271}