Skip to main content

task_graph_mcp/config/
files.rs

1//! File resolution across configuration tiers.
2//!
3//! Non-YAML files (skills, templates, etc.) use first-found-wins resolution
4//! from highest tier to lowest.
5
6use super::loader::{ConfigLoader, ConfigTier};
7use std::path::PathBuf;
8
9/// Source of a resolved file.
10#[derive(Debug, Clone, Copy, PartialEq, Eq)]
11pub enum FileSource {
12    /// File was found in user config directory
13    User,
14    /// File was found in project config directory
15    Project,
16    /// File was found in deprecated project config directory
17    ProjectDeprecated,
18    /// File is embedded in the binary
19    Embedded,
20}
21
22impl std::fmt::Display for FileSource {
23    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
24        match self {
25            FileSource::User => write!(f, "user"),
26            FileSource::Project => write!(f, "project"),
27            FileSource::ProjectDeprecated => write!(f, "project (deprecated)"),
28            FileSource::Embedded => write!(f, "embedded"),
29        }
30    }
31}
32
33/// A resolved file with its content and metadata.
34#[derive(Debug, Clone)]
35pub struct ResolvedFile {
36    /// The file content
37    pub content: String,
38    /// The path where the file was found (None for embedded)
39    pub path: Option<PathBuf>,
40    /// The source tier
41    pub source: FileSource,
42}
43
44impl ResolvedFile {
45    /// Create a new resolved file from disk.
46    pub fn from_disk(content: String, path: PathBuf, source: FileSource) -> Self {
47        Self {
48            content,
49            path: Some(path),
50            source,
51        }
52    }
53
54    /// Create a new resolved file from embedded content.
55    pub fn from_embedded(content: &'static str) -> Self {
56        Self {
57            content: content.to_string(),
58            path: None,
59            source: FileSource::Embedded,
60        }
61    }
62}
63
64impl ConfigLoader {
65    /// Find a file by relative path, searching from highest tier to lowest.
66    ///
67    /// Returns the first file found, or None if not found in any tier.
68    pub fn find_file(&self, relative_path: &str) -> Option<ResolvedFile> {
69        // Tier 3: User directory (highest priority for files)
70        if let Some(ref user_dir) = self.paths.user_dir {
71            let path = user_dir.join(relative_path);
72            if path.exists() {
73                if let Ok(content) = std::fs::read_to_string(&path) {
74                    return Some(ResolvedFile::from_disk(content, path, FileSource::User));
75                }
76            }
77        }
78
79        // Tier 2: Project directory (new location)
80        if let Some(ref project_dir) = self.paths.project_dir {
81            if project_dir.exists() {
82                let path = project_dir.join(relative_path);
83                if path.exists() {
84                    if let Ok(content) = std::fs::read_to_string(&path) {
85                        return Some(ResolvedFile::from_disk(content, path, FileSource::Project));
86                    }
87                }
88            }
89        }
90
91        // Tier 2b: Project directory (deprecated location)
92        if let Some(ref project_dir) = self.paths.project_dir_deprecated {
93            if project_dir.exists() {
94                let path = project_dir.join(relative_path);
95                if path.exists() {
96                    if let Ok(content) = std::fs::read_to_string(&path) {
97                        return Some(ResolvedFile::from_disk(
98                            content,
99                            path,
100                            FileSource::ProjectDeprecated,
101                        ));
102                    }
103                }
104            }
105        }
106
107        // Tier 1: Embedded content is handled separately by callers
108        None
109    }
110
111    /// Find a file, returning the path if found on disk.
112    pub fn find_file_path(&self, relative_path: &str) -> Option<PathBuf> {
113        // Tier 3: User directory
114        if let Some(ref user_dir) = self.paths.user_dir {
115            let path = user_dir.join(relative_path);
116            if path.exists() {
117                return Some(path);
118            }
119        }
120
121        // Tier 2: Project directory (new location)
122        if let Some(ref project_dir) = self.paths.project_dir {
123            if project_dir.exists() {
124                let path = project_dir.join(relative_path);
125                if path.exists() {
126                    return Some(path);
127                }
128            }
129        }
130
131        // Tier 2b: Project directory (deprecated location)
132        if let Some(ref project_dir) = self.paths.project_dir_deprecated {
133            if project_dir.exists() {
134                let path = project_dir.join(relative_path);
135                if path.exists() {
136                    return Some(path);
137                }
138            }
139        }
140
141        None
142    }
143
144    /// Check if a file exists in any tier.
145    pub fn file_exists(&self, relative_path: &str) -> bool {
146        self.find_file_path(relative_path).is_some()
147    }
148
149    /// Get the tier where a file would be found.
150    pub fn file_tier(&self, relative_path: &str) -> Option<ConfigTier> {
151        // Tier 3: User directory
152        if let Some(ref user_dir) = self.paths.user_dir {
153            if user_dir.join(relative_path).exists() {
154                return Some(ConfigTier::User);
155            }
156        }
157
158        // Tier 2: Project directory
159        if let Some(ref project_dir) = self.paths.project_dir {
160            if project_dir.exists() && project_dir.join(relative_path).exists() {
161                return Some(ConfigTier::Project);
162            }
163        }
164
165        // Tier 2b: Deprecated project directory
166        if let Some(ref project_dir) = self.paths.project_dir_deprecated {
167            if project_dir.exists() && project_dir.join(relative_path).exists() {
168                return Some(ConfigTier::Project);
169            }
170        }
171
172        None
173    }
174
175    /// List files in a directory across all tiers.
176    ///
177    /// Returns a deduplicated list where higher-tier files shadow lower-tier ones.
178    pub fn list_files(&self, relative_dir: &str) -> Vec<(String, FileSource)> {
179        use std::collections::HashMap;
180
181        let mut files: HashMap<String, FileSource> = HashMap::new();
182
183        // Scan from lowest to highest tier (higher tiers override)
184
185        // Tier 2b: Deprecated project directory
186        if let Some(ref project_dir) = self.paths.project_dir_deprecated {
187            let dir = project_dir.join(relative_dir);
188            if dir.is_dir() {
189                if let Ok(entries) = std::fs::read_dir(&dir) {
190                    for entry in entries.flatten() {
191                        if let Some(name) = entry.file_name().to_str() {
192                            files.insert(name.to_string(), FileSource::ProjectDeprecated);
193                        }
194                    }
195                }
196            }
197        }
198
199        // Tier 2: Project directory (new location)
200        if let Some(ref project_dir) = self.paths.project_dir {
201            let dir = project_dir.join(relative_dir);
202            if dir.is_dir() {
203                if let Ok(entries) = std::fs::read_dir(&dir) {
204                    for entry in entries.flatten() {
205                        if let Some(name) = entry.file_name().to_str() {
206                            files.insert(name.to_string(), FileSource::Project);
207                        }
208                    }
209                }
210            }
211        }
212
213        // Tier 3: User directory
214        if let Some(ref user_dir) = self.paths.user_dir {
215            let dir = user_dir.join(relative_dir);
216            if dir.is_dir() {
217                if let Ok(entries) = std::fs::read_dir(&dir) {
218                    for entry in entries.flatten() {
219                        if let Some(name) = entry.file_name().to_str() {
220                            files.insert(name.to_string(), FileSource::User);
221                        }
222                    }
223                }
224            }
225        }
226
227        let mut result: Vec<_> = files.into_iter().collect();
228        result.sort_by(|a, b| a.0.cmp(&b.0));
229        result
230    }
231}
232
233#[cfg(test)]
234mod tests {
235    use super::*;
236    use crate::config::ConfigPaths;
237    use tempfile::TempDir;
238
239    fn create_test_loader(temp: &TempDir) -> ConfigLoader {
240        let project_dir = temp.path().join("task-graph");
241        let user_dir = temp.path().join("user");
242        std::fs::create_dir_all(&project_dir).unwrap();
243        std::fs::create_dir_all(&user_dir).unwrap();
244
245        let paths = ConfigPaths::with_dirs(Some(project_dir), Some(user_dir));
246        ConfigLoader::load_with_paths(paths).unwrap()
247    }
248
249    #[test]
250    fn test_find_file_user_priority() {
251        let temp = TempDir::new().unwrap();
252        let project_dir = temp.path().join("task-graph");
253        let user_dir = temp.path().join("user");
254        std::fs::create_dir_all(&project_dir).unwrap();
255        std::fs::create_dir_all(&user_dir).unwrap();
256
257        // Create file in both locations
258        std::fs::write(project_dir.join("test.txt"), "project content").unwrap();
259        std::fs::write(user_dir.join("test.txt"), "user content").unwrap();
260
261        let paths = ConfigPaths::with_dirs(Some(project_dir), Some(user_dir));
262        let loader = ConfigLoader::load_with_paths(paths).unwrap();
263
264        let file = loader.find_file("test.txt").unwrap();
265        assert_eq!(file.content, "user content");
266        assert_eq!(file.source, FileSource::User);
267    }
268
269    #[test]
270    fn test_find_file_project_fallback() {
271        let temp = TempDir::new().unwrap();
272        let project_dir = temp.path().join("task-graph");
273        let user_dir = temp.path().join("user");
274        std::fs::create_dir_all(&project_dir).unwrap();
275        std::fs::create_dir_all(&user_dir).unwrap();
276
277        // Create file only in project
278        std::fs::write(project_dir.join("test.txt"), "project content").unwrap();
279
280        let paths = ConfigPaths::with_dirs(Some(project_dir), Some(user_dir));
281        let loader = ConfigLoader::load_with_paths(paths).unwrap();
282
283        let file = loader.find_file("test.txt").unwrap();
284        assert_eq!(file.content, "project content");
285        assert_eq!(file.source, FileSource::Project);
286    }
287
288    #[test]
289    fn test_find_file_not_found() {
290        let temp = TempDir::new().unwrap();
291        let loader = create_test_loader(&temp);
292
293        assert!(loader.find_file("nonexistent.txt").is_none());
294    }
295
296    #[test]
297    fn test_list_files_deduplication() {
298        let temp = TempDir::new().unwrap();
299        let project_dir = temp.path().join("task-graph");
300        let user_dir = temp.path().join("user");
301        let project_skills = project_dir.join("skills");
302        let user_skills = user_dir.join("skills");
303        std::fs::create_dir_all(&project_skills).unwrap();
304        std::fs::create_dir_all(&user_skills).unwrap();
305
306        // Create overlapping files
307        std::fs::write(project_skills.join("shared.md"), "project").unwrap();
308        std::fs::write(project_skills.join("project-only.md"), "project").unwrap();
309        std::fs::write(user_skills.join("shared.md"), "user").unwrap();
310        std::fs::write(user_skills.join("user-only.md"), "user").unwrap();
311
312        let paths = ConfigPaths::with_dirs(Some(project_dir), Some(user_dir));
313        let loader = ConfigLoader::load_with_paths(paths).unwrap();
314
315        let files = loader.list_files("skills");
316
317        // Should have 3 unique files
318        assert_eq!(files.len(), 3);
319
320        // shared.md should be from user (higher priority)
321        let shared = files.iter().find(|(name, _)| name == "shared.md").unwrap();
322        assert_eq!(shared.1, FileSource::User);
323    }
324}