fabryk_core/util/
resolver.rs1use std::env;
25use std::path::PathBuf;
26
27use crate::util::paths::{binary_dir, expand_tilde, find_dir_with_marker};
28
29#[derive(Debug, Clone)]
31pub struct PathResolver {
32 project_name: String,
34 env_prefix: String,
36 config_marker: Option<String>,
38 project_markers: Vec<String>,
40 config_fallback: Option<PathBuf>,
42 project_fallback: Option<PathBuf>,
44}
45
46impl PathResolver {
47 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 pub fn with_config_marker(mut self, marker: &str) -> Self {
67 self.config_marker = Some(marker.to_string());
68 self
69 }
70
71 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 pub fn with_config_fallback(mut self, path: &str) -> Self {
79 self.config_fallback = Some(expand_tilde(path));
80 self
81 }
82
83 pub fn with_project_fallback(mut self, path: &str) -> Self {
85 self.project_fallback = Some(expand_tilde(path));
86 self
87 }
88
89 pub fn env_var(&self, suffix: &str) -> String {
99 format!("{}_{}", self.env_prefix, suffix)
100 }
101
102 pub fn config_dir(&self) -> Option<PathBuf> {
109 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 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 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 if let Some(fallback) = &self.config_fallback {
135 if fallback.exists() {
136 return Some(fallback.clone());
137 }
138 }
139
140 None
141 }
142
143 pub fn project_root(&self) -> Option<PathBuf> {
150 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 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 if let Some(fallback) = &self.project_fallback {
170 if fallback.exists() {
171 return Some(fallback.clone());
172 }
173 }
174
175 None
176 }
177
178 pub fn project_name(&self) -> &str {
180 &self.project_name
181 }
182
183 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 let temp_dir = std::env::temp_dir().join("fabryk_test_config_resolver");
228 let _ = std::fs::create_dir_all(&temp_dir);
229
230 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 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 let temp_dir = std::env::temp_dir().join("fabryk_test_root_resolver");
250 let _ = std::fs::create_dir_all(&temp_dir);
251
252 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 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 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 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 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 let result = resolver.project_root();
325 assert!(result.is_none());
326 }
327}