Skip to main content

bn/
config.rs

1use std::collections::HashSet;
2use std::fs;
3use std::path::{Path, PathBuf};
4
5use anyhow::{anyhow, Context, Result};
6use serde::{Deserialize, Serialize};
7
8/// Configuration for the adversarial review feature (`bn review` / `bn run --review`).
9#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
10pub struct ReviewConfig {
11    /// Shell command template for the review agent. Use `{id}` as placeholder for bean ID.
12    /// If unset, falls back to the global `run` template.
13    #[serde(default, skip_serializing_if = "Option::is_none")]
14    pub run: Option<String>,
15    /// Maximum number of times review can reopen a bean before giving up (default: 2).
16    #[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    /// Auto-close parent beans when all children are closed/archived (default: true)
38    #[serde(default = "default_auto_close_parent")]
39    pub auto_close_parent: bool,
40    /// Maximum tokens for bean context (default: 30000)
41    #[serde(default = "default_max_tokens")]
42    pub max_tokens: u32,
43    /// Shell command template for `--run`. Use `{id}` as placeholder for bean ID.
44    /// Example: `claude -p "implement bean {id} and run bn close {id}"`.
45    /// If unset, `--run` will print an error asking the user to configure it.
46    #[serde(default, skip_serializing_if = "Option::is_none")]
47    pub run: Option<String>,
48    /// Shell command template for planning large beans. Uses `{id}` placeholder.
49    /// If unset, plan operations will print an error asking the user to configure it.
50    #[serde(default, skip_serializing_if = "Option::is_none")]
51    pub plan: Option<String>,
52    /// Maximum agent loops before stopping (default: 10, 0 = unlimited)
53    #[serde(default = "default_max_loops")]
54    pub max_loops: u32,
55    /// Maximum parallel agents for `bn run` (default: 4)
56    #[serde(default = "default_max_concurrent")]
57    pub max_concurrent: u32,
58    /// Seconds between polls in --watch mode (default: 30)
59    #[serde(default = "default_poll_interval")]
60    pub poll_interval: u32,
61    /// Paths to parent config files to inherit from (lowest to highest priority).
62    /// Supports `~/` for home directory. Paths are relative to the project root.
63    #[serde(default, skip_serializing_if = "Vec::is_empty")]
64    pub extends: Vec<String>,
65    /// Path to project rules file, relative to .beans/ directory (default: "RULES.md").
66    /// Contents are injected into every `bn context` output.
67    #[serde(default, skip_serializing_if = "Option::is_none")]
68    pub rules_file: Option<String>,
69    /// Enable file locking for concurrent agents (default: false).
70    /// When enabled, agents lock files listed in bean `paths` on spawn
71    /// and lock-on-write during execution. Prevents concurrent agents
72    /// from clobbering the same file.
73    #[serde(default, skip_serializing_if = "is_false_bool")]
74    pub file_locking: bool,
75    /// Shell command template to run after a bean is successfully closed.
76    /// Supports template variables: {id}, {title}, {status}, {branch}.
77    /// Runs asynchronously — failures are logged but don't affect the close.
78    #[serde(default, skip_serializing_if = "Option::is_none")]
79    pub on_close: Option<String>,
80    /// Shell command template to run after a verify attempt fails.
81    /// Supports template variables: {id}, {title}, {attempt}, {output}, {branch}.
82    /// Runs asynchronously — failures are logged but don't affect the operation.
83    #[serde(default, skip_serializing_if = "Option::is_none")]
84    pub on_fail: Option<String>,
85    /// Shell command template to run after `bn plan` creates children.
86    /// Supports template variables: {id}, {parent}, {children}, {branch}.
87    /// Runs asynchronously — failures are logged but don't affect the plan.
88    #[serde(default, skip_serializing_if = "Option::is_none")]
89    pub post_plan: Option<String>,
90    /// Default timeout in seconds for verify commands (default: None = no limit).
91    /// Per-bean `verify_timeout` overrides this value.
92    #[serde(default, skip_serializing_if = "Option::is_none")]
93    pub verify_timeout: Option<u64>,
94    /// Adversarial review configuration (`bn review` / `bn run --review`).
95    /// Optional — review is disabled if not configured.
96    #[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    /// Load config from .beans/config.yaml inside the given beans directory.
150    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    /// Load config with inheritance from extended configs.
160    ///
161    /// Resolves the `extends` field, loading parent configs and merging
162    /// inheritable fields. Local values take precedence over extended values.
163    /// Fields `project`, `next_id`, and `extends` are never inherited.
164    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; // Cycle detection
184            }
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        // Merge: closest parent first (highest priority among parents).
201        // Only override local values that are still at their defaults.
202        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            // Never inherit: project, next_id, extends
246        }
247
248        Ok(config)
249    }
250
251    /// Resolve an extends path to an absolute path.
252    /// `~/` expands to the home directory; other paths are relative to the project root.
253    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            // Resolve relative to the project root (parent of .beans/)
259            let project_root = beans_dir.parent().unwrap_or(Path::new("."));
260            Ok(project_root.join(path_str))
261        }
262    }
263
264    /// Save config to .beans/config.yaml inside the given beans directory.
265    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    /// Return the path to the project rules file.
274    /// Defaults to `.beans/RULES.md` if `rules_file` is not set.
275    /// The path is resolved relative to the beans directory.
276    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    /// Return the current next_id and increment it for the next call.
291    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        // Write a config WITHOUT auto_close_parent field
408        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        // Write a config WITHOUT max_tokens field
450        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    // --- extends tests ---
602
603    /// Helper: write a YAML config file at the given path.
604    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    /// Helper: write a minimal local config inside a beans dir, with extends.
612    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); // default
633        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        // Parent config (outside .beans, at project root)
643        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        // Inherited
653        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        // Never inherited
657        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        // Local config sets its own max_tokens and run
674        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        // Local values win
682        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        // A extends B, B extends A
694        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        // Should not infinite loop; loads successfully
708        let config = Config::load_with_extends(&beans_dir).unwrap();
709        assert_eq!(config.project, "test");
710        // Gets value from one of the parents
711        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        // C: base config
739        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        // B extends C, overrides max_tokens
746        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        // Local extends B
753        write_local_config(&beans_dir, &["b.yaml"], "");
754
755        let config = Config::load_with_extends(&beans_dir).unwrap();
756        // B's max_tokens (50000) should apply since it's the direct parent
757        assert_eq!(config.max_tokens, 50000);
758        // run comes from C (B doesn't set it, but C does)
759        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        // We can't fully test ~ expansion without writing to the real home dir,
784        // but we can verify the path resolution logic.
785        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    // --- plan, max_concurrent, poll_interval tests ---
833
834    #[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}