Skip to main content

tokmd_types/
cockpit.rs

1//! Cockpit receipt types for PR metrics and evidence gates.
2//!
3//! These types define the data model for the `tokmd cockpit` command output.
4//! They are extracted here (Tier 0) so that lower-tier crates like `tokmd-cockpit`
5//! and `tokmd-core` can reference them without depending on the CLI binary.
6
7use serde::{Deserialize, Serialize};
8
9/// Cockpit receipt schema version.
10pub const COCKPIT_SCHEMA_VERSION: u32 = 3;
11
12// =============================================================================
13// Top-level receipt
14// =============================================================================
15
16/// Cockpit receipt containing all PR metrics.
17#[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    /// Trend comparison with baseline (if --baseline was provided).
32    #[serde(skip_serializing_if = "Option::is_none")]
33    pub trend: Option<TrendComparison>,
34}
35
36// =============================================================================
37// Evidence gates
38// =============================================================================
39
40/// Evidence section containing hard gates.
41#[derive(Debug, Clone, Serialize, Deserialize)]
42pub struct Evidence {
43    /// Aggregate status of all gates.
44    pub overall_status: GateStatus,
45    /// Mutation testing gate (always present).
46    pub mutation: MutationGate,
47    /// Diff coverage gate (optional).
48    #[serde(skip_serializing_if = "Option::is_none")]
49    pub diff_coverage: Option<DiffCoverageGate>,
50    /// Contract diff gate (optional).
51    #[serde(skip_serializing_if = "Option::is_none")]
52    pub contracts: Option<ContractDiffGate>,
53    /// Supply chain gate (optional).
54    #[serde(skip_serializing_if = "Option::is_none")]
55    pub supply_chain: Option<SupplyChainGate>,
56    /// Determinism gate (optional).
57    #[serde(skip_serializing_if = "Option::is_none")]
58    pub determinism: Option<DeterminismGate>,
59    /// Complexity gate (optional).
60    #[serde(skip_serializing_if = "Option::is_none")]
61    pub complexity: Option<ComplexityGate>,
62}
63
64/// Status of a gate check.
65#[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/// Source of evidence/gate results.
76#[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/// Commit match quality for evidence.
85#[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/// Common metadata for all gates.
95#[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    /// SHA this evidence was generated for.
102    #[serde(skip_serializing_if = "Option::is_none")]
103    pub evidence_commit: Option<String>,
104    /// Timestamp when evidence was generated (ms since epoch).
105    #[serde(skip_serializing_if = "Option::is_none")]
106    pub evidence_generated_at_ms: Option<u64>,
107}
108
109/// Scope coverage for a gate.
110#[derive(Debug, Clone, Serialize, Deserialize)]
111pub struct ScopeCoverage {
112    /// Files in scope for the gate.
113    pub relevant: Vec<String>,
114    /// Files actually tested.
115    pub tested: Vec<String>,
116    /// Coverage ratio (tested/relevant, 0.0-1.0).
117    pub ratio: f64,
118    /// Lines in scope (optional, for line-level gates).
119    #[serde(skip_serializing_if = "Option::is_none")]
120    pub lines_relevant: Option<usize>,
121    /// Lines actually tested (optional, for line-level gates).
122    #[serde(skip_serializing_if = "Option::is_none")]
123    pub lines_tested: Option<usize>,
124}
125
126// =============================================================================
127// Individual gate types
128// =============================================================================
129
130/// Mutation testing gate results.
131#[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/// A mutation that survived testing (escaped detection).
142#[derive(Debug, Clone, Serialize, Deserialize)]
143pub struct MutationSurvivor {
144    pub file: String,
145    pub line: usize,
146    pub mutation: String,
147}
148
149/// Diff coverage gate results.
150#[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/// Uncovered hunk in diff coverage.
161#[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/// Contract diff gate results (compound gate).
169#[derive(Debug, Clone, Serialize, Deserialize)]
170pub struct ContractDiffGate {
171    #[serde(flatten)]
172    pub meta: GateMeta,
173    /// Semver sub-gate.
174    #[serde(skip_serializing_if = "Option::is_none")]
175    pub semver: Option<SemverSubGate>,
176    /// CLI sub-gate.
177    #[serde(skip_serializing_if = "Option::is_none")]
178    pub cli: Option<CliSubGate>,
179    /// Schema sub-gate.
180    #[serde(skip_serializing_if = "Option::is_none")]
181    pub schema: Option<SchemaSubGate>,
182    /// Count of failed sub-gates.
183    pub failures: usize,
184}
185
186/// Semver sub-gate for contract diff.
187#[derive(Debug, Clone, Serialize, Deserialize)]
188pub struct SemverSubGate {
189    pub status: GateStatus,
190    pub breaking_changes: Vec<BreakingChange>,
191}
192
193/// Breaking change detected by semver check.
194#[derive(Debug, Clone, Serialize, Deserialize)]
195pub struct BreakingChange {
196    pub kind: String,
197    pub path: String,
198    pub message: String,
199}
200
201/// CLI sub-gate for contract diff.
202#[derive(Debug, Clone, Serialize, Deserialize)]
203pub struct CliSubGate {
204    pub status: GateStatus,
205    pub diff_summary: Option<String>,
206}
207
208/// Schema sub-gate for contract diff.
209#[derive(Debug, Clone, Serialize, Deserialize)]
210pub struct SchemaSubGate {
211    pub status: GateStatus,
212    pub diff_summary: Option<String>,
213}
214
215/// Supply chain gate results.
216#[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/// Vulnerability from cargo-audit.
227#[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/// Determinism gate results.
236#[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/// Complexity gate results.
249#[derive(Debug, Clone, Serialize, Deserialize)]
250pub struct ComplexityGate {
251    #[serde(flatten)]
252    pub meta: GateMeta,
253    /// Number of files analyzed for complexity.
254    pub files_analyzed: usize,
255    /// Files with high complexity (CC > threshold).
256    pub high_complexity_files: Vec<HighComplexityFile>,
257    /// Average cyclomatic complexity across all analyzed files.
258    pub avg_cyclomatic: f64,
259    /// Maximum cyclomatic complexity found.
260    pub max_cyclomatic: u32,
261    /// Whether the threshold was exceeded.
262    pub threshold_exceeded: bool,
263}
264
265/// A file with high cyclomatic complexity.
266#[derive(Debug, Clone, Serialize, Deserialize)]
267pub struct HighComplexityFile {
268    /// Path to the file.
269    pub path: String,
270    /// Cyclomatic complexity score.
271    pub cyclomatic: u32,
272    /// Number of functions in the file.
273    pub function_count: usize,
274    /// Maximum function length in lines.
275    pub max_function_length: usize,
276}
277
278// =============================================================================
279// Metric types
280// =============================================================================
281
282/// Change surface metrics.
283#[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    /// Churn velocity: average lines changed per commit.
291    pub churn_velocity: f64,
292    /// Change concentration: what % of changes are in top 20% of files.
293    pub change_concentration: f64,
294}
295
296/// File composition breakdown.
297#[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    /// Test-to-code ratio (tests / code files).
304    pub test_ratio: f64,
305}
306
307/// Code health indicators for DevEx.
308#[derive(Debug, Clone, Serialize, Deserialize)]
309pub struct CodeHealth {
310    /// Overall health score (0-100).
311    pub score: u32,
312    /// Health grade (A-F).
313    pub grade: String,
314    /// Number of large files (>500 lines) being changed.
315    pub large_files_touched: usize,
316    /// Average file size in changed files.
317    pub avg_file_size: usize,
318    /// Complexity indicator based on file patterns.
319    pub complexity_indicator: ComplexityIndicator,
320    /// Files with potential issues.
321    pub warnings: Vec<HealthWarning>,
322}
323
324/// Complexity indicator levels.
325#[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/// Health warning for specific files.
335#[derive(Debug, Clone, Serialize, Deserialize)]
336pub struct HealthWarning {
337    pub path: String,
338    pub warning_type: WarningType,
339    pub message: String,
340}
341
342/// Types of health warnings.
343#[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/// Risk indicators.
354#[derive(Debug, Clone, Serialize, Deserialize)]
355pub struct Risk {
356    pub hotspots_touched: Vec<String>,
357    pub bus_factor_warnings: Vec<String>,
358    /// Overall risk level for this PR.
359    pub level: RiskLevel,
360    /// Risk score (0-100).
361    pub score: u32,
362}
363
364/// Risk level classification.
365#[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/// Contract change indicators.
386#[derive(Debug, Clone, Serialize, Deserialize)]
387pub struct Contracts {
388    pub api_changed: bool,
389    pub cli_changed: bool,
390    pub schema_changed: bool,
391    /// Number of breaking change indicators.
392    pub breaking_indicators: usize,
393}
394
395/// Review plan item.
396#[derive(Debug, Clone, Serialize, Deserialize)]
397pub struct ReviewItem {
398    pub path: String,
399    pub reason: String,
400    pub priority: u32,
401    /// Estimated review complexity (1-5).
402    #[serde(skip_serializing_if = "Option::is_none")]
403    pub complexity: Option<u8>,
404    /// Lines changed in this file.
405    #[serde(skip_serializing_if = "Option::is_none")]
406    pub lines_changed: Option<usize>,
407}
408
409// =============================================================================
410// Trend comparison types
411// =============================================================================
412
413/// Trend comparison between current state and baseline.
414#[derive(Debug, Clone, Default, Serialize, Deserialize)]
415pub struct TrendComparison {
416    /// Whether a baseline was successfully loaded.
417    pub baseline_available: bool,
418    /// Path to the baseline file used.
419    #[serde(skip_serializing_if = "Option::is_none")]
420    pub baseline_path: Option<String>,
421    /// Timestamp of baseline generation.
422    #[serde(skip_serializing_if = "Option::is_none")]
423    pub baseline_generated_at_ms: Option<u64>,
424    /// Health score trend.
425    #[serde(skip_serializing_if = "Option::is_none")]
426    pub health: Option<TrendMetric>,
427    /// Risk score trend.
428    #[serde(skip_serializing_if = "Option::is_none")]
429    pub risk: Option<TrendMetric>,
430    /// Complexity trend indicator.
431    #[serde(skip_serializing_if = "Option::is_none")]
432    pub complexity: Option<TrendIndicator>,
433}
434
435/// A trend metric with current, previous, delta values.
436#[derive(Debug, Clone, Serialize, Deserialize)]
437pub struct TrendMetric {
438    /// Current value.
439    pub current: f64,
440    /// Previous (baseline) value.
441    pub previous: f64,
442    /// Absolute delta (current - previous).
443    pub delta: f64,
444    /// Percentage change.
445    pub delta_pct: f64,
446    /// Direction of change.
447    pub direction: TrendDirection,
448}
449
450/// Complexity trend indicator.
451#[derive(Debug, Clone, Serialize, Deserialize)]
452pub struct TrendIndicator {
453    /// Overall trend direction.
454    pub direction: TrendDirection,
455    /// Human-readable summary.
456    pub summary: String,
457    /// Number of files that got more complex.
458    pub files_increased: usize,
459    /// Number of files that got less complex.
460    pub files_decreased: usize,
461    /// Average cyclomatic delta.
462    #[serde(skip_serializing_if = "Option::is_none")]
463    pub avg_cyclomatic_delta: Option<f64>,
464    /// Average cognitive delta.
465    #[serde(skip_serializing_if = "Option::is_none")]
466    pub avg_cognitive_delta: Option<f64>,
467}
468
469/// Direction of a trend.
470#[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}