1use serde::{Deserialize, Serialize};
8
9pub const COCKPIT_SCHEMA_VERSION: u32 = 3;
11
12#[derive(Debug, Clone, Serialize, Deserialize)]
18pub struct CockpitReceipt {
19 pub schema_version: u32,
20 pub mode: String,
21 pub generated_at_ms: u64,
22 pub base_ref: String,
23 pub head_ref: String,
24 pub change_surface: ChangeSurface,
25 pub composition: Composition,
26 pub code_health: CodeHealth,
27 pub risk: Risk,
28 pub contracts: Contracts,
29 pub evidence: Evidence,
30 pub review_plan: Vec<ReviewItem>,
31 #[serde(skip_serializing_if = "Option::is_none")]
33 pub trend: Option<TrendComparison>,
34}
35
36#[derive(Debug, Clone, Serialize, Deserialize)]
42pub struct Evidence {
43 pub overall_status: GateStatus,
45 pub mutation: MutationGate,
47 #[serde(skip_serializing_if = "Option::is_none")]
49 pub diff_coverage: Option<DiffCoverageGate>,
50 #[serde(skip_serializing_if = "Option::is_none")]
52 pub contracts: Option<ContractDiffGate>,
53 #[serde(skip_serializing_if = "Option::is_none")]
55 pub supply_chain: Option<SupplyChainGate>,
56 #[serde(skip_serializing_if = "Option::is_none")]
58 pub determinism: Option<DeterminismGate>,
59 #[serde(skip_serializing_if = "Option::is_none")]
61 pub complexity: Option<ComplexityGate>,
62}
63
64#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
66#[serde(rename_all = "lowercase")]
67pub enum GateStatus {
68 Pass,
69 Warn,
70 Fail,
71 Skipped,
72 Pending,
73}
74
75#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
77#[serde(rename_all = "snake_case")]
78pub enum EvidenceSource {
79 CiArtifact,
80 Cached,
81 RanLocal,
82}
83
84#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
86#[serde(rename_all = "lowercase")]
87pub enum CommitMatch {
88 Exact,
89 Partial,
90 Stale,
91 Unknown,
92}
93
94#[derive(Debug, Clone, Serialize, Deserialize)]
96pub struct GateMeta {
97 pub status: GateStatus,
98 pub source: EvidenceSource,
99 pub commit_match: CommitMatch,
100 pub scope: ScopeCoverage,
101 #[serde(skip_serializing_if = "Option::is_none")]
103 pub evidence_commit: Option<String>,
104 #[serde(skip_serializing_if = "Option::is_none")]
106 pub evidence_generated_at_ms: Option<u64>,
107}
108
109#[derive(Debug, Clone, Serialize, Deserialize)]
111pub struct ScopeCoverage {
112 pub relevant: Vec<String>,
114 pub tested: Vec<String>,
116 pub ratio: f64,
118 #[serde(skip_serializing_if = "Option::is_none")]
120 pub lines_relevant: Option<usize>,
121 #[serde(skip_serializing_if = "Option::is_none")]
123 pub lines_tested: Option<usize>,
124}
125
126#[derive(Debug, Clone, Serialize, Deserialize)]
132pub struct MutationGate {
133 #[serde(flatten)]
134 pub meta: GateMeta,
135 pub survivors: Vec<MutationSurvivor>,
136 pub killed: usize,
137 pub timeout: usize,
138 pub unviable: usize,
139}
140
141#[derive(Debug, Clone, Serialize, Deserialize)]
143pub struct MutationSurvivor {
144 pub file: String,
145 pub line: usize,
146 pub mutation: String,
147}
148
149#[derive(Debug, Clone, Serialize, Deserialize)]
151pub struct DiffCoverageGate {
152 #[serde(flatten)]
153 pub meta: GateMeta,
154 pub lines_added: usize,
155 pub lines_covered: usize,
156 pub coverage_pct: f64,
157 pub uncovered_hunks: Vec<UncoveredHunk>,
158}
159
160#[derive(Debug, Clone, Serialize, Deserialize)]
162pub struct UncoveredHunk {
163 pub file: String,
164 pub start_line: usize,
165 pub end_line: usize,
166}
167
168#[derive(Debug, Clone, Serialize, Deserialize)]
170pub struct ContractDiffGate {
171 #[serde(flatten)]
172 pub meta: GateMeta,
173 #[serde(skip_serializing_if = "Option::is_none")]
175 pub semver: Option<SemverSubGate>,
176 #[serde(skip_serializing_if = "Option::is_none")]
178 pub cli: Option<CliSubGate>,
179 #[serde(skip_serializing_if = "Option::is_none")]
181 pub schema: Option<SchemaSubGate>,
182 pub failures: usize,
184}
185
186#[derive(Debug, Clone, Serialize, Deserialize)]
188pub struct SemverSubGate {
189 pub status: GateStatus,
190 pub breaking_changes: Vec<BreakingChange>,
191}
192
193#[derive(Debug, Clone, Serialize, Deserialize)]
195pub struct BreakingChange {
196 pub kind: String,
197 pub path: String,
198 pub message: String,
199}
200
201#[derive(Debug, Clone, Serialize, Deserialize)]
203pub struct CliSubGate {
204 pub status: GateStatus,
205 pub diff_summary: Option<String>,
206}
207
208#[derive(Debug, Clone, Serialize, Deserialize)]
210pub struct SchemaSubGate {
211 pub status: GateStatus,
212 pub diff_summary: Option<String>,
213}
214
215#[derive(Debug, Clone, Serialize, Deserialize)]
217pub struct SupplyChainGate {
218 #[serde(flatten)]
219 pub meta: GateMeta,
220 pub vulnerabilities: Vec<Vulnerability>,
221 pub denied: Vec<String>,
222 #[serde(skip_serializing_if = "Option::is_none")]
223 pub advisory_db_version: Option<String>,
224}
225
226#[derive(Debug, Clone, Serialize, Deserialize)]
228pub struct Vulnerability {
229 pub id: String,
230 pub package: String,
231 pub severity: String,
232 pub title: String,
233}
234
235#[derive(Debug, Clone, Serialize, Deserialize)]
237pub struct DeterminismGate {
238 #[serde(flatten)]
239 pub meta: GateMeta,
240 #[serde(skip_serializing_if = "Option::is_none")]
241 pub expected_hash: Option<String>,
242 #[serde(skip_serializing_if = "Option::is_none")]
243 pub actual_hash: Option<String>,
244 pub algo: String,
245 pub differences: Vec<String>,
246}
247
248#[derive(Debug, Clone, Serialize, Deserialize)]
250pub struct ComplexityGate {
251 #[serde(flatten)]
252 pub meta: GateMeta,
253 pub files_analyzed: usize,
255 pub high_complexity_files: Vec<HighComplexityFile>,
257 pub avg_cyclomatic: f64,
259 pub max_cyclomatic: u32,
261 pub threshold_exceeded: bool,
263}
264
265#[derive(Debug, Clone, Serialize, Deserialize)]
267pub struct HighComplexityFile {
268 pub path: String,
270 pub cyclomatic: u32,
272 pub function_count: usize,
274 pub max_function_length: usize,
276}
277
278#[derive(Debug, Clone, Serialize, Deserialize)]
284pub struct ChangeSurface {
285 pub commits: usize,
286 pub files_changed: usize,
287 pub insertions: usize,
288 pub deletions: usize,
289 pub net_lines: i64,
290 pub churn_velocity: f64,
292 pub change_concentration: f64,
294}
295
296#[derive(Debug, Clone, Serialize, Deserialize)]
298pub struct Composition {
299 pub code_pct: f64,
300 pub test_pct: f64,
301 pub docs_pct: f64,
302 pub config_pct: f64,
303 pub test_ratio: f64,
305}
306
307#[derive(Debug, Clone, Serialize, Deserialize)]
309pub struct CodeHealth {
310 pub score: u32,
312 pub grade: String,
314 pub large_files_touched: usize,
316 pub avg_file_size: usize,
318 pub complexity_indicator: ComplexityIndicator,
320 pub warnings: Vec<HealthWarning>,
322}
323
324#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
326#[serde(rename_all = "lowercase")]
327pub enum ComplexityIndicator {
328 Low,
329 Medium,
330 High,
331 Critical,
332}
333
334#[derive(Debug, Clone, Serialize, Deserialize)]
336pub struct HealthWarning {
337 pub path: String,
338 pub warning_type: WarningType,
339 pub message: String,
340}
341
342#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
344#[serde(rename_all = "snake_case")]
345pub enum WarningType {
346 LargeFile,
347 HighChurn,
348 LowTestCoverage,
349 ComplexChange,
350 BusFactor,
351}
352
353#[derive(Debug, Clone, Serialize, Deserialize)]
355pub struct Risk {
356 pub hotspots_touched: Vec<String>,
357 pub bus_factor_warnings: Vec<String>,
358 pub level: RiskLevel,
360 pub score: u32,
362}
363
364#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
366#[serde(rename_all = "lowercase")]
367pub enum RiskLevel {
368 Low,
369 Medium,
370 High,
371 Critical,
372}
373
374impl std::fmt::Display for RiskLevel {
375 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
376 match self {
377 RiskLevel::Low => write!(f, "low"),
378 RiskLevel::Medium => write!(f, "medium"),
379 RiskLevel::High => write!(f, "high"),
380 RiskLevel::Critical => write!(f, "critical"),
381 }
382 }
383}
384
385#[derive(Debug, Clone, Serialize, Deserialize)]
387pub struct Contracts {
388 pub api_changed: bool,
389 pub cli_changed: bool,
390 pub schema_changed: bool,
391 pub breaking_indicators: usize,
393}
394
395#[derive(Debug, Clone, Serialize, Deserialize)]
397pub struct ReviewItem {
398 pub path: String,
399 pub reason: String,
400 pub priority: u32,
401 #[serde(skip_serializing_if = "Option::is_none")]
403 pub complexity: Option<u8>,
404 #[serde(skip_serializing_if = "Option::is_none")]
406 pub lines_changed: Option<usize>,
407}
408
409#[derive(Debug, Clone, Default, Serialize, Deserialize)]
415pub struct TrendComparison {
416 pub baseline_available: bool,
418 #[serde(skip_serializing_if = "Option::is_none")]
420 pub baseline_path: Option<String>,
421 #[serde(skip_serializing_if = "Option::is_none")]
423 pub baseline_generated_at_ms: Option<u64>,
424 #[serde(skip_serializing_if = "Option::is_none")]
426 pub health: Option<TrendMetric>,
427 #[serde(skip_serializing_if = "Option::is_none")]
429 pub risk: Option<TrendMetric>,
430 #[serde(skip_serializing_if = "Option::is_none")]
432 pub complexity: Option<TrendIndicator>,
433}
434
435#[derive(Debug, Clone, Serialize, Deserialize)]
437pub struct TrendMetric {
438 pub current: f64,
440 pub previous: f64,
442 pub delta: f64,
444 pub delta_pct: f64,
446 pub direction: TrendDirection,
448}
449
450#[derive(Debug, Clone, Serialize, Deserialize)]
452pub struct TrendIndicator {
453 pub direction: TrendDirection,
455 pub summary: String,
457 pub files_increased: usize,
459 pub files_decreased: usize,
461 #[serde(skip_serializing_if = "Option::is_none")]
463 pub avg_cyclomatic_delta: Option<f64>,
464 #[serde(skip_serializing_if = "Option::is_none")]
466 pub avg_cognitive_delta: Option<f64>,
467}
468
469#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
471#[serde(rename_all = "lowercase")]
472pub enum TrendDirection {
473 Improving,
474 Stable,
475 Degrading,
476}
477
478#[cfg(test)]
479mod tests {
480 use super::*;
481
482 #[test]
483 fn cockpit_receipt_serde_roundtrip() {
484 let receipt = CockpitReceipt {
485 schema_version: COCKPIT_SCHEMA_VERSION,
486 mode: "cockpit".to_string(),
487 generated_at_ms: 1000,
488 base_ref: "main".to_string(),
489 head_ref: "HEAD".to_string(),
490 change_surface: ChangeSurface {
491 commits: 1,
492 files_changed: 2,
493 insertions: 10,
494 deletions: 5,
495 net_lines: 5,
496 churn_velocity: 15.0,
497 change_concentration: 0.8,
498 },
499 composition: Composition {
500 code_pct: 70.0,
501 test_pct: 20.0,
502 docs_pct: 5.0,
503 config_pct: 5.0,
504 test_ratio: 0.29,
505 },
506 code_health: CodeHealth {
507 score: 85,
508 grade: "B".to_string(),
509 large_files_touched: 0,
510 avg_file_size: 100,
511 complexity_indicator: ComplexityIndicator::Low,
512 warnings: vec![],
513 },
514 risk: Risk {
515 hotspots_touched: vec![],
516 bus_factor_warnings: vec![],
517 level: RiskLevel::Low,
518 score: 10,
519 },
520 contracts: Contracts {
521 api_changed: false,
522 cli_changed: false,
523 schema_changed: false,
524 breaking_indicators: 0,
525 },
526 evidence: Evidence {
527 overall_status: GateStatus::Pass,
528 mutation: MutationGate {
529 meta: GateMeta {
530 status: GateStatus::Pass,
531 source: EvidenceSource::RanLocal,
532 commit_match: CommitMatch::Exact,
533 scope: ScopeCoverage {
534 relevant: vec![],
535 tested: vec![],
536 ratio: 1.0,
537 lines_relevant: None,
538 lines_tested: None,
539 },
540 evidence_commit: None,
541 evidence_generated_at_ms: None,
542 },
543 survivors: vec![],
544 killed: 0,
545 timeout: 0,
546 unviable: 0,
547 },
548 diff_coverage: None,
549 contracts: None,
550 supply_chain: None,
551 determinism: None,
552 complexity: None,
553 },
554 review_plan: vec![],
555 trend: None,
556 };
557
558 let json = serde_json::to_string(&receipt).expect("serialize");
559 let back: CockpitReceipt = serde_json::from_str(&json).expect("deserialize");
560 assert_eq!(back.schema_version, COCKPIT_SCHEMA_VERSION);
561 assert_eq!(back.mode, "cockpit");
562 }
563
564 #[test]
565 fn gate_status_serde() {
566 let json = serde_json::to_string(&GateStatus::Pass).unwrap();
567 assert_eq!(json, "\"pass\"");
568 let back: GateStatus = serde_json::from_str(&json).unwrap();
569 assert_eq!(back, GateStatus::Pass);
570 }
571
572 #[test]
573 fn trend_direction_serde() {
574 let json = serde_json::to_string(&TrendDirection::Improving).unwrap();
575 assert_eq!(json, "\"improving\"");
576 let back: TrendDirection = serde_json::from_str(&json).unwrap();
577 assert_eq!(back, TrendDirection::Improving);
578 }
579
580 #[test]
581 fn risk_level_display() {
582 assert_eq!(RiskLevel::Low.to_string(), "low");
583 assert_eq!(RiskLevel::Critical.to_string(), "critical");
584 }
585}