1use crate::config::{
6 global_config_path, load_global_config, load_project_config, project_config_path,
7 save_global_config, save_project_config, validate_config, Config,
8};
9use crate::error::{Autom8Error, Result};
10use crate::git::is_git_repo;
11use crate::output::{BOLD, CYAN, GRAY, GREEN, RESET, YELLOW};
12use clap::Subcommand;
13use std::fs;
14
15pub const VALID_CONFIG_KEYS: &[&str] = &[
17 "review",
18 "commit",
19 "pull_request",
20 "pull_request_draft",
21 "worktree",
22 "worktree_path_pattern",
23 "worktree_cleanup",
24];
25
26#[derive(Debug, Clone, Copy, PartialEq, Eq)]
28pub enum ConfigScope {
29 Global,
31 Project,
33 Both,
35}
36
37#[derive(Subcommand, Debug, Clone)]
39pub enum ConfigSubcommand {
40 #[command(after_help = "EXAMPLES:
42 autom8 config set review false # Disable review step in project config
43 autom8 config set --global commit true # Enable auto-commit globally
44 autom8 config set worktree_path_pattern \"{repo}-feature-{branch}\"
45
46VALID KEYS:
47 review - Enable code review step (true/false)
48 commit - Enable auto-commit (true/false)
49 pull_request - Enable auto-PR creation (true/false, requires commit=true)
50 pull_request_draft - Create PRs as drafts (true/false, requires pull_request=true)
51 worktree - Enable worktree mode (true/false)
52 worktree_path_pattern - Pattern for worktree directory names (string)
53 worktree_cleanup - Auto-cleanup worktrees after completion (true/false)
54
55VALUE FORMATS:
56 Boolean: true, false (case-insensitive)
57 String: Quoted or unquoted text
58
59VALIDATION:
60 - Setting pull_request=true requires commit=true
61 - Invalid keys or values are rejected with an error message")]
62 Set {
63 #[arg(short, long)]
65 global: bool,
66
67 key: String,
69
70 value: String,
72 },
73
74 #[command(after_help = "EXAMPLES:
76 autom8 config reset # Reset project config (with confirmation)
77 autom8 config reset --global # Reset global config (with confirmation)
78 autom8 config reset -y # Reset without confirmation prompt
79 autom8 config reset --global -y # Reset global config without prompting
80
81DEFAULT VALUES:
82 review = true
83 commit = true
84 pull_request = true
85 pull_request_draft = false
86 worktree = true
87 worktree_path_pattern = \"{repo}-wt-{branch}\"
88 worktree_cleanup = false
89
90BEHAVIOR:
91 - Prompts for confirmation before resetting (unless -y/--yes is used)
92 - Overwrites the config file with default values
93 - Displays the new configuration after reset
94 - If config file doesn't exist, informs you defaults are already in use")]
95 Reset {
96 #[arg(short, long)]
98 global: bool,
99
100 #[arg(short, long)]
102 yes: bool,
103 },
104}
105
106pub fn config_display_command(scope: ConfigScope) -> Result<()> {
120 match scope {
121 ConfigScope::Global => display_global_config(),
122 ConfigScope::Project => display_project_config(),
123 ConfigScope::Both => {
124 display_global_config()?;
125 println!();
126 display_project_config()
127 }
128 }
129}
130
131fn display_global_config() -> Result<()> {
133 println!("{BOLD}# Global config{RESET}");
134 println!("{GRAY}# {}{RESET}", global_config_path()?.display());
135 println!();
136
137 let config_path = global_config_path()?;
138
139 if !config_path.exists() {
140 println!("{YELLOW}# (file does not exist, using defaults){RESET}");
141 println!();
142 print_config_as_toml(&Config::default());
143 return Ok(());
144 }
145
146 let config = load_config_from_path(&config_path)?;
147 print_config_as_toml(&config);
148
149 Ok(())
150}
151
152fn display_project_config() -> Result<()> {
154 if !is_git_repo() {
156 return Err(Autom8Error::Config(
157 "Not in a git repository.\n\n\
158 Project configuration requires being inside a git repository.\n\
159 Run this command from within a git repository, or use --global to view global config."
160 .to_string(),
161 ));
162 }
163
164 println!("{BOLD}# Project config{RESET}");
165 println!("{GRAY}# {}{RESET}", project_config_path()?.display());
166 println!();
167
168 let config_path = project_config_path()?;
169
170 if !config_path.exists() {
171 println!("{YELLOW}# (file does not exist, using global config or defaults){RESET}");
172 println!();
173 let effective_config = if global_config_path()?.exists() {
175 load_config_from_path(&global_config_path()?)?
176 } else {
177 Config::default()
178 };
179 print_config_as_toml(&effective_config);
180 return Ok(());
181 }
182
183 let config = load_config_from_path(&config_path)?;
184 print_config_as_toml(&config);
185
186 Ok(())
187}
188
189fn load_config_from_path(path: &std::path::Path) -> Result<Config> {
191 let content = fs::read_to_string(path)?;
192 toml::from_str(&content).map_err(|e| {
193 Autom8Error::Config(format!("Failed to parse config file at {:?}: {}", path, e))
194 })
195}
196
197pub fn config_set_command(key: &str, value: &str, global: bool) -> Result<()> {
217 if !VALID_CONFIG_KEYS.contains(&key) {
219 return Err(Autom8Error::Config(format!(
220 "Invalid configuration key: '{}'\n\n\
221 Valid keys are:\n - {}\n\n\
222 Use 'autom8 config set --help' for more information.",
223 key,
224 VALID_CONFIG_KEYS.join("\n - ")
225 )));
226 }
227
228 if !global && !is_git_repo() {
230 return Err(Autom8Error::Config(
231 "Not in a git repository.\n\n\
232 Project configuration requires being inside a git repository.\n\
233 Either:\n - Run this command from within a git repository, or\n - Use --global to set the global config."
234 .to_string(),
235 ));
236 }
237
238 let mut config = if global {
240 load_global_config()?
241 } else {
242 load_project_config()?
243 };
244
245 set_config_value(&mut config, key, value)?;
247
248 validate_config(&config).map_err(|e| Autom8Error::Config(e.to_string()))?;
250
251 let config_type = if global { "global" } else { "project" };
253 if global {
254 save_global_config(&config)?;
255 } else {
256 save_project_config(&config)?;
257 }
258
259 let display_value = format_value_for_display(key, &config);
261 println!("{GREEN}Set {CYAN}{key}{RESET} = {display_value} in {config_type} config{RESET}");
262
263 Ok(())
264}
265
266fn set_config_value(config: &mut Config, key: &str, value: &str) -> Result<()> {
270 match key {
271 "review" => {
272 config.review = parse_bool_value(value, key)?;
273 }
274 "commit" => {
275 config.commit = parse_bool_value(value, key)?;
276 }
277 "pull_request" => {
278 config.pull_request = parse_bool_value(value, key)?;
279 }
280 "pull_request_draft" => {
281 config.pull_request_draft = parse_bool_value(value, key)?;
282 }
283 "worktree" => {
284 config.worktree = parse_bool_value(value, key)?;
285 }
286 "worktree_path_pattern" => {
287 config.worktree_path_pattern = value.to_string();
288 }
289 "worktree_cleanup" => {
290 config.worktree_cleanup = parse_bool_value(value, key)?;
291 }
292 _ => {
293 return Err(Autom8Error::Config(format!("Unknown key: {}", key)));
295 }
296 }
297 Ok(())
298}
299
300fn parse_bool_value(value: &str, key: &str) -> Result<bool> {
302 match value.to_lowercase().as_str() {
303 "true" => Ok(true),
304 "false" => Ok(false),
305 _ => Err(Autom8Error::Config(format!(
306 "Invalid value for '{}': expected boolean (true/false), got '{}'",
307 key, value
308 ))),
309 }
310}
311
312fn format_value_for_display(key: &str, config: &Config) -> String {
314 match key {
315 "review" => config.review.to_string(),
316 "commit" => config.commit.to_string(),
317 "pull_request" => config.pull_request.to_string(),
318 "pull_request_draft" => config.pull_request_draft.to_string(),
319 "worktree" => config.worktree.to_string(),
320 "worktree_path_pattern" => format!("\"{}\"", config.worktree_path_pattern),
321 "worktree_cleanup" => config.worktree_cleanup.to_string(),
322 _ => "unknown".to_string(),
323 }
324}
325
326pub fn config_reset_command(global: bool, yes: bool) -> Result<()> {
345 if !global && !is_git_repo() {
347 return Err(Autom8Error::Config(
348 "Not in a git repository.\n\n\
349 Project configuration requires being inside a git repository.\n\
350 Either:\n - Run this command from within a git repository, or\n - Use --global to reset the global config."
351 .to_string(),
352 ));
353 }
354
355 let config_type = if global { "global" } else { "project" };
356 let config_path = if global {
357 global_config_path()?
358 } else {
359 project_config_path()?
360 };
361
362 if !config_path.exists() {
364 println!(
365 "{YELLOW}Config file does not exist: {}{RESET}",
366 config_path.display()
367 );
368 println!();
369 println!("Default values are already in use:");
370 println!();
371 print_config_as_toml(&Config::default());
372 return Ok(());
373 }
374
375 if !yes {
377 print!("Reset {} config to defaults? [y/N] ", config_type);
378 std::io::Write::flush(&mut std::io::stdout())?;
379
380 let mut input = String::new();
381 std::io::stdin().read_line(&mut input)?;
382
383 let input = input.trim().to_lowercase();
384 if input != "y" && input != "yes" {
385 println!("{YELLOW}Reset cancelled.{RESET}");
386 return Ok(());
387 }
388 }
389
390 let default_config = Config::default();
392
393 if global {
394 save_global_config(&default_config)?;
395 } else {
396 save_project_config(&default_config)?;
397 }
398
399 println!("{GREEN}Reset {} config to defaults:{RESET}", config_type);
401 println!();
402 print_config_as_toml(&default_config);
403
404 Ok(())
405}
406
407fn print_config_as_toml(config: &Config) {
409 println!("{CYAN}review{RESET} = {}", config.review);
410 println!("{CYAN}commit{RESET} = {}", config.commit);
411 println!("{CYAN}pull_request{RESET} = {}", config.pull_request);
412 println!(
413 "{CYAN}pull_request_draft{RESET} = {}",
414 config.pull_request_draft
415 );
416 println!("{CYAN}worktree{RESET} = {}", config.worktree);
417 println!(
418 "{CYAN}worktree_path_pattern{RESET} = \"{}\"",
419 config.worktree_path_pattern
420 );
421 println!(
422 "{CYAN}worktree_cleanup{RESET} = {}",
423 config.worktree_cleanup
424 );
425}
426
427#[cfg(test)]
429fn config_to_toml_string(config: &Config) -> String {
430 format!(
431 "review = {}\n\
432 commit = {}\n\
433 pull_request = {}\n\
434 pull_request_draft = {}\n\
435 worktree = {}\n\
436 worktree_path_pattern = \"{}\"\n\
437 worktree_cleanup = {}",
438 config.review,
439 config.commit,
440 config.pull_request,
441 config.pull_request_draft,
442 config.worktree,
443 config.worktree_path_pattern,
444 config.worktree_cleanup
445 )
446}
447
448#[cfg(test)]
449mod tests {
450 use super::*;
451
452 #[test]
457 fn test_us001_config_to_toml_produces_valid_toml() {
458 let config = Config::default();
459 let toml_str = config_to_toml_string(&config);
460
461 let parsed: std::result::Result<Config, _> = toml::from_str(&toml_str);
463 assert!(
464 parsed.is_ok(),
465 "Generated TOML should be parseable: {:?}",
466 parsed.err()
467 );
468
469 let parsed_config = parsed.unwrap();
471 assert_eq!(parsed_config, config);
472 }
473
474 #[test]
475 fn test_us001_config_to_toml_includes_all_fields() {
476 let config = Config {
477 review: false,
478 commit: true,
479 pull_request: false,
480 pull_request_draft: true,
481 worktree: false,
482 worktree_path_pattern: "custom-{branch}".to_string(),
483 worktree_cleanup: true,
484 };
485 let toml_str = config_to_toml_string(&config);
486
487 assert!(toml_str.contains("review = false"));
488 assert!(toml_str.contains("commit = true"));
489 assert!(toml_str.contains("pull_request = false"));
490 assert!(toml_str.contains("pull_request_draft = true"));
491 assert!(toml_str.contains("worktree = false"));
492 assert!(toml_str.contains("worktree_path_pattern = \"custom-{branch}\""));
493 assert!(toml_str.contains("worktree_cleanup = true"));
494 }
495
496 #[test]
497 fn test_us001_config_to_toml_default_values() {
498 let config = Config::default();
499 let toml_str = config_to_toml_string(&config);
500
501 assert!(toml_str.contains("review = true"));
503 assert!(toml_str.contains("commit = true"));
504 assert!(toml_str.contains("pull_request = true"));
505 assert!(toml_str.contains("worktree = true"));
506 assert!(toml_str.contains("worktree_path_pattern = \"{repo}-wt-{branch}\""));
507 assert!(toml_str.contains("worktree_cleanup = false"));
508 }
509
510 #[test]
511 fn test_us001_config_scope_variants() {
512 assert_ne!(ConfigScope::Global, ConfigScope::Project);
514 assert_ne!(ConfigScope::Global, ConfigScope::Both);
515 assert_ne!(ConfigScope::Project, ConfigScope::Both);
516 }
517
518 #[test]
523 fn test_us002_valid_config_keys_constant() {
524 assert!(VALID_CONFIG_KEYS.contains(&"review"));
526 assert!(VALID_CONFIG_KEYS.contains(&"commit"));
527 assert!(VALID_CONFIG_KEYS.contains(&"pull_request"));
528 assert!(VALID_CONFIG_KEYS.contains(&"pull_request_draft"));
529 assert!(VALID_CONFIG_KEYS.contains(&"worktree"));
530 assert!(VALID_CONFIG_KEYS.contains(&"worktree_path_pattern"));
531 assert!(VALID_CONFIG_KEYS.contains(&"worktree_cleanup"));
532 assert_eq!(
533 VALID_CONFIG_KEYS.len(),
534 7,
535 "Should have exactly 7 valid keys"
536 );
537 }
538
539 #[test]
540 fn test_us002_parse_bool_value_true() {
541 assert!(parse_bool_value("true", "test").unwrap());
543 assert!(parse_bool_value("TRUE", "test").unwrap());
544 assert!(parse_bool_value("True", "test").unwrap());
545 assert!(parse_bool_value("tRuE", "test").unwrap());
546 }
547
548 #[test]
549 fn test_us002_parse_bool_value_false() {
550 assert!(!parse_bool_value("false", "test").unwrap());
552 assert!(!parse_bool_value("FALSE", "test").unwrap());
553 assert!(!parse_bool_value("False", "test").unwrap());
554 assert!(!parse_bool_value("fAlSe", "test").unwrap());
555 }
556
557 #[test]
558 fn test_us002_parse_bool_value_invalid() {
559 let result = parse_bool_value("yes", "review");
561 assert!(result.is_err());
562 let err = result.unwrap_err().to_string();
563 assert!(err.contains("Invalid value for 'review'"));
564 assert!(err.contains("expected boolean"));
565 assert!(err.contains("yes"));
566
567 let result = parse_bool_value("1", "commit");
568 assert!(result.is_err());
569
570 let result = parse_bool_value("on", "worktree");
571 assert!(result.is_err());
572
573 let result = parse_bool_value("", "review");
574 assert!(result.is_err());
575 }
576
577 #[test]
578 fn test_us002_set_config_value_review() {
579 let mut config = Config::default();
580 assert!(config.review); set_config_value(&mut config, "review", "false").unwrap();
583 assert!(!config.review);
584
585 set_config_value(&mut config, "review", "true").unwrap();
586 assert!(config.review);
587 }
588
589 #[test]
590 fn test_us002_set_config_value_commit() {
591 let mut config = Config::default();
592 set_config_value(&mut config, "commit", "false").unwrap();
593 assert!(!config.commit);
594 }
595
596 #[test]
597 fn test_us002_set_config_value_pull_request() {
598 let mut config = Config::default();
599 set_config_value(&mut config, "pull_request", "false").unwrap();
600 assert!(!config.pull_request);
601 }
602
603 #[test]
604 fn test_us002_set_config_value_worktree() {
605 let mut config = Config::default();
606 set_config_value(&mut config, "worktree", "false").unwrap();
607 assert!(!config.worktree);
608 }
609
610 #[test]
611 fn test_us002_set_config_value_worktree_cleanup() {
612 let mut config = Config::default();
613 assert!(!config.worktree_cleanup); set_config_value(&mut config, "worktree_cleanup", "true").unwrap();
616 assert!(config.worktree_cleanup);
617 }
618
619 #[test]
620 fn test_us002_set_config_value_worktree_path_pattern() {
621 let mut config = Config::default();
622 let custom_pattern = "{repo}-feature-{branch}";
623
624 set_config_value(&mut config, "worktree_path_pattern", custom_pattern).unwrap();
625 assert_eq!(config.worktree_path_pattern, custom_pattern);
626 }
627
628 #[test]
629 fn test_us002_set_config_value_worktree_path_pattern_with_spaces() {
630 let mut config = Config::default();
631 let pattern_with_spaces = "my-repo wt {branch}";
632
633 set_config_value(&mut config, "worktree_path_pattern", pattern_with_spaces).unwrap();
634 assert_eq!(config.worktree_path_pattern, pattern_with_spaces);
635 }
636
637 #[test]
638 fn test_us002_format_value_for_display_boolean() {
639 let mut config = Config::default();
640 config.review = true;
641 config.commit = false;
642
643 assert_eq!(format_value_for_display("review", &config), "true");
644 assert_eq!(format_value_for_display("commit", &config), "false");
645 }
646
647 #[test]
648 fn test_us002_format_value_for_display_string() {
649 let mut config = Config::default();
650 config.worktree_path_pattern = "custom-{branch}".to_string();
651
652 assert_eq!(
654 format_value_for_display("worktree_path_pattern", &config),
655 "\"custom-{branch}\""
656 );
657 }
658
659 #[test]
660 fn test_us002_invalid_key_rejected() {
661 let mut config = Config::default();
662 let result = set_config_value(&mut config, "invalid_key", "value");
663 assert!(result.is_err());
664 assert!(result.unwrap_err().to_string().contains("Unknown key"));
665 }
666
667 #[test]
668 fn test_us002_all_valid_keys_settable() {
669 for key in VALID_CONFIG_KEYS.iter() {
671 let mut config = Config::default();
672 let value = match *key {
673 "worktree_path_pattern" => "custom-pattern",
674 _ => "false", };
676 let result = set_config_value(&mut config, key, value);
677 assert!(result.is_ok(), "Setting key '{}' should succeed", key);
678 }
679 }
680
681 #[test]
682 fn test_us002_validation_enforced_pr_without_commit() {
683 let mut config = Config {
685 review: true,
686 commit: false,
687 pull_request: false,
688 ..Default::default()
689 };
690
691 set_config_value(&mut config, "pull_request", "true").unwrap();
693
694 let validation_result = validate_config(&config);
696 assert!(validation_result.is_err());
697 assert!(validation_result
698 .unwrap_err()
699 .to_string()
700 .contains("commit"));
701 }
702
703 #[test]
704 fn test_us002_validation_enforced_commit_false_with_pr_true() {
705 let mut config = Config {
707 review: true,
708 commit: true,
709 pull_request: true,
710 ..Default::default()
711 };
712
713 set_config_value(&mut config, "commit", "false").unwrap();
715
716 let validation_result = validate_config(&config);
718 assert!(validation_result.is_err());
719 }
720
721 #[test]
722 fn test_us002_valid_combinations_pass_validation() {
723 let valid_combos = [
725 (true, true, true), (true, true, false), (true, false, false), (false, true, true), ];
730
731 for (review, commit, pull_request) in valid_combos {
732 let config = Config {
733 review,
734 commit,
735 pull_request,
736 ..Default::default()
737 };
738 let result = validate_config(&config);
739 assert!(
740 result.is_ok(),
741 "Config (review={}, commit={}, pull_request={}) should be valid",
742 review,
743 commit,
744 pull_request
745 );
746 }
747 }
748
749 #[test]
750 fn test_us002_case_insensitive_boolean_values() {
751 let mut config = Config::default();
752
753 set_config_value(&mut config, "review", "TRUE").unwrap();
755 assert!(config.review);
756
757 set_config_value(&mut config, "review", "False").unwrap();
758 assert!(!config.review);
759
760 set_config_value(&mut config, "worktree", "TrUe").unwrap();
761 assert!(config.worktree);
762 }
763
764 #[test]
765 fn test_us002_invalid_boolean_value_descriptive_error() {
766 let result = parse_bool_value("yes", "review");
767 assert!(result.is_err());
768
769 let error_msg = result.unwrap_err().to_string();
770 assert!(error_msg.contains("review"), "Error should mention the key");
771 assert!(
772 error_msg.contains("boolean"),
773 "Error should mention expected type"
774 );
775 assert!(
776 error_msg.contains("true/false"),
777 "Error should mention valid values"
778 );
779 assert!(
780 error_msg.contains("yes"),
781 "Error should mention the invalid value"
782 );
783 }
784
785 #[test]
790 fn test_us003_config_reset_function_exists() {
791 use super::config_reset_command;
793 let _: fn(bool, bool) -> Result<()> = config_reset_command;
794 }
795
796 #[test]
797 fn test_us003_default_config_values() {
798 let config = Config::default();
800 assert!(config.review, "default review should be true");
801 assert!(config.commit, "default commit should be true");
802 assert!(config.pull_request, "default pull_request should be true");
803 assert!(config.worktree, "default worktree should be true");
804 assert_eq!(
805 config.worktree_path_pattern, "{repo}-wt-{branch}",
806 "default worktree_path_pattern should be '{{repo}}-wt-{{branch}}'"
807 );
808 assert!(
809 !config.worktree_cleanup,
810 "default worktree_cleanup should be false"
811 );
812 }
813
814 #[test]
815 fn test_us003_reset_produces_defaults() {
816 let reset_config = Config::default();
818 let expected = Config::default();
819
820 assert_eq!(reset_config.review, expected.review);
821 assert_eq!(reset_config.commit, expected.commit);
822 assert_eq!(reset_config.pull_request, expected.pull_request);
823 assert_eq!(reset_config.worktree, expected.worktree);
824 assert_eq!(
825 reset_config.worktree_path_pattern,
826 expected.worktree_path_pattern
827 );
828 assert_eq!(reset_config.worktree_cleanup, expected.worktree_cleanup);
829 }
830
831 #[test]
832 fn test_us003_config_toml_from_default() {
833 let config = Config::default();
835 let toml_str = config_to_toml_string(&config);
836
837 assert!(toml_str.contains("review = true"));
839 assert!(toml_str.contains("commit = true"));
840 assert!(toml_str.contains("pull_request = true"));
841 assert!(toml_str.contains("worktree = true"));
842 assert!(toml_str.contains("worktree_path_pattern = \"{repo}-wt-{branch}\""));
843 assert!(toml_str.contains("worktree_cleanup = false"));
844 }
845
846 #[test]
847 fn test_us003_reset_toml_round_trip() {
848 let default_config = Config::default();
850 let toml_str = config_to_toml_string(&default_config);
851
852 let parsed: Config = toml::from_str(&toml_str).unwrap();
853 assert_eq!(parsed.review, default_config.review);
854 assert_eq!(parsed.commit, default_config.commit);
855 assert_eq!(parsed.pull_request, default_config.pull_request);
856 assert_eq!(parsed.worktree, default_config.worktree);
857 assert_eq!(
858 parsed.worktree_path_pattern,
859 default_config.worktree_path_pattern
860 );
861 assert_eq!(parsed.worktree_cleanup, default_config.worktree_cleanup);
862 }
863
864 #[test]
865 fn test_us003_modified_config_differs_from_default() {
866 let mut modified = Config::default();
868 modified.review = false;
869 modified.commit = false;
870 modified.worktree_path_pattern = "custom-{branch}".to_string();
871
872 let default = Config::default();
873
874 assert_ne!(modified.review, default.review);
875 assert_ne!(modified.commit, default.commit);
876 assert_ne!(
877 modified.worktree_path_pattern,
878 default.worktree_path_pattern
879 );
880 }
881
882 #[test]
883 fn test_us003_global_vs_project_config_paths_different() {
884 let global_path = global_config_path();
887 let project_path = project_config_path();
888
889 if let (Ok(global), Ok(project)) = (global_path, project_path) {
891 assert_ne!(
892 global, project,
893 "Global and project config paths should be different"
894 );
895 }
896 }
897
898 #[test]
899 fn test_us003_save_global_config_available() {
900 let config = Config::default();
902 let _ = &config; }
907
908 #[test]
909 fn test_us003_save_project_config_available() {
910 let config = Config::default();
912 let _ = &config;
913 }
915}