Skip to main content

covguard_reporting/
lib.rs

1//! Report assembly and schema composition for covguard.
2
3use chrono::{DateTime, Utc};
4
5use covguard_domain::EvalOutput;
6use covguard_output::truncate_findings;
7use covguard_types::{
8    CHECK_ID_RUNTIME, CODE_COVERAGE_BELOW_THRESHOLD, CODE_RUNTIME_ERROR, Capabilities, Finding,
9    InputCapability, InputStatus, Inputs, InputsCapability, REASON_BELOW_THRESHOLD,
10    REASON_DIFF_COVERED, REASON_MISSING_DIFF, REASON_MISSING_LCOV, REASON_NO_CHANGED_LINES,
11    REASON_SKIPPED, REASON_TOOL_ERROR, REASON_TRUNCATED, REASON_UNCOVERED_LINES, Report,
12    ReportData, SCHEMA_ID, SENSOR_SCHEMA_ID, Scope, Severity, Tool, Verdict, VerdictCounts,
13    VerdictStatus, compute_fingerprint,
14};
15
16/// Context needed to materialize reports from `EvalOutput`.
17#[derive(Debug, Clone)]
18pub struct ReportContext {
19    /// Coverage threshold used for the run.
20    pub threshold_pct: f64,
21    /// Evaluation scope (`added` or `touched`).
22    pub scope: Scope,
23    /// Emit `sensor.report.v1` with capability metadata.
24    pub sensor_schema: bool,
25    /// Optional findings cap for standard-mode reports.
26    pub max_findings: Option<usize>,
27    /// Path to a diff file, if available.
28    pub diff_file_path: Option<String>,
29    /// Base ref in git-diff mode.
30    pub base_ref: Option<String>,
31    /// Head ref in git-diff mode.
32    pub head_ref: Option<String>,
33    /// LCOV paths to include in report metadata.
34    pub lcov_paths: Vec<String>,
35}
36
37impl ReportContext {
38    fn diff_source(&self) -> &'static str {
39        if self.diff_file_path.is_some() {
40            "diff-file"
41        } else if self.base_ref.is_some() && self.head_ref.is_some() {
42            "git-refs"
43        } else {
44            "stdin"
45        }
46    }
47
48    fn scope(&self) -> &str {
49        self.scope.as_str()
50    }
51
52    fn inputs(&self) -> Inputs {
53        Inputs {
54            diff_source: self.diff_source().to_string(),
55            diff_file: self.diff_file_path.clone(),
56            base: self.base_ref.clone(),
57            head: self.head_ref.clone(),
58            lcov_paths: self.lcov_paths.clone(),
59        }
60    }
61}
62
63fn report_run(started_at: DateTime<Utc>, ended_at: DateTime<Utc>) -> covguard_types::Run {
64    covguard_types::Run {
65        started_at: started_at.format("%Y-%m-%dT%H:%M:%SZ").to_string(),
66        ended_at: Some(ended_at.format("%Y-%m-%dT%H:%M:%SZ").to_string()),
67        duration_ms: Some((ended_at - started_at).num_milliseconds().max(0) as u64),
68        capabilities: None,
69    }
70}
71
72fn finding_counts(eval: &EvalOutput) -> VerdictCounts {
73    VerdictCounts {
74        info: eval
75            .findings
76            .iter()
77            .filter(|finding| finding.severity == Severity::Info)
78            .count() as u32,
79        warn: eval
80            .findings
81            .iter()
82            .filter(|finding| finding.severity == Severity::Warn)
83            .count() as u32,
84        error: eval
85            .findings
86            .iter()
87            .filter(|finding| finding.severity == Severity::Error)
88            .count() as u32,
89    }
90}
91
92/// Build a pair of reports: domain and optional cockpit receipt.
93pub fn build_report_pair(
94    eval: EvalOutput,
95    context: &ReportContext,
96    started_at: DateTime<Utc>,
97    ended_at: DateTime<Utc>,
98    excluded_files_count: u32,
99    debug: Option<serde_json::Value>,
100) -> (Report, Option<Report>) {
101    let inputs = context.inputs();
102    let run = report_run(started_at, ended_at);
103    let counts = finding_counts(&eval);
104    let reasons = build_reasons(&eval);
105    let scope = context.scope().to_string();
106    let tool = Tool {
107        name: "covguard".to_string(),
108        version: env!("CARGO_PKG_VERSION").to_string(),
109        commit: None,
110    };
111
112    let cockpit_receipt = if context.sensor_schema {
113        let capabilities = Some(Capabilities {
114            inputs: InputsCapability {
115                diff: InputCapability {
116                    status: InputStatus::Available,
117                    reason: None,
118                },
119                coverage: InputCapability {
120                    status: InputStatus::Available,
121                    reason: None,
122                },
123            },
124        });
125
126        let (cockpit_findings, cockpit_truncation) =
127            truncate_findings(eval.findings.clone(), context.max_findings);
128
129        let mut cockpit_reasons = reasons.clone();
130        if cockpit_truncation.is_some() {
131            cockpit_reasons.push(REASON_TRUNCATED.to_string());
132        }
133
134        Some(Report {
135            schema: SENSOR_SCHEMA_ID.to_string(),
136            tool: tool.clone(),
137            run: covguard_types::Run {
138                capabilities,
139                ..run.clone()
140            },
141            verdict: Verdict {
142                status: eval.verdict,
143                counts: counts.clone(),
144                reasons: cockpit_reasons,
145            },
146            findings: cockpit_findings,
147            data: ReportData {
148                scope: scope.clone(),
149                threshold_pct: context.threshold_pct,
150                changed_lines_total: eval.metrics.changed_lines_total,
151                covered_lines: eval.metrics.covered_lines,
152                uncovered_lines: eval.metrics.uncovered_lines,
153                missing_lines: eval.metrics.missing_lines,
154                ignored_lines_count: eval.metrics.ignored_lines,
155                excluded_files_count,
156                diff_coverage_pct: eval.metrics.diff_coverage_pct,
157                inputs: inputs.clone(),
158                debug: debug.clone(),
159                truncation: cockpit_truncation,
160            },
161        })
162    } else {
163        None
164    };
165
166    let (domain_findings, domain_truncation) = if context.sensor_schema {
167        (eval.findings, None)
168    } else {
169        truncate_findings(eval.findings, context.max_findings)
170    };
171
172    let mut domain_reasons = reasons;
173    if domain_truncation.is_some() {
174        domain_reasons.push(REASON_TRUNCATED.to_string());
175    }
176
177    let domain_report = Report {
178        schema: SCHEMA_ID.to_string(),
179        tool,
180        run: covguard_types::Run {
181            capabilities: None,
182            ..run
183        },
184        verdict: Verdict {
185            status: eval.verdict,
186            counts,
187            reasons: domain_reasons,
188        },
189        findings: domain_findings,
190        data: ReportData {
191            scope,
192            threshold_pct: context.threshold_pct,
193            changed_lines_total: eval.metrics.changed_lines_total,
194            covered_lines: eval.metrics.covered_lines,
195            uncovered_lines: eval.metrics.uncovered_lines,
196            missing_lines: eval.metrics.missing_lines,
197            ignored_lines_count: eval.metrics.ignored_lines,
198            excluded_files_count,
199            diff_coverage_pct: eval.metrics.diff_coverage_pct,
200            inputs,
201            debug,
202            truncation: domain_truncation,
203        },
204    };
205
206    (domain_report, cockpit_receipt)
207}
208
209/// Build only the domain report from evaluation output.
210pub fn build_report(
211    eval: EvalOutput,
212    context: &ReportContext,
213    started_at: DateTime<Utc>,
214    ended_at: DateTime<Utc>,
215    excluded_files_count: u32,
216    debug: Option<serde_json::Value>,
217) -> Report {
218    let (report, _) = build_report_pair(
219        eval,
220        context,
221        started_at,
222        ended_at,
223        excluded_files_count,
224        debug,
225    );
226    report
227}
228
229/// Build both domain report and optional cockpit receipt for runtime error cases.
230pub fn build_error_report_pair(
231    context: &ReportContext,
232    started_at: DateTime<Utc>,
233    ended_at: DateTime<Utc>,
234    code: &str,
235    message: &str,
236    diff_available: bool,
237    coverage_available: bool,
238) -> (Report, Option<Report>) {
239    let inputs = context.inputs();
240
241    let input_fp = compute_fingerprint(&[code, "covguard"]);
242    let runtime_fp = compute_fingerprint(&[CODE_RUNTIME_ERROR, "covguard"]);
243
244    let findings = vec![
245        Finding {
246            severity: Severity::Error,
247            check_id: "input.invalid".to_string(),
248            code: code.to_string(),
249            message: message.to_string(),
250            location: None,
251            data: None,
252            fingerprint: Some(input_fp),
253        },
254        Finding {
255            severity: Severity::Error,
256            check_id: CHECK_ID_RUNTIME.to_string(),
257            code: CODE_RUNTIME_ERROR.to_string(),
258            message: "covguard failed due to a runtime error.".to_string(),
259            location: None,
260            data: None,
261            fingerprint: Some(runtime_fp),
262        },
263    ];
264
265    let counts = VerdictCounts {
266        info: 0,
267        warn: 0,
268        error: findings.len() as u32,
269    };
270
271    let scope = context.scope().to_string();
272    let tool = Tool {
273        name: "covguard".to_string(),
274        version: env!("CARGO_PKG_VERSION").to_string(),
275        commit: None,
276    };
277    let run = report_run(started_at, ended_at);
278
279    let data = ReportData {
280        scope,
281        threshold_pct: context.threshold_pct,
282        changed_lines_total: 0,
283        covered_lines: 0,
284        uncovered_lines: 0,
285        missing_lines: 0,
286        ignored_lines_count: 0,
287        excluded_files_count: 0,
288        diff_coverage_pct: 0.0,
289        inputs,
290        debug: None,
291        truncation: None,
292    };
293
294    let cockpit_receipt = if context.sensor_schema {
295        let capabilities = Some(Capabilities {
296            inputs: InputsCapability {
297                diff: InputCapability {
298                    status: if diff_available {
299                        InputStatus::Available
300                    } else {
301                        InputStatus::Unavailable
302                    },
303                    reason: if diff_available {
304                        None
305                    } else {
306                        Some(REASON_MISSING_DIFF.to_string())
307                    },
308                },
309                coverage: InputCapability {
310                    status: if coverage_available {
311                        InputStatus::Available
312                    } else {
313                        InputStatus::Unavailable
314                    },
315                    reason: if coverage_available {
316                        None
317                    } else {
318                        Some(REASON_MISSING_LCOV.to_string())
319                    },
320                },
321            },
322        });
323
324        Some(Report {
325            schema: SENSOR_SCHEMA_ID.to_string(),
326            tool: tool.clone(),
327            run: covguard_types::Run {
328                capabilities,
329                ..run.clone()
330            },
331            verdict: Verdict {
332                status: VerdictStatus::Fail,
333                counts: counts.clone(),
334                reasons: vec![REASON_TOOL_ERROR.to_string()],
335            },
336            findings: findings.clone(),
337            data: data.clone(),
338        })
339    } else {
340        None
341    };
342
343    let domain_report = Report {
344        schema: SCHEMA_ID.to_string(),
345        tool,
346        run: covguard_types::Run {
347            capabilities: None,
348            ..run
349        },
350        verdict: Verdict {
351            status: VerdictStatus::Fail,
352            counts,
353            reasons: vec![REASON_TOOL_ERROR.to_string()],
354        },
355        findings,
356        data,
357    };
358
359    (domain_report, cockpit_receipt)
360}
361
362/// Build both domain report and optional cockpit receipt for skip cases.
363pub fn build_skip_report_pair(
364    context: &ReportContext,
365    started_at: DateTime<Utc>,
366    ended_at: DateTime<Utc>,
367    diff_available: bool,
368    coverage_available: bool,
369    reason: &str,
370) -> (Report, Option<Report>) {
371    let inputs = context.inputs();
372    let capabilities = Capabilities {
373        inputs: InputsCapability {
374            diff: InputCapability {
375                status: if diff_available {
376                    InputStatus::Available
377                } else {
378                    InputStatus::Unavailable
379                },
380                reason: if diff_available {
381                    None
382                } else {
383                    Some(REASON_MISSING_DIFF.to_string())
384                },
385            },
386            coverage: InputCapability {
387                status: if coverage_available {
388                    InputStatus::Available
389                } else {
390                    InputStatus::Unavailable
391                },
392                reason: if coverage_available {
393                    None
394                } else {
395                    Some(REASON_MISSING_LCOV.to_string())
396                },
397            },
398        },
399    };
400
401    let run = report_run(started_at, ended_at);
402    let scope = context.scope().to_string();
403    let tool = Tool {
404        name: "covguard".to_string(),
405        version: env!("CARGO_PKG_VERSION").to_string(),
406        commit: None,
407    };
408
409    let data = ReportData {
410        scope,
411        threshold_pct: context.threshold_pct,
412        changed_lines_total: 0,
413        covered_lines: 0,
414        uncovered_lines: 0,
415        missing_lines: 0,
416        ignored_lines_count: 0,
417        excluded_files_count: 0,
418        diff_coverage_pct: 0.0,
419        inputs,
420        debug: None,
421        truncation: None,
422    };
423
424    let cockpit_receipt = if context.sensor_schema {
425        Some(Report {
426            schema: SENSOR_SCHEMA_ID.to_string(),
427            tool: tool.clone(),
428            run: covguard_types::Run {
429                capabilities: Some(capabilities),
430                ..run.clone()
431            },
432            verdict: Verdict {
433                status: VerdictStatus::Skip,
434                counts: VerdictCounts {
435                    info: 0,
436                    warn: 0,
437                    error: 0,
438                },
439                reasons: vec![reason.to_string()],
440            },
441            findings: vec![],
442            data: data.clone(),
443        })
444    } else {
445        None
446    };
447
448    let domain_report = Report {
449        schema: SCHEMA_ID.to_string(),
450        tool,
451        run: covguard_types::Run {
452            capabilities: None,
453            ..run
454        },
455        verdict: Verdict {
456            status: VerdictStatus::Skip,
457            counts: VerdictCounts {
458                info: 0,
459                warn: 0,
460                error: 0,
461            },
462            reasons: vec![reason.to_string()],
463        },
464        findings: vec![],
465        data,
466    };
467
468    (domain_report, cockpit_receipt)
469}
470
471/// Check if diff input looks invalid at a basic marker level.
472pub fn is_invalid_diff(diff_text: &str) -> bool {
473    let trimmed = diff_text.trim();
474    if trimmed.is_empty() {
475        return false;
476    }
477
478    let has_marker = trimmed.contains("diff --git")
479        || trimmed.contains("@@")
480        || trimmed.contains("+++ ")
481        || trimmed.contains("--- ")
482        || trimmed.contains("rename from ")
483        || trimmed.contains("rename to ");
484    !has_marker
485}
486
487/// Build report-level reasons from verdict metrics and findings.
488pub fn build_reasons(output: &EvalOutput) -> Vec<String> {
489    let mut reasons = Vec::new();
490
491    match output.verdict {
492        VerdictStatus::Pass => {
493            if output.metrics.changed_lines_total == 0 {
494                reasons.push(REASON_NO_CHANGED_LINES.to_string());
495            } else {
496                reasons.push(REASON_DIFF_COVERED.to_string());
497            }
498        }
499        VerdictStatus::Warn | VerdictStatus::Fail => {
500            if output.metrics.uncovered_lines > 0 {
501                reasons.push(REASON_UNCOVERED_LINES.to_string());
502            }
503            if output
504                .findings
505                .iter()
506                .any(|finding| finding.code == CODE_COVERAGE_BELOW_THRESHOLD)
507            {
508                reasons.push(REASON_BELOW_THRESHOLD.to_string());
509            }
510        }
511        VerdictStatus::Skip => {
512            reasons.push(REASON_SKIPPED.to_string());
513        }
514    }
515
516    reasons
517}
518
519/// Build debug payload for binary file lists.
520pub fn build_debug(binary_files: &[String]) -> Option<serde_json::Value> {
521    if binary_files.is_empty() {
522        None
523    } else {
524        Some(serde_json::json!({
525            "binary_files_count": binary_files.len(),
526            "binary_files": binary_files,
527        }))
528    }
529}