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
57impl ConfigPaths {
58    /// Discover configuration paths from environment and defaults.
59    pub fn discover() -> Self {
60        // User dir: TASK_GRAPH_USER_DIR or ~/.task-graph
61        let user_dir = std::env::var("TASK_GRAPH_USER_DIR")
62            .ok()
63            .map(PathBuf::from)
64            .or_else(|| dirs::home_dir().map(|h| h.join(".task-graph")));
65
66        // Project dir: TASK_GRAPH_PROJECT_DIR or $CWD/task-graph
67        let project_dir = std::env::var("TASK_GRAPH_PROJECT_DIR")
68            .ok()
69            .map(PathBuf::from)
70            .or_else(|| Some(PathBuf::from("task-graph")));
71
72        // Deprecated project dir: $CWD/.task-graph
73        let project_dir_deprecated = Some(PathBuf::from(".task-graph"));
74
75        // Install dir: TASK_GRAPH_INSTALL_DIR or $CWD/config (for built-in workflows)
76        let install_dir = std::env::var("TASK_GRAPH_INSTALL_DIR")
77            .ok()
78            .map(PathBuf::from)
79            .or_else(|| Some(PathBuf::from("config")));
80
81        Self {
82            defaults_dir: None, // Defaults are embedded, not on disk
83            install_dir,
84            project_dir,
85            project_dir_deprecated,
86            user_dir,
87        }
88    }
89
90    /// Create paths with explicit directories.
91    /// Does not include install_dir (use with_all_dirs for full control).
92    pub fn with_dirs(project_dir: Option<PathBuf>, user_dir: Option<PathBuf>) -> Self {
93        Self {
94            defaults_dir: None,
95            install_dir: None, // Not included for test isolation
96            project_dir,
97            project_dir_deprecated: Some(PathBuf::from(".task-graph")),
98            user_dir,
99        }
100    }
101
102    /// Create paths with all directories explicitly specified.
103    pub fn with_all_dirs(
104        install_dir: Option<PathBuf>,
105        project_dir: Option<PathBuf>,
106        user_dir: Option<PathBuf>,
107    ) -> Self {
108        Self {
109            defaults_dir: None,
110            install_dir,
111            project_dir,
112            project_dir_deprecated: Some(PathBuf::from(".task-graph")),
113            user_dir,
114        }
115    }
116
117    /// Get the effective project directory (prefers new location, falls back to deprecated).
118    pub fn effective_project_dir(&self) -> Option<&Path> {
119        // Check new location first
120        if let Some(ref dir) = self.project_dir
121            && dir.exists()
122        {
123            return Some(dir);
124        }
125
126        // Fall back to deprecated location
127        if let Some(ref dir) = self.project_dir_deprecated
128            && dir.exists()
129        {
130            return Some(dir);
131        }
132
133        // If neither exists, prefer new location for creation
134        self.project_dir.as_deref()
135    }
136
137    /// Check if using deprecated project directory.
138    pub fn is_using_deprecated(&self) -> bool {
139        if let Some(ref new_dir) = self.project_dir
140            && new_dir.exists()
141        {
142            return false;
143        }
144
145        if let Some(ref dep_dir) = self.project_dir_deprecated {
146            return dep_dir.exists();
147        }
148
149        false
150    }
151}
152
153/// Configuration loader that handles tier-based merging.
154#[derive(Debug, Clone)]
155pub struct ConfigLoader {
156    /// Paths for each tier
157    pub paths: ConfigPaths,
158    /// Loaded configuration
159    config: Config,
160    /// Path to the config file that was used (if any)
161    config_path: Option<PathBuf>,
162    /// Whether deprecated paths are in use
163    using_deprecated: bool,
164}
165
166impl ConfigLoader {
167    /// Load configuration from all tiers with proper merging.
168    pub fn load() -> Result<Self> {
169        Self::load_with_paths(ConfigPaths::discover())
170    }
171
172    /// Load configuration with explicit paths.
173    pub fn load_with_paths(paths: ConfigPaths) -> Result<Self> {
174        let using_deprecated = paths.is_using_deprecated();
175
176        if using_deprecated {
177            warn!(
178                "Using deprecated config directory '.task-graph/'. \
179                 Run 'task-graph migrate' to move to 'task-graph/'."
180            );
181        }
182
183        // Check for explicit config path override
184        if let Ok(explicit_path) = std::env::var("TASK_GRAPH_CONFIG_PATH") {
185            let path = PathBuf::from(&explicit_path);
186            let config = Config::load(&path)?;
187            return Ok(Self {
188                paths,
189                config,
190                config_path: Some(path),
191                using_deprecated,
192            });
193        }
194
195        // Collect configs from each tier
196        let mut configs: Vec<Value> = Vec::new();
197
198        // Tier 1: Defaults (embedded)
199        let default_config = Config::default();
200        if let Ok(default_json) = serde_json::to_value(&default_config) {
201            configs.push(default_json);
202        }
203
204        // Tier 2: Project config
205        let mut project_config_path = None;
206        if let Some(project_dir) = paths.effective_project_dir() {
207            let config_file = project_dir.join("config.yaml");
208            if config_file.exists()
209                && let Ok(content) = std::fs::read_to_string(&config_file)
210                && let Ok(yaml_value) = serde_yaml::from_str::<Value>(&content)
211            {
212                configs.push(yaml_value);
213                project_config_path = Some(config_file);
214            }
215        }
216
217        // Tier 3: User config
218        if let Some(ref user_dir) = paths.user_dir {
219            let config_file = user_dir.join("config.yaml");
220            if config_file.exists()
221                && let Ok(content) = std::fs::read_to_string(&config_file)
222                && let Ok(yaml_value) = serde_yaml::from_str::<Value>(&content)
223            {
224                configs.push(yaml_value);
225            }
226        }
227
228        // Merge all configs
229        let merged = deep_merge_all(configs);
230        let mut config: Config = serde_json::from_value(merged)?;
231
232        // Tier 4: Environment variable overrides
233        Self::apply_env_overrides(&mut config);
234
235        Ok(Self {
236            paths,
237            config,
238            config_path: project_config_path,
239            using_deprecated,
240        })
241    }
242
243    /// Apply environment variable overrides to config.
244    fn apply_env_overrides(config: &mut Config) {
245        if let Ok(db_path) = std::env::var("TASK_GRAPH_DB_PATH") {
246            config.server.db_path = PathBuf::from(db_path);
247        }
248
249        if let Ok(media_dir) = std::env::var("TASK_GRAPH_MEDIA_DIR") {
250            config.server.media_dir = PathBuf::from(media_dir);
251        }
252
253        if let Ok(log_dir) = std::env::var("TASK_GRAPH_LOG_DIR") {
254            config.server.log_dir = PathBuf::from(log_dir);
255        }
256
257        if let Ok(skills_dir) = std::env::var("TASK_GRAPH_SKILLS_DIR") {
258            config.server.skills_dir = PathBuf::from(skills_dir);
259        }
260    }
261
262    /// Load prompts configuration with tier merging.
263    pub fn load_prompts(&self) -> Prompts {
264        let mut prompts_configs: Vec<Value> = Vec::new();
265
266        // Tier 1: Defaults (empty)
267        if let Ok(default_json) = serde_json::to_value(Prompts::default()) {
268            prompts_configs.push(default_json);
269        }
270
271        // Tier 2: Project prompts
272        if let Some(project_dir) = self.paths.effective_project_dir() {
273            let prompts_file = project_dir.join("prompts.yaml");
274            if prompts_file.exists()
275                && let Ok(content) = std::fs::read_to_string(&prompts_file)
276                && let Ok(yaml_value) = serde_yaml::from_str::<Value>(&content)
277            {
278                prompts_configs.push(yaml_value);
279            }
280        }
281
282        // Tier 3: User prompts
283        if let Some(ref user_dir) = self.paths.user_dir {
284            let prompts_file = user_dir.join("prompts.yaml");
285            if prompts_file.exists()
286                && let Ok(content) = std::fs::read_to_string(&prompts_file)
287                && let Ok(yaml_value) = serde_yaml::from_str::<Value>(&content)
288            {
289                prompts_configs.push(yaml_value);
290            }
291        }
292
293        // Merge and deserialize
294        let merged = deep_merge_all(prompts_configs);
295        serde_json::from_value(merged).unwrap_or_default()
296    }
297
298    /// Load workflows configuration with tier merging.
299    ///
300    /// Loads from embedded defaults, then project workflows.yaml, then user workflows.yaml.
301    /// Later tiers override earlier ones (objects are deep-merged, prompts are replaced).
302    pub fn load_workflows(&self) -> super::workflows::WorkflowsConfig {
303        let mut workflows_configs: Vec<Value> = Vec::new();
304
305        // Tier 1: Defaults (embedded)
306        if let Ok(default_json) = serde_json::to_value(super::workflows::WorkflowsConfig::default())
307        {
308            workflows_configs.push(default_json);
309        }
310
311        // Tier 2: Project workflows
312        if let Some(project_dir) = self.paths.effective_project_dir() {
313            let workflows_file = project_dir.join("workflows.yaml");
314            if workflows_file.exists()
315                && let Ok(content) = std::fs::read_to_string(&workflows_file)
316                && let Ok(yaml_value) = serde_yaml::from_str::<Value>(&content)
317            {
318                workflows_configs.push(yaml_value);
319            }
320        }
321
322        // Tier 3: User workflows
323        if let Some(ref user_dir) = self.paths.user_dir {
324            let workflows_file = user_dir.join("workflows.yaml");
325            if workflows_file.exists()
326                && let Ok(content) = std::fs::read_to_string(&workflows_file)
327                && let Ok(yaml_value) = serde_yaml::from_str::<Value>(&content)
328            {
329                workflows_configs.push(yaml_value);
330            }
331        }
332
333        // Merge and deserialize
334        let merged = deep_merge_all(workflows_configs);
335        serde_json::from_value(merged).unwrap_or_default()
336    }
337
338    /// Get the loaded configuration.
339    pub fn config(&self) -> &Config {
340        &self.config
341    }
342
343    /// Get mutable access to the configuration.
344    pub fn config_mut(&mut self) -> &mut Config {
345        &mut self.config
346    }
347
348    /// Consume the loader and return the configuration.
349    pub fn into_config(self) -> Config {
350        self.config
351    }
352
353    /// Get the config file path that was used.
354    pub fn config_path(&self) -> Option<&Path> {
355        self.config_path.as_deref()
356    }
357
358    /// Check if using deprecated paths.
359    pub fn is_using_deprecated(&self) -> bool {
360        self.using_deprecated
361    }
362
363    /// Get the effective project directory.
364    pub fn project_dir(&self) -> Option<&Path> {
365        self.paths.effective_project_dir()
366    }
367
368    /// Get the user directory.
369    pub fn user_dir(&self) -> Option<&Path> {
370        self.paths.user_dir.as_deref()
371    }
372
373    /// Get the skills directory, checking all tiers.
374    pub fn skills_dir(&self) -> PathBuf {
375        // Environment override takes precedence
376        if let Ok(skills_dir) = std::env::var("TASK_GRAPH_SKILLS_DIR") {
377            return PathBuf::from(skills_dir);
378        }
379
380        // Check project dir
381        if let Some(project_dir) = self.paths.effective_project_dir() {
382            let skills_dir = project_dir.join("skills");
383            if skills_dir.exists() {
384                return skills_dir;
385            }
386        }
387
388        // Use config default
389        self.config.server.skills_dir.clone()
390    }
391
392    /// Load a named workflow file (workflow-{name}.yaml).
393    ///
394    /// Searches in order: user directory, project directory, install directory.
395    /// User overrides project, project overrides install defaults.
396    /// Returns the merged workflow config (defaults + named workflow).
397    pub fn load_workflow_by_name(&self, name: &str) -> Result<super::workflows::WorkflowsConfig> {
398        let filename = format!("workflow-{}.yaml", name);
399
400        // Check user directory first (highest priority)
401        if let Some(ref user_dir) = self.paths.user_dir {
402            let workflow_file = user_dir.join(&filename);
403            if workflow_file.exists() {
404                return self.load_workflow_from_path(&workflow_file);
405            }
406        }
407
408        // Check project directory second
409        if let Some(project_dir) = self.paths.effective_project_dir() {
410            let workflow_file = project_dir.join(&filename);
411            if workflow_file.exists() {
412                return self.load_workflow_from_path(&workflow_file);
413            }
414        }
415
416        // Fall back to install directory (built-in defaults)
417        if let Some(ref install_dir) = self.paths.install_dir {
418            let workflow_file = install_dir.join(&filename);
419            if workflow_file.exists() {
420                return self.load_workflow_from_path(&workflow_file);
421            }
422        }
423
424        Err(anyhow::anyhow!(
425            "Workflow '{}' not found. Searched for '{}' in user, project, and install directories.",
426            name,
427            filename
428        ))
429    }
430
431    /// Load workflow from a specific path, merging with defaults.
432    fn load_workflow_from_path(&self, path: &Path) -> Result<super::workflows::WorkflowsConfig> {
433        let content = std::fs::read_to_string(path)?;
434        let yaml_value: Value = serde_yaml::from_str(&content)?;
435
436        // Start with defaults and merge the named workflow on top
437        let mut configs: Vec<Value> = Vec::new();
438
439        // Tier 1: Defaults
440        if let Ok(default_json) = serde_json::to_value(super::workflows::WorkflowsConfig::default())
441        {
442            configs.push(default_json);
443        }
444
445        // Tier 2: The named workflow file
446        configs.push(yaml_value);
447
448        let merged = deep_merge_all(configs);
449        let mut workflow: super::workflows::WorkflowsConfig = serde_json::from_value(merged)?;
450
451        // Populate source_file (not serialized, so must be set after deserialization)
452        workflow.source_file = Some(path.to_path_buf());
453
454        Ok(workflow)
455    }
456
457    /// List available named workflows.
458    ///
459    /// Returns workflow names (e.g., "solo", "swarm") found in user, project, and install directories.
460    pub fn list_workflows(&self) -> Vec<String> {
461        let mut workflows = Vec::new();
462
463        // Check user directory
464        if let Some(ref user_dir) = self.paths.user_dir
465            && let Ok(entries) = std::fs::read_dir(user_dir)
466        {
467            for entry in entries.filter_map(|e| e.ok()) {
468                if let Some(name) = Self::extract_workflow_name(&entry.path())
469                    && !workflows.contains(&name)
470                {
471                    workflows.push(name);
472                }
473            }
474        }
475
476        // Check project directory
477        if let Some(project_dir) = self.paths.effective_project_dir()
478            && let Ok(entries) = std::fs::read_dir(project_dir)
479        {
480            for entry in entries.filter_map(|e| e.ok()) {
481                if let Some(name) = Self::extract_workflow_name(&entry.path())
482                    && !workflows.contains(&name)
483                {
484                    workflows.push(name);
485                }
486            }
487        }
488
489        // Check install directory (built-in workflows)
490        if let Some(ref install_dir) = self.paths.install_dir
491            && let Ok(entries) = std::fs::read_dir(install_dir)
492        {
493            for entry in entries.filter_map(|e| e.ok()) {
494                if let Some(name) = Self::extract_workflow_name(&entry.path())
495                    && !workflows.contains(&name)
496                {
497                    workflows.push(name);
498                }
499            }
500        }
501
502        workflows.sort();
503        workflows
504    }
505
506    /// Extract workflow name from a path like "workflow-swarm.yaml" -> "swarm".
507    fn extract_workflow_name(path: &Path) -> Option<String> {
508        let filename = path.file_name()?.to_str()?;
509        if filename.starts_with("workflow-") && filename.ends_with(".yaml") {
510            let name = filename.strip_prefix("workflow-")?.strip_suffix(".yaml")?;
511            if !name.is_empty() {
512                return Some(name.to_string());
513            }
514        }
515        None
516    }
517
518    /// List available overlay files (overlay-*.yaml).
519    ///
520    /// Returns overlay names (e.g., "git", "user-request") found in user, project, and install directories.
521    pub fn list_overlays(&self) -> Vec<String> {
522        let mut overlays = Vec::new();
523
524        // Check user directory
525        if let Some(ref user_dir) = self.paths.user_dir
526            && let Ok(entries) = std::fs::read_dir(user_dir)
527        {
528            for entry in entries.filter_map(|e| e.ok()) {
529                if let Some(name) = Self::extract_overlay_name(&entry.path())
530                    && !overlays.contains(&name)
531                {
532                    overlays.push(name);
533                }
534            }
535        }
536
537        // Check project directory
538        if let Some(project_dir) = self.paths.effective_project_dir()
539            && let Ok(entries) = std::fs::read_dir(project_dir)
540        {
541            for entry in entries.filter_map(|e| e.ok()) {
542                if let Some(name) = Self::extract_overlay_name(&entry.path())
543                    && !overlays.contains(&name)
544                {
545                    overlays.push(name);
546                }
547            }
548        }
549
550        // Check install directory (built-in overlays)
551        if let Some(ref install_dir) = self.paths.install_dir
552            && let Ok(entries) = std::fs::read_dir(install_dir)
553        {
554            for entry in entries.filter_map(|e| e.ok()) {
555                if let Some(name) = Self::extract_overlay_name(&entry.path())
556                    && !overlays.contains(&name)
557                {
558                    overlays.push(name);
559                }
560            }
561        }
562
563        overlays.sort();
564        overlays
565    }
566
567    /// Load an overlay by name (overlay-{name}.yaml).
568    ///
569    /// Unlike `load_workflow_by_name`, overlays are loaded as raw deltas WITHOUT
570    /// merging with defaults. This prevents double-appending prompts when
571    /// the overlay is later applied via `apply_overlay()`.
572    pub fn load_overlay_by_name(&self, name: &str) -> Result<super::workflows::WorkflowsConfig> {
573        let filename = format!("overlay-{}.yaml", name);
574
575        // Check user directory first (highest priority)
576        if let Some(ref user_dir) = self.paths.user_dir {
577            let overlay_file = user_dir.join(&filename);
578            if overlay_file.exists() {
579                return self.load_overlay_from_path(&overlay_file);
580            }
581        }
582
583        // Check project directory second
584        if let Some(project_dir) = self.paths.effective_project_dir() {
585            let overlay_file = project_dir.join(&filename);
586            if overlay_file.exists() {
587                return self.load_overlay_from_path(&overlay_file);
588            }
589        }
590
591        // Fall back to install directory (built-in defaults)
592        if let Some(ref install_dir) = self.paths.install_dir {
593            let overlay_file = install_dir.join(&filename);
594            if overlay_file.exists() {
595                return self.load_overlay_from_path(&overlay_file);
596            }
597        }
598
599        Err(anyhow::anyhow!(
600            "Overlay '{}' not found. Searched for '{}' in user, project, and install directories.",
601            name,
602            filename
603        ))
604    }
605
606    /// Load an overlay from a specific path WITHOUT merging with defaults.
607    /// This is the critical difference from `load_workflow_from_path`.
608    fn load_overlay_from_path(&self, path: &Path) -> Result<super::workflows::WorkflowsConfig> {
609        let content = std::fs::read_to_string(path)?;
610        let mut overlay: super::workflows::WorkflowsConfig = serde_yaml::from_str(&content)?;
611        overlay.source_file = Some(path.to_path_buf());
612        Ok(overlay)
613    }
614
615    /// Extract overlay name from a path like "overlay-git.yaml" -> "git".
616    fn extract_overlay_name(path: &Path) -> Option<String> {
617        let filename = path.file_name()?.to_str()?;
618        if filename.starts_with("overlay-") && filename.ends_with(".yaml") {
619            let name = filename.strip_prefix("overlay-")?.strip_suffix(".yaml")?;
620            if !name.is_empty() {
621                return Some(name.to_string());
622            }
623        }
624        None
625    }
626}
627
628#[cfg(test)]
629mod tests {
630    use super::*;
631    use tempfile::TempDir;
632
633    #[test]
634    fn test_config_paths_discover() {
635        let paths = ConfigPaths::discover();
636        assert!(paths.project_dir.is_some());
637        // user_dir may or may not exist depending on environment
638    }
639
640    #[test]
641    fn test_load_defaults_only() {
642        // Create empty temp dirs so no config files are found
643        let temp = TempDir::new().unwrap();
644        let paths = ConfigPaths::with_dirs(
645            Some(temp.path().join("project")),
646            Some(temp.path().join("user")),
647        );
648
649        let loader = ConfigLoader::load_with_paths(paths).unwrap();
650        let config = loader.config();
651
652        // Should have default values
653        assert_eq!(config.server.claim_limit, 5);
654        assert_eq!(config.server.stale_timeout_seconds, 900);
655    }
656
657    #[test]
658    fn test_project_config_overrides_defaults() {
659        let temp = TempDir::new().unwrap();
660        let project_dir = temp.path().join("task-graph");
661        std::fs::create_dir_all(&project_dir).unwrap();
662
663        // Create project config that overrides claim_limit
664        let config_content = r#"
665server:
666  claim_limit: 10
667"#;
668        std::fs::write(project_dir.join("config.yaml"), config_content).unwrap();
669
670        let paths = ConfigPaths::with_dirs(Some(project_dir), Some(temp.path().join("user")));
671
672        let loader = ConfigLoader::load_with_paths(paths).unwrap();
673        let config = loader.config();
674
675        // claim_limit should be overridden
676        assert_eq!(config.server.claim_limit, 10);
677        // stale_timeout_seconds should be default
678        assert_eq!(config.server.stale_timeout_seconds, 900);
679    }
680
681    #[test]
682    fn test_user_config_overrides_project() {
683        let temp = TempDir::new().unwrap();
684        let project_dir = temp.path().join("task-graph");
685        let user_dir = temp.path().join("user");
686        std::fs::create_dir_all(&project_dir).unwrap();
687        std::fs::create_dir_all(&user_dir).unwrap();
688
689        // Project config
690        let project_config = r#"
691server:
692  claim_limit: 10
693  stale_timeout_seconds: 600
694"#;
695        std::fs::write(project_dir.join("config.yaml"), project_config).unwrap();
696
697        // User config overrides claim_limit
698        let user_config = r#"
699server:
700  claim_limit: 20
701"#;
702        std::fs::write(user_dir.join("config.yaml"), user_config).unwrap();
703
704        let paths = ConfigPaths::with_dirs(Some(project_dir), Some(user_dir));
705
706        let loader = ConfigLoader::load_with_paths(paths).unwrap();
707        let config = loader.config();
708
709        // claim_limit should be from user
710        assert_eq!(config.server.claim_limit, 20);
711        // stale_timeout_seconds should be from project
712        assert_eq!(config.server.stale_timeout_seconds, 600);
713    }
714}