1use anyhow::Result;
2use clap::Args;
3use console::style;
4use serde::Serialize;
5
6use crate::cli::drift::{compare_protection, compare_security};
7use crate::config::Manifest;
8use crate::config::manifest::TeamAccess;
9use crate::github::Client;
10use crate::github::repos::Repository;
11
12#[derive(Args)]
13pub struct PlanCommand {
14 #[arg(long)]
16 all: bool,
17}
18
19#[derive(Debug, Serialize)]
20struct PlanReport {
21 systems: Vec<SystemPlan>,
22 total_repos: usize,
23 total_actions: usize,
24}
25
26#[derive(Debug, Serialize)]
27struct SystemPlan {
28 id: String,
29 repo_count: usize,
30 security: CategoryResult,
31 branch_protection: CategoryResult,
32 rulesets: CategoryResult,
33 teams: CategoryResult,
34}
35
36#[derive(Debug, Serialize)]
37struct CategoryResult {
38 compliant: usize,
39 total: usize,
40 issues: Vec<String>,
41}
42
43impl PlanCommand {
44 pub async fn run(
45 &self,
46 client: &Client,
47 manifest: &Manifest,
48 system: Option<&str>,
49 json: bool,
50 ) -> Result<()> {
51 let system_ids = resolve_system_ids(manifest, system, self.all)?;
52
53 let mut report = PlanReport {
54 systems: Vec::new(),
55 total_repos: 0,
56 total_actions: 0,
57 };
58
59 for sys_id in &system_ids {
60 let excludes = manifest.exclude_patterns_for_system(sys_id);
61 let explicit = manifest.explicit_repos_for_system(sys_id);
62 let repos = client
63 .list_repos_for_system(sys_id, &excludes, &explicit)
64 .await?;
65
66 if repos.is_empty() {
67 continue;
68 }
69
70 let sys_plan = check_system(client, manifest, sys_id, &repos).await?;
71 report.total_repos += sys_plan.repo_count;
72 report.total_actions += count_actions(&sys_plan);
73 report.systems.push(sys_plan);
74 }
75
76 if json {
77 println!(
78 "{}",
79 serde_json::to_string_pretty(&report).unwrap_or_default()
80 );
81 } else {
82 print_report(&report);
83 }
84
85 Ok(())
86 }
87}
88
89fn resolve_system_ids(manifest: &Manifest, system: Option<&str>, all: bool) -> Result<Vec<String>> {
90 if let Some(sys) = system {
91 return Ok(vec![sys.to_string()]);
92 }
93
94 if all || !manifest.systems.is_empty() {
95 let ids: Vec<String> = manifest.systems.iter().map(|s| s.id.clone()).collect();
96 if ids.is_empty() {
97 anyhow::bail!("No systems configured in ward.toml");
98 }
99 return Ok(ids);
100 }
101
102 anyhow::bail!("Use --system <ID> or --all to scan all systems")
103}
104
105async fn check_system(
106 client: &Client,
107 manifest: &Manifest,
108 sys_id: &str,
109 repos: &[Repository],
110) -> Result<SystemPlan> {
111 let desired_security = manifest.security_for_system(sys_id);
112 let desired_protection = &manifest.branch_protection;
113
114 let mut security_result = CategoryResult {
115 compliant: 0,
116 total: repos.len(),
117 issues: Vec::new(),
118 };
119 let mut protection_result = CategoryResult {
120 compliant: 0,
121 total: repos.len(),
122 issues: Vec::new(),
123 };
124 let mut rulesets_result = CategoryResult {
125 compliant: 0,
126 total: repos.len(),
127 issues: Vec::new(),
128 };
129 let mut teams_result = CategoryResult {
130 compliant: 0,
131 total: repos.len(),
132 issues: Vec::new(),
133 };
134
135 let desired_teams: &[TeamAccess] = manifest
136 .system(sys_id)
137 .map(|s| s.teams.as_slice())
138 .unwrap_or(&[]);
139
140 let expected_ruleset = manifest.rulesets.branch_protection.as_ref().and_then(|c| {
141 if c.enabled {
142 Some(c.name.as_deref().unwrap_or("Branch Protection"))
143 } else {
144 None
145 }
146 });
147
148 for repo in repos {
149 if let Ok(sec_state) = client.get_security_state(&repo.name).await {
151 let drifts = compare_security(desired_security, &sec_state);
152 if drifts.is_empty() {
153 security_result.compliant += 1;
154 } else {
155 security_result.issues.push(repo.name.clone());
156 }
157 }
158
159 if let Ok(prot_opt) = client
161 .get_branch_protection(&repo.name, &repo.default_branch)
162 .await
163 {
164 let prot_state = prot_opt.unwrap_or_default();
165 let drifts = compare_protection(desired_protection, &prot_state);
166 if drifts.is_empty() {
167 protection_result.compliant += 1;
168 } else {
169 protection_result.issues.push(repo.name.clone());
170 }
171 }
172
173 if let Some(expected_name) = expected_ruleset {
175 if let Ok(rulesets) = client.list_rulesets(&repo.name).await {
176 if rulesets.iter().any(|r| r.name == expected_name) {
177 rulesets_result.compliant += 1;
178 } else {
179 rulesets_result.issues.push(repo.name.clone());
180 }
181 }
182 } else {
183 rulesets_result.compliant += 1;
184 }
185
186 if desired_teams.is_empty() {
188 teams_result.compliant += 1;
189 } else if let Ok(current_teams) = client.list_repo_teams(&repo.name).await {
190 let all_present = desired_teams.iter().all(|d| {
191 current_teams
192 .iter()
193 .any(|t| t.slug == d.slug && t.permission == d.permission)
194 });
195 if all_present {
196 teams_result.compliant += 1;
197 } else {
198 teams_result.issues.push(repo.name.clone());
199 }
200 }
201 }
202
203 Ok(SystemPlan {
204 id: sys_id.to_string(),
205 repo_count: repos.len(),
206 security: security_result,
207 branch_protection: protection_result,
208 rulesets: rulesets_result,
209 teams: teams_result,
210 })
211}
212
213fn count_actions(plan: &SystemPlan) -> usize {
214 plan.security.issues.len()
215 + plan.branch_protection.issues.len()
216 + plan.rulesets.issues.len()
217 + plan.teams.issues.len()
218}
219
220fn print_report(report: &PlanReport) {
221 println!();
222 println!(" {}", style("Ward Plan").bold().cyan());
223 println!(" {}", style("=========").bold().cyan());
224
225 for sys in &report.systems {
226 println!();
227 println!(
228 " System: {} ({} repositories)",
229 style(&sys.id).bold(),
230 sys.repo_count
231 );
232
233 print_category("Security", &sys.security);
234 print_category("Branch Protection", &sys.branch_protection);
235 print_category("Rulesets", &sys.rulesets);
236 print_category("Teams", &sys.teams);
237 }
238
239 println!();
240 println!(
241 " Summary: {} repos scanned, {} actions needed",
242 style(report.total_repos).bold(),
243 if report.total_actions > 0 {
244 style(report.total_actions).red().bold()
245 } else {
246 style(report.total_actions).green().bold()
247 }
248 );
249}
250
251fn print_category(name: &str, result: &CategoryResult) {
252 println!();
253 println!(" {}", style(name).underlined());
254 println!(
255 " {}/{} in compliance",
256 style(result.compliant).green(),
257 result.total
258 );
259
260 if !result.issues.is_empty() {
261 println!(
262 " {} repos need changes: {}",
263 result.issues.len(),
264 result.issues.join(", ")
265 );
266 }
267}
268
269#[cfg(test)]
270mod tests {
271 use super::*;
272
273 #[test]
274 fn test_plan_summary_counts() {
275 let plan = SystemPlan {
276 id: "backend".to_string(),
277 repo_count: 5,
278 security: CategoryResult {
279 compliant: 3,
280 total: 5,
281 issues: vec!["repo-a".to_string(), "repo-b".to_string()],
282 },
283 branch_protection: CategoryResult {
284 compliant: 5,
285 total: 5,
286 issues: vec![],
287 },
288 rulesets: CategoryResult {
289 compliant: 4,
290 total: 5,
291 issues: vec!["repo-c".to_string()],
292 },
293 teams: CategoryResult {
294 compliant: 5,
295 total: 5,
296 issues: vec![],
297 },
298 };
299
300 assert_eq!(count_actions(&plan), 3);
301 assert_eq!(plan.security.compliant, 3);
302 assert_eq!(plan.branch_protection.compliant, 5);
303 }
304
305 #[test]
306 fn test_plan_json_structure() {
307 let report = PlanReport {
308 systems: vec![SystemPlan {
309 id: "backend".to_string(),
310 repo_count: 3,
311 security: CategoryResult {
312 compliant: 3,
313 total: 3,
314 issues: vec![],
315 },
316 branch_protection: CategoryResult {
317 compliant: 2,
318 total: 3,
319 issues: vec!["repo-x".to_string()],
320 },
321 rulesets: CategoryResult {
322 compliant: 3,
323 total: 3,
324 issues: vec![],
325 },
326 teams: CategoryResult {
327 compliant: 3,
328 total: 3,
329 issues: vec![],
330 },
331 }],
332 total_repos: 3,
333 total_actions: 1,
334 };
335
336 let json_str = serde_json::to_string_pretty(&report).unwrap();
337 let parsed: serde_json::Value = serde_json::from_str(&json_str).unwrap();
338
339 assert_eq!(parsed["total_repos"], 3);
340 assert_eq!(parsed["total_actions"], 1);
341 assert_eq!(parsed["systems"][0]["id"], "backend");
342 assert_eq!(parsed["systems"][0]["security"]["compliant"], 3);
343 assert_eq!(
344 parsed["systems"][0]["branch_protection"]["issues"][0],
345 "repo-x"
346 );
347 }
348
349 #[test]
350 fn test_plan_all_systems() {
351 let toml_str = r#"
352 [org]
353 name = "org"
354 [[systems]]
355 id = "backend"
356 name = "Backend"
357 [[systems]]
358 id = "frontend"
359 name = "Frontend"
360 "#;
361 let manifest: Manifest = toml::from_str(toml_str).unwrap();
362
363 let ids = resolve_system_ids(&manifest, None, true).unwrap();
364 assert_eq!(ids, vec!["backend", "frontend"]);
365
366 let single = resolve_system_ids(&manifest, Some("backend"), false).unwrap();
367 assert_eq!(single, vec!["backend"]);
368 }
369
370 #[test]
371 fn test_plan_no_systems_errors() {
372 let manifest = Manifest::default();
373 let result = resolve_system_ids(&manifest, None, true);
374 assert!(result.is_err());
375 }
376}