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 if dir.exists() {
122 return Some(dir);
123 }
124 }
125
126 if let Some(ref dir) = self.project_dir_deprecated {
128 if dir.exists() {
129 return Some(dir);
130 }
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 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#[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 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 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 let merged = deep_merge_all(configs);
232 let mut config: Config = serde_json::from_value(merged)?;
233
234 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 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 pub fn load_prompts(&self) -> Prompts {
266 let mut prompts_configs: Vec<Value> = Vec::new();
267
268 if let Ok(default_json) = serde_json::to_value(&Prompts::default()) {
270 prompts_configs.push(default_json);
271 }
272
273 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 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 let merged = deep_merge_all(prompts_configs);
299 serde_json::from_value(merged).unwrap_or_default()
300 }
301
302 pub fn load_workflows(&self) -> super::workflows::WorkflowsConfig {
307 let mut workflows_configs: Vec<Value> = Vec::new();
308
309 if let Ok(default_json) =
311 serde_json::to_value(&super::workflows::WorkflowsConfig::default())
312 {
313 workflows_configs.push(default_json);
314 }
315
316 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 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 let merged = deep_merge_all(workflows_configs);
342 serde_json::from_value(merged).unwrap_or_default()
343 }
344
345 pub fn config(&self) -> &Config {
347 &self.config
348 }
349
350 pub fn config_mut(&mut self) -> &mut Config {
352 &mut self.config
353 }
354
355 pub fn into_config(self) -> Config {
357 self.config
358 }
359
360 pub fn config_path(&self) -> Option<&Path> {
362 self.config_path.as_deref()
363 }
364
365 pub fn is_using_deprecated(&self) -> bool {
367 self.using_deprecated
368 }
369
370 pub fn project_dir(&self) -> Option<&Path> {
372 self.paths.effective_project_dir()
373 }
374
375 pub fn user_dir(&self) -> Option<&Path> {
377 self.paths.user_dir.as_deref()
378 }
379
380 pub fn skills_dir(&self) -> PathBuf {
382 if let Ok(skills_dir) = std::env::var("TASK_GRAPH_SKILLS_DIR") {
384 return PathBuf::from(skills_dir);
385 }
386
387 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 self.config.server.skills_dir.clone()
397 }
398
399 pub fn load_workflow_by_name(&self, name: &str) -> Result<super::workflows::WorkflowsConfig> {
405 let filename = format!("workflow-{}.yaml", name);
406
407 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 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 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 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 let mut configs: Vec<Value> = Vec::new();
445
446 if let Ok(default_json) =
448 serde_json::to_value(&super::workflows::WorkflowsConfig::default())
449 {
450 configs.push(default_json);
451 }
452
453 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 workflow.source_file = Some(path.to_path_buf());
461
462 Ok(workflow)
463 }
464
465 pub fn list_workflows(&self) -> Vec<String> {
469 let mut workflows = Vec::new();
470
471 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 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 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 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 }
538
539 #[test]
540 fn test_load_defaults_only() {
541 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 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 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 assert_eq!(config.server.claim_limit, 10);
576 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 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 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 assert_eq!(config.server.claim_limit, 20);
610 assert_eq!(config.server.stale_timeout_seconds, 600);
612 }
613}