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                && let Ok(content) = std::fs::read_to_string(&path)
74            {
75                return Some(ResolvedFile::from_disk(content, path, FileSource::User));
76            }
77        }
78
79        // Tier 2: Project directory (new location)
80        if let Some(ref project_dir) = self.paths.project_dir
81            && project_dir.exists()
82        {
83            let path = project_dir.join(relative_path);
84            if path.exists()
85                && let Ok(content) = std::fs::read_to_string(&path)
86            {
87                return Some(ResolvedFile::from_disk(content, path, FileSource::Project));
88            }
89        }
90
91        // Tier 2b: Project directory (deprecated location)
92        if let Some(ref project_dir) = self.paths.project_dir_deprecated
93            && project_dir.exists()
94        {
95            let path = project_dir.join(relative_path);
96            if path.exists()
97                && let Ok(content) = std::fs::read_to_string(&path)
98            {
99                return Some(ResolvedFile::from_disk(
100                    content,
101                    path,
102                    FileSource::ProjectDeprecated,
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            && project_dir.exists()
124        {
125            let path = project_dir.join(relative_path);
126            if path.exists() {
127                return Some(path);
128            }
129        }
130
131        // Tier 2b: Project directory (deprecated location)
132        if let Some(ref project_dir) = self.paths.project_dir_deprecated
133            && project_dir.exists()
134        {
135            let path = project_dir.join(relative_path);
136            if path.exists() {
137                return Some(path);
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            && user_dir.join(relative_path).exists()
154        {
155            return Some(ConfigTier::User);
156        }
157
158        // Tier 2: Project directory
159        if let Some(ref project_dir) = self.paths.project_dir
160            && project_dir.exists()
161            && project_dir.join(relative_path).exists()
162        {
163            return Some(ConfigTier::Project);
164        }
165
166        // Tier 2b: Deprecated project directory
167        if let Some(ref project_dir) = self.paths.project_dir_deprecated
168            && project_dir.exists()
169            && project_dir.join(relative_path).exists()
170        {
171            return Some(ConfigTier::Project);
172        }
173
174        None
175    }
176
177    /// List files in a directory across all tiers.
178    ///
179    /// Returns a deduplicated list where higher-tier files shadow lower-tier ones.
180    pub fn list_files(&self, relative_dir: &str) -> Vec<(String, FileSource)> {
181        use std::collections::HashMap;
182
183        let mut files: HashMap<String, FileSource> = HashMap::new();
184
185        // Scan from lowest to highest tier (higher tiers override)
186
187        // Tier 2b: Deprecated project directory
188        if let Some(ref project_dir) = self.paths.project_dir_deprecated {
189            let dir = project_dir.join(relative_dir);
190            if dir.is_dir()
191                && let Ok(entries) = std::fs::read_dir(&dir)
192            {
193                for entry in entries.flatten() {
194                    if let Some(name) = entry.file_name().to_str() {
195                        files.insert(name.to_string(), FileSource::ProjectDeprecated);
196                    }
197                }
198            }
199        }
200
201        // Tier 2: Project directory (new location)
202        if let Some(ref project_dir) = self.paths.project_dir {
203            let dir = project_dir.join(relative_dir);
204            if dir.is_dir()
205                && let Ok(entries) = std::fs::read_dir(&dir)
206            {
207                for entry in entries.flatten() {
208                    if let Some(name) = entry.file_name().to_str() {
209                        files.insert(name.to_string(), FileSource::Project);
210                    }
211                }
212            }
213        }
214
215        // Tier 3: User directory
216        if let Some(ref user_dir) = self.paths.user_dir {
217            let dir = user_dir.join(relative_dir);
218            if dir.is_dir()
219                && let Ok(entries) = std::fs::read_dir(&dir)
220            {
221                for entry in entries.flatten() {
222                    if let Some(name) = entry.file_name().to_str() {
223                        files.insert(name.to_string(), FileSource::User);
224                    }
225                }
226            }
227        }
228
229        let mut result: Vec<_> = files.into_iter().collect();
230        result.sort_by(|a, b| a.0.cmp(&b.0));
231        result
232    }
233}
234
235#[cfg(test)]
236mod tests {
237    use super::*;
238    use crate::config::ConfigPaths;
239    use tempfile::TempDir;
240
241    fn create_test_loader(temp: &TempDir) -> ConfigLoader {
242        let project_dir = temp.path().join("task-graph");
243        let user_dir = temp.path().join("user");
244        std::fs::create_dir_all(&project_dir).unwrap();
245        std::fs::create_dir_all(&user_dir).unwrap();
246
247        let paths = ConfigPaths::with_dirs(Some(project_dir), Some(user_dir));
248        ConfigLoader::load_with_paths(paths).unwrap()
249    }
250
251    #[test]
252    fn test_find_file_user_priority() {
253        let temp = TempDir::new().unwrap();
254        let project_dir = temp.path().join("task-graph");
255        let user_dir = temp.path().join("user");
256        std::fs::create_dir_all(&project_dir).unwrap();
257        std::fs::create_dir_all(&user_dir).unwrap();
258
259        // Create file in both locations
260        std::fs::write(project_dir.join("test.txt"), "project content").unwrap();
261        std::fs::write(user_dir.join("test.txt"), "user content").unwrap();
262
263        let paths = ConfigPaths::with_dirs(Some(project_dir), Some(user_dir));
264        let loader = ConfigLoader::load_with_paths(paths).unwrap();
265
266        let file = loader.find_file("test.txt").unwrap();
267        assert_eq!(file.content, "user content");
268        assert_eq!(file.source, FileSource::User);
269    }
270
271    #[test]
272    fn test_find_file_project_fallback() {
273        let temp = TempDir::new().unwrap();
274        let project_dir = temp.path().join("task-graph");
275        let user_dir = temp.path().join("user");
276        std::fs::create_dir_all(&project_dir).unwrap();
277        std::fs::create_dir_all(&user_dir).unwrap();
278
279        // Create file only in project
280        std::fs::write(project_dir.join("test.txt"), "project content").unwrap();
281
282        let paths = ConfigPaths::with_dirs(Some(project_dir), Some(user_dir));
283        let loader = ConfigLoader::load_with_paths(paths).unwrap();
284
285        let file = loader.find_file("test.txt").unwrap();
286        assert_eq!(file.content, "project content");
287        assert_eq!(file.source, FileSource::Project);
288    }
289
290    #[test]
291    fn test_find_file_not_found() {
292        let temp = TempDir::new().unwrap();
293        let loader = create_test_loader(&temp);
294
295        assert!(loader.find_file("nonexistent.txt").is_none());
296    }
297
298    #[test]
299    fn test_list_files_deduplication() {
300        let temp = TempDir::new().unwrap();
301        let project_dir = temp.path().join("task-graph");
302        let user_dir = temp.path().join("user");
303        let project_skills = project_dir.join("skills");
304        let user_skills = user_dir.join("skills");
305        std::fs::create_dir_all(&project_skills).unwrap();
306        std::fs::create_dir_all(&user_skills).unwrap();
307
308        // Create overlapping files
309        std::fs::write(project_skills.join("shared.md"), "project").unwrap();
310        std::fs::write(project_skills.join("project-only.md"), "project").unwrap();
311        std::fs::write(user_skills.join("shared.md"), "user").unwrap();
312        std::fs::write(user_skills.join("user-only.md"), "user").unwrap();
313
314        let paths = ConfigPaths::with_dirs(Some(project_dir), Some(user_dir));
315        let loader = ConfigLoader::load_with_paths(paths).unwrap();
316
317        let files = loader.list_files("skills");
318
319        // Should have 3 unique files
320        assert_eq!(files.len(), 3);
321
322        // shared.md should be from user (higher priority)
323        let shared = files.iter().find(|(name, _)| name == "shared.md").unwrap();
324        assert_eq!(shared.1, FileSource::User);
325    }
326}