1use 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#[derive(Debug, Clone)]
18pub struct ReportContext {
19 pub threshold_pct: f64,
21 pub scope: Scope,
23 pub sensor_schema: bool,
25 pub max_findings: Option<usize>,
27 pub diff_file_path: Option<String>,
29 pub base_ref: Option<String>,
31 pub head_ref: Option<String>,
33 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
92pub 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
209pub 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
229pub 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
362pub 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
471pub 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
487pub 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
519pub 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}