Skip to main content

ward/config/
manifest.rs

1use std::collections::HashMap;
2use std::path::Path;
3
4use anyhow::{Context, Result};
5use serde::Deserialize;
6
7#[derive(Debug, PartialEq, Deserialize)]
8pub struct Manifest {
9    pub org: OrgConfig,
10
11    #[serde(default)]
12    pub security: SecurityConfig,
13
14    #[serde(default)]
15    pub templates: TemplateConfig,
16
17    #[serde(default)]
18    pub branch_protection: BranchProtectionConfig,
19
20    #[serde(default)]
21    pub systems: Vec<SystemConfig>,
22}
23
24#[derive(Debug, PartialEq, Deserialize)]
25pub struct OrgConfig {
26    pub name: String,
27}
28
29#[derive(Debug, Default, Clone, PartialEq, Deserialize)]
30pub struct SecurityConfig {
31    #[serde(default = "default_true")]
32    pub secret_scanning: bool,
33
34    #[serde(default = "default_true")]
35    pub secret_scanning_ai_detection: bool,
36
37    #[serde(default = "default_true")]
38    pub push_protection: bool,
39
40    #[serde(default = "default_true")]
41    pub dependabot_alerts: bool,
42
43    #[serde(default = "default_true")]
44    pub dependabot_security_updates: bool,
45
46    #[serde(default)]
47    pub codeql_advanced_setup: bool,
48}
49
50#[derive(Debug, Default, PartialEq, Deserialize)]
51pub struct TemplateConfig {
52    #[serde(default = "default_branch_name")]
53    pub branch: String,
54
55    #[serde(default)]
56    pub reviewers: Vec<String>,
57
58    #[serde(default = "default_commit_prefix")]
59    pub commit_message_prefix: String,
60
61    #[serde(default)]
62    pub custom_dir: Option<String>,
63
64    #[serde(default)]
65    pub registries: HashMap<String, RegistryConfig>,
66}
67
68#[derive(Debug, Default, Clone, PartialEq, Deserialize)]
69pub struct BranchProtectionConfig {
70    #[serde(default)]
71    pub enabled: bool,
72
73    #[serde(default = "default_one")]
74    pub required_approvals: u32,
75
76    #[serde(default)]
77    pub dismiss_stale_reviews: bool,
78
79    #[serde(default)]
80    pub require_code_owner_reviews: bool,
81
82    #[serde(default)]
83    pub require_status_checks: bool,
84
85    #[serde(default)]
86    pub strict_status_checks: bool,
87
88    #[serde(default)]
89    pub enforce_admins: bool,
90
91    #[serde(default)]
92    pub required_linear_history: bool,
93
94    #[serde(default)]
95    pub allow_force_pushes: bool,
96
97    #[serde(default)]
98    pub allow_deletions: bool,
99}
100
101fn default_one() -> u32 {
102    1
103}
104
105#[derive(Debug, Clone, PartialEq, Deserialize)]
106pub struct RegistryConfig {
107    #[serde(rename = "type")]
108    pub registry_type: String,
109    pub url: String,
110    #[serde(default)]
111    pub jfrog_oidc_provider: Option<String>,
112    #[serde(default)]
113    pub audience: Option<String>,
114}
115
116#[derive(Debug, PartialEq, Deserialize)]
117pub struct SystemConfig {
118    pub id: String,
119    pub name: String,
120
121    #[serde(default)]
122    pub exclude: Vec<String>,
123
124    #[serde(default)]
125    pub repos: Vec<String>,
126
127    #[serde(default)]
128    pub security: Option<SecurityConfig>,
129}
130
131impl Manifest {
132    pub fn load(path: Option<&str>) -> Result<Self> {
133        let default_path = "ward.toml";
134        let path = path.unwrap_or(default_path);
135
136        if !Path::new(path).exists() {
137            tracing::info!("No ward.toml found, using defaults");
138            return Ok(Self::default());
139        }
140
141        let content =
142            std::fs::read_to_string(path).with_context(|| format!("Failed to read {path}"))?;
143
144        toml::from_str(&content).with_context(|| format!("Failed to parse {path}"))
145    }
146
147    pub fn system(&self, id: &str) -> Option<&SystemConfig> {
148        self.systems.iter().find(|s| s.id == id)
149    }
150
151    pub fn security_for_system(&self, system_id: &str) -> &SecurityConfig {
152        self.systems
153            .iter()
154            .find(|s| s.id == system_id)
155            .and_then(|s| s.security.as_ref())
156            .unwrap_or(&self.security)
157    }
158
159    pub fn exclude_patterns_for_system(&self, system_id: &str) -> Vec<String> {
160        self.systems
161            .iter()
162            .find(|s| s.id == system_id)
163            .map(|s| s.exclude.clone())
164            .unwrap_or_default()
165    }
166
167    pub fn explicit_repos_for_system(&self, system_id: &str) -> Vec<String> {
168        self.systems
169            .iter()
170            .find(|s| s.id == system_id)
171            .map(|s| s.repos.clone())
172            .unwrap_or_default()
173    }
174}
175
176impl Default for Manifest {
177    fn default() -> Self {
178        Self {
179            org: OrgConfig {
180                name: String::new(),
181            },
182            security: SecurityConfig::default(),
183            templates: TemplateConfig::default(),
184            branch_protection: BranchProtectionConfig::default(),
185            systems: Vec::new(),
186        }
187    }
188}
189
190fn default_true() -> bool {
191    true
192}
193
194fn default_branch_name() -> String {
195    "chore/ward-setup".to_owned()
196}
197
198fn default_commit_prefix() -> String {
199    "chore: ".to_owned()
200}
201
202#[cfg(test)]
203mod tests {
204    use super::*;
205
206    #[test]
207    fn parse_minimal_manifest() {
208        let toml = r#"
209            [org]
210            name = "test-org"
211        "#;
212        let m: Manifest = toml::from_str(toml).unwrap();
213        assert_eq!(m.org.name, "test-org");
214        // #[serde(default)] on the struct field uses derive(Default), not serde field defaults
215        assert!(!m.security.secret_scanning);
216        assert!(m.systems.is_empty());
217    }
218
219    #[test]
220    fn parse_full_manifest() {
221        let toml = r#"
222            [org]
223            name = "my-org"
224            [security]
225            secret_scanning = false
226            push_protection = true
227            dependabot_alerts = true
228            dependabot_security_updates = false
229            [templates]
230            branch = "feat/setup"
231            reviewers = ["alice"]
232            [[systems]]
233            id = "backend"
234            name = "Backend"
235            exclude = ["ops", "infra"]
236        "#;
237        let m: Manifest = toml::from_str(toml).unwrap();
238        assert_eq!(m.org.name, "my-org");
239        assert!(!m.security.secret_scanning);
240        assert!(m.security.push_protection);
241        assert!(!m.security.dependabot_security_updates);
242        assert_eq!(m.templates.branch, "feat/setup");
243        assert_eq!(m.templates.reviewers, vec!["alice"]);
244        assert_eq!(m.systems.len(), 1);
245        assert_eq!(m.systems[0].id, "backend");
246        assert_eq!(m.systems[0].exclude, vec!["ops", "infra"]);
247    }
248
249    #[test]
250    fn system_lookup() {
251        let toml = r#"
252            [org]
253            name = "org"
254            [[systems]]
255            id = "be"
256            name = "Backend"
257            [[systems]]
258            id = "fe"
259            name = "Frontend"
260        "#;
261        let m: Manifest = toml::from_str(toml).unwrap();
262        assert_eq!(m.system("be").unwrap().name, "Backend");
263        assert_eq!(m.system("fe").unwrap().name, "Frontend");
264        assert!(m.system("missing").is_none());
265    }
266
267    #[test]
268    fn security_for_system_falls_back_to_global() {
269        let toml = r#"
270            [org]
271            name = "org"
272            [security]
273            secret_scanning = false
274            [[systems]]
275            id = "be"
276            name = "Backend"
277        "#;
278        let m: Manifest = toml::from_str(toml).unwrap();
279        assert!(!m.security_for_system("be").secret_scanning);
280    }
281
282    #[test]
283    fn security_for_system_uses_override() {
284        let toml = r#"
285            [org]
286            name = "org"
287            [security]
288            secret_scanning = true
289            [[systems]]
290            id = "be"
291            name = "Backend"
292            [systems.security]
293            secret_scanning = false
294        "#;
295        let m: Manifest = toml::from_str(toml).unwrap();
296        assert!(!m.security_for_system("be").secret_scanning);
297    }
298
299    #[test]
300    fn exclude_patterns_for_unknown_system_returns_empty() {
301        let m = Manifest::default();
302        assert!(m.exclude_patterns_for_system("nope").is_empty());
303    }
304
305    #[test]
306    fn exclude_patterns_for_known_system() {
307        let toml = r#"
308            [org]
309            name = "org"
310            [[systems]]
311            id = "be"
312            name = "Backend"
313            exclude = ["ops", "infra"]
314        "#;
315        let m: Manifest = toml::from_str(toml).unwrap();
316        assert_eq!(m.exclude_patterns_for_system("be"), vec!["ops", "infra"]);
317    }
318
319    #[test]
320    fn system_with_explicit_repos() {
321        let toml = r#"
322            [org]
323            name = "org"
324            [[systems]]
325            id = "be"
326            name = "Backend"
327            repos = ["standalone-service", "legacy-api"]
328        "#;
329        let m: Manifest = toml::from_str(toml).unwrap();
330        assert_eq!(
331            m.explicit_repos_for_system("be"),
332            vec!["standalone-service", "legacy-api"]
333        );
334    }
335
336    #[test]
337    fn system_without_explicit_repos_returns_empty() {
338        let toml = r#"
339            [org]
340            name = "org"
341            [[systems]]
342            id = "be"
343            name = "Backend"
344        "#;
345        let m: Manifest = toml::from_str(toml).unwrap();
346        assert!(m.explicit_repos_for_system("be").is_empty());
347    }
348
349    #[test]
350    fn branch_protection_serde_defaults() {
351        let bp: BranchProtectionConfig = toml::from_str("").unwrap();
352        assert!(!bp.enabled);
353        assert_eq!(bp.required_approvals, 1);
354        assert!(!bp.dismiss_stale_reviews);
355        assert!(!bp.require_code_owner_reviews);
356        assert!(!bp.require_status_checks);
357        assert!(!bp.strict_status_checks);
358        assert!(!bp.enforce_admins);
359        assert!(!bp.required_linear_history);
360        assert!(!bp.allow_force_pushes);
361        assert!(!bp.allow_deletions);
362    }
363
364    #[test]
365    fn branch_protection_full_parse() {
366        let toml_str = r#"
367            enabled = true
368            required_approvals = 2
369            dismiss_stale_reviews = true
370            require_code_owner_reviews = true
371            enforce_admins = true
372        "#;
373        let bp: BranchProtectionConfig = toml::from_str(toml_str).unwrap();
374        assert!(bp.enabled);
375        assert_eq!(bp.required_approvals, 2);
376        assert!(bp.dismiss_stale_reviews);
377        assert!(bp.require_code_owner_reviews);
378        assert!(bp.enforce_admins);
379        assert!(!bp.allow_force_pushes);
380    }
381
382    #[test]
383    fn default_template_config_values() {
384        // derive(Default) gives empty strings/vecs, not the serde defaults
385        let tc = TemplateConfig::default();
386        assert_eq!(tc.branch, "");
387        assert_eq!(tc.commit_message_prefix, "");
388        assert!(tc.reviewers.is_empty());
389        assert!(tc.registries.is_empty());
390    }
391
392    #[test]
393    fn serde_template_config_defaults() {
394        // When deserialized with missing fields, serde uses the custom defaults
395        let tc: TemplateConfig = toml::from_str("").unwrap();
396        assert_eq!(tc.branch, "chore/ward-setup");
397        assert_eq!(tc.commit_message_prefix, "chore: ");
398        assert!(tc.reviewers.is_empty());
399        assert!(tc.registries.is_empty());
400    }
401
402    #[test]
403    fn default_security_config_all_false() {
404        // derive(Default) sets all bools to false
405        let sc = SecurityConfig::default();
406        assert!(!sc.secret_scanning);
407        assert!(!sc.secret_scanning_ai_detection);
408        assert!(!sc.push_protection);
409        assert!(!sc.dependabot_alerts);
410        assert!(!sc.dependabot_security_updates);
411        assert!(!sc.codeql_advanced_setup);
412    }
413
414    #[test]
415    fn serde_security_config_defaults_to_true() {
416        // When deserialized with missing fields, serde uses default_true
417        let sc: SecurityConfig = toml::from_str("").unwrap();
418        assert!(sc.secret_scanning);
419        assert!(sc.secret_scanning_ai_detection);
420        assert!(sc.push_protection);
421        assert!(sc.dependabot_alerts);
422        assert!(sc.dependabot_security_updates);
423        assert!(!sc.codeql_advanced_setup); // this one defaults false
424    }
425}