1use std::collections::HashMap;
7
8use diffguard_types::{ConfigFile, SensorReport};
9
10use crate::check::{CheckPlan, run_check};
11use crate::sensor::{RuleMetadata, SensorReportContext, render_sensor_report};
12
13pub struct Settings {
17 pub config: ConfigFile,
19 pub plan: CheckPlan,
21 pub diff_text: String,
23 pub context: SensorReportContext,
25}
26
27pub trait Substrate {
32 fn changed_files(&self) -> Option<&[String]> {
34 None
35 }
36 fn repo_root(&self) -> Option<&std::path::Path> {
38 None
39 }
40 fn metadata(&self) -> Option<&serde_json::Value> {
42 None
43 }
44}
45
46pub fn run_sensor(
51 settings: &Settings,
52 substrate: Option<&dyn Substrate>,
53) -> Result<SensorReport, anyhow::Error> {
54 let _ = substrate;
56 let check_run = run_check(&settings.plan, &settings.config, &settings.diff_text)?;
57
58 let rule_metadata = extract_rule_metadata(&settings.config);
60 let ctx = SensorReportContext {
61 rule_metadata,
62 truncated_count: check_run.truncated_findings,
63 rules_total: check_run.rules_evaluated,
64 ..settings.context.clone()
65 };
66
67 Ok(render_sensor_report(&check_run.receipt, &ctx))
69}
70
71fn extract_rule_metadata(config: &ConfigFile) -> HashMap<String, RuleMetadata> {
73 config
74 .rule
75 .iter()
76 .map(|r| {
77 (
78 r.id.clone(),
79 RuleMetadata {
80 help: r.help.clone(),
81 url: r.url.clone(),
82 tags: r.tags.clone(),
83 },
84 )
85 })
86 .collect()
87}
88
89#[cfg(test)]
90mod tests {
91 use super::*;
92 use diffguard_types::{
93 CAP_GIT, CAP_STATUS_AVAILABLE, CapabilityStatus, FailOn, RuleConfig,
94 SENSOR_REPORT_SCHEMA_V1, Scope, Severity,
95 };
96 use std::collections::HashMap;
97
98 fn test_config() -> ConfigFile {
99 ConfigFile {
100 includes: vec![],
101 defaults: diffguard_types::Defaults::default(),
102 rule: vec![RuleConfig {
103 id: "test.rule".to_string(),
104 severity: Severity::Warn,
105 message: "Test match".to_string(),
106 languages: vec![],
107 patterns: vec!["test_pattern".to_string()],
108 paths: vec![],
109 exclude_paths: vec![],
110 ignore_comments: false,
111 ignore_strings: false,
112 match_mode: Default::default(),
113 multiline: false,
114 multiline_window: None,
115 context_patterns: vec![],
116 context_window: None,
117 escalate_patterns: vec![],
118 escalate_window: None,
119 escalate_to: None,
120 depends_on: vec![],
121 help: Some("Fix the test pattern".to_string()),
122 url: Some("https://example.com/help".to_string()),
123 tags: vec![],
124 test_cases: vec![],
125 }],
126 }
127 }
128
129 fn test_plan() -> CheckPlan {
130 CheckPlan {
131 base: "origin/main".to_string(),
132 head: "HEAD".to_string(),
133 scope: Scope::Added,
134 diff_context: 0,
135 fail_on: FailOn::Error,
136 max_findings: 100,
137 path_filters: vec![],
138 only_tags: vec![],
139 enable_tags: vec![],
140 disable_tags: vec![],
141 directory_overrides: vec![],
142 force_language: None,
143 allowed_lines: None,
144 false_positive_fingerprints: std::collections::BTreeSet::new(),
145 }
146 }
147
148 fn test_context() -> SensorReportContext {
149 let mut capabilities = HashMap::new();
150 capabilities.insert(
151 CAP_GIT.to_string(),
152 CapabilityStatus {
153 status: CAP_STATUS_AVAILABLE.to_string(),
154 reason: None,
155 detail: None,
156 },
157 );
158 SensorReportContext {
159 started_at: "2024-01-15T10:30:00Z".to_string(),
160 ended_at: "2024-01-15T10:30:01Z".to_string(),
161 duration_ms: 1000,
162 capabilities,
163 artifacts: vec![],
164 rule_metadata: HashMap::new(),
165 truncated_count: 0,
166 rules_total: 0,
167 }
168 }
169
170 fn make_diff_with_finding() -> String {
171 "--- a/test.rs\n+++ b/test.rs\n@@ -0,0 +1 @@\n+let x = test_pattern();\n".to_string()
172 }
173
174 #[test]
175 fn run_sensor_returns_sensor_report() {
176 let settings = Settings {
177 config: test_config(),
178 plan: test_plan(),
179 diff_text: make_diff_with_finding(),
180 context: test_context(),
181 };
182
183 let report = run_sensor(&settings, None).unwrap();
184 assert_eq!(report.schema, SENSOR_REPORT_SCHEMA_V1);
185 assert_eq!(report.tool.name, "diffguard");
186 assert!(!report.findings.is_empty());
187 }
188
189 #[test]
190 fn run_sensor_with_no_substrate() {
191 let settings = Settings {
192 config: test_config(),
193 plan: test_plan(),
194 diff_text: String::new(),
195 context: test_context(),
196 };
197
198 let report = run_sensor(&settings, None).unwrap();
199 assert_eq!(report.schema, SENSOR_REPORT_SCHEMA_V1);
200 assert!(report.findings.is_empty());
201 }
202
203 #[test]
204 fn run_sensor_populates_rule_metadata() {
205 let settings = Settings {
206 config: test_config(),
207 plan: test_plan(),
208 diff_text: make_diff_with_finding(),
209 context: test_context(),
210 };
211
212 let report = run_sensor(&settings, None).unwrap();
213 let finding = &report.findings[0];
214 assert_eq!(finding.help.as_deref(), Some("Fix the test pattern"));
215 assert_eq!(finding.url.as_deref(), Some("https://example.com/help"));
216 }
217
218 #[test]
219 fn substrate_defaults_return_none() {
220 struct Dummy;
221 impl Substrate for Dummy {}
222
223 let dummy = Dummy;
224 assert!(dummy.changed_files().is_none());
225 assert!(dummy.repo_root().is_none());
226 assert!(dummy.metadata().is_none());
227 }
228
229 #[test]
230 fn run_sensor_preserves_timing_from_context() {
231 let settings = Settings {
232 config: test_config(),
233 plan: test_plan(),
234 diff_text: String::new(),
235 context: test_context(),
236 };
237
238 let report = run_sensor(&settings, None).unwrap();
239 assert_eq!(report.run.started_at, "2024-01-15T10:30:00Z");
240 assert_eq!(report.run.ended_at, "2024-01-15T10:30:01Z");
241 assert_eq!(report.run.duration_ms, 1000);
242 }
243
244 #[test]
245 fn run_sensor_propagates_check_error() {
246 let mut plan = test_plan();
247 plan.fail_on = FailOn::Error;
248
249 let settings = Settings {
250 config: ConfigFile {
251 includes: vec![],
252 defaults: diffguard_types::Defaults::default(),
253 rule: vec![RuleConfig {
254 id: "bad.rule".to_string(),
255 severity: Severity::Error,
256 message: "Bad pattern".to_string(),
257 languages: vec![],
258 patterns: vec!["[invalid".to_string()],
260 paths: vec![],
261 exclude_paths: vec![],
262 ignore_comments: false,
263 ignore_strings: false,
264 match_mode: Default::default(),
265 multiline: false,
266 multiline_window: None,
267 context_patterns: vec![],
268 context_window: None,
269 escalate_patterns: vec![],
270 escalate_window: None,
271 escalate_to: None,
272 depends_on: vec![],
273 help: None,
274 url: None,
275 tags: vec![],
276 test_cases: vec![],
277 }],
278 },
279 plan,
280 diff_text: make_diff_with_finding(),
281 context: test_context(),
282 };
283
284 let result = run_sensor(&settings, None);
285 assert!(result.is_err());
286 }
287
288 #[test]
289 fn extract_rule_metadata_maps_config_rules() {
290 let config = test_config();
291 let meta = extract_rule_metadata(&config);
292 assert!(meta.contains_key("test.rule"));
293 let entry = &meta["test.rule"];
294 assert_eq!(entry.help.as_deref(), Some("Fix the test pattern"));
295 assert_eq!(entry.url.as_deref(), Some("https://example.com/help"));
296 }
297}