ferrous_forge/config/locking/
mod.rs1use crate::config::hierarchy::ConfigLevel;
10use crate::{Error, Result};
11use chrono::{DateTime, Utc};
12use serde::{Deserialize, Serialize};
13use std::collections::HashMap;
14use std::path::PathBuf;
15use tokio::fs;
16use tracing::{info, warn};
17
18pub mod audit_log;
20pub mod validator;
22
23pub use validator::ConfigValidator;
24
25#[derive(Debug, Clone, Serialize, Deserialize)]
27pub struct LockEntry {
28 pub value: String,
30 pub locked_at: DateTime<Utc>,
32 pub locked_by: String,
34 pub reason: String,
36 pub level: ConfigLevel,
38}
39
40impl LockEntry {
41 pub fn new(value: impl Into<String>, reason: impl Into<String>, level: ConfigLevel) -> Self {
43 Self {
44 value: value.into(),
45 locked_at: Utc::now(),
46 locked_by: whoami::username(),
47 reason: reason.into(),
48 level,
49 }
50 }
51}
52
53#[derive(Debug, Clone, Serialize, Deserialize, Default)]
55pub struct LockedConfig {
56 pub locks: HashMap<String, LockEntry>,
58 pub version: String,
60}
61
62impl LockedConfig {
63 pub fn new() -> Self {
65 Self {
66 locks: HashMap::new(),
67 version: "1.0.0".to_string(),
68 }
69 }
70
71 pub async fn load_from_level(level: ConfigLevel) -> Result<Option<Self>> {
77 let path = Self::lock_file_path_for_level(level)?;
78
79 if !path.exists() {
80 return Ok(None);
81 }
82
83 let contents = fs::read_to_string(&path).await.map_err(|e| {
84 Error::config(format!(
85 "Failed to read {} lock file: {}",
86 level.display_name(),
87 e
88 ))
89 })?;
90
91 let locked: LockedConfig = toml::from_str(&contents).map_err(|e| {
92 Error::config(format!(
93 "Failed to parse {} lock file: {}",
94 level.display_name(),
95 e
96 ))
97 })?;
98
99 info!(
100 "Loaded {} locks from {} level",
101 locked.locks.len(),
102 level.display_name()
103 );
104 Ok(Some(locked))
105 }
106
107 pub async fn save_to_level(&self, level: ConfigLevel) -> Result<()> {
113 let path = Self::lock_file_path_for_level(level)?;
114
115 if let Some(parent) = path.parent() {
117 fs::create_dir_all(parent).await.map_err(|e| {
118 Error::config(format!(
119 "Failed to create directory for {} lock file: {}",
120 level.display_name(),
121 e
122 ))
123 })?;
124 }
125
126 let contents = toml::to_string_pretty(self)
127 .map_err(|e| Error::config(format!("Failed to serialize lock file: {}", e)))?;
128
129 fs::write(&path, contents).await.map_err(|e| {
130 Error::config(format!(
131 "Failed to write {} lock file: {}",
132 level.display_name(),
133 e
134 ))
135 })?;
136
137 info!(
138 "Saved {} lock file to {}",
139 level.display_name(),
140 path.display()
141 );
142 Ok(())
143 }
144
145 pub fn lock_file_path_for_level(level: ConfigLevel) -> Result<PathBuf> {
151 match level {
152 ConfigLevel::System => Ok(PathBuf::from("/etc/ferrous-forge/locked.toml")),
153 ConfigLevel::User => {
154 let config_dir = dirs::config_dir()
155 .ok_or_else(|| Error::config("Could not find config directory"))?;
156 Ok(config_dir.join("ferrous-forge").join("locked.toml"))
157 }
158 ConfigLevel::Project => Ok(PathBuf::from(".forge/locked.toml")),
159 }
160 }
161
162 pub fn is_locked(&self, key: &str) -> bool {
164 self.locks.contains_key(key)
165 }
166
167 pub fn get_lock(&self, key: &str) -> Option<&LockEntry> {
169 self.locks.get(key)
170 }
171
172 pub fn lock(&mut self, key: impl Into<String>, entry: LockEntry) {
174 let key = key.into();
175 self.locks.insert(key, entry);
176 }
177
178 pub fn unlock(&mut self, key: &str) -> Option<LockEntry> {
180 self.locks.remove(key)
181 }
182
183 pub fn list_locks(&self) -> Vec<(&String, &LockEntry)> {
185 self.locks.iter().collect()
186 }
187}
188
189pub struct HierarchicalLockManager {
191 system: Option<LockedConfig>,
193 user: Option<LockedConfig>,
195 project: Option<LockedConfig>,
197}
198
199impl HierarchicalLockManager {
200 pub async fn load() -> Result<Self> {
206 let system = LockedConfig::load_from_level(ConfigLevel::System).await?;
207 let user = LockedConfig::load_from_level(ConfigLevel::User).await?;
208 let project = LockedConfig::load_from_level(ConfigLevel::Project).await?;
209
210 Ok(Self {
211 system,
212 user,
213 project,
214 })
215 }
216
217 #[allow(clippy::collapsible_if)]
221 pub fn is_locked(&self, key: &str) -> Option<(ConfigLevel, &LockEntry)> {
222 if let Some(project) = &self.project {
224 if let Some(entry) = project.get_lock(key) {
225 return Some((ConfigLevel::Project, entry));
226 }
227 }
228
229 if let Some(user) = &self.user {
230 if let Some(entry) = user.get_lock(key) {
231 return Some((ConfigLevel::User, entry));
232 }
233 }
234
235 if let Some(system) = &self.system {
236 if let Some(entry) = system.get_lock(key) {
237 return Some((ConfigLevel::System, entry));
238 }
239 }
240
241 None
242 }
243
244 pub fn is_locked_at_level(&self, key: &str, level: ConfigLevel) -> Option<&LockEntry> {
246 let locks = match level {
247 ConfigLevel::System => self.system.as_ref(),
248 ConfigLevel::User => self.user.as_ref(),
249 ConfigLevel::Project => self.project.as_ref(),
250 };
251
252 locks.and_then(|l| l.get_lock(key))
253 }
254
255 pub fn get_effective_locks(&self) -> HashMap<String, (ConfigLevel, LockEntry)> {
257 let mut effective = HashMap::new();
258
259 if let Some(system) = &self.system {
261 for (key, entry) in &system.locks {
262 effective.insert(key.clone(), (ConfigLevel::System, entry.clone()));
263 }
264 }
265
266 if let Some(user) = &self.user {
267 for (key, entry) in &user.locks {
268 effective.insert(key.clone(), (ConfigLevel::User, entry.clone()));
269 }
270 }
271
272 if let Some(project) = &self.project {
273 for (key, entry) in &project.locks {
274 effective.insert(key.clone(), (ConfigLevel::Project, entry.clone()));
275 }
276 }
277
278 effective
279 }
280
281 #[allow(clippy::collapsible_if)]
287 pub async fn lock(
288 &mut self,
289 key: impl Into<String>,
290 value: impl Into<String>,
291 reason: impl Into<String>,
292 level: ConfigLevel,
293 ) -> Result<()> {
294 let key = key.into();
295 let entry = LockEntry::new(value, reason, level);
296
297 if let Some((existing_level, _)) = self.is_locked(&key) {
299 if existing_level >= level {
300 warn!(
301 "Key '{}' is already locked at {} level",
302 key,
303 existing_level.display_name()
304 );
305 return Err(Error::config(format!(
306 "Key '{}' is already locked at {} level",
307 key,
308 existing_level.display_name()
309 )));
310 }
311 }
312
313 let locks = match level {
315 ConfigLevel::System => &mut self.system,
316 ConfigLevel::User => &mut self.user,
317 ConfigLevel::Project => &mut self.project,
318 };
319
320 if locks.is_none() {
321 *locks = Some(LockedConfig::new());
322 }
323
324 if let Some(config) = locks {
325 config.lock(key.clone(), entry);
326 config.save_to_level(level).await?;
327
328 info!("Locked key '{}' at {} level", key, level.display_name());
329 }
330
331 Ok(())
332 }
333
334 pub async fn unlock(
340 &mut self,
341 key: &str,
342 level: ConfigLevel,
343 reason: &str,
344 ) -> Result<LockEntry> {
345 let locks = match level {
347 ConfigLevel::System => &mut self.system,
348 ConfigLevel::User => &mut self.user,
349 ConfigLevel::Project => &mut self.project,
350 };
351
352 let config = locks.as_mut().ok_or_else(|| {
353 Error::config(format!(
354 "No locks defined at {} level",
355 level.display_name()
356 ))
357 })?;
358
359 let entry = config.unlock(key).ok_or_else(|| {
360 Error::config(format!(
361 "Key '{}' is not locked at {} level",
362 key,
363 level.display_name()
364 ))
365 })?;
366
367 config.save_to_level(level).await?;
369
370 info!(
371 "Unlocked key '{}' at {} level. Reason: {}",
372 key,
373 level.display_name(),
374 reason
375 );
376
377 audit_log::log_unlock(key, &entry, level, reason).await?;
379
380 Ok(entry)
381 }
382
383 pub fn status_report(&self) -> String {
385 let mut report = String::from("Configuration Lock Status:\n\n");
386
387 let effective = self.get_effective_locks();
388
389 if effective.is_empty() {
390 report.push_str("No configuration values are currently locked.\n");
391 return report;
392 }
393
394 report.push_str(&format!("Total locked keys: {}\n\n", effective.len()));
395
396 for (key, (level, entry)) in effective {
397 report.push_str(&format!(
398 " {}: {} (locked at {} level)\n",
399 key,
400 entry.value,
401 level.display_name()
402 ));
403 report.push_str(&format!(
404 " Locked by: {} at {}\n",
405 entry.locked_by, entry.locked_at
406 ));
407 report.push_str(&format!(" Reason: {}\n\n", entry.reason));
408 }
409
410 report
411 }
412}
413
414#[cfg(test)]
415mod tests {
416 use super::*;
417
418 #[test]
419 fn test_lock_entry_creation() {
420 let entry = LockEntry::new("2024", "Required for project", ConfigLevel::Project);
421 assert_eq!(entry.value, "2024");
422 assert_eq!(entry.reason, "Required for project");
423 assert_eq!(entry.level, ConfigLevel::Project);
424 }
425
426 #[test]
427 fn test_locked_config_lock_unlock() {
428 let mut config = LockedConfig::new();
429 assert!(!config.is_locked("edition"));
430
431 let entry = LockEntry::new("2024", "Required", ConfigLevel::User);
432 config.lock("edition", entry.clone());
433 assert!(config.is_locked("edition"));
434 assert_eq!(config.get_lock("edition").unwrap().value, "2024");
435
436 let removed = config.unlock("edition");
437 assert!(removed.is_some());
438 assert!(!config.is_locked("edition"));
439 }
440
441 #[test]
442 fn test_locked_config_list_locks() {
443 let mut config = LockedConfig::new();
444 let entry1 = LockEntry::new("2024", "Required", ConfigLevel::User);
445 let entry2 = LockEntry::new("1.85", "Required", ConfigLevel::User);
446
447 config.lock("edition", entry1);
448 config.lock("rust-version", entry2);
449
450 let locks = config.list_locks();
451 assert_eq!(locks.len(), 2);
452 }
453
454 #[test]
455 fn test_hierarchical_lock_precedence() {
456 let mut system = LockedConfig::new();
457 let mut user = LockedConfig::new();
458 let mut project = LockedConfig::new();
459
460 system.lock(
461 "edition",
462 LockEntry::new("2021", "System default", ConfigLevel::System),
463 );
464 user.lock(
465 "edition",
466 LockEntry::new("2024", "User preference", ConfigLevel::User),
467 );
468 project.lock(
469 "rust-version",
470 LockEntry::new("1.88", "Project requirement", ConfigLevel::Project),
471 );
472
473 let manager = HierarchicalLockManager {
474 system: Some(system),
475 user: Some(user),
476 project: Some(project),
477 };
478
479 let result = manager.is_locked("edition");
481 assert!(result.is_some());
482 let (level, entry) = result.unwrap();
483 assert_eq!(level, ConfigLevel::User);
484 assert_eq!(entry.value, "2024");
485
486 let result = manager.is_locked("rust-version");
488 assert!(result.is_some());
489 let (level, entry) = result.unwrap();
490 assert_eq!(level, ConfigLevel::Project);
491 assert_eq!(entry.value, "1.88");
492 }
493}