use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::{Arc, RwLock};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum TrustDecision {
Trusted,
NotTrusted,
Undecided,
}
impl Default for TrustDecision {
fn default() -> Self {
TrustDecision::Undecided
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProjectTrust {
pub path: String,
pub decision: TrustDecision,
pub decided_at: Option<i64>,
pub decided_by: Option<String>,
}
#[derive(Debug, Clone)]
pub struct TrustStore {
decisions: Arc<RwLock<HashMap<String, ProjectTrust>>>,
store_path: PathBuf,
}
impl TrustStore {
pub fn new(store_path: PathBuf) -> Self {
Self {
decisions: Arc::new(RwLock::new(HashMap::new())),
store_path,
}
}
pub fn load(&self) -> Result<(), TrustError> {
if !self.store_path.exists() {
return Ok(());
}
let content = std::fs::read_to_string(&self.store_path)
.map_err(|e| TrustError::IoError(e.to_string()))?;
let decisions: HashMap<String, ProjectTrust> =
serde_json::from_str(&content).map_err(|e| TrustError::ParseError(e.to_string()))?;
let mut store = self.decisions.write().map_err(|_| TrustError::LockError)?;
*store = decisions;
Ok(())
}
pub fn save(&self) -> Result<(), TrustError> {
let decisions = self.decisions.read().map_err(|_| TrustError::LockError)?;
let content = serde_json::to_string_pretty(&*decisions)
.map_err(|e| TrustError::SerializeError(e.to_string()))?;
if let Some(parent) = self.store_path.parent() {
std::fs::create_dir_all(parent).map_err(|e| TrustError::IoError(e.to_string()))?;
}
std::fs::write(&self.store_path, content)
.map_err(|e| TrustError::IoError(e.to_string()))?;
Ok(())
}
pub fn get_decision(&self, project_path: &Path) -> TrustDecision {
let normalized = normalize_path(project_path);
let decisions = self.decisions.read().unwrap_or_else(|e| e.into_inner());
decisions
.get(&normalized)
.map(|t| t.decision)
.unwrap_or(TrustDecision::Undecided)
}
pub fn set_decision(
&self,
project_path: &Path,
decision: TrustDecision,
decided_by: Option<String>,
) -> Result<(), TrustError> {
let normalized = normalize_path(project_path);
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_millis() as i64)
.unwrap_or(0);
let trust = ProjectTrust {
path: normalized.clone(),
decision,
decided_at: Some(now),
decided_by,
};
let mut decisions = self.decisions.write().map_err(|_| TrustError::LockError)?;
decisions.insert(normalized, trust);
Ok(())
}
pub fn is_trusted(&self, project_path: &Path) -> bool {
self.get_decision(project_path) == TrustDecision::Trusted
}
pub fn remove_decision(&self, project_path: &Path) -> Result<(), TrustError> {
let normalized = normalize_path(project_path);
let mut decisions = self.decisions.write().map_err(|_| TrustError::LockError)?;
decisions.remove(&normalized);
Ok(())
}
pub fn all_decisions(&self) -> Vec<ProjectTrust> {
let decisions = self.decisions.read().unwrap_or_else(|e| e.into_inner());
decisions.values().cloned().collect()
}
}
fn normalize_path(path: &Path) -> String {
let absolute = if path.is_absolute() {
path.to_path_buf()
} else {
std::env::current_dir()
.unwrap_or_else(|_| PathBuf::from("."))
.join(path)
};
absolute
.to_string_lossy()
.replace('\\', "/")
.trim_end_matches('/')
.to_string()
}
#[derive(Debug, Clone)]
pub enum TrustError {
IoError(String),
ParseError(String),
SerializeError(String),
LockError,
}
impl std::fmt::Display for TrustError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
TrustError::IoError(e) => write!(f, "I/O error: {}", e),
TrustError::ParseError(e) => write!(f, "Parse error: {}", e),
TrustError::SerializeError(e) => write!(f, "Serialize error: {}", e),
TrustError::LockError => write!(f, "Lock error"),
}
}
}
impl std::error::Error for TrustError {}
pub fn should_trust_project(
project_path: &Path,
trust_store: &TrustStore,
cli_approve: Option<bool>,
) -> bool {
if let Some(approve) = cli_approve {
return approve;
}
match trust_store.get_decision(project_path) {
TrustDecision::Trusted => return true,
TrustDecision::NotTrusted => return false,
TrustDecision::Undecided => {}
}
if let Some(home) = dirs::home_dir() {
if project_path.starts_with(&home) {
return true;
}
}
false
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
#[test]
fn test_trust_decision_default() {
assert_eq!(TrustDecision::default(), TrustDecision::Undecided);
}
#[test]
fn test_trust_store_new() {
let temp_dir = TempDir::new().unwrap();
let store_path = temp_dir.path().join("trust.json");
let store = TrustStore::new(store_path);
assert_eq!(store.all_decisions().len(), 0);
}
#[test]
fn test_trust_store_set_and_get() {
let temp_dir = TempDir::new().unwrap();
let store_path = temp_dir.path().join("trust.json");
let store = TrustStore::new(store_path);
let project_path = Path::new("/test/project");
assert_eq!(store.get_decision(project_path), TrustDecision::Undecided);
store
.set_decision(
project_path,
TrustDecision::Trusted,
Some("test".to_string()),
)
.unwrap();
assert_eq!(store.get_decision(project_path), TrustDecision::Trusted);
assert!(store.is_trusted(project_path));
store
.set_decision(project_path, TrustDecision::NotTrusted, None)
.unwrap();
assert_eq!(store.get_decision(project_path), TrustDecision::NotTrusted);
assert!(!store.is_trusted(project_path));
}
#[test]
fn test_trust_store_save_and_load() {
let temp_dir = TempDir::new().unwrap();
let store_path = temp_dir.path().join("trust.json");
let store1 = TrustStore::new(store_path.clone());
let project1 = Path::new("/test/project1");
let project2 = Path::new("/test/project2");
store1
.set_decision(project1, TrustDecision::Trusted, Some("user".to_string()))
.unwrap();
store1
.set_decision(project2, TrustDecision::NotTrusted, None)
.unwrap();
store1.save().unwrap();
let store2 = TrustStore::new(store_path);
store2.load().unwrap();
assert_eq!(store2.get_decision(project1), TrustDecision::Trusted);
assert_eq!(store2.get_decision(project2), TrustDecision::NotTrusted);
}
#[test]
fn test_normalize_path() {
let path1 = Path::new("/test/project");
let path2 = Path::new("/test/project/");
assert_eq!(normalize_path(path1), normalize_path(path2));
}
#[test]
fn test_should_trust_project_with_cli_flag() {
let temp_dir = TempDir::new().unwrap();
let store_path = temp_dir.path().join("trust.json");
let store = TrustStore::new(store_path);
let project_path = Path::new("/test/project");
assert!(should_trust_project(project_path, &store, Some(true)));
assert!(!should_trust_project(project_path, &store, Some(false)));
}
#[test]
fn test_should_trust_project_with_store() {
let temp_dir = TempDir::new().unwrap();
let store_path = temp_dir.path().join("trust.json");
let store = TrustStore::new(store_path);
let project_path = Path::new("/test/project");
store
.set_decision(project_path, TrustDecision::Trusted, None)
.unwrap();
assert!(should_trust_project(project_path, &store, None));
store
.set_decision(project_path, TrustDecision::NotTrusted, None)
.unwrap();
assert!(!should_trust_project(project_path, &store, None));
}
}