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