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
46use crate::yaml;
47
48pub const DEFAULT_COMMIT_TEMPLATE: &str = "feat(unit-{id}): {title}";
49
50#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, Default)]
69pub struct NotifyConfig {
70 #[serde(default, skip_serializing_if = "Option::is_none")]
72 pub on_close: Option<String>,
73
74 #[serde(default, skip_serializing_if = "Option::is_none")]
76 pub on_fail: Option<String>,
77
78 #[serde(default, skip_serializing_if = "Option::is_none")]
80 pub on_scheduled_complete: Option<String>,
81}
82
83#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
85pub struct ReviewConfig {
86 #[serde(default, skip_serializing_if = "Option::is_none")]
89 pub run: Option<String>,
90 #[serde(default = "default_max_reopens")]
92 pub max_reopens: u32,
93}
94
95fn default_max_reopens() -> u32 {
96 2
97}
98
99impl Default for ReviewConfig {
100 fn default() -> Self {
101 Self {
102 run: None,
103 max_reopens: 2,
104 }
105 }
106}
107
108#[derive(Debug, Serialize, Deserialize, PartialEq)]
117pub struct Config {
118 pub project: String,
119 pub next_id: u32,
120 #[serde(default = "default_auto_close_parent")]
122 pub auto_close_parent: bool,
123 #[serde(default, skip_serializing_if = "Option::is_none")]
127 pub run: Option<String>,
128 #[serde(default, skip_serializing_if = "Option::is_none")]
131 pub plan: Option<String>,
132 #[serde(default = "default_max_loops")]
134 pub max_loops: u32,
135 #[serde(default = "default_max_concurrent")]
137 pub max_concurrent: u32,
138 #[serde(default = "default_poll_interval")]
140 pub poll_interval: u32,
141 #[serde(default, skip_serializing_if = "Vec::is_empty")]
144 pub extends: Vec<String>,
145 #[serde(default, skip_serializing_if = "Option::is_none")]
148 pub rules_file: Option<String>,
149 #[serde(default, skip_serializing_if = "is_false_bool")]
154 pub file_locking: bool,
155 #[serde(default, skip_serializing_if = "is_false_bool")]
160 pub worktree: bool,
161 #[serde(default, skip_serializing_if = "Option::is_none")]
165 pub on_close: Option<String>,
166 #[serde(default, skip_serializing_if = "Option::is_none")]
170 pub on_fail: Option<String>,
171 #[serde(default, skip_serializing_if = "Option::is_none")]
174 pub verify_timeout: Option<u64>,
175 #[serde(default, skip_serializing_if = "Option::is_none")]
178 pub review: Option<ReviewConfig>,
179 #[serde(default, skip_serializing_if = "Option::is_none")]
181 pub user: Option<String>,
182 #[serde(default, skip_serializing_if = "Option::is_none")]
184 pub user_email: Option<String>,
185 #[serde(default, skip_serializing_if = "is_false_bool")]
189 pub auto_commit: bool,
190 #[serde(default, skip_serializing_if = "Option::is_none")]
195 pub commit_template: Option<String>,
196 #[serde(default, skip_serializing_if = "Option::is_none")]
200 pub research: Option<String>,
201 #[serde(default, skip_serializing_if = "Option::is_none")]
203 pub run_model: Option<String>,
204 #[serde(default, skip_serializing_if = "Option::is_none")]
206 pub plan_model: Option<String>,
207 #[serde(default, skip_serializing_if = "Option::is_none")]
209 pub review_model: Option<String>,
210 #[serde(default, skip_serializing_if = "Option::is_none")]
212 pub research_model: Option<String>,
213 #[serde(default, skip_serializing_if = "is_false_bool")]
218 pub batch_verify: bool,
219 #[serde(default, skip_serializing_if = "is_zero_u64")]
225 pub memory_reserve_mb: u64,
226 #[serde(default, skip_serializing_if = "Option::is_none")]
230 pub notify: Option<NotifyConfig>,
231}
232
233fn default_auto_close_parent() -> bool {
234 true
235}
236
237fn default_max_loops() -> u32 {
238 10
239}
240
241fn default_max_concurrent() -> u32 {
242 4
243}
244
245fn default_poll_interval() -> u32 {
246 30
247}
248
249fn is_false_bool(v: &bool) -> bool {
250 !v
251}
252
253fn is_zero_u64(v: &u64) -> bool {
254 *v == 0
255}
256
257fn inherit_option<T: Clone>(current: &mut Option<T>, inherited: &Option<T>) {
258 if current.is_none() {
259 *current = inherited.clone();
260 }
261}
262
263fn inherit_value_if_default<T: Copy + PartialEq>(current: &mut T, default: T, inherited: T) {
264 if *current == default {
265 *current = inherited;
266 }
267}
268
269fn inherit_sparse_value_if_default<T: Copy + PartialEq>(
270 current: &mut T,
271 default: T,
272 inherited: Option<T>,
273) {
274 if *current == default {
275 if let Some(inherited) = inherited {
276 *current = inherited;
277 }
278 }
279}
280
281impl Default for Config {
282 fn default() -> Self {
283 Self {
284 project: String::new(),
285 next_id: 1,
286 auto_close_parent: true,
287 run: None,
288 plan: None,
289 max_loops: 10,
290 max_concurrent: 4,
291 poll_interval: 30,
292 extends: Vec::new(),
293 rules_file: None,
294 file_locking: false,
295 worktree: false,
296 on_close: None,
297 on_fail: None,
298 verify_timeout: None,
299 review: None,
300 user: None,
301 user_email: None,
302 auto_commit: false,
303 commit_template: None,
304 research: None,
305 run_model: None,
306 plan_model: None,
307 review_model: None,
308 research_model: None,
309 batch_verify: false,
310 memory_reserve_mb: 0,
311 notify: None,
312 }
313 }
314}
315
316impl Config {
317 pub fn load(mana_dir: &Path) -> Result<Self> {
319 let path = mana_dir.join("config.yaml");
320 let contents = fs::read_to_string(&path)
321 .with_context(|| format!("Failed to read config at {}", path.display()))?;
322 let config: Config = yaml::from_str(&contents)
323 .with_context(|| format!("Failed to parse config at {}", path.display()))?;
324 Ok(config)
325 }
326
327 pub fn load_with_extends(mana_dir: &Path) -> Result<Self> {
333 let mut config = Self::load(mana_dir)?;
334
335 let mut seen = HashSet::new();
336 let mut stack: Vec<String> = config.extends.clone();
337 let mut parents: Vec<Config> = Vec::new();
338
339 while let Some(path_str) = stack.pop() {
340 let resolved = Self::resolve_extends_path(&path_str, mana_dir)?;
341
342 let canonical = resolved
343 .canonicalize()
344 .with_context(|| format!("Cannot resolve extends path: {}", path_str))?;
345
346 if !seen.insert(canonical.clone()) {
347 continue; }
349
350 let contents = fs::read_to_string(&canonical).with_context(|| {
351 format!("Failed to read extends config: {}", canonical.display())
352 })?;
353 let parent: Config = yaml::from_str(&contents).with_context(|| {
354 format!("Failed to parse extends config: {}", canonical.display())
355 })?;
356
357 for ext in &parent.extends {
358 stack.push(ext.clone());
359 }
360
361 parents.push(parent);
362 }
363
364 for parent in &parents {
367 config.apply_inherited_defaults_from(parent);
368 }
370
371 if let Ok(global) = GlobalConfig::load() {
372 global.apply_defaults_to_config(&mut config);
373 }
374
375 Ok(config)
376 }
377
378 fn apply_inherited_defaults_from(&mut self, defaults: &Config) {
379 inherit_option(&mut self.run, &defaults.run);
380 inherit_option(&mut self.plan, &defaults.plan);
381 inherit_value_if_default(&mut self.max_loops, default_max_loops(), defaults.max_loops);
382 inherit_value_if_default(
383 &mut self.max_concurrent,
384 default_max_concurrent(),
385 defaults.max_concurrent,
386 );
387 inherit_value_if_default(
388 &mut self.poll_interval,
389 default_poll_interval(),
390 defaults.poll_interval,
391 );
392 inherit_value_if_default(
393 &mut self.auto_close_parent,
394 default_auto_close_parent(),
395 defaults.auto_close_parent,
396 );
397 inherit_option(&mut self.rules_file, &defaults.rules_file);
398 inherit_value_if_default(&mut self.file_locking, false, defaults.file_locking);
399 inherit_value_if_default(&mut self.worktree, false, defaults.worktree);
400 inherit_option(&mut self.on_close, &defaults.on_close);
401 inherit_option(&mut self.on_fail, &defaults.on_fail);
402 inherit_option(&mut self.verify_timeout, &defaults.verify_timeout);
403 inherit_option(&mut self.review, &defaults.review);
404 inherit_option(&mut self.user, &defaults.user);
405 inherit_option(&mut self.user_email, &defaults.user_email);
406 inherit_value_if_default(&mut self.auto_commit, false, defaults.auto_commit);
407 inherit_option(&mut self.commit_template, &defaults.commit_template);
408 inherit_option(&mut self.research, &defaults.research);
409 inherit_option(&mut self.run_model, &defaults.run_model);
410 inherit_option(&mut self.plan_model, &defaults.plan_model);
411 inherit_option(&mut self.review_model, &defaults.review_model);
412 inherit_option(&mut self.research_model, &defaults.research_model);
413 inherit_value_if_default(&mut self.batch_verify, false, defaults.batch_verify);
414 inherit_value_if_default(&mut self.memory_reserve_mb, 0, defaults.memory_reserve_mb);
415 inherit_option(&mut self.notify, &defaults.notify);
416 }
417
418 fn resolve_extends_path(path_str: &str, mana_dir: &Path) -> Result<PathBuf> {
421 if let Some(stripped) = path_str.strip_prefix("~/") {
422 let home = dirs::home_dir().ok_or_else(|| anyhow!("Cannot resolve home directory"))?;
423 Ok(home.join(stripped))
424 } else {
425 let project_root = mana_dir.parent().unwrap_or(Path::new("."));
427 Ok(project_root.join(path_str))
428 }
429 }
430
431 pub fn save(&self, mana_dir: &Path) -> Result<()> {
433 let path = mana_dir.join("config.yaml");
434 let contents = serde_yml::to_string(self).context("Failed to serialize config")?;
435 fs::write(&path, &contents)
436 .with_context(|| format!("Failed to write config at {}", path.display()))?;
437 Ok(())
438 }
439
440 pub fn rules_path(&self, mana_dir: &Path) -> PathBuf {
444 match &self.rules_file {
445 Some(custom) => {
446 let p = Path::new(custom);
447 if p.is_absolute() {
448 p.to_path_buf()
449 } else {
450 mana_dir.join(custom)
451 }
452 }
453 None => mana_dir.join("RULES.md"),
454 }
455 }
456
457 pub fn increment_id(&mut self) -> u32 {
459 let id = self.next_id;
460 self.next_id += 1;
461 id
462 }
463}
464
465#[derive(Debug, Default, Serialize, Deserialize, PartialEq, Clone)]
476pub struct GlobalConfig {
477 #[serde(default, skip_serializing_if = "Option::is_none")]
478 pub auto_close_parent: Option<bool>,
479 #[serde(default, skip_serializing_if = "Option::is_none")]
480 pub run: Option<String>,
481 #[serde(default, skip_serializing_if = "Option::is_none")]
482 pub plan: Option<String>,
483 #[serde(default, skip_serializing_if = "Option::is_none")]
484 pub max_loops: Option<u32>,
485 #[serde(default, skip_serializing_if = "Option::is_none")]
486 pub max_concurrent: Option<u32>,
487 #[serde(default, skip_serializing_if = "Option::is_none")]
488 pub poll_interval: Option<u32>,
489 #[serde(default, skip_serializing_if = "Option::is_none")]
490 pub rules_file: Option<String>,
491 #[serde(default, skip_serializing_if = "Option::is_none")]
492 pub file_locking: Option<bool>,
493 #[serde(default, skip_serializing_if = "Option::is_none")]
494 pub worktree: Option<bool>,
495 #[serde(default, skip_serializing_if = "Option::is_none")]
496 pub on_close: Option<String>,
497 #[serde(default, skip_serializing_if = "Option::is_none")]
498 pub on_fail: Option<String>,
499 #[serde(default, skip_serializing_if = "Option::is_none")]
500 pub verify_timeout: Option<u64>,
501 #[serde(default, skip_serializing_if = "Option::is_none")]
502 pub review: Option<ReviewConfig>,
503 #[serde(default, skip_serializing_if = "Option::is_none")]
504 pub user: Option<String>,
505 #[serde(default, skip_serializing_if = "Option::is_none")]
506 pub user_email: Option<String>,
507 #[serde(default, skip_serializing_if = "Option::is_none")]
508 pub auto_commit: Option<bool>,
509 #[serde(default, skip_serializing_if = "Option::is_none")]
510 pub commit_template: Option<String>,
511 #[serde(default, skip_serializing_if = "Option::is_none")]
512 pub research: Option<String>,
513 #[serde(default, skip_serializing_if = "Option::is_none")]
514 pub run_model: Option<String>,
515 #[serde(default, skip_serializing_if = "Option::is_none")]
516 pub plan_model: Option<String>,
517 #[serde(default, skip_serializing_if = "Option::is_none")]
518 pub review_model: Option<String>,
519 #[serde(default, skip_serializing_if = "Option::is_none")]
520 pub research_model: Option<String>,
521 #[serde(default, skip_serializing_if = "Option::is_none")]
522 pub batch_verify: Option<bool>,
523 #[serde(default, skip_serializing_if = "Option::is_none")]
524 pub memory_reserve_mb: Option<u64>,
525 #[serde(default, skip_serializing_if = "Option::is_none")]
526 pub notify: Option<NotifyConfig>,
527}
528
529impl GlobalConfig {
530 pub fn path() -> Result<PathBuf> {
532 let home = dirs::home_dir().ok_or_else(|| anyhow!("Cannot determine home directory"))?;
533 Ok(home.join(".config").join("mana").join("config.yaml"))
534 }
535
536 fn legacy_path() -> Result<PathBuf> {
539 let home = dirs::home_dir().ok_or_else(|| anyhow!("Cannot determine home directory"))?;
540 Ok(home.join(".config").join("units").join("config.yaml"))
541 }
542
543 pub fn load() -> Result<Self> {
548 let path = Self::path()?;
549 if path.exists() {
550 let contents = fs::read_to_string(&path)
551 .with_context(|| format!("Failed to read global config at {}", path.display()))?;
552 let config: GlobalConfig = yaml::from_str(&contents)
553 .with_context(|| format!("Failed to parse global config at {}", path.display()))?;
554 return Ok(config);
555 }
556
557 if let Ok(legacy) = Self::legacy_path() {
559 if legacy.exists() {
560 let contents = fs::read_to_string(&legacy).with_context(|| {
561 format!(
562 "Failed to read legacy global config at {}",
563 legacy.display()
564 )
565 })?;
566 let config: GlobalConfig = yaml::from_str(&contents).with_context(|| {
567 format!(
568 "Failed to parse legacy global config at {}",
569 legacy.display()
570 )
571 })?;
572 return Ok(config);
573 }
574 }
575
576 Ok(Self::default())
577 }
578
579 pub fn save(&self) -> Result<()> {
581 let path = Self::path()?;
582 if let Some(parent) = path.parent() {
583 fs::create_dir_all(parent)
584 .with_context(|| format!("Failed to create {}", parent.display()))?;
585 }
586 let contents = serde_yml::to_string(self).context("Failed to serialize global config")?;
587 fs::write(&path, &contents)
588 .with_context(|| format!("Failed to write global config at {}", path.display()))?;
589 Ok(())
590 }
591
592 fn apply_defaults_to_config(&self, config: &mut Config) {
593 inherit_option(&mut config.run, &self.run);
594 inherit_option(&mut config.plan, &self.plan);
595 inherit_sparse_value_if_default(&mut config.max_loops, default_max_loops(), self.max_loops);
596 inherit_sparse_value_if_default(
597 &mut config.max_concurrent,
598 default_max_concurrent(),
599 self.max_concurrent,
600 );
601 inherit_sparse_value_if_default(
602 &mut config.poll_interval,
603 default_poll_interval(),
604 self.poll_interval,
605 );
606 inherit_sparse_value_if_default(
607 &mut config.auto_close_parent,
608 default_auto_close_parent(),
609 self.auto_close_parent,
610 );
611 inherit_option(&mut config.rules_file, &self.rules_file);
612 inherit_sparse_value_if_default(&mut config.file_locking, false, self.file_locking);
613 inherit_sparse_value_if_default(&mut config.worktree, false, self.worktree);
614 inherit_option(&mut config.on_close, &self.on_close);
615 inherit_option(&mut config.on_fail, &self.on_fail);
616 inherit_option(&mut config.verify_timeout, &self.verify_timeout);
617 inherit_option(&mut config.review, &self.review);
618 inherit_option(&mut config.user, &self.user);
619 inherit_option(&mut config.user_email, &self.user_email);
620 inherit_sparse_value_if_default(&mut config.auto_commit, false, self.auto_commit);
621 inherit_option(&mut config.commit_template, &self.commit_template);
622 inherit_option(&mut config.research, &self.research);
623 inherit_option(&mut config.run_model, &self.run_model);
624 inherit_option(&mut config.plan_model, &self.plan_model);
625 inherit_option(&mut config.review_model, &self.review_model);
626 inherit_option(&mut config.research_model, &self.research_model);
627 inherit_sparse_value_if_default(&mut config.batch_verify, false, self.batch_verify);
628 inherit_sparse_value_if_default(&mut config.memory_reserve_mb, 0, self.memory_reserve_mb);
629 inherit_option(&mut config.notify, &self.notify);
630 }
631}
632
633pub fn resolve_identity(mana_dir: &Path) -> Option<String> {
646 if let Ok(config) = Config::load_with_extends(mana_dir) {
648 if let Some(ref user) = config.user {
649 if !user.is_empty() {
650 return Some(user.clone());
651 }
652 }
653 }
654
655 if let Ok(global) = GlobalConfig::load() {
657 if let Some(ref user) = global.user {
658 if !user.is_empty() {
659 return Some(user.clone());
660 }
661 }
662 }
663
664 if let Some(git_user) = git_config_user_name() {
666 return Some(git_user);
667 }
668
669 std::env::var("USER").ok().filter(|u| !u.is_empty())
671}
672
673fn git_config_user_name() -> Option<String> {
675 Command::new("git")
676 .args(["config", "user.name"])
677 .output()
678 .ok()
679 .filter(|o| o.status.success())
680 .and_then(|o| String::from_utf8(o.stdout).ok())
681 .map(|s| s.trim().to_string())
682 .filter(|s| !s.is_empty())
683}
684
685#[cfg(test)]
686mod tests {
687 use super::*;
688 use std::fs;
689
690 #[test]
691 fn config_round_trips_through_yaml() {
692 let dir = tempfile::tempdir().unwrap();
693 let config = Config {
694 project: "test-project".to_string(),
695 next_id: 42,
696 auto_close_parent: true,
697 run: None,
698 plan: None,
699 max_loops: 10,
700 max_concurrent: 4,
701 poll_interval: 30,
702 extends: vec![],
703 rules_file: None,
704 file_locking: false,
705 worktree: false,
706 on_close: None,
707 on_fail: None,
708 verify_timeout: None,
709 review: None,
710 user: None,
711 user_email: None,
712 auto_commit: false,
713 commit_template: None,
714 research: None,
715 run_model: None,
716 plan_model: None,
717 review_model: None,
718 research_model: None,
719 batch_verify: false,
720 memory_reserve_mb: 0,
721 notify: None,
722 };
723
724 config.save(dir.path()).unwrap();
725 let loaded = Config::load(dir.path()).unwrap();
726
727 assert_eq!(config, loaded);
728 }
729
730 #[test]
731 fn increment_id_returns_current_and_bumps() {
732 let mut config = Config {
733 project: "test".to_string(),
734 next_id: 1,
735 auto_close_parent: true,
736 run: None,
737 plan: None,
738 max_loops: 10,
739 max_concurrent: 4,
740 poll_interval: 30,
741 extends: vec![],
742 rules_file: None,
743 file_locking: false,
744 worktree: false,
745 on_close: None,
746 on_fail: None,
747 verify_timeout: None,
748 review: None,
749 user: None,
750 user_email: None,
751 auto_commit: false,
752 commit_template: None,
753 research: None,
754 run_model: None,
755 plan_model: None,
756 review_model: None,
757 research_model: None,
758 batch_verify: false,
759 memory_reserve_mb: 0,
760 notify: None,
761 };
762
763 assert_eq!(config.increment_id(), 1);
764 assert_eq!(config.increment_id(), 2);
765 assert_eq!(config.increment_id(), 3);
766 assert_eq!(config.next_id, 4);
767 }
768
769 #[test]
770 fn load_returns_error_for_missing_file() {
771 let dir = tempfile::tempdir().unwrap();
772 let result = Config::load(dir.path());
773 assert!(result.is_err());
774 }
775
776 #[test]
777 fn load_returns_error_for_invalid_yaml() {
778 let dir = tempfile::tempdir().unwrap();
779 fs::write(dir.path().join("config.yaml"), "not: [valid: yaml: config").unwrap();
780 let result = Config::load(dir.path());
781 assert!(result.is_err());
782 }
783
784 #[test]
785 fn save_creates_file_that_is_valid_yaml() {
786 let dir = tempfile::tempdir().unwrap();
787 let config = Config {
788 project: "my-project".to_string(),
789 next_id: 100,
790 auto_close_parent: true,
791 run: None,
792 plan: None,
793 max_loops: 10,
794 max_concurrent: 4,
795 poll_interval: 30,
796 extends: vec![],
797 rules_file: None,
798 file_locking: false,
799 worktree: false,
800 on_close: None,
801 on_fail: None,
802 verify_timeout: None,
803 review: None,
804 user: None,
805 user_email: None,
806 auto_commit: false,
807 commit_template: None,
808 research: None,
809 run_model: None,
810 plan_model: None,
811 review_model: None,
812 research_model: None,
813 batch_verify: false,
814 memory_reserve_mb: 0,
815 notify: None,
816 };
817 config.save(dir.path()).unwrap();
818
819 let contents = fs::read_to_string(dir.path().join("config.yaml")).unwrap();
820 assert!(contents.contains("project: my-project"));
821 assert!(contents.contains("next_id: 100"));
822 }
823
824 #[test]
825 fn auto_close_parent_defaults_to_true() {
826 let dir = tempfile::tempdir().unwrap();
827 fs::write(
829 dir.path().join("config.yaml"),
830 "project: test\nnext_id: 1\n",
831 )
832 .unwrap();
833
834 let loaded = Config::load(dir.path()).unwrap();
835 assert!(loaded.auto_close_parent);
836 }
837
838 #[test]
839 fn auto_close_parent_can_be_disabled() {
840 let dir = tempfile::tempdir().unwrap();
841 let config = Config {
842 project: "test".to_string(),
843 next_id: 1,
844 auto_close_parent: false,
845 run: None,
846 plan: None,
847 max_loops: 10,
848 max_concurrent: 4,
849 poll_interval: 30,
850 extends: vec![],
851 rules_file: None,
852 file_locking: false,
853 worktree: false,
854 on_close: None,
855 on_fail: None,
856 verify_timeout: None,
857 review: None,
858 user: None,
859 user_email: None,
860 auto_commit: false,
861 commit_template: None,
862 research: None,
863 run_model: None,
864 plan_model: None,
865 review_model: None,
866 research_model: None,
867 batch_verify: false,
868 memory_reserve_mb: 0,
869 notify: None,
870 };
871 config.save(dir.path()).unwrap();
872
873 let loaded = Config::load(dir.path()).unwrap();
874 assert!(!loaded.auto_close_parent);
875 }
876
877 #[test]
878 fn max_tokens_in_yaml_silently_ignored() {
879 let dir = tempfile::tempdir().unwrap();
880 fs::write(
882 dir.path().join("config.yaml"),
883 "project: test\nnext_id: 1\nmax_tokens: 50000\n",
884 )
885 .unwrap();
886
887 let loaded = Config::load(dir.path()).unwrap();
888 assert_eq!(loaded.project, "test");
889 }
890
891 #[test]
892 fn run_defaults_to_none() {
893 let dir = tempfile::tempdir().unwrap();
894 fs::write(
895 dir.path().join("config.yaml"),
896 "project: test\nnext_id: 1\n",
897 )
898 .unwrap();
899
900 let loaded = Config::load(dir.path()).unwrap();
901 assert_eq!(loaded.run, None);
902 }
903
904 #[test]
905 fn run_can_be_set() {
906 let dir = tempfile::tempdir().unwrap();
907 let config = Config {
908 project: "test".to_string(),
909 next_id: 1,
910 auto_close_parent: true,
911 run: Some("claude -p 'implement unit {id}'".to_string()),
912 plan: None,
913 max_loops: 10,
914 max_concurrent: 4,
915 poll_interval: 30,
916 extends: vec![],
917 rules_file: None,
918 file_locking: false,
919 worktree: false,
920 on_close: None,
921 on_fail: None,
922 verify_timeout: None,
923 review: None,
924 user: None,
925 user_email: None,
926 auto_commit: false,
927 commit_template: None,
928 research: None,
929 run_model: None,
930 plan_model: None,
931 review_model: None,
932 research_model: None,
933 batch_verify: false,
934 memory_reserve_mb: 0,
935 notify: None,
936 };
937 config.save(dir.path()).unwrap();
938
939 let loaded = Config::load(dir.path()).unwrap();
940 assert_eq!(
941 loaded.run,
942 Some("claude -p 'implement unit {id}'".to_string())
943 );
944 }
945
946 #[test]
947 fn run_not_serialized_when_none() {
948 let dir = tempfile::tempdir().unwrap();
949 let config = Config {
950 project: "test".to_string(),
951 next_id: 1,
952 auto_close_parent: true,
953 run: None,
954 plan: None,
955 max_loops: 10,
956 max_concurrent: 4,
957 poll_interval: 30,
958 extends: vec![],
959 rules_file: None,
960 file_locking: false,
961 worktree: false,
962 on_close: None,
963 on_fail: None,
964 verify_timeout: None,
965 review: None,
966 user: None,
967 user_email: None,
968 auto_commit: false,
969 commit_template: None,
970 research: None,
971 run_model: None,
972 plan_model: None,
973 review_model: None,
974 research_model: None,
975 batch_verify: false,
976 memory_reserve_mb: 0,
977 notify: None,
978 };
979 config.save(dir.path()).unwrap();
980
981 let contents = fs::read_to_string(dir.path().join("config.yaml")).unwrap();
982 assert!(!contents.contains("run:"));
983 }
984
985 #[test]
986 fn max_loops_defaults_to_10() {
987 let dir = tempfile::tempdir().unwrap();
988 fs::write(
989 dir.path().join("config.yaml"),
990 "project: test\nnext_id: 1\n",
991 )
992 .unwrap();
993
994 let loaded = Config::load(dir.path()).unwrap();
995 assert_eq!(loaded.max_loops, 10);
996 }
997
998 #[test]
999 fn max_loops_can_be_customized() {
1000 let dir = tempfile::tempdir().unwrap();
1001 let config = Config {
1002 project: "test".to_string(),
1003 next_id: 1,
1004 auto_close_parent: true,
1005 run: None,
1006 plan: None,
1007 max_loops: 25,
1008 max_concurrent: 4,
1009 poll_interval: 30,
1010 extends: vec![],
1011 rules_file: None,
1012 file_locking: false,
1013 worktree: false,
1014 on_close: None,
1015 on_fail: None,
1016 verify_timeout: None,
1017 review: None,
1018 user: None,
1019 user_email: None,
1020 auto_commit: false,
1021 commit_template: None,
1022 research: None,
1023 run_model: None,
1024 plan_model: None,
1025 review_model: None,
1026 research_model: None,
1027 batch_verify: false,
1028 memory_reserve_mb: 0,
1029 notify: None,
1030 };
1031 config.save(dir.path()).unwrap();
1032
1033 let loaded = Config::load(dir.path()).unwrap();
1034 assert_eq!(loaded.max_loops, 25);
1035 }
1036
1037 fn write_yaml(path: &std::path::Path, yaml: &str) {
1041 if let Some(parent) = path.parent() {
1042 fs::create_dir_all(parent).unwrap();
1043 }
1044 fs::write(path, yaml).unwrap();
1045 }
1046
1047 fn write_local_config(mana_dir: &std::path::Path, extends: &[&str], extra: &str) {
1049 let extends_yaml: Vec<String> = extends.iter().map(|e| format!(" - \"{}\"", e)).collect();
1050 let extends_block = if extends.is_empty() {
1051 String::new()
1052 } else {
1053 format!("extends:\n{}\n", extends_yaml.join("\n"))
1054 };
1055 let yaml = format!("project: test\nnext_id: 1\n{}{}", extends_block, extra);
1056 write_yaml(&mana_dir.join("config.yaml"), &yaml);
1057 }
1058
1059 #[test]
1060 fn extends_empty_loads_normally() {
1061 let dir = tempfile::tempdir().unwrap();
1062 let home = tempfile::tempdir().unwrap();
1063 std::env::set_var("HOME", home.path());
1064 let mana_dir = dir.path().join(".mana");
1065 fs::create_dir_all(&mana_dir).unwrap();
1066 write_local_config(&mana_dir, &[], "");
1067
1068 let config = Config::load_with_extends(&mana_dir).unwrap();
1069 assert_eq!(config.project, "test");
1070 assert!(config.run.is_none());
1071 }
1072
1073 #[test]
1074 fn extends_single_merges_fields() {
1075 let dir = tempfile::tempdir().unwrap();
1076 let mana_dir = dir.path().join(".mana");
1077 fs::create_dir_all(&mana_dir).unwrap();
1078
1079 let parent_path = dir.path().join("shared.yaml");
1081 write_yaml(
1082 &parent_path,
1083 "project: shared\nnext_id: 999\nrun: \"deli spawn {id}\"\nmax_loops: 20\n",
1084 );
1085
1086 write_local_config(&mana_dir, &["shared.yaml"], "");
1087
1088 let config = Config::load_with_extends(&mana_dir).unwrap();
1089 assert_eq!(config.run, Some("deli spawn {id}".to_string()));
1091 assert_eq!(config.max_loops, 20);
1092 assert_eq!(config.project, "test");
1094 assert_eq!(config.next_id, 1);
1095 }
1096
1097 #[test]
1098 fn extends_local_overrides_parent() {
1099 let dir = tempfile::tempdir().unwrap();
1100 let mana_dir = dir.path().join(".mana");
1101 fs::create_dir_all(&mana_dir).unwrap();
1102
1103 let parent_path = dir.path().join("shared.yaml");
1104 write_yaml(
1105 &parent_path,
1106 "project: shared\nnext_id: 999\nrun: \"parent-run\"\nmax_loops: 20\n",
1107 );
1108
1109 write_local_config(
1111 &mana_dir,
1112 &["shared.yaml"],
1113 "run: \"local-run\"\nmax_loops: 5\n",
1114 );
1115
1116 let config = Config::load_with_extends(&mana_dir).unwrap();
1117 assert_eq!(config.run, Some("local-run".to_string()));
1119 assert_eq!(config.max_loops, 5);
1120 }
1121
1122 #[test]
1123 fn extends_circular_detected_and_skipped() {
1124 let dir = tempfile::tempdir().unwrap();
1125 let mana_dir = dir.path().join(".mana");
1126 fs::create_dir_all(&mana_dir).unwrap();
1127
1128 let a_path = dir.path().join("a.yaml");
1130 let b_path = dir.path().join("b.yaml");
1131 write_yaml(
1132 &a_path,
1133 "project: a\nnext_id: 1\nextends:\n - \"b.yaml\"\nmax_loops: 40\n",
1134 );
1135 write_yaml(
1136 &b_path,
1137 "project: b\nnext_id: 1\nextends:\n - \"a.yaml\"\nmax_loops: 50\n",
1138 );
1139
1140 write_local_config(&mana_dir, &["a.yaml"], "");
1141
1142 let config = Config::load_with_extends(&mana_dir).unwrap();
1144 assert_eq!(config.project, "test");
1145 assert!(config.max_loops == 40 || config.max_loops == 50);
1147 }
1148
1149 #[test]
1150 fn extends_missing_file_errors() {
1151 let dir = tempfile::tempdir().unwrap();
1152 let mana_dir = dir.path().join(".mana");
1153 fs::create_dir_all(&mana_dir).unwrap();
1154
1155 write_local_config(&mana_dir, &["nonexistent.yaml"], "");
1156
1157 let result = Config::load_with_extends(&mana_dir);
1158 assert!(result.is_err());
1159 let err_msg = format!("{}", result.unwrap_err());
1160 assert!(
1161 err_msg.contains("nonexistent.yaml"),
1162 "Error should mention the missing file: {}",
1163 err_msg
1164 );
1165 }
1166
1167 #[test]
1168 fn extends_recursive_a_extends_b_extends_c() {
1169 let dir = tempfile::tempdir().unwrap();
1170 let mana_dir = dir.path().join(".mana");
1171 fs::create_dir_all(&mana_dir).unwrap();
1172
1173 let c_path = dir.path().join("c.yaml");
1175 write_yaml(
1176 &c_path,
1177 "project: c\nnext_id: 1\nrun: \"from-c\"\nmax_loops: 40\n",
1178 );
1179
1180 let b_path = dir.path().join("b.yaml");
1182 write_yaml(
1183 &b_path,
1184 "project: b\nnext_id: 1\nextends:\n - \"c.yaml\"\nmax_loops: 50\n",
1185 );
1186
1187 write_local_config(&mana_dir, &["b.yaml"], "");
1189
1190 let config = Config::load_with_extends(&mana_dir).unwrap();
1191 assert_eq!(config.max_loops, 50);
1193 assert_eq!(config.run, Some("from-c".to_string()));
1195 }
1196
1197 #[test]
1198 fn extends_project_and_next_id_never_inherited() {
1199 let dir = tempfile::tempdir().unwrap();
1200 let mana_dir = dir.path().join(".mana");
1201 fs::create_dir_all(&mana_dir).unwrap();
1202
1203 let parent_path = dir.path().join("shared.yaml");
1204 write_yaml(
1205 &parent_path,
1206 "project: parent-project\nnext_id: 999\nmax_loops: 50\n",
1207 );
1208
1209 write_local_config(&mana_dir, &["shared.yaml"], "");
1210
1211 let config = Config::load_with_extends(&mana_dir).unwrap();
1212 assert_eq!(config.project, "test");
1213 assert_eq!(config.next_id, 1);
1214 }
1215
1216 #[test]
1217 fn extends_tilde_resolves_to_home_dir() {
1218 let mana_dir = std::path::Path::new("/tmp/fake-units");
1221 let resolved = Config::resolve_extends_path("~/shared/config.yaml", mana_dir).unwrap();
1222 let home = dirs::home_dir().unwrap();
1223 assert_eq!(resolved, home.join("shared/config.yaml"));
1224 }
1225
1226 #[test]
1227 fn extends_not_serialized_when_empty() {
1228 let dir = tempfile::tempdir().unwrap();
1229 let config = Config {
1230 project: "test".to_string(),
1231 next_id: 1,
1232 auto_close_parent: true,
1233 run: None,
1234 plan: None,
1235 max_loops: 10,
1236 max_concurrent: 4,
1237 poll_interval: 30,
1238 extends: vec![],
1239 rules_file: None,
1240 file_locking: false,
1241 worktree: false,
1242 on_close: None,
1243 on_fail: None,
1244 verify_timeout: None,
1245 review: None,
1246 user: None,
1247 user_email: None,
1248 auto_commit: false,
1249 commit_template: None,
1250 research: None,
1251 run_model: None,
1252 plan_model: None,
1253 review_model: None,
1254 research_model: None,
1255 batch_verify: false,
1256 memory_reserve_mb: 0,
1257 notify: None,
1258 };
1259 config.save(dir.path()).unwrap();
1260
1261 let contents = fs::read_to_string(dir.path().join("config.yaml")).unwrap();
1262 assert!(!contents.contains("extends"));
1263 }
1264
1265 #[test]
1266 fn extends_defaults_to_empty() {
1267 let dir = tempfile::tempdir().unwrap();
1268 fs::write(
1269 dir.path().join("config.yaml"),
1270 "project: test\nnext_id: 1\n",
1271 )
1272 .unwrap();
1273
1274 let loaded = Config::load(dir.path()).unwrap();
1275 assert!(loaded.extends.is_empty());
1276 }
1277
1278 #[test]
1281 fn plan_defaults_to_none() {
1282 let dir = tempfile::tempdir().unwrap();
1283 fs::write(
1284 dir.path().join("config.yaml"),
1285 "project: test\nnext_id: 1\n",
1286 )
1287 .unwrap();
1288
1289 let loaded = Config::load(dir.path()).unwrap();
1290 assert_eq!(loaded.plan, None);
1291 }
1292
1293 #[test]
1294 fn plan_can_be_set() {
1295 let dir = tempfile::tempdir().unwrap();
1296 let config = Config {
1297 project: "test".to_string(),
1298 next_id: 1,
1299 auto_close_parent: true,
1300 run: None,
1301 plan: Some("claude -p 'plan unit {id}'".to_string()),
1302 max_loops: 10,
1303 max_concurrent: 4,
1304 poll_interval: 30,
1305 extends: vec![],
1306 rules_file: None,
1307 file_locking: false,
1308 worktree: false,
1309 on_close: None,
1310 on_fail: None,
1311 verify_timeout: None,
1312 review: None,
1313 user: None,
1314 user_email: None,
1315 auto_commit: false,
1316 commit_template: None,
1317 research: None,
1318 run_model: None,
1319 plan_model: None,
1320 review_model: None,
1321 research_model: None,
1322 batch_verify: false,
1323 memory_reserve_mb: 0,
1324 notify: None,
1325 };
1326 config.save(dir.path()).unwrap();
1327
1328 let loaded = Config::load(dir.path()).unwrap();
1329 assert_eq!(loaded.plan, Some("claude -p 'plan unit {id}'".to_string()));
1330 }
1331
1332 #[test]
1333 fn plan_not_serialized_when_none() {
1334 let dir = tempfile::tempdir().unwrap();
1335 let config = Config {
1336 project: "test".to_string(),
1337 next_id: 1,
1338 auto_close_parent: true,
1339 run: None,
1340 plan: None,
1341 max_loops: 10,
1342 max_concurrent: 4,
1343 poll_interval: 30,
1344 extends: vec![],
1345 rules_file: None,
1346 file_locking: false,
1347 worktree: false,
1348 on_close: None,
1349 on_fail: None,
1350 verify_timeout: None,
1351 review: None,
1352 user: None,
1353 user_email: None,
1354 auto_commit: false,
1355 commit_template: None,
1356 research: None,
1357 run_model: None,
1358 plan_model: None,
1359 review_model: None,
1360 research_model: None,
1361 batch_verify: false,
1362 memory_reserve_mb: 0,
1363 notify: None,
1364 };
1365 config.save(dir.path()).unwrap();
1366
1367 let contents = fs::read_to_string(dir.path().join("config.yaml")).unwrap();
1368 assert!(!contents.contains("plan:"));
1369 }
1370
1371 #[test]
1372 fn max_concurrent_defaults_to_4() {
1373 let dir = tempfile::tempdir().unwrap();
1374 fs::write(
1375 dir.path().join("config.yaml"),
1376 "project: test\nnext_id: 1\n",
1377 )
1378 .unwrap();
1379
1380 let loaded = Config::load(dir.path()).unwrap();
1381 assert_eq!(loaded.max_concurrent, 4);
1382 }
1383
1384 #[test]
1385 fn max_concurrent_can_be_customized() {
1386 let dir = tempfile::tempdir().unwrap();
1387 let config = Config {
1388 project: "test".to_string(),
1389 next_id: 1,
1390 auto_close_parent: true,
1391 run: None,
1392 plan: None,
1393 max_loops: 10,
1394 max_concurrent: 8,
1395 poll_interval: 30,
1396 extends: vec![],
1397 rules_file: None,
1398 file_locking: false,
1399 worktree: false,
1400 on_close: None,
1401 on_fail: None,
1402 verify_timeout: None,
1403 review: None,
1404 user: None,
1405 user_email: None,
1406 auto_commit: false,
1407 commit_template: None,
1408 research: None,
1409 run_model: None,
1410 plan_model: None,
1411 review_model: None,
1412 research_model: None,
1413 batch_verify: false,
1414 memory_reserve_mb: 0,
1415 notify: None,
1416 };
1417 config.save(dir.path()).unwrap();
1418
1419 let loaded = Config::load(dir.path()).unwrap();
1420 assert_eq!(loaded.max_concurrent, 8);
1421 }
1422
1423 #[test]
1424 fn poll_interval_defaults_to_30() {
1425 let dir = tempfile::tempdir().unwrap();
1426 fs::write(
1427 dir.path().join("config.yaml"),
1428 "project: test\nnext_id: 1\n",
1429 )
1430 .unwrap();
1431
1432 let loaded = Config::load(dir.path()).unwrap();
1433 assert_eq!(loaded.poll_interval, 30);
1434 }
1435
1436 #[test]
1437 fn poll_interval_can_be_customized() {
1438 let dir = tempfile::tempdir().unwrap();
1439 let config = Config {
1440 project: "test".to_string(),
1441 next_id: 1,
1442 auto_close_parent: true,
1443 run: None,
1444 plan: None,
1445 max_loops: 10,
1446 max_concurrent: 4,
1447 poll_interval: 60,
1448 extends: vec![],
1449 rules_file: None,
1450 file_locking: false,
1451 worktree: false,
1452 on_close: None,
1453 on_fail: None,
1454 verify_timeout: None,
1455 review: None,
1456 user: None,
1457 user_email: None,
1458 auto_commit: false,
1459 commit_template: None,
1460 research: None,
1461 run_model: None,
1462 plan_model: None,
1463 review_model: None,
1464 research_model: None,
1465 batch_verify: false,
1466 memory_reserve_mb: 0,
1467 notify: None,
1468 };
1469 config.save(dir.path()).unwrap();
1470
1471 let loaded = Config::load(dir.path()).unwrap();
1472 assert_eq!(loaded.poll_interval, 60);
1473 }
1474
1475 #[test]
1476 fn extends_inherits_plan() {
1477 let dir = tempfile::tempdir().unwrap();
1478 let mana_dir = dir.path().join(".mana");
1479 fs::create_dir_all(&mana_dir).unwrap();
1480
1481 let parent_path = dir.path().join("shared.yaml");
1482 write_yaml(
1483 &parent_path,
1484 "project: shared\nnext_id: 999\nplan: \"plan-cmd {id}\"\n",
1485 );
1486
1487 write_local_config(&mana_dir, &["shared.yaml"], "");
1488
1489 let config = Config::load_with_extends(&mana_dir).unwrap();
1490 assert_eq!(config.plan, Some("plan-cmd {id}".to_string()));
1491 }
1492
1493 #[test]
1494 fn extends_inherits_max_concurrent() {
1495 let dir = tempfile::tempdir().unwrap();
1496 let mana_dir = dir.path().join(".mana");
1497 fs::create_dir_all(&mana_dir).unwrap();
1498
1499 let parent_path = dir.path().join("shared.yaml");
1500 write_yaml(
1501 &parent_path,
1502 "project: shared\nnext_id: 999\nmax_concurrent: 16\n",
1503 );
1504
1505 write_local_config(&mana_dir, &["shared.yaml"], "");
1506
1507 let config = Config::load_with_extends(&mana_dir).unwrap();
1508 assert_eq!(config.max_concurrent, 16);
1509 }
1510
1511 #[test]
1512 fn extends_inherits_poll_interval() {
1513 let dir = tempfile::tempdir().unwrap();
1514 let mana_dir = dir.path().join(".mana");
1515 fs::create_dir_all(&mana_dir).unwrap();
1516
1517 let parent_path = dir.path().join("shared.yaml");
1518 write_yaml(
1519 &parent_path,
1520 "project: shared\nnext_id: 999\npoll_interval: 120\n",
1521 );
1522
1523 write_local_config(&mana_dir, &["shared.yaml"], "");
1524
1525 let config = Config::load_with_extends(&mana_dir).unwrap();
1526 assert_eq!(config.poll_interval, 120);
1527 }
1528
1529 #[test]
1530 fn extends_local_overrides_new_fields() {
1531 let dir = tempfile::tempdir().unwrap();
1532 let mana_dir = dir.path().join(".mana");
1533 fs::create_dir_all(&mana_dir).unwrap();
1534
1535 let parent_path = dir.path().join("shared.yaml");
1536 write_yaml(
1537 &parent_path,
1538 "project: shared\nnext_id: 999\nplan: \"parent-plan\"\nmax_concurrent: 16\npoll_interval: 120\n",
1539 );
1540
1541 write_local_config(
1542 &mana_dir,
1543 &["shared.yaml"],
1544 "plan: \"local-plan\"\nmax_concurrent: 2\npoll_interval: 10\n",
1545 );
1546
1547 let config = Config::load_with_extends(&mana_dir).unwrap();
1548 assert_eq!(config.plan, Some("local-plan".to_string()));
1549 assert_eq!(config.max_concurrent, 2);
1550 assert_eq!(config.poll_interval, 10);
1551 }
1552
1553 #[test]
1554 fn new_fields_round_trip_through_yaml() {
1555 let dir = tempfile::tempdir().unwrap();
1556 let config = Config {
1557 project: "test".to_string(),
1558 next_id: 1,
1559 auto_close_parent: true,
1560 run: None,
1561 plan: Some("plan {id}".to_string()),
1562 max_loops: 10,
1563 max_concurrent: 8,
1564 poll_interval: 60,
1565 extends: vec![],
1566 rules_file: None,
1567 file_locking: false,
1568 worktree: false,
1569 on_close: None,
1570 on_fail: None,
1571 verify_timeout: None,
1572 review: None,
1573 user: None,
1574 user_email: None,
1575 auto_commit: false,
1576 commit_template: None,
1577 research: None,
1578 run_model: None,
1579 plan_model: None,
1580 review_model: None,
1581 research_model: None,
1582 batch_verify: false,
1583 memory_reserve_mb: 0,
1584 notify: None,
1585 };
1586
1587 config.save(dir.path()).unwrap();
1588 let loaded = Config::load(dir.path()).unwrap();
1589
1590 assert_eq!(config, loaded);
1591 }
1592
1593 #[test]
1594 fn batch_verify_defaults_to_false() {
1595 let dir = tempfile::tempdir().unwrap();
1596 fs::write(
1597 dir.path().join("config.yaml"),
1598 "project: test\nnext_id: 1\n",
1599 )
1600 .unwrap();
1601
1602 let loaded = Config::load(dir.path()).unwrap();
1603 assert!(!loaded.batch_verify);
1604 }
1605
1606 #[test]
1607 fn batch_verify_can_be_enabled() {
1608 let dir = tempfile::tempdir().unwrap();
1609 fs::write(
1610 dir.path().join("config.yaml"),
1611 "project: test\nnext_id: 1\nbatch_verify: true\n",
1612 )
1613 .unwrap();
1614
1615 let loaded = Config::load(dir.path()).unwrap();
1616 assert!(loaded.batch_verify);
1617 }
1618
1619 #[test]
1620 fn batch_verify_not_serialized_when_false() {
1621 let dir = tempfile::tempdir().unwrap();
1622 fs::write(
1623 dir.path().join("config.yaml"),
1624 "project: test\nnext_id: 1\n",
1625 )
1626 .unwrap();
1627
1628 let loaded = Config::load(dir.path()).unwrap();
1629 assert!(!loaded.batch_verify);
1630
1631 loaded.save(dir.path()).unwrap();
1632 let contents = fs::read_to_string(dir.path().join("config.yaml")).unwrap();
1633 assert!(!contents.contains("batch_verify"));
1634 }
1635
1636 fn with_temp_home<T>(f: impl FnOnce(&std::path::Path) -> T) -> T {
1637 use std::sync::{Mutex, OnceLock};
1638
1639 static HOME_LOCK: OnceLock<Mutex<()>> = OnceLock::new();
1640 let guard = HOME_LOCK.get_or_init(|| Mutex::new(())).lock().unwrap();
1641
1642 let home = tempfile::tempdir().unwrap();
1643 let old_home = std::env::var_os("HOME");
1644 std::env::set_var("HOME", home.path());
1645 let result = f(home.path());
1646 if let Some(old_home) = old_home {
1647 std::env::set_var("HOME", old_home);
1648 } else {
1649 std::env::remove_var("HOME");
1650 }
1651 drop(guard);
1652 result
1653 }
1654
1655 #[test]
1656 fn load_with_extends_inherits_global_defaults() {
1657 with_temp_home(|home| {
1658 let global_dir = home.join(".config").join("mana");
1659 fs::create_dir_all(&global_dir).unwrap();
1660 fs::write(
1661 global_dir.join("config.yaml"),
1662 "run: \"imp run {id} && mana close {id}\"\nrun_model: gpt-5.4\nmax_concurrent: 12\nbatch_verify: true\nmemory_reserve_mb: 2048\nnotify:\n on_fail: \"echo fail\"\n",
1663 )
1664 .unwrap();
1665
1666 let dir = tempfile::tempdir().unwrap();
1667 let mana_dir = dir.path().join(".mana");
1668 fs::create_dir_all(&mana_dir).unwrap();
1669 write_local_config(&mana_dir, &[], "");
1670
1671 let config = Config::load_with_extends(&mana_dir).unwrap();
1672 assert_eq!(
1673 config.run.as_deref(),
1674 Some("imp run {id} && mana close {id}")
1675 );
1676 assert_eq!(config.run_model.as_deref(), Some("gpt-5.4"));
1677 assert_eq!(config.max_concurrent, 12);
1678 assert!(config.batch_verify);
1679 assert_eq!(config.memory_reserve_mb, 2048);
1680 assert_eq!(
1681 config.notify,
1682 Some(NotifyConfig {
1683 on_close: None,
1684 on_fail: Some("echo fail".to_string()),
1685 on_scheduled_complete: None,
1686 })
1687 );
1688 });
1689 }
1690
1691 #[test]
1692 fn load_with_extends_inherits_defaults_from_extended_config() {
1693 let dir = tempfile::tempdir().unwrap();
1694 let mana_dir = dir.path().join(".mana");
1695 fs::create_dir_all(&mana_dir).unwrap();
1696
1697 let parent_path = dir.path().join("shared.yaml");
1698 write_yaml(
1699 &parent_path,
1700 "project: shared\nnext_id: 999\nbatch_verify: true\nmemory_reserve_mb: 1024\nnotify:\n on_close: \"echo closed\"\n",
1701 );
1702
1703 write_local_config(&mana_dir, &["shared.yaml"], "");
1704
1705 let config = Config::load_with_extends(&mana_dir).unwrap();
1706 assert!(config.batch_verify);
1707 assert_eq!(config.memory_reserve_mb, 1024);
1708 assert_eq!(
1709 config.notify,
1710 Some(NotifyConfig {
1711 on_close: Some("echo closed".to_string()),
1712 on_fail: None,
1713 on_scheduled_complete: None,
1714 })
1715 );
1716 }
1717
1718 #[test]
1719 fn load_with_extends_prefers_project_over_global_defaults() {
1720 with_temp_home(|home| {
1721 let global_dir = home.join(".config").join("mana");
1722 fs::create_dir_all(&global_dir).unwrap();
1723 fs::write(
1724 global_dir.join("config.yaml"),
1725 "run: \"imp run {id} && mana close {id}\"\nrun_model: gpt-5.4\n",
1726 )
1727 .unwrap();
1728
1729 let dir = tempfile::tempdir().unwrap();
1730 let mana_dir = dir.path().join(".mana");
1731 fs::create_dir_all(&mana_dir).unwrap();
1732 write_local_config(
1733 &mana_dir,
1734 &[],
1735 "run: \"local-run {id}\"\nrun_model: sonnet\n",
1736 );
1737
1738 let config = Config::load_with_extends(&mana_dir).unwrap();
1739 assert_eq!(config.run.as_deref(), Some("local-run {id}"));
1740 assert_eq!(config.run_model.as_deref(), Some("sonnet"));
1741 });
1742 }
1743}