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//! * FFI `run_json` response parsing/extraction helpers
12//! * `Verdict`, `Finding`, `FindingSeverity`, `FindingLocation`
13//! * `GateResults`, `GateItem`, `Artifact`, `CapabilityStatus`
14//! * Finding ID constants
15//!
16//! ## What does NOT belong here
17//! * tokmd-specific analysis types (use tokmd-analysis-types)
18//! * I/O operations or business logic
19
20mod artifact;
21mod capability;
22pub mod ffi;
23mod finding;
24pub mod findings;
25mod gate;
26
27pub use artifact::Artifact;
28pub use capability::{CapabilityState, CapabilityStatus};
29pub use finding::{Finding, FindingLocation, FindingSeverity};
30pub use gate::{GateItem, GateResults};
31
32use serde::{Deserialize, Serialize};
33
34/// Schema identifier for sensor report format.
35/// v1: Initial sensor report specification for multi-sensor integration.
36pub const SENSOR_REPORT_SCHEMA: &str = "sensor.report.v1";
37
38/// Sensor report envelope for multi-sensor integration.
39///
40/// The envelope provides a standardized JSON format that allows sensors to
41/// integrate with external orchestrators ("directors") that aggregate reports
42/// from multiple code quality sensors into a unified PR view.
43///
44/// # Design Principles
45/// - **Stable top-level, rich underneath**: Minimal stable envelope; tool-specific richness in `data`
46/// - **Verdict-first**: Quick pass/fail/warn determination without parsing tool-specific data
47/// - **Findings are portable**: Common finding structure for cross-tool aggregation
48/// - **Self-describing**: Schema version and tool metadata enable forward compatibility
49///
50/// # Examples
51///
52/// ```
53/// use tokmd_envelope::{SensorReport, ToolMeta, Verdict, SENSOR_REPORT_SCHEMA};
54///
55/// let report = SensorReport::new(
56///     ToolMeta::tokmd("1.5.0", "cockpit"),
57///     "2024-01-15T10:30:00Z".to_string(),
58///     Verdict::Pass,
59///     "All checks passed".to_string(),
60/// );
61/// assert_eq!(report.schema, SENSOR_REPORT_SCHEMA);
62/// assert_eq!(report.verdict, Verdict::Pass);
63/// assert!(report.findings.is_empty());
64/// ```
65#[derive(Debug, Clone, Serialize, Deserialize)]
66pub struct SensorReport {
67    /// Schema identifier (e.g., "sensor.report.v1").
68    pub schema: String,
69    /// Tool identification.
70    pub tool: ToolMeta,
71    /// Generation timestamp (ISO 8601 format).
72    pub generated_at: String,
73    /// Overall result verdict.
74    pub verdict: Verdict,
75    /// Human-readable one-line summary.
76    pub summary: String,
77    /// List of findings (may be empty).
78    pub findings: Vec<Finding>,
79    /// Related artifact paths.
80    #[serde(skip_serializing_if = "Option::is_none")]
81    pub artifacts: Option<Vec<Artifact>>,
82    /// Capability availability status for "No Green By Omission".
83    ///
84    /// Reports which checks were available, unavailable, or skipped.
85    /// Enables directors to distinguish between "all passed" and "nothing ran".
86    #[serde(skip_serializing_if = "Option::is_none")]
87    pub capabilities: Option<std::collections::BTreeMap<String, CapabilityStatus>>,
88    /// Tool-specific payload (opaque to director).
89    #[serde(skip_serializing_if = "Option::is_none")]
90    pub data: Option<serde_json::Value>,
91}
92
93/// Tool identification for the sensor report.
94///
95/// # Examples
96///
97/// ```
98/// use tokmd_envelope::ToolMeta;
99///
100/// let meta = ToolMeta::new("my-sensor", "0.1.0", "analyze");
101/// assert_eq!(meta.name, "my-sensor");
102///
103/// // Shortcut for tokmd tools
104/// let tokmd = ToolMeta::tokmd("1.5.0", "cockpit");
105/// assert_eq!(tokmd.name, "tokmd");
106/// assert_eq!(tokmd.mode, "cockpit");
107/// ```
108#[derive(Debug, Clone, Serialize, Deserialize)]
109pub struct ToolMeta {
110    /// Tool name (e.g., "tokmd").
111    pub name: String,
112    /// Tool version (e.g., "1.5.0").
113    pub version: String,
114    /// Operation mode (e.g., "cockpit", "analyze").
115    pub mode: String,
116}
117
118/// Overall verdict for the sensor report.
119///
120/// Directors aggregate verdicts: `fail` > `pending` > `warn` > `pass` > `skip`
121///
122/// # Examples
123///
124/// ```
125/// use tokmd_envelope::Verdict;
126///
127/// let v = Verdict::default();
128/// assert_eq!(v, Verdict::Pass);
129/// assert_eq!(format!("{v}"), "pass");
130/// ```
131#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
132#[serde(rename_all = "lowercase")]
133pub enum Verdict {
134    /// All checks passed, no significant findings.
135    #[default]
136    Pass,
137    /// Hard failure (evidence gate failed, policy violation).
138    Fail,
139    /// Soft warnings present, review recommended.
140    Warn,
141    /// Sensor skipped (missing inputs, not applicable).
142    Skip,
143    /// Awaiting external data (CI artifacts, etc.).
144    Pending,
145}
146
147// --------------------------
148// Builder/helper methods
149// --------------------------
150
151impl SensorReport {
152    /// Create a new sensor report with the current version.
153    ///
154    /// # Examples
155    ///
156    /// ```
157    /// use tokmd_envelope::{SensorReport, ToolMeta, Verdict, Finding, FindingSeverity};
158    ///
159    /// let mut report = SensorReport::new(
160    ///     ToolMeta::tokmd("1.5.0", "analyze"),
161    ///     "2024-06-01T12:00:00Z".to_string(),
162    ///     Verdict::Warn,
163    ///     "Risk hotspots detected".to_string(),
164    /// );
165    /// report.add_finding(Finding::new(
166    ///     "risk", "hotspot",
167    ///     FindingSeverity::Warn,
168    ///     "High-churn file",
169    ///     "src/lib.rs modified frequently",
170    /// ));
171    /// assert_eq!(report.findings.len(), 1);
172    /// ```
173    pub fn new(tool: ToolMeta, generated_at: String, verdict: Verdict, summary: String) -> Self {
174        Self {
175            schema: SENSOR_REPORT_SCHEMA.to_string(),
176            tool,
177            generated_at,
178            verdict,
179            summary,
180            findings: Vec::new(),
181            artifacts: None,
182            capabilities: None,
183            data: None,
184        }
185    }
186
187    /// Add a finding to the report.
188    pub fn add_finding(&mut self, finding: Finding) {
189        self.findings.push(finding);
190    }
191
192    /// Set the artifacts section.
193    pub fn with_artifacts(mut self, artifacts: Vec<Artifact>) -> Self {
194        self.artifacts = Some(artifacts);
195        self
196    }
197
198    /// Set the data payload.
199    pub fn with_data(mut self, data: serde_json::Value) -> Self {
200        self.data = Some(data);
201        self
202    }
203
204    /// Set the capabilities section for "No Green By Omission".
205    pub fn with_capabilities(
206        mut self,
207        capabilities: std::collections::BTreeMap<String, CapabilityStatus>,
208    ) -> Self {
209        self.capabilities = Some(capabilities);
210        self
211    }
212
213    /// Add a single capability to the report.
214    pub fn add_capability(&mut self, name: impl Into<String>, status: CapabilityStatus) {
215        self.capabilities
216            .get_or_insert_with(std::collections::BTreeMap::new)
217            .insert(name.into(), status);
218    }
219}
220
221impl ToolMeta {
222    /// Create a new tool identifier.
223    pub fn new(name: &str, version: &str, mode: &str) -> Self {
224        Self {
225            name: name.to_string(),
226            version: version.to_string(),
227            mode: mode.to_string(),
228        }
229    }
230
231    /// Create a tool identifier for tokmd.
232    pub fn tokmd(version: &str, mode: &str) -> Self {
233        Self::new("tokmd", version, mode)
234    }
235}
236
237impl std::fmt::Display for Verdict {
238    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
239        match self {
240            Verdict::Pass => write!(f, "pass"),
241            Verdict::Fail => write!(f, "fail"),
242            Verdict::Warn => write!(f, "warn"),
243            Verdict::Skip => write!(f, "skip"),
244            Verdict::Pending => write!(f, "pending"),
245        }
246    }
247}
248
249#[cfg(test)]
250mod tests {
251    use super::*;
252
253    #[test]
254    fn serde_roundtrip_sensor_report() {
255        let report = SensorReport::new(
256            ToolMeta::tokmd("1.5.0", "cockpit"),
257            "2024-01-01T00:00:00Z".to_string(),
258            Verdict::Pass,
259            "All checks passed".to_string(),
260        );
261        let json = serde_json::to_string(&report).unwrap();
262        let back: SensorReport = serde_json::from_str(&json).unwrap();
263        assert_eq!(back.schema, SENSOR_REPORT_SCHEMA);
264        assert_eq!(back.verdict, Verdict::Pass);
265        assert_eq!(back.tool.name, "tokmd");
266    }
267
268    #[test]
269    fn serde_roundtrip_with_findings() {
270        let mut report = SensorReport::new(
271            ToolMeta::tokmd("1.5.0", "cockpit"),
272            "2024-01-01T00:00:00Z".to_string(),
273            Verdict::Warn,
274            "Risk hotspots detected".to_string(),
275        );
276        report.add_finding(
277            Finding::new(
278                findings::risk::CHECK_ID,
279                findings::risk::HOTSPOT,
280                FindingSeverity::Warn,
281                "High-churn file",
282                "src/lib.rs has been modified 42 times",
283            )
284            .with_location(FindingLocation::path("src/lib.rs")),
285        );
286        let json = serde_json::to_string(&report).unwrap();
287        let back: SensorReport = serde_json::from_str(&json).unwrap();
288        assert_eq!(back.findings.len(), 1);
289        assert_eq!(back.findings[0].check_id, "risk");
290        assert_eq!(back.findings[0].code, "hotspot");
291
292        // Verify finding_id composition
293        let fid = findings::finding_id("tokmd", findings::risk::CHECK_ID, findings::risk::HOTSPOT);
294        assert_eq!(fid, "tokmd.risk.hotspot");
295    }
296
297    #[test]
298    fn serde_roundtrip_with_gates_in_data() {
299        let gates = GateResults::new(
300            Verdict::Fail,
301            vec![
302                GateItem::new("mutation", Verdict::Fail)
303                    .with_threshold(80.0, 72.0)
304                    .with_reason("Below threshold"),
305            ],
306        );
307        let report = SensorReport::new(
308            ToolMeta::tokmd("1.5.0", "cockpit"),
309            "2024-01-01T00:00:00Z".to_string(),
310            Verdict::Fail,
311            "Gate failed".to_string(),
312        )
313        .with_data(serde_json::json!({
314            "gates": serde_json::to_value(gates).unwrap(),
315        }));
316        let json = serde_json::to_string(&report).unwrap();
317        let back: SensorReport = serde_json::from_str(&json).unwrap();
318        let data = back.data.unwrap();
319        let back_gates: GateResults = serde_json::from_value(data["gates"].clone()).unwrap();
320        assert_eq!(back_gates.items[0].id, "mutation");
321        assert_eq!(back_gates.status, Verdict::Fail);
322    }
323
324    #[test]
325    fn verdict_default_is_pass() {
326        assert_eq!(Verdict::default(), Verdict::Pass);
327    }
328
329    #[test]
330    fn schema_field_contains_string_identifier() {
331        let report = SensorReport::new(
332            ToolMeta::tokmd("1.5.0", "test"),
333            "2024-01-01T00:00:00Z".to_string(),
334            Verdict::Pass,
335            "test".to_string(),
336        );
337        let json = serde_json::to_string(&report).unwrap();
338        assert!(json.contains("\"schema\""));
339        assert!(json.contains("sensor.report.v1"));
340    }
341
342    #[test]
343    fn verdict_display_matches_serde() {
344        for (variant, expected) in [
345            (Verdict::Pass, "pass"),
346            (Verdict::Fail, "fail"),
347            (Verdict::Warn, "warn"),
348            (Verdict::Skip, "skip"),
349            (Verdict::Pending, "pending"),
350        ] {
351            assert_eq!(variant.to_string(), expected);
352            let json = serde_json::to_value(variant).unwrap();
353            assert_eq!(json.as_str().unwrap(), expected);
354        }
355    }
356
357    #[test]
358    fn sensor_report_with_capabilities() {
359        use std::collections::BTreeMap;
360
361        let mut caps = BTreeMap::new();
362        caps.insert("mutation".to_string(), CapabilityStatus::available());
363        caps.insert(
364            "coverage".to_string(),
365            CapabilityStatus::unavailable("no coverage artifact"),
366        );
367        caps.insert(
368            "semver".to_string(),
369            CapabilityStatus::skipped("no API files changed"),
370        );
371
372        let report = SensorReport::new(
373            ToolMeta::tokmd("1.5.0", "cockpit"),
374            "2024-01-01T00:00:00Z".to_string(),
375            Verdict::Pass,
376            "All checks passed".to_string(),
377        )
378        .with_capabilities(caps);
379
380        let json = serde_json::to_string(&report).unwrap();
381        assert!(json.contains("\"capabilities\""));
382        assert!(json.contains("\"mutation\""));
383        assert!(json.contains("\"available\""));
384
385        let back: SensorReport = serde_json::from_str(&json).unwrap();
386        let caps = back.capabilities.unwrap();
387        assert_eq!(caps.len(), 3);
388        assert_eq!(caps["mutation"].status, CapabilityState::Available);
389        assert_eq!(caps["coverage"].status, CapabilityState::Unavailable);
390        assert_eq!(caps["semver"].status, CapabilityState::Skipped);
391    }
392
393    #[test]
394    fn sensor_report_add_capability() {
395        let mut report = SensorReport::new(
396            ToolMeta::tokmd("1.5.0", "cockpit"),
397            "2024-01-01T00:00:00Z".to_string(),
398            Verdict::Pass,
399            "All checks passed".to_string(),
400        );
401        report.add_capability("mutation", CapabilityStatus::available());
402        report.add_capability("coverage", CapabilityStatus::unavailable("missing"));
403
404        let caps = report.capabilities.unwrap();
405        assert_eq!(caps.len(), 2);
406    }
407
408    #[test]
409    fn sensor_report_with_artifacts_and_data() {
410        let artifact = Artifact::comment("out/comment.md")
411            .with_id("commentary")
412            .with_mime("text/markdown");
413        let report = SensorReport::new(
414            ToolMeta::tokmd("1.5.0", "cockpit"),
415            "2024-01-01T00:00:00Z".to_string(),
416            Verdict::Pass,
417            "Artifacts attached".to_string(),
418        )
419        .with_artifacts(vec![artifact.clone()])
420        .with_data(serde_json::json!({ "key": "value" }));
421
422        let artifacts = report.artifacts.as_ref().unwrap();
423        assert_eq!(artifacts.len(), 1);
424        assert_eq!(artifacts[0].artifact_type, "comment");
425        assert_eq!(artifacts[0].id.as_deref(), Some("commentary"));
426        assert_eq!(artifacts[0].mime.as_deref(), Some("text/markdown"));
427        assert_eq!(report.data.as_ref().unwrap()["key"], "value");
428    }
429}