Skip to main content

ward/engine/
planner.rs

1use serde::Serialize;
2
3use crate::config::SecurityConfig;
4use crate::github::security::SecurityState;
5
6/// A planned change for a single security feature.
7#[derive(Debug, Clone, PartialEq, Serialize)]
8pub struct SecurityChange {
9    pub feature: String,
10    pub current: bool,
11    pub desired: bool,
12}
13
14/// A plan for a single repository.
15#[derive(Debug, Clone, PartialEq, Serialize)]
16pub struct RepoPlan {
17    pub repo: String,
18    pub changes: Vec<SecurityChange>,
19}
20
21impl RepoPlan {
22    pub fn has_changes(&self) -> bool {
23        !self.changes.is_empty()
24    }
25}
26
27/// Diff the current security state against the desired config.
28pub fn plan_security(repo: &str, current: &SecurityState, desired: &SecurityConfig) -> RepoPlan {
29    let mut changes = Vec::new();
30
31    let checks = [
32        (
33            "dependabot_alerts",
34            current.dependabot_alerts,
35            desired.dependabot_alerts,
36        ),
37        (
38            "dependabot_security_updates",
39            current.dependabot_security_updates,
40            desired.dependabot_security_updates,
41        ),
42        (
43            "secret_scanning",
44            current.secret_scanning,
45            desired.secret_scanning,
46        ),
47        (
48            "secret_scanning_ai_detection",
49            current.secret_scanning_ai_detection,
50            desired.secret_scanning_ai_detection,
51        ),
52        (
53            "push_protection",
54            current.push_protection,
55            desired.push_protection,
56        ),
57    ];
58
59    for (feature, current_val, desired_val) in checks {
60        if current_val != desired_val {
61            changes.push(SecurityChange {
62                feature: feature.to_string(),
63                current: current_val,
64                desired: desired_val,
65            });
66        }
67    }
68
69    RepoPlan {
70        repo: repo.to_string(),
71        changes,
72    }
73}
74
75#[cfg(test)]
76mod tests {
77    use super::*;
78
79    fn state(da: bool, dsu: bool, ss: bool, ai: bool, pp: bool) -> SecurityState {
80        SecurityState {
81            dependabot_alerts: da,
82            dependabot_security_updates: dsu,
83            secret_scanning: ss,
84            secret_scanning_ai_detection: ai,
85            push_protection: pp,
86        }
87    }
88
89    fn config(da: bool, dsu: bool, ss: bool, ai: bool, pp: bool) -> SecurityConfig {
90        SecurityConfig {
91            dependabot_alerts: da,
92            dependabot_security_updates: dsu,
93            secret_scanning: ss,
94            secret_scanning_ai_detection: ai,
95            push_protection: pp,
96            codeql_advanced_setup: false,
97        }
98    }
99
100    #[test]
101    fn no_changes_when_state_matches_config() {
102        let plan = plan_security(
103            "repo",
104            &state(true, true, true, true, true),
105            &config(true, true, true, true, true),
106        );
107        assert!(!plan.has_changes());
108        assert!(plan.changes.is_empty());
109    }
110
111    #[test]
112    fn all_changes_when_nothing_enabled() {
113        let plan = plan_security(
114            "repo",
115            &state(false, false, false, false, false),
116            &config(true, true, true, true, true),
117        );
118        assert!(plan.has_changes());
119        assert_eq!(plan.changes.len(), 5);
120        assert!(plan.changes.iter().all(|c| !c.current && c.desired));
121    }
122
123    #[test]
124    fn partial_changes() {
125        let plan = plan_security(
126            "repo",
127            &state(true, false, true, false, false),
128            &config(true, true, true, true, true),
129        );
130        assert_eq!(plan.changes.len(), 3);
131        let features: Vec<&str> = plan.changes.iter().map(|c| c.feature.as_str()).collect();
132        assert!(features.contains(&"dependabot_security_updates"));
133        assert!(features.contains(&"secret_scanning_ai_detection"));
134        assert!(features.contains(&"push_protection"));
135    }
136
137    #[test]
138    fn plan_to_disable_features() {
139        let plan = plan_security(
140            "repo",
141            &state(true, true, true, true, true),
142            &config(false, false, false, false, false),
143        );
144        assert_eq!(plan.changes.len(), 5);
145        assert!(plan.changes.iter().all(|c| c.current && !c.desired));
146    }
147
148    #[test]
149    fn repo_name_preserved() {
150        let plan = plan_security(
151            "my-cool-repo",
152            &state(false, false, false, false, false),
153            &config(true, true, true, true, true),
154        );
155        assert_eq!(plan.repo, "my-cool-repo");
156    }
157}