Skip to main content

ward/cli/
config_cmd.rs

1use std::path::{Path, PathBuf};
2
3use anyhow::{Context, Result, bail};
4use console::style;
5use dialoguer::{Confirm, Input};
6use toml_edit::DocumentMut;
7
8use crate::config::Manifest;
9
10#[derive(clap::Args)]
11pub struct ConfigCommand {
12    #[command(subcommand)]
13    pub action: ConfigAction,
14}
15
16#[derive(clap::Subcommand)]
17pub enum ConfigAction {
18    /// Display current configuration
19    Show,
20    /// Open configuration in editor
21    Edit,
22    /// Show configuration file path
23    Path,
24    /// Set a configuration value (dot notation: security.push_protection true)
25    Set {
26        /// Config key in dot notation (e.g., security.push_protection)
27        key: String,
28        /// Value to set
29        value: String,
30    },
31    /// Add a new system interactively
32    AddSystem,
33    /// Remove a system by ID
34    RemoveSystem {
35        /// System ID to remove
36        id: String,
37        /// Skip confirmation
38        #[arg(long, short)]
39        yes: bool,
40    },
41}
42
43const VALID_KEYS: &[(&str, ValueKind)] = &[
44    ("org.name", ValueKind::Str),
45    ("security.secret_scanning", ValueKind::Bool),
46    ("security.push_protection", ValueKind::Bool),
47    ("security.dependabot_alerts", ValueKind::Bool),
48    ("security.dependabot_security_updates", ValueKind::Bool),
49    ("security.secret_scanning_ai_detection", ValueKind::Bool),
50    ("security.codeql_advanced_setup", ValueKind::Bool),
51    ("branch_protection.enabled", ValueKind::Bool),
52    ("branch_protection.required_approvals", ValueKind::Int),
53    ("branch_protection.dismiss_stale_reviews", ValueKind::Bool),
54    (
55        "branch_protection.require_code_owner_reviews",
56        ValueKind::Bool,
57    ),
58    ("branch_protection.require_status_checks", ValueKind::Bool),
59    ("branch_protection.strict_status_checks", ValueKind::Bool),
60    ("branch_protection.enforce_admins", ValueKind::Bool),
61    ("branch_protection.required_linear_history", ValueKind::Bool),
62    ("branch_protection.allow_force_pushes", ValueKind::Bool),
63    ("branch_protection.allow_deletions", ValueKind::Bool),
64    ("templates.branch", ValueKind::Str),
65    ("templates.commit_message_prefix", ValueKind::Str),
66    ("templates.custom_dir", ValueKind::Str),
67];
68
69#[derive(Clone, Copy, PartialEq, Eq)]
70enum ValueKind {
71    Bool,
72    Int,
73    Str,
74}
75
76impl ConfigCommand {
77    pub fn run(self, config_override: Option<&str>) -> Result<()> {
78        match self.action {
79            ConfigAction::Show => run_show(config_override),
80            ConfigAction::Edit => run_edit(config_override),
81            ConfigAction::Path => run_path(config_override),
82            ConfigAction::Set { key, value } => {
83                let path = resolve_config_path(config_override);
84                apply_set(&path, &key, &value)
85            }
86            ConfigAction::AddSystem => run_add_system(config_override),
87            ConfigAction::RemoveSystem { id, yes } => {
88                let path = resolve_config_path(config_override);
89                if !yes {
90                    let confirmed = Confirm::new()
91                        .with_prompt(format!("Remove system '{id}'?"))
92                        .default(false)
93                        .interact()?;
94                    if !confirmed {
95                        println!("  {} Cancelled.", style("[..]").dim());
96                        return Ok(());
97                    }
98                }
99                remove_system_by_id(&path, &id)
100            }
101        }
102    }
103}
104
105pub fn resolve_config_path(config_override: Option<&str>) -> PathBuf {
106    match config_override {
107        Some(p) => PathBuf::from(p),
108        None => PathBuf::from("ward.toml"),
109    }
110}
111
112fn run_show(config_override: Option<&str>) -> Result<()> {
113    let path = resolve_config_path(config_override);
114    if !path.exists() {
115        println!(
116            "  {} No configuration file found at {}",
117            style("[!!]").yellow(),
118            path.display()
119        );
120        println!("  Run {} to create one.", style("ward init").bold());
121        return Ok(());
122    }
123
124    let manifest = Manifest::load(config_override)?;
125
126    println!();
127    println!("  {}", style("Organization").bold());
128    println!("    name: {}", style(&manifest.org.name).cyan());
129
130    println!();
131    println!("  {}", style("Security").bold());
132    print_bool("    secret_scanning", manifest.security.secret_scanning);
133    print_bool(
134        "    secret_scanning_ai_detection",
135        manifest.security.secret_scanning_ai_detection,
136    );
137    print_bool("    push_protection", manifest.security.push_protection);
138    print_bool("    dependabot_alerts", manifest.security.dependabot_alerts);
139    print_bool(
140        "    dependabot_security_updates",
141        manifest.security.dependabot_security_updates,
142    );
143    print_bool(
144        "    codeql_advanced_setup",
145        manifest.security.codeql_advanced_setup,
146    );
147
148    println!();
149    println!("  {}", style("Branch Protection").bold());
150    print_bool("    enabled", manifest.branch_protection.enabled);
151    println!(
152        "    required_approvals: {}",
153        manifest.branch_protection.required_approvals
154    );
155    print_bool(
156        "    dismiss_stale_reviews",
157        manifest.branch_protection.dismiss_stale_reviews,
158    );
159    print_bool(
160        "    require_code_owner_reviews",
161        manifest.branch_protection.require_code_owner_reviews,
162    );
163    print_bool(
164        "    require_status_checks",
165        manifest.branch_protection.require_status_checks,
166    );
167    print_bool(
168        "    strict_status_checks",
169        manifest.branch_protection.strict_status_checks,
170    );
171    print_bool(
172        "    enforce_admins",
173        manifest.branch_protection.enforce_admins,
174    );
175    print_bool(
176        "    required_linear_history",
177        manifest.branch_protection.required_linear_history,
178    );
179    print_bool(
180        "    allow_force_pushes",
181        manifest.branch_protection.allow_force_pushes,
182    );
183    print_bool(
184        "    allow_deletions",
185        manifest.branch_protection.allow_deletions,
186    );
187
188    println!();
189    println!("  {}", style("Templates").bold());
190    println!("    branch: {}", manifest.templates.branch);
191    println!(
192        "    commit_message_prefix: {}",
193        manifest.templates.commit_message_prefix
194    );
195    if let Some(ref dir) = manifest.templates.custom_dir {
196        println!("    custom_dir: {dir}");
197    }
198
199    if manifest.systems.is_empty() {
200        println!();
201        println!("  {}", style("Systems").bold());
202        println!("    (none)");
203    } else {
204        for sys in &manifest.systems {
205            println!();
206            println!("  {}", style(format!("System: {}", sys.name)).bold());
207            println!("    id: {}", style(&sys.id).cyan());
208            if !sys.exclude.is_empty() {
209                println!("    exclude: {}", sys.exclude.join(", "));
210            }
211            if !sys.repos.is_empty() {
212                println!("    repos: {}", sys.repos.join(", "));
213            }
214        }
215    }
216
217    println!();
218    Ok(())
219}
220
221fn print_bool(label: &str, value: bool) {
222    if value {
223        println!("{label}: {}", style("true").green());
224    } else {
225        println!("{label}: {}", style("false").red());
226    }
227}
228
229fn run_edit(config_override: Option<&str>) -> Result<()> {
230    let path = resolve_config_path(config_override);
231    if !path.exists() {
232        bail!(
233            "No configuration file at {}. Run `ward init` first.",
234            path.display()
235        );
236    }
237
238    let editor = std::env::var("EDITOR")
239        .or_else(|_| std::env::var("VISUAL"))
240        .unwrap_or_else(|_| "vi".to_owned());
241
242    let status = std::process::Command::new(&editor)
243        .arg(&path)
244        .status()
245        .with_context(|| format!("Failed to open editor '{editor}'"))?;
246
247    if !status.success() {
248        bail!("Editor exited with non-zero status");
249    }
250
251    let content = std::fs::read_to_string(&path)?;
252    match toml::from_str::<Manifest>(&content) {
253        Ok(_) => println!("  {} Configuration is valid.", style("[ok]").green()),
254        Err(e) => {
255            println!("  {} Configuration has errors: {e}", style("[!!]").red());
256        }
257    }
258
259    Ok(())
260}
261
262fn run_path(config_override: Option<&str>) -> Result<()> {
263    let path = resolve_config_path(config_override);
264    let abs = std::fs::canonicalize(&path).unwrap_or_else(|_| path.clone());
265    println!("{}", abs.display());
266    if path.exists() {
267        println!("  {} File exists.", style("[ok]").green());
268    } else {
269        println!("  {} File does not exist.", style("[..]").yellow());
270    }
271    Ok(())
272}
273
274fn lookup_key(key: &str) -> Option<ValueKind> {
275    VALID_KEYS.iter().find(|(k, _)| *k == key).map(|(_, v)| *v)
276}
277
278pub fn apply_set(path: &Path, key: &str, value: &str) -> Result<()> {
279    let kind = lookup_key(key).ok_or_else(|| {
280        anyhow::anyhow!(
281            "Unknown config key '{key}'. Valid keys:\n  {}",
282            VALID_KEYS
283                .iter()
284                .map(|(k, _)| *k)
285                .collect::<Vec<_>>()
286                .join("\n  ")
287        )
288    })?;
289
290    let content = std::fs::read_to_string(path)
291        .with_context(|| format!("Failed to read {}", path.display()))?;
292    let mut doc: DocumentMut = content
293        .parse()
294        .with_context(|| format!("Failed to parse {}", path.display()))?;
295
296    let parts: Vec<&str> = key.splitn(2, '.').collect();
297    if parts.len() != 2 {
298        bail!("Key must use dot notation (e.g., security.push_protection)");
299    }
300    let (section, field) = (parts[0], parts[1]);
301
302    if doc.get(section).is_none() {
303        doc[section] = toml_edit::Item::Table(toml_edit::Table::new());
304    }
305
306    match kind {
307        ValueKind::Bool => {
308            let parsed: bool = value
309                .parse()
310                .with_context(|| format!("Expected bool for '{key}', got '{value}'"))?;
311            doc[section][field] = toml_edit::value(parsed);
312        }
313        ValueKind::Int => {
314            let parsed: i64 = value
315                .parse()
316                .with_context(|| format!("Expected integer for '{key}', got '{value}'"))?;
317            doc[section][field] = toml_edit::value(parsed);
318        }
319        ValueKind::Str => {
320            doc[section][field] = toml_edit::value(value);
321        }
322    }
323
324    std::fs::write(path, doc.to_string())
325        .with_context(|| format!("Failed to write {}", path.display()))?;
326
327    println!("  {} Set {key} = {value}", style("[ok]").green());
328    Ok(())
329}
330
331fn run_add_system(config_override: Option<&str>) -> Result<()> {
332    let path = resolve_config_path(config_override);
333    if !path.exists() {
334        bail!(
335            "No configuration file at {}. Run `ward init` first.",
336            path.display()
337        );
338    }
339
340    let id: String = Input::new().with_prompt("  System ID").interact_text()?;
341
342    let name: String = Input::new()
343        .with_prompt("  Display name")
344        .default(id.clone())
345        .interact_text()?;
346
347    let exclude_raw: String = Input::new()
348        .with_prompt("  Exclude patterns (comma-separated, optional)")
349        .default(String::new())
350        .allow_empty(true)
351        .interact_text()?;
352
353    let exclude: Vec<String> = exclude_raw
354        .split(',')
355        .map(|s| s.trim().to_owned())
356        .filter(|s| !s.is_empty())
357        .collect();
358
359    let repos_raw: String = Input::new()
360        .with_prompt("  Explicit repos (comma-separated, optional)")
361        .default(String::new())
362        .allow_empty(true)
363        .interact_text()?;
364
365    let repos: Vec<String> = repos_raw
366        .split(',')
367        .map(|s| s.trim().to_owned())
368        .filter(|s| !s.is_empty())
369        .collect();
370
371    append_system(&path, &id, &name, &exclude, &repos)?;
372    println!(
373        "  {} Added system {} ({})",
374        style("[ok]").green(),
375        style(&name).cyan(),
376        id,
377    );
378    Ok(())
379}
380
381pub fn append_system(
382    path: &Path,
383    id: &str,
384    name: &str,
385    exclude: &[String],
386    repos: &[String],
387) -> Result<()> {
388    let content = std::fs::read_to_string(path)
389        .with_context(|| format!("Failed to read {}", path.display()))?;
390
391    let manifest: Manifest =
392        toml::from_str(&content).with_context(|| format!("Failed to parse {}", path.display()))?;
393    if manifest.systems.iter().any(|s| s.id == id) {
394        bail!("System '{id}' already exists");
395    }
396
397    let mut doc: DocumentMut = content
398        .parse()
399        .with_context(|| format!("Failed to parse {}", path.display()))?;
400
401    let systems = doc
402        .entry("systems")
403        .or_insert_with(|| toml_edit::Item::ArrayOfTables(toml_edit::ArrayOfTables::new()));
404
405    let arr = systems
406        .as_array_of_tables_mut()
407        .ok_or_else(|| anyhow::anyhow!("'systems' is not an array of tables"))?;
408
409    let mut table = toml_edit::Table::new();
410    table.insert("id", toml_edit::value(id));
411    table.insert("name", toml_edit::value(name));
412    if !exclude.is_empty() {
413        let mut arr_val = toml_edit::Array::new();
414        for e in exclude {
415            arr_val.push(e.as_str());
416        }
417        table.insert("exclude", toml_edit::value(arr_val));
418    }
419    if !repos.is_empty() {
420        let mut arr_val = toml_edit::Array::new();
421        for r in repos {
422            arr_val.push(r.as_str());
423        }
424        table.insert("repos", toml_edit::value(arr_val));
425    }
426
427    arr.push(table);
428
429    std::fs::write(path, doc.to_string())
430        .with_context(|| format!("Failed to write {}", path.display()))?;
431
432    Ok(())
433}
434
435pub fn remove_system_by_id(path: &Path, id: &str) -> Result<()> {
436    let content = std::fs::read_to_string(path)
437        .with_context(|| format!("Failed to read {}", path.display()))?;
438
439    let mut doc: DocumentMut = content
440        .parse()
441        .with_context(|| format!("Failed to parse {}", path.display()))?;
442
443    let systems = doc
444        .get_mut("systems")
445        .and_then(|s| s.as_array_of_tables_mut())
446        .ok_or_else(|| anyhow::anyhow!("No [[systems]] found in configuration"))?;
447
448    let idx = systems
449        .iter()
450        .position(|t| t.get("id").and_then(|v| v.as_str()) == Some(id))
451        .ok_or_else(|| anyhow::anyhow!("System '{id}' not found"))?;
452
453    systems.remove(idx);
454
455    std::fs::write(path, doc.to_string())
456        .with_context(|| format!("Failed to write {}", path.display()))?;
457
458    println!(
459        "  {} Removed system {}",
460        style("[ok]").green(),
461        style(id).cyan()
462    );
463    Ok(())
464}
465
466#[cfg(test)]
467mod tests {
468    use super::*;
469    use tempfile::NamedTempFile;
470
471    const SAMPLE_TOML: &str = r#"# Ward configuration
472[org]
473name = "my-org"
474
475[security]
476# Enable secret scanning
477secret_scanning = true
478push_protection = false
479dependabot_alerts = true
480dependabot_security_updates = true
481
482[branch_protection]
483enabled = true
484required_approvals = 1
485
486[templates]
487branch = "chore/ward-setup"
488commit_message_prefix = "chore: "
489
490[[systems]]
491id = "backend"
492name = "Backend Services"
493exclude = ["operations?"]
494"#;
495
496    fn write_temp(content: &str) -> NamedTempFile {
497        let file = NamedTempFile::new().unwrap();
498        std::fs::write(file.path(), content).unwrap();
499        file
500    }
501
502    #[test]
503    fn test_config_set_bool_value() {
504        let file = write_temp(SAMPLE_TOML);
505        apply_set(file.path(), "security.push_protection", "true").unwrap();
506
507        let updated = std::fs::read_to_string(file.path()).unwrap();
508        let manifest: Manifest = toml::from_str(&updated).unwrap();
509        assert!(manifest.security.push_protection);
510    }
511
512    #[test]
513    fn test_config_set_string_value() {
514        let file = write_temp(SAMPLE_TOML);
515        apply_set(file.path(), "org.name", "new-org").unwrap();
516
517        let updated = std::fs::read_to_string(file.path()).unwrap();
518        let manifest: Manifest = toml::from_str(&updated).unwrap();
519        assert_eq!(manifest.org.name, "new-org");
520    }
521
522    #[test]
523    fn test_config_set_integer_value() {
524        let file = write_temp(SAMPLE_TOML);
525        apply_set(file.path(), "branch_protection.required_approvals", "3").unwrap();
526
527        let updated = std::fs::read_to_string(file.path()).unwrap();
528        let manifest: Manifest = toml::from_str(&updated).unwrap();
529        assert_eq!(manifest.branch_protection.required_approvals, 3);
530    }
531
532    #[test]
533    fn test_config_set_preserves_comments() {
534        let file = write_temp(SAMPLE_TOML);
535        apply_set(file.path(), "security.push_protection", "true").unwrap();
536
537        let updated = std::fs::read_to_string(file.path()).unwrap();
538        assert!(
539            updated.contains("# Ward configuration"),
540            "Top-level comment should be preserved"
541        );
542        assert!(
543            updated.contains("# Enable secret scanning"),
544            "Inline comment should be preserved"
545        );
546    }
547
548    #[test]
549    fn test_config_set_invalid_key() {
550        let file = write_temp(SAMPLE_TOML);
551        let result = apply_set(file.path(), "nonexistent.key", "value");
552        assert!(result.is_err());
553        assert!(
554            result
555                .unwrap_err()
556                .to_string()
557                .contains("Unknown config key")
558        );
559    }
560
561    #[test]
562    fn test_config_add_system_to_toml() {
563        let file = write_temp(SAMPLE_TOML);
564        append_system(
565            file.path(),
566            "frontend",
567            "Frontend Apps",
568            &["workflows".to_owned()],
569            &[],
570        )
571        .unwrap();
572
573        let updated = std::fs::read_to_string(file.path()).unwrap();
574        let manifest: Manifest = toml::from_str(&updated).unwrap();
575        assert_eq!(manifest.systems.len(), 2);
576        assert_eq!(manifest.systems[1].id, "frontend");
577        assert_eq!(manifest.systems[1].name, "Frontend Apps");
578        assert_eq!(manifest.systems[1].exclude, vec!["workflows"]);
579    }
580
581    #[test]
582    fn test_config_add_system_rejects_duplicate() {
583        let file = write_temp(SAMPLE_TOML);
584        let result = append_system(file.path(), "backend", "Duplicate", &[], &[]);
585        assert!(result.is_err());
586        assert!(result.unwrap_err().to_string().contains("already exists"));
587    }
588
589    #[test]
590    fn test_config_remove_system_from_toml() {
591        let file = write_temp(SAMPLE_TOML);
592        remove_system_by_id(file.path(), "backend").unwrap();
593
594        let updated = std::fs::read_to_string(file.path()).unwrap();
595        let manifest: Manifest = toml::from_str(&updated).unwrap();
596        assert!(manifest.systems.is_empty());
597    }
598
599    #[test]
600    fn test_config_remove_nonexistent_system() {
601        let file = write_temp(SAMPLE_TOML);
602        let result = remove_system_by_id(file.path(), "nope");
603        assert!(result.is_err());
604        assert!(result.unwrap_err().to_string().contains("not found"));
605    }
606
607    #[test]
608    fn test_resolve_config_path_default() {
609        let path = resolve_config_path(None);
610        assert_eq!(path, PathBuf::from("ward.toml"));
611    }
612
613    #[test]
614    fn test_resolve_config_path_override() {
615        let path = resolve_config_path(Some("/tmp/custom.toml"));
616        assert_eq!(path, PathBuf::from("/tmp/custom.toml"));
617    }
618}