1use std::collections::HashSet;
2use std::fs;
3use std::path::{Path, PathBuf};
4
5use anyhow::{anyhow, Context, Result};
6use serde::{Deserialize, Serialize};
7
8#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
10pub struct ReviewConfig {
11 #[serde(default, skip_serializing_if = "Option::is_none")]
14 pub run: Option<String>,
15 #[serde(default = "default_max_reopens")]
17 pub max_reopens: u32,
18}
19
20fn default_max_reopens() -> u32 {
21 2
22}
23
24impl Default for ReviewConfig {
25 fn default() -> Self {
26 Self {
27 run: None,
28 max_reopens: 2,
29 }
30 }
31}
32
33#[derive(Debug, Serialize, Deserialize, PartialEq)]
34pub struct Config {
35 pub project: String,
36 pub next_id: u32,
37 #[serde(default = "default_auto_close_parent")]
39 pub auto_close_parent: bool,
40 #[serde(default = "default_max_tokens")]
42 pub max_tokens: u32,
43 #[serde(default, skip_serializing_if = "Option::is_none")]
47 pub run: Option<String>,
48 #[serde(default, skip_serializing_if = "Option::is_none")]
51 pub plan: Option<String>,
52 #[serde(default = "default_max_loops")]
54 pub max_loops: u32,
55 #[serde(default = "default_max_concurrent")]
57 pub max_concurrent: u32,
58 #[serde(default = "default_poll_interval")]
60 pub poll_interval: u32,
61 #[serde(default, skip_serializing_if = "Vec::is_empty")]
64 pub extends: Vec<String>,
65 #[serde(default, skip_serializing_if = "Option::is_none")]
68 pub rules_file: Option<String>,
69 #[serde(default, skip_serializing_if = "is_false_bool")]
74 pub file_locking: bool,
75 #[serde(default, skip_serializing_if = "Option::is_none")]
79 pub on_close: Option<String>,
80 #[serde(default, skip_serializing_if = "Option::is_none")]
84 pub on_fail: Option<String>,
85 #[serde(default, skip_serializing_if = "Option::is_none")]
89 pub post_plan: Option<String>,
90 #[serde(default, skip_serializing_if = "Option::is_none")]
93 pub verify_timeout: Option<u64>,
94 #[serde(default, skip_serializing_if = "Option::is_none")]
97 pub review: Option<ReviewConfig>,
98}
99
100fn default_auto_close_parent() -> bool {
101 true
102}
103
104fn default_max_tokens() -> u32 {
105 30000
106}
107
108fn default_max_loops() -> u32 {
109 10
110}
111
112fn default_max_concurrent() -> u32 {
113 4
114}
115
116fn default_poll_interval() -> u32 {
117 30
118}
119
120fn is_false_bool(v: &bool) -> bool {
121 !v
122}
123
124impl Default for Config {
125 fn default() -> Self {
126 Self {
127 project: String::new(),
128 next_id: 1,
129 auto_close_parent: true,
130 max_tokens: 30000,
131 run: None,
132 plan: None,
133 max_loops: 10,
134 max_concurrent: 4,
135 poll_interval: 30,
136 extends: Vec::new(),
137 rules_file: None,
138 file_locking: false,
139 on_close: None,
140 on_fail: None,
141 post_plan: None,
142 verify_timeout: None,
143 review: None,
144 }
145 }
146}
147
148impl Config {
149 pub fn load(beans_dir: &Path) -> Result<Self> {
151 let path = beans_dir.join("config.yaml");
152 let contents = fs::read_to_string(&path)
153 .with_context(|| format!("Failed to read config at {}", path.display()))?;
154 let config: Config = serde_yml::from_str(&contents)
155 .with_context(|| format!("Failed to parse config at {}", path.display()))?;
156 Ok(config)
157 }
158
159 pub fn load_with_extends(beans_dir: &Path) -> Result<Self> {
165 let mut config = Self::load(beans_dir)?;
166
167 if config.extends.is_empty() {
168 return Ok(config);
169 }
170
171 let mut seen = HashSet::new();
172 let mut stack: Vec<String> = config.extends.clone();
173 let mut parents: Vec<Config> = Vec::new();
174
175 while let Some(path_str) = stack.pop() {
176 let resolved = Self::resolve_extends_path(&path_str, beans_dir)?;
177
178 let canonical = resolved
179 .canonicalize()
180 .with_context(|| format!("Cannot resolve extends path: {}", path_str))?;
181
182 if !seen.insert(canonical.clone()) {
183 continue; }
185
186 let contents = fs::read_to_string(&canonical).with_context(|| {
187 format!("Failed to read extends config: {}", canonical.display())
188 })?;
189 let parent: Config = serde_yml::from_str(&contents).with_context(|| {
190 format!("Failed to parse extends config: {}", canonical.display())
191 })?;
192
193 for ext in &parent.extends {
194 stack.push(ext.clone());
195 }
196
197 parents.push(parent);
198 }
199
200 for parent in &parents {
203 if config.max_tokens == default_max_tokens() {
204 config.max_tokens = parent.max_tokens;
205 }
206 if config.run.is_none() {
207 config.run = parent.run.clone();
208 }
209 if config.plan.is_none() {
210 config.plan = parent.plan.clone();
211 }
212 if config.max_loops == default_max_loops() {
213 config.max_loops = parent.max_loops;
214 }
215 if config.max_concurrent == default_max_concurrent() {
216 config.max_concurrent = parent.max_concurrent;
217 }
218 if config.poll_interval == default_poll_interval() {
219 config.poll_interval = parent.poll_interval;
220 }
221 if config.auto_close_parent == default_auto_close_parent() {
222 config.auto_close_parent = parent.auto_close_parent;
223 }
224 if config.rules_file.is_none() {
225 config.rules_file = parent.rules_file.clone();
226 }
227 if !config.file_locking {
228 config.file_locking = parent.file_locking;
229 }
230 if config.on_close.is_none() {
231 config.on_close = parent.on_close.clone();
232 }
233 if config.on_fail.is_none() {
234 config.on_fail = parent.on_fail.clone();
235 }
236 if config.post_plan.is_none() {
237 config.post_plan = parent.post_plan.clone();
238 }
239 if config.verify_timeout.is_none() {
240 config.verify_timeout = parent.verify_timeout;
241 }
242 if config.review.is_none() {
243 config.review = parent.review.clone();
244 }
245 }
247
248 Ok(config)
249 }
250
251 fn resolve_extends_path(path_str: &str, beans_dir: &Path) -> Result<PathBuf> {
254 if let Some(stripped) = path_str.strip_prefix("~/") {
255 let home = dirs::home_dir().ok_or_else(|| anyhow!("Cannot resolve home directory"))?;
256 Ok(home.join(stripped))
257 } else {
258 let project_root = beans_dir.parent().unwrap_or(Path::new("."));
260 Ok(project_root.join(path_str))
261 }
262 }
263
264 pub fn save(&self, beans_dir: &Path) -> Result<()> {
266 let path = beans_dir.join("config.yaml");
267 let contents = serde_yml::to_string(self).context("Failed to serialize config")?;
268 fs::write(&path, &contents)
269 .with_context(|| format!("Failed to write config at {}", path.display()))?;
270 Ok(())
271 }
272
273 pub fn rules_path(&self, beans_dir: &Path) -> PathBuf {
277 match &self.rules_file {
278 Some(custom) => {
279 let p = Path::new(custom);
280 if p.is_absolute() {
281 p.to_path_buf()
282 } else {
283 beans_dir.join(custom)
284 }
285 }
286 None => beans_dir.join("RULES.md"),
287 }
288 }
289
290 pub fn increment_id(&mut self) -> u32 {
292 let id = self.next_id;
293 self.next_id += 1;
294 id
295 }
296}
297
298#[cfg(test)]
299mod tests {
300 use super::*;
301 use std::fs;
302
303 #[test]
304 fn config_round_trips_through_yaml() {
305 let dir = tempfile::tempdir().unwrap();
306 let config = Config {
307 project: "test-project".to_string(),
308 next_id: 42,
309 auto_close_parent: true,
310 max_tokens: 30000,
311 run: None,
312 plan: None,
313 max_loops: 10,
314 max_concurrent: 4,
315 poll_interval: 30,
316 extends: vec![],
317 rules_file: None,
318 file_locking: false,
319 on_close: None,
320 on_fail: None,
321 post_plan: None,
322 verify_timeout: None,
323 review: None,
324 };
325
326 config.save(dir.path()).unwrap();
327 let loaded = Config::load(dir.path()).unwrap();
328
329 assert_eq!(config, loaded);
330 }
331
332 #[test]
333 fn increment_id_returns_current_and_bumps() {
334 let mut config = Config {
335 project: "test".to_string(),
336 next_id: 1,
337 auto_close_parent: true,
338 max_tokens: 30000,
339 run: None,
340 plan: None,
341 max_loops: 10,
342 max_concurrent: 4,
343 poll_interval: 30,
344 extends: vec![],
345 rules_file: None,
346 file_locking: false,
347 on_close: None,
348 on_fail: None,
349 post_plan: None,
350 verify_timeout: None,
351 review: None,
352 };
353
354 assert_eq!(config.increment_id(), 1);
355 assert_eq!(config.increment_id(), 2);
356 assert_eq!(config.increment_id(), 3);
357 assert_eq!(config.next_id, 4);
358 }
359
360 #[test]
361 fn load_returns_error_for_missing_file() {
362 let dir = tempfile::tempdir().unwrap();
363 let result = Config::load(dir.path());
364 assert!(result.is_err());
365 }
366
367 #[test]
368 fn load_returns_error_for_invalid_yaml() {
369 let dir = tempfile::tempdir().unwrap();
370 fs::write(dir.path().join("config.yaml"), "not: [valid: yaml: config").unwrap();
371 let result = Config::load(dir.path());
372 assert!(result.is_err());
373 }
374
375 #[test]
376 fn save_creates_file_that_is_valid_yaml() {
377 let dir = tempfile::tempdir().unwrap();
378 let config = Config {
379 project: "my-project".to_string(),
380 next_id: 100,
381 auto_close_parent: true,
382 max_tokens: 30000,
383 run: None,
384 plan: None,
385 max_loops: 10,
386 max_concurrent: 4,
387 poll_interval: 30,
388 extends: vec![],
389 rules_file: None,
390 file_locking: false,
391 on_close: None,
392 on_fail: None,
393 post_plan: None,
394 verify_timeout: None,
395 review: None,
396 };
397 config.save(dir.path()).unwrap();
398
399 let contents = fs::read_to_string(dir.path().join("config.yaml")).unwrap();
400 assert!(contents.contains("project: my-project"));
401 assert!(contents.contains("next_id: 100"));
402 }
403
404 #[test]
405 fn auto_close_parent_defaults_to_true() {
406 let dir = tempfile::tempdir().unwrap();
407 fs::write(
409 dir.path().join("config.yaml"),
410 "project: test\nnext_id: 1\n",
411 )
412 .unwrap();
413
414 let loaded = Config::load(dir.path()).unwrap();
415 assert!(loaded.auto_close_parent);
416 }
417
418 #[test]
419 fn auto_close_parent_can_be_disabled() {
420 let dir = tempfile::tempdir().unwrap();
421 let config = Config {
422 project: "test".to_string(),
423 next_id: 1,
424 auto_close_parent: false,
425 max_tokens: 30000,
426 run: None,
427 plan: None,
428 max_loops: 10,
429 max_concurrent: 4,
430 poll_interval: 30,
431 extends: vec![],
432 rules_file: None,
433 file_locking: false,
434 on_close: None,
435 on_fail: None,
436 post_plan: None,
437 verify_timeout: None,
438 review: None,
439 };
440 config.save(dir.path()).unwrap();
441
442 let loaded = Config::load(dir.path()).unwrap();
443 assert!(!loaded.auto_close_parent);
444 }
445
446 #[test]
447 fn max_tokens_defaults_to_30000() {
448 let dir = tempfile::tempdir().unwrap();
449 fs::write(
451 dir.path().join("config.yaml"),
452 "project: test\nnext_id: 1\n",
453 )
454 .unwrap();
455
456 let loaded = Config::load(dir.path()).unwrap();
457 assert_eq!(loaded.max_tokens, 30000);
458 }
459
460 #[test]
461 fn max_tokens_can_be_customized() {
462 let dir = tempfile::tempdir().unwrap();
463 let config = Config {
464 project: "test".to_string(),
465 next_id: 1,
466 auto_close_parent: true,
467 max_tokens: 50000,
468 run: None,
469 plan: None,
470 max_loops: 10,
471 max_concurrent: 4,
472 poll_interval: 30,
473 extends: vec![],
474 rules_file: None,
475 file_locking: false,
476 on_close: None,
477 on_fail: None,
478 post_plan: None,
479 verify_timeout: None,
480 review: None,
481 };
482 config.save(dir.path()).unwrap();
483
484 let loaded = Config::load(dir.path()).unwrap();
485 assert_eq!(loaded.max_tokens, 50000);
486 }
487
488 #[test]
489 fn run_defaults_to_none() {
490 let dir = tempfile::tempdir().unwrap();
491 fs::write(
492 dir.path().join("config.yaml"),
493 "project: test\nnext_id: 1\n",
494 )
495 .unwrap();
496
497 let loaded = Config::load(dir.path()).unwrap();
498 assert_eq!(loaded.run, None);
499 }
500
501 #[test]
502 fn run_can_be_set() {
503 let dir = tempfile::tempdir().unwrap();
504 let config = Config {
505 project: "test".to_string(),
506 next_id: 1,
507 auto_close_parent: true,
508 max_tokens: 30000,
509 run: Some("claude -p 'implement bean {id}'".to_string()),
510 plan: None,
511 max_loops: 10,
512 max_concurrent: 4,
513 poll_interval: 30,
514 extends: vec![],
515 rules_file: None,
516 file_locking: false,
517 on_close: None,
518 on_fail: None,
519 post_plan: None,
520 verify_timeout: None,
521 review: None,
522 };
523 config.save(dir.path()).unwrap();
524
525 let loaded = Config::load(dir.path()).unwrap();
526 assert_eq!(
527 loaded.run,
528 Some("claude -p 'implement bean {id}'".to_string())
529 );
530 }
531
532 #[test]
533 fn run_not_serialized_when_none() {
534 let dir = tempfile::tempdir().unwrap();
535 let config = Config {
536 project: "test".to_string(),
537 next_id: 1,
538 auto_close_parent: true,
539 max_tokens: 30000,
540 run: None,
541 plan: None,
542 max_loops: 10,
543 max_concurrent: 4,
544 poll_interval: 30,
545 extends: vec![],
546 rules_file: None,
547 file_locking: false,
548 on_close: None,
549 on_fail: None,
550 post_plan: None,
551 verify_timeout: None,
552 review: None,
553 };
554 config.save(dir.path()).unwrap();
555
556 let contents = fs::read_to_string(dir.path().join("config.yaml")).unwrap();
557 assert!(!contents.contains("run:"));
558 }
559
560 #[test]
561 fn max_loops_defaults_to_10() {
562 let dir = tempfile::tempdir().unwrap();
563 fs::write(
564 dir.path().join("config.yaml"),
565 "project: test\nnext_id: 1\n",
566 )
567 .unwrap();
568
569 let loaded = Config::load(dir.path()).unwrap();
570 assert_eq!(loaded.max_loops, 10);
571 }
572
573 #[test]
574 fn max_loops_can_be_customized() {
575 let dir = tempfile::tempdir().unwrap();
576 let config = Config {
577 project: "test".to_string(),
578 next_id: 1,
579 auto_close_parent: true,
580 max_tokens: 30000,
581 run: None,
582 plan: None,
583 max_loops: 25,
584 max_concurrent: 4,
585 poll_interval: 30,
586 extends: vec![],
587 rules_file: None,
588 file_locking: false,
589 on_close: None,
590 on_fail: None,
591 post_plan: None,
592 verify_timeout: None,
593 review: None,
594 };
595 config.save(dir.path()).unwrap();
596
597 let loaded = Config::load(dir.path()).unwrap();
598 assert_eq!(loaded.max_loops, 25);
599 }
600
601 fn write_yaml(path: &std::path::Path, yaml: &str) {
605 if let Some(parent) = path.parent() {
606 fs::create_dir_all(parent).unwrap();
607 }
608 fs::write(path, yaml).unwrap();
609 }
610
611 fn write_local_config(beans_dir: &std::path::Path, extends: &[&str], extra: &str) {
613 let extends_yaml: Vec<String> = extends.iter().map(|e| format!(" - \"{}\"", e)).collect();
614 let extends_block = if extends.is_empty() {
615 String::new()
616 } else {
617 format!("extends:\n{}\n", extends_yaml.join("\n"))
618 };
619 let yaml = format!("project: test\nnext_id: 1\n{}{}", extends_block, extra);
620 write_yaml(&beans_dir.join("config.yaml"), &yaml);
621 }
622
623 #[test]
624 fn extends_empty_loads_normally() {
625 let dir = tempfile::tempdir().unwrap();
626 let beans_dir = dir.path().join(".beans");
627 fs::create_dir_all(&beans_dir).unwrap();
628 write_local_config(&beans_dir, &[], "");
629
630 let config = Config::load_with_extends(&beans_dir).unwrap();
631 assert_eq!(config.project, "test");
632 assert_eq!(config.max_tokens, 30000); assert!(config.run.is_none());
634 }
635
636 #[test]
637 fn extends_single_merges_fields() {
638 let dir = tempfile::tempdir().unwrap();
639 let beans_dir = dir.path().join(".beans");
640 fs::create_dir_all(&beans_dir).unwrap();
641
642 let parent_path = dir.path().join("shared.yaml");
644 write_yaml(
645 &parent_path,
646 "project: shared\nnext_id: 999\nmax_tokens: 50000\nrun: \"deli spawn {id}\"\nmax_loops: 20\n",
647 );
648
649 write_local_config(&beans_dir, &["shared.yaml"], "");
650
651 let config = Config::load_with_extends(&beans_dir).unwrap();
652 assert_eq!(config.max_tokens, 50000);
654 assert_eq!(config.run, Some("deli spawn {id}".to_string()));
655 assert_eq!(config.max_loops, 20);
656 assert_eq!(config.project, "test");
658 assert_eq!(config.next_id, 1);
659 }
660
661 #[test]
662 fn extends_local_overrides_parent() {
663 let dir = tempfile::tempdir().unwrap();
664 let beans_dir = dir.path().join(".beans");
665 fs::create_dir_all(&beans_dir).unwrap();
666
667 let parent_path = dir.path().join("shared.yaml");
668 write_yaml(
669 &parent_path,
670 "project: shared\nnext_id: 999\nmax_tokens: 50000\nrun: \"parent-run\"\nmax_loops: 20\n",
671 );
672
673 write_local_config(
675 &beans_dir,
676 &["shared.yaml"],
677 "max_tokens: 60000\nrun: \"local-run\"\nmax_loops: 5\n",
678 );
679
680 let config = Config::load_with_extends(&beans_dir).unwrap();
681 assert_eq!(config.max_tokens, 60000);
683 assert_eq!(config.run, Some("local-run".to_string()));
684 assert_eq!(config.max_loops, 5);
685 }
686
687 #[test]
688 fn extends_circular_detected_and_skipped() {
689 let dir = tempfile::tempdir().unwrap();
690 let beans_dir = dir.path().join(".beans");
691 fs::create_dir_all(&beans_dir).unwrap();
692
693 let a_path = dir.path().join("a.yaml");
695 let b_path = dir.path().join("b.yaml");
696 write_yaml(
697 &a_path,
698 "project: a\nnext_id: 1\nextends:\n - \"b.yaml\"\nmax_tokens: 40000\n",
699 );
700 write_yaml(
701 &b_path,
702 "project: b\nnext_id: 1\nextends:\n - \"a.yaml\"\nmax_tokens: 50000\n",
703 );
704
705 write_local_config(&beans_dir, &["a.yaml"], "");
706
707 let config = Config::load_with_extends(&beans_dir).unwrap();
709 assert_eq!(config.project, "test");
710 assert!(config.max_tokens == 40000 || config.max_tokens == 50000);
712 }
713
714 #[test]
715 fn extends_missing_file_errors() {
716 let dir = tempfile::tempdir().unwrap();
717 let beans_dir = dir.path().join(".beans");
718 fs::create_dir_all(&beans_dir).unwrap();
719
720 write_local_config(&beans_dir, &["nonexistent.yaml"], "");
721
722 let result = Config::load_with_extends(&beans_dir);
723 assert!(result.is_err());
724 let err_msg = format!("{}", result.unwrap_err());
725 assert!(
726 err_msg.contains("nonexistent.yaml"),
727 "Error should mention the missing file: {}",
728 err_msg
729 );
730 }
731
732 #[test]
733 fn extends_recursive_a_extends_b_extends_c() {
734 let dir = tempfile::tempdir().unwrap();
735 let beans_dir = dir.path().join(".beans");
736 fs::create_dir_all(&beans_dir).unwrap();
737
738 let c_path = dir.path().join("c.yaml");
740 write_yaml(
741 &c_path,
742 "project: c\nnext_id: 1\nmax_tokens: 40000\nrun: \"from-c\"\n",
743 );
744
745 let b_path = dir.path().join("b.yaml");
747 write_yaml(
748 &b_path,
749 "project: b\nnext_id: 1\nextends:\n - \"c.yaml\"\nmax_tokens: 50000\n",
750 );
751
752 write_local_config(&beans_dir, &["b.yaml"], "");
754
755 let config = Config::load_with_extends(&beans_dir).unwrap();
756 assert_eq!(config.max_tokens, 50000);
758 assert_eq!(config.run, Some("from-c".to_string()));
760 }
761
762 #[test]
763 fn extends_project_and_next_id_never_inherited() {
764 let dir = tempfile::tempdir().unwrap();
765 let beans_dir = dir.path().join(".beans");
766 fs::create_dir_all(&beans_dir).unwrap();
767
768 let parent_path = dir.path().join("shared.yaml");
769 write_yaml(
770 &parent_path,
771 "project: parent-project\nnext_id: 999\nmax_tokens: 50000\n",
772 );
773
774 write_local_config(&beans_dir, &["shared.yaml"], "");
775
776 let config = Config::load_with_extends(&beans_dir).unwrap();
777 assert_eq!(config.project, "test");
778 assert_eq!(config.next_id, 1);
779 }
780
781 #[test]
782 fn extends_tilde_resolves_to_home_dir() {
783 let beans_dir = std::path::Path::new("/tmp/fake-beans");
786 let resolved = Config::resolve_extends_path("~/shared/config.yaml", beans_dir).unwrap();
787 let home = dirs::home_dir().unwrap();
788 assert_eq!(resolved, home.join("shared/config.yaml"));
789 }
790
791 #[test]
792 fn extends_not_serialized_when_empty() {
793 let dir = tempfile::tempdir().unwrap();
794 let config = Config {
795 project: "test".to_string(),
796 next_id: 1,
797 auto_close_parent: true,
798 max_tokens: 30000,
799 run: None,
800 plan: None,
801 max_loops: 10,
802 max_concurrent: 4,
803 poll_interval: 30,
804 extends: vec![],
805 rules_file: None,
806 file_locking: false,
807 on_close: None,
808 on_fail: None,
809 post_plan: None,
810 verify_timeout: None,
811 review: None,
812 };
813 config.save(dir.path()).unwrap();
814
815 let contents = fs::read_to_string(dir.path().join("config.yaml")).unwrap();
816 assert!(!contents.contains("extends"));
817 }
818
819 #[test]
820 fn extends_defaults_to_empty() {
821 let dir = tempfile::tempdir().unwrap();
822 fs::write(
823 dir.path().join("config.yaml"),
824 "project: test\nnext_id: 1\n",
825 )
826 .unwrap();
827
828 let loaded = Config::load(dir.path()).unwrap();
829 assert!(loaded.extends.is_empty());
830 }
831
832 #[test]
835 fn plan_defaults_to_none() {
836 let dir = tempfile::tempdir().unwrap();
837 fs::write(
838 dir.path().join("config.yaml"),
839 "project: test\nnext_id: 1\n",
840 )
841 .unwrap();
842
843 let loaded = Config::load(dir.path()).unwrap();
844 assert_eq!(loaded.plan, None);
845 }
846
847 #[test]
848 fn plan_can_be_set() {
849 let dir = tempfile::tempdir().unwrap();
850 let config = Config {
851 project: "test".to_string(),
852 next_id: 1,
853 auto_close_parent: true,
854 max_tokens: 30000,
855 run: None,
856 plan: Some("claude -p 'plan bean {id}'".to_string()),
857 max_loops: 10,
858 max_concurrent: 4,
859 poll_interval: 30,
860 extends: vec![],
861 rules_file: None,
862 file_locking: false,
863 on_close: None,
864 on_fail: None,
865 post_plan: None,
866 verify_timeout: None,
867 review: None,
868 };
869 config.save(dir.path()).unwrap();
870
871 let loaded = Config::load(dir.path()).unwrap();
872 assert_eq!(loaded.plan, Some("claude -p 'plan bean {id}'".to_string()));
873 }
874
875 #[test]
876 fn plan_not_serialized_when_none() {
877 let dir = tempfile::tempdir().unwrap();
878 let config = Config {
879 project: "test".to_string(),
880 next_id: 1,
881 auto_close_parent: true,
882 max_tokens: 30000,
883 run: None,
884 plan: None,
885 max_loops: 10,
886 max_concurrent: 4,
887 poll_interval: 30,
888 extends: vec![],
889 rules_file: None,
890 file_locking: false,
891 on_close: None,
892 on_fail: None,
893 post_plan: None,
894 verify_timeout: None,
895 review: None,
896 };
897 config.save(dir.path()).unwrap();
898
899 let contents = fs::read_to_string(dir.path().join("config.yaml")).unwrap();
900 assert!(!contents.contains("plan:"));
901 }
902
903 #[test]
904 fn max_concurrent_defaults_to_4() {
905 let dir = tempfile::tempdir().unwrap();
906 fs::write(
907 dir.path().join("config.yaml"),
908 "project: test\nnext_id: 1\n",
909 )
910 .unwrap();
911
912 let loaded = Config::load(dir.path()).unwrap();
913 assert_eq!(loaded.max_concurrent, 4);
914 }
915
916 #[test]
917 fn max_concurrent_can_be_customized() {
918 let dir = tempfile::tempdir().unwrap();
919 let config = Config {
920 project: "test".to_string(),
921 next_id: 1,
922 auto_close_parent: true,
923 max_tokens: 30000,
924 run: None,
925 plan: None,
926 max_loops: 10,
927 max_concurrent: 8,
928 poll_interval: 30,
929 extends: vec![],
930 rules_file: None,
931 file_locking: false,
932 on_close: None,
933 on_fail: None,
934 post_plan: None,
935 verify_timeout: None,
936 review: None,
937 };
938 config.save(dir.path()).unwrap();
939
940 let loaded = Config::load(dir.path()).unwrap();
941 assert_eq!(loaded.max_concurrent, 8);
942 }
943
944 #[test]
945 fn poll_interval_defaults_to_30() {
946 let dir = tempfile::tempdir().unwrap();
947 fs::write(
948 dir.path().join("config.yaml"),
949 "project: test\nnext_id: 1\n",
950 )
951 .unwrap();
952
953 let loaded = Config::load(dir.path()).unwrap();
954 assert_eq!(loaded.poll_interval, 30);
955 }
956
957 #[test]
958 fn poll_interval_can_be_customized() {
959 let dir = tempfile::tempdir().unwrap();
960 let config = Config {
961 project: "test".to_string(),
962 next_id: 1,
963 auto_close_parent: true,
964 max_tokens: 30000,
965 run: None,
966 plan: None,
967 max_loops: 10,
968 max_concurrent: 4,
969 poll_interval: 60,
970 extends: vec![],
971 rules_file: None,
972 file_locking: false,
973 on_close: None,
974 on_fail: None,
975 post_plan: None,
976 verify_timeout: None,
977 review: None,
978 };
979 config.save(dir.path()).unwrap();
980
981 let loaded = Config::load(dir.path()).unwrap();
982 assert_eq!(loaded.poll_interval, 60);
983 }
984
985 #[test]
986 fn extends_inherits_plan() {
987 let dir = tempfile::tempdir().unwrap();
988 let beans_dir = dir.path().join(".beans");
989 fs::create_dir_all(&beans_dir).unwrap();
990
991 let parent_path = dir.path().join("shared.yaml");
992 write_yaml(
993 &parent_path,
994 "project: shared\nnext_id: 999\nplan: \"plan-cmd {id}\"\n",
995 );
996
997 write_local_config(&beans_dir, &["shared.yaml"], "");
998
999 let config = Config::load_with_extends(&beans_dir).unwrap();
1000 assert_eq!(config.plan, Some("plan-cmd {id}".to_string()));
1001 }
1002
1003 #[test]
1004 fn extends_inherits_max_concurrent() {
1005 let dir = tempfile::tempdir().unwrap();
1006 let beans_dir = dir.path().join(".beans");
1007 fs::create_dir_all(&beans_dir).unwrap();
1008
1009 let parent_path = dir.path().join("shared.yaml");
1010 write_yaml(
1011 &parent_path,
1012 "project: shared\nnext_id: 999\nmax_concurrent: 16\n",
1013 );
1014
1015 write_local_config(&beans_dir, &["shared.yaml"], "");
1016
1017 let config = Config::load_with_extends(&beans_dir).unwrap();
1018 assert_eq!(config.max_concurrent, 16);
1019 }
1020
1021 #[test]
1022 fn extends_inherits_poll_interval() {
1023 let dir = tempfile::tempdir().unwrap();
1024 let beans_dir = dir.path().join(".beans");
1025 fs::create_dir_all(&beans_dir).unwrap();
1026
1027 let parent_path = dir.path().join("shared.yaml");
1028 write_yaml(
1029 &parent_path,
1030 "project: shared\nnext_id: 999\npoll_interval: 120\n",
1031 );
1032
1033 write_local_config(&beans_dir, &["shared.yaml"], "");
1034
1035 let config = Config::load_with_extends(&beans_dir).unwrap();
1036 assert_eq!(config.poll_interval, 120);
1037 }
1038
1039 #[test]
1040 fn extends_local_overrides_new_fields() {
1041 let dir = tempfile::tempdir().unwrap();
1042 let beans_dir = dir.path().join(".beans");
1043 fs::create_dir_all(&beans_dir).unwrap();
1044
1045 let parent_path = dir.path().join("shared.yaml");
1046 write_yaml(
1047 &parent_path,
1048 "project: shared\nnext_id: 999\nplan: \"parent-plan\"\nmax_concurrent: 16\npoll_interval: 120\n",
1049 );
1050
1051 write_local_config(
1052 &beans_dir,
1053 &["shared.yaml"],
1054 "plan: \"local-plan\"\nmax_concurrent: 2\npoll_interval: 10\n",
1055 );
1056
1057 let config = Config::load_with_extends(&beans_dir).unwrap();
1058 assert_eq!(config.plan, Some("local-plan".to_string()));
1059 assert_eq!(config.max_concurrent, 2);
1060 assert_eq!(config.poll_interval, 10);
1061 }
1062
1063 #[test]
1064 fn new_fields_round_trip_through_yaml() {
1065 let dir = tempfile::tempdir().unwrap();
1066 let config = Config {
1067 project: "test".to_string(),
1068 next_id: 1,
1069 auto_close_parent: true,
1070 max_tokens: 30000,
1071 run: None,
1072 plan: Some("plan {id}".to_string()),
1073 max_loops: 10,
1074 max_concurrent: 8,
1075 poll_interval: 60,
1076 extends: vec![],
1077 rules_file: None,
1078 file_locking: false,
1079 on_close: None,
1080 on_fail: None,
1081 post_plan: None,
1082 verify_timeout: None,
1083 review: None,
1084 };
1085
1086 config.save(dir.path()).unwrap();
1087 let loaded = Config::load(dir.path()).unwrap();
1088
1089 assert_eq!(config, loaded);
1090 }
1091}