1use 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#[derive(Debug, Clone, Default)]
16pub struct SensorReportContext {
17 pub started_at: String,
19 pub ended_at: String,
21 pub duration_ms: u64,
23 pub capabilities: HashMap<String, CapabilityStatus>,
25 pub artifacts: Vec<Artifact>,
27 pub rule_metadata: HashMap<String, RuleMetadata>,
29 pub truncated_count: u32,
31 pub rules_total: usize,
33}
34
35#[derive(Debug, Clone, Default)]
37pub struct RuleMetadata {
38 pub help: Option<String>,
39 pub url: Option<String>,
40 pub tags: Vec<String>,
41}
42
43pub 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 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 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
133pub 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
142fn 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}