1pub mod findings;
20
21use serde::{Deserialize, Serialize};
22
23pub const SENSOR_REPORT_SCHEMA: &str = "sensor.report.v1";
26
27#[derive(Debug, Clone, Serialize, Deserialize)]
39pub struct SensorReport {
40 pub schema: String,
42 pub tool: ToolMeta,
44 pub generated_at: String,
46 pub verdict: Verdict,
48 pub summary: String,
50 pub findings: Vec<Finding>,
52 #[serde(skip_serializing_if = "Option::is_none")]
54 pub artifacts: Option<Vec<Artifact>>,
55 #[serde(skip_serializing_if = "Option::is_none")]
60 pub capabilities: Option<std::collections::BTreeMap<String, CapabilityStatus>>,
61 #[serde(skip_serializing_if = "Option::is_none")]
63 pub data: Option<serde_json::Value>,
64}
65
66#[derive(Debug, Clone, Serialize, Deserialize)]
68pub struct ToolMeta {
69 pub name: String,
71 pub version: String,
73 pub mode: String,
75}
76
77#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
81#[serde(rename_all = "lowercase")]
82pub enum Verdict {
83 #[default]
85 Pass,
86 Fail,
88 Warn,
90 Skip,
92 Pending,
94}
95
96#[derive(Debug, Clone, Serialize, Deserialize)]
102pub struct Finding {
103 pub check_id: String,
105 pub code: String,
107 pub severity: FindingSeverity,
109 pub title: String,
111 pub message: String,
113 #[serde(skip_serializing_if = "Option::is_none")]
115 pub location: Option<FindingLocation>,
116 #[serde(skip_serializing_if = "Option::is_none")]
118 pub evidence: Option<serde_json::Value>,
119 #[serde(skip_serializing_if = "Option::is_none")]
121 pub docs_url: Option<String>,
122 #[serde(skip_serializing_if = "Option::is_none")]
125 pub fingerprint: Option<String>,
126}
127
128#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
130#[serde(rename_all = "lowercase")]
131pub enum FindingSeverity {
132 Error,
134 Warn,
136 Info,
138}
139
140#[derive(Debug, Clone, Serialize, Deserialize)]
142pub struct FindingLocation {
143 pub path: String,
145 #[serde(skip_serializing_if = "Option::is_none")]
147 pub line: Option<u32>,
148 #[serde(skip_serializing_if = "Option::is_none")]
150 pub column: Option<u32>,
151}
152
153#[derive(Debug, Clone, Serialize, Deserialize)]
155pub struct GateResults {
156 pub status: Verdict,
158 pub items: Vec<GateItem>,
160}
161
162#[derive(Debug, Clone, Serialize, Deserialize)]
164pub struct GateItem {
165 pub id: String,
167 pub status: Verdict,
169 #[serde(skip_serializing_if = "Option::is_none")]
171 pub threshold: Option<f64>,
172 #[serde(skip_serializing_if = "Option::is_none")]
174 pub actual: Option<f64>,
175 #[serde(skip_serializing_if = "Option::is_none")]
177 pub reason: Option<String>,
178 #[serde(skip_serializing_if = "Option::is_none")]
180 pub source: Option<String>,
181 #[serde(skip_serializing_if = "Option::is_none")]
183 pub artifact_path: Option<String>,
184}
185
186#[derive(Debug, Clone, Serialize, Deserialize)]
188pub struct Artifact {
189 #[serde(skip_serializing_if = "Option::is_none")]
191 pub id: Option<String>,
192 #[serde(rename = "type")]
194 pub artifact_type: String,
195 pub path: String,
197 #[serde(skip_serializing_if = "Option::is_none")]
199 pub mime: Option<String>,
200}
201
202#[derive(Debug, Clone, Serialize, Deserialize)]
209pub struct CapabilityStatus {
210 pub status: CapabilityState,
212 #[serde(skip_serializing_if = "Option::is_none")]
214 pub reason: Option<String>,
215}
216
217#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
219#[serde(rename_all = "lowercase")]
220pub enum CapabilityState {
221 Available,
223 Unavailable,
225 Skipped,
227}
228
229impl CapabilityStatus {
230 pub fn new(status: CapabilityState) -> Self {
232 Self {
233 status,
234 reason: None,
235 }
236 }
237
238 pub fn available() -> Self {
240 Self::new(CapabilityState::Available)
241 }
242
243 pub fn unavailable(reason: impl Into<String>) -> Self {
245 Self {
246 status: CapabilityState::Unavailable,
247 reason: Some(reason.into()),
248 }
249 }
250
251 pub fn skipped(reason: impl Into<String>) -> Self {
253 Self {
254 status: CapabilityState::Skipped,
255 reason: Some(reason.into()),
256 }
257 }
258
259 pub fn with_reason(mut self, reason: impl Into<String>) -> Self {
261 self.reason = Some(reason.into());
262 self
263 }
264}
265
266impl SensorReport {
271 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 pub fn add_finding(&mut self, finding: Finding) {
288 self.findings.push(finding);
289 }
290
291 pub fn with_artifacts(mut self, artifacts: Vec<Artifact>) -> Self {
293 self.artifacts = Some(artifacts);
294 self
295 }
296
297 pub fn with_data(mut self, data: serde_json::Value) -> Self {
299 self.data = Some(data);
300 self
301 }
302
303 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 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 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 pub fn tokmd(version: &str, mode: &str) -> Self {
332 Self::new("tokmd", version, mode)
333 }
334}
335
336impl Finding {
337 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 pub fn with_location(mut self, location: FindingLocation) -> Self {
360 self.location = Some(location);
361 self
362 }
363
364 pub fn with_evidence(mut self, evidence: serde_json::Value) -> Self {
366 self.evidence = Some(evidence);
367 self
368 }
369
370 pub fn with_docs_url(mut self, url: impl Into<String>) -> Self {
372 self.docs_url = Some(url.into());
373 self
374 }
375
376 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 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 pub fn path(path: impl Into<String>) -> Self {
401 Self {
402 path: path.into(),
403 line: None,
404 column: None,
405 }
406 }
407
408 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 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 pub fn new(status: Verdict, items: Vec<GateItem>) -> Self {
452 Self { status, items }
453 }
454}
455
456impl GateItem {
457 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 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 pub fn with_reason(mut self, reason: impl Into<String>) -> Self {
479 self.reason = Some(reason.into());
480 self
481 }
482
483 pub fn with_source(mut self, source: impl Into<String>) -> Self {
485 self.source = Some(source.into());
486 self
487 }
488
489 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 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 pub fn comment(path: impl Into<String>) -> Self {
509 Self::new("comment", path)
510 }
511
512 pub fn receipt(path: impl Into<String>) -> Self {
514 Self::new("receipt", path)
515 }
516
517 pub fn badge(path: impl Into<String>) -> Self {
519 Self::new("badge", path)
520 }
521
522 pub fn with_id(mut self, id: impl Into<String>) -> Self {
524 self.id = Some(id.into());
525 self
526 }
527
528 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 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}