Skip to main content

mana_core/
config.rs

1//! Project and global configuration.
2//!
3//! Configuration is stored in `.mana/config.yaml` (project-level) and
4//! `~/.config/mana/config.yaml` (global/user-level).
5//!
6//! ## Loading config
7//!
8//! ```rust,no_run
9//! use mana_core::config::Config;
10//! use std::path::Path;
11//!
12//! let mana_dir = Path::new("/project/.mana");
13//!
14//! // Load project config only
15//! let config = Config::load(mana_dir).unwrap();
16//!
17//! // Load with inheritance from `extends` paths (recommended)
18//! let config = Config::load_with_extends(mana_dir).unwrap();
19//! println!("Project: {}", config.project);
20//! println!("Max concurrent agents: {}", config.max_concurrent);
21//! ```
22//!
23//! ## Config inheritance
24//!
25//! A project config can extend shared configs via the `extends` field:
26//!
27//! ```yaml
28//! project: my-project
29//! next_id: 42
30//! extends:
31//!   - ~/shared/mana-config.yaml
32//!   - ../team-defaults.yaml
33//! ```
34//!
35//! Extended configs are merged with the local config taking precedence.
36//! The `project`, `next_id`, and `extends` fields are never inherited.
37
38use std::collections::HashSet;
39use std::fs;
40use std::path::{Path, PathBuf};
41use std::process::Command;
42
43use anyhow::{anyhow, Context, Result};
44use serde::{Deserialize, Serialize};
45
46pub const DEFAULT_COMMIT_TEMPLATE: &str = "feat(unit-{id}): {title}";
47
48/// Notification configuration for human-facing alerts.
49///
50/// Commands are shell templates run via `sh -c` with variable interpolation.
51/// All are fire-and-forget — failures are logged but never block operations.
52///
53/// ## Template variables
54///
55/// | Variable | Available in | Description |
56/// |----------|-------------|-------------|
57/// | `{id}` | all | Unit ID |
58/// | `{title}` | all | Unit title |
59/// | `{status}` | on_close, on_scheduled_complete | "pass" or "fail" |
60/// | `{verify_output}` | on_close | First 200 chars of verify output |
61/// | `{attempt}` | on_fail | Current attempt number |
62/// | `{max_attempts}` | on_fail | Max attempts configured |
63/// | `{output}` | on_fail | First 200 chars of verify output |
64/// | `{schedule}` | on_scheduled_complete | Schedule expression |
65/// | `{next_run_at}` | on_scheduled_complete | Next scheduled run time |
66#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, Default)]
67pub struct NotifyConfig {
68    /// Command run when a unit closes successfully.
69    #[serde(default, skip_serializing_if = "Option::is_none")]
70    pub on_close: Option<String>,
71
72    /// Command run when a unit's verify fails.
73    #[serde(default, skip_serializing_if = "Option::is_none")]
74    pub on_fail: Option<String>,
75
76    /// Command run when a scheduled unit completes (pass or fail).
77    #[serde(default, skip_serializing_if = "Option::is_none")]
78    pub on_scheduled_complete: Option<String>,
79}
80
81/// Configuration for the adversarial review feature (`mana review` / `mana run --review`).
82#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
83pub struct ReviewConfig {
84    /// Shell command template for the review agent. Use `{id}` as placeholder for unit ID.
85    /// If unset, falls back to the global `run` template.
86    #[serde(default, skip_serializing_if = "Option::is_none")]
87    pub run: Option<String>,
88    /// Maximum number of times review can reopen a unit before giving up (default: 2).
89    #[serde(default = "default_max_reopens")]
90    pub max_reopens: u32,
91}
92
93fn default_max_reopens() -> u32 {
94    2
95}
96
97impl Default for ReviewConfig {
98    fn default() -> Self {
99        Self {
100            run: None,
101            max_reopens: 2,
102        }
103    }
104}
105
106/// Project-level mana configuration, loaded from `.mana/config.yaml`.
107///
108/// All fields have sensible defaults; only `project` and `next_id` are
109/// required in the YAML file. Optional fields are omitted when serialized
110/// to keep config files minimal.
111///
112/// Use [`Config::load_with_extends`] to load with inherited values from
113/// parent configs listed in the `extends` field.
114#[derive(Debug, Serialize, Deserialize, PartialEq)]
115pub struct Config {
116    pub project: String,
117    pub next_id: u32,
118    /// Auto-close parent units when all children are closed/archived (default: true)
119    #[serde(default = "default_auto_close_parent")]
120    pub auto_close_parent: bool,
121    /// Shell command template for `--run`. Use `{id}` as placeholder for unit ID.
122    /// Example: `claude -p "implement unit {id} and run mana close {id}"`.
123    /// If unset, `--run` will print an error asking the user to configure it.
124    #[serde(default, skip_serializing_if = "Option::is_none")]
125    pub run: Option<String>,
126    /// Shell command template for planning large units. Uses `{id}` placeholder.
127    /// If unset, plan operations will print an error asking the user to configure it.
128    #[serde(default, skip_serializing_if = "Option::is_none")]
129    pub plan: Option<String>,
130    /// Maximum agent loops before stopping (default: 10, 0 = unlimited)
131    #[serde(default = "default_max_loops")]
132    pub max_loops: u32,
133    /// Maximum parallel agents for `mana run` (default: 4)
134    #[serde(default = "default_max_concurrent")]
135    pub max_concurrent: u32,
136    /// Seconds between polls in --watch mode (default: 30)
137    #[serde(default = "default_poll_interval")]
138    pub poll_interval: u32,
139    /// Paths to parent config files to inherit from (lowest to highest priority).
140    /// Supports `~/` for home directory. Paths are relative to the project root.
141    #[serde(default, skip_serializing_if = "Vec::is_empty")]
142    pub extends: Vec<String>,
143    /// Path to project rules file, relative to .mana/ directory (default: "RULES.md").
144    /// Contents are injected into every `mana context` output.
145    #[serde(default, skip_serializing_if = "Option::is_none")]
146    pub rules_file: Option<String>,
147    /// Enable file locking for concurrent agents (default: false).
148    /// When enabled, agents lock files listed in unit `paths` on spawn
149    /// and lock-on-write during execution. Prevents concurrent agents
150    /// from clobbering the same file.
151    #[serde(default, skip_serializing_if = "is_false_bool")]
152    pub file_locking: bool,
153    /// Enable git worktree isolation for parallel agents (default: false).
154    /// When enabled, `mana run` creates a separate git worktree for each agent.
155    /// Each agent works in its own directory, preventing file contention.
156    /// On `mana close`, the worktree branch is merged back to main.
157    #[serde(default, skip_serializing_if = "is_false_bool")]
158    pub worktree: bool,
159    /// Shell command template to run after a unit is successfully closed.
160    /// Supports template variables: {id}, {title}, {status}, {branch}.
161    /// Runs asynchronously — failures are logged but don't affect the close.
162    #[serde(default, skip_serializing_if = "Option::is_none")]
163    pub on_close: Option<String>,
164    /// Shell command template to run after a verify attempt fails.
165    /// Supports template variables: {id}, {title}, {attempt}, {output}, {branch}.
166    /// Runs asynchronously — failures are logged but don't affect the operation.
167    #[serde(default, skip_serializing_if = "Option::is_none")]
168    pub on_fail: Option<String>,
169    /// Shell command template to run after `mana plan` creates children.
170    /// Supports template variables: {id}, {parent}, {children}, {branch}.
171    /// Runs asynchronously — failures are logged but don't affect the plan.
172    #[serde(default, skip_serializing_if = "Option::is_none")]
173    pub post_plan: Option<String>,
174    /// Default timeout in seconds for verify commands (default: None = no limit).
175    /// Per-unit `verify_timeout` overrides this value.
176    #[serde(default, skip_serializing_if = "Option::is_none")]
177    pub verify_timeout: Option<u64>,
178    /// Adversarial review configuration (`mana review` / `mana run --review`).
179    /// Optional — review is disabled if not configured.
180    #[serde(default, skip_serializing_if = "Option::is_none")]
181    pub review: Option<ReviewConfig>,
182    /// User identity name (e.g., "alice"). Used for claimed_by and created_by.
183    #[serde(default, skip_serializing_if = "Option::is_none")]
184    pub user: Option<String>,
185    /// User email (e.g., "alice@co"). Optional, for git integration.
186    #[serde(default, skip_serializing_if = "Option::is_none")]
187    pub user_email: Option<String>,
188    /// Automatically commit all changes when a unit is closed (default: false).
189    /// Creates a commit with message based on `commit_template`.
190    /// Skipped in worktree mode (worktree already commits).
191    #[serde(default, skip_serializing_if = "is_false_bool")]
192    pub auto_commit: bool,
193    /// Template for auto-commit messages. Placeholders: {id}, {title}, {parent_id}, {labels}.
194    /// Default: "feat(unit-{id}): {title}"
195    ///
196    /// Keep `{id}` in the template so `mana diff <id>` can find the unit's commit.
197    #[serde(default, skip_serializing_if = "Option::is_none")]
198    pub commit_template: Option<String>,
199    /// Shell command template for project-level research (`mana plan` with no ID).
200    /// Uses `{parent_id}` as placeholder for the parent unit that groups findings.
201    /// Falls back to `plan` template with a research-oriented prompt if unset.
202    #[serde(default, skip_serializing_if = "Option::is_none")]
203    pub research: Option<String>,
204    /// Model to use for implementing units (`mana run`). Substituted into `{model}` in templates.
205    #[serde(default, skip_serializing_if = "Option::is_none")]
206    pub run_model: Option<String>,
207    /// Model to use for planning/splitting (`mana plan`). Substituted into `{model}` in templates.
208    #[serde(default, skip_serializing_if = "Option::is_none")]
209    pub plan_model: Option<String>,
210    /// Model to use for adversarial review (`mana run --review`). Substituted into `{model}` in templates.
211    #[serde(default, skip_serializing_if = "Option::is_none")]
212    pub review_model: Option<String>,
213    /// Model to use for project research (`mana plan` with no args). Substituted into `{model}` in templates.
214    #[serde(default, skip_serializing_if = "Option::is_none")]
215    pub research_model: Option<String>,
216    /// Enable runner-mediated batch verification (default: false).
217    /// When enabled, `mana run` sets `MANA_BATCH_VERIFY=1` on spawned agents.
218    /// Agents signal completion without running verify inline; the runner
219    /// collects AwaitingVerify units and runs each unique verify command once.
220    #[serde(default, skip_serializing_if = "is_false_bool")]
221    pub batch_verify: bool,
222    /// Minimum available system memory (MB) to keep free when spawning agents.
223    /// When set to a non-zero value, `mana run` checks available system memory
224    /// before each agent spawn. If available memory is below this threshold,
225    /// dispatch pauses until a running agent finishes and frees memory.
226    /// Default: 0 (disabled). Recommended: 2048–4096 on a 16GB machine.
227    #[serde(default, skip_serializing_if = "is_zero_u64")]
228    pub memory_reserve_mb: u64,
229    /// Notification settings for human-facing alerts (push notifications,
230    /// desktop alerts, webhook pings). Separate from on_close/on_fail workflow
231    /// hooks — those are for automation, these are for humans.
232    #[serde(default, skip_serializing_if = "Option::is_none")]
233    pub notify: Option<NotifyConfig>,
234}
235
236fn default_auto_close_parent() -> bool {
237    true
238}
239
240fn default_max_loops() -> u32 {
241    10
242}
243
244fn default_max_concurrent() -> u32 {
245    4
246}
247
248fn default_poll_interval() -> u32 {
249    30
250}
251
252fn is_false_bool(v: &bool) -> bool {
253    !v
254}
255
256fn is_zero_u64(v: &u64) -> bool {
257    *v == 0
258}
259
260impl Default for Config {
261    fn default() -> Self {
262        Self {
263            project: String::new(),
264            next_id: 1,
265            auto_close_parent: true,
266            run: None,
267            plan: None,
268            max_loops: 10,
269            max_concurrent: 4,
270            poll_interval: 30,
271            extends: Vec::new(),
272            rules_file: None,
273            file_locking: false,
274            worktree: false,
275            on_close: None,
276            on_fail: None,
277            post_plan: None,
278            verify_timeout: None,
279            review: None,
280            user: None,
281            user_email: None,
282            auto_commit: false,
283            commit_template: None,
284            research: None,
285            run_model: None,
286            plan_model: None,
287            review_model: None,
288            research_model: None,
289            batch_verify: false,
290            memory_reserve_mb: 0,
291            notify: None,
292        }
293    }
294}
295
296impl Config {
297    /// Load config from .mana/config.yaml inside the given units directory.
298    pub fn load(mana_dir: &Path) -> Result<Self> {
299        let path = mana_dir.join("config.yaml");
300        let contents = fs::read_to_string(&path)
301            .with_context(|| format!("Failed to read config at {}", path.display()))?;
302        let config: Config = serde_yml::from_str(&contents)
303            .with_context(|| format!("Failed to parse config at {}", path.display()))?;
304        Ok(config)
305    }
306
307    /// Load config with inheritance from extended configs.
308    ///
309    /// Resolves the `extends` field, loading parent configs and merging
310    /// inheritable fields. Local values take precedence over extended values.
311    /// Fields `project`, `next_id`, and `extends` are never inherited.
312    pub fn load_with_extends(mana_dir: &Path) -> Result<Self> {
313        let mut config = Self::load(mana_dir)?;
314
315        if config.extends.is_empty() {
316            return Ok(config);
317        }
318
319        let mut seen = HashSet::new();
320        let mut stack: Vec<String> = config.extends.clone();
321        let mut parents: Vec<Config> = Vec::new();
322
323        while let Some(path_str) = stack.pop() {
324            let resolved = Self::resolve_extends_path(&path_str, mana_dir)?;
325
326            let canonical = resolved
327                .canonicalize()
328                .with_context(|| format!("Cannot resolve extends path: {}", path_str))?;
329
330            if !seen.insert(canonical.clone()) {
331                continue; // Cycle detection
332            }
333
334            let contents = fs::read_to_string(&canonical).with_context(|| {
335                format!("Failed to read extends config: {}", canonical.display())
336            })?;
337            let parent: Config = serde_yml::from_str(&contents).with_context(|| {
338                format!("Failed to parse extends config: {}", canonical.display())
339            })?;
340
341            for ext in &parent.extends {
342                stack.push(ext.clone());
343            }
344
345            parents.push(parent);
346        }
347
348        // Merge: closest parent first (highest priority among parents).
349        // Only override local values that are still at their defaults.
350        for parent in &parents {
351            if config.run.is_none() {
352                config.run = parent.run.clone();
353            }
354            if config.plan.is_none() {
355                config.plan = parent.plan.clone();
356            }
357            if config.max_loops == default_max_loops() {
358                config.max_loops = parent.max_loops;
359            }
360            if config.max_concurrent == default_max_concurrent() {
361                config.max_concurrent = parent.max_concurrent;
362            }
363            if config.poll_interval == default_poll_interval() {
364                config.poll_interval = parent.poll_interval;
365            }
366            if config.auto_close_parent == default_auto_close_parent() {
367                config.auto_close_parent = parent.auto_close_parent;
368            }
369            if config.rules_file.is_none() {
370                config.rules_file = parent.rules_file.clone();
371            }
372            if !config.file_locking {
373                config.file_locking = parent.file_locking;
374            }
375            if !config.worktree {
376                config.worktree = parent.worktree;
377            }
378            if config.on_close.is_none() {
379                config.on_close = parent.on_close.clone();
380            }
381            if config.on_fail.is_none() {
382                config.on_fail = parent.on_fail.clone();
383            }
384            if config.post_plan.is_none() {
385                config.post_plan = parent.post_plan.clone();
386            }
387            if config.verify_timeout.is_none() {
388                config.verify_timeout = parent.verify_timeout;
389            }
390            if config.review.is_none() {
391                config.review = parent.review.clone();
392            }
393            if config.user.is_none() {
394                config.user = parent.user.clone();
395            }
396            if config.user_email.is_none() {
397                config.user_email = parent.user_email.clone();
398            }
399            if !config.auto_commit {
400                config.auto_commit = parent.auto_commit;
401            }
402            if config.commit_template.is_none() {
403                config.commit_template = parent.commit_template.clone();
404            }
405            if config.research.is_none() {
406                config.research = parent.research.clone();
407            }
408            if config.run_model.is_none() {
409                config.run_model = parent.run_model.clone();
410            }
411            if config.plan_model.is_none() {
412                config.plan_model = parent.plan_model.clone();
413            }
414            if config.review_model.is_none() {
415                config.review_model = parent.review_model.clone();
416            }
417            if config.research_model.is_none() {
418                config.research_model = parent.research_model.clone();
419            }
420            // Never inherit: project, next_id, extends
421        }
422
423        Ok(config)
424    }
425
426    /// Resolve an extends path to an absolute path.
427    /// `~/` expands to the home directory; other paths are relative to the project root.
428    fn resolve_extends_path(path_str: &str, mana_dir: &Path) -> Result<PathBuf> {
429        if let Some(stripped) = path_str.strip_prefix("~/") {
430            let home = dirs::home_dir().ok_or_else(|| anyhow!("Cannot resolve home directory"))?;
431            Ok(home.join(stripped))
432        } else {
433            // Resolve relative to the project root (parent of .mana/)
434            let project_root = mana_dir.parent().unwrap_or(Path::new("."));
435            Ok(project_root.join(path_str))
436        }
437    }
438
439    /// Save config to .mana/config.yaml inside the given units directory.
440    pub fn save(&self, mana_dir: &Path) -> Result<()> {
441        let path = mana_dir.join("config.yaml");
442        let contents = serde_yml::to_string(self).context("Failed to serialize config")?;
443        fs::write(&path, &contents)
444            .with_context(|| format!("Failed to write config at {}", path.display()))?;
445        Ok(())
446    }
447
448    /// Return the path to the project rules file.
449    /// Defaults to `.mana/RULES.md` if `rules_file` is not set.
450    /// The path is resolved relative to the units directory.
451    pub fn rules_path(&self, mana_dir: &Path) -> PathBuf {
452        match &self.rules_file {
453            Some(custom) => {
454                let p = Path::new(custom);
455                if p.is_absolute() {
456                    p.to_path_buf()
457                } else {
458                    mana_dir.join(custom)
459                }
460            }
461            None => mana_dir.join("RULES.md"),
462        }
463    }
464
465    /// Return the current next_id and increment it for the next call.
466    pub fn increment_id(&mut self) -> u32 {
467        let id = self.next_id;
468        self.next_id += 1;
469        id
470    }
471}
472
473// ---------------------------------------------------------------------------
474// Global config (~/.config/mana/config.yaml)
475// ---------------------------------------------------------------------------
476
477/// Minimal global config stored at `~/.config/mana/config.yaml`.
478/// Only holds user identity fields — project-level config has everything else.
479#[derive(Debug, Default, Serialize, Deserialize)]
480pub struct GlobalConfig {
481    #[serde(default, skip_serializing_if = "Option::is_none")]
482    pub user: Option<String>,
483    #[serde(default, skip_serializing_if = "Option::is_none")]
484    pub user_email: Option<String>,
485}
486
487impl GlobalConfig {
488    /// Path to the new global config file: `~/.config/mana/config.yaml`.
489    pub fn path() -> Result<PathBuf> {
490        let home = dirs::home_dir().ok_or_else(|| anyhow!("Cannot determine home directory"))?;
491        Ok(home.join(".config").join("mana").join("config.yaml"))
492    }
493
494    /// Path to the legacy global config file (`~/.config/` + `units/config.yaml`).
495    /// Used as a read-only fallback during migration.
496    fn legacy_path() -> Result<PathBuf> {
497        let home = dirs::home_dir().ok_or_else(|| anyhow!("Cannot determine home directory"))?;
498        Ok(home.join(".config").join("units").join("config.yaml"))
499    }
500
501    /// Load global config. Returns Default if file doesn't exist.
502    ///
503    /// Falls back to the legacy `units` config directory if the new path doesn't exist
504    /// but the old one does, to support migration from the old location.
505    pub fn load() -> Result<Self> {
506        let path = Self::path()?;
507        if path.exists() {
508            let contents = fs::read_to_string(&path)
509                .with_context(|| format!("Failed to read global config at {}", path.display()))?;
510            let config: GlobalConfig = serde_yml::from_str(&contents)
511                .with_context(|| format!("Failed to parse global config at {}", path.display()))?;
512            return Ok(config);
513        }
514
515        // Backwards-compatible fallback: read from legacy units config path.
516        if let Ok(legacy) = Self::legacy_path() {
517            if legacy.exists() {
518                let contents = fs::read_to_string(&legacy).with_context(|| {
519                    format!(
520                        "Failed to read legacy global config at {}",
521                        legacy.display()
522                    )
523                })?;
524                let config: GlobalConfig = serde_yml::from_str(&contents).with_context(|| {
525                    format!(
526                        "Failed to parse legacy global config at {}",
527                        legacy.display()
528                    )
529                })?;
530                return Ok(config);
531            }
532        }
533
534        Ok(Self::default())
535    }
536
537    /// Save global config to `~/.config/mana/config.yaml`, creating parent directories if needed.
538    pub fn save(&self) -> Result<()> {
539        let path = Self::path()?;
540        if let Some(parent) = path.parent() {
541            fs::create_dir_all(parent)
542                .with_context(|| format!("Failed to create {}", parent.display()))?;
543        }
544        let contents = serde_yml::to_string(self).context("Failed to serialize global config")?;
545        fs::write(&path, &contents)
546            .with_context(|| format!("Failed to write global config at {}", path.display()))?;
547        Ok(())
548    }
549}
550
551// ---------------------------------------------------------------------------
552// Identity resolution
553// ---------------------------------------------------------------------------
554
555/// Resolve the current user identity using a priority chain:
556///
557/// 1. Project config `user` field (from `.mana/config.yaml`)
558/// 2. Global config `user` field (from `~/.config/mana/config.yaml`)
559/// 3. `git config user.name` (fallback)
560/// 4. `$USER` environment variable (last resort)
561///
562/// Returns `None` only if all sources fail.
563pub fn resolve_identity(mana_dir: &Path) -> Option<String> {
564    // 1. Project config
565    if let Ok(config) = Config::load(mana_dir) {
566        if let Some(ref user) = config.user {
567            if !user.is_empty() {
568                return Some(user.clone());
569            }
570        }
571    }
572
573    // 2. Global config
574    if let Ok(global) = GlobalConfig::load() {
575        if let Some(ref user) = global.user {
576            if !user.is_empty() {
577                return Some(user.clone());
578            }
579        }
580    }
581
582    // 3. git config user.name
583    if let Some(git_user) = git_config_user_name() {
584        return Some(git_user);
585    }
586
587    // 4. $USER env var
588    std::env::var("USER").ok().filter(|u| !u.is_empty())
589}
590
591/// Try to get `git config user.name`. Returns None on failure.
592fn git_config_user_name() -> Option<String> {
593    Command::new("git")
594        .args(["config", "user.name"])
595        .output()
596        .ok()
597        .filter(|o| o.status.success())
598        .and_then(|o| String::from_utf8(o.stdout).ok())
599        .map(|s| s.trim().to_string())
600        .filter(|s| !s.is_empty())
601}
602
603#[cfg(test)]
604mod tests {
605    use super::*;
606    use std::fs;
607
608    #[test]
609    fn config_round_trips_through_yaml() {
610        let dir = tempfile::tempdir().unwrap();
611        let config = Config {
612            project: "test-project".to_string(),
613            next_id: 42,
614            auto_close_parent: true,
615            run: None,
616            plan: None,
617            max_loops: 10,
618            max_concurrent: 4,
619            poll_interval: 30,
620            extends: vec![],
621            rules_file: None,
622            file_locking: false,
623            worktree: false,
624            on_close: None,
625            on_fail: None,
626            post_plan: None,
627            verify_timeout: None,
628            review: None,
629            user: None,
630            user_email: None,
631            auto_commit: false,
632            commit_template: None,
633            research: None,
634            run_model: None,
635            plan_model: None,
636            review_model: None,
637            research_model: None,
638            batch_verify: false,
639            memory_reserve_mb: 0,
640            notify: None,
641        };
642
643        config.save(dir.path()).unwrap();
644        let loaded = Config::load(dir.path()).unwrap();
645
646        assert_eq!(config, loaded);
647    }
648
649    #[test]
650    fn increment_id_returns_current_and_bumps() {
651        let mut config = Config {
652            project: "test".to_string(),
653            next_id: 1,
654            auto_close_parent: true,
655            run: None,
656            plan: None,
657            max_loops: 10,
658            max_concurrent: 4,
659            poll_interval: 30,
660            extends: vec![],
661            rules_file: None,
662            file_locking: false,
663            worktree: false,
664            on_close: None,
665            on_fail: None,
666            post_plan: None,
667            verify_timeout: None,
668            review: None,
669            user: None,
670            user_email: None,
671            auto_commit: false,
672            commit_template: None,
673            research: None,
674            run_model: None,
675            plan_model: None,
676            review_model: None,
677            research_model: None,
678            batch_verify: false,
679            memory_reserve_mb: 0,
680            notify: None,
681        };
682
683        assert_eq!(config.increment_id(), 1);
684        assert_eq!(config.increment_id(), 2);
685        assert_eq!(config.increment_id(), 3);
686        assert_eq!(config.next_id, 4);
687    }
688
689    #[test]
690    fn load_returns_error_for_missing_file() {
691        let dir = tempfile::tempdir().unwrap();
692        let result = Config::load(dir.path());
693        assert!(result.is_err());
694    }
695
696    #[test]
697    fn load_returns_error_for_invalid_yaml() {
698        let dir = tempfile::tempdir().unwrap();
699        fs::write(dir.path().join("config.yaml"), "not: [valid: yaml: config").unwrap();
700        let result = Config::load(dir.path());
701        assert!(result.is_err());
702    }
703
704    #[test]
705    fn save_creates_file_that_is_valid_yaml() {
706        let dir = tempfile::tempdir().unwrap();
707        let config = Config {
708            project: "my-project".to_string(),
709            next_id: 100,
710            auto_close_parent: true,
711            run: None,
712            plan: None,
713            max_loops: 10,
714            max_concurrent: 4,
715            poll_interval: 30,
716            extends: vec![],
717            rules_file: None,
718            file_locking: false,
719            worktree: false,
720            on_close: None,
721            on_fail: None,
722            post_plan: None,
723            verify_timeout: None,
724            review: None,
725            user: None,
726            user_email: None,
727            auto_commit: false,
728            commit_template: None,
729            research: None,
730            run_model: None,
731            plan_model: None,
732            review_model: None,
733            research_model: None,
734            batch_verify: false,
735            memory_reserve_mb: 0,
736            notify: None,
737        };
738        config.save(dir.path()).unwrap();
739
740        let contents = fs::read_to_string(dir.path().join("config.yaml")).unwrap();
741        assert!(contents.contains("project: my-project"));
742        assert!(contents.contains("next_id: 100"));
743    }
744
745    #[test]
746    fn auto_close_parent_defaults_to_true() {
747        let dir = tempfile::tempdir().unwrap();
748        // Write a config WITHOUT auto_close_parent field
749        fs::write(
750            dir.path().join("config.yaml"),
751            "project: test\nnext_id: 1\n",
752        )
753        .unwrap();
754
755        let loaded = Config::load(dir.path()).unwrap();
756        assert!(loaded.auto_close_parent);
757    }
758
759    #[test]
760    fn auto_close_parent_can_be_disabled() {
761        let dir = tempfile::tempdir().unwrap();
762        let config = Config {
763            project: "test".to_string(),
764            next_id: 1,
765            auto_close_parent: false,
766            run: None,
767            plan: None,
768            max_loops: 10,
769            max_concurrent: 4,
770            poll_interval: 30,
771            extends: vec![],
772            rules_file: None,
773            file_locking: false,
774            worktree: false,
775            on_close: None,
776            on_fail: None,
777            post_plan: None,
778            verify_timeout: None,
779            review: None,
780            user: None,
781            user_email: None,
782            auto_commit: false,
783            commit_template: None,
784            research: None,
785            run_model: None,
786            plan_model: None,
787            review_model: None,
788            research_model: None,
789            batch_verify: false,
790            memory_reserve_mb: 0,
791            notify: None,
792        };
793        config.save(dir.path()).unwrap();
794
795        let loaded = Config::load(dir.path()).unwrap();
796        assert!(!loaded.auto_close_parent);
797    }
798
799    #[test]
800    fn max_tokens_in_yaml_silently_ignored() {
801        let dir = tempfile::tempdir().unwrap();
802        // Existing configs in the wild may have max_tokens — must not error
803        fs::write(
804            dir.path().join("config.yaml"),
805            "project: test\nnext_id: 1\nmax_tokens: 50000\n",
806        )
807        .unwrap();
808
809        let loaded = Config::load(dir.path()).unwrap();
810        assert_eq!(loaded.project, "test");
811    }
812
813    #[test]
814    fn run_defaults_to_none() {
815        let dir = tempfile::tempdir().unwrap();
816        fs::write(
817            dir.path().join("config.yaml"),
818            "project: test\nnext_id: 1\n",
819        )
820        .unwrap();
821
822        let loaded = Config::load(dir.path()).unwrap();
823        assert_eq!(loaded.run, None);
824    }
825
826    #[test]
827    fn run_can_be_set() {
828        let dir = tempfile::tempdir().unwrap();
829        let config = Config {
830            project: "test".to_string(),
831            next_id: 1,
832            auto_close_parent: true,
833            run: Some("claude -p 'implement unit {id}'".to_string()),
834            plan: None,
835            max_loops: 10,
836            max_concurrent: 4,
837            poll_interval: 30,
838            extends: vec![],
839            rules_file: None,
840            file_locking: false,
841            worktree: false,
842            on_close: None,
843            on_fail: None,
844            post_plan: None,
845            verify_timeout: None,
846            review: None,
847            user: None,
848            user_email: None,
849            auto_commit: false,
850            commit_template: None,
851            research: None,
852            run_model: None,
853            plan_model: None,
854            review_model: None,
855            research_model: None,
856            batch_verify: false,
857            memory_reserve_mb: 0,
858            notify: None,
859        };
860        config.save(dir.path()).unwrap();
861
862        let loaded = Config::load(dir.path()).unwrap();
863        assert_eq!(
864            loaded.run,
865            Some("claude -p 'implement unit {id}'".to_string())
866        );
867    }
868
869    #[test]
870    fn run_not_serialized_when_none() {
871        let dir = tempfile::tempdir().unwrap();
872        let config = Config {
873            project: "test".to_string(),
874            next_id: 1,
875            auto_close_parent: true,
876            run: None,
877            plan: None,
878            max_loops: 10,
879            max_concurrent: 4,
880            poll_interval: 30,
881            extends: vec![],
882            rules_file: None,
883            file_locking: false,
884            worktree: false,
885            on_close: None,
886            on_fail: None,
887            post_plan: None,
888            verify_timeout: None,
889            review: None,
890            user: None,
891            user_email: None,
892            auto_commit: false,
893            commit_template: None,
894            research: None,
895            run_model: None,
896            plan_model: None,
897            review_model: None,
898            research_model: None,
899            batch_verify: false,
900            memory_reserve_mb: 0,
901            notify: None,
902        };
903        config.save(dir.path()).unwrap();
904
905        let contents = fs::read_to_string(dir.path().join("config.yaml")).unwrap();
906        assert!(!contents.contains("run:"));
907    }
908
909    #[test]
910    fn max_loops_defaults_to_10() {
911        let dir = tempfile::tempdir().unwrap();
912        fs::write(
913            dir.path().join("config.yaml"),
914            "project: test\nnext_id: 1\n",
915        )
916        .unwrap();
917
918        let loaded = Config::load(dir.path()).unwrap();
919        assert_eq!(loaded.max_loops, 10);
920    }
921
922    #[test]
923    fn max_loops_can_be_customized() {
924        let dir = tempfile::tempdir().unwrap();
925        let config = Config {
926            project: "test".to_string(),
927            next_id: 1,
928            auto_close_parent: true,
929            run: None,
930            plan: None,
931            max_loops: 25,
932            max_concurrent: 4,
933            poll_interval: 30,
934            extends: vec![],
935            rules_file: None,
936            file_locking: false,
937            worktree: false,
938            on_close: None,
939            on_fail: None,
940            post_plan: None,
941            verify_timeout: None,
942            review: None,
943            user: None,
944            user_email: None,
945            auto_commit: false,
946            commit_template: None,
947            research: None,
948            run_model: None,
949            plan_model: None,
950            review_model: None,
951            research_model: None,
952            batch_verify: false,
953            memory_reserve_mb: 0,
954            notify: None,
955        };
956        config.save(dir.path()).unwrap();
957
958        let loaded = Config::load(dir.path()).unwrap();
959        assert_eq!(loaded.max_loops, 25);
960    }
961
962    // --- extends tests ---
963
964    /// Helper: write a YAML config file at the given path.
965    fn write_yaml(path: &std::path::Path, yaml: &str) {
966        if let Some(parent) = path.parent() {
967            fs::create_dir_all(parent).unwrap();
968        }
969        fs::write(path, yaml).unwrap();
970    }
971
972    /// Helper: write a minimal local config inside a units dir, with extends.
973    fn write_local_config(mana_dir: &std::path::Path, extends: &[&str], extra: &str) {
974        let extends_yaml: Vec<String> = extends.iter().map(|e| format!("  - \"{}\"", e)).collect();
975        let extends_block = if extends.is_empty() {
976            String::new()
977        } else {
978            format!("extends:\n{}\n", extends_yaml.join("\n"))
979        };
980        let yaml = format!("project: test\nnext_id: 1\n{}{}", extends_block, extra);
981        write_yaml(&mana_dir.join("config.yaml"), &yaml);
982    }
983
984    #[test]
985    fn extends_empty_loads_normally() {
986        let dir = tempfile::tempdir().unwrap();
987        let mana_dir = dir.path().join(".mana");
988        fs::create_dir_all(&mana_dir).unwrap();
989        write_local_config(&mana_dir, &[], "");
990
991        let config = Config::load_with_extends(&mana_dir).unwrap();
992        assert_eq!(config.project, "test");
993        assert!(config.run.is_none());
994    }
995
996    #[test]
997    fn extends_single_merges_fields() {
998        let dir = tempfile::tempdir().unwrap();
999        let mana_dir = dir.path().join(".mana");
1000        fs::create_dir_all(&mana_dir).unwrap();
1001
1002        // Parent config (outside .mana, at project root)
1003        let parent_path = dir.path().join("shared.yaml");
1004        write_yaml(
1005            &parent_path,
1006            "project: shared\nnext_id: 999\nrun: \"deli spawn {id}\"\nmax_loops: 20\n",
1007        );
1008
1009        write_local_config(&mana_dir, &["shared.yaml"], "");
1010
1011        let config = Config::load_with_extends(&mana_dir).unwrap();
1012        // Inherited
1013        assert_eq!(config.run, Some("deli spawn {id}".to_string()));
1014        assert_eq!(config.max_loops, 20);
1015        // Never inherited
1016        assert_eq!(config.project, "test");
1017        assert_eq!(config.next_id, 1);
1018    }
1019
1020    #[test]
1021    fn extends_local_overrides_parent() {
1022        let dir = tempfile::tempdir().unwrap();
1023        let mana_dir = dir.path().join(".mana");
1024        fs::create_dir_all(&mana_dir).unwrap();
1025
1026        let parent_path = dir.path().join("shared.yaml");
1027        write_yaml(
1028            &parent_path,
1029            "project: shared\nnext_id: 999\nrun: \"parent-run\"\nmax_loops: 20\n",
1030        );
1031
1032        // Local config sets its own run
1033        write_local_config(
1034            &mana_dir,
1035            &["shared.yaml"],
1036            "run: \"local-run\"\nmax_loops: 5\n",
1037        );
1038
1039        let config = Config::load_with_extends(&mana_dir).unwrap();
1040        // Local values win
1041        assert_eq!(config.run, Some("local-run".to_string()));
1042        assert_eq!(config.max_loops, 5);
1043    }
1044
1045    #[test]
1046    fn extends_circular_detected_and_skipped() {
1047        let dir = tempfile::tempdir().unwrap();
1048        let mana_dir = dir.path().join(".mana");
1049        fs::create_dir_all(&mana_dir).unwrap();
1050
1051        // A extends B, B extends A
1052        let a_path = dir.path().join("a.yaml");
1053        let b_path = dir.path().join("b.yaml");
1054        write_yaml(
1055            &a_path,
1056            "project: a\nnext_id: 1\nextends:\n  - \"b.yaml\"\nmax_loops: 40\n",
1057        );
1058        write_yaml(
1059            &b_path,
1060            "project: b\nnext_id: 1\nextends:\n  - \"a.yaml\"\nmax_loops: 50\n",
1061        );
1062
1063        write_local_config(&mana_dir, &["a.yaml"], "");
1064
1065        // Should not infinite loop; loads successfully
1066        let config = Config::load_with_extends(&mana_dir).unwrap();
1067        assert_eq!(config.project, "test");
1068        // Gets value from one of the parents
1069        assert!(config.max_loops == 40 || config.max_loops == 50);
1070    }
1071
1072    #[test]
1073    fn extends_missing_file_errors() {
1074        let dir = tempfile::tempdir().unwrap();
1075        let mana_dir = dir.path().join(".mana");
1076        fs::create_dir_all(&mana_dir).unwrap();
1077
1078        write_local_config(&mana_dir, &["nonexistent.yaml"], "");
1079
1080        let result = Config::load_with_extends(&mana_dir);
1081        assert!(result.is_err());
1082        let err_msg = format!("{}", result.unwrap_err());
1083        assert!(
1084            err_msg.contains("nonexistent.yaml"),
1085            "Error should mention the missing file: {}",
1086            err_msg
1087        );
1088    }
1089
1090    #[test]
1091    fn extends_recursive_a_extends_b_extends_c() {
1092        let dir = tempfile::tempdir().unwrap();
1093        let mana_dir = dir.path().join(".mana");
1094        fs::create_dir_all(&mana_dir).unwrap();
1095
1096        // C: base config
1097        let c_path = dir.path().join("c.yaml");
1098        write_yaml(
1099            &c_path,
1100            "project: c\nnext_id: 1\nrun: \"from-c\"\nmax_loops: 40\n",
1101        );
1102
1103        // B extends C, overrides max_loops
1104        let b_path = dir.path().join("b.yaml");
1105        write_yaml(
1106            &b_path,
1107            "project: b\nnext_id: 1\nextends:\n  - \"c.yaml\"\nmax_loops: 50\n",
1108        );
1109
1110        // Local extends B
1111        write_local_config(&mana_dir, &["b.yaml"], "");
1112
1113        let config = Config::load_with_extends(&mana_dir).unwrap();
1114        // B's max_loops (50) should apply since it's the direct parent
1115        assert_eq!(config.max_loops, 50);
1116        // run comes from C (B doesn't set it, but C does)
1117        assert_eq!(config.run, Some("from-c".to_string()));
1118    }
1119
1120    #[test]
1121    fn extends_project_and_next_id_never_inherited() {
1122        let dir = tempfile::tempdir().unwrap();
1123        let mana_dir = dir.path().join(".mana");
1124        fs::create_dir_all(&mana_dir).unwrap();
1125
1126        let parent_path = dir.path().join("shared.yaml");
1127        write_yaml(
1128            &parent_path,
1129            "project: parent-project\nnext_id: 999\nmax_loops: 50\n",
1130        );
1131
1132        write_local_config(&mana_dir, &["shared.yaml"], "");
1133
1134        let config = Config::load_with_extends(&mana_dir).unwrap();
1135        assert_eq!(config.project, "test");
1136        assert_eq!(config.next_id, 1);
1137    }
1138
1139    #[test]
1140    fn extends_tilde_resolves_to_home_dir() {
1141        // We can't fully test ~ expansion without writing to the real home dir,
1142        // but we can verify the path resolution logic.
1143        let mana_dir = std::path::Path::new("/tmp/fake-units");
1144        let resolved = Config::resolve_extends_path("~/shared/config.yaml", mana_dir).unwrap();
1145        let home = dirs::home_dir().unwrap();
1146        assert_eq!(resolved, home.join("shared/config.yaml"));
1147    }
1148
1149    #[test]
1150    fn extends_not_serialized_when_empty() {
1151        let dir = tempfile::tempdir().unwrap();
1152        let config = Config {
1153            project: "test".to_string(),
1154            next_id: 1,
1155            auto_close_parent: true,
1156            run: None,
1157            plan: None,
1158            max_loops: 10,
1159            max_concurrent: 4,
1160            poll_interval: 30,
1161            extends: vec![],
1162            rules_file: None,
1163            file_locking: false,
1164            worktree: false,
1165            on_close: None,
1166            on_fail: None,
1167            post_plan: None,
1168            verify_timeout: None,
1169            review: None,
1170            user: None,
1171            user_email: None,
1172            auto_commit: false,
1173            commit_template: None,
1174            research: None,
1175            run_model: None,
1176            plan_model: None,
1177            review_model: None,
1178            research_model: None,
1179            batch_verify: false,
1180            memory_reserve_mb: 0,
1181            notify: None,
1182        };
1183        config.save(dir.path()).unwrap();
1184
1185        let contents = fs::read_to_string(dir.path().join("config.yaml")).unwrap();
1186        assert!(!contents.contains("extends"));
1187    }
1188
1189    #[test]
1190    fn extends_defaults_to_empty() {
1191        let dir = tempfile::tempdir().unwrap();
1192        fs::write(
1193            dir.path().join("config.yaml"),
1194            "project: test\nnext_id: 1\n",
1195        )
1196        .unwrap();
1197
1198        let loaded = Config::load(dir.path()).unwrap();
1199        assert!(loaded.extends.is_empty());
1200    }
1201
1202    // --- plan, max_concurrent, poll_interval tests ---
1203
1204    #[test]
1205    fn plan_defaults_to_none() {
1206        let dir = tempfile::tempdir().unwrap();
1207        fs::write(
1208            dir.path().join("config.yaml"),
1209            "project: test\nnext_id: 1\n",
1210        )
1211        .unwrap();
1212
1213        let loaded = Config::load(dir.path()).unwrap();
1214        assert_eq!(loaded.plan, None);
1215    }
1216
1217    #[test]
1218    fn plan_can_be_set() {
1219        let dir = tempfile::tempdir().unwrap();
1220        let config = Config {
1221            project: "test".to_string(),
1222            next_id: 1,
1223            auto_close_parent: true,
1224            run: None,
1225            plan: Some("claude -p 'plan unit {id}'".to_string()),
1226            max_loops: 10,
1227            max_concurrent: 4,
1228            poll_interval: 30,
1229            extends: vec![],
1230            rules_file: None,
1231            file_locking: false,
1232            worktree: false,
1233            on_close: None,
1234            on_fail: None,
1235            post_plan: None,
1236            verify_timeout: None,
1237            review: None,
1238            user: None,
1239            user_email: None,
1240            auto_commit: false,
1241            commit_template: None,
1242            research: None,
1243            run_model: None,
1244            plan_model: None,
1245            review_model: None,
1246            research_model: None,
1247            batch_verify: false,
1248            memory_reserve_mb: 0,
1249            notify: None,
1250        };
1251        config.save(dir.path()).unwrap();
1252
1253        let loaded = Config::load(dir.path()).unwrap();
1254        assert_eq!(loaded.plan, Some("claude -p 'plan unit {id}'".to_string()));
1255    }
1256
1257    #[test]
1258    fn plan_not_serialized_when_none() {
1259        let dir = tempfile::tempdir().unwrap();
1260        let config = Config {
1261            project: "test".to_string(),
1262            next_id: 1,
1263            auto_close_parent: true,
1264            run: None,
1265            plan: None,
1266            max_loops: 10,
1267            max_concurrent: 4,
1268            poll_interval: 30,
1269            extends: vec![],
1270            rules_file: None,
1271            file_locking: false,
1272            worktree: false,
1273            on_close: None,
1274            on_fail: None,
1275            post_plan: None,
1276            verify_timeout: None,
1277            review: None,
1278            user: None,
1279            user_email: None,
1280            auto_commit: false,
1281            commit_template: None,
1282            research: None,
1283            run_model: None,
1284            plan_model: None,
1285            review_model: None,
1286            research_model: None,
1287            batch_verify: false,
1288            memory_reserve_mb: 0,
1289            notify: None,
1290        };
1291        config.save(dir.path()).unwrap();
1292
1293        let contents = fs::read_to_string(dir.path().join("config.yaml")).unwrap();
1294        assert!(!contents.contains("plan:"));
1295    }
1296
1297    #[test]
1298    fn max_concurrent_defaults_to_4() {
1299        let dir = tempfile::tempdir().unwrap();
1300        fs::write(
1301            dir.path().join("config.yaml"),
1302            "project: test\nnext_id: 1\n",
1303        )
1304        .unwrap();
1305
1306        let loaded = Config::load(dir.path()).unwrap();
1307        assert_eq!(loaded.max_concurrent, 4);
1308    }
1309
1310    #[test]
1311    fn max_concurrent_can_be_customized() {
1312        let dir = tempfile::tempdir().unwrap();
1313        let config = Config {
1314            project: "test".to_string(),
1315            next_id: 1,
1316            auto_close_parent: true,
1317            run: None,
1318            plan: None,
1319            max_loops: 10,
1320            max_concurrent: 8,
1321            poll_interval: 30,
1322            extends: vec![],
1323            rules_file: None,
1324            file_locking: false,
1325            worktree: false,
1326            on_close: None,
1327            on_fail: None,
1328            post_plan: None,
1329            verify_timeout: None,
1330            review: None,
1331            user: None,
1332            user_email: None,
1333            auto_commit: false,
1334            commit_template: None,
1335            research: None,
1336            run_model: None,
1337            plan_model: None,
1338            review_model: None,
1339            research_model: None,
1340            batch_verify: false,
1341            memory_reserve_mb: 0,
1342            notify: None,
1343        };
1344        config.save(dir.path()).unwrap();
1345
1346        let loaded = Config::load(dir.path()).unwrap();
1347        assert_eq!(loaded.max_concurrent, 8);
1348    }
1349
1350    #[test]
1351    fn poll_interval_defaults_to_30() {
1352        let dir = tempfile::tempdir().unwrap();
1353        fs::write(
1354            dir.path().join("config.yaml"),
1355            "project: test\nnext_id: 1\n",
1356        )
1357        .unwrap();
1358
1359        let loaded = Config::load(dir.path()).unwrap();
1360        assert_eq!(loaded.poll_interval, 30);
1361    }
1362
1363    #[test]
1364    fn poll_interval_can_be_customized() {
1365        let dir = tempfile::tempdir().unwrap();
1366        let config = Config {
1367            project: "test".to_string(),
1368            next_id: 1,
1369            auto_close_parent: true,
1370            run: None,
1371            plan: None,
1372            max_loops: 10,
1373            max_concurrent: 4,
1374            poll_interval: 60,
1375            extends: vec![],
1376            rules_file: None,
1377            file_locking: false,
1378            worktree: false,
1379            on_close: None,
1380            on_fail: None,
1381            post_plan: None,
1382            verify_timeout: None,
1383            review: None,
1384            user: None,
1385            user_email: None,
1386            auto_commit: false,
1387            commit_template: None,
1388            research: None,
1389            run_model: None,
1390            plan_model: None,
1391            review_model: None,
1392            research_model: None,
1393            batch_verify: false,
1394            memory_reserve_mb: 0,
1395            notify: None,
1396        };
1397        config.save(dir.path()).unwrap();
1398
1399        let loaded = Config::load(dir.path()).unwrap();
1400        assert_eq!(loaded.poll_interval, 60);
1401    }
1402
1403    #[test]
1404    fn extends_inherits_plan() {
1405        let dir = tempfile::tempdir().unwrap();
1406        let mana_dir = dir.path().join(".mana");
1407        fs::create_dir_all(&mana_dir).unwrap();
1408
1409        let parent_path = dir.path().join("shared.yaml");
1410        write_yaml(
1411            &parent_path,
1412            "project: shared\nnext_id: 999\nplan: \"plan-cmd {id}\"\n",
1413        );
1414
1415        write_local_config(&mana_dir, &["shared.yaml"], "");
1416
1417        let config = Config::load_with_extends(&mana_dir).unwrap();
1418        assert_eq!(config.plan, Some("plan-cmd {id}".to_string()));
1419    }
1420
1421    #[test]
1422    fn extends_inherits_max_concurrent() {
1423        let dir = tempfile::tempdir().unwrap();
1424        let mana_dir = dir.path().join(".mana");
1425        fs::create_dir_all(&mana_dir).unwrap();
1426
1427        let parent_path = dir.path().join("shared.yaml");
1428        write_yaml(
1429            &parent_path,
1430            "project: shared\nnext_id: 999\nmax_concurrent: 16\n",
1431        );
1432
1433        write_local_config(&mana_dir, &["shared.yaml"], "");
1434
1435        let config = Config::load_with_extends(&mana_dir).unwrap();
1436        assert_eq!(config.max_concurrent, 16);
1437    }
1438
1439    #[test]
1440    fn extends_inherits_poll_interval() {
1441        let dir = tempfile::tempdir().unwrap();
1442        let mana_dir = dir.path().join(".mana");
1443        fs::create_dir_all(&mana_dir).unwrap();
1444
1445        let parent_path = dir.path().join("shared.yaml");
1446        write_yaml(
1447            &parent_path,
1448            "project: shared\nnext_id: 999\npoll_interval: 120\n",
1449        );
1450
1451        write_local_config(&mana_dir, &["shared.yaml"], "");
1452
1453        let config = Config::load_with_extends(&mana_dir).unwrap();
1454        assert_eq!(config.poll_interval, 120);
1455    }
1456
1457    #[test]
1458    fn extends_local_overrides_new_fields() {
1459        let dir = tempfile::tempdir().unwrap();
1460        let mana_dir = dir.path().join(".mana");
1461        fs::create_dir_all(&mana_dir).unwrap();
1462
1463        let parent_path = dir.path().join("shared.yaml");
1464        write_yaml(
1465            &parent_path,
1466            "project: shared\nnext_id: 999\nplan: \"parent-plan\"\nmax_concurrent: 16\npoll_interval: 120\n",
1467        );
1468
1469        write_local_config(
1470            &mana_dir,
1471            &["shared.yaml"],
1472            "plan: \"local-plan\"\nmax_concurrent: 2\npoll_interval: 10\n",
1473        );
1474
1475        let config = Config::load_with_extends(&mana_dir).unwrap();
1476        assert_eq!(config.plan, Some("local-plan".to_string()));
1477        assert_eq!(config.max_concurrent, 2);
1478        assert_eq!(config.poll_interval, 10);
1479    }
1480
1481    #[test]
1482    fn new_fields_round_trip_through_yaml() {
1483        let dir = tempfile::tempdir().unwrap();
1484        let config = Config {
1485            project: "test".to_string(),
1486            next_id: 1,
1487            auto_close_parent: true,
1488            run: None,
1489            plan: Some("plan {id}".to_string()),
1490            max_loops: 10,
1491            max_concurrent: 8,
1492            poll_interval: 60,
1493            extends: vec![],
1494            rules_file: None,
1495            file_locking: false,
1496            worktree: false,
1497            on_close: None,
1498            on_fail: None,
1499            post_plan: None,
1500            verify_timeout: None,
1501            review: None,
1502            user: None,
1503            user_email: None,
1504            auto_commit: false,
1505            commit_template: None,
1506            research: None,
1507            run_model: None,
1508            plan_model: None,
1509            review_model: None,
1510            research_model: None,
1511            batch_verify: false,
1512            memory_reserve_mb: 0,
1513            notify: None,
1514        };
1515
1516        config.save(dir.path()).unwrap();
1517        let loaded = Config::load(dir.path()).unwrap();
1518
1519        assert_eq!(config, loaded);
1520    }
1521
1522    #[test]
1523    fn batch_verify_defaults_to_false() {
1524        let dir = tempfile::tempdir().unwrap();
1525        fs::write(
1526            dir.path().join("config.yaml"),
1527            "project: test\nnext_id: 1\n",
1528        )
1529        .unwrap();
1530
1531        let loaded = Config::load(dir.path()).unwrap();
1532        assert!(!loaded.batch_verify);
1533    }
1534
1535    #[test]
1536    fn batch_verify_can_be_enabled() {
1537        let dir = tempfile::tempdir().unwrap();
1538        fs::write(
1539            dir.path().join("config.yaml"),
1540            "project: test\nnext_id: 1\nbatch_verify: true\n",
1541        )
1542        .unwrap();
1543
1544        let loaded = Config::load(dir.path()).unwrap();
1545        assert!(loaded.batch_verify);
1546    }
1547
1548    #[test]
1549    fn batch_verify_not_serialized_when_false() {
1550        let dir = tempfile::tempdir().unwrap();
1551        fs::write(
1552            dir.path().join("config.yaml"),
1553            "project: test\nnext_id: 1\n",
1554        )
1555        .unwrap();
1556
1557        let loaded = Config::load(dir.path()).unwrap();
1558        assert!(!loaded.batch_verify);
1559
1560        loaded.save(dir.path()).unwrap();
1561        let contents = fs::read_to_string(dir.path().join("config.yaml")).unwrap();
1562        assert!(!contents.contains("batch_verify"));
1563    }
1564}