1use std::collections::HashSet;
2use std::fs;
3use std::path::{Path, PathBuf};
4use std::process::Command;
5
6use anyhow::{anyhow, Context, Result};
7use serde::{Deserialize, Serialize};
8
9#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
11pub struct ReviewConfig {
12 #[serde(default, skip_serializing_if = "Option::is_none")]
15 pub run: Option<String>,
16 #[serde(default = "default_max_reopens")]
18 pub max_reopens: u32,
19}
20
21fn default_max_reopens() -> u32 {
22 2
23}
24
25impl Default for ReviewConfig {
26 fn default() -> Self {
27 Self {
28 run: None,
29 max_reopens: 2,
30 }
31 }
32}
33
34#[derive(Debug, Serialize, Deserialize, PartialEq)]
35pub struct Config {
36 pub project: String,
37 pub next_id: u32,
38 #[serde(default = "default_auto_close_parent")]
40 pub auto_close_parent: bool,
41 #[serde(default, skip_serializing_if = "Option::is_none")]
45 pub run: Option<String>,
46 #[serde(default, skip_serializing_if = "Option::is_none")]
49 pub plan: Option<String>,
50 #[serde(default = "default_max_loops")]
52 pub max_loops: u32,
53 #[serde(default = "default_max_concurrent")]
55 pub max_concurrent: u32,
56 #[serde(default = "default_poll_interval")]
58 pub poll_interval: u32,
59 #[serde(default, skip_serializing_if = "Vec::is_empty")]
62 pub extends: Vec<String>,
63 #[serde(default, skip_serializing_if = "Option::is_none")]
66 pub rules_file: Option<String>,
67 #[serde(default, skip_serializing_if = "is_false_bool")]
72 pub file_locking: bool,
73 #[serde(default, skip_serializing_if = "is_false_bool")]
78 pub worktree: bool,
79 #[serde(default, skip_serializing_if = "Option::is_none")]
83 pub on_close: Option<String>,
84 #[serde(default, skip_serializing_if = "Option::is_none")]
88 pub on_fail: Option<String>,
89 #[serde(default, skip_serializing_if = "Option::is_none")]
93 pub post_plan: Option<String>,
94 #[serde(default, skip_serializing_if = "Option::is_none")]
97 pub verify_timeout: Option<u64>,
98 #[serde(default, skip_serializing_if = "Option::is_none")]
101 pub review: Option<ReviewConfig>,
102 #[serde(default, skip_serializing_if = "Option::is_none")]
104 pub user: Option<String>,
105 #[serde(default, skip_serializing_if = "Option::is_none")]
107 pub user_email: Option<String>,
108}
109
110fn default_auto_close_parent() -> bool {
111 true
112}
113
114fn default_max_loops() -> u32 {
115 10
116}
117
118fn default_max_concurrent() -> u32 {
119 4
120}
121
122fn default_poll_interval() -> u32 {
123 30
124}
125
126fn is_false_bool(v: &bool) -> bool {
127 !v
128}
129
130impl Default for Config {
131 fn default() -> Self {
132 Self {
133 project: String::new(),
134 next_id: 1,
135 auto_close_parent: true,
136 run: None,
137 plan: None,
138 max_loops: 10,
139 max_concurrent: 4,
140 poll_interval: 30,
141 extends: Vec::new(),
142 rules_file: None,
143 file_locking: false,
144 worktree: false,
145 on_close: None,
146 on_fail: None,
147 post_plan: None,
148 verify_timeout: None,
149 review: None,
150 user: None,
151 user_email: None,
152 }
153 }
154}
155
156impl Config {
157 pub fn load(beans_dir: &Path) -> Result<Self> {
159 let path = beans_dir.join("config.yaml");
160 let contents = fs::read_to_string(&path)
161 .with_context(|| format!("Failed to read config at {}", path.display()))?;
162 let config: Config = serde_yml::from_str(&contents)
163 .with_context(|| format!("Failed to parse config at {}", path.display()))?;
164 Ok(config)
165 }
166
167 pub fn load_with_extends(beans_dir: &Path) -> Result<Self> {
173 let mut config = Self::load(beans_dir)?;
174
175 if config.extends.is_empty() {
176 return Ok(config);
177 }
178
179 let mut seen = HashSet::new();
180 let mut stack: Vec<String> = config.extends.clone();
181 let mut parents: Vec<Config> = Vec::new();
182
183 while let Some(path_str) = stack.pop() {
184 let resolved = Self::resolve_extends_path(&path_str, beans_dir)?;
185
186 let canonical = resolved
187 .canonicalize()
188 .with_context(|| format!("Cannot resolve extends path: {}", path_str))?;
189
190 if !seen.insert(canonical.clone()) {
191 continue; }
193
194 let contents = fs::read_to_string(&canonical).with_context(|| {
195 format!("Failed to read extends config: {}", canonical.display())
196 })?;
197 let parent: Config = serde_yml::from_str(&contents).with_context(|| {
198 format!("Failed to parse extends config: {}", canonical.display())
199 })?;
200
201 for ext in &parent.extends {
202 stack.push(ext.clone());
203 }
204
205 parents.push(parent);
206 }
207
208 for parent in &parents {
211 if config.run.is_none() {
212 config.run = parent.run.clone();
213 }
214 if config.plan.is_none() {
215 config.plan = parent.plan.clone();
216 }
217 if config.max_loops == default_max_loops() {
218 config.max_loops = parent.max_loops;
219 }
220 if config.max_concurrent == default_max_concurrent() {
221 config.max_concurrent = parent.max_concurrent;
222 }
223 if config.poll_interval == default_poll_interval() {
224 config.poll_interval = parent.poll_interval;
225 }
226 if config.auto_close_parent == default_auto_close_parent() {
227 config.auto_close_parent = parent.auto_close_parent;
228 }
229 if config.rules_file.is_none() {
230 config.rules_file = parent.rules_file.clone();
231 }
232 if !config.file_locking {
233 config.file_locking = parent.file_locking;
234 }
235 if !config.worktree {
236 config.worktree = parent.worktree;
237 }
238 if config.on_close.is_none() {
239 config.on_close = parent.on_close.clone();
240 }
241 if config.on_fail.is_none() {
242 config.on_fail = parent.on_fail.clone();
243 }
244 if config.post_plan.is_none() {
245 config.post_plan = parent.post_plan.clone();
246 }
247 if config.verify_timeout.is_none() {
248 config.verify_timeout = parent.verify_timeout;
249 }
250 if config.review.is_none() {
251 config.review = parent.review.clone();
252 }
253 if config.user.is_none() {
254 config.user = parent.user.clone();
255 }
256 if config.user_email.is_none() {
257 config.user_email = parent.user_email.clone();
258 }
259 }
261
262 Ok(config)
263 }
264
265 fn resolve_extends_path(path_str: &str, beans_dir: &Path) -> Result<PathBuf> {
268 if let Some(stripped) = path_str.strip_prefix("~/") {
269 let home = dirs::home_dir().ok_or_else(|| anyhow!("Cannot resolve home directory"))?;
270 Ok(home.join(stripped))
271 } else {
272 let project_root = beans_dir.parent().unwrap_or(Path::new("."));
274 Ok(project_root.join(path_str))
275 }
276 }
277
278 pub fn save(&self, beans_dir: &Path) -> Result<()> {
280 let path = beans_dir.join("config.yaml");
281 let contents = serde_yml::to_string(self).context("Failed to serialize config")?;
282 fs::write(&path, &contents)
283 .with_context(|| format!("Failed to write config at {}", path.display()))?;
284 Ok(())
285 }
286
287 pub fn rules_path(&self, beans_dir: &Path) -> PathBuf {
291 match &self.rules_file {
292 Some(custom) => {
293 let p = Path::new(custom);
294 if p.is_absolute() {
295 p.to_path_buf()
296 } else {
297 beans_dir.join(custom)
298 }
299 }
300 None => beans_dir.join("RULES.md"),
301 }
302 }
303
304 pub fn increment_id(&mut self) -> u32 {
306 let id = self.next_id;
307 self.next_id += 1;
308 id
309 }
310}
311
312#[derive(Debug, Default, Serialize, Deserialize)]
319pub struct GlobalConfig {
320 #[serde(default, skip_serializing_if = "Option::is_none")]
321 pub user: Option<String>,
322 #[serde(default, skip_serializing_if = "Option::is_none")]
323 pub user_email: Option<String>,
324}
325
326impl GlobalConfig {
327 pub fn path() -> Result<PathBuf> {
329 let home = dirs::home_dir().ok_or_else(|| anyhow!("Cannot determine home directory"))?;
330 Ok(home.join(".config").join("beans").join("config.yaml"))
331 }
332
333 pub fn load() -> Result<Self> {
335 let path = Self::path()?;
336 if !path.exists() {
337 return Ok(Self::default());
338 }
339 let contents = fs::read_to_string(&path)
340 .with_context(|| format!("Failed to read global config at {}", path.display()))?;
341 let config: GlobalConfig = serde_yml::from_str(&contents)
342 .with_context(|| format!("Failed to parse global config at {}", path.display()))?;
343 Ok(config)
344 }
345
346 pub fn save(&self) -> Result<()> {
348 let path = Self::path()?;
349 if let Some(parent) = path.parent() {
350 fs::create_dir_all(parent)
351 .with_context(|| format!("Failed to create {}", parent.display()))?;
352 }
353 let contents = serde_yml::to_string(self).context("Failed to serialize global config")?;
354 fs::write(&path, &contents)
355 .with_context(|| format!("Failed to write global config at {}", path.display()))?;
356 Ok(())
357 }
358}
359
360pub fn resolve_identity(beans_dir: &Path) -> Option<String> {
373 if let Ok(config) = Config::load(beans_dir) {
375 if let Some(ref user) = config.user {
376 if !user.is_empty() {
377 return Some(user.clone());
378 }
379 }
380 }
381
382 if let Ok(global) = GlobalConfig::load() {
384 if let Some(ref user) = global.user {
385 if !user.is_empty() {
386 return Some(user.clone());
387 }
388 }
389 }
390
391 if let Some(git_user) = git_config_user_name() {
393 return Some(git_user);
394 }
395
396 std::env::var("USER").ok().filter(|u| !u.is_empty())
398}
399
400fn git_config_user_name() -> Option<String> {
402 Command::new("git")
403 .args(["config", "user.name"])
404 .output()
405 .ok()
406 .filter(|o| o.status.success())
407 .and_then(|o| String::from_utf8(o.stdout).ok())
408 .map(|s| s.trim().to_string())
409 .filter(|s| !s.is_empty())
410}
411
412#[cfg(test)]
413mod tests {
414 use super::*;
415 use std::fs;
416
417 #[test]
418 fn config_round_trips_through_yaml() {
419 let dir = tempfile::tempdir().unwrap();
420 let config = Config {
421 project: "test-project".to_string(),
422 next_id: 42,
423 auto_close_parent: true,
424 run: None,
425 plan: None,
426 max_loops: 10,
427 max_concurrent: 4,
428 poll_interval: 30,
429 extends: vec![],
430 rules_file: None,
431 file_locking: false,
432 worktree: false,
433 on_close: None,
434 on_fail: None,
435 post_plan: None,
436 verify_timeout: None,
437 review: None,
438 user: None,
439 user_email: None,
440 };
441
442 config.save(dir.path()).unwrap();
443 let loaded = Config::load(dir.path()).unwrap();
444
445 assert_eq!(config, loaded);
446 }
447
448 #[test]
449 fn increment_id_returns_current_and_bumps() {
450 let mut config = Config {
451 project: "test".to_string(),
452 next_id: 1,
453 auto_close_parent: true,
454 run: None,
455 plan: None,
456 max_loops: 10,
457 max_concurrent: 4,
458 poll_interval: 30,
459 extends: vec![],
460 rules_file: None,
461 file_locking: false,
462 worktree: false,
463 on_close: None,
464 on_fail: None,
465 post_plan: None,
466 verify_timeout: None,
467 review: None,
468 user: None,
469 user_email: None,
470 };
471
472 assert_eq!(config.increment_id(), 1);
473 assert_eq!(config.increment_id(), 2);
474 assert_eq!(config.increment_id(), 3);
475 assert_eq!(config.next_id, 4);
476 }
477
478 #[test]
479 fn load_returns_error_for_missing_file() {
480 let dir = tempfile::tempdir().unwrap();
481 let result = Config::load(dir.path());
482 assert!(result.is_err());
483 }
484
485 #[test]
486 fn load_returns_error_for_invalid_yaml() {
487 let dir = tempfile::tempdir().unwrap();
488 fs::write(dir.path().join("config.yaml"), "not: [valid: yaml: config").unwrap();
489 let result = Config::load(dir.path());
490 assert!(result.is_err());
491 }
492
493 #[test]
494 fn save_creates_file_that_is_valid_yaml() {
495 let dir = tempfile::tempdir().unwrap();
496 let config = Config {
497 project: "my-project".to_string(),
498 next_id: 100,
499 auto_close_parent: true,
500 run: None,
501 plan: None,
502 max_loops: 10,
503 max_concurrent: 4,
504 poll_interval: 30,
505 extends: vec![],
506 rules_file: None,
507 file_locking: false,
508 worktree: false,
509 on_close: None,
510 on_fail: None,
511 post_plan: None,
512 verify_timeout: None,
513 review: None,
514 user: None,
515 user_email: None,
516 };
517 config.save(dir.path()).unwrap();
518
519 let contents = fs::read_to_string(dir.path().join("config.yaml")).unwrap();
520 assert!(contents.contains("project: my-project"));
521 assert!(contents.contains("next_id: 100"));
522 }
523
524 #[test]
525 fn auto_close_parent_defaults_to_true() {
526 let dir = tempfile::tempdir().unwrap();
527 fs::write(
529 dir.path().join("config.yaml"),
530 "project: test\nnext_id: 1\n",
531 )
532 .unwrap();
533
534 let loaded = Config::load(dir.path()).unwrap();
535 assert!(loaded.auto_close_parent);
536 }
537
538 #[test]
539 fn auto_close_parent_can_be_disabled() {
540 let dir = tempfile::tempdir().unwrap();
541 let config = Config {
542 project: "test".to_string(),
543 next_id: 1,
544 auto_close_parent: false,
545 run: None,
546 plan: None,
547 max_loops: 10,
548 max_concurrent: 4,
549 poll_interval: 30,
550 extends: vec![],
551 rules_file: None,
552 file_locking: false,
553 worktree: false,
554 on_close: None,
555 on_fail: None,
556 post_plan: None,
557 verify_timeout: None,
558 review: None,
559 user: None,
560 user_email: None,
561 };
562 config.save(dir.path()).unwrap();
563
564 let loaded = Config::load(dir.path()).unwrap();
565 assert!(!loaded.auto_close_parent);
566 }
567
568 #[test]
569 fn max_tokens_in_yaml_silently_ignored() {
570 let dir = tempfile::tempdir().unwrap();
571 fs::write(
573 dir.path().join("config.yaml"),
574 "project: test\nnext_id: 1\nmax_tokens: 50000\n",
575 )
576 .unwrap();
577
578 let loaded = Config::load(dir.path()).unwrap();
579 assert_eq!(loaded.project, "test");
580 }
581
582 #[test]
583 fn run_defaults_to_none() {
584 let dir = tempfile::tempdir().unwrap();
585 fs::write(
586 dir.path().join("config.yaml"),
587 "project: test\nnext_id: 1\n",
588 )
589 .unwrap();
590
591 let loaded = Config::load(dir.path()).unwrap();
592 assert_eq!(loaded.run, None);
593 }
594
595 #[test]
596 fn run_can_be_set() {
597 let dir = tempfile::tempdir().unwrap();
598 let config = Config {
599 project: "test".to_string(),
600 next_id: 1,
601 auto_close_parent: true,
602 run: Some("claude -p 'implement bean {id}'".to_string()),
603 plan: None,
604 max_loops: 10,
605 max_concurrent: 4,
606 poll_interval: 30,
607 extends: vec![],
608 rules_file: None,
609 file_locking: false,
610 worktree: false,
611 on_close: None,
612 on_fail: None,
613 post_plan: None,
614 verify_timeout: None,
615 review: None,
616 user: None,
617 user_email: None,
618 };
619 config.save(dir.path()).unwrap();
620
621 let loaded = Config::load(dir.path()).unwrap();
622 assert_eq!(
623 loaded.run,
624 Some("claude -p 'implement bean {id}'".to_string())
625 );
626 }
627
628 #[test]
629 fn run_not_serialized_when_none() {
630 let dir = tempfile::tempdir().unwrap();
631 let config = Config {
632 project: "test".to_string(),
633 next_id: 1,
634 auto_close_parent: true,
635 run: None,
636 plan: None,
637 max_loops: 10,
638 max_concurrent: 4,
639 poll_interval: 30,
640 extends: vec![],
641 rules_file: None,
642 file_locking: false,
643 worktree: false,
644 on_close: None,
645 on_fail: None,
646 post_plan: None,
647 verify_timeout: None,
648 review: None,
649 user: None,
650 user_email: None,
651 };
652 config.save(dir.path()).unwrap();
653
654 let contents = fs::read_to_string(dir.path().join("config.yaml")).unwrap();
655 assert!(!contents.contains("run:"));
656 }
657
658 #[test]
659 fn max_loops_defaults_to_10() {
660 let dir = tempfile::tempdir().unwrap();
661 fs::write(
662 dir.path().join("config.yaml"),
663 "project: test\nnext_id: 1\n",
664 )
665 .unwrap();
666
667 let loaded = Config::load(dir.path()).unwrap();
668 assert_eq!(loaded.max_loops, 10);
669 }
670
671 #[test]
672 fn max_loops_can_be_customized() {
673 let dir = tempfile::tempdir().unwrap();
674 let config = Config {
675 project: "test".to_string(),
676 next_id: 1,
677 auto_close_parent: true,
678 run: None,
679 plan: None,
680 max_loops: 25,
681 max_concurrent: 4,
682 poll_interval: 30,
683 extends: vec![],
684 rules_file: None,
685 file_locking: false,
686 worktree: false,
687 on_close: None,
688 on_fail: None,
689 post_plan: None,
690 verify_timeout: None,
691 review: None,
692 user: None,
693 user_email: None,
694 };
695 config.save(dir.path()).unwrap();
696
697 let loaded = Config::load(dir.path()).unwrap();
698 assert_eq!(loaded.max_loops, 25);
699 }
700
701 fn write_yaml(path: &std::path::Path, yaml: &str) {
705 if let Some(parent) = path.parent() {
706 fs::create_dir_all(parent).unwrap();
707 }
708 fs::write(path, yaml).unwrap();
709 }
710
711 fn write_local_config(beans_dir: &std::path::Path, extends: &[&str], extra: &str) {
713 let extends_yaml: Vec<String> = extends.iter().map(|e| format!(" - \"{}\"", e)).collect();
714 let extends_block = if extends.is_empty() {
715 String::new()
716 } else {
717 format!("extends:\n{}\n", extends_yaml.join("\n"))
718 };
719 let yaml = format!("project: test\nnext_id: 1\n{}{}", extends_block, extra);
720 write_yaml(&beans_dir.join("config.yaml"), &yaml);
721 }
722
723 #[test]
724 fn extends_empty_loads_normally() {
725 let dir = tempfile::tempdir().unwrap();
726 let beans_dir = dir.path().join(".beans");
727 fs::create_dir_all(&beans_dir).unwrap();
728 write_local_config(&beans_dir, &[], "");
729
730 let config = Config::load_with_extends(&beans_dir).unwrap();
731 assert_eq!(config.project, "test");
732 assert!(config.run.is_none());
733 }
734
735 #[test]
736 fn extends_single_merges_fields() {
737 let dir = tempfile::tempdir().unwrap();
738 let beans_dir = dir.path().join(".beans");
739 fs::create_dir_all(&beans_dir).unwrap();
740
741 let parent_path = dir.path().join("shared.yaml");
743 write_yaml(
744 &parent_path,
745 "project: shared\nnext_id: 999\nrun: \"deli spawn {id}\"\nmax_loops: 20\n",
746 );
747
748 write_local_config(&beans_dir, &["shared.yaml"], "");
749
750 let config = Config::load_with_extends(&beans_dir).unwrap();
751 assert_eq!(config.run, Some("deli spawn {id}".to_string()));
753 assert_eq!(config.max_loops, 20);
754 assert_eq!(config.project, "test");
756 assert_eq!(config.next_id, 1);
757 }
758
759 #[test]
760 fn extends_local_overrides_parent() {
761 let dir = tempfile::tempdir().unwrap();
762 let beans_dir = dir.path().join(".beans");
763 fs::create_dir_all(&beans_dir).unwrap();
764
765 let parent_path = dir.path().join("shared.yaml");
766 write_yaml(
767 &parent_path,
768 "project: shared\nnext_id: 999\nrun: \"parent-run\"\nmax_loops: 20\n",
769 );
770
771 write_local_config(
773 &beans_dir,
774 &["shared.yaml"],
775 "run: \"local-run\"\nmax_loops: 5\n",
776 );
777
778 let config = Config::load_with_extends(&beans_dir).unwrap();
779 assert_eq!(config.run, Some("local-run".to_string()));
781 assert_eq!(config.max_loops, 5);
782 }
783
784 #[test]
785 fn extends_circular_detected_and_skipped() {
786 let dir = tempfile::tempdir().unwrap();
787 let beans_dir = dir.path().join(".beans");
788 fs::create_dir_all(&beans_dir).unwrap();
789
790 let a_path = dir.path().join("a.yaml");
792 let b_path = dir.path().join("b.yaml");
793 write_yaml(
794 &a_path,
795 "project: a\nnext_id: 1\nextends:\n - \"b.yaml\"\nmax_loops: 40\n",
796 );
797 write_yaml(
798 &b_path,
799 "project: b\nnext_id: 1\nextends:\n - \"a.yaml\"\nmax_loops: 50\n",
800 );
801
802 write_local_config(&beans_dir, &["a.yaml"], "");
803
804 let config = Config::load_with_extends(&beans_dir).unwrap();
806 assert_eq!(config.project, "test");
807 assert!(config.max_loops == 40 || config.max_loops == 50);
809 }
810
811 #[test]
812 fn extends_missing_file_errors() {
813 let dir = tempfile::tempdir().unwrap();
814 let beans_dir = dir.path().join(".beans");
815 fs::create_dir_all(&beans_dir).unwrap();
816
817 write_local_config(&beans_dir, &["nonexistent.yaml"], "");
818
819 let result = Config::load_with_extends(&beans_dir);
820 assert!(result.is_err());
821 let err_msg = format!("{}", result.unwrap_err());
822 assert!(
823 err_msg.contains("nonexistent.yaml"),
824 "Error should mention the missing file: {}",
825 err_msg
826 );
827 }
828
829 #[test]
830 fn extends_recursive_a_extends_b_extends_c() {
831 let dir = tempfile::tempdir().unwrap();
832 let beans_dir = dir.path().join(".beans");
833 fs::create_dir_all(&beans_dir).unwrap();
834
835 let c_path = dir.path().join("c.yaml");
837 write_yaml(
838 &c_path,
839 "project: c\nnext_id: 1\nrun: \"from-c\"\nmax_loops: 40\n",
840 );
841
842 let b_path = dir.path().join("b.yaml");
844 write_yaml(
845 &b_path,
846 "project: b\nnext_id: 1\nextends:\n - \"c.yaml\"\nmax_loops: 50\n",
847 );
848
849 write_local_config(&beans_dir, &["b.yaml"], "");
851
852 let config = Config::load_with_extends(&beans_dir).unwrap();
853 assert_eq!(config.max_loops, 50);
855 assert_eq!(config.run, Some("from-c".to_string()));
857 }
858
859 #[test]
860 fn extends_project_and_next_id_never_inherited() {
861 let dir = tempfile::tempdir().unwrap();
862 let beans_dir = dir.path().join(".beans");
863 fs::create_dir_all(&beans_dir).unwrap();
864
865 let parent_path = dir.path().join("shared.yaml");
866 write_yaml(
867 &parent_path,
868 "project: parent-project\nnext_id: 999\nmax_loops: 50\n",
869 );
870
871 write_local_config(&beans_dir, &["shared.yaml"], "");
872
873 let config = Config::load_with_extends(&beans_dir).unwrap();
874 assert_eq!(config.project, "test");
875 assert_eq!(config.next_id, 1);
876 }
877
878 #[test]
879 fn extends_tilde_resolves_to_home_dir() {
880 let beans_dir = std::path::Path::new("/tmp/fake-beans");
883 let resolved = Config::resolve_extends_path("~/shared/config.yaml", beans_dir).unwrap();
884 let home = dirs::home_dir().unwrap();
885 assert_eq!(resolved, home.join("shared/config.yaml"));
886 }
887
888 #[test]
889 fn extends_not_serialized_when_empty() {
890 let dir = tempfile::tempdir().unwrap();
891 let config = Config {
892 project: "test".to_string(),
893 next_id: 1,
894 auto_close_parent: true,
895 run: None,
896 plan: None,
897 max_loops: 10,
898 max_concurrent: 4,
899 poll_interval: 30,
900 extends: vec![],
901 rules_file: None,
902 file_locking: false,
903 worktree: false,
904 on_close: None,
905 on_fail: None,
906 post_plan: None,
907 verify_timeout: None,
908 review: None,
909 user: None,
910 user_email: None,
911 };
912 config.save(dir.path()).unwrap();
913
914 let contents = fs::read_to_string(dir.path().join("config.yaml")).unwrap();
915 assert!(!contents.contains("extends"));
916 }
917
918 #[test]
919 fn extends_defaults_to_empty() {
920 let dir = tempfile::tempdir().unwrap();
921 fs::write(
922 dir.path().join("config.yaml"),
923 "project: test\nnext_id: 1\n",
924 )
925 .unwrap();
926
927 let loaded = Config::load(dir.path()).unwrap();
928 assert!(loaded.extends.is_empty());
929 }
930
931 #[test]
934 fn plan_defaults_to_none() {
935 let dir = tempfile::tempdir().unwrap();
936 fs::write(
937 dir.path().join("config.yaml"),
938 "project: test\nnext_id: 1\n",
939 )
940 .unwrap();
941
942 let loaded = Config::load(dir.path()).unwrap();
943 assert_eq!(loaded.plan, None);
944 }
945
946 #[test]
947 fn plan_can_be_set() {
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: Some("claude -p 'plan bean {id}'".to_string()),
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 post_plan: None,
965 verify_timeout: None,
966 review: None,
967 user: None,
968 user_email: None,
969 };
970 config.save(dir.path()).unwrap();
971
972 let loaded = Config::load(dir.path()).unwrap();
973 assert_eq!(loaded.plan, Some("claude -p 'plan bean {id}'".to_string()));
974 }
975
976 #[test]
977 fn plan_not_serialized_when_none() {
978 let dir = tempfile::tempdir().unwrap();
979 let config = Config {
980 project: "test".to_string(),
981 next_id: 1,
982 auto_close_parent: true,
983 run: None,
984 plan: None,
985 max_loops: 10,
986 max_concurrent: 4,
987 poll_interval: 30,
988 extends: vec![],
989 rules_file: None,
990 file_locking: false,
991 worktree: false,
992 on_close: None,
993 on_fail: None,
994 post_plan: None,
995 verify_timeout: None,
996 review: None,
997 user: None,
998 user_email: None,
999 };
1000 config.save(dir.path()).unwrap();
1001
1002 let contents = fs::read_to_string(dir.path().join("config.yaml")).unwrap();
1003 assert!(!contents.contains("plan:"));
1004 }
1005
1006 #[test]
1007 fn max_concurrent_defaults_to_4() {
1008 let dir = tempfile::tempdir().unwrap();
1009 fs::write(
1010 dir.path().join("config.yaml"),
1011 "project: test\nnext_id: 1\n",
1012 )
1013 .unwrap();
1014
1015 let loaded = Config::load(dir.path()).unwrap();
1016 assert_eq!(loaded.max_concurrent, 4);
1017 }
1018
1019 #[test]
1020 fn max_concurrent_can_be_customized() {
1021 let dir = tempfile::tempdir().unwrap();
1022 let config = Config {
1023 project: "test".to_string(),
1024 next_id: 1,
1025 auto_close_parent: true,
1026 run: None,
1027 plan: None,
1028 max_loops: 10,
1029 max_concurrent: 8,
1030 poll_interval: 30,
1031 extends: vec![],
1032 rules_file: None,
1033 file_locking: false,
1034 worktree: false,
1035 on_close: None,
1036 on_fail: None,
1037 post_plan: None,
1038 verify_timeout: None,
1039 review: None,
1040 user: None,
1041 user_email: None,
1042 };
1043 config.save(dir.path()).unwrap();
1044
1045 let loaded = Config::load(dir.path()).unwrap();
1046 assert_eq!(loaded.max_concurrent, 8);
1047 }
1048
1049 #[test]
1050 fn poll_interval_defaults_to_30() {
1051 let dir = tempfile::tempdir().unwrap();
1052 fs::write(
1053 dir.path().join("config.yaml"),
1054 "project: test\nnext_id: 1\n",
1055 )
1056 .unwrap();
1057
1058 let loaded = Config::load(dir.path()).unwrap();
1059 assert_eq!(loaded.poll_interval, 30);
1060 }
1061
1062 #[test]
1063 fn poll_interval_can_be_customized() {
1064 let dir = tempfile::tempdir().unwrap();
1065 let config = Config {
1066 project: "test".to_string(),
1067 next_id: 1,
1068 auto_close_parent: true,
1069 run: None,
1070 plan: None,
1071 max_loops: 10,
1072 max_concurrent: 4,
1073 poll_interval: 60,
1074 extends: vec![],
1075 rules_file: None,
1076 file_locking: false,
1077 worktree: false,
1078 on_close: None,
1079 on_fail: None,
1080 post_plan: None,
1081 verify_timeout: None,
1082 review: None,
1083 user: None,
1084 user_email: None,
1085 };
1086 config.save(dir.path()).unwrap();
1087
1088 let loaded = Config::load(dir.path()).unwrap();
1089 assert_eq!(loaded.poll_interval, 60);
1090 }
1091
1092 #[test]
1093 fn extends_inherits_plan() {
1094 let dir = tempfile::tempdir().unwrap();
1095 let beans_dir = dir.path().join(".beans");
1096 fs::create_dir_all(&beans_dir).unwrap();
1097
1098 let parent_path = dir.path().join("shared.yaml");
1099 write_yaml(
1100 &parent_path,
1101 "project: shared\nnext_id: 999\nplan: \"plan-cmd {id}\"\n",
1102 );
1103
1104 write_local_config(&beans_dir, &["shared.yaml"], "");
1105
1106 let config = Config::load_with_extends(&beans_dir).unwrap();
1107 assert_eq!(config.plan, Some("plan-cmd {id}".to_string()));
1108 }
1109
1110 #[test]
1111 fn extends_inherits_max_concurrent() {
1112 let dir = tempfile::tempdir().unwrap();
1113 let beans_dir = dir.path().join(".beans");
1114 fs::create_dir_all(&beans_dir).unwrap();
1115
1116 let parent_path = dir.path().join("shared.yaml");
1117 write_yaml(
1118 &parent_path,
1119 "project: shared\nnext_id: 999\nmax_concurrent: 16\n",
1120 );
1121
1122 write_local_config(&beans_dir, &["shared.yaml"], "");
1123
1124 let config = Config::load_with_extends(&beans_dir).unwrap();
1125 assert_eq!(config.max_concurrent, 16);
1126 }
1127
1128 #[test]
1129 fn extends_inherits_poll_interval() {
1130 let dir = tempfile::tempdir().unwrap();
1131 let beans_dir = dir.path().join(".beans");
1132 fs::create_dir_all(&beans_dir).unwrap();
1133
1134 let parent_path = dir.path().join("shared.yaml");
1135 write_yaml(
1136 &parent_path,
1137 "project: shared\nnext_id: 999\npoll_interval: 120\n",
1138 );
1139
1140 write_local_config(&beans_dir, &["shared.yaml"], "");
1141
1142 let config = Config::load_with_extends(&beans_dir).unwrap();
1143 assert_eq!(config.poll_interval, 120);
1144 }
1145
1146 #[test]
1147 fn extends_local_overrides_new_fields() {
1148 let dir = tempfile::tempdir().unwrap();
1149 let beans_dir = dir.path().join(".beans");
1150 fs::create_dir_all(&beans_dir).unwrap();
1151
1152 let parent_path = dir.path().join("shared.yaml");
1153 write_yaml(
1154 &parent_path,
1155 "project: shared\nnext_id: 999\nplan: \"parent-plan\"\nmax_concurrent: 16\npoll_interval: 120\n",
1156 );
1157
1158 write_local_config(
1159 &beans_dir,
1160 &["shared.yaml"],
1161 "plan: \"local-plan\"\nmax_concurrent: 2\npoll_interval: 10\n",
1162 );
1163
1164 let config = Config::load_with_extends(&beans_dir).unwrap();
1165 assert_eq!(config.plan, Some("local-plan".to_string()));
1166 assert_eq!(config.max_concurrent, 2);
1167 assert_eq!(config.poll_interval, 10);
1168 }
1169
1170 #[test]
1171 fn new_fields_round_trip_through_yaml() {
1172 let dir = tempfile::tempdir().unwrap();
1173 let config = Config {
1174 project: "test".to_string(),
1175 next_id: 1,
1176 auto_close_parent: true,
1177 run: None,
1178 plan: Some("plan {id}".to_string()),
1179 max_loops: 10,
1180 max_concurrent: 8,
1181 poll_interval: 60,
1182 extends: vec![],
1183 rules_file: None,
1184 file_locking: false,
1185 worktree: false,
1186 on_close: None,
1187 on_fail: None,
1188 post_plan: None,
1189 verify_timeout: None,
1190 review: None,
1191 user: None,
1192 user_email: None,
1193 };
1194
1195 config.save(dir.path()).unwrap();
1196 let loaded = Config::load(dir.path()).unwrap();
1197
1198 assert_eq!(config, loaded);
1199 }
1200}