1use std::collections::HashSet;
39use std::fs;
40use std::path::{Path, PathBuf};
41use std::process::Command;
42
43use anyhow::{anyhow, Context, Result};
44use serde::{Deserialize, Serialize};
45
46pub const DEFAULT_COMMIT_TEMPLATE: &str = "feat(unit-{id}): {title}";
47
48#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, Default)]
67pub struct NotifyConfig {
68 #[serde(default, skip_serializing_if = "Option::is_none")]
70 pub on_close: Option<String>,
71
72 #[serde(default, skip_serializing_if = "Option::is_none")]
74 pub on_fail: Option<String>,
75
76 #[serde(default, skip_serializing_if = "Option::is_none")]
78 pub on_scheduled_complete: Option<String>,
79}
80
81#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
83pub struct ReviewConfig {
84 #[serde(default, skip_serializing_if = "Option::is_none")]
87 pub run: Option<String>,
88 #[serde(default = "default_max_reopens")]
90 pub max_reopens: u32,
91}
92
93fn default_max_reopens() -> u32 {
94 2
95}
96
97impl Default for ReviewConfig {
98 fn default() -> Self {
99 Self {
100 run: None,
101 max_reopens: 2,
102 }
103 }
104}
105
106#[derive(Debug, Serialize, Deserialize, PartialEq)]
115pub struct Config {
116 pub project: String,
117 pub next_id: u32,
118 #[serde(default = "default_auto_close_parent")]
120 pub auto_close_parent: bool,
121 #[serde(default, skip_serializing_if = "Option::is_none")]
125 pub run: Option<String>,
126 #[serde(default, skip_serializing_if = "Option::is_none")]
129 pub plan: Option<String>,
130 #[serde(default = "default_max_loops")]
132 pub max_loops: u32,
133 #[serde(default = "default_max_concurrent")]
135 pub max_concurrent: u32,
136 #[serde(default = "default_poll_interval")]
138 pub poll_interval: u32,
139 #[serde(default, skip_serializing_if = "Vec::is_empty")]
142 pub extends: Vec<String>,
143 #[serde(default, skip_serializing_if = "Option::is_none")]
146 pub rules_file: Option<String>,
147 #[serde(default, skip_serializing_if = "is_false_bool")]
152 pub file_locking: bool,
153 #[serde(default, skip_serializing_if = "is_false_bool")]
158 pub worktree: bool,
159 #[serde(default, skip_serializing_if = "Option::is_none")]
163 pub on_close: Option<String>,
164 #[serde(default, skip_serializing_if = "Option::is_none")]
168 pub on_fail: Option<String>,
169 #[serde(default, skip_serializing_if = "Option::is_none")]
173 pub post_plan: Option<String>,
174 #[serde(default, skip_serializing_if = "Option::is_none")]
177 pub verify_timeout: Option<u64>,
178 #[serde(default, skip_serializing_if = "Option::is_none")]
181 pub review: Option<ReviewConfig>,
182 #[serde(default, skip_serializing_if = "Option::is_none")]
184 pub user: Option<String>,
185 #[serde(default, skip_serializing_if = "Option::is_none")]
187 pub user_email: Option<String>,
188 #[serde(default, skip_serializing_if = "is_false_bool")]
192 pub auto_commit: bool,
193 #[serde(default, skip_serializing_if = "Option::is_none")]
198 pub commit_template: Option<String>,
199 #[serde(default, skip_serializing_if = "Option::is_none")]
203 pub research: Option<String>,
204 #[serde(default, skip_serializing_if = "Option::is_none")]
206 pub run_model: Option<String>,
207 #[serde(default, skip_serializing_if = "Option::is_none")]
209 pub plan_model: Option<String>,
210 #[serde(default, skip_serializing_if = "Option::is_none")]
212 pub review_model: Option<String>,
213 #[serde(default, skip_serializing_if = "Option::is_none")]
215 pub research_model: Option<String>,
216 #[serde(default, skip_serializing_if = "is_false_bool")]
221 pub batch_verify: bool,
222 #[serde(default, skip_serializing_if = "is_zero_u64")]
228 pub memory_reserve_mb: u64,
229 #[serde(default, skip_serializing_if = "Option::is_none")]
233 pub notify: Option<NotifyConfig>,
234}
235
236fn default_auto_close_parent() -> bool {
237 true
238}
239
240fn default_max_loops() -> u32 {
241 10
242}
243
244fn default_max_concurrent() -> u32 {
245 4
246}
247
248fn default_poll_interval() -> u32 {
249 30
250}
251
252fn is_false_bool(v: &bool) -> bool {
253 !v
254}
255
256fn is_zero_u64(v: &u64) -> bool {
257 *v == 0
258}
259
260impl Default for Config {
261 fn default() -> Self {
262 Self {
263 project: String::new(),
264 next_id: 1,
265 auto_close_parent: true,
266 run: None,
267 plan: None,
268 max_loops: 10,
269 max_concurrent: 4,
270 poll_interval: 30,
271 extends: Vec::new(),
272 rules_file: None,
273 file_locking: false,
274 worktree: false,
275 on_close: None,
276 on_fail: None,
277 post_plan: None,
278 verify_timeout: None,
279 review: None,
280 user: None,
281 user_email: None,
282 auto_commit: false,
283 commit_template: None,
284 research: None,
285 run_model: None,
286 plan_model: None,
287 review_model: None,
288 research_model: None,
289 batch_verify: false,
290 memory_reserve_mb: 0,
291 notify: None,
292 }
293 }
294}
295
296impl Config {
297 pub fn load(mana_dir: &Path) -> Result<Self> {
299 let path = mana_dir.join("config.yaml");
300 let contents = fs::read_to_string(&path)
301 .with_context(|| format!("Failed to read config at {}", path.display()))?;
302 let config: Config = serde_yml::from_str(&contents)
303 .with_context(|| format!("Failed to parse config at {}", path.display()))?;
304 Ok(config)
305 }
306
307 pub fn load_with_extends(mana_dir: &Path) -> Result<Self> {
313 let mut config = Self::load(mana_dir)?;
314
315 if config.extends.is_empty() {
316 return Ok(config);
317 }
318
319 let mut seen = HashSet::new();
320 let mut stack: Vec<String> = config.extends.clone();
321 let mut parents: Vec<Config> = Vec::new();
322
323 while let Some(path_str) = stack.pop() {
324 let resolved = Self::resolve_extends_path(&path_str, mana_dir)?;
325
326 let canonical = resolved
327 .canonicalize()
328 .with_context(|| format!("Cannot resolve extends path: {}", path_str))?;
329
330 if !seen.insert(canonical.clone()) {
331 continue; }
333
334 let contents = fs::read_to_string(&canonical).with_context(|| {
335 format!("Failed to read extends config: {}", canonical.display())
336 })?;
337 let parent: Config = serde_yml::from_str(&contents).with_context(|| {
338 format!("Failed to parse extends config: {}", canonical.display())
339 })?;
340
341 for ext in &parent.extends {
342 stack.push(ext.clone());
343 }
344
345 parents.push(parent);
346 }
347
348 for parent in &parents {
351 if config.run.is_none() {
352 config.run = parent.run.clone();
353 }
354 if config.plan.is_none() {
355 config.plan = parent.plan.clone();
356 }
357 if config.max_loops == default_max_loops() {
358 config.max_loops = parent.max_loops;
359 }
360 if config.max_concurrent == default_max_concurrent() {
361 config.max_concurrent = parent.max_concurrent;
362 }
363 if config.poll_interval == default_poll_interval() {
364 config.poll_interval = parent.poll_interval;
365 }
366 if config.auto_close_parent == default_auto_close_parent() {
367 config.auto_close_parent = parent.auto_close_parent;
368 }
369 if config.rules_file.is_none() {
370 config.rules_file = parent.rules_file.clone();
371 }
372 if !config.file_locking {
373 config.file_locking = parent.file_locking;
374 }
375 if !config.worktree {
376 config.worktree = parent.worktree;
377 }
378 if config.on_close.is_none() {
379 config.on_close = parent.on_close.clone();
380 }
381 if config.on_fail.is_none() {
382 config.on_fail = parent.on_fail.clone();
383 }
384 if config.post_plan.is_none() {
385 config.post_plan = parent.post_plan.clone();
386 }
387 if config.verify_timeout.is_none() {
388 config.verify_timeout = parent.verify_timeout;
389 }
390 if config.review.is_none() {
391 config.review = parent.review.clone();
392 }
393 if config.user.is_none() {
394 config.user = parent.user.clone();
395 }
396 if config.user_email.is_none() {
397 config.user_email = parent.user_email.clone();
398 }
399 if !config.auto_commit {
400 config.auto_commit = parent.auto_commit;
401 }
402 if config.commit_template.is_none() {
403 config.commit_template = parent.commit_template.clone();
404 }
405 if config.research.is_none() {
406 config.research = parent.research.clone();
407 }
408 if config.run_model.is_none() {
409 config.run_model = parent.run_model.clone();
410 }
411 if config.plan_model.is_none() {
412 config.plan_model = parent.plan_model.clone();
413 }
414 if config.review_model.is_none() {
415 config.review_model = parent.review_model.clone();
416 }
417 if config.research_model.is_none() {
418 config.research_model = parent.research_model.clone();
419 }
420 }
422
423 Ok(config)
424 }
425
426 fn resolve_extends_path(path_str: &str, mana_dir: &Path) -> Result<PathBuf> {
429 if let Some(stripped) = path_str.strip_prefix("~/") {
430 let home = dirs::home_dir().ok_or_else(|| anyhow!("Cannot resolve home directory"))?;
431 Ok(home.join(stripped))
432 } else {
433 let project_root = mana_dir.parent().unwrap_or(Path::new("."));
435 Ok(project_root.join(path_str))
436 }
437 }
438
439 pub fn save(&self, mana_dir: &Path) -> Result<()> {
441 let path = mana_dir.join("config.yaml");
442 let contents = serde_yml::to_string(self).context("Failed to serialize config")?;
443 fs::write(&path, &contents)
444 .with_context(|| format!("Failed to write config at {}", path.display()))?;
445 Ok(())
446 }
447
448 pub fn rules_path(&self, mana_dir: &Path) -> PathBuf {
452 match &self.rules_file {
453 Some(custom) => {
454 let p = Path::new(custom);
455 if p.is_absolute() {
456 p.to_path_buf()
457 } else {
458 mana_dir.join(custom)
459 }
460 }
461 None => mana_dir.join("RULES.md"),
462 }
463 }
464
465 pub fn increment_id(&mut self) -> u32 {
467 let id = self.next_id;
468 self.next_id += 1;
469 id
470 }
471}
472
473#[derive(Debug, Default, Serialize, Deserialize)]
480pub struct GlobalConfig {
481 #[serde(default, skip_serializing_if = "Option::is_none")]
482 pub user: Option<String>,
483 #[serde(default, skip_serializing_if = "Option::is_none")]
484 pub user_email: Option<String>,
485}
486
487impl GlobalConfig {
488 pub fn path() -> Result<PathBuf> {
490 let home = dirs::home_dir().ok_or_else(|| anyhow!("Cannot determine home directory"))?;
491 Ok(home.join(".config").join("mana").join("config.yaml"))
492 }
493
494 fn legacy_path() -> Result<PathBuf> {
497 let home = dirs::home_dir().ok_or_else(|| anyhow!("Cannot determine home directory"))?;
498 Ok(home.join(".config").join("units").join("config.yaml"))
499 }
500
501 pub fn load() -> Result<Self> {
506 let path = Self::path()?;
507 if path.exists() {
508 let contents = fs::read_to_string(&path)
509 .with_context(|| format!("Failed to read global config at {}", path.display()))?;
510 let config: GlobalConfig = serde_yml::from_str(&contents)
511 .with_context(|| format!("Failed to parse global config at {}", path.display()))?;
512 return Ok(config);
513 }
514
515 if let Ok(legacy) = Self::legacy_path() {
517 if legacy.exists() {
518 let contents = fs::read_to_string(&legacy).with_context(|| {
519 format!(
520 "Failed to read legacy global config at {}",
521 legacy.display()
522 )
523 })?;
524 let config: GlobalConfig = serde_yml::from_str(&contents).with_context(|| {
525 format!(
526 "Failed to parse legacy global config at {}",
527 legacy.display()
528 )
529 })?;
530 return Ok(config);
531 }
532 }
533
534 Ok(Self::default())
535 }
536
537 pub fn save(&self) -> Result<()> {
539 let path = Self::path()?;
540 if let Some(parent) = path.parent() {
541 fs::create_dir_all(parent)
542 .with_context(|| format!("Failed to create {}", parent.display()))?;
543 }
544 let contents = serde_yml::to_string(self).context("Failed to serialize global config")?;
545 fs::write(&path, &contents)
546 .with_context(|| format!("Failed to write global config at {}", path.display()))?;
547 Ok(())
548 }
549}
550
551pub fn resolve_identity(mana_dir: &Path) -> Option<String> {
564 if let Ok(config) = Config::load(mana_dir) {
566 if let Some(ref user) = config.user {
567 if !user.is_empty() {
568 return Some(user.clone());
569 }
570 }
571 }
572
573 if let Ok(global) = GlobalConfig::load() {
575 if let Some(ref user) = global.user {
576 if !user.is_empty() {
577 return Some(user.clone());
578 }
579 }
580 }
581
582 if let Some(git_user) = git_config_user_name() {
584 return Some(git_user);
585 }
586
587 std::env::var("USER").ok().filter(|u| !u.is_empty())
589}
590
591fn git_config_user_name() -> Option<String> {
593 Command::new("git")
594 .args(["config", "user.name"])
595 .output()
596 .ok()
597 .filter(|o| o.status.success())
598 .and_then(|o| String::from_utf8(o.stdout).ok())
599 .map(|s| s.trim().to_string())
600 .filter(|s| !s.is_empty())
601}
602
603#[cfg(test)]
604mod tests {
605 use super::*;
606 use std::fs;
607
608 #[test]
609 fn config_round_trips_through_yaml() {
610 let dir = tempfile::tempdir().unwrap();
611 let config = Config {
612 project: "test-project".to_string(),
613 next_id: 42,
614 auto_close_parent: true,
615 run: None,
616 plan: None,
617 max_loops: 10,
618 max_concurrent: 4,
619 poll_interval: 30,
620 extends: vec![],
621 rules_file: None,
622 file_locking: false,
623 worktree: false,
624 on_close: None,
625 on_fail: None,
626 post_plan: None,
627 verify_timeout: None,
628 review: None,
629 user: None,
630 user_email: None,
631 auto_commit: false,
632 commit_template: None,
633 research: None,
634 run_model: None,
635 plan_model: None,
636 review_model: None,
637 research_model: None,
638 batch_verify: false,
639 memory_reserve_mb: 0,
640 notify: None,
641 };
642
643 config.save(dir.path()).unwrap();
644 let loaded = Config::load(dir.path()).unwrap();
645
646 assert_eq!(config, loaded);
647 }
648
649 #[test]
650 fn increment_id_returns_current_and_bumps() {
651 let mut config = Config {
652 project: "test".to_string(),
653 next_id: 1,
654 auto_close_parent: true,
655 run: None,
656 plan: None,
657 max_loops: 10,
658 max_concurrent: 4,
659 poll_interval: 30,
660 extends: vec![],
661 rules_file: None,
662 file_locking: false,
663 worktree: false,
664 on_close: None,
665 on_fail: None,
666 post_plan: None,
667 verify_timeout: None,
668 review: None,
669 user: None,
670 user_email: None,
671 auto_commit: false,
672 commit_template: None,
673 research: None,
674 run_model: None,
675 plan_model: None,
676 review_model: None,
677 research_model: None,
678 batch_verify: false,
679 memory_reserve_mb: 0,
680 notify: None,
681 };
682
683 assert_eq!(config.increment_id(), 1);
684 assert_eq!(config.increment_id(), 2);
685 assert_eq!(config.increment_id(), 3);
686 assert_eq!(config.next_id, 4);
687 }
688
689 #[test]
690 fn load_returns_error_for_missing_file() {
691 let dir = tempfile::tempdir().unwrap();
692 let result = Config::load(dir.path());
693 assert!(result.is_err());
694 }
695
696 #[test]
697 fn load_returns_error_for_invalid_yaml() {
698 let dir = tempfile::tempdir().unwrap();
699 fs::write(dir.path().join("config.yaml"), "not: [valid: yaml: config").unwrap();
700 let result = Config::load(dir.path());
701 assert!(result.is_err());
702 }
703
704 #[test]
705 fn save_creates_file_that_is_valid_yaml() {
706 let dir = tempfile::tempdir().unwrap();
707 let config = Config {
708 project: "my-project".to_string(),
709 next_id: 100,
710 auto_close_parent: true,
711 run: None,
712 plan: None,
713 max_loops: 10,
714 max_concurrent: 4,
715 poll_interval: 30,
716 extends: vec![],
717 rules_file: None,
718 file_locking: false,
719 worktree: false,
720 on_close: None,
721 on_fail: None,
722 post_plan: None,
723 verify_timeout: None,
724 review: None,
725 user: None,
726 user_email: None,
727 auto_commit: false,
728 commit_template: None,
729 research: None,
730 run_model: None,
731 plan_model: None,
732 review_model: None,
733 research_model: None,
734 batch_verify: false,
735 memory_reserve_mb: 0,
736 notify: None,
737 };
738 config.save(dir.path()).unwrap();
739
740 let contents = fs::read_to_string(dir.path().join("config.yaml")).unwrap();
741 assert!(contents.contains("project: my-project"));
742 assert!(contents.contains("next_id: 100"));
743 }
744
745 #[test]
746 fn auto_close_parent_defaults_to_true() {
747 let dir = tempfile::tempdir().unwrap();
748 fs::write(
750 dir.path().join("config.yaml"),
751 "project: test\nnext_id: 1\n",
752 )
753 .unwrap();
754
755 let loaded = Config::load(dir.path()).unwrap();
756 assert!(loaded.auto_close_parent);
757 }
758
759 #[test]
760 fn auto_close_parent_can_be_disabled() {
761 let dir = tempfile::tempdir().unwrap();
762 let config = Config {
763 project: "test".to_string(),
764 next_id: 1,
765 auto_close_parent: false,
766 run: None,
767 plan: None,
768 max_loops: 10,
769 max_concurrent: 4,
770 poll_interval: 30,
771 extends: vec![],
772 rules_file: None,
773 file_locking: false,
774 worktree: false,
775 on_close: None,
776 on_fail: None,
777 post_plan: None,
778 verify_timeout: None,
779 review: None,
780 user: None,
781 user_email: None,
782 auto_commit: false,
783 commit_template: None,
784 research: None,
785 run_model: None,
786 plan_model: None,
787 review_model: None,
788 research_model: None,
789 batch_verify: false,
790 memory_reserve_mb: 0,
791 notify: None,
792 };
793 config.save(dir.path()).unwrap();
794
795 let loaded = Config::load(dir.path()).unwrap();
796 assert!(!loaded.auto_close_parent);
797 }
798
799 #[test]
800 fn max_tokens_in_yaml_silently_ignored() {
801 let dir = tempfile::tempdir().unwrap();
802 fs::write(
804 dir.path().join("config.yaml"),
805 "project: test\nnext_id: 1\nmax_tokens: 50000\n",
806 )
807 .unwrap();
808
809 let loaded = Config::load(dir.path()).unwrap();
810 assert_eq!(loaded.project, "test");
811 }
812
813 #[test]
814 fn run_defaults_to_none() {
815 let dir = tempfile::tempdir().unwrap();
816 fs::write(
817 dir.path().join("config.yaml"),
818 "project: test\nnext_id: 1\n",
819 )
820 .unwrap();
821
822 let loaded = Config::load(dir.path()).unwrap();
823 assert_eq!(loaded.run, None);
824 }
825
826 #[test]
827 fn run_can_be_set() {
828 let dir = tempfile::tempdir().unwrap();
829 let config = Config {
830 project: "test".to_string(),
831 next_id: 1,
832 auto_close_parent: true,
833 run: Some("claude -p 'implement unit {id}'".to_string()),
834 plan: None,
835 max_loops: 10,
836 max_concurrent: 4,
837 poll_interval: 30,
838 extends: vec![],
839 rules_file: None,
840 file_locking: false,
841 worktree: false,
842 on_close: None,
843 on_fail: None,
844 post_plan: None,
845 verify_timeout: None,
846 review: None,
847 user: None,
848 user_email: None,
849 auto_commit: false,
850 commit_template: None,
851 research: None,
852 run_model: None,
853 plan_model: None,
854 review_model: None,
855 research_model: None,
856 batch_verify: false,
857 memory_reserve_mb: 0,
858 notify: None,
859 };
860 config.save(dir.path()).unwrap();
861
862 let loaded = Config::load(dir.path()).unwrap();
863 assert_eq!(
864 loaded.run,
865 Some("claude -p 'implement unit {id}'".to_string())
866 );
867 }
868
869 #[test]
870 fn run_not_serialized_when_none() {
871 let dir = tempfile::tempdir().unwrap();
872 let config = Config {
873 project: "test".to_string(),
874 next_id: 1,
875 auto_close_parent: true,
876 run: None,
877 plan: None,
878 max_loops: 10,
879 max_concurrent: 4,
880 poll_interval: 30,
881 extends: vec![],
882 rules_file: None,
883 file_locking: false,
884 worktree: false,
885 on_close: None,
886 on_fail: None,
887 post_plan: None,
888 verify_timeout: None,
889 review: None,
890 user: None,
891 user_email: None,
892 auto_commit: false,
893 commit_template: None,
894 research: None,
895 run_model: None,
896 plan_model: None,
897 review_model: None,
898 research_model: None,
899 batch_verify: false,
900 memory_reserve_mb: 0,
901 notify: None,
902 };
903 config.save(dir.path()).unwrap();
904
905 let contents = fs::read_to_string(dir.path().join("config.yaml")).unwrap();
906 assert!(!contents.contains("run:"));
907 }
908
909 #[test]
910 fn max_loops_defaults_to_10() {
911 let dir = tempfile::tempdir().unwrap();
912 fs::write(
913 dir.path().join("config.yaml"),
914 "project: test\nnext_id: 1\n",
915 )
916 .unwrap();
917
918 let loaded = Config::load(dir.path()).unwrap();
919 assert_eq!(loaded.max_loops, 10);
920 }
921
922 #[test]
923 fn max_loops_can_be_customized() {
924 let dir = tempfile::tempdir().unwrap();
925 let config = Config {
926 project: "test".to_string(),
927 next_id: 1,
928 auto_close_parent: true,
929 run: None,
930 plan: None,
931 max_loops: 25,
932 max_concurrent: 4,
933 poll_interval: 30,
934 extends: vec![],
935 rules_file: None,
936 file_locking: false,
937 worktree: false,
938 on_close: None,
939 on_fail: None,
940 post_plan: None,
941 verify_timeout: None,
942 review: None,
943 user: None,
944 user_email: None,
945 auto_commit: false,
946 commit_template: None,
947 research: None,
948 run_model: None,
949 plan_model: None,
950 review_model: None,
951 research_model: None,
952 batch_verify: false,
953 memory_reserve_mb: 0,
954 notify: None,
955 };
956 config.save(dir.path()).unwrap();
957
958 let loaded = Config::load(dir.path()).unwrap();
959 assert_eq!(loaded.max_loops, 25);
960 }
961
962 fn write_yaml(path: &std::path::Path, yaml: &str) {
966 if let Some(parent) = path.parent() {
967 fs::create_dir_all(parent).unwrap();
968 }
969 fs::write(path, yaml).unwrap();
970 }
971
972 fn write_local_config(mana_dir: &std::path::Path, extends: &[&str], extra: &str) {
974 let extends_yaml: Vec<String> = extends.iter().map(|e| format!(" - \"{}\"", e)).collect();
975 let extends_block = if extends.is_empty() {
976 String::new()
977 } else {
978 format!("extends:\n{}\n", extends_yaml.join("\n"))
979 };
980 let yaml = format!("project: test\nnext_id: 1\n{}{}", extends_block, extra);
981 write_yaml(&mana_dir.join("config.yaml"), &yaml);
982 }
983
984 #[test]
985 fn extends_empty_loads_normally() {
986 let dir = tempfile::tempdir().unwrap();
987 let mana_dir = dir.path().join(".mana");
988 fs::create_dir_all(&mana_dir).unwrap();
989 write_local_config(&mana_dir, &[], "");
990
991 let config = Config::load_with_extends(&mana_dir).unwrap();
992 assert_eq!(config.project, "test");
993 assert!(config.run.is_none());
994 }
995
996 #[test]
997 fn extends_single_merges_fields() {
998 let dir = tempfile::tempdir().unwrap();
999 let mana_dir = dir.path().join(".mana");
1000 fs::create_dir_all(&mana_dir).unwrap();
1001
1002 let parent_path = dir.path().join("shared.yaml");
1004 write_yaml(
1005 &parent_path,
1006 "project: shared\nnext_id: 999\nrun: \"deli spawn {id}\"\nmax_loops: 20\n",
1007 );
1008
1009 write_local_config(&mana_dir, &["shared.yaml"], "");
1010
1011 let config = Config::load_with_extends(&mana_dir).unwrap();
1012 assert_eq!(config.run, Some("deli spawn {id}".to_string()));
1014 assert_eq!(config.max_loops, 20);
1015 assert_eq!(config.project, "test");
1017 assert_eq!(config.next_id, 1);
1018 }
1019
1020 #[test]
1021 fn extends_local_overrides_parent() {
1022 let dir = tempfile::tempdir().unwrap();
1023 let mana_dir = dir.path().join(".mana");
1024 fs::create_dir_all(&mana_dir).unwrap();
1025
1026 let parent_path = dir.path().join("shared.yaml");
1027 write_yaml(
1028 &parent_path,
1029 "project: shared\nnext_id: 999\nrun: \"parent-run\"\nmax_loops: 20\n",
1030 );
1031
1032 write_local_config(
1034 &mana_dir,
1035 &["shared.yaml"],
1036 "run: \"local-run\"\nmax_loops: 5\n",
1037 );
1038
1039 let config = Config::load_with_extends(&mana_dir).unwrap();
1040 assert_eq!(config.run, Some("local-run".to_string()));
1042 assert_eq!(config.max_loops, 5);
1043 }
1044
1045 #[test]
1046 fn extends_circular_detected_and_skipped() {
1047 let dir = tempfile::tempdir().unwrap();
1048 let mana_dir = dir.path().join(".mana");
1049 fs::create_dir_all(&mana_dir).unwrap();
1050
1051 let a_path = dir.path().join("a.yaml");
1053 let b_path = dir.path().join("b.yaml");
1054 write_yaml(
1055 &a_path,
1056 "project: a\nnext_id: 1\nextends:\n - \"b.yaml\"\nmax_loops: 40\n",
1057 );
1058 write_yaml(
1059 &b_path,
1060 "project: b\nnext_id: 1\nextends:\n - \"a.yaml\"\nmax_loops: 50\n",
1061 );
1062
1063 write_local_config(&mana_dir, &["a.yaml"], "");
1064
1065 let config = Config::load_with_extends(&mana_dir).unwrap();
1067 assert_eq!(config.project, "test");
1068 assert!(config.max_loops == 40 || config.max_loops == 50);
1070 }
1071
1072 #[test]
1073 fn extends_missing_file_errors() {
1074 let dir = tempfile::tempdir().unwrap();
1075 let mana_dir = dir.path().join(".mana");
1076 fs::create_dir_all(&mana_dir).unwrap();
1077
1078 write_local_config(&mana_dir, &["nonexistent.yaml"], "");
1079
1080 let result = Config::load_with_extends(&mana_dir);
1081 assert!(result.is_err());
1082 let err_msg = format!("{}", result.unwrap_err());
1083 assert!(
1084 err_msg.contains("nonexistent.yaml"),
1085 "Error should mention the missing file: {}",
1086 err_msg
1087 );
1088 }
1089
1090 #[test]
1091 fn extends_recursive_a_extends_b_extends_c() {
1092 let dir = tempfile::tempdir().unwrap();
1093 let mana_dir = dir.path().join(".mana");
1094 fs::create_dir_all(&mana_dir).unwrap();
1095
1096 let c_path = dir.path().join("c.yaml");
1098 write_yaml(
1099 &c_path,
1100 "project: c\nnext_id: 1\nrun: \"from-c\"\nmax_loops: 40\n",
1101 );
1102
1103 let b_path = dir.path().join("b.yaml");
1105 write_yaml(
1106 &b_path,
1107 "project: b\nnext_id: 1\nextends:\n - \"c.yaml\"\nmax_loops: 50\n",
1108 );
1109
1110 write_local_config(&mana_dir, &["b.yaml"], "");
1112
1113 let config = Config::load_with_extends(&mana_dir).unwrap();
1114 assert_eq!(config.max_loops, 50);
1116 assert_eq!(config.run, Some("from-c".to_string()));
1118 }
1119
1120 #[test]
1121 fn extends_project_and_next_id_never_inherited() {
1122 let dir = tempfile::tempdir().unwrap();
1123 let mana_dir = dir.path().join(".mana");
1124 fs::create_dir_all(&mana_dir).unwrap();
1125
1126 let parent_path = dir.path().join("shared.yaml");
1127 write_yaml(
1128 &parent_path,
1129 "project: parent-project\nnext_id: 999\nmax_loops: 50\n",
1130 );
1131
1132 write_local_config(&mana_dir, &["shared.yaml"], "");
1133
1134 let config = Config::load_with_extends(&mana_dir).unwrap();
1135 assert_eq!(config.project, "test");
1136 assert_eq!(config.next_id, 1);
1137 }
1138
1139 #[test]
1140 fn extends_tilde_resolves_to_home_dir() {
1141 let mana_dir = std::path::Path::new("/tmp/fake-units");
1144 let resolved = Config::resolve_extends_path("~/shared/config.yaml", mana_dir).unwrap();
1145 let home = dirs::home_dir().unwrap();
1146 assert_eq!(resolved, home.join("shared/config.yaml"));
1147 }
1148
1149 #[test]
1150 fn extends_not_serialized_when_empty() {
1151 let dir = tempfile::tempdir().unwrap();
1152 let config = Config {
1153 project: "test".to_string(),
1154 next_id: 1,
1155 auto_close_parent: true,
1156 run: None,
1157 plan: None,
1158 max_loops: 10,
1159 max_concurrent: 4,
1160 poll_interval: 30,
1161 extends: vec![],
1162 rules_file: None,
1163 file_locking: false,
1164 worktree: false,
1165 on_close: None,
1166 on_fail: None,
1167 post_plan: None,
1168 verify_timeout: None,
1169 review: None,
1170 user: None,
1171 user_email: None,
1172 auto_commit: false,
1173 commit_template: None,
1174 research: None,
1175 run_model: None,
1176 plan_model: None,
1177 review_model: None,
1178 research_model: None,
1179 batch_verify: false,
1180 memory_reserve_mb: 0,
1181 notify: None,
1182 };
1183 config.save(dir.path()).unwrap();
1184
1185 let contents = fs::read_to_string(dir.path().join("config.yaml")).unwrap();
1186 assert!(!contents.contains("extends"));
1187 }
1188
1189 #[test]
1190 fn extends_defaults_to_empty() {
1191 let dir = tempfile::tempdir().unwrap();
1192 fs::write(
1193 dir.path().join("config.yaml"),
1194 "project: test\nnext_id: 1\n",
1195 )
1196 .unwrap();
1197
1198 let loaded = Config::load(dir.path()).unwrap();
1199 assert!(loaded.extends.is_empty());
1200 }
1201
1202 #[test]
1205 fn plan_defaults_to_none() {
1206 let dir = tempfile::tempdir().unwrap();
1207 fs::write(
1208 dir.path().join("config.yaml"),
1209 "project: test\nnext_id: 1\n",
1210 )
1211 .unwrap();
1212
1213 let loaded = Config::load(dir.path()).unwrap();
1214 assert_eq!(loaded.plan, None);
1215 }
1216
1217 #[test]
1218 fn plan_can_be_set() {
1219 let dir = tempfile::tempdir().unwrap();
1220 let config = Config {
1221 project: "test".to_string(),
1222 next_id: 1,
1223 auto_close_parent: true,
1224 run: None,
1225 plan: Some("claude -p 'plan unit {id}'".to_string()),
1226 max_loops: 10,
1227 max_concurrent: 4,
1228 poll_interval: 30,
1229 extends: vec![],
1230 rules_file: None,
1231 file_locking: false,
1232 worktree: false,
1233 on_close: None,
1234 on_fail: None,
1235 post_plan: None,
1236 verify_timeout: None,
1237 review: None,
1238 user: None,
1239 user_email: None,
1240 auto_commit: false,
1241 commit_template: None,
1242 research: None,
1243 run_model: None,
1244 plan_model: None,
1245 review_model: None,
1246 research_model: None,
1247 batch_verify: false,
1248 memory_reserve_mb: 0,
1249 notify: None,
1250 };
1251 config.save(dir.path()).unwrap();
1252
1253 let loaded = Config::load(dir.path()).unwrap();
1254 assert_eq!(loaded.plan, Some("claude -p 'plan unit {id}'".to_string()));
1255 }
1256
1257 #[test]
1258 fn plan_not_serialized_when_none() {
1259 let dir = tempfile::tempdir().unwrap();
1260 let config = Config {
1261 project: "test".to_string(),
1262 next_id: 1,
1263 auto_close_parent: true,
1264 run: None,
1265 plan: None,
1266 max_loops: 10,
1267 max_concurrent: 4,
1268 poll_interval: 30,
1269 extends: vec![],
1270 rules_file: None,
1271 file_locking: false,
1272 worktree: false,
1273 on_close: None,
1274 on_fail: None,
1275 post_plan: None,
1276 verify_timeout: None,
1277 review: None,
1278 user: None,
1279 user_email: None,
1280 auto_commit: false,
1281 commit_template: None,
1282 research: None,
1283 run_model: None,
1284 plan_model: None,
1285 review_model: None,
1286 research_model: None,
1287 batch_verify: false,
1288 memory_reserve_mb: 0,
1289 notify: None,
1290 };
1291 config.save(dir.path()).unwrap();
1292
1293 let contents = fs::read_to_string(dir.path().join("config.yaml")).unwrap();
1294 assert!(!contents.contains("plan:"));
1295 }
1296
1297 #[test]
1298 fn max_concurrent_defaults_to_4() {
1299 let dir = tempfile::tempdir().unwrap();
1300 fs::write(
1301 dir.path().join("config.yaml"),
1302 "project: test\nnext_id: 1\n",
1303 )
1304 .unwrap();
1305
1306 let loaded = Config::load(dir.path()).unwrap();
1307 assert_eq!(loaded.max_concurrent, 4);
1308 }
1309
1310 #[test]
1311 fn max_concurrent_can_be_customized() {
1312 let dir = tempfile::tempdir().unwrap();
1313 let config = Config {
1314 project: "test".to_string(),
1315 next_id: 1,
1316 auto_close_parent: true,
1317 run: None,
1318 plan: None,
1319 max_loops: 10,
1320 max_concurrent: 8,
1321 poll_interval: 30,
1322 extends: vec![],
1323 rules_file: None,
1324 file_locking: false,
1325 worktree: false,
1326 on_close: None,
1327 on_fail: None,
1328 post_plan: None,
1329 verify_timeout: None,
1330 review: None,
1331 user: None,
1332 user_email: None,
1333 auto_commit: false,
1334 commit_template: None,
1335 research: None,
1336 run_model: None,
1337 plan_model: None,
1338 review_model: None,
1339 research_model: None,
1340 batch_verify: false,
1341 memory_reserve_mb: 0,
1342 notify: None,
1343 };
1344 config.save(dir.path()).unwrap();
1345
1346 let loaded = Config::load(dir.path()).unwrap();
1347 assert_eq!(loaded.max_concurrent, 8);
1348 }
1349
1350 #[test]
1351 fn poll_interval_defaults_to_30() {
1352 let dir = tempfile::tempdir().unwrap();
1353 fs::write(
1354 dir.path().join("config.yaml"),
1355 "project: test\nnext_id: 1\n",
1356 )
1357 .unwrap();
1358
1359 let loaded = Config::load(dir.path()).unwrap();
1360 assert_eq!(loaded.poll_interval, 30);
1361 }
1362
1363 #[test]
1364 fn poll_interval_can_be_customized() {
1365 let dir = tempfile::tempdir().unwrap();
1366 let config = Config {
1367 project: "test".to_string(),
1368 next_id: 1,
1369 auto_close_parent: true,
1370 run: None,
1371 plan: None,
1372 max_loops: 10,
1373 max_concurrent: 4,
1374 poll_interval: 60,
1375 extends: vec![],
1376 rules_file: None,
1377 file_locking: false,
1378 worktree: false,
1379 on_close: None,
1380 on_fail: None,
1381 post_plan: None,
1382 verify_timeout: None,
1383 review: None,
1384 user: None,
1385 user_email: None,
1386 auto_commit: false,
1387 commit_template: None,
1388 research: None,
1389 run_model: None,
1390 plan_model: None,
1391 review_model: None,
1392 research_model: None,
1393 batch_verify: false,
1394 memory_reserve_mb: 0,
1395 notify: None,
1396 };
1397 config.save(dir.path()).unwrap();
1398
1399 let loaded = Config::load(dir.path()).unwrap();
1400 assert_eq!(loaded.poll_interval, 60);
1401 }
1402
1403 #[test]
1404 fn extends_inherits_plan() {
1405 let dir = tempfile::tempdir().unwrap();
1406 let mana_dir = dir.path().join(".mana");
1407 fs::create_dir_all(&mana_dir).unwrap();
1408
1409 let parent_path = dir.path().join("shared.yaml");
1410 write_yaml(
1411 &parent_path,
1412 "project: shared\nnext_id: 999\nplan: \"plan-cmd {id}\"\n",
1413 );
1414
1415 write_local_config(&mana_dir, &["shared.yaml"], "");
1416
1417 let config = Config::load_with_extends(&mana_dir).unwrap();
1418 assert_eq!(config.plan, Some("plan-cmd {id}".to_string()));
1419 }
1420
1421 #[test]
1422 fn extends_inherits_max_concurrent() {
1423 let dir = tempfile::tempdir().unwrap();
1424 let mana_dir = dir.path().join(".mana");
1425 fs::create_dir_all(&mana_dir).unwrap();
1426
1427 let parent_path = dir.path().join("shared.yaml");
1428 write_yaml(
1429 &parent_path,
1430 "project: shared\nnext_id: 999\nmax_concurrent: 16\n",
1431 );
1432
1433 write_local_config(&mana_dir, &["shared.yaml"], "");
1434
1435 let config = Config::load_with_extends(&mana_dir).unwrap();
1436 assert_eq!(config.max_concurrent, 16);
1437 }
1438
1439 #[test]
1440 fn extends_inherits_poll_interval() {
1441 let dir = tempfile::tempdir().unwrap();
1442 let mana_dir = dir.path().join(".mana");
1443 fs::create_dir_all(&mana_dir).unwrap();
1444
1445 let parent_path = dir.path().join("shared.yaml");
1446 write_yaml(
1447 &parent_path,
1448 "project: shared\nnext_id: 999\npoll_interval: 120\n",
1449 );
1450
1451 write_local_config(&mana_dir, &["shared.yaml"], "");
1452
1453 let config = Config::load_with_extends(&mana_dir).unwrap();
1454 assert_eq!(config.poll_interval, 120);
1455 }
1456
1457 #[test]
1458 fn extends_local_overrides_new_fields() {
1459 let dir = tempfile::tempdir().unwrap();
1460 let mana_dir = dir.path().join(".mana");
1461 fs::create_dir_all(&mana_dir).unwrap();
1462
1463 let parent_path = dir.path().join("shared.yaml");
1464 write_yaml(
1465 &parent_path,
1466 "project: shared\nnext_id: 999\nplan: \"parent-plan\"\nmax_concurrent: 16\npoll_interval: 120\n",
1467 );
1468
1469 write_local_config(
1470 &mana_dir,
1471 &["shared.yaml"],
1472 "plan: \"local-plan\"\nmax_concurrent: 2\npoll_interval: 10\n",
1473 );
1474
1475 let config = Config::load_with_extends(&mana_dir).unwrap();
1476 assert_eq!(config.plan, Some("local-plan".to_string()));
1477 assert_eq!(config.max_concurrent, 2);
1478 assert_eq!(config.poll_interval, 10);
1479 }
1480
1481 #[test]
1482 fn new_fields_round_trip_through_yaml() {
1483 let dir = tempfile::tempdir().unwrap();
1484 let config = Config {
1485 project: "test".to_string(),
1486 next_id: 1,
1487 auto_close_parent: true,
1488 run: None,
1489 plan: Some("plan {id}".to_string()),
1490 max_loops: 10,
1491 max_concurrent: 8,
1492 poll_interval: 60,
1493 extends: vec![],
1494 rules_file: None,
1495 file_locking: false,
1496 worktree: false,
1497 on_close: None,
1498 on_fail: None,
1499 post_plan: None,
1500 verify_timeout: None,
1501 review: None,
1502 user: None,
1503 user_email: None,
1504 auto_commit: false,
1505 commit_template: None,
1506 research: None,
1507 run_model: None,
1508 plan_model: None,
1509 review_model: None,
1510 research_model: None,
1511 batch_verify: false,
1512 memory_reserve_mb: 0,
1513 notify: None,
1514 };
1515
1516 config.save(dir.path()).unwrap();
1517 let loaded = Config::load(dir.path()).unwrap();
1518
1519 assert_eq!(config, loaded);
1520 }
1521
1522 #[test]
1523 fn batch_verify_defaults_to_false() {
1524 let dir = tempfile::tempdir().unwrap();
1525 fs::write(
1526 dir.path().join("config.yaml"),
1527 "project: test\nnext_id: 1\n",
1528 )
1529 .unwrap();
1530
1531 let loaded = Config::load(dir.path()).unwrap();
1532 assert!(!loaded.batch_verify);
1533 }
1534
1535 #[test]
1536 fn batch_verify_can_be_enabled() {
1537 let dir = tempfile::tempdir().unwrap();
1538 fs::write(
1539 dir.path().join("config.yaml"),
1540 "project: test\nnext_id: 1\nbatch_verify: true\n",
1541 )
1542 .unwrap();
1543
1544 let loaded = Config::load(dir.path()).unwrap();
1545 assert!(loaded.batch_verify);
1546 }
1547
1548 #[test]
1549 fn batch_verify_not_serialized_when_false() {
1550 let dir = tempfile::tempdir().unwrap();
1551 fs::write(
1552 dir.path().join("config.yaml"),
1553 "project: test\nnext_id: 1\n",
1554 )
1555 .unwrap();
1556
1557 let loaded = Config::load(dir.path()).unwrap();
1558 assert!(!loaded.batch_verify);
1559
1560 loaded.save(dir.path()).unwrap();
1561 let contents = fs::read_to_string(dir.path().join("config.yaml")).unwrap();
1562 assert!(!contents.contains("batch_verify"));
1563 }
1564}