Skip to main content

fabryk_core/util/
resolver.rs

1//! Configurable path resolver for domain-specific directories.
2//!
3//! `PathResolver` provides a configurable way to locate project directories
4//! using environment variables, directory markers, and fallback paths.
5//! Each domain (music-theory, another-project, etc.) creates its own resolver
6//! with appropriate configuration.
7//!
8//! # Example
9//!
10//! ```no_run
11//! use fabryk_core::util::resolver::PathResolver;
12//!
13//! // Create a resolver for "music-theory" project
14//! let resolver = PathResolver::new("music-theory")
15//!     .with_config_marker("config/default.toml")
16//!     .with_project_markers(&["SKILL.md", "CONVENTIONS.md"]);
17//!
18//! // These check MUSIC_THEORY_CONFIG_DIR, then search for markers
19//! if let Some(config) = resolver.config_dir() {
20//!     println!("Config: {:?}", config);
21//! }
22//! ```
23
24use std::env;
25use std::path::PathBuf;
26
27use crate::util::paths::{binary_dir, expand_tilde, find_dir_with_marker};
28
29/// Configurable path resolver for a specific project/domain.
30#[derive(Debug, Clone)]
31pub struct PathResolver {
32    /// Project name (e.g., "music-theory")
33    project_name: String,
34    /// Environment variable prefix (e.g., "MUSIC_THEORY")
35    env_prefix: String,
36    /// Marker file/dir to identify config directory (e.g., "config/default.toml")
37    config_marker: Option<String>,
38    /// Marker files to identify project root (e.g., ["SKILL.md", "Cargo.toml"])
39    project_markers: Vec<String>,
40    /// Fallback config path (expanded with tilde)
41    config_fallback: Option<PathBuf>,
42    /// Fallback project root (expanded with tilde)
43    project_fallback: Option<PathBuf>,
44}
45
46impl PathResolver {
47    /// Create a new resolver for the given project name.
48    ///
49    /// The project name is converted to an environment variable prefix:
50    /// - "music-theory" → "MUSIC_THEORY"
51    /// - "my_project" → "MY_PROJECT"
52    pub fn new(project_name: &str) -> Self {
53        let env_prefix = project_name.to_uppercase().replace(['-', ' '], "_");
54
55        Self {
56            project_name: project_name.to_string(),
57            env_prefix,
58            config_marker: None,
59            project_markers: vec![],
60            config_fallback: None,
61            project_fallback: None,
62        }
63    }
64
65    /// Set the marker file/directory that identifies a config directory.
66    pub fn with_config_marker(mut self, marker: &str) -> Self {
67        self.config_marker = Some(marker.to_string());
68        self
69    }
70
71    /// Set marker files that identify the project root.
72    pub fn with_project_markers(mut self, markers: &[&str]) -> Self {
73        self.project_markers = markers.iter().map(|s| (*s).to_string()).collect();
74        self
75    }
76
77    /// Set a fallback path for config directory (supports ~ expansion).
78    pub fn with_config_fallback(mut self, path: &str) -> Self {
79        self.config_fallback = Some(expand_tilde(path));
80        self
81    }
82
83    /// Set a fallback path for project root (supports ~ expansion).
84    pub fn with_project_fallback(mut self, path: &str) -> Self {
85        self.project_fallback = Some(expand_tilde(path));
86        self
87    }
88
89    /// Get the environment variable name for a given suffix.
90    ///
91    /// # Example
92    /// ```
93    /// use fabryk_core::util::resolver::PathResolver;
94    ///
95    /// let resolver = PathResolver::new("music-theory");
96    /// assert_eq!(resolver.env_var("CONFIG_DIR"), "MUSIC_THEORY_CONFIG_DIR");
97    /// ```
98    pub fn env_var(&self, suffix: &str) -> String {
99        format!("{}_{}", self.env_prefix, suffix)
100    }
101
102    /// Resolve the config directory.
103    ///
104    /// Checks in order:
105    /// 1. `{PROJECT}_CONFIG_DIR` environment variable
106    /// 2. Walk up from binary looking for config marker
107    /// 3. Fallback path (if configured)
108    pub fn config_dir(&self) -> Option<PathBuf> {
109        // 1. Check environment variable
110        let env_var = self.env_var("CONFIG_DIR");
111        if let Ok(path) = env::var(&env_var) {
112            let path = expand_tilde(&path);
113            if path.exists() {
114                return Some(path);
115            }
116        }
117
118        // 2. Walk up from binary location
119        if let (Some(bin_dir), Some(marker)) = (binary_dir(), &self.config_marker) {
120            if let Some(root) = find_dir_with_marker(&bin_dir, marker) {
121                // The marker might be nested (e.g., "config/default.toml")
122                // Return the directory containing the first component
123                if let Some(first_component) = marker.split('/').next() {
124                    let config_path = root.join(first_component);
125                    if config_path.exists() {
126                        return Some(config_path);
127                    }
128                }
129                return Some(root);
130            }
131        }
132
133        // 3. Try fallback
134        if let Some(fallback) = &self.config_fallback {
135            if fallback.exists() {
136                return Some(fallback.clone());
137            }
138        }
139
140        None
141    }
142
143    /// Resolve the project root directory.
144    ///
145    /// Checks in order:
146    /// 1. `{PROJECT}_ROOT` environment variable
147    /// 2. Walk up from binary looking for project markers
148    /// 3. Fallback path (if configured)
149    pub fn project_root(&self) -> Option<PathBuf> {
150        // 1. Check environment variable
151        let env_var = self.env_var("ROOT");
152        if let Ok(path) = env::var(&env_var) {
153            let path = expand_tilde(&path);
154            if path.exists() {
155                return Some(path);
156            }
157        }
158
159        // 2. Walk up from binary location, trying each marker
160        if let Some(bin_dir) = binary_dir() {
161            for marker in &self.project_markers {
162                if let Some(root) = find_dir_with_marker(&bin_dir, marker) {
163                    return Some(root);
164                }
165            }
166        }
167
168        // 3. Try fallback
169        if let Some(fallback) = &self.project_fallback {
170            if fallback.exists() {
171                return Some(fallback.clone());
172            }
173        }
174
175        None
176    }
177
178    /// Get the project name.
179    pub fn project_name(&self) -> &str {
180        &self.project_name
181    }
182
183    /// Get the environment variable prefix.
184    pub fn env_prefix(&self) -> &str {
185        &self.env_prefix
186    }
187}
188
189#[cfg(test)]
190mod tests {
191    use super::*;
192
193    #[test]
194    fn test_new_simple_name() {
195        let resolver = PathResolver::new("myproject");
196        assert_eq!(resolver.project_name(), "myproject");
197        assert_eq!(resolver.env_prefix(), "MYPROJECT");
198    }
199
200    #[test]
201    fn test_new_kebab_case_name() {
202        let resolver = PathResolver::new("music-theory");
203        assert_eq!(resolver.project_name(), "music-theory");
204        assert_eq!(resolver.env_prefix(), "MUSIC_THEORY");
205    }
206
207    #[test]
208    fn test_new_snake_case_name() {
209        let resolver = PathResolver::new("my_project");
210        assert_eq!(resolver.project_name(), "my_project");
211        assert_eq!(resolver.env_prefix(), "MY_PROJECT");
212    }
213
214    #[test]
215    fn test_env_var() {
216        let resolver = PathResolver::new("music-theory");
217        assert_eq!(resolver.env_var("CONFIG_DIR"), "MUSIC_THEORY_CONFIG_DIR");
218        assert_eq!(resolver.env_var("ROOT"), "MUSIC_THEORY_ROOT");
219        assert_eq!(resolver.env_var("DATA_DIR"), "MUSIC_THEORY_DATA_DIR");
220    }
221
222    #[test]
223    fn test_config_dir_from_env() {
224        let resolver = PathResolver::new("fabryk-test-config");
225
226        // Create temp directory
227        let temp_dir = std::env::temp_dir().join("fabryk_test_config_resolver");
228        let _ = std::fs::create_dir_all(&temp_dir);
229
230        // Set env var
231        env::set_var("FABRYK_TEST_CONFIG_CONFIG_DIR", &temp_dir);
232
233        let result = resolver.config_dir();
234        assert!(result.is_some());
235        assert_eq!(result.unwrap(), temp_dir);
236
237        // Clean up
238        env::remove_var("FABRYK_TEST_CONFIG_CONFIG_DIR");
239        let _ = std::fs::remove_dir_all(&temp_dir);
240    }
241
242    #[test]
243    fn test_project_root_from_env() {
244        let resolver = PathResolver::new("fabryk-test-root");
245
246        // Create temp directory
247        let temp_dir = std::env::temp_dir().join("fabryk_test_root_resolver");
248        let _ = std::fs::create_dir_all(&temp_dir);
249
250        // Set env var
251        env::set_var("FABRYK_TEST_ROOT_ROOT", &temp_dir);
252
253        let result = resolver.project_root();
254        assert!(result.is_some());
255        assert_eq!(result.unwrap(), temp_dir);
256
257        // Clean up
258        env::remove_var("FABRYK_TEST_ROOT_ROOT");
259        let _ = std::fs::remove_dir_all(&temp_dir);
260    }
261
262    #[test]
263    fn test_config_dir_with_fallback() {
264        let temp_dir = std::env::temp_dir().join("fabryk_test_config_fallback");
265        let _ = std::fs::create_dir_all(&temp_dir);
266
267        let resolver = PathResolver::new("nonexistent-fabryk-project")
268            .with_config_fallback(&temp_dir.to_string_lossy());
269
270        let result = resolver.config_dir();
271        assert!(result.is_some());
272        assert_eq!(result.unwrap(), temp_dir);
273
274        // Clean up
275        let _ = std::fs::remove_dir_all(&temp_dir);
276    }
277
278    #[test]
279    fn test_project_root_with_fallback() {
280        let temp_dir = std::env::temp_dir().join("fabryk_test_root_fallback");
281        let _ = std::fs::create_dir_all(&temp_dir);
282
283        let resolver = PathResolver::new("nonexistent-fabryk-project")
284            .with_project_fallback(&temp_dir.to_string_lossy());
285
286        let result = resolver.project_root();
287        assert!(result.is_some());
288        assert_eq!(result.unwrap(), temp_dir);
289
290        // Clean up
291        let _ = std::fs::remove_dir_all(&temp_dir);
292    }
293
294    #[test]
295    fn test_builder_pattern() {
296        let resolver = PathResolver::new("my-app")
297            .with_config_marker("config/settings.toml")
298            .with_project_markers(&["Cargo.toml", "package.json"])
299            .with_config_fallback("~/.config/my-app")
300            .with_project_fallback("~/projects/my-app");
301
302        assert_eq!(resolver.project_name(), "my-app");
303        assert_eq!(resolver.env_prefix(), "MY_APP");
304    }
305
306    #[test]
307    fn test_config_dir_nonexistent_env_var() {
308        let resolver = PathResolver::new("definitely-nonexistent-fabryk-xyz");
309
310        // No env var set, no markers, no fallback
311        let result = resolver.config_dir();
312        assert!(result.is_none());
313    }
314
315    #[test]
316    fn test_project_root_nonexistent() {
317        let resolver = PathResolver::new("definitely-nonexistent-fabryk-xyz");
318
319        // No env var set, no markers, no fallback
320        let result = resolver.project_root();
321        assert!(result.is_none());
322    }
323}