Skip to main content

diffguard_core/
sensor_api.rs

1//! R2 Library Contract: `run_sensor()` entry point for Cockpit/BusyBox integration.
2//!
3//! This module provides the `Settings` + `Substrate` → `SensorReport` contract
4//! required by the Governance OS "Fleet Crate Tiering" specification.
5
6use 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
13/// Consolidated input for the diffguard sensor engine.
14///
15/// This is the R2 Library Contract entry point.
16pub struct Settings {
17    /// Parsed configuration (rules, defaults).
18    pub config: ConfigFile,
19    /// Check execution plan (refs, scope, fail_on, etc.).
20    pub plan: CheckPlan,
21    /// Raw unified diff text.
22    pub diff_text: String,
23    /// Sensor envelope context (timing, capabilities).
24    pub context: SensorReportContext,
25}
26
27/// Optional shared substrate from the Cockpit runtime.
28///
29/// Provides pre-computed inventory to avoid redundant scans.
30/// No sensor may *require* a `Substrate` to function.
31pub trait Substrate {
32    /// Pre-computed list of changed file paths (forward-slash normalized).
33    fn changed_files(&self) -> Option<&[String]> {
34        None
35    }
36    /// Repository root path.
37    fn repo_root(&self) -> Option<&std::path::Path> {
38        None
39    }
40    /// Arbitrary metadata from the substrate provider.
41    fn metadata(&self) -> Option<&serde_json::Value> {
42        None
43    }
44}
45
46/// R2 Library Contract: run the diffguard sensor and return a `SensorReport`.
47///
48/// This is the entry point for BusyBox/integrated cockpit usage.
49/// For standalone CLI usage, use `run_check()` directly.
50pub fn run_sensor(
51    settings: &Settings,
52    substrate: Option<&dyn Substrate>,
53) -> Result<SensorReport, anyhow::Error> {
54    // 1. Run the check (substrate currently unused; reserved for future optimization)
55    let _ = substrate;
56    let check_run = run_check(&settings.plan, &settings.config, &settings.diff_text)?;
57
58    // 2. Build rule metadata from config and merge check stats into context
59    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    // 3. Convert to sensor report
68    Ok(render_sensor_report(&check_run.receipt, &ctx))
69}
70
71/// Extracts rule metadata (help text, URL, and tags) from a config file.
72fn 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                    // Invalid regex pattern
259                    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}