//! State persistence for doot. use serde::{Deserialize, Serialize}; use std::collections::HashMap; use std::path::{Path, PathBuf}; use thiserror::Error; /// State storage errors. #[derive(Error, Debug)] pub enum StateError { #[error("io error: {0}")] IoError(#[from] std::io::Error), #[error("serialization error: {0}")] SerializationError(#[from] serde_json::Error), } /// Persistent doot state. #[derive(Debug, Clone, Serialize, Deserialize, Default)] pub struct State { pub version: u32, pub deployments: HashMap, pub packages: HashMap, pub snapshots: Vec, } /// Deploy mode for a file. #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Default)] pub enum DeployMode { #[default] Copy, Link, } /// Record of a deployed file. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct DeploymentRecord { pub source: PathBuf, pub target: PathBuf, pub source_hash: String, pub target_hash: String, pub deployed_at: String, pub mode: DeployMode, /// Whether this file was deployed as a template (source != target content). #[serde(default)] pub template: bool, } /// Sync status after comparing current hashes with recorded state. #[derive(Debug, Clone, Copy, PartialEq)] pub enum SyncStatus { Synced, SourceChanged, TargetChanged, Conflict, NotDeployed, TargetMissing, SourceMissing, } /// Record of an installed package. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct PackageRecord { pub name: String, pub manager: String, pub installed_at: String, } /// Manages doot state persistence. pub struct StateStore { path: PathBuf, state: State, dirty: bool, } impl StateStore { /// Loads or creates a state store at the given path. #[tracing::instrument(skip_all, fields(path = %path.display()))] pub fn new(path: &Path) -> Self { let state = if path.exists() { std::fs::read_to_string(path) .ok() .and_then(|s| serde_json::from_str(&s).ok()) .unwrap_or_default() } else { State::default() }; Self { path: path.to_path_buf(), state, dirty: false, } } /// Records a deployment with both source and target hashes. #[tracing::instrument(skip(self), fields(source = %source.display(), target = %target.display()))] pub fn record_deployment(&mut self, source: &Path, target: &Path, mode: DeployMode) { self.record_deployment_with_template(source, target, mode, false); } /// Records a deployment with template flag. #[tracing::instrument(skip(self), fields(source = %source.display(), target = %target.display()))] pub fn record_deployment_with_template( &mut self, source: &Path, target: &Path, mode: DeployMode, template: bool, ) { let source_hash = hash_path(source); let target_hash = hash_path(target); let record = DeploymentRecord { source: source.to_path_buf(), target: target.to_path_buf(), source_hash, target_hash, deployed_at: chrono_now(), mode, template, }; self.state .deployments .insert(target.display().to_string(), record); self.dirty = true; } /// Checks sync status by comparing current hashes with recorded state. #[tracing::instrument(level = "trace", skip(self))] pub fn check_sync_status(&self, source: &Path, target: &Path) -> SyncStatus { self.check_sync_status_with_config(source, target, None, None) } /// Checks sync status, also detecting if template flag changed in config. #[tracing::instrument(level = "trace", skip(self))] pub fn check_sync_status_with_template( &self, source: &Path, target: &Path, current_template: Option, ) -> SyncStatus { self.check_sync_status_with_config(source, target, current_template, None) } /// Checks sync status, also detecting if config flags changed. #[tracing::instrument(level = "trace", skip(self))] pub fn check_sync_status_with_config( &self, source: &Path, target: &Path, current_template: Option, current_mode: Option, ) -> SyncStatus { let Some(record) = self.get_deployment(target) else { return SyncStatus::NotDeployed; }; // If template flag changed in config, force re-deploy if let Some(is_template) = current_template && is_template != record.template { return SyncStatus::SourceChanged; } // If deploy mode changed in config, force re-deploy if let Some(mode) = current_mode && mode != record.mode { return SyncStatus::SourceChanged; } if !source.exists() { return SyncStatus::SourceMissing; } if !target.exists() { return SyncStatus::TargetMissing; } // If stored hashes are empty (legacy record), treat as needing re-sync if record.source_hash.is_empty() || record.target_hash.is_empty() { // For templates, we can't compare source to target directly if record.template { return SyncStatus::SourceChanged; } let current_source_hash = hash_path(source); let current_target_hash = hash_path(target); // If source and target currently match, consider it synced if current_source_hash == current_target_hash { return SyncStatus::Synced; } // Otherwise, treat as source changed (needs re-deploy) return SyncStatus::SourceChanged; } let current_source_hash = hash_path(source); let current_target_hash = hash_path(target); tracing::trace!(source_hash = %current_source_hash, target_hash = %current_target_hash, "computed hashes"); let source_changed = current_source_hash != record.source_hash; let target_changed = current_target_hash != record.target_hash; match (source_changed, target_changed) { (false, false) => SyncStatus::Synced, (true, false) => SyncStatus::SourceChanged, (false, true) => SyncStatus::TargetChanged, (true, true) => SyncStatus::Conflict, } } /// Records a package installation. #[tracing::instrument(skip(self))] pub fn record_package(&mut self, name: &str, manager: &str) { let record = PackageRecord { name: name.to_string(), manager: manager.to_string(), installed_at: chrono_now(), }; self.state.packages.insert(name.to_string(), record); self.dirty = true; } /// Gets a deployment record by target path. pub fn get_deployment(&self, target: &Path) -> Option<&DeploymentRecord> { self.state.deployments.get(&target.display().to_string()) } /// Returns all deployment records. pub fn get_all_deployments(&self) -> &HashMap { &self.state.deployments } /// Removes a deployment record. pub fn remove_deployment(&mut self, target: &Path) { self.state.deployments.remove(&target.display().to_string()); self.dirty = true; } /// Records a snapshot name. pub fn add_snapshot(&mut self, name: &str) { self.state.snapshots.push(name.to_string()); self.dirty = true; } /// Returns all snapshot names. pub fn get_snapshots(&self) -> &[String] { &self.state.snapshots } /// Saves state to disk if dirty. #[tracing::instrument(skip(self))] pub fn save(&mut self) -> Result<(), StateError> { if !self.dirty { return Ok(()); } if let Some(parent) = self.path.parent() { std::fs::create_dir_all(parent)?; } let json = serde_json::to_string_pretty(&self.state)?; std::fs::write(&self.path, json)?; self.dirty = false; Ok(()) } /// Checks if a target is deployed. pub fn is_deployed(&self, target: &Path) -> bool { self.state .deployments .contains_key(&target.display().to_string()) } /// Checks if source has changed since deployment. pub fn has_changed(&self, source: &Path, target: &Path) -> bool { matches!( self.check_sync_status(source, target), SyncStatus::SourceChanged | SyncStatus::Conflict | SyncStatus::NotDeployed ) } /// Records a directory deployment by tracking each file individually. #[tracing::instrument(skip(self))] pub fn record_directory_deployment( &mut self, source_dir: &Path, target_dir: &Path, mode: DeployMode, ) { let mut files = Vec::new(); collect_files(source_dir, &mut files); for source_file in files { if let Ok(relative) = source_file.strip_prefix(source_dir) { let target_file = target_dir.join(relative); self.record_deployment(&source_file, &target_file, mode); } } } /// Returns files that have changed in a directory. /// Returns (source_path, target_path, status) for each changed file. #[tracing::instrument(skip(self), fields(source_dir = %source_dir.display(), target_dir = %target_dir.display()))] pub fn get_changed_files_in_dir( &self, source_dir: &Path, target_dir: &Path, ) -> Vec<(PathBuf, PathBuf, SyncStatus)> { let mut changed = Vec::new(); // Check files in source directory let mut source_files = Vec::new(); collect_files(source_dir, &mut source_files); for source_file in source_files { if let Ok(relative) = source_file.strip_prefix(source_dir) { let target_file = target_dir.join(relative); let status = self.check_sync_status(&source_file, &target_file); if status != SyncStatus::Synced { changed.push((source_file, target_file, status)); } } } // Check for files that exist in target but not in source (deleted from source) let mut target_files = Vec::new(); if target_dir.exists() { collect_files(target_dir, &mut target_files); } for target_file in target_files { if let Ok(relative) = target_file.strip_prefix(target_dir) { let source_file = source_dir.join(relative); if !source_file.exists() { // Only mark as SourceMissing if we previously tracked this file // Files that were never in source (e.g., fish_variables) should be ignored if self.get_deployment(&target_file).is_some() { changed.push((source_file, target_file, SyncStatus::SourceMissing)); } // Otherwise, ignore - it's an untracked file created in target } } } changed } /// Removes all deployment records for files within a directory. #[tracing::instrument(skip(self))] pub fn remove_directory_deployment(&mut self, target_dir: &Path) { let target_prefix = target_dir.display().to_string(); let to_remove: Vec = self .state .deployments .keys() .filter(|k| k.starts_with(&target_prefix)) .cloned() .collect(); for key in to_remove { self.state.deployments.remove(&key); } self.dirty = true; } } fn hash_path(path: &Path) -> String { if !path.exists() { return String::new(); } if path.is_file() { std::fs::read(path) .map(|content| blake3::hash(&content).to_hex().to_string()) .unwrap_or_default() } else if path.is_dir() { hash_directory(path) } else { String::new() } } fn hash_directory(dir: &Path) -> String { let mut hasher = blake3::Hasher::new(); let mut entries = Vec::new(); // Collect all file paths recursively collect_files(dir, &mut entries); // Sort for deterministic hashing entries.sort(); for file_path in entries { // Include the relative path in the hash to detect renames if let Ok(relative) = file_path.strip_prefix(dir) { hasher.update(relative.to_string_lossy().as_bytes()); } // Hash the file content if let Ok(content) = std::fs::read(&file_path) { hasher.update(&content); } } hasher.finalize().to_hex().to_string() } fn collect_files(dir: &Path, files: &mut Vec) { if let Ok(entries) = std::fs::read_dir(dir) { for entry in entries.flatten() { let path = entry.path(); if path.is_dir() { collect_files(&path, files); } else if path.is_file() { files.push(path); } } } } fn chrono_now() -> String { use std::time::{SystemTime, UNIX_EPOCH}; let secs = SystemTime::now() .duration_since(UNIX_EPOCH) .unwrap() .as_secs(); format!("{}", secs) }