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        // SAFETY: This test runs serially and no other thread reads this env var.
232        unsafe { env::set_var("FABRYK_TEST_CONFIG_CONFIG_DIR", &temp_dir) };
233
234        let result = resolver.config_dir();
235        assert!(result.is_some());
236        assert_eq!(result.unwrap(), temp_dir);
237
238        // Clean up
239        // SAFETY: This test runs serially and no other thread reads this env var.
240        unsafe { env::remove_var("FABRYK_TEST_CONFIG_CONFIG_DIR") };
241        let _ = std::fs::remove_dir_all(&temp_dir);
242    }
243
244    #[test]
245    fn test_project_root_from_env() {
246        let resolver = PathResolver::new("fabryk-test-root");
247
248        // Create temp directory
249        let temp_dir = std::env::temp_dir().join("fabryk_test_root_resolver");
250        let _ = std::fs::create_dir_all(&temp_dir);
251
252        // Set env var
253        // SAFETY: This test runs serially and no other thread reads this env var.
254        unsafe { env::set_var("FABRYK_TEST_ROOT_ROOT", &temp_dir) };
255
256        let result = resolver.project_root();
257        assert!(result.is_some());
258        assert_eq!(result.unwrap(), temp_dir);
259
260        // Clean up
261        // SAFETY: This test runs serially and no other thread reads this env var.
262        unsafe { env::remove_var("FABRYK_TEST_ROOT_ROOT") };
263        let _ = std::fs::remove_dir_all(&temp_dir);
264    }
265
266    #[test]
267    fn test_config_dir_with_fallback() {
268        let temp_dir = std::env::temp_dir().join("fabryk_test_config_fallback");
269        let _ = std::fs::create_dir_all(&temp_dir);
270
271        let resolver = PathResolver::new("nonexistent-fabryk-project")
272            .with_config_fallback(&temp_dir.to_string_lossy());
273
274        let result = resolver.config_dir();
275        assert!(result.is_some());
276        assert_eq!(result.unwrap(), temp_dir);
277
278        // Clean up
279        let _ = std::fs::remove_dir_all(&temp_dir);
280    }
281
282    #[test]
283    fn test_project_root_with_fallback() {
284        let temp_dir = std::env::temp_dir().join("fabryk_test_root_fallback");
285        let _ = std::fs::create_dir_all(&temp_dir);
286
287        let resolver = PathResolver::new("nonexistent-fabryk-project")
288            .with_project_fallback(&temp_dir.to_string_lossy());
289
290        let result = resolver.project_root();
291        assert!(result.is_some());
292        assert_eq!(result.unwrap(), temp_dir);
293
294        // Clean up
295        let _ = std::fs::remove_dir_all(&temp_dir);
296    }
297
298    #[test]
299    fn test_builder_pattern() {
300        let resolver = PathResolver::new("my-app")
301            .with_config_marker("config/settings.toml")
302            .with_project_markers(&["Cargo.toml", "package.json"])
303            .with_config_fallback("~/.config/my-app")
304            .with_project_fallback("~/projects/my-app");
305
306        assert_eq!(resolver.project_name(), "my-app");
307        assert_eq!(resolver.env_prefix(), "MY_APP");
308    }
309
310    #[test]
311    fn test_config_dir_nonexistent_env_var() {
312        let resolver = PathResolver::new("definitely-nonexistent-fabryk-xyz");
313
314        // No env var set, no markers, no fallback
315        let result = resolver.config_dir();
316        assert!(result.is_none());
317    }
318
319    #[test]
320    fn test_project_root_nonexistent() {
321        let resolver = PathResolver::new("definitely-nonexistent-fabryk-xyz");
322
323        // No env var set, no markers, no fallback
324        let result = resolver.project_root();
325        assert!(result.is_none());
326    }
327}