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 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 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 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 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 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); }
425}