use crate::config::hierarchy::ConfigLevel;
use crate::{Error, Result};
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::PathBuf;
use tokio::fs;
use tracing::{info, warn};
pub mod audit_log;
pub mod validator;
pub use validator::ConfigValidator;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LockEntry {
pub value: String,
pub locked_at: DateTime<Utc>,
pub locked_by: String,
pub reason: String,
pub level: ConfigLevel,
}
impl LockEntry {
pub fn new(value: impl Into<String>, reason: impl Into<String>, level: ConfigLevel) -> Self {
Self {
value: value.into(),
locked_at: Utc::now(),
locked_by: whoami::username(),
reason: reason.into(),
level,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct LockedConfig {
pub locks: HashMap<String, LockEntry>,
pub version: String,
}
impl LockedConfig {
pub fn new() -> Self {
Self {
locks: HashMap::new(),
version: "1.0.0".to_string(),
}
}
pub async fn load_from_level(level: ConfigLevel) -> Result<Option<Self>> {
let path = Self::lock_file_path_for_level(level)?;
if !path.exists() {
return Ok(None);
}
let contents = fs::read_to_string(&path).await.map_err(|e| {
Error::config(format!(
"Failed to read {} lock file: {}",
level.display_name(),
e
))
})?;
let locked: LockedConfig = toml::from_str(&contents).map_err(|e| {
Error::config(format!(
"Failed to parse {} lock file: {}",
level.display_name(),
e
))
})?;
info!(
"Loaded {} locks from {} level",
locked.locks.len(),
level.display_name()
);
Ok(Some(locked))
}
pub async fn save_to_level(&self, level: ConfigLevel) -> Result<()> {
let path = Self::lock_file_path_for_level(level)?;
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).await.map_err(|e| {
Error::config(format!(
"Failed to create directory for {} lock file: {}",
level.display_name(),
e
))
})?;
}
let contents = toml::to_string_pretty(self)
.map_err(|e| Error::config(format!("Failed to serialize lock file: {}", e)))?;
fs::write(&path, contents).await.map_err(|e| {
Error::config(format!(
"Failed to write {} lock file: {}",
level.display_name(),
e
))
})?;
info!(
"Saved {} lock file to {}",
level.display_name(),
path.display()
);
Ok(())
}
pub fn lock_file_path_for_level(level: ConfigLevel) -> Result<PathBuf> {
match level {
ConfigLevel::System => Ok(PathBuf::from("/etc/ferrous-forge/locked.toml")),
ConfigLevel::User => {
let config_dir = dirs::config_dir()
.ok_or_else(|| Error::config("Could not find config directory"))?;
Ok(config_dir.join("ferrous-forge").join("locked.toml"))
}
ConfigLevel::Project => Ok(PathBuf::from(".forge/locked.toml")),
}
}
pub fn is_locked(&self, key: &str) -> bool {
self.locks.contains_key(key)
}
pub fn get_lock(&self, key: &str) -> Option<&LockEntry> {
self.locks.get(key)
}
pub fn lock(&mut self, key: impl Into<String>, entry: LockEntry) {
let key = key.into();
self.locks.insert(key, entry);
}
pub fn unlock(&mut self, key: &str) -> Option<LockEntry> {
self.locks.remove(key)
}
pub fn list_locks(&self) -> Vec<(&String, &LockEntry)> {
self.locks.iter().collect()
}
}
pub struct HierarchicalLockManager {
system: Option<LockedConfig>,
user: Option<LockedConfig>,
project: Option<LockedConfig>,
}
impl HierarchicalLockManager {
pub async fn load() -> Result<Self> {
let system = LockedConfig::load_from_level(ConfigLevel::System).await?;
let user = LockedConfig::load_from_level(ConfigLevel::User).await?;
let project = LockedConfig::load_from_level(ConfigLevel::Project).await?;
Ok(Self {
system,
user,
project,
})
}
#[allow(clippy::collapsible_if)]
pub fn is_locked(&self, key: &str) -> Option<(ConfigLevel, &LockEntry)> {
if let Some(project) = &self.project {
if let Some(entry) = project.get_lock(key) {
return Some((ConfigLevel::Project, entry));
}
}
if let Some(user) = &self.user {
if let Some(entry) = user.get_lock(key) {
return Some((ConfigLevel::User, entry));
}
}
if let Some(system) = &self.system {
if let Some(entry) = system.get_lock(key) {
return Some((ConfigLevel::System, entry));
}
}
None
}
pub fn is_locked_at_level(&self, key: &str, level: ConfigLevel) -> Option<&LockEntry> {
let locks = match level {
ConfigLevel::System => self.system.as_ref(),
ConfigLevel::User => self.user.as_ref(),
ConfigLevel::Project => self.project.as_ref(),
};
locks.and_then(|l| l.get_lock(key))
}
pub fn get_effective_locks(&self) -> HashMap<String, (ConfigLevel, LockEntry)> {
let mut effective = HashMap::new();
if let Some(system) = &self.system {
for (key, entry) in &system.locks {
effective.insert(key.clone(), (ConfigLevel::System, entry.clone()));
}
}
if let Some(user) = &self.user {
for (key, entry) in &user.locks {
effective.insert(key.clone(), (ConfigLevel::User, entry.clone()));
}
}
if let Some(project) = &self.project {
for (key, entry) in &project.locks {
effective.insert(key.clone(), (ConfigLevel::Project, entry.clone()));
}
}
effective
}
#[allow(clippy::collapsible_if)]
pub async fn lock(
&mut self,
key: impl Into<String>,
value: impl Into<String>,
reason: impl Into<String>,
level: ConfigLevel,
) -> Result<()> {
let key = key.into();
let entry = LockEntry::new(value, reason, level);
if let Some((existing_level, _)) = self.is_locked(&key) {
if existing_level >= level {
warn!(
"Key '{}' is already locked at {} level",
key,
existing_level.display_name()
);
return Err(Error::config(format!(
"Key '{}' is already locked at {} level",
key,
existing_level.display_name()
)));
}
}
let locks = match level {
ConfigLevel::System => &mut self.system,
ConfigLevel::User => &mut self.user,
ConfigLevel::Project => &mut self.project,
};
if locks.is_none() {
*locks = Some(LockedConfig::new());
}
if let Some(config) = locks {
config.lock(key.clone(), entry);
config.save_to_level(level).await?;
info!("Locked key '{}' at {} level", key, level.display_name());
}
Ok(())
}
pub async fn unlock(
&mut self,
key: &str,
level: ConfigLevel,
reason: &str,
) -> Result<LockEntry> {
let locks = match level {
ConfigLevel::System => &mut self.system,
ConfigLevel::User => &mut self.user,
ConfigLevel::Project => &mut self.project,
};
let config = locks.as_mut().ok_or_else(|| {
Error::config(format!(
"No locks defined at {} level",
level.display_name()
))
})?;
let entry = config.unlock(key).ok_or_else(|| {
Error::config(format!(
"Key '{}' is not locked at {} level",
key,
level.display_name()
))
})?;
config.save_to_level(level).await?;
info!(
"Unlocked key '{}' at {} level. Reason: {}",
key,
level.display_name(),
reason
);
audit_log::log_unlock(key, &entry, level, reason).await?;
Ok(entry)
}
pub fn status_report(&self) -> String {
let mut report = String::from("Configuration Lock Status:\n\n");
let effective = self.get_effective_locks();
if effective.is_empty() {
report.push_str("No configuration values are currently locked.\n");
return report;
}
report.push_str(&format!("Total locked keys: {}\n\n", effective.len()));
for (key, (level, entry)) in effective {
report.push_str(&format!(
" {}: {} (locked at {} level)\n",
key,
entry.value,
level.display_name()
));
report.push_str(&format!(
" Locked by: {} at {}\n",
entry.locked_by, entry.locked_at
));
report.push_str(&format!(" Reason: {}\n\n", entry.reason));
}
report
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use super::*;
#[test]
fn test_lock_entry_creation() {
let entry = LockEntry::new("2024", "Required for project", ConfigLevel::Project);
assert_eq!(entry.value, "2024");
assert_eq!(entry.reason, "Required for project");
assert_eq!(entry.level, ConfigLevel::Project);
}
#[test]
fn test_locked_config_lock_unlock() {
let mut config = LockedConfig::new();
assert!(!config.is_locked("edition"));
let entry = LockEntry::new("2024", "Required", ConfigLevel::User);
config.lock("edition", entry.clone());
assert!(config.is_locked("edition"));
assert_eq!(config.get_lock("edition").unwrap().value, "2024");
let removed = config.unlock("edition");
assert!(removed.is_some());
assert!(!config.is_locked("edition"));
}
#[test]
fn test_locked_config_list_locks() {
let mut config = LockedConfig::new();
let entry1 = LockEntry::new("2024", "Required", ConfigLevel::User);
let entry2 = LockEntry::new("1.85", "Required", ConfigLevel::User);
config.lock("edition", entry1);
config.lock("rust-version", entry2);
let locks = config.list_locks();
assert_eq!(locks.len(), 2);
}
#[test]
fn test_hierarchical_lock_precedence() {
let mut system = LockedConfig::new();
let mut user = LockedConfig::new();
let mut project = LockedConfig::new();
system.lock(
"edition",
LockEntry::new("2021", "System default", ConfigLevel::System),
);
user.lock(
"edition",
LockEntry::new("2024", "User preference", ConfigLevel::User),
);
project.lock(
"rust-version",
LockEntry::new("1.88", "Project requirement", ConfigLevel::Project),
);
let manager = HierarchicalLockManager {
system: Some(system),
user: Some(user),
project: Some(project),
};
let result = manager.is_locked("edition");
assert!(result.is_some());
let (level, entry) = result.unwrap();
assert_eq!(level, ConfigLevel::User);
assert_eq!(entry.value, "2024");
let result = manager.is_locked("rust-version");
assert!(result.is_some());
let (level, entry) = result.unwrap();
assert_eq!(level, ConfigLevel::Project);
assert_eq!(entry.value, "1.88");
}
}