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