Skip to main content

diffguard_core/
sensor.rs

1//! Sensor report rendering for Cockpit ecosystem integration.
2//!
3//! This module converts CheckReceipt to the `sensor.report.v1` format.
4
5use std::collections::{BTreeMap, HashMap};
6
7use diffguard_types::{
8    Artifact, CHECK_ID_PATTERN, CapabilityStatus, CheckReceipt, RunMeta, SENSOR_REPORT_SCHEMA_V1,
9    SensorFinding, SensorLocation, SensorReport,
10};
11
12use crate::fingerprint::compute_fingerprint;
13
14/// Context for rendering a sensor report.
15#[derive(Debug, Clone, Default)]
16pub struct SensorReportContext {
17    /// ISO 8601 timestamp when the run started.
18    pub started_at: String,
19    /// ISO 8601 timestamp when the run ended.
20    pub ended_at: String,
21    /// Duration in milliseconds.
22    pub duration_ms: u64,
23    /// Capability status (e.g., git availability).
24    pub capabilities: HashMap<String, CapabilityStatus>,
25    /// List of artifacts produced.
26    pub artifacts: Vec<Artifact>,
27    /// Rule metadata for help/url lookup.
28    pub rule_metadata: HashMap<String, RuleMetadata>,
29    /// Number of findings beyond max_findings that were dropped.
30    pub truncated_count: u32,
31    /// Total number of rules evaluated.
32    pub rules_total: usize,
33}
34
35/// Metadata for a rule (help text, URL, and tags).
36#[derive(Debug, Clone, Default)]
37pub struct RuleMetadata {
38    pub help: Option<String>,
39    pub url: Option<String>,
40    pub tags: Vec<String>,
41}
42
43/// Renders a CheckReceipt as a SensorReport.
44pub fn render_sensor_report(receipt: &CheckReceipt, ctx: &SensorReportContext) -> SensorReport {
45    let findings = receipt
46        .findings
47        .iter()
48        .map(|f| {
49            let metadata = ctx.rule_metadata.get(&f.rule_id);
50            SensorFinding {
51                check_id: CHECK_ID_PATTERN.to_string(),
52                code: f.rule_id.clone(),
53                severity: f.severity,
54                message: f.message.clone(),
55                location: SensorLocation {
56                    path: normalize_path(&f.path),
57                    line: f.line,
58                    column: f.column,
59                },
60                fingerprint: compute_fingerprint(f),
61                help: metadata.and_then(|m| m.help.clone()),
62                url: metadata.and_then(|m| m.url.clone()),
63                data: Some(serde_json::json!({
64                    "match_text": f.match_text,
65                    "snippet": f.snippet,
66                })),
67            }
68        })
69        .collect();
70
71    // Count distinct rule_ids across findings
72    let rules_matched = {
73        let mut seen = std::collections::BTreeSet::new();
74        for f in &receipt.findings {
75            seen.insert(&f.rule_id);
76        }
77        seen.len()
78    };
79
80    // Count findings per matched rule tag (BTreeMap for deterministic key ordering)
81    let tags_matched: BTreeMap<String, u32> = {
82        let mut counts = BTreeMap::new();
83        for f in &receipt.findings {
84            if let Some(meta) = ctx.rule_metadata.get(&f.rule_id) {
85                for tag in &meta.tags {
86                    *counts.entry(tag.clone()).or_insert(0) += 1;
87                }
88            }
89        }
90        counts
91    };
92
93    let mut diffguard_data = serde_json::json!({
94        "suppressed_count": receipt.verdict.counts.suppressed,
95        "truncated_count": ctx.truncated_count,
96        "rules_matched": rules_matched,
97        "rules_total": ctx.rules_total,
98    });
99
100    if !tags_matched.is_empty() {
101        diffguard_data["tags_matched"] =
102            serde_json::to_value(&tags_matched).expect("serialize tags_matched");
103    }
104
105    let data = serde_json::json!({
106        "diff": {
107            "base": receipt.diff.base,
108            "head": receipt.diff.head,
109            "context_lines": receipt.diff.context_lines,
110            "scope": receipt.diff.scope,
111            "files_scanned": receipt.diff.files_scanned,
112            "lines_scanned": receipt.diff.lines_scanned,
113        },
114        "diffguard": diffguard_data,
115    });
116
117    SensorReport {
118        schema: SENSOR_REPORT_SCHEMA_V1.to_string(),
119        tool: receipt.tool.clone(),
120        run: RunMeta {
121            started_at: ctx.started_at.clone(),
122            ended_at: ctx.ended_at.clone(),
123            duration_ms: ctx.duration_ms,
124            capabilities: ctx.capabilities.clone(),
125        },
126        verdict: receipt.verdict.clone(),
127        findings,
128        artifacts: ctx.artifacts.clone(),
129        data: Some(data),
130    }
131}
132
133/// Renders a CheckReceipt as a sensor.report.v1 JSON string.
134pub fn render_sensor_json(
135    receipt: &CheckReceipt,
136    ctx: &SensorReportContext,
137) -> Result<String, serde_json::Error> {
138    let report = render_sensor_report(receipt, ctx);
139    serde_json::to_string_pretty(&report)
140}
141
142/// Normalizes a path to use forward slashes (for cross-platform consistency).
143fn normalize_path(path: &str) -> String {
144    path.replace('\\', "/")
145}
146
147#[cfg(test)]
148mod tests {
149    use super::*;
150    use diffguard_types::{
151        CAP_GIT, CAP_STATUS_UNAVAILABLE, DiffMeta, Finding, REASON_GIT_UNAVAILABLE, Scope,
152        Severity, ToolMeta, Verdict, VerdictCounts, VerdictStatus,
153    };
154
155    fn test_receipt() -> CheckReceipt {
156        CheckReceipt {
157            schema: diffguard_types::CHECK_SCHEMA_V1.to_string(),
158            tool: ToolMeta {
159                name: "diffguard".to_string(),
160                version: "0.1.0".to_string(),
161            },
162            diff: DiffMeta {
163                base: "origin/main".to_string(),
164                head: "HEAD".to_string(),
165                context_lines: 0,
166                scope: Scope::Added,
167                files_scanned: 2,
168                lines_scanned: 50,
169            },
170            findings: vec![Finding {
171                rule_id: "rust.no_unwrap".to_string(),
172                severity: Severity::Error,
173                message: "Avoid unwrap".to_string(),
174                path: "src/lib.rs".to_string(),
175                line: 42,
176                column: Some(10),
177                match_text: ".unwrap()".to_string(),
178                snippet: "let x = foo.unwrap();".to_string(),
179            }],
180            verdict: Verdict {
181                status: VerdictStatus::Fail,
182                counts: VerdictCounts {
183                    info: 0,
184                    warn: 0,
185                    error: 1,
186                    suppressed: 0,
187                },
188                reasons: vec![],
189            },
190            timing: None,
191        }
192    }
193
194    fn test_context() -> SensorReportContext {
195        let mut ctx = SensorReportContext {
196            started_at: "2024-01-15T10:30:00Z".to_string(),
197            ended_at: "2024-01-15T10:30:01Z".to_string(),
198            duration_ms: 1234,
199            capabilities: HashMap::new(),
200            artifacts: vec![Artifact {
201                path: "artifacts/diffguard/report.json".to_string(),
202                format: "json".to_string(),
203            }],
204            rule_metadata: HashMap::new(),
205            truncated_count: 0,
206            rules_total: 5,
207        };
208        ctx.capabilities.insert(
209            "git".to_string(),
210            CapabilityStatus {
211                status: "available".to_string(),
212                reason: None,
213                detail: None,
214            },
215        );
216        ctx.rule_metadata.insert(
217            "rust.no_unwrap".to_string(),
218            RuleMetadata {
219                help: Some("Use ? operator instead".to_string()),
220                url: Some(
221                    "https://doc.rust-lang.org/book/ch09-02-recoverable-errors-with-result.html"
222                        .to_string(),
223                ),
224                tags: vec!["safety".to_string()],
225            },
226        );
227        ctx
228    }
229
230    #[test]
231    fn sensor_report_has_correct_schema() {
232        let receipt = test_receipt();
233        let ctx = test_context();
234        let report = render_sensor_report(&receipt, &ctx);
235        assert_eq!(report.schema, "sensor.report.v1");
236    }
237
238    #[test]
239    fn sensor_report_preserves_tool_meta() {
240        let receipt = test_receipt();
241        let ctx = test_context();
242        let report = render_sensor_report(&receipt, &ctx);
243        assert_eq!(report.tool.name, "diffguard");
244        assert_eq!(report.tool.version, "0.1.0");
245    }
246
247    #[test]
248    fn sensor_report_includes_run_meta() {
249        let receipt = test_receipt();
250        let ctx = test_context();
251        let report = render_sensor_report(&receipt, &ctx);
252        assert_eq!(report.run.started_at, "2024-01-15T10:30:00Z");
253        assert_eq!(report.run.ended_at, "2024-01-15T10:30:01Z");
254        assert_eq!(report.run.duration_ms, 1234);
255        assert!(report.run.capabilities.contains_key("git"));
256    }
257
258    #[test]
259    fn sensor_finding_has_correct_check_id() {
260        let receipt = test_receipt();
261        let ctx = test_context();
262        let report = render_sensor_report(&receipt, &ctx);
263        assert_eq!(report.findings[0].check_id, "diffguard.pattern");
264    }
265
266    #[test]
267    fn sensor_finding_maps_rule_id_to_code() {
268        let receipt = test_receipt();
269        let ctx = test_context();
270        let report = render_sensor_report(&receipt, &ctx);
271        assert_eq!(report.findings[0].code, "rust.no_unwrap");
272    }
273
274    #[test]
275    fn sensor_finding_has_fingerprint() {
276        let receipt = test_receipt();
277        let ctx = test_context();
278        let report = render_sensor_report(&receipt, &ctx);
279        assert_eq!(report.findings[0].fingerprint.len(), 64);
280    }
281
282    #[test]
283    fn sensor_finding_includes_help_and_url() {
284        let receipt = test_receipt();
285        let ctx = test_context();
286        let report = render_sensor_report(&receipt, &ctx);
287        assert!(report.findings[0].help.is_some());
288        assert!(report.findings[0].url.is_some());
289    }
290
291    #[test]
292    fn sensor_finding_includes_data() {
293        let receipt = test_receipt();
294        let ctx = test_context();
295        let report = render_sensor_report(&receipt, &ctx);
296        let data = report.findings[0].data.as_ref().unwrap();
297        assert_eq!(data["match_text"], ".unwrap()");
298        assert_eq!(data["snippet"], "let x = foo.unwrap();");
299    }
300
301    #[test]
302    fn sensor_report_includes_diff_data() {
303        let receipt = test_receipt();
304        let ctx = test_context();
305        let report = render_sensor_report(&receipt, &ctx);
306        let data = report.data.as_ref().unwrap();
307        assert_eq!(data["diff"]["base"], "origin/main");
308        assert_eq!(data["diff"]["head"], "HEAD");
309    }
310
311    #[test]
312    fn sensor_report_includes_tags_matched() {
313        let receipt = test_receipt();
314        let ctx = test_context();
315        let report = render_sensor_report(&receipt, &ctx);
316        let data = report.data.as_ref().unwrap();
317        let tags = data["diffguard"]["tags_matched"]
318            .as_object()
319            .expect("tags_matched");
320        assert_eq!(tags["safety"].as_u64(), Some(1));
321    }
322
323    #[test]
324    fn sensor_report_omits_tags_matched_when_metadata_missing() {
325        let mut receipt = test_receipt();
326        receipt.findings[0].rule_id = "missing.rule".to_string();
327
328        let mut ctx = test_context();
329        ctx.rule_metadata.clear();
330
331        let report = render_sensor_report(&receipt, &ctx);
332        let data = report.data.as_ref().unwrap();
333        let diffguard = data
334            .get("diffguard")
335            .and_then(|v| v.as_object())
336            .expect("diffguard data");
337        assert!(!diffguard.contains_key("tags_matched"));
338    }
339
340    #[test]
341    fn normalize_path_converts_backslashes() {
342        assert_eq!(normalize_path(r"src\lib.rs"), "src/lib.rs");
343        assert_eq!(normalize_path(r"src\nested\file.rs"), "src/nested/file.rs");
344        assert_eq!(normalize_path("src/lib.rs"), "src/lib.rs");
345    }
346
347    #[test]
348    fn snapshot_sensor_report_with_findings() {
349        let receipt = test_receipt();
350        let ctx = test_context();
351        let json = render_sensor_json(&receipt, &ctx).unwrap();
352        insta::assert_snapshot!(json);
353    }
354
355    #[test]
356    fn snapshot_sensor_report_no_findings() {
357        let mut receipt = test_receipt();
358        receipt.findings = vec![];
359        receipt.verdict = Verdict {
360            status: VerdictStatus::Pass,
361            counts: VerdictCounts::default(),
362            reasons: vec![],
363        };
364        let ctx = test_context();
365        let json = render_sensor_json(&receipt, &ctx).unwrap();
366        insta::assert_snapshot!(json);
367    }
368
369    #[test]
370    fn snapshot_sensor_report_skip_status() {
371        let mut receipt = test_receipt();
372        receipt.findings = vec![];
373        receipt.verdict = Verdict {
374            status: VerdictStatus::Skip,
375            counts: VerdictCounts::default(),
376            reasons: vec![REASON_GIT_UNAVAILABLE.to_string()],
377        };
378        let mut ctx = test_context();
379        ctx.capabilities.insert(
380            CAP_GIT.to_string(),
381            CapabilityStatus {
382                status: CAP_STATUS_UNAVAILABLE.to_string(),
383                reason: Some(REASON_GIT_UNAVAILABLE.to_string()),
384                detail: Some("git command not found".to_string()),
385            },
386        );
387        let json = render_sensor_json(&receipt, &ctx).unwrap();
388        insta::assert_snapshot!(json);
389    }
390}