Skip to main content

tokmd_envelope/
lib.rs

1//! # tokmd-envelope
2//!
3//! **Tier 0 (Cross-Fleet Contract)**
4//!
5//! Defines the `SensorReport` envelope and associated types for multi-sensor
6//! integration. External sensors depend on this crate without pulling in
7//! tokmd-specific analysis types.
8//!
9//! ## What belongs here
10//! * `SensorReport` (the cross-fleet envelope)
11//! * `Verdict`, `Finding`, `FindingSeverity`, `FindingLocation`
12//! * `GateResults`, `GateItem`, `Artifact`
13//! * Finding ID constants
14//!
15//! ## What does NOT belong here
16//! * tokmd-specific analysis types (use tokmd-analysis-types)
17//! * I/O operations or business logic
18
19pub mod findings;
20
21use serde::{Deserialize, Serialize};
22
23/// Schema identifier for sensor report format.
24/// v1: Initial sensor report specification for multi-sensor integration.
25pub const SENSOR_REPORT_SCHEMA: &str = "sensor.report.v1";
26
27/// Sensor report envelope for multi-sensor integration.
28///
29/// The envelope provides a standardized JSON format that allows sensors to
30/// integrate with external orchestrators ("directors") that aggregate reports
31/// from multiple code quality sensors into a unified PR view.
32///
33/// # Design Principles
34/// - **Stable top-level, rich underneath**: Minimal stable envelope; tool-specific richness in `data`
35/// - **Verdict-first**: Quick pass/fail/warn determination without parsing tool-specific data
36/// - **Findings are portable**: Common finding structure for cross-tool aggregation
37/// - **Self-describing**: Schema version and tool metadata enable forward compatibility
38///
39/// # Examples
40///
41/// ```
42/// use tokmd_envelope::{SensorReport, ToolMeta, Verdict, SENSOR_REPORT_SCHEMA};
43///
44/// let report = SensorReport::new(
45///     ToolMeta::tokmd("1.5.0", "cockpit"),
46///     "2024-01-15T10:30:00Z".to_string(),
47///     Verdict::Pass,
48///     "All checks passed".to_string(),
49/// );
50/// assert_eq!(report.schema, SENSOR_REPORT_SCHEMA);
51/// assert_eq!(report.verdict, Verdict::Pass);
52/// assert!(report.findings.is_empty());
53/// ```
54#[derive(Debug, Clone, Serialize, Deserialize)]
55pub struct SensorReport {
56    /// Schema identifier (e.g., "sensor.report.v1").
57    pub schema: String,
58    /// Tool identification.
59    pub tool: ToolMeta,
60    /// Generation timestamp (ISO 8601 format).
61    pub generated_at: String,
62    /// Overall result verdict.
63    pub verdict: Verdict,
64    /// Human-readable one-line summary.
65    pub summary: String,
66    /// List of findings (may be empty).
67    pub findings: Vec<Finding>,
68    /// Related artifact paths.
69    #[serde(skip_serializing_if = "Option::is_none")]
70    pub artifacts: Option<Vec<Artifact>>,
71    /// Capability availability status for "No Green By Omission".
72    ///
73    /// Reports which checks were available, unavailable, or skipped.
74    /// Enables directors to distinguish between "all passed" and "nothing ran".
75    #[serde(skip_serializing_if = "Option::is_none")]
76    pub capabilities: Option<std::collections::BTreeMap<String, CapabilityStatus>>,
77    /// Tool-specific payload (opaque to director).
78    #[serde(skip_serializing_if = "Option::is_none")]
79    pub data: Option<serde_json::Value>,
80}
81
82/// Tool identification for the sensor report.
83///
84/// # Examples
85///
86/// ```
87/// use tokmd_envelope::ToolMeta;
88///
89/// let meta = ToolMeta::new("my-sensor", "0.1.0", "analyze");
90/// assert_eq!(meta.name, "my-sensor");
91///
92/// // Shortcut for tokmd tools
93/// let tokmd = ToolMeta::tokmd("1.5.0", "cockpit");
94/// assert_eq!(tokmd.name, "tokmd");
95/// assert_eq!(tokmd.mode, "cockpit");
96/// ```
97#[derive(Debug, Clone, Serialize, Deserialize)]
98pub struct ToolMeta {
99    /// Tool name (e.g., "tokmd").
100    pub name: String,
101    /// Tool version (e.g., "1.5.0").
102    pub version: String,
103    /// Operation mode (e.g., "cockpit", "analyze").
104    pub mode: String,
105}
106
107/// Overall verdict for the sensor report.
108///
109/// Directors aggregate verdicts: `fail` > `pending` > `warn` > `pass` > `skip`
110///
111/// # Examples
112///
113/// ```
114/// use tokmd_envelope::Verdict;
115///
116/// let v = Verdict::default();
117/// assert_eq!(v, Verdict::Pass);
118/// assert_eq!(format!("{v}"), "pass");
119/// ```
120#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
121#[serde(rename_all = "lowercase")]
122pub enum Verdict {
123    /// All checks passed, no significant findings.
124    #[default]
125    Pass,
126    /// Hard failure (evidence gate failed, policy violation).
127    Fail,
128    /// Soft warnings present, review recommended.
129    Warn,
130    /// Sensor skipped (missing inputs, not applicable).
131    Skip,
132    /// Awaiting external data (CI artifacts, etc.).
133    Pending,
134}
135
136/// A finding reported by the sensor.
137///
138/// Findings use a `(check_id, code)` tuple for identity. Combined with
139/// `tool.name` this forms the triple `(tool, check_id, code)` used for
140/// buildfix routing and cockpit policy (e.g., `("tokmd", "risk", "hotspot")`).
141///
142/// # Examples
143///
144/// ```
145/// use tokmd_envelope::{Finding, FindingSeverity, FindingLocation};
146///
147/// let finding = Finding::new(
148///     "risk", "hotspot",
149///     FindingSeverity::Warn,
150///     "High-churn file",
151///     "src/lib.rs modified 42 times in 30 days",
152/// ).with_location(FindingLocation::path_line("src/lib.rs", 1));
153///
154/// assert_eq!(finding.check_id, "risk");
155/// assert_eq!(finding.code, "hotspot");
156/// assert!(finding.location.is_some());
157/// ```
158#[derive(Debug, Clone, Serialize, Deserialize)]
159pub struct Finding {
160    /// Check category (e.g., "risk", "contract", "gate").
161    pub check_id: String,
162    /// Finding code within the category (e.g., "hotspot", "coupling").
163    pub code: String,
164    /// Severity level.
165    pub severity: FindingSeverity,
166    /// Short title for the finding.
167    pub title: String,
168    /// Detailed message describing the finding.
169    pub message: String,
170    /// Source location (if applicable).
171    #[serde(skip_serializing_if = "Option::is_none")]
172    pub location: Option<FindingLocation>,
173    /// Additional evidence data.
174    #[serde(skip_serializing_if = "Option::is_none")]
175    pub evidence: Option<serde_json::Value>,
176    /// Documentation URL for this finding type.
177    #[serde(skip_serializing_if = "Option::is_none")]
178    pub docs_url: Option<String>,
179    /// Stable identity fingerprint for deduplication and buildfix routing.
180    /// BLAKE3 hash of (tool_name, check_id, code, location.path).
181    #[serde(skip_serializing_if = "Option::is_none")]
182    pub fingerprint: Option<String>,
183}
184
185/// Severity level for findings.
186#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
187#[serde(rename_all = "lowercase")]
188pub enum FindingSeverity {
189    /// Blocks merge (hard gate failure).
190    Error,
191    /// Review recommended.
192    Warn,
193    /// Informational, no action required.
194    Info,
195}
196
197/// Source location for a finding.
198///
199/// # Examples
200///
201/// ```
202/// use tokmd_envelope::FindingLocation;
203///
204/// // Path only
205/// let loc = FindingLocation::path("src/main.rs");
206/// assert_eq!(loc.path, "src/main.rs");
207/// assert!(loc.line.is_none());
208///
209/// // Path + line
210/// let loc = FindingLocation::path_line("src/lib.rs", 42);
211/// assert_eq!(loc.line, Some(42));
212///
213/// // Path + line + column
214/// let loc = FindingLocation::path_line_column("src/lib.rs", 42, 10);
215/// assert_eq!(loc.column, Some(10));
216/// ```
217#[derive(Debug, Clone, Serialize, Deserialize)]
218pub struct FindingLocation {
219    /// File path (normalized to forward slashes).
220    pub path: String,
221    /// Line number (1-indexed).
222    #[serde(skip_serializing_if = "Option::is_none")]
223    pub line: Option<u32>,
224    /// Column number (1-indexed).
225    #[serde(skip_serializing_if = "Option::is_none")]
226    pub column: Option<u32>,
227}
228
229/// Evidence gate results section.
230#[derive(Debug, Clone, Serialize, Deserialize)]
231pub struct GateResults {
232    /// Overall gate status.
233    pub status: Verdict,
234    /// Individual gate items.
235    pub items: Vec<GateItem>,
236}
237
238/// Individual gate item in the gates section.
239///
240/// # Examples
241///
242/// ```
243/// use tokmd_envelope::{GateItem, Verdict};
244///
245/// let gate = GateItem::new("coverage", Verdict::Pass)
246///     .with_threshold(80.0, 85.5)
247///     .with_source("ci_artifact");
248/// assert_eq!(gate.id, "coverage");
249/// assert_eq!(gate.actual, Some(85.5));
250/// ```
251#[derive(Debug, Clone, Serialize, Deserialize)]
252pub struct GateItem {
253    /// Gate identifier (e.g., "mutation", "diff_coverage").
254    pub id: String,
255    /// Gate status.
256    pub status: Verdict,
257    /// Threshold value (if applicable).
258    #[serde(skip_serializing_if = "Option::is_none")]
259    pub threshold: Option<f64>,
260    /// Actual measured value (if applicable).
261    #[serde(skip_serializing_if = "Option::is_none")]
262    pub actual: Option<f64>,
263    /// Reason for the status (especially for pending/fail).
264    #[serde(skip_serializing_if = "Option::is_none")]
265    pub reason: Option<String>,
266    /// Data source (e.g., "ci_artifact", "computed").
267    #[serde(skip_serializing_if = "Option::is_none")]
268    pub source: Option<String>,
269    /// Path to the source artifact (if applicable).
270    #[serde(skip_serializing_if = "Option::is_none")]
271    pub artifact_path: Option<String>,
272}
273
274/// Artifact reference in the sensor report.
275///
276/// # Examples
277///
278/// ```
279/// use tokmd_envelope::Artifact;
280///
281/// let art = Artifact::receipt("output/receipt.json")
282///     .with_id("analysis")
283///     .with_mime("application/json");
284/// assert_eq!(art.artifact_type, "receipt");
285/// assert_eq!(art.id.as_deref(), Some("analysis"));
286/// ```
287#[derive(Debug, Clone, Serialize, Deserialize)]
288pub struct Artifact {
289    /// Artifact identifier (e.g., "analysis", "handoff").
290    #[serde(skip_serializing_if = "Option::is_none")]
291    pub id: Option<String>,
292    /// Artifact type (e.g., "comment", "receipt", "badge").
293    #[serde(rename = "type")]
294    pub artifact_type: String,
295    /// Path to the artifact file.
296    pub path: String,
297    /// MIME type (e.g., "application/json").
298    #[serde(skip_serializing_if = "Option::is_none")]
299    pub mime: Option<String>,
300}
301
302/// Status of a capability for "No Green By Omission".
303///
304/// Enables directors to distinguish between checks that:
305/// - Passed (available and ran successfully)
306/// - Weren't applicable (skipped due to no relevant files)
307/// - Couldn't run (unavailable due to missing tools or inputs)
308#[derive(Debug, Clone, Serialize, Deserialize)]
309pub struct CapabilityStatus {
310    /// Whether the capability was available, unavailable, or skipped.
311    pub status: CapabilityState,
312    /// Optional reason explaining the status.
313    #[serde(skip_serializing_if = "Option::is_none")]
314    pub reason: Option<String>,
315}
316
317/// State of a capability.
318#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
319#[serde(rename_all = "lowercase")]
320pub enum CapabilityState {
321    /// Capability was available and produced results.
322    Available,
323    /// Capability was not available (missing tool, missing inputs).
324    Unavailable,
325    /// Capability was skipped (no relevant files, not applicable).
326    Skipped,
327}
328
329impl CapabilityStatus {
330    /// Create a new capability status.
331    pub fn new(status: CapabilityState) -> Self {
332        Self {
333            status,
334            reason: None,
335        }
336    }
337
338    /// Create an available capability status.
339    pub fn available() -> Self {
340        Self::new(CapabilityState::Available)
341    }
342
343    /// Create an unavailable capability status with a reason.
344    pub fn unavailable(reason: impl Into<String>) -> Self {
345        Self {
346            status: CapabilityState::Unavailable,
347            reason: Some(reason.into()),
348        }
349    }
350
351    /// Create a skipped capability status with a reason.
352    pub fn skipped(reason: impl Into<String>) -> Self {
353        Self {
354            status: CapabilityState::Skipped,
355            reason: Some(reason.into()),
356        }
357    }
358
359    /// Add a reason to the capability status.
360    pub fn with_reason(mut self, reason: impl Into<String>) -> Self {
361        self.reason = Some(reason.into());
362        self
363    }
364}
365
366// --------------------------
367// Builder/helper methods
368// --------------------------
369
370impl SensorReport {
371    /// Create a new sensor report with the current version.
372    ///
373    /// # Examples
374    ///
375    /// ```
376    /// use tokmd_envelope::{SensorReport, ToolMeta, Verdict, Finding, FindingSeverity};
377    ///
378    /// let mut report = SensorReport::new(
379    ///     ToolMeta::tokmd("1.5.0", "analyze"),
380    ///     "2024-06-01T12:00:00Z".to_string(),
381    ///     Verdict::Warn,
382    ///     "Risk hotspots detected".to_string(),
383    /// );
384    /// report.add_finding(Finding::new(
385    ///     "risk", "hotspot",
386    ///     FindingSeverity::Warn,
387    ///     "High-churn file",
388    ///     "src/lib.rs modified frequently",
389    /// ));
390    /// assert_eq!(report.findings.len(), 1);
391    /// ```
392    pub fn new(tool: ToolMeta, generated_at: String, verdict: Verdict, summary: String) -> Self {
393        Self {
394            schema: SENSOR_REPORT_SCHEMA.to_string(),
395            tool,
396            generated_at,
397            verdict,
398            summary,
399            findings: Vec::new(),
400            artifacts: None,
401            capabilities: None,
402            data: None,
403        }
404    }
405
406    /// Add a finding to the report.
407    pub fn add_finding(&mut self, finding: Finding) {
408        self.findings.push(finding);
409    }
410
411    /// Set the artifacts section.
412    pub fn with_artifacts(mut self, artifacts: Vec<Artifact>) -> Self {
413        self.artifacts = Some(artifacts);
414        self
415    }
416
417    /// Set the data payload.
418    pub fn with_data(mut self, data: serde_json::Value) -> Self {
419        self.data = Some(data);
420        self
421    }
422
423    /// Set the capabilities section for "No Green By Omission".
424    pub fn with_capabilities(
425        mut self,
426        capabilities: std::collections::BTreeMap<String, CapabilityStatus>,
427    ) -> Self {
428        self.capabilities = Some(capabilities);
429        self
430    }
431
432    /// Add a single capability to the report.
433    pub fn add_capability(&mut self, name: impl Into<String>, status: CapabilityStatus) {
434        self.capabilities
435            .get_or_insert_with(std::collections::BTreeMap::new)
436            .insert(name.into(), status);
437    }
438}
439
440impl ToolMeta {
441    /// Create a new tool identifier.
442    pub fn new(name: &str, version: &str, mode: &str) -> Self {
443        Self {
444            name: name.to_string(),
445            version: version.to_string(),
446            mode: mode.to_string(),
447        }
448    }
449
450    /// Create a tool identifier for tokmd.
451    pub fn tokmd(version: &str, mode: &str) -> Self {
452        Self::new("tokmd", version, mode)
453    }
454}
455
456impl Finding {
457    /// Create a new finding with required fields.
458    pub fn new(
459        check_id: impl Into<String>,
460        code: impl Into<String>,
461        severity: FindingSeverity,
462        title: impl Into<String>,
463        message: impl Into<String>,
464    ) -> Self {
465        Self {
466            check_id: check_id.into(),
467            code: code.into(),
468            severity,
469            title: title.into(),
470            message: message.into(),
471            location: None,
472            evidence: None,
473            docs_url: None,
474            fingerprint: None,
475        }
476    }
477
478    /// Add a location to the finding.
479    pub fn with_location(mut self, location: FindingLocation) -> Self {
480        self.location = Some(location);
481        self
482    }
483
484    /// Add evidence to the finding.
485    pub fn with_evidence(mut self, evidence: serde_json::Value) -> Self {
486        self.evidence = Some(evidence);
487        self
488    }
489
490    /// Add a documentation URL to the finding.
491    pub fn with_docs_url(mut self, url: impl Into<String>) -> Self {
492        self.docs_url = Some(url.into());
493        self
494    }
495
496    /// Compute a stable fingerprint from `(tool_name, check_id, code, path)`.
497    ///
498    /// Returns first 16 bytes (32 hex chars) of a BLAKE3 hash for compactness.
499    ///
500    /// # Examples
501    ///
502    /// ```
503    /// use tokmd_envelope::{Finding, FindingSeverity, FindingLocation};
504    ///
505    /// let f = Finding::new("risk", "hotspot", FindingSeverity::Warn, "Churn", "high")
506    ///     .with_location(FindingLocation::path("src/lib.rs"));
507    /// let fp = f.compute_fingerprint("tokmd");
508    /// assert_eq!(fp.len(), 32);
509    ///
510    /// // Same inputs produce same fingerprint
511    /// let f2 = Finding::new("risk", "hotspot", FindingSeverity::Warn, "Churn", "high")
512    ///     .with_location(FindingLocation::path("src/lib.rs"));
513    /// assert_eq!(f2.compute_fingerprint("tokmd"), fp);
514    /// ```
515    pub fn compute_fingerprint(&self, tool_name: &str) -> String {
516        let path = self
517            .location
518            .as_ref()
519            .map(|l| l.path.as_str())
520            .unwrap_or("");
521        let identity = format!("{}\0{}\0{}\0{}", tool_name, self.check_id, self.code, path);
522        let hash = blake3::hash(identity.as_bytes());
523        let hex = hash.to_hex();
524        hex[..32].to_string()
525    }
526
527    /// Auto-compute and set fingerprint. Builder pattern.
528    pub fn with_fingerprint(mut self, tool_name: &str) -> Self {
529        self.fingerprint = Some(self.compute_fingerprint(tool_name));
530        self
531    }
532}
533
534impl FindingLocation {
535    /// Create a new location with just a path.
536    pub fn path(path: impl Into<String>) -> Self {
537        Self {
538            path: path.into(),
539            line: None,
540            column: None,
541        }
542    }
543
544    /// Create a new location with path and line.
545    pub fn path_line(path: impl Into<String>, line: u32) -> Self {
546        Self {
547            path: path.into(),
548            line: Some(line),
549            column: None,
550        }
551    }
552
553    /// Create a new location with path, line, and column.
554    pub fn path_line_column(path: impl Into<String>, line: u32, column: u32) -> Self {
555        Self {
556            path: path.into(),
557            line: Some(line),
558            column: Some(column),
559        }
560    }
561}
562
563impl std::fmt::Display for Verdict {
564    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
565        match self {
566            Verdict::Pass => write!(f, "pass"),
567            Verdict::Fail => write!(f, "fail"),
568            Verdict::Warn => write!(f, "warn"),
569            Verdict::Skip => write!(f, "skip"),
570            Verdict::Pending => write!(f, "pending"),
571        }
572    }
573}
574
575impl std::fmt::Display for FindingSeverity {
576    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
577        match self {
578            FindingSeverity::Error => write!(f, "error"),
579            FindingSeverity::Warn => write!(f, "warn"),
580            FindingSeverity::Info => write!(f, "info"),
581        }
582    }
583}
584
585impl GateResults {
586    /// Create a new gate results section.
587    pub fn new(status: Verdict, items: Vec<GateItem>) -> Self {
588        Self { status, items }
589    }
590}
591
592impl GateItem {
593    /// Create a new gate item with required fields.
594    pub fn new(id: impl Into<String>, status: Verdict) -> Self {
595        Self {
596            id: id.into(),
597            status,
598            threshold: None,
599            actual: None,
600            reason: None,
601            source: None,
602            artifact_path: None,
603        }
604    }
605
606    /// Create a gate item with pass/fail based on threshold comparison.
607    pub fn with_threshold(mut self, threshold: f64, actual: f64) -> Self {
608        self.threshold = Some(threshold);
609        self.actual = Some(actual);
610        self
611    }
612
613    /// Add a reason to the gate item.
614    pub fn with_reason(mut self, reason: impl Into<String>) -> Self {
615        self.reason = Some(reason.into());
616        self
617    }
618
619    /// Add a source to the gate item.
620    pub fn with_source(mut self, source: impl Into<String>) -> Self {
621        self.source = Some(source.into());
622        self
623    }
624
625    /// Add an artifact path to the gate item.
626    pub fn with_artifact_path(mut self, path: impl Into<String>) -> Self {
627        self.artifact_path = Some(path.into());
628        self
629    }
630}
631
632impl Artifact {
633    /// Create a new artifact reference.
634    pub fn new(artifact_type: impl Into<String>, path: impl Into<String>) -> Self {
635        Self {
636            id: None,
637            artifact_type: artifact_type.into(),
638            path: path.into(),
639            mime: None,
640        }
641    }
642
643    /// Create a comment artifact.
644    pub fn comment(path: impl Into<String>) -> Self {
645        Self::new("comment", path)
646    }
647
648    /// Create a receipt artifact.
649    pub fn receipt(path: impl Into<String>) -> Self {
650        Self::new("receipt", path)
651    }
652
653    /// Create a badge artifact.
654    pub fn badge(path: impl Into<String>) -> Self {
655        Self::new("badge", path)
656    }
657
658    /// Set the artifact ID. Builder pattern.
659    pub fn with_id(mut self, id: impl Into<String>) -> Self {
660        self.id = Some(id.into());
661        self
662    }
663
664    /// Set the MIME type. Builder pattern.
665    pub fn with_mime(mut self, mime: impl Into<String>) -> Self {
666        self.mime = Some(mime.into());
667        self
668    }
669}
670
671#[cfg(test)]
672mod tests {
673    use super::*;
674
675    #[test]
676    fn serde_roundtrip_sensor_report() {
677        let report = SensorReport::new(
678            ToolMeta::tokmd("1.5.0", "cockpit"),
679            "2024-01-01T00:00:00Z".to_string(),
680            Verdict::Pass,
681            "All checks passed".to_string(),
682        );
683        let json = serde_json::to_string(&report).unwrap();
684        let back: SensorReport = serde_json::from_str(&json).unwrap();
685        assert_eq!(back.schema, SENSOR_REPORT_SCHEMA);
686        assert_eq!(back.verdict, Verdict::Pass);
687        assert_eq!(back.tool.name, "tokmd");
688    }
689
690    #[test]
691    fn serde_roundtrip_with_findings() {
692        let mut report = SensorReport::new(
693            ToolMeta::tokmd("1.5.0", "cockpit"),
694            "2024-01-01T00:00:00Z".to_string(),
695            Verdict::Warn,
696            "Risk hotspots detected".to_string(),
697        );
698        report.add_finding(
699            Finding::new(
700                findings::risk::CHECK_ID,
701                findings::risk::HOTSPOT,
702                FindingSeverity::Warn,
703                "High-churn file",
704                "src/lib.rs has been modified 42 times",
705            )
706            .with_location(FindingLocation::path("src/lib.rs")),
707        );
708        let json = serde_json::to_string(&report).unwrap();
709        let back: SensorReport = serde_json::from_str(&json).unwrap();
710        assert_eq!(back.findings.len(), 1);
711        assert_eq!(back.findings[0].check_id, "risk");
712        assert_eq!(back.findings[0].code, "hotspot");
713
714        // Verify finding_id composition
715        let fid = findings::finding_id("tokmd", findings::risk::CHECK_ID, findings::risk::HOTSPOT);
716        assert_eq!(fid, "tokmd.risk.hotspot");
717    }
718
719    #[test]
720    fn serde_roundtrip_with_gates_in_data() {
721        let gates = GateResults::new(
722            Verdict::Fail,
723            vec![
724                GateItem::new("mutation", Verdict::Fail)
725                    .with_threshold(80.0, 72.0)
726                    .with_reason("Below threshold"),
727            ],
728        );
729        let report = SensorReport::new(
730            ToolMeta::tokmd("1.5.0", "cockpit"),
731            "2024-01-01T00:00:00Z".to_string(),
732            Verdict::Fail,
733            "Gate failed".to_string(),
734        )
735        .with_data(serde_json::json!({
736            "gates": serde_json::to_value(gates).unwrap(),
737        }));
738        let json = serde_json::to_string(&report).unwrap();
739        let back: SensorReport = serde_json::from_str(&json).unwrap();
740        let data = back.data.unwrap();
741        let back_gates: GateResults = serde_json::from_value(data["gates"].clone()).unwrap();
742        assert_eq!(back_gates.items[0].id, "mutation");
743        assert_eq!(back_gates.status, Verdict::Fail);
744    }
745
746    #[test]
747    fn verdict_default_is_pass() {
748        assert_eq!(Verdict::default(), Verdict::Pass);
749    }
750
751    #[test]
752    fn schema_field_contains_string_identifier() {
753        let report = SensorReport::new(
754            ToolMeta::tokmd("1.5.0", "test"),
755            "2024-01-01T00:00:00Z".to_string(),
756            Verdict::Pass,
757            "test".to_string(),
758        );
759        let json = serde_json::to_string(&report).unwrap();
760        assert!(json.contains("\"schema\""));
761        assert!(json.contains("sensor.report.v1"));
762    }
763
764    #[test]
765    fn verdict_display_matches_serde() {
766        for (variant, expected) in [
767            (Verdict::Pass, "pass"),
768            (Verdict::Fail, "fail"),
769            (Verdict::Warn, "warn"),
770            (Verdict::Skip, "skip"),
771            (Verdict::Pending, "pending"),
772        ] {
773            assert_eq!(variant.to_string(), expected);
774            let json = serde_json::to_value(variant).unwrap();
775            assert_eq!(json.as_str().unwrap(), expected);
776        }
777    }
778
779    #[test]
780    fn finding_severity_display_matches_serde() {
781        for (variant, expected) in [
782            (FindingSeverity::Error, "error"),
783            (FindingSeverity::Warn, "warn"),
784            (FindingSeverity::Info, "info"),
785        ] {
786            assert_eq!(variant.to_string(), expected);
787            let json = serde_json::to_value(variant).unwrap();
788            assert_eq!(json.as_str().unwrap(), expected);
789        }
790    }
791
792    #[test]
793    fn capability_status_serde_roundtrip() {
794        let status = CapabilityStatus::available();
795        let json = serde_json::to_string(&status).unwrap();
796        let back: CapabilityStatus = serde_json::from_str(&json).unwrap();
797        assert_eq!(back.status, CapabilityState::Available);
798        assert!(back.reason.is_none());
799    }
800
801    #[test]
802    fn capability_status_with_reason() {
803        let status = CapabilityStatus::unavailable("cargo-mutants not installed");
804        let json = serde_json::to_string(&status).unwrap();
805        let back: CapabilityStatus = serde_json::from_str(&json).unwrap();
806        assert_eq!(back.status, CapabilityState::Unavailable);
807        assert_eq!(back.reason.as_deref(), Some("cargo-mutants not installed"));
808    }
809
810    #[test]
811    fn sensor_report_with_capabilities() {
812        use std::collections::BTreeMap;
813
814        let mut caps = BTreeMap::new();
815        caps.insert("mutation".to_string(), CapabilityStatus::available());
816        caps.insert(
817            "coverage".to_string(),
818            CapabilityStatus::unavailable("no coverage artifact"),
819        );
820        caps.insert(
821            "semver".to_string(),
822            CapabilityStatus::skipped("no API files changed"),
823        );
824
825        let report = SensorReport::new(
826            ToolMeta::tokmd("1.5.0", "cockpit"),
827            "2024-01-01T00:00:00Z".to_string(),
828            Verdict::Pass,
829            "All checks passed".to_string(),
830        )
831        .with_capabilities(caps);
832
833        let json = serde_json::to_string(&report).unwrap();
834        assert!(json.contains("\"capabilities\""));
835        assert!(json.contains("\"mutation\""));
836        assert!(json.contains("\"available\""));
837
838        let back: SensorReport = serde_json::from_str(&json).unwrap();
839        let caps = back.capabilities.unwrap();
840        assert_eq!(caps.len(), 3);
841        assert_eq!(caps["mutation"].status, CapabilityState::Available);
842        assert_eq!(caps["coverage"].status, CapabilityState::Unavailable);
843        assert_eq!(caps["semver"].status, CapabilityState::Skipped);
844    }
845
846    #[test]
847    fn sensor_report_add_capability() {
848        let mut report = SensorReport::new(
849            ToolMeta::tokmd("1.5.0", "cockpit"),
850            "2024-01-01T00:00:00Z".to_string(),
851            Verdict::Pass,
852            "All checks passed".to_string(),
853        );
854        report.add_capability("mutation", CapabilityStatus::available());
855        report.add_capability("coverage", CapabilityStatus::unavailable("missing"));
856
857        let caps = report.capabilities.unwrap();
858        assert_eq!(caps.len(), 2);
859    }
860
861    #[test]
862    fn capability_status_with_reason_builder() {
863        let status = CapabilityStatus::available().with_reason("extra context");
864        assert_eq!(status.status, CapabilityState::Available);
865        assert_eq!(status.reason.as_deref(), Some("extra context"));
866    }
867
868    #[test]
869    fn sensor_report_with_artifacts_and_data() {
870        let artifact = Artifact::comment("out/comment.md")
871            .with_id("commentary")
872            .with_mime("text/markdown");
873        let report = SensorReport::new(
874            ToolMeta::tokmd("1.5.0", "cockpit"),
875            "2024-01-01T00:00:00Z".to_string(),
876            Verdict::Pass,
877            "Artifacts attached".to_string(),
878        )
879        .with_artifacts(vec![artifact.clone()])
880        .with_data(serde_json::json!({ "key": "value" }));
881
882        let artifacts = report.artifacts.as_ref().unwrap();
883        assert_eq!(artifacts.len(), 1);
884        assert_eq!(artifacts[0].artifact_type, "comment");
885        assert_eq!(artifacts[0].id.as_deref(), Some("commentary"));
886        assert_eq!(artifacts[0].mime.as_deref(), Some("text/markdown"));
887        assert_eq!(report.data.as_ref().unwrap()["key"], "value");
888    }
889
890    #[test]
891    fn finding_builders_and_fingerprint() {
892        let location = FindingLocation::path_line_column("src/lib.rs", 10, 2);
893        let finding = Finding::new(
894            findings::risk::CHECK_ID,
895            findings::risk::COUPLING,
896            FindingSeverity::Info,
897            "Coupled module",
898            "Modules share excessive dependencies",
899        )
900        .with_location(location.clone())
901        .with_evidence(serde_json::json!({ "coupling": 0.87 }))
902        .with_docs_url("https://example.com/docs/coupling");
903
904        let expected_identity = format!(
905            "{}\0{}\0{}\0{}",
906            "tokmd",
907            findings::risk::CHECK_ID,
908            findings::risk::COUPLING,
909            location.path
910        );
911        let expected_hash = blake3::hash(expected_identity.as_bytes()).to_hex();
912        let expected_fingerprint = expected_hash[..32].to_string();
913
914        assert_eq!(finding.compute_fingerprint("tokmd"), expected_fingerprint);
915
916        let with_fp = finding.clone().with_fingerprint("tokmd");
917        assert_eq!(
918            with_fp.fingerprint.as_deref(),
919            Some(expected_fingerprint.as_str())
920        );
921
922        let no_location = Finding::new(
923            findings::risk::CHECK_ID,
924            findings::risk::HOTSPOT,
925            FindingSeverity::Warn,
926            "Hotspot",
927            "Churn is elevated",
928        );
929        assert_ne!(
930            no_location.compute_fingerprint("tokmd"),
931            finding.compute_fingerprint("tokmd")
932        );
933    }
934
935    #[test]
936    fn finding_location_constructors() {
937        let path_only = FindingLocation::path("src/main.rs");
938        assert_eq!(path_only.path, "src/main.rs");
939        assert_eq!(path_only.line, None);
940        assert_eq!(path_only.column, None);
941
942        let path_line = FindingLocation::path_line("src/main.rs", 42);
943        assert_eq!(path_line.path, "src/main.rs");
944        assert_eq!(path_line.line, Some(42));
945        assert_eq!(path_line.column, None);
946
947        let path_line_column = FindingLocation::path_line_column("src/main.rs", 7, 3);
948        assert_eq!(path_line_column.path, "src/main.rs");
949        assert_eq!(path_line_column.line, Some(7));
950        assert_eq!(path_line_column.column, Some(3));
951    }
952
953    #[test]
954    fn gate_item_builder_fields() {
955        let gate = GateItem::new("diff_coverage", Verdict::Warn)
956            .with_threshold(0.8, 0.72)
957            .with_reason("Below threshold")
958            .with_source("ci_artifact")
959            .with_artifact_path("coverage/lcov.info");
960
961        assert_eq!(gate.id, "diff_coverage");
962        assert_eq!(gate.status, Verdict::Warn);
963        assert_eq!(gate.threshold, Some(0.8));
964        assert_eq!(gate.actual, Some(0.72));
965        assert_eq!(gate.reason.as_deref(), Some("Below threshold"));
966        assert_eq!(gate.source.as_deref(), Some("ci_artifact"));
967        assert_eq!(gate.artifact_path.as_deref(), Some("coverage/lcov.info"));
968    }
969
970    #[test]
971    fn artifact_builders_cover_variants() {
972        let custom = Artifact::new("custom", "out/custom.json");
973        assert_eq!(custom.artifact_type, "custom");
974        assert_eq!(custom.path, "out/custom.json");
975
976        let comment = Artifact::comment("out/comment.md");
977        assert_eq!(comment.artifact_type, "comment");
978        assert_eq!(comment.path, "out/comment.md");
979
980        let receipt = Artifact::receipt("out/receipt.json");
981        assert_eq!(receipt.artifact_type, "receipt");
982        assert_eq!(receipt.path, "out/receipt.json");
983
984        let badge = Artifact::badge("out/badge.svg");
985        assert_eq!(badge.artifact_type, "badge");
986        assert_eq!(badge.path, "out/badge.svg");
987    }
988}