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