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 Show,
20 Edit,
22 Path,
24 Set {
26 key: String,
28 value: String,
30 },
31 AddSystem,
33 RemoveSystem {
35 id: String,
37 #[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}