doot/crates/doot-core/src/state/store.rs
2026-02-05 23:18:33 -06:00

434 lines
13 KiB
Rust

//! 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<String, DeploymentRecord>,
pub packages: HashMap<String, PackageRecord>,
pub snapshots: Vec<String>,
}
/// 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<bool>,
) -> 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<bool>,
current_mode: Option<DeployMode>,
) -> 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<String, DeploymentRecord> {
&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<String> = 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<PathBuf>) {
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)
}