434 lines
13 KiB
Rust
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)
|
|
}
|