Skip to main content

task_graph_mcp/config/
loader.rs

1//! Configuration loader with tier-based merging.
2//!
3//! Loads configuration from multiple tiers and merges them field-by-field.
4
5use super::merge::deep_merge_all;
6use super::types::{Config, Prompts};
7use anyhow::Result;
8use serde_json::Value;
9use std::path::{Path, PathBuf};
10use tracing::warn;
11
12/// Configuration tier priority (lowest to highest).
13#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
14pub enum ConfigTier {
15    /// Embedded defaults (lowest priority)
16    Defaults = 0,
17    /// Project-level config ($CWD/task-graph/ or .task-graph/)
18    Project = 1,
19    /// User-level config (~/.task-graph/)
20    User = 2,
21    /// Environment variables (highest priority)
22    Environment = 3,
23}
24
25impl std::fmt::Display for ConfigTier {
26    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
27        match self {
28            ConfigTier::Defaults => write!(f, "defaults"),
29            ConfigTier::Project => write!(f, "project"),
30            ConfigTier::User => write!(f, "user"),
31            ConfigTier::Environment => write!(f, "environment"),
32        }
33    }
34}
35
36/// Paths for each configuration tier.
37#[derive(Debug, Clone)]
38pub struct ConfigPaths {
39    /// Embedded defaults directory (not a real path, but conceptual)
40    pub defaults_dir: Option<PathBuf>,
41    /// Install/package config directory (e.g., $CWD/config/ for built-in workflows)
42    pub install_dir: Option<PathBuf>,
43    /// Project-level config directory
44    pub project_dir: Option<PathBuf>,
45    /// Deprecated project-level config directory (.task-graph)
46    pub project_dir_deprecated: Option<PathBuf>,
47    /// User-level config directory
48    pub user_dir: Option<PathBuf>,
49}
50
51impl Default for ConfigPaths {
52    fn default() -> Self {
53        Self::discover()
54    }
55}
56
57/// Walk up from CWD looking for an existing directory with the given name.
58/// Returns an absolute path if found, otherwise falls back to a relative path
59/// (creating it in CWD on first use). This lets agents running in
60/// subdirectories (e.g., git worktrees) share the project root's task-graph
61/// directory. Works regardless of source control system.
62fn find_project_dir(dir_name: &str) -> Option<PathBuf> {
63    let cwd = std::env::current_dir().ok()?;
64    find_project_dir_from(dir_name, &cwd, None)
65}
66
67/// Walk up from `start_dir` looking for a directory named `dir_name`.
68/// Searches at most `max_depth` ancestor levels (None = unlimited).
69/// Returns the first match found, or falls back to a relative `dir_name` path.
70fn find_project_dir_from(
71    dir_name: &str,
72    start_dir: &Path,
73    max_depth: Option<usize>,
74) -> Option<PathBuf> {
75    let mut search = start_dir;
76    let mut depth = 0;
77    loop {
78        if max_depth.is_some_and(|max| depth > max) {
79            break;
80        }
81        let candidate = search.join(dir_name);
82        if candidate.is_dir() {
83            if candidate != start_dir.join(dir_name) {
84                tracing::info!(
85                    "Found '{}' at {} (resolved from {})",
86                    dir_name,
87                    candidate.display(),
88                    start_dir.display()
89                );
90            }
91            return Some(candidate);
92        }
93        match search.parent() {
94            Some(parent) => {
95                search = parent;
96                depth += 1;
97            }
98            None => break,
99        }
100    }
101    // Not found anywhere — fall back to relative (will be created in start_dir)
102    Some(PathBuf::from(dir_name))
103}
104
105impl ConfigPaths {
106    /// Discover configuration paths from environment and defaults.
107    pub fn discover() -> Self {
108        // User dir: TASK_GRAPH_USER_DIR or ~/.task-graph
109        let user_dir = std::env::var("TASK_GRAPH_USER_DIR")
110            .ok()
111            .map(PathBuf::from)
112            .or_else(|| dirs::home_dir().map(|h| h.join(".task-graph")));
113
114        // Project dir: TASK_GRAPH_PROJECT_DIR or walk up from CWD to find task-graph/
115        let project_dir = std::env::var("TASK_GRAPH_PROJECT_DIR")
116            .ok()
117            .map(PathBuf::from)
118            .or_else(|| find_project_dir("task-graph"));
119
120        // Deprecated project dir: walk up for .task-graph/
121        let project_dir_deprecated = find_project_dir(".task-graph");
122
123        // Install dir: TASK_GRAPH_INSTALL_DIR or walk up for config/
124        let install_dir = std::env::var("TASK_GRAPH_INSTALL_DIR")
125            .ok()
126            .map(PathBuf::from)
127            .or_else(|| find_project_dir("config"));
128
129        Self {
130            defaults_dir: None, // Defaults are embedded, not on disk
131            install_dir,
132            project_dir,
133            project_dir_deprecated,
134            user_dir,
135        }
136    }
137
138    /// Create paths with explicit directories.
139    /// Does not include install_dir (use with_all_dirs for full control).
140    pub fn with_dirs(project_dir: Option<PathBuf>, user_dir: Option<PathBuf>) -> Self {
141        Self {
142            defaults_dir: None,
143            install_dir: None, // Not included for test isolation
144            project_dir,
145            project_dir_deprecated: Some(PathBuf::from(".task-graph")),
146            user_dir,
147        }
148    }
149
150    /// Create paths with all directories explicitly specified.
151    pub fn with_all_dirs(
152        install_dir: Option<PathBuf>,
153        project_dir: Option<PathBuf>,
154        user_dir: Option<PathBuf>,
155    ) -> Self {
156        Self {
157            defaults_dir: None,
158            install_dir,
159            project_dir,
160            project_dir_deprecated: Some(PathBuf::from(".task-graph")),
161            user_dir,
162        }
163    }
164
165    /// Get the effective project directory (prefers new location, falls back to deprecated).
166    pub fn effective_project_dir(&self) -> Option<&Path> {
167        // Check new location first
168        if let Some(ref dir) = self.project_dir
169            && dir.exists()
170        {
171            return Some(dir);
172        }
173
174        // Fall back to deprecated location
175        if let Some(ref dir) = self.project_dir_deprecated
176            && dir.exists()
177        {
178            return Some(dir);
179        }
180
181        // If neither exists, prefer new location for creation
182        self.project_dir.as_deref()
183    }
184
185    /// Check if using deprecated project directory.
186    pub fn is_using_deprecated(&self) -> bool {
187        if let Some(ref new_dir) = self.project_dir
188            && new_dir.exists()
189        {
190            return false;
191        }
192
193        if let Some(ref dep_dir) = self.project_dir_deprecated {
194            return dep_dir.exists();
195        }
196
197        false
198    }
199}
200
201/// Configuration loader that handles tier-based merging.
202#[derive(Debug, Clone)]
203pub struct ConfigLoader {
204    /// Paths for each tier
205    pub paths: ConfigPaths,
206    /// Loaded configuration
207    config: Config,
208    /// Path to the config file that was used (if any)
209    config_path: Option<PathBuf>,
210    /// Whether deprecated paths are in use
211    using_deprecated: bool,
212}
213
214impl ConfigLoader {
215    /// Load configuration from all tiers with proper merging.
216    pub fn load() -> Result<Self> {
217        Self::load_with_paths(ConfigPaths::discover())
218    }
219
220    /// Load configuration with explicit paths.
221    pub fn load_with_paths(paths: ConfigPaths) -> Result<Self> {
222        let using_deprecated = paths.is_using_deprecated();
223
224        if using_deprecated {
225            warn!(
226                "Using deprecated config directory '.task-graph/'. \
227                 Run 'task-graph migrate' to move to 'task-graph/'."
228            );
229        }
230
231        // Check for explicit config path override
232        if let Ok(explicit_path) = std::env::var("TASK_GRAPH_CONFIG_PATH") {
233            let path = PathBuf::from(&explicit_path);
234            let config = Config::load(&path)?;
235            return Ok(Self {
236                paths,
237                config,
238                config_path: Some(path),
239                using_deprecated,
240            });
241        }
242
243        // Collect configs from each tier
244        let mut configs: Vec<Value> = Vec::new();
245
246        // Tier 1: Defaults (embedded)
247        let default_config = Config::default();
248        if let Ok(default_json) = serde_json::to_value(&default_config) {
249            configs.push(default_json);
250        }
251
252        // Tier 2: Project config
253        let mut project_config_path = None;
254        if let Some(project_dir) = paths.effective_project_dir() {
255            let config_file = project_dir.join("config.yaml");
256            if config_file.exists()
257                && let Ok(content) = std::fs::read_to_string(&config_file)
258                && let Ok(yaml_value) = serde_yaml::from_str::<Value>(&content)
259            {
260                configs.push(yaml_value);
261                project_config_path = Some(config_file);
262            }
263        }
264
265        // Tier 3: User config
266        if let Some(ref user_dir) = paths.user_dir {
267            let config_file = user_dir.join("config.yaml");
268            if config_file.exists()
269                && let Ok(content) = std::fs::read_to_string(&config_file)
270                && let Ok(yaml_value) = serde_yaml::from_str::<Value>(&content)
271            {
272                configs.push(yaml_value);
273            }
274        }
275
276        // Merge all configs
277        let merged = deep_merge_all(configs);
278        let mut config: Config = serde_json::from_value(merged)?;
279
280        // Tier 4: Environment variable overrides
281        Self::apply_env_overrides(&mut config);
282
283        // Resolve relative server paths against the discovered project root.
284        // This ensures agents running in subdirectories (e.g., git worktrees)
285        // share the project's database instead of creating a new one.
286        if let Some(project_dir) = paths.effective_project_dir()
287            && project_dir.is_absolute()
288            && let Some(project_root) = project_dir.parent()
289        {
290            Self::resolve_server_paths(&mut config, project_root);
291        }
292
293        Ok(Self {
294            paths,
295            config,
296            config_path: project_config_path,
297            using_deprecated,
298        })
299    }
300
301    /// Apply environment variable overrides to config.
302    /// If server paths (db_path, media_dir, etc.) are relative, resolve them
303    /// against the given project root so agents in subdirectories use the same DB.
304    fn resolve_server_paths(config: &mut Config, project_root: &Path) {
305        let resolve = |p: &mut PathBuf| {
306            if p.is_relative() {
307                *p = project_root.join(&*p);
308            }
309        };
310        resolve(&mut config.server.db_path);
311        resolve(&mut config.server.media_dir);
312        resolve(&mut config.server.log_dir);
313        resolve(&mut config.server.skills_dir);
314    }
315
316    fn apply_env_overrides(config: &mut Config) {
317        if let Ok(db_path) = std::env::var("TASK_GRAPH_DB_PATH") {
318            config.server.db_path = PathBuf::from(db_path);
319        }
320
321        if let Ok(media_dir) = std::env::var("TASK_GRAPH_MEDIA_DIR") {
322            config.server.media_dir = PathBuf::from(media_dir);
323        }
324
325        if let Ok(log_dir) = std::env::var("TASK_GRAPH_LOG_DIR") {
326            config.server.log_dir = PathBuf::from(log_dir);
327        }
328
329        if let Ok(skills_dir) = std::env::var("TASK_GRAPH_SKILLS_DIR") {
330            config.server.skills_dir = PathBuf::from(skills_dir);
331        }
332    }
333
334    /// Load prompts configuration with tier merging.
335    pub fn load_prompts(&self) -> Prompts {
336        let mut prompts_configs: Vec<Value> = Vec::new();
337
338        // Tier 1: Defaults (empty)
339        if let Ok(default_json) = serde_json::to_value(Prompts::default()) {
340            prompts_configs.push(default_json);
341        }
342
343        // Tier 2: Project prompts
344        if let Some(project_dir) = self.paths.effective_project_dir() {
345            let prompts_file = project_dir.join("prompts.yaml");
346            if prompts_file.exists()
347                && let Ok(content) = std::fs::read_to_string(&prompts_file)
348                && let Ok(yaml_value) = serde_yaml::from_str::<Value>(&content)
349            {
350                prompts_configs.push(yaml_value);
351            }
352        }
353
354        // Tier 3: User prompts
355        if let Some(ref user_dir) = self.paths.user_dir {
356            let prompts_file = user_dir.join("prompts.yaml");
357            if prompts_file.exists()
358                && let Ok(content) = std::fs::read_to_string(&prompts_file)
359                && let Ok(yaml_value) = serde_yaml::from_str::<Value>(&content)
360            {
361                prompts_configs.push(yaml_value);
362            }
363        }
364
365        // Merge and deserialize
366        let merged = deep_merge_all(prompts_configs);
367        serde_json::from_value(merged).unwrap_or_default()
368    }
369
370    /// Load workflows configuration with tier merging.
371    ///
372    /// Loads from embedded defaults, then project workflows.yaml, then user workflows.yaml.
373    /// Later tiers override earlier ones (objects are deep-merged, prompts are replaced).
374    pub fn load_workflows(&self) -> super::workflows::WorkflowsConfig {
375        let mut workflows_configs: Vec<Value> = Vec::new();
376
377        // Tier 1: Defaults (embedded)
378        if let Ok(default_json) = serde_json::to_value(super::workflows::WorkflowsConfig::default())
379        {
380            workflows_configs.push(default_json);
381        }
382
383        // Tier 2: Project workflows
384        if let Some(project_dir) = self.paths.effective_project_dir() {
385            let workflows_file = project_dir.join("workflows.yaml");
386            if workflows_file.exists()
387                && let Ok(content) = std::fs::read_to_string(&workflows_file)
388                && let Ok(yaml_value) = serde_yaml::from_str::<Value>(&content)
389            {
390                workflows_configs.push(yaml_value);
391            }
392        }
393
394        // Tier 3: User workflows
395        if let Some(ref user_dir) = self.paths.user_dir {
396            let workflows_file = user_dir.join("workflows.yaml");
397            if workflows_file.exists()
398                && let Ok(content) = std::fs::read_to_string(&workflows_file)
399                && let Ok(yaml_value) = serde_yaml::from_str::<Value>(&content)
400            {
401                workflows_configs.push(yaml_value);
402            }
403        }
404
405        // Merge and deserialize
406        let merged = deep_merge_all(workflows_configs);
407        serde_json::from_value(merged).unwrap_or_default()
408    }
409
410    /// Get the loaded configuration.
411    pub fn config(&self) -> &Config {
412        &self.config
413    }
414
415    /// Get mutable access to the configuration.
416    pub fn config_mut(&mut self) -> &mut Config {
417        &mut self.config
418    }
419
420    /// Consume the loader and return the configuration.
421    pub fn into_config(self) -> Config {
422        self.config
423    }
424
425    /// Get the config file path that was used.
426    pub fn config_path(&self) -> Option<&Path> {
427        self.config_path.as_deref()
428    }
429
430    /// Check if using deprecated paths.
431    pub fn is_using_deprecated(&self) -> bool {
432        self.using_deprecated
433    }
434
435    /// Get the effective project directory.
436    pub fn project_dir(&self) -> Option<&Path> {
437        self.paths.effective_project_dir()
438    }
439
440    /// Get the user directory.
441    pub fn user_dir(&self) -> Option<&Path> {
442        self.paths.user_dir.as_deref()
443    }
444
445    /// Get the skills directory, checking all tiers.
446    pub fn skills_dir(&self) -> PathBuf {
447        // Environment override takes precedence
448        if let Ok(skills_dir) = std::env::var("TASK_GRAPH_SKILLS_DIR") {
449            return PathBuf::from(skills_dir);
450        }
451
452        // Check project dir
453        if let Some(project_dir) = self.paths.effective_project_dir() {
454            let skills_dir = project_dir.join("skills");
455            if skills_dir.exists() {
456                return skills_dir;
457            }
458        }
459
460        // Use config default
461        self.config.server.skills_dir.clone()
462    }
463
464    /// Load a named workflow file (workflow-{name}.yaml).
465    ///
466    /// Searches in order: user directory, project directory, install directory, embedded.
467    /// User overrides project, project overrides install, install overrides embedded.
468    /// Returns the merged workflow config (defaults + named workflow).
469    pub fn load_workflow_by_name(&self, name: &str) -> Result<super::workflows::WorkflowsConfig> {
470        let filename = format!("workflow-{}.yaml", name);
471
472        // Check user directory first (highest priority)
473        if let Some(ref user_dir) = self.paths.user_dir {
474            let workflow_file = user_dir.join(&filename);
475            if workflow_file.exists() {
476                return self.load_workflow_from_path(&workflow_file);
477            }
478        }
479
480        // Check project directory second
481        if let Some(project_dir) = self.paths.effective_project_dir() {
482            let workflow_file = project_dir.join(&filename);
483            if workflow_file.exists() {
484                return self.load_workflow_from_path(&workflow_file);
485            }
486        }
487
488        // Check install directory (built-in defaults on disk)
489        if let Some(ref install_dir) = self.paths.install_dir {
490            let workflow_file = install_dir.join(&filename);
491            if workflow_file.exists() {
492                return self.load_workflow_from_path(&workflow_file);
493            }
494        }
495
496        // Fall back to embedded workflows (always available)
497        if let Some(content) = super::embedded::workflows::get(name) {
498            return self.load_workflow_from_content(content, name);
499        }
500
501        Err(anyhow::anyhow!(
502            "Workflow '{}' not found. Searched for '{}' in user, project, install directories, and embedded defaults.",
503            name,
504            filename
505        ))
506    }
507
508    /// Load workflow from a specific path, merging with defaults.
509    fn load_workflow_from_path(&self, path: &Path) -> Result<super::workflows::WorkflowsConfig> {
510        let content = std::fs::read_to_string(path)?;
511        let yaml_value: Value = serde_yaml::from_str(&content)?;
512
513        // Start with defaults and merge the named workflow on top
514        let mut configs: Vec<Value> = Vec::new();
515
516        // Tier 1: Defaults
517        if let Ok(default_json) = serde_json::to_value(super::workflows::WorkflowsConfig::default())
518        {
519            configs.push(default_json);
520        }
521
522        // Tier 2: The named workflow file
523        configs.push(yaml_value);
524
525        let merged = deep_merge_all(configs);
526        let mut workflow: super::workflows::WorkflowsConfig = serde_json::from_value(merged)?;
527
528        // Populate source_file (not serialized, so must be set after deserialization)
529        workflow.source_file = Some(path.to_path_buf());
530
531        Ok(workflow)
532    }
533
534    /// Load workflow from embedded content string, merging with defaults.
535    fn load_workflow_from_content(
536        &self,
537        content: &str,
538        name: &str,
539    ) -> Result<super::workflows::WorkflowsConfig> {
540        let yaml_value: Value = serde_yaml::from_str(content)?;
541
542        // Start with defaults and merge the named workflow on top
543        let mut configs: Vec<Value> = Vec::new();
544
545        // Tier 1: Defaults
546        if let Ok(default_json) = serde_json::to_value(super::workflows::WorkflowsConfig::default())
547        {
548            configs.push(default_json);
549        }
550
551        // Tier 2: The embedded workflow content
552        configs.push(yaml_value);
553
554        let merged = deep_merge_all(configs);
555        let mut workflow: super::workflows::WorkflowsConfig = serde_json::from_value(merged)?;
556
557        // Mark as embedded (no file path)
558        workflow.source_file = None;
559        // Store embedded source indicator
560        workflow.description = workflow
561            .description
562            .or_else(|| Some(format!("Built-in workflow: {}", name)));
563
564        Ok(workflow)
565    }
566
567    /// List available named workflows.
568    ///
569    /// Returns workflow names (e.g., "solo", "swarm") found in user, project, install directories,
570    /// and embedded defaults. Embedded workflows are always available as fallbacks.
571    pub fn list_workflows(&self) -> Vec<String> {
572        let mut workflows = Vec::new();
573
574        // Check user directory
575        if let Some(ref user_dir) = self.paths.user_dir
576            && let Ok(entries) = std::fs::read_dir(user_dir)
577        {
578            for entry in entries.filter_map(|e| e.ok()) {
579                if let Some(name) = Self::extract_workflow_name(&entry.path())
580                    && !workflows.contains(&name)
581                {
582                    workflows.push(name);
583                }
584            }
585        }
586
587        // Check project directory
588        if let Some(project_dir) = self.paths.effective_project_dir()
589            && let Ok(entries) = std::fs::read_dir(project_dir)
590        {
591            for entry in entries.filter_map(|e| e.ok()) {
592                if let Some(name) = Self::extract_workflow_name(&entry.path())
593                    && !workflows.contains(&name)
594                {
595                    workflows.push(name);
596                }
597            }
598        }
599
600        // Check install directory (built-in workflows on disk)
601        if let Some(ref install_dir) = self.paths.install_dir
602            && let Ok(entries) = std::fs::read_dir(install_dir)
603        {
604            for entry in entries.filter_map(|e| e.ok()) {
605                if let Some(name) = Self::extract_workflow_name(&entry.path())
606                    && !workflows.contains(&name)
607                {
608                    workflows.push(name);
609                }
610            }
611        }
612
613        // Include embedded workflows (always available as fallbacks)
614        for name in super::embedded::workflows::names() {
615            if !workflows.contains(&name.to_string()) {
616                workflows.push(name.to_string());
617            }
618        }
619
620        workflows.sort();
621        workflows
622    }
623
624    /// Extract workflow name from a path like "workflow-swarm.yaml" -> "swarm".
625    fn extract_workflow_name(path: &Path) -> Option<String> {
626        let filename = path.file_name()?.to_str()?;
627        if filename.starts_with("workflow-") && filename.ends_with(".yaml") {
628            let name = filename.strip_prefix("workflow-")?.strip_suffix(".yaml")?;
629            if !name.is_empty() {
630                return Some(name.to_string());
631            }
632        }
633        None
634    }
635
636    /// List available overlay files (overlay-*.yaml).
637    ///
638    /// Returns overlay names (e.g., "git", "troubleshooting") found in user, project, install directories,
639    /// and embedded defaults. Embedded overlays are always available as fallbacks.
640    pub fn list_overlays(&self) -> Vec<String> {
641        let mut overlays = Vec::new();
642
643        // Check user directory
644        if let Some(ref user_dir) = self.paths.user_dir
645            && let Ok(entries) = std::fs::read_dir(user_dir)
646        {
647            for entry in entries.filter_map(|e| e.ok()) {
648                if let Some(name) = Self::extract_overlay_name(&entry.path())
649                    && !overlays.contains(&name)
650                {
651                    overlays.push(name);
652                }
653            }
654        }
655
656        // Check project directory
657        if let Some(project_dir) = self.paths.effective_project_dir()
658            && let Ok(entries) = std::fs::read_dir(project_dir)
659        {
660            for entry in entries.filter_map(|e| e.ok()) {
661                if let Some(name) = Self::extract_overlay_name(&entry.path())
662                    && !overlays.contains(&name)
663                {
664                    overlays.push(name);
665                }
666            }
667        }
668
669        // Check install directory (built-in overlays on disk)
670        if let Some(ref install_dir) = self.paths.install_dir
671            && let Ok(entries) = std::fs::read_dir(install_dir)
672        {
673            for entry in entries.filter_map(|e| e.ok()) {
674                if let Some(name) = Self::extract_overlay_name(&entry.path())
675                    && !overlays.contains(&name)
676                {
677                    overlays.push(name);
678                }
679            }
680        }
681
682        // Include embedded overlays (always available as fallbacks)
683        for name in super::embedded::overlays::names() {
684            if !overlays.contains(&name.to_string()) {
685                overlays.push(name.to_string());
686            }
687        }
688
689        overlays.sort();
690        overlays
691    }
692
693    /// Load an overlay by name (overlay-{name}.yaml).
694    ///
695    /// Unlike `load_workflow_by_name`, overlays are loaded as raw deltas WITHOUT
696    /// merging with defaults. This prevents double-appending prompts when
697    /// the overlay is later applied via `apply_overlay()`.
698    ///
699    /// Searches in order: user directory, project directory, install directory, embedded.
700    pub fn load_overlay_by_name(&self, name: &str) -> Result<super::workflows::WorkflowsConfig> {
701        let filename = format!("overlay-{}.yaml", name);
702
703        // Check user directory first (highest priority)
704        if let Some(ref user_dir) = self.paths.user_dir {
705            let overlay_file = user_dir.join(&filename);
706            if overlay_file.exists() {
707                return self.load_overlay_from_path(&overlay_file);
708            }
709        }
710
711        // Check project directory second
712        if let Some(project_dir) = self.paths.effective_project_dir() {
713            let overlay_file = project_dir.join(&filename);
714            if overlay_file.exists() {
715                return self.load_overlay_from_path(&overlay_file);
716            }
717        }
718
719        // Check install directory (built-in defaults on disk)
720        if let Some(ref install_dir) = self.paths.install_dir {
721            let overlay_file = install_dir.join(&filename);
722            if overlay_file.exists() {
723                return self.load_overlay_from_path(&overlay_file);
724            }
725        }
726
727        // Fall back to embedded overlays (always available)
728        if let Some(content) = super::embedded::overlays::get(name) {
729            return self.load_overlay_from_content(content);
730        }
731
732        Err(anyhow::anyhow!(
733            "Overlay '{}' not found. Searched for '{}' in user, project, install directories, and embedded defaults.",
734            name,
735            filename
736        ))
737    }
738
739    /// Load an overlay from a specific path WITHOUT merging with defaults.
740    /// This is the critical difference from `load_workflow_from_path`.
741    fn load_overlay_from_path(&self, path: &Path) -> Result<super::workflows::WorkflowsConfig> {
742        let content = std::fs::read_to_string(path)?;
743        let mut overlay: super::workflows::WorkflowsConfig = serde_yaml::from_str(&content)?;
744        overlay.source_file = Some(path.to_path_buf());
745        Ok(overlay)
746    }
747
748    /// Load an overlay from embedded content WITHOUT merging with defaults.
749    fn load_overlay_from_content(
750        &self,
751        content: &str,
752    ) -> Result<super::workflows::WorkflowsConfig> {
753        let overlay: super::workflows::WorkflowsConfig = serde_yaml::from_str(content)?;
754        // No source_file for embedded content
755        Ok(overlay)
756    }
757
758    /// Extract overlay name from a path like "overlay-git.yaml" -> "git".
759    fn extract_overlay_name(path: &Path) -> Option<String> {
760        let filename = path.file_name()?.to_str()?;
761        if filename.starts_with("overlay-") && filename.ends_with(".yaml") {
762            let name = filename.strip_prefix("overlay-")?.strip_suffix(".yaml")?;
763            if !name.is_empty() {
764                return Some(name.to_string());
765            }
766        }
767        None
768    }
769}
770
771#[cfg(test)]
772mod tests {
773    use super::*;
774    use tempfile::TempDir;
775
776    #[test]
777    fn test_config_paths_discover() {
778        let paths = ConfigPaths::discover();
779        assert!(paths.project_dir.is_some());
780        // user_dir may or may not exist depending on environment
781    }
782
783    #[test]
784    fn test_load_defaults_only() {
785        // Create empty temp dirs so no config files are found
786        let temp = TempDir::new().unwrap();
787        let paths = ConfigPaths::with_dirs(
788            Some(temp.path().join("project")),
789            Some(temp.path().join("user")),
790        );
791
792        let loader = ConfigLoader::load_with_paths(paths).unwrap();
793        let config = loader.config();
794
795        // Should have default values
796        assert_eq!(config.server.claim_limit, 5);
797        assert_eq!(config.server.stale_timeout_seconds, 900);
798    }
799
800    #[test]
801    fn test_project_config_overrides_defaults() {
802        let temp = TempDir::new().unwrap();
803        let project_dir = temp.path().join("task-graph");
804        std::fs::create_dir_all(&project_dir).unwrap();
805
806        // Create project config that overrides claim_limit
807        let config_content = r#"
808server:
809  claim_limit: 10
810"#;
811        std::fs::write(project_dir.join("config.yaml"), config_content).unwrap();
812
813        let paths = ConfigPaths::with_dirs(Some(project_dir), Some(temp.path().join("user")));
814
815        let loader = ConfigLoader::load_with_paths(paths).unwrap();
816        let config = loader.config();
817
818        // claim_limit should be overridden
819        assert_eq!(config.server.claim_limit, 10);
820        // stale_timeout_seconds should be default
821        assert_eq!(config.server.stale_timeout_seconds, 900);
822    }
823
824    #[test]
825    fn test_user_config_overrides_project() {
826        let temp = TempDir::new().unwrap();
827        let project_dir = temp.path().join("task-graph");
828        let user_dir = temp.path().join("user");
829        std::fs::create_dir_all(&project_dir).unwrap();
830        std::fs::create_dir_all(&user_dir).unwrap();
831
832        // Project config
833        let project_config = r#"
834server:
835  claim_limit: 10
836  stale_timeout_seconds: 600
837"#;
838        std::fs::write(project_dir.join("config.yaml"), project_config).unwrap();
839
840        // User config overrides claim_limit
841        let user_config = r#"
842server:
843  claim_limit: 20
844"#;
845        std::fs::write(user_dir.join("config.yaml"), user_config).unwrap();
846
847        let paths = ConfigPaths::with_dirs(Some(project_dir), Some(user_dir));
848
849        let loader = ConfigLoader::load_with_paths(paths).unwrap();
850        let config = loader.config();
851
852        // claim_limit should be from user
853        assert_eq!(config.server.claim_limit, 20);
854        // stale_timeout_seconds should be from project
855        assert_eq!(config.server.stale_timeout_seconds, 600);
856    }
857
858    /// Verifies that `find_project_dir_from` walks up from a subdirectory to find
859    /// a `task-graph/` directory in an ancestor. This is the core mechanism that
860    /// enables agents running in git worktrees or project subdirectories to share
861    /// the parent project's database and configuration.
862    #[test]
863    fn find_project_dir_from_subdirectory() {
864        let temp = TempDir::new().unwrap();
865        let root = temp.path();
866
867        // Create project structure:
868        //   root/
869        //     task-graph/         ← the project config dir
870        //     src/
871        //       deep/
872        //         nested/        ← agent runs from here
873        let project_dir = root.join("task-graph");
874        let nested_dir = root.join("src").join("deep").join("nested");
875        std::fs::create_dir_all(&project_dir).unwrap();
876        std::fs::create_dir_all(&nested_dir).unwrap();
877
878        // Starting from nested subdir (3 levels deep), should find task-graph/ in root
879        let found = find_project_dir_from("task-graph", &nested_dir, Some(5));
880        assert_eq!(found, Some(project_dir));
881    }
882
883    /// Verifies that `find_project_dir_from` finds the nearest ancestor's directory,
884    /// not a more distant one. This matters for nested project structures.
885    #[test]
886    fn find_project_dir_from_finds_nearest_ancestor() {
887        let temp = TempDir::new().unwrap();
888        let root = temp.path();
889
890        // Create nested project structure:
891        //   root/
892        //     task-graph/              ← outer (should NOT be found)
893        //     subproject/
894        //       task-graph/            ← inner (should be found)
895        //       src/                   ← agent runs from here
896        let outer = root.join("task-graph");
897        let inner_project = root.join("subproject");
898        let inner = inner_project.join("task-graph");
899        let working_dir = inner_project.join("src");
900        std::fs::create_dir_all(&outer).unwrap();
901        std::fs::create_dir_all(&inner).unwrap();
902        std::fs::create_dir_all(&working_dir).unwrap();
903
904        let found = find_project_dir_from("task-graph", &working_dir, Some(3));
905        assert_eq!(found, Some(inner));
906    }
907
908    /// Verifies that when max_depth is too shallow to reach the target,
909    /// the function falls back to a relative path.
910    #[test]
911    fn find_project_dir_from_respects_max_depth() {
912        let temp = TempDir::new().unwrap();
913        let root = temp.path();
914
915        // Create: root/task-graph/ and root/a/b/c/ (3 levels deep)
916        let project_dir = root.join("task-graph");
917        let deep_dir = root.join("a").join("b").join("c");
918        std::fs::create_dir_all(&project_dir).unwrap();
919        std::fs::create_dir_all(&deep_dir).unwrap();
920
921        // Depth 2 can't reach root from a/b/c (needs 3 hops)
922        let not_found = find_project_dir_from("task-graph", &deep_dir, Some(2));
923        assert_eq!(not_found, Some(PathBuf::from("task-graph")));
924
925        // Depth 3 can reach it
926        let found = find_project_dir_from("task-graph", &deep_dir, Some(3));
927        assert_eq!(found, Some(project_dir));
928    }
929
930    /// Verifies that when no ancestor has the target directory, the function
931    /// falls back to a relative path (for creation in the starting directory).
932    #[test]
933    fn find_project_dir_from_falls_back_when_not_found() {
934        let temp = TempDir::new().unwrap();
935        let empty_dir = temp.path().join("empty");
936        std::fs::create_dir_all(&empty_dir).unwrap();
937
938        // max_depth=0 means only check start_dir itself
939        let found = find_project_dir_from("task-graph", &empty_dir, Some(0));
940        assert_eq!(found, Some(PathBuf::from("task-graph")));
941    }
942}