1mod 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
34pub const SENSOR_REPORT_SCHEMA: &str = "sensor.report.v1";
37
38#[derive(Debug, Clone, Serialize, Deserialize)]
66pub struct SensorReport {
67 pub schema: String,
69 pub tool: ToolMeta,
71 pub generated_at: String,
73 pub verdict: Verdict,
75 pub summary: String,
77 pub findings: Vec<Finding>,
79 #[serde(skip_serializing_if = "Option::is_none")]
81 pub artifacts: Option<Vec<Artifact>>,
82 #[serde(skip_serializing_if = "Option::is_none")]
87 pub capabilities: Option<std::collections::BTreeMap<String, CapabilityStatus>>,
88 #[serde(skip_serializing_if = "Option::is_none")]
90 pub data: Option<serde_json::Value>,
91}
92
93#[derive(Debug, Clone, Serialize, Deserialize)]
109pub struct ToolMeta {
110 pub name: String,
112 pub version: String,
114 pub mode: String,
116}
117
118#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
132#[serde(rename_all = "lowercase")]
133pub enum Verdict {
134 #[default]
136 Pass,
137 Fail,
139 Warn,
141 Skip,
143 Pending,
145}
146
147impl SensorReport {
152 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 pub fn add_finding(&mut self, finding: Finding) {
189 self.findings.push(finding);
190 }
191
192 pub fn with_artifacts(mut self, artifacts: Vec<Artifact>) -> Self {
194 self.artifacts = Some(artifacts);
195 self
196 }
197
198 pub fn with_data(mut self, data: serde_json::Value) -> Self {
200 self.data = Some(data);
201 self
202 }
203
204 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 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 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 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 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}