raz_config/
hierarchy.rs

1//! Configuration hierarchy management
2//!
3//! Implements a three-level configuration system:
4//! 1. Project-level (.raz in nearest parent with Cargo.toml or existing .raz)
5//! 2. Workspace-level (.raz in Cargo workspace root)
6//! 3. Global-level (~/.raz)
7
8use crate::error::{ConfigError, Result};
9use std::env;
10use std::path::{Path, PathBuf};
11
12/// Configuration levels in order of precedence
13#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
14pub enum ConfigLevel {
15    /// Project-specific configuration
16    Project,
17    /// Workspace-level configuration
18    Workspace,
19    /// Global user configuration
20    Global,
21}
22
23/// Represents a configuration location
24#[derive(Debug, Clone)]
25pub struct ConfigLocation {
26    pub level: ConfigLevel,
27    pub path: PathBuf,
28    pub exists: bool,
29}
30
31/// Configuration hierarchy manager
32pub struct ConfigHierarchy {
33    locations: Vec<ConfigLocation>,
34}
35
36impl ConfigHierarchy {
37    /// Discover configuration hierarchy for a given path
38    pub fn discover(start_path: &Path) -> Result<Self> {
39        let mut locations = Vec::new();
40
41        // 1. Find project-level config
42        if let Some(project_root) = find_project_root(start_path) {
43            let config_path = project_root.join(".raz");
44            locations.push(ConfigLocation {
45                level: ConfigLevel::Project,
46                path: config_path.clone(),
47                exists: config_path.exists(),
48            });
49        }
50
51        // 2. Find workspace-level config (if different from project)
52        if let Some(workspace_root) = find_workspace_root(start_path) {
53            let config_path = workspace_root.join(".raz");
54
55            // Only add if it's different from project level
56            if !locations.iter().any(|loc| loc.path == config_path) {
57                locations.push(ConfigLocation {
58                    level: ConfigLevel::Workspace,
59                    path: config_path.clone(),
60                    exists: config_path.exists(),
61                });
62            }
63        }
64
65        // 3. Global config
66        if let Some(global_path) = get_global_config_path() {
67            locations.push(ConfigLocation {
68                level: ConfigLevel::Global,
69                path: global_path.clone(),
70                exists: global_path.exists(),
71            });
72        }
73
74        Ok(Self { locations })
75    }
76
77    /// Get all configuration locations in order of precedence
78    pub fn locations(&self) -> &[ConfigLocation] {
79        &self.locations
80    }
81
82    /// Get the most specific existing configuration location
83    pub fn primary_location(&self) -> Option<&ConfigLocation> {
84        self.locations.iter().find(|loc| loc.exists)
85    }
86
87    /// Get or create the most appropriate configuration location
88    pub fn get_or_create_location(
89        &self,
90        prefer_level: Option<ConfigLevel>,
91    ) -> Result<&ConfigLocation> {
92        // If a specific level is preferred and exists, use it
93        if let Some(level) = prefer_level {
94            if let Some(loc) = self.locations.iter().find(|l| l.level == level) {
95                return Ok(loc);
96            }
97        }
98
99        // Otherwise, use the most specific location (first in list)
100        self.locations.first().ok_or_else(|| {
101            ConfigError::ValidationError("No configuration location available".to_string())
102        })
103    }
104
105    /// Initialize a configuration at the specified level
106    pub fn init_config(&self, level: ConfigLevel) -> Result<PathBuf> {
107        let location = self
108            .locations
109            .iter()
110            .find(|loc| loc.level == level)
111            .ok_or_else(|| {
112                ConfigError::ValidationError(format!("No {level:?} level configuration path found"))
113            })?;
114
115        // Create the .raz directory if it doesn't exist
116        std::fs::create_dir_all(&location.path)?;
117
118        Ok(location.path.clone())
119    }
120
121    /// Get the path for override storage at the appropriate level
122    pub fn get_override_storage_path(&self) -> Result<PathBuf> {
123        // For overrides, prefer project level if available
124        let location = self.get_or_create_location(Some(ConfigLevel::Project))?;
125        Ok(location.path.join("overrides.toml"))
126    }
127
128    /// Aggregate override files from all levels
129    pub fn get_all_override_paths(&self) -> Vec<PathBuf> {
130        self.locations
131            .iter()
132            .filter(|loc| loc.exists)
133            .map(|loc| loc.path.join("overrides.toml"))
134            .filter(|path| path.exists())
135            .collect()
136    }
137}
138
139/// Find the nearest project root (directory with Cargo.toml or existing .raz)
140fn find_project_root(start_path: &Path) -> Option<PathBuf> {
141    let mut current = if start_path.is_file() {
142        start_path.parent()?
143    } else {
144        start_path
145    };
146
147    loop {
148        // Check for Cargo.toml (indicating a Rust project)
149        if current.join("Cargo.toml").exists() {
150            return Some(current.to_path_buf());
151        }
152
153        // Check for existing .raz directory (for non-Cargo projects)
154        if current.join(".raz").exists() {
155            return Some(current.to_path_buf());
156        }
157
158        current = current.parent()?;
159    }
160}
161
162/// Find the Cargo workspace root
163fn find_workspace_root(start_path: &Path) -> Option<PathBuf> {
164    let mut current = if start_path.is_file() {
165        start_path.parent()?
166    } else {
167        start_path
168    };
169
170    let mut _last_cargo_root = None;
171
172    loop {
173        let cargo_toml = current.join("Cargo.toml");
174        if cargo_toml.exists() {
175            // Check if this is a workspace root
176            if let Ok(content) = std::fs::read_to_string(&cargo_toml) {
177                if content.contains("[workspace]") {
178                    return Some(current.to_path_buf());
179                }
180            }
181            _last_cargo_root = Some(current.to_path_buf());
182        }
183
184        current = current.parent()?;
185    }
186}
187
188/// Get the global configuration directory path
189fn get_global_config_path() -> Option<PathBuf> {
190    if let Ok(home) = env::var("HOME") {
191        Some(PathBuf::from(home).join(".raz"))
192    } else if let Ok(userprofile) = env::var("USERPROFILE") {
193        // Windows fallback
194        Some(PathBuf::from(userprofile).join(".raz"))
195    } else {
196        None
197    }
198}
199
200#[cfg(test)]
201mod tests {
202    use super::*;
203    use std::fs;
204    use tempfile::TempDir;
205
206    #[test]
207    fn test_config_hierarchy_discovery() {
208        let temp = TempDir::new().unwrap();
209        let root = temp.path();
210
211        // Create a workspace structure
212        let workspace_cargo = "[workspace]\nmembers = [\"crate1\"]";
213        fs::write(root.join("Cargo.toml"), workspace_cargo).unwrap();
214        fs::create_dir_all(root.join(".raz")).unwrap();
215
216        // Create a member crate
217        let crate1 = root.join("crate1");
218        fs::create_dir_all(&crate1).unwrap();
219        fs::write(crate1.join("Cargo.toml"), "[package]\nname = \"crate1\"").unwrap();
220
221        // Test from within the crate
222        let hierarchy = ConfigHierarchy::discover(&crate1).unwrap();
223        let locations = hierarchy.locations();
224
225        // Should have project (crate1), workspace (root), and global
226        assert!(locations.len() >= 2); // At least project and workspace
227
228        // First should be project level
229        assert_eq!(locations[0].level, ConfigLevel::Project);
230        assert_eq!(locations[0].path, crate1.join(".raz"));
231
232        // Second should be workspace level
233        assert_eq!(locations[1].level, ConfigLevel::Workspace);
234        assert_eq!(locations[1].path, root.join(".raz"));
235    }
236
237    #[test]
238    fn test_standalone_file_config() {
239        let temp = TempDir::new().unwrap();
240        let root = temp.path();
241
242        // Create a standalone Rust file
243        let rust_file = root.join("standalone.rs");
244        fs::write(&rust_file, "fn main() {}").unwrap();
245
246        // Create .raz directory in the same location
247        fs::create_dir_all(root.join(".raz")).unwrap();
248
249        let hierarchy = ConfigHierarchy::discover(&rust_file).unwrap();
250        let locations = hierarchy.locations();
251
252        // Should find the .raz directory
253        assert!(!locations.is_empty());
254        assert_eq!(locations[0].level, ConfigLevel::Project);
255        assert_eq!(locations[0].path, root.join(".raz"));
256        assert!(locations[0].exists);
257    }
258}