Skip to main content

ward/config/
manifest.rs

1use std::collections::HashMap;
2use std::path::Path;
3
4use anyhow::{Context, Result};
5use serde::{Deserialize, Serialize};
6
7use crate::cli::policy::PolicyRule;
8
9#[derive(Debug, PartialEq, Deserialize)]
10pub struct Manifest {
11    pub org: OrgConfig,
12
13    #[serde(default)]
14    pub security: SecurityConfig,
15
16    #[serde(default)]
17    pub templates: TemplateConfig,
18
19    #[serde(default)]
20    pub branch_protection: BranchProtectionConfig,
21
22    #[serde(default)]
23    pub rulesets: RulesetsConfig,
24
25    #[serde(default)]
26    pub systems: Vec<SystemConfig>,
27
28    #[serde(default)]
29    pub policies: Vec<PolicyRule>,
30}
31
32#[derive(Debug, PartialEq, Deserialize)]
33pub struct OrgConfig {
34    pub name: String,
35}
36
37#[derive(Debug, Default, Clone, PartialEq, Deserialize)]
38pub struct SecurityConfig {
39    #[serde(default = "default_true")]
40    pub secret_scanning: bool,
41
42    #[serde(default = "default_true")]
43    pub secret_scanning_ai_detection: bool,
44
45    #[serde(default = "default_true")]
46    pub push_protection: bool,
47
48    #[serde(default = "default_true")]
49    pub dependabot_alerts: bool,
50
51    #[serde(default = "default_true")]
52    pub dependabot_security_updates: bool,
53
54    #[serde(default)]
55    pub codeql_advanced_setup: bool,
56}
57
58#[derive(Debug, Default, PartialEq, Deserialize)]
59pub struct TemplateConfig {
60    #[serde(default = "default_branch_name")]
61    pub branch: String,
62
63    #[serde(default)]
64    pub reviewers: Vec<String>,
65
66    #[serde(default = "default_commit_prefix")]
67    pub commit_message_prefix: String,
68
69    #[serde(default)]
70    pub custom_dir: Option<String>,
71
72    #[serde(default)]
73    pub registries: HashMap<String, RegistryConfig>,
74}
75
76#[derive(Debug, Default, Clone, PartialEq, Deserialize)]
77pub struct BranchProtectionConfig {
78    #[serde(default)]
79    pub enabled: bool,
80
81    #[serde(default = "default_one")]
82    pub required_approvals: u32,
83
84    #[serde(default)]
85    pub dismiss_stale_reviews: bool,
86
87    #[serde(default)]
88    pub require_code_owner_reviews: bool,
89
90    #[serde(default)]
91    pub require_status_checks: bool,
92
93    #[serde(default)]
94    pub strict_status_checks: bool,
95
96    #[serde(default)]
97    pub enforce_admins: bool,
98
99    #[serde(default)]
100    pub required_linear_history: bool,
101
102    #[serde(default)]
103    pub allow_force_pushes: bool,
104
105    #[serde(default)]
106    pub allow_deletions: bool,
107}
108
109fn default_one() -> u32 {
110    1
111}
112
113fn default_active() -> String {
114    "active".to_string()
115}
116
117#[derive(Debug, Default, Clone, PartialEq, Deserialize)]
118pub struct RulesetsConfig {
119    #[serde(default)]
120    pub branch_protection: Option<RulesetBranchProtection>,
121}
122
123#[derive(Debug, Clone, PartialEq, Deserialize)]
124pub struct RulesetBranchProtection {
125    #[serde(default = "default_true")]
126    pub enabled: bool,
127
128    #[serde(default)]
129    pub name: Option<String>,
130
131    #[serde(default = "default_active")]
132    pub enforcement: String,
133
134    #[serde(default = "default_one")]
135    pub required_approvals: u32,
136
137    #[serde(default)]
138    pub dismiss_stale_reviews: bool,
139
140    #[serde(default)]
141    pub require_code_owner_reviews: bool,
142
143    #[serde(default)]
144    pub required_status_checks: Vec<String>,
145
146    #[serde(default)]
147    pub require_linear_history: bool,
148
149    #[serde(default)]
150    pub block_force_pushes: bool,
151
152    #[serde(default)]
153    pub block_deletions: bool,
154}
155
156#[derive(Debug, Default, Clone, PartialEq, Deserialize, Serialize)]
157pub struct TeamAccess {
158    pub slug: String,
159    pub permission: String,
160}
161
162#[derive(Debug, Clone, PartialEq, Deserialize)]
163pub struct RegistryConfig {
164    #[serde(rename = "type")]
165    pub registry_type: String,
166    pub url: String,
167    #[serde(default)]
168    pub jfrog_oidc_provider: Option<String>,
169    #[serde(default)]
170    pub audience: Option<String>,
171}
172
173#[derive(Debug, PartialEq, Deserialize)]
174pub struct SystemConfig {
175    pub id: String,
176    pub name: String,
177
178    #[serde(default)]
179    pub exclude: Vec<String>,
180
181    #[serde(default)]
182    pub repos: Vec<String>,
183
184    #[serde(default)]
185    pub security: Option<SecurityConfig>,
186
187    #[serde(default)]
188    pub teams: Vec<TeamAccess>,
189}
190
191impl Manifest {
192    pub fn load(path: Option<&str>) -> Result<Self> {
193        let default_path = "ward.toml";
194        let path = path.unwrap_or(default_path);
195
196        if !Path::new(path).exists() {
197            tracing::info!("No ward.toml found, using defaults");
198            return Ok(Self::default());
199        }
200
201        let content =
202            std::fs::read_to_string(path).with_context(|| format!("Failed to read {path}"))?;
203
204        toml::from_str(&content).with_context(|| format!("Failed to parse {path}"))
205    }
206
207    pub fn system(&self, id: &str) -> Option<&SystemConfig> {
208        self.systems.iter().find(|s| s.id == id)
209    }
210
211    pub fn security_for_system(&self, system_id: &str) -> &SecurityConfig {
212        self.systems
213            .iter()
214            .find(|s| s.id == system_id)
215            .and_then(|s| s.security.as_ref())
216            .unwrap_or(&self.security)
217    }
218
219    pub fn exclude_patterns_for_system(&self, system_id: &str) -> Vec<String> {
220        self.systems
221            .iter()
222            .find(|s| s.id == system_id)
223            .map(|s| s.exclude.clone())
224            .unwrap_or_default()
225    }
226
227    pub fn explicit_repos_for_system(&self, system_id: &str) -> Vec<String> {
228        self.systems
229            .iter()
230            .find(|s| s.id == system_id)
231            .map(|s| s.repos.clone())
232            .unwrap_or_default()
233    }
234}
235
236impl Default for Manifest {
237    fn default() -> Self {
238        Self {
239            org: OrgConfig {
240                name: String::new(),
241            },
242            security: SecurityConfig::default(),
243            templates: TemplateConfig::default(),
244            branch_protection: BranchProtectionConfig::default(),
245            rulesets: RulesetsConfig::default(),
246            systems: Vec::new(),
247            policies: Vec::new(),
248        }
249    }
250}
251
252fn default_true() -> bool {
253    true
254}
255
256fn default_branch_name() -> String {
257    "chore/ward-setup".to_owned()
258}
259
260fn default_commit_prefix() -> String {
261    "chore: ".to_owned()
262}
263
264#[cfg(test)]
265mod tests {
266    use super::*;
267
268    #[test]
269    fn parse_minimal_manifest() {
270        let toml = r#"
271            [org]
272            name = "test-org"
273        "#;
274        let m: Manifest = toml::from_str(toml).unwrap();
275        assert_eq!(m.org.name, "test-org");
276        // #[serde(default)] on the struct field uses derive(Default), not serde field defaults
277        assert!(!m.security.secret_scanning);
278        assert!(m.systems.is_empty());
279    }
280
281    #[test]
282    fn parse_full_manifest() {
283        let toml = r#"
284            [org]
285            name = "my-org"
286            [security]
287            secret_scanning = false
288            push_protection = true
289            dependabot_alerts = true
290            dependabot_security_updates = false
291            [templates]
292            branch = "feat/setup"
293            reviewers = ["alice"]
294            [[systems]]
295            id = "backend"
296            name = "Backend"
297            exclude = ["ops", "infra"]
298        "#;
299        let m: Manifest = toml::from_str(toml).unwrap();
300        assert_eq!(m.org.name, "my-org");
301        assert!(!m.security.secret_scanning);
302        assert!(m.security.push_protection);
303        assert!(!m.security.dependabot_security_updates);
304        assert_eq!(m.templates.branch, "feat/setup");
305        assert_eq!(m.templates.reviewers, vec!["alice"]);
306        assert_eq!(m.systems.len(), 1);
307        assert_eq!(m.systems[0].id, "backend");
308        assert_eq!(m.systems[0].exclude, vec!["ops", "infra"]);
309    }
310
311    #[test]
312    fn system_lookup() {
313        let toml = r#"
314            [org]
315            name = "org"
316            [[systems]]
317            id = "be"
318            name = "Backend"
319            [[systems]]
320            id = "fe"
321            name = "Frontend"
322        "#;
323        let m: Manifest = toml::from_str(toml).unwrap();
324        assert_eq!(m.system("be").unwrap().name, "Backend");
325        assert_eq!(m.system("fe").unwrap().name, "Frontend");
326        assert!(m.system("missing").is_none());
327    }
328
329    #[test]
330    fn security_for_system_falls_back_to_global() {
331        let toml = r#"
332            [org]
333            name = "org"
334            [security]
335            secret_scanning = false
336            [[systems]]
337            id = "be"
338            name = "Backend"
339        "#;
340        let m: Manifest = toml::from_str(toml).unwrap();
341        assert!(!m.security_for_system("be").secret_scanning);
342    }
343
344    #[test]
345    fn security_for_system_uses_override() {
346        let toml = r#"
347            [org]
348            name = "org"
349            [security]
350            secret_scanning = true
351            [[systems]]
352            id = "be"
353            name = "Backend"
354            [systems.security]
355            secret_scanning = false
356        "#;
357        let m: Manifest = toml::from_str(toml).unwrap();
358        assert!(!m.security_for_system("be").secret_scanning);
359    }
360
361    #[test]
362    fn exclude_patterns_for_unknown_system_returns_empty() {
363        let m = Manifest::default();
364        assert!(m.exclude_patterns_for_system("nope").is_empty());
365    }
366
367    #[test]
368    fn exclude_patterns_for_known_system() {
369        let toml = r#"
370            [org]
371            name = "org"
372            [[systems]]
373            id = "be"
374            name = "Backend"
375            exclude = ["ops", "infra"]
376        "#;
377        let m: Manifest = toml::from_str(toml).unwrap();
378        assert_eq!(m.exclude_patterns_for_system("be"), vec!["ops", "infra"]);
379    }
380
381    #[test]
382    fn system_with_explicit_repos() {
383        let toml = r#"
384            [org]
385            name = "org"
386            [[systems]]
387            id = "be"
388            name = "Backend"
389            repos = ["standalone-service", "legacy-api"]
390        "#;
391        let m: Manifest = toml::from_str(toml).unwrap();
392        assert_eq!(
393            m.explicit_repos_for_system("be"),
394            vec!["standalone-service", "legacy-api"]
395        );
396    }
397
398    #[test]
399    fn system_without_explicit_repos_returns_empty() {
400        let toml = r#"
401            [org]
402            name = "org"
403            [[systems]]
404            id = "be"
405            name = "Backend"
406        "#;
407        let m: Manifest = toml::from_str(toml).unwrap();
408        assert!(m.explicit_repos_for_system("be").is_empty());
409    }
410
411    #[test]
412    fn branch_protection_serde_defaults() {
413        let bp: BranchProtectionConfig = toml::from_str("").unwrap();
414        assert!(!bp.enabled);
415        assert_eq!(bp.required_approvals, 1);
416        assert!(!bp.dismiss_stale_reviews);
417        assert!(!bp.require_code_owner_reviews);
418        assert!(!bp.require_status_checks);
419        assert!(!bp.strict_status_checks);
420        assert!(!bp.enforce_admins);
421        assert!(!bp.required_linear_history);
422        assert!(!bp.allow_force_pushes);
423        assert!(!bp.allow_deletions);
424    }
425
426    #[test]
427    fn branch_protection_full_parse() {
428        let toml_str = r#"
429            enabled = true
430            required_approvals = 2
431            dismiss_stale_reviews = true
432            require_code_owner_reviews = true
433            enforce_admins = true
434        "#;
435        let bp: BranchProtectionConfig = toml::from_str(toml_str).unwrap();
436        assert!(bp.enabled);
437        assert_eq!(bp.required_approvals, 2);
438        assert!(bp.dismiss_stale_reviews);
439        assert!(bp.require_code_owner_reviews);
440        assert!(bp.enforce_admins);
441        assert!(!bp.allow_force_pushes);
442    }
443
444    #[test]
445    fn default_template_config_values() {
446        // derive(Default) gives empty strings/vecs, not the serde defaults
447        let tc = TemplateConfig::default();
448        assert_eq!(tc.branch, "");
449        assert_eq!(tc.commit_message_prefix, "");
450        assert!(tc.reviewers.is_empty());
451        assert!(tc.registries.is_empty());
452    }
453
454    #[test]
455    fn serde_template_config_defaults() {
456        // When deserialized with missing fields, serde uses the custom defaults
457        let tc: TemplateConfig = toml::from_str("").unwrap();
458        assert_eq!(tc.branch, "chore/ward-setup");
459        assert_eq!(tc.commit_message_prefix, "chore: ");
460        assert!(tc.reviewers.is_empty());
461        assert!(tc.registries.is_empty());
462    }
463
464    #[test]
465    fn default_security_config_all_false() {
466        // derive(Default) sets all bools to false
467        let sc = SecurityConfig::default();
468        assert!(!sc.secret_scanning);
469        assert!(!sc.secret_scanning_ai_detection);
470        assert!(!sc.push_protection);
471        assert!(!sc.dependabot_alerts);
472        assert!(!sc.dependabot_security_updates);
473        assert!(!sc.codeql_advanced_setup);
474    }
475
476    #[test]
477    fn serde_security_config_defaults_to_true() {
478        // When deserialized with missing fields, serde uses default_true
479        let sc: SecurityConfig = toml::from_str("").unwrap();
480        assert!(sc.secret_scanning);
481        assert!(sc.secret_scanning_ai_detection);
482        assert!(sc.push_protection);
483        assert!(sc.dependabot_alerts);
484        assert!(sc.dependabot_security_updates);
485        assert!(!sc.codeql_advanced_setup); // this one defaults false
486    }
487
488    #[test]
489    fn rulesets_config_defaults() {
490        let rc = RulesetsConfig::default();
491        assert!(rc.branch_protection.is_none());
492    }
493
494    #[test]
495    fn rulesets_config_serde_defaults() {
496        let rc: RulesetsConfig = toml::from_str("").unwrap();
497        assert!(rc.branch_protection.is_none());
498    }
499
500    #[test]
501    fn ruleset_branch_protection_serde_defaults() {
502        let rbp: RulesetBranchProtection = toml::from_str("").unwrap();
503        assert!(rbp.enabled);
504        assert!(rbp.name.is_none());
505        assert_eq!(rbp.enforcement, "active");
506        assert_eq!(rbp.required_approvals, 1);
507        assert!(!rbp.dismiss_stale_reviews);
508        assert!(!rbp.require_code_owner_reviews);
509        assert!(rbp.required_status_checks.is_empty());
510        assert!(!rbp.require_linear_history);
511        assert!(!rbp.block_force_pushes);
512        assert!(!rbp.block_deletions);
513    }
514
515    #[test]
516    fn ruleset_branch_protection_custom_values() {
517        let toml_str = r#"
518            enabled = true
519            name = "Custom Rules"
520            enforcement = "evaluate"
521            required_approvals = 2
522            dismiss_stale_reviews = true
523            require_code_owner_reviews = true
524            required_status_checks = ["ci", "lint"]
525            require_linear_history = true
526            block_force_pushes = true
527            block_deletions = true
528        "#;
529        let rbp: RulesetBranchProtection = toml::from_str(toml_str).unwrap();
530        assert!(rbp.enabled);
531        assert_eq!(rbp.name.as_deref(), Some("Custom Rules"));
532        assert_eq!(rbp.enforcement, "evaluate");
533        assert_eq!(rbp.required_approvals, 2);
534        assert!(rbp.dismiss_stale_reviews);
535        assert!(rbp.require_code_owner_reviews);
536        assert_eq!(rbp.required_status_checks, vec!["ci", "lint"]);
537        assert!(rbp.require_linear_history);
538        assert!(rbp.block_force_pushes);
539        assert!(rbp.block_deletions);
540    }
541
542    #[test]
543    fn team_access_empty_default() {
544        let toml_str = r#"
545            [org]
546            name = "org"
547            [[systems]]
548            id = "be"
549            name = "Backend"
550        "#;
551        let m: Manifest = toml::from_str(toml_str).unwrap();
552        assert!(m.systems[0].teams.is_empty());
553    }
554
555    #[test]
556    fn team_access_parsing() {
557        let toml_str = r#"
558            [org]
559            name = "org"
560            [[systems]]
561            id = "be"
562            name = "Backend"
563            teams = [
564                { slug = "developers", permission = "push" },
565                { slug = "devops", permission = "admin" },
566            ]
567        "#;
568        let m: Manifest = toml::from_str(toml_str).unwrap();
569        assert_eq!(m.systems[0].teams.len(), 2);
570        assert_eq!(m.systems[0].teams[0].slug, "developers");
571        assert_eq!(m.systems[0].teams[0].permission, "push");
572        assert_eq!(m.systems[0].teams[1].slug, "devops");
573        assert_eq!(m.systems[0].teams[1].permission, "admin");
574    }
575
576    #[test]
577    fn manifest_with_rulesets_and_teams() {
578        let toml_str = r#"
579            [org]
580            name = "org"
581
582            [rulesets.branch_protection]
583            enabled = true
584            enforcement = "active"
585            required_approvals = 1
586            block_force_pushes = true
587
588            [[systems]]
589            id = "be"
590            name = "Backend"
591            teams = [
592                { slug = "dev", permission = "push" },
593            ]
594        "#;
595        let m: Manifest = toml::from_str(toml_str).unwrap();
596        let bp = m.rulesets.branch_protection.as_ref().unwrap();
597        assert!(bp.enabled);
598        assert_eq!(bp.enforcement, "active");
599        assert_eq!(bp.required_approvals, 1);
600        assert!(bp.block_force_pushes);
601        assert_eq!(m.systems[0].teams.len(), 1);
602    }
603}