Skip to main content

bn/
config.rs

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/// Configuration for the adversarial review feature (`bn review` / `bn run --review`).
10#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
11pub struct ReviewConfig {
12    /// Shell command template for the review agent. Use `{id}` as placeholder for bean ID.
13    /// If unset, falls back to the global `run` template.
14    #[serde(default, skip_serializing_if = "Option::is_none")]
15    pub run: Option<String>,
16    /// Maximum number of times review can reopen a bean before giving up (default: 2).
17    #[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    /// Auto-close parent beans when all children are closed/archived (default: true)
39    #[serde(default = "default_auto_close_parent")]
40    pub auto_close_parent: bool,
41    /// Shell command template for `--run`. Use `{id}` as placeholder for bean ID.
42    /// Example: `claude -p "implement bean {id} and run bn close {id}"`.
43    /// If unset, `--run` will print an error asking the user to configure it.
44    #[serde(default, skip_serializing_if = "Option::is_none")]
45    pub run: Option<String>,
46    /// Shell command template for planning large beans. Uses `{id}` placeholder.
47    /// If unset, plan operations will print an error asking the user to configure it.
48    #[serde(default, skip_serializing_if = "Option::is_none")]
49    pub plan: Option<String>,
50    /// Maximum agent loops before stopping (default: 10, 0 = unlimited)
51    #[serde(default = "default_max_loops")]
52    pub max_loops: u32,
53    /// Maximum parallel agents for `bn run` (default: 4)
54    #[serde(default = "default_max_concurrent")]
55    pub max_concurrent: u32,
56    /// Seconds between polls in --watch mode (default: 30)
57    #[serde(default = "default_poll_interval")]
58    pub poll_interval: u32,
59    /// Paths to parent config files to inherit from (lowest to highest priority).
60    /// Supports `~/` for home directory. Paths are relative to the project root.
61    #[serde(default, skip_serializing_if = "Vec::is_empty")]
62    pub extends: Vec<String>,
63    /// Path to project rules file, relative to .beans/ directory (default: "RULES.md").
64    /// Contents are injected into every `bn context` output.
65    #[serde(default, skip_serializing_if = "Option::is_none")]
66    pub rules_file: Option<String>,
67    /// Enable file locking for concurrent agents (default: false).
68    /// When enabled, agents lock files listed in bean `paths` on spawn
69    /// and lock-on-write during execution. Prevents concurrent agents
70    /// from clobbering the same file.
71    #[serde(default, skip_serializing_if = "is_false_bool")]
72    pub file_locking: bool,
73    /// Enable git worktree isolation for parallel agents (default: false).
74    /// When enabled, `bn run` creates a separate git worktree for each agent.
75    /// Each agent works in its own directory, preventing file contention.
76    /// On `bn close`, the worktree branch is merged back to main.
77    #[serde(default, skip_serializing_if = "is_false_bool")]
78    pub worktree: bool,
79    /// Shell command template to run after a bean is successfully closed.
80    /// Supports template variables: {id}, {title}, {status}, {branch}.
81    /// Runs asynchronously — failures are logged but don't affect the close.
82    #[serde(default, skip_serializing_if = "Option::is_none")]
83    pub on_close: Option<String>,
84    /// Shell command template to run after a verify attempt fails.
85    /// Supports template variables: {id}, {title}, {attempt}, {output}, {branch}.
86    /// Runs asynchronously — failures are logged but don't affect the operation.
87    #[serde(default, skip_serializing_if = "Option::is_none")]
88    pub on_fail: Option<String>,
89    /// Shell command template to run after `bn plan` creates children.
90    /// Supports template variables: {id}, {parent}, {children}, {branch}.
91    /// Runs asynchronously — failures are logged but don't affect the plan.
92    #[serde(default, skip_serializing_if = "Option::is_none")]
93    pub post_plan: Option<String>,
94    /// Default timeout in seconds for verify commands (default: None = no limit).
95    /// Per-bean `verify_timeout` overrides this value.
96    #[serde(default, skip_serializing_if = "Option::is_none")]
97    pub verify_timeout: Option<u64>,
98    /// Adversarial review configuration (`bn review` / `bn run --review`).
99    /// Optional — review is disabled if not configured.
100    #[serde(default, skip_serializing_if = "Option::is_none")]
101    pub review: Option<ReviewConfig>,
102    /// User identity name (e.g., "alice"). Used for claimed_by and created_by.
103    #[serde(default, skip_serializing_if = "Option::is_none")]
104    pub user: Option<String>,
105    /// User email (e.g., "alice@co"). Optional, for git integration.
106    #[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    /// Load config from .beans/config.yaml inside the given beans directory.
158    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    /// Load config with inheritance from extended configs.
168    ///
169    /// Resolves the `extends` field, loading parent configs and merging
170    /// inheritable fields. Local values take precedence over extended values.
171    /// Fields `project`, `next_id`, and `extends` are never inherited.
172    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; // Cycle detection
192            }
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        // Merge: closest parent first (highest priority among parents).
209        // Only override local values that are still at their defaults.
210        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            // Never inherit: project, next_id, extends
260        }
261
262        Ok(config)
263    }
264
265    /// Resolve an extends path to an absolute path.
266    /// `~/` expands to the home directory; other paths are relative to the project root.
267    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            // Resolve relative to the project root (parent of .beans/)
273            let project_root = beans_dir.parent().unwrap_or(Path::new("."));
274            Ok(project_root.join(path_str))
275        }
276    }
277
278    /// Save config to .beans/config.yaml inside the given beans directory.
279    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    /// Return the path to the project rules file.
288    /// Defaults to `.beans/RULES.md` if `rules_file` is not set.
289    /// The path is resolved relative to the beans directory.
290    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    /// Return the current next_id and increment it for the next call.
305    pub fn increment_id(&mut self) -> u32 {
306        let id = self.next_id;
307        self.next_id += 1;
308        id
309    }
310}
311
312// ---------------------------------------------------------------------------
313// Global config (~/.config/beans/config.yaml)
314// ---------------------------------------------------------------------------
315
316/// Minimal global config stored at `~/.config/beans/config.yaml`.
317/// Only holds user identity fields — project-level config has everything else.
318#[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    /// Path to global config file: `~/.config/beans/config.yaml`.
328    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    /// Load global config. Returns Default if file doesn't exist.
334    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    /// Save global config, creating parent directories if needed.
347    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
360// ---------------------------------------------------------------------------
361// Identity resolution
362// ---------------------------------------------------------------------------
363
364/// Resolve the current user identity using a priority chain:
365///
366/// 1. Project config `user` field (from `.beans/config.yaml`)
367/// 2. Global config `user` field (from `~/.config/beans/config.yaml`)
368/// 3. `git config user.name` (fallback)
369/// 4. `$USER` environment variable (last resort)
370///
371/// Returns `None` only if all sources fail.
372pub fn resolve_identity(beans_dir: &Path) -> Option<String> {
373    // 1. Project config
374    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    // 2. Global config
383    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    // 3. git config user.name
392    if let Some(git_user) = git_config_user_name() {
393        return Some(git_user);
394    }
395
396    // 4. $USER env var
397    std::env::var("USER").ok().filter(|u| !u.is_empty())
398}
399
400/// Try to get `git config user.name`. Returns None on failure.
401fn 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        // Write a config WITHOUT auto_close_parent field
528        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        // Existing configs in the wild may have max_tokens — must not error
572        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    // --- extends tests ---
702
703    /// Helper: write a YAML config file at the given path.
704    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    /// Helper: write a minimal local config inside a beans dir, with extends.
712    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        // Parent config (outside .beans, at project root)
742        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        // Inherited
752        assert_eq!(config.run, Some("deli spawn {id}".to_string()));
753        assert_eq!(config.max_loops, 20);
754        // Never inherited
755        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        // Local config sets its own run
772        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        // Local values win
780        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        // A extends B, B extends A
791        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        // Should not infinite loop; loads successfully
805        let config = Config::load_with_extends(&beans_dir).unwrap();
806        assert_eq!(config.project, "test");
807        // Gets value from one of the parents
808        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        // C: base config
836        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        // B extends C, overrides max_loops
843        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        // Local extends B
850        write_local_config(&beans_dir, &["b.yaml"], "");
851
852        let config = Config::load_with_extends(&beans_dir).unwrap();
853        // B's max_loops (50) should apply since it's the direct parent
854        assert_eq!(config.max_loops, 50);
855        // run comes from C (B doesn't set it, but C does)
856        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        // We can't fully test ~ expansion without writing to the real home dir,
881        // but we can verify the path resolution logic.
882        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    // --- plan, max_concurrent, poll_interval tests ---
932
933    #[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}