task_graph_mcp/config/
loader.rs1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
14pub enum ConfigTier {
15 Defaults = 0,
17 Project = 1,
19 User = 2,
21 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#[derive(Debug, Clone)]
38pub struct ConfigPaths {
39 pub defaults_dir: Option<PathBuf>,
41 pub install_dir: Option<PathBuf>,
43 pub project_dir: Option<PathBuf>,
45 pub project_dir_deprecated: Option<PathBuf>,
47 pub user_dir: Option<PathBuf>,
49}
50
51impl Default for ConfigPaths {
52 fn default() -> Self {
53 Self::discover()
54 }
55}
56
57impl ConfigPaths {
58 pub fn discover() -> Self {
60 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 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 let project_dir_deprecated = Some(PathBuf::from(".task-graph"));
74
75 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, install_dir,
84 project_dir,
85 project_dir_deprecated,
86 user_dir,
87 }
88 }
89
90 pub fn with_dirs(project_dir: Option<PathBuf>, user_dir: Option<PathBuf>) -> Self {
93 Self {
94 defaults_dir: None,
95 install_dir: None, project_dir,
97 project_dir_deprecated: Some(PathBuf::from(".task-graph")),
98 user_dir,
99 }
100 }
101
102 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 pub fn effective_project_dir(&self) -> Option<&Path> {
119 if let Some(ref dir) = self.project_dir
121 && dir.exists()
122 {
123 return Some(dir);
124 }
125
126 if let Some(ref dir) = self.project_dir_deprecated
128 && dir.exists()
129 {
130 return Some(dir);
131 }
132
133 self.project_dir.as_deref()
135 }
136
137 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#[derive(Debug, Clone)]
155pub struct ConfigLoader {
156 pub paths: ConfigPaths,
158 config: Config,
160 config_path: Option<PathBuf>,
162 using_deprecated: bool,
164}
165
166impl ConfigLoader {
167 pub fn load() -> Result<Self> {
169 Self::load_with_paths(ConfigPaths::discover())
170 }
171
172 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 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 let mut configs: Vec<Value> = Vec::new();
197
198 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 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 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 let merged = deep_merge_all(configs);
230 let mut config: Config = serde_json::from_value(merged)?;
231
232 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 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 pub fn load_prompts(&self) -> Prompts {
264 let mut prompts_configs: Vec<Value> = Vec::new();
265
266 if let Ok(default_json) = serde_json::to_value(Prompts::default()) {
268 prompts_configs.push(default_json);
269 }
270
271 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 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 let merged = deep_merge_all(prompts_configs);
295 serde_json::from_value(merged).unwrap_or_default()
296 }
297
298 pub fn load_workflows(&self) -> super::workflows::WorkflowsConfig {
303 let mut workflows_configs: Vec<Value> = Vec::new();
304
305 if let Ok(default_json) = serde_json::to_value(super::workflows::WorkflowsConfig::default())
307 {
308 workflows_configs.push(default_json);
309 }
310
311 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 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 let merged = deep_merge_all(workflows_configs);
335 serde_json::from_value(merged).unwrap_or_default()
336 }
337
338 pub fn config(&self) -> &Config {
340 &self.config
341 }
342
343 pub fn config_mut(&mut self) -> &mut Config {
345 &mut self.config
346 }
347
348 pub fn into_config(self) -> Config {
350 self.config
351 }
352
353 pub fn config_path(&self) -> Option<&Path> {
355 self.config_path.as_deref()
356 }
357
358 pub fn is_using_deprecated(&self) -> bool {
360 self.using_deprecated
361 }
362
363 pub fn project_dir(&self) -> Option<&Path> {
365 self.paths.effective_project_dir()
366 }
367
368 pub fn user_dir(&self) -> Option<&Path> {
370 self.paths.user_dir.as_deref()
371 }
372
373 pub fn skills_dir(&self) -> PathBuf {
375 if let Ok(skills_dir) = std::env::var("TASK_GRAPH_SKILLS_DIR") {
377 return PathBuf::from(skills_dir);
378 }
379
380 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 self.config.server.skills_dir.clone()
390 }
391
392 pub fn load_workflow_by_name(&self, name: &str) -> Result<super::workflows::WorkflowsConfig> {
398 let filename = format!("workflow-{}.yaml", name);
399
400 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 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 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 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 let mut configs: Vec<Value> = Vec::new();
438
439 if let Ok(default_json) = serde_json::to_value(super::workflows::WorkflowsConfig::default())
441 {
442 configs.push(default_json);
443 }
444
445 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 workflow.source_file = Some(path.to_path_buf());
453
454 Ok(workflow)
455 }
456
457 pub fn list_workflows(&self) -> Vec<String> {
461 let mut workflows = Vec::new();
462
463 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 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 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 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
519#[cfg(test)]
520mod tests {
521 use super::*;
522 use tempfile::TempDir;
523
524 #[test]
525 fn test_config_paths_discover() {
526 let paths = ConfigPaths::discover();
527 assert!(paths.project_dir.is_some());
528 }
530
531 #[test]
532 fn test_load_defaults_only() {
533 let temp = TempDir::new().unwrap();
535 let paths = ConfigPaths::with_dirs(
536 Some(temp.path().join("project")),
537 Some(temp.path().join("user")),
538 );
539
540 let loader = ConfigLoader::load_with_paths(paths).unwrap();
541 let config = loader.config();
542
543 assert_eq!(config.server.claim_limit, 5);
545 assert_eq!(config.server.stale_timeout_seconds, 900);
546 }
547
548 #[test]
549 fn test_project_config_overrides_defaults() {
550 let temp = TempDir::new().unwrap();
551 let project_dir = temp.path().join("task-graph");
552 std::fs::create_dir_all(&project_dir).unwrap();
553
554 let config_content = r#"
556server:
557 claim_limit: 10
558"#;
559 std::fs::write(project_dir.join("config.yaml"), config_content).unwrap();
560
561 let paths = ConfigPaths::with_dirs(Some(project_dir), Some(temp.path().join("user")));
562
563 let loader = ConfigLoader::load_with_paths(paths).unwrap();
564 let config = loader.config();
565
566 assert_eq!(config.server.claim_limit, 10);
568 assert_eq!(config.server.stale_timeout_seconds, 900);
570 }
571
572 #[test]
573 fn test_user_config_overrides_project() {
574 let temp = TempDir::new().unwrap();
575 let project_dir = temp.path().join("task-graph");
576 let user_dir = temp.path().join("user");
577 std::fs::create_dir_all(&project_dir).unwrap();
578 std::fs::create_dir_all(&user_dir).unwrap();
579
580 let project_config = r#"
582server:
583 claim_limit: 10
584 stale_timeout_seconds: 600
585"#;
586 std::fs::write(project_dir.join("config.yaml"), project_config).unwrap();
587
588 let user_config = r#"
590server:
591 claim_limit: 20
592"#;
593 std::fs::write(user_dir.join("config.yaml"), user_config).unwrap();
594
595 let paths = ConfigPaths::with_dirs(Some(project_dir), Some(user_dir));
596
597 let loader = ConfigLoader::load_with_paths(paths).unwrap();
598 let config = loader.config();
599
600 assert_eq!(config.server.claim_limit, 20);
602 assert_eq!(config.server.stale_timeout_seconds, 600);
604 }
605}