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            if dir.exists() {
122                return Some(dir);
123            }
124        }
125
126        // Fall back to deprecated location
127        if let Some(ref dir) = self.project_dir_deprecated {
128            if dir.exists() {
129                return Some(dir);
130            }
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            if new_dir.exists() {
141                return false;
142            }
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                if let Ok(content) = std::fs::read_to_string(&config_file) {
210                    if let Ok(yaml_value) = serde_yaml::from_str::<Value>(&content) {
211                        configs.push(yaml_value);
212                        project_config_path = Some(config_file);
213                    }
214                }
215            }
216        }
217
218        // Tier 3: User config
219        if let Some(ref user_dir) = paths.user_dir {
220            let config_file = user_dir.join("config.yaml");
221            if config_file.exists() {
222                if let Ok(content) = std::fs::read_to_string(&config_file) {
223                    if let Ok(yaml_value) = serde_yaml::from_str::<Value>(&content) {
224                        configs.push(yaml_value);
225                    }
226                }
227            }
228        }
229
230        // Merge all configs
231        let merged = deep_merge_all(configs);
232        let mut config: Config = serde_json::from_value(merged)?;
233
234        // Tier 4: Environment variable overrides
235        Self::apply_env_overrides(&mut config);
236
237        Ok(Self {
238            paths,
239            config,
240            config_path: project_config_path,
241            using_deprecated,
242        })
243    }
244
245    /// Apply environment variable overrides to config.
246    fn apply_env_overrides(config: &mut Config) {
247        if let Ok(db_path) = std::env::var("TASK_GRAPH_DB_PATH") {
248            config.server.db_path = PathBuf::from(db_path);
249        }
250
251        if let Ok(media_dir) = std::env::var("TASK_GRAPH_MEDIA_DIR") {
252            config.server.media_dir = PathBuf::from(media_dir);
253        }
254
255        if let Ok(log_dir) = std::env::var("TASK_GRAPH_LOG_DIR") {
256            config.server.log_dir = PathBuf::from(log_dir);
257        }
258
259        if let Ok(skills_dir) = std::env::var("TASK_GRAPH_SKILLS_DIR") {
260            config.server.skills_dir = PathBuf::from(skills_dir);
261        }
262    }
263
264    /// Load prompts configuration with tier merging.
265    pub fn load_prompts(&self) -> Prompts {
266        let mut prompts_configs: Vec<Value> = Vec::new();
267
268        // Tier 1: Defaults (empty)
269        if let Ok(default_json) = serde_json::to_value(&Prompts::default()) {
270            prompts_configs.push(default_json);
271        }
272
273        // Tier 2: Project prompts
274        if let Some(project_dir) = self.paths.effective_project_dir() {
275            let prompts_file = project_dir.join("prompts.yaml");
276            if prompts_file.exists() {
277                if let Ok(content) = std::fs::read_to_string(&prompts_file) {
278                    if let Ok(yaml_value) = serde_yaml::from_str::<Value>(&content) {
279                        prompts_configs.push(yaml_value);
280                    }
281                }
282            }
283        }
284
285        // Tier 3: User prompts
286        if let Some(ref user_dir) = self.paths.user_dir {
287            let prompts_file = user_dir.join("prompts.yaml");
288            if prompts_file.exists() {
289                if let Ok(content) = std::fs::read_to_string(&prompts_file) {
290                    if let Ok(yaml_value) = serde_yaml::from_str::<Value>(&content) {
291                        prompts_configs.push(yaml_value);
292                    }
293                }
294            }
295        }
296
297        // Merge and deserialize
298        let merged = deep_merge_all(prompts_configs);
299        serde_json::from_value(merged).unwrap_or_default()
300    }
301
302    /// Load workflows configuration with tier merging.
303    ///
304    /// Loads from embedded defaults, then project workflows.yaml, then user workflows.yaml.
305    /// Later tiers override earlier ones (objects are deep-merged, prompts are replaced).
306    pub fn load_workflows(&self) -> super::workflows::WorkflowsConfig {
307        let mut workflows_configs: Vec<Value> = Vec::new();
308
309        // Tier 1: Defaults (embedded)
310        if let Ok(default_json) =
311            serde_json::to_value(&super::workflows::WorkflowsConfig::default())
312        {
313            workflows_configs.push(default_json);
314        }
315
316        // Tier 2: Project workflows
317        if let Some(project_dir) = self.paths.effective_project_dir() {
318            let workflows_file = project_dir.join("workflows.yaml");
319            if workflows_file.exists() {
320                if let Ok(content) = std::fs::read_to_string(&workflows_file) {
321                    if let Ok(yaml_value) = serde_yaml::from_str::<Value>(&content) {
322                        workflows_configs.push(yaml_value);
323                    }
324                }
325            }
326        }
327
328        // Tier 3: User workflows
329        if let Some(ref user_dir) = self.paths.user_dir {
330            let workflows_file = user_dir.join("workflows.yaml");
331            if workflows_file.exists() {
332                if let Ok(content) = std::fs::read_to_string(&workflows_file) {
333                    if let Ok(yaml_value) = serde_yaml::from_str::<Value>(&content) {
334                        workflows_configs.push(yaml_value);
335                    }
336                }
337            }
338        }
339
340        // Merge and deserialize
341        let merged = deep_merge_all(workflows_configs);
342        serde_json::from_value(merged).unwrap_or_default()
343    }
344
345    /// Get the loaded configuration.
346    pub fn config(&self) -> &Config {
347        &self.config
348    }
349
350    /// Get mutable access to the configuration.
351    pub fn config_mut(&mut self) -> &mut Config {
352        &mut self.config
353    }
354
355    /// Consume the loader and return the configuration.
356    pub fn into_config(self) -> Config {
357        self.config
358    }
359
360    /// Get the config file path that was used.
361    pub fn config_path(&self) -> Option<&Path> {
362        self.config_path.as_deref()
363    }
364
365    /// Check if using deprecated paths.
366    pub fn is_using_deprecated(&self) -> bool {
367        self.using_deprecated
368    }
369
370    /// Get the effective project directory.
371    pub fn project_dir(&self) -> Option<&Path> {
372        self.paths.effective_project_dir()
373    }
374
375    /// Get the user directory.
376    pub fn user_dir(&self) -> Option<&Path> {
377        self.paths.user_dir.as_deref()
378    }
379
380    /// Get the skills directory, checking all tiers.
381    pub fn skills_dir(&self) -> PathBuf {
382        // Environment override takes precedence
383        if let Ok(skills_dir) = std::env::var("TASK_GRAPH_SKILLS_DIR") {
384            return PathBuf::from(skills_dir);
385        }
386
387        // Check project dir
388        if let Some(project_dir) = self.paths.effective_project_dir() {
389            let skills_dir = project_dir.join("skills");
390            if skills_dir.exists() {
391                return skills_dir;
392            }
393        }
394
395        // Use config default
396        self.config.server.skills_dir.clone()
397    }
398
399    /// Load a named workflow file (workflow-{name}.yaml).
400    ///
401    /// Searches in order: user directory, project directory, install directory.
402    /// User overrides project, project overrides install defaults.
403    /// Returns the merged workflow config (defaults + named workflow).
404    pub fn load_workflow_by_name(&self, name: &str) -> Result<super::workflows::WorkflowsConfig> {
405        let filename = format!("workflow-{}.yaml", name);
406
407        // Check user directory first (highest priority)
408        if let Some(ref user_dir) = self.paths.user_dir {
409            let workflow_file = user_dir.join(&filename);
410            if workflow_file.exists() {
411                return self.load_workflow_from_path(&workflow_file);
412            }
413        }
414
415        // Check project directory second
416        if let Some(project_dir) = self.paths.effective_project_dir() {
417            let workflow_file = project_dir.join(&filename);
418            if workflow_file.exists() {
419                return self.load_workflow_from_path(&workflow_file);
420            }
421        }
422
423        // Fall back to install directory (built-in defaults)
424        if let Some(ref install_dir) = self.paths.install_dir {
425            let workflow_file = install_dir.join(&filename);
426            if workflow_file.exists() {
427                return self.load_workflow_from_path(&workflow_file);
428            }
429        }
430
431        Err(anyhow::anyhow!(
432            "Workflow '{}' not found. Searched for '{}' in user, project, and install directories.",
433            name,
434            filename
435        ))
436    }
437
438    /// Load workflow from a specific path, merging with defaults.
439    fn load_workflow_from_path(&self, path: &Path) -> Result<super::workflows::WorkflowsConfig> {
440        let content = std::fs::read_to_string(path)?;
441        let yaml_value: Value = serde_yaml::from_str(&content)?;
442
443        // Start with defaults and merge the named workflow on top
444        let mut configs: Vec<Value> = Vec::new();
445
446        // Tier 1: Defaults
447        if let Ok(default_json) =
448            serde_json::to_value(&super::workflows::WorkflowsConfig::default())
449        {
450            configs.push(default_json);
451        }
452
453        // Tier 2: The named workflow file
454        configs.push(yaml_value);
455
456        let merged = deep_merge_all(configs);
457        let mut workflow: super::workflows::WorkflowsConfig = serde_json::from_value(merged)?;
458
459        // Populate source_file (not serialized, so must be set after deserialization)
460        workflow.source_file = Some(path.to_path_buf());
461
462        Ok(workflow)
463    }
464
465    /// List available named workflows.
466    ///
467    /// Returns workflow names (e.g., "solo", "swarm") found in user, project, and install directories.
468    pub fn list_workflows(&self) -> Vec<String> {
469        let mut workflows = Vec::new();
470
471        // Check user directory
472        if let Some(ref user_dir) = self.paths.user_dir {
473            if let Ok(entries) = std::fs::read_dir(user_dir) {
474                for entry in entries.filter_map(|e| e.ok()) {
475                    if let Some(name) = Self::extract_workflow_name(&entry.path()) {
476                        if !workflows.contains(&name) {
477                            workflows.push(name);
478                        }
479                    }
480                }
481            }
482        }
483
484        // Check project directory
485        if let Some(project_dir) = self.paths.effective_project_dir() {
486            if let Ok(entries) = std::fs::read_dir(project_dir) {
487                for entry in entries.filter_map(|e| e.ok()) {
488                    if let Some(name) = Self::extract_workflow_name(&entry.path()) {
489                        if !workflows.contains(&name) {
490                            workflows.push(name);
491                        }
492                    }
493                }
494            }
495        }
496
497        // Check install directory (built-in workflows)
498        if let Some(ref install_dir) = self.paths.install_dir {
499            if let Ok(entries) = std::fs::read_dir(install_dir) {
500                for entry in entries.filter_map(|e| e.ok()) {
501                    if let Some(name) = Self::extract_workflow_name(&entry.path()) {
502                        if !workflows.contains(&name) {
503                            workflows.push(name);
504                        }
505                    }
506                }
507            }
508        }
509
510        workflows.sort();
511        workflows
512    }
513
514    /// Extract workflow name from a path like "workflow-swarm.yaml" -> "swarm".
515    fn extract_workflow_name(path: &Path) -> Option<String> {
516        let filename = path.file_name()?.to_str()?;
517        if filename.starts_with("workflow-") && filename.ends_with(".yaml") {
518            let name = filename.strip_prefix("workflow-")?.strip_suffix(".yaml")?;
519            if !name.is_empty() {
520                return Some(name.to_string());
521            }
522        }
523        None
524    }
525}
526
527#[cfg(test)]
528mod tests {
529    use super::*;
530    use tempfile::TempDir;
531
532    #[test]
533    fn test_config_paths_discover() {
534        let paths = ConfigPaths::discover();
535        assert!(paths.project_dir.is_some());
536        // user_dir may or may not exist depending on environment
537    }
538
539    #[test]
540    fn test_load_defaults_only() {
541        // Create empty temp dirs so no config files are found
542        let temp = TempDir::new().unwrap();
543        let paths = ConfigPaths::with_dirs(
544            Some(temp.path().join("project")),
545            Some(temp.path().join("user")),
546        );
547
548        let loader = ConfigLoader::load_with_paths(paths).unwrap();
549        let config = loader.config();
550
551        // Should have default values
552        assert_eq!(config.server.claim_limit, 5);
553        assert_eq!(config.server.stale_timeout_seconds, 900);
554    }
555
556    #[test]
557    fn test_project_config_overrides_defaults() {
558        let temp = TempDir::new().unwrap();
559        let project_dir = temp.path().join("task-graph");
560        std::fs::create_dir_all(&project_dir).unwrap();
561
562        // Create project config that overrides claim_limit
563        let config_content = r#"
564server:
565  claim_limit: 10
566"#;
567        std::fs::write(project_dir.join("config.yaml"), config_content).unwrap();
568
569        let paths = ConfigPaths::with_dirs(Some(project_dir), Some(temp.path().join("user")));
570
571        let loader = ConfigLoader::load_with_paths(paths).unwrap();
572        let config = loader.config();
573
574        // claim_limit should be overridden
575        assert_eq!(config.server.claim_limit, 10);
576        // stale_timeout_seconds should be default
577        assert_eq!(config.server.stale_timeout_seconds, 900);
578    }
579
580    #[test]
581    fn test_user_config_overrides_project() {
582        let temp = TempDir::new().unwrap();
583        let project_dir = temp.path().join("task-graph");
584        let user_dir = temp.path().join("user");
585        std::fs::create_dir_all(&project_dir).unwrap();
586        std::fs::create_dir_all(&user_dir).unwrap();
587
588        // Project config
589        let project_config = r#"
590server:
591  claim_limit: 10
592  stale_timeout_seconds: 600
593"#;
594        std::fs::write(project_dir.join("config.yaml"), project_config).unwrap();
595
596        // User config overrides claim_limit
597        let user_config = r#"
598server:
599  claim_limit: 20
600"#;
601        std::fs::write(user_dir.join("config.yaml"), user_config).unwrap();
602
603        let paths = ConfigPaths::with_dirs(Some(project_dir), Some(user_dir));
604
605        let loader = ConfigLoader::load_with_paths(paths).unwrap();
606        let config = loader.config();
607
608        // claim_limit should be from user
609        assert_eq!(config.server.claim_limit, 20);
610        // stale_timeout_seconds should be from project
611        assert_eq!(config.server.stale_timeout_seconds, 600);
612    }
613}