use std::collections::{BTreeMap, HashMap};
use diffguard_types::{
Artifact, CHECK_ID_PATTERN, CapabilityStatus, CheckReceipt, RunMeta, SENSOR_REPORT_SCHEMA_V1,
SensorFinding, SensorLocation, SensorReport,
};
use crate::fingerprint::compute_fingerprint;
#[derive(Debug, Clone, Default)]
pub struct SensorReportContext {
pub started_at: String,
pub ended_at: String,
pub duration_ms: u64,
pub capabilities: HashMap<String, CapabilityStatus>,
pub artifacts: Vec<Artifact>,
pub rule_metadata: HashMap<String, RuleMetadata>,
pub truncated_count: u32,
pub rules_total: usize,
}
#[derive(Debug, Clone, Default)]
pub struct RuleMetadata {
pub help: Option<String>,
pub url: Option<String>,
pub tags: Vec<String>,
}
pub fn render_sensor_report(receipt: &CheckReceipt, ctx: &SensorReportContext) -> SensorReport {
let findings = receipt
.findings
.iter()
.map(|f| {
let metadata = ctx.rule_metadata.get(&f.rule_id);
SensorFinding {
check_id: CHECK_ID_PATTERN.to_string(),
code: f.rule_id.clone(),
severity: f.severity,
message: f.message.clone(),
location: SensorLocation {
path: normalize_path(&f.path),
line: f.line,
column: f.column,
},
fingerprint: compute_fingerprint(f),
help: metadata.and_then(|m| m.help.clone()),
url: metadata.and_then(|m| m.url.clone()),
data: Some(serde_json::json!({
"match_text": f.match_text,
"snippet": f.snippet,
})),
}
})
.collect();
let rules_matched = {
let mut seen = std::collections::BTreeSet::new();
for f in &receipt.findings {
seen.insert(&f.rule_id);
}
seen.len()
};
let tags_matched: BTreeMap<String, u32> = {
let mut counts = BTreeMap::new();
for f in &receipt.findings {
if let Some(meta) = ctx.rule_metadata.get(&f.rule_id) {
for tag in &meta.tags {
*counts.entry(tag.clone()).or_insert(0) += 1;
}
}
}
counts
};
let mut diffguard_data = serde_json::json!({
"suppressed_count": receipt.verdict.counts.suppressed,
"truncated_count": ctx.truncated_count,
"rules_matched": rules_matched,
"rules_total": ctx.rules_total,
});
if !tags_matched.is_empty() {
diffguard_data["tags_matched"] =
serde_json::to_value(&tags_matched).expect("serialize tags_matched");
}
let data = serde_json::json!({
"diff": {
"base": receipt.diff.base,
"head": receipt.diff.head,
"context_lines": receipt.diff.context_lines,
"scope": receipt.diff.scope,
"files_scanned": receipt.diff.files_scanned,
"lines_scanned": receipt.diff.lines_scanned,
},
"diffguard": diffguard_data,
});
SensorReport {
schema: SENSOR_REPORT_SCHEMA_V1.to_string(),
tool: receipt.tool.clone(),
run: RunMeta {
started_at: ctx.started_at.clone(),
ended_at: ctx.ended_at.clone(),
duration_ms: ctx.duration_ms,
capabilities: ctx.capabilities.clone(),
},
verdict: receipt.verdict.clone(),
findings,
artifacts: ctx.artifacts.clone(),
data: Some(data),
}
}
pub fn render_sensor_json(
receipt: &CheckReceipt,
ctx: &SensorReportContext,
) -> Result<String, serde_json::Error> {
let report = render_sensor_report(receipt, ctx);
serde_json::to_string_pretty(&report)
}
fn normalize_path(path: &str) -> String {
path.replace('\\', "/")
}
#[cfg(test)]
mod tests {
use super::*;
use diffguard_types::{
CAP_GIT, CAP_STATUS_UNAVAILABLE, DiffMeta, Finding, REASON_GIT_UNAVAILABLE, Scope,
Severity, ToolMeta, Verdict, VerdictCounts, VerdictStatus,
};
fn test_receipt() -> CheckReceipt {
CheckReceipt {
schema: diffguard_types::CHECK_SCHEMA_V1.to_string(),
tool: ToolMeta {
name: "diffguard".to_string(),
version: "0.1.0".to_string(),
},
diff: DiffMeta {
base: "origin/main".to_string(),
head: "HEAD".to_string(),
context_lines: 0,
scope: Scope::Added,
files_scanned: 2,
lines_scanned: 50,
},
findings: vec![Finding {
rule_id: "rust.no_unwrap".to_string(),
severity: Severity::Error,
message: "Avoid unwrap".to_string(),
path: "src/lib.rs".to_string(),
line: 42,
column: Some(10),
match_text: ".unwrap()".to_string(),
snippet: "let x = foo.unwrap();".to_string(),
}],
verdict: Verdict {
status: VerdictStatus::Fail,
counts: VerdictCounts {
info: 0,
warn: 0,
error: 1,
suppressed: 0,
},
reasons: vec![],
},
timing: None,
}
}
fn test_context() -> SensorReportContext {
let mut ctx = SensorReportContext {
started_at: "2024-01-15T10:30:00Z".to_string(),
ended_at: "2024-01-15T10:30:01Z".to_string(),
duration_ms: 1234,
capabilities: HashMap::new(),
artifacts: vec![Artifact {
path: "artifacts/diffguard/report.json".to_string(),
format: "json".to_string(),
}],
rule_metadata: HashMap::new(),
truncated_count: 0,
rules_total: 5,
};
ctx.capabilities.insert(
"git".to_string(),
CapabilityStatus {
status: "available".to_string(),
reason: None,
detail: None,
},
);
ctx.rule_metadata.insert(
"rust.no_unwrap".to_string(),
RuleMetadata {
help: Some("Use ? operator instead".to_string()),
url: Some(
"https://doc.rust-lang.org/book/ch09-02-recoverable-errors-with-result.html"
.to_string(),
),
tags: vec!["safety".to_string()],
},
);
ctx
}
#[test]
fn sensor_report_has_correct_schema() {
let receipt = test_receipt();
let ctx = test_context();
let report = render_sensor_report(&receipt, &ctx);
assert_eq!(report.schema, "sensor.report.v1");
}
#[test]
fn sensor_report_preserves_tool_meta() {
let receipt = test_receipt();
let ctx = test_context();
let report = render_sensor_report(&receipt, &ctx);
assert_eq!(report.tool.name, "diffguard");
assert_eq!(report.tool.version, "0.1.0");
}
#[test]
fn sensor_report_includes_run_meta() {
let receipt = test_receipt();
let ctx = test_context();
let report = render_sensor_report(&receipt, &ctx);
assert_eq!(report.run.started_at, "2024-01-15T10:30:00Z");
assert_eq!(report.run.ended_at, "2024-01-15T10:30:01Z");
assert_eq!(report.run.duration_ms, 1234);
assert!(report.run.capabilities.contains_key("git"));
}
#[test]
fn sensor_finding_has_correct_check_id() {
let receipt = test_receipt();
let ctx = test_context();
let report = render_sensor_report(&receipt, &ctx);
assert_eq!(report.findings[0].check_id, "diffguard.pattern");
}
#[test]
fn sensor_finding_maps_rule_id_to_code() {
let receipt = test_receipt();
let ctx = test_context();
let report = render_sensor_report(&receipt, &ctx);
assert_eq!(report.findings[0].code, "rust.no_unwrap");
}
#[test]
fn sensor_finding_has_fingerprint() {
let receipt = test_receipt();
let ctx = test_context();
let report = render_sensor_report(&receipt, &ctx);
assert_eq!(report.findings[0].fingerprint.len(), 64);
}
#[test]
fn sensor_finding_includes_help_and_url() {
let receipt = test_receipt();
let ctx = test_context();
let report = render_sensor_report(&receipt, &ctx);
assert!(report.findings[0].help.is_some());
assert!(report.findings[0].url.is_some());
}
#[test]
fn sensor_finding_includes_data() {
let receipt = test_receipt();
let ctx = test_context();
let report = render_sensor_report(&receipt, &ctx);
let data = report.findings[0].data.as_ref().unwrap();
assert_eq!(data["match_text"], ".unwrap()");
assert_eq!(data["snippet"], "let x = foo.unwrap();");
}
#[test]
fn sensor_report_includes_diff_data() {
let receipt = test_receipt();
let ctx = test_context();
let report = render_sensor_report(&receipt, &ctx);
let data = report.data.as_ref().unwrap();
assert_eq!(data["diff"]["base"], "origin/main");
assert_eq!(data["diff"]["head"], "HEAD");
}
#[test]
fn sensor_report_includes_tags_matched() {
let receipt = test_receipt();
let ctx = test_context();
let report = render_sensor_report(&receipt, &ctx);
let data = report.data.as_ref().unwrap();
let tags = data["diffguard"]["tags_matched"]
.as_object()
.expect("tags_matched");
assert_eq!(tags["safety"].as_u64(), Some(1));
}
#[test]
fn sensor_report_omits_tags_matched_when_metadata_missing() {
let mut receipt = test_receipt();
receipt.findings[0].rule_id = "missing.rule".to_string();
let mut ctx = test_context();
ctx.rule_metadata.clear();
let report = render_sensor_report(&receipt, &ctx);
let data = report.data.as_ref().unwrap();
let diffguard = data
.get("diffguard")
.and_then(|v| v.as_object())
.expect("diffguard data");
assert!(!diffguard.contains_key("tags_matched"));
}
#[test]
fn normalize_path_converts_backslashes() {
assert_eq!(normalize_path(r"src\lib.rs"), "src/lib.rs");
assert_eq!(normalize_path(r"src\nested\file.rs"), "src/nested/file.rs");
assert_eq!(normalize_path("src/lib.rs"), "src/lib.rs");
}
#[test]
fn snapshot_sensor_report_with_findings() {
let receipt = test_receipt();
let ctx = test_context();
let json = render_sensor_json(&receipt, &ctx).unwrap();
insta::assert_snapshot!(json);
}
#[test]
fn snapshot_sensor_report_no_findings() {
let mut receipt = test_receipt();
receipt.findings = vec![];
receipt.verdict = Verdict {
status: VerdictStatus::Pass,
counts: VerdictCounts::default(),
reasons: vec![],
};
let ctx = test_context();
let json = render_sensor_json(&receipt, &ctx).unwrap();
insta::assert_snapshot!(json);
}
#[test]
fn snapshot_sensor_report_skip_status() {
let mut receipt = test_receipt();
receipt.findings = vec![];
receipt.verdict = Verdict {
status: VerdictStatus::Skip,
counts: VerdictCounts::default(),
reasons: vec![REASON_GIT_UNAVAILABLE.to_string()],
};
let mut ctx = test_context();
ctx.capabilities.insert(
CAP_GIT.to_string(),
CapabilityStatus {
status: CAP_STATUS_UNAVAILABLE.to_string(),
reason: Some(REASON_GIT_UNAVAILABLE.to_string()),
detail: Some("git command not found".to_string()),
},
);
let json = render_sensor_json(&receipt, &ctx).unwrap();
insta::assert_snapshot!(json);
}
}