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)]
55pub struct SensorReport {
56 pub schema: String,
58 pub tool: ToolMeta,
60 pub generated_at: String,
62 pub verdict: Verdict,
64 pub summary: String,
66 pub findings: Vec<Finding>,
68 #[serde(skip_serializing_if = "Option::is_none")]
70 pub artifacts: Option<Vec<Artifact>>,
71 #[serde(skip_serializing_if = "Option::is_none")]
76 pub capabilities: Option<std::collections::BTreeMap<String, CapabilityStatus>>,
77 #[serde(skip_serializing_if = "Option::is_none")]
79 pub data: Option<serde_json::Value>,
80}
81
82#[derive(Debug, Clone, Serialize, Deserialize)]
98pub struct ToolMeta {
99 pub name: String,
101 pub version: String,
103 pub mode: String,
105}
106
107#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
121#[serde(rename_all = "lowercase")]
122pub enum Verdict {
123 #[default]
125 Pass,
126 Fail,
128 Warn,
130 Skip,
132 Pending,
134}
135
136#[derive(Debug, Clone, Serialize, Deserialize)]
159pub struct Finding {
160 pub check_id: String,
162 pub code: String,
164 pub severity: FindingSeverity,
166 pub title: String,
168 pub message: String,
170 #[serde(skip_serializing_if = "Option::is_none")]
172 pub location: Option<FindingLocation>,
173 #[serde(skip_serializing_if = "Option::is_none")]
175 pub evidence: Option<serde_json::Value>,
176 #[serde(skip_serializing_if = "Option::is_none")]
178 pub docs_url: Option<String>,
179 #[serde(skip_serializing_if = "Option::is_none")]
182 pub fingerprint: Option<String>,
183}
184
185#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
187#[serde(rename_all = "lowercase")]
188pub enum FindingSeverity {
189 Error,
191 Warn,
193 Info,
195}
196
197#[derive(Debug, Clone, Serialize, Deserialize)]
218pub struct FindingLocation {
219 pub path: String,
221 #[serde(skip_serializing_if = "Option::is_none")]
223 pub line: Option<u32>,
224 #[serde(skip_serializing_if = "Option::is_none")]
226 pub column: Option<u32>,
227}
228
229#[derive(Debug, Clone, Serialize, Deserialize)]
231pub struct GateResults {
232 pub status: Verdict,
234 pub items: Vec<GateItem>,
236}
237
238#[derive(Debug, Clone, Serialize, Deserialize)]
252pub struct GateItem {
253 pub id: String,
255 pub status: Verdict,
257 #[serde(skip_serializing_if = "Option::is_none")]
259 pub threshold: Option<f64>,
260 #[serde(skip_serializing_if = "Option::is_none")]
262 pub actual: Option<f64>,
263 #[serde(skip_serializing_if = "Option::is_none")]
265 pub reason: Option<String>,
266 #[serde(skip_serializing_if = "Option::is_none")]
268 pub source: Option<String>,
269 #[serde(skip_serializing_if = "Option::is_none")]
271 pub artifact_path: Option<String>,
272}
273
274#[derive(Debug, Clone, Serialize, Deserialize)]
288pub struct Artifact {
289 #[serde(skip_serializing_if = "Option::is_none")]
291 pub id: Option<String>,
292 #[serde(rename = "type")]
294 pub artifact_type: String,
295 pub path: String,
297 #[serde(skip_serializing_if = "Option::is_none")]
299 pub mime: Option<String>,
300}
301
302#[derive(Debug, Clone, Serialize, Deserialize)]
309pub struct CapabilityStatus {
310 pub status: CapabilityState,
312 #[serde(skip_serializing_if = "Option::is_none")]
314 pub reason: Option<String>,
315}
316
317#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
319#[serde(rename_all = "lowercase")]
320pub enum CapabilityState {
321 Available,
323 Unavailable,
325 Skipped,
327}
328
329impl CapabilityStatus {
330 pub fn new(status: CapabilityState) -> Self {
332 Self {
333 status,
334 reason: None,
335 }
336 }
337
338 pub fn available() -> Self {
340 Self::new(CapabilityState::Available)
341 }
342
343 pub fn unavailable(reason: impl Into<String>) -> Self {
345 Self {
346 status: CapabilityState::Unavailable,
347 reason: Some(reason.into()),
348 }
349 }
350
351 pub fn skipped(reason: impl Into<String>) -> Self {
353 Self {
354 status: CapabilityState::Skipped,
355 reason: Some(reason.into()),
356 }
357 }
358
359 pub fn with_reason(mut self, reason: impl Into<String>) -> Self {
361 self.reason = Some(reason.into());
362 self
363 }
364}
365
366impl SensorReport {
371 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 pub fn add_finding(&mut self, finding: Finding) {
408 self.findings.push(finding);
409 }
410
411 pub fn with_artifacts(mut self, artifacts: Vec<Artifact>) -> Self {
413 self.artifacts = Some(artifacts);
414 self
415 }
416
417 pub fn with_data(mut self, data: serde_json::Value) -> Self {
419 self.data = Some(data);
420 self
421 }
422
423 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 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 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 pub fn tokmd(version: &str, mode: &str) -> Self {
452 Self::new("tokmd", version, mode)
453 }
454}
455
456impl Finding {
457 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 pub fn with_location(mut self, location: FindingLocation) -> Self {
480 self.location = Some(location);
481 self
482 }
483
484 pub fn with_evidence(mut self, evidence: serde_json::Value) -> Self {
486 self.evidence = Some(evidence);
487 self
488 }
489
490 pub fn with_docs_url(mut self, url: impl Into<String>) -> Self {
492 self.docs_url = Some(url.into());
493 self
494 }
495
496 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 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 pub fn path(path: impl Into<String>) -> Self {
537 Self {
538 path: path.into(),
539 line: None,
540 column: None,
541 }
542 }
543
544 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 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 pub fn new(status: Verdict, items: Vec<GateItem>) -> Self {
588 Self { status, items }
589 }
590}
591
592impl GateItem {
593 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 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 pub fn with_reason(mut self, reason: impl Into<String>) -> Self {
615 self.reason = Some(reason.into());
616 self
617 }
618
619 pub fn with_source(mut self, source: impl Into<String>) -> Self {
621 self.source = Some(source.into());
622 self
623 }
624
625 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 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 pub fn comment(path: impl Into<String>) -> Self {
645 Self::new("comment", path)
646 }
647
648 pub fn receipt(path: impl Into<String>) -> Self {
650 Self::new("receipt", path)
651 }
652
653 pub fn badge(path: impl Into<String>) -> Self {
655 Self::new("badge", path)
656 }
657
658 pub fn with_id(mut self, id: impl Into<String>) -> Self {
660 self.id = Some(id.into());
661 self
662 }
663
664 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 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}