Skip to main content

ward/cli/
plan.rs

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    /// Check all systems (default: requires --system)
15    #[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        // Security
150        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        // Branch protection
160        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        // Rulesets
174        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        // Teams
187        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}