use std::collections::HashMap;
use std::fs;
use std::path::{Path, PathBuf};
use chrono::{DateTime, Utc};
use super::checkpoint::{Checkpoint, CheckpointManager};
use super::delta::{compute_delta, compute_file_hash};
use super::graph::{GraphManager, OperationGraph};
use super::operation::{Operation, OperationId, OperationType};
use crate::error::Result;
pub struct HistoryManager {
#[allow(dead_code)]
base_dir: PathBuf,
graph_manager: GraphManager,
checkpoint_manager: CheckpointManager,
cached_hashes: HashMap<PathBuf, FileHashCache>,
}
#[derive(Debug, Clone, Default)]
struct FileHashCache {
hashes: HashMap<String, String>,
#[allow(dead_code)]
captured_at: Option<DateTime<Utc>>,
}
impl HistoryManager {
pub fn new(base_dir: PathBuf) -> Result<Self> {
let history_dir = base_dir.join("history");
fs::create_dir_all(&history_dir)?;
let graph_manager = GraphManager::new(&history_dir)?;
let checkpoint_manager = CheckpointManager::new(history_dir.join("checkpoints"))?;
Ok(Self {
base_dir,
graph_manager,
checkpoint_manager,
cached_hashes: HashMap::new(),
})
}
pub fn graph(&self) -> &OperationGraph {
self.graph_manager.graph()
}
pub fn record_operation(
&mut self,
operation_type: OperationType,
target_dir: &Path,
) -> Result<Operation> {
let parent = self
.graph_manager
.graph()
.head
.clone()
.map(OperationId::from_string);
let temp_op_id = OperationId::new();
let checkpoint = self
.checkpoint_manager
.create_checkpoint(temp_op_id.as_str(), target_dir)?;
let operation = Operation::new(operation_type, parent, checkpoint.id.clone());
self.graph_manager.add_operation(operation.clone())?;
self.cached_hashes.insert(
target_dir.to_path_buf(),
FileHashCache {
hashes: checkpoint.file_hashes,
captured_at: Some(Utc::now()),
},
);
Ok(operation)
}
pub fn rollback(&mut self, operation_id: &str, target_dir: &Path) -> Result<RollbackResult> {
let operation = self
.graph_manager
.graph()
.get_operation(operation_id)
.ok_or_else(|| crate::error::DotAgentError::NotFound(operation_id.to_string()))?;
let checkpoint_id = operation.checkpoint_id.clone();
let op_ids_to_undo: Vec<String> = self
.graph_manager
.operations_since(operation_id)
.iter()
.map(|op| op.id.as_str().to_string())
.collect();
let undo_count = op_ids_to_undo.len();
self.checkpoint_manager
.restore_checkpoint(&checkpoint_id, target_dir)?;
for op_id in &op_ids_to_undo {
self.graph_manager.graph_mut().remove_operation_tree(op_id);
}
self.graph_manager.save()?;
if let Some(checkpoint) = self.checkpoint_manager.load_checkpoint(&checkpoint_id)? {
self.cached_hashes.insert(
target_dir.to_path_buf(),
FileHashCache {
hashes: checkpoint.file_hashes,
captured_at: Some(Utc::now()),
},
);
}
Ok(RollbackResult {
restored_to: operation_id.to_string(),
operations_undone: undo_count,
})
}
pub fn restore(&self, checkpoint_id: &str, target_dir: &Path) -> Result<()> {
self.checkpoint_manager
.restore_checkpoint(checkpoint_id, target_dir)
}
pub fn detect_changes(&self, target_dir: &Path) -> Result<ChangeDetectionResult> {
let cached = self
.cached_hashes
.get(target_dir)
.cloned()
.unwrap_or_default();
let delta = compute_delta(&cached.hashes, target_dir)?;
Ok(ChangeDetectionResult {
has_changes: !delta.is_empty(),
added: delta.added_count(),
modified: delta.modified_count(),
deleted: delta.deleted_count(),
files_changed: delta.entries.iter().map(|e| e.path.clone()).collect(),
})
}
pub fn sync(&mut self, target_dir: &Path) -> Result<Option<Operation>> {
let changes = self.detect_changes(target_dir)?;
if !changes.has_changes {
return Ok(None);
}
let operation = self.record_operation(
OperationType::UserEdit {
target: target_dir.to_path_buf(),
files_changed: changes.files_changed,
auto_detected: true,
},
target_dir,
)?;
Ok(Some(operation))
}
pub fn list_history(&self, limit: Option<usize>) -> Vec<HistoryEntry> {
let ops = self
.graph_manager
.graph()
.operations_reverse_chronological();
let iter = ops.iter();
let entries: Vec<_> = if let Some(limit) = limit {
iter.take(limit)
.map(|op| HistoryEntry::from_operation(op))
.collect()
} else {
iter.map(|op| HistoryEntry::from_operation(op)).collect()
};
entries
}
pub fn get_operation(&self, operation_id: &str) -> Option<&Operation> {
self.graph_manager.graph().get_operation(operation_id)
}
pub fn get_checkpoint(&self, operation_id: &str) -> Result<Option<Checkpoint>> {
let operation = match self.graph_manager.graph().get_operation(operation_id) {
Some(op) => op,
None => return Ok(None),
};
self.checkpoint_manager
.load_checkpoint(&operation.checkpoint_id)
}
pub fn prune_checkpoints(&mut self, keep_count: usize) -> Result<usize> {
self.checkpoint_manager.prune(keep_count)
}
pub fn get_ancestry(&self, operation_id: &str) -> Vec<&Operation> {
self.graph_manager.graph().get_ancestry(operation_id)
}
pub fn init_cache(&mut self, target_dir: &Path) -> Result<()> {
let mut hashes = HashMap::new();
Self::walk_and_hash(target_dir, target_dir, &mut hashes)?;
self.cached_hashes.insert(
target_dir.to_path_buf(),
FileHashCache {
hashes,
captured_at: Some(Utc::now()),
},
);
Ok(())
}
fn walk_and_hash(
root: &Path,
current: &Path,
hashes: &mut HashMap<String, String>,
) -> Result<()> {
if !current.is_dir() {
return Ok(());
}
for entry in fs::read_dir(current)? {
let entry = entry?;
let path = entry.path();
if path.file_name().and_then(|n| n.to_str()) == Some(".dot-agent-history") {
continue;
}
if path.is_dir() {
Self::walk_and_hash(root, &path, hashes)?;
} else if path.is_file() {
let relative = path
.strip_prefix(root)
.map_err(|e| crate::error::DotAgentError::Internal(e.to_string()))?;
if let Ok(hash) = compute_file_hash(&path) {
hashes.insert(relative.to_string_lossy().to_string(), hash);
}
}
}
Ok(())
}
}
#[derive(Debug, Clone)]
pub struct RollbackResult {
pub restored_to: String,
pub operations_undone: usize,
}
#[derive(Debug, Clone)]
pub struct ChangeDetectionResult {
pub has_changes: bool,
pub added: usize,
pub modified: usize,
pub deleted: usize,
pub files_changed: Vec<String>,
}
#[derive(Debug, Clone)]
pub struct HistoryEntry {
pub id: String,
pub summary: String,
pub timestamp: DateTime<Utc>,
pub checkpoint_id: String,
pub is_auto_detected: bool,
}
impl HistoryEntry {
fn from_operation(op: &Operation) -> Self {
Self {
id: op.id.as_str().to_string(),
summary: op.summary(),
timestamp: op.timestamp,
checkpoint_id: op.checkpoint_id.clone(),
is_auto_detected: op.is_auto_detected(),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::history::operation::InstallOperationOptions;
use tempfile::TempDir;
#[test]
fn history_manager_init() -> Result<()> {
let temp = TempDir::new()?;
let manager = HistoryManager::new(temp.path().to_path_buf())?;
assert!(manager.graph().is_empty());
Ok(())
}
#[test]
fn record_and_list_operations() -> Result<()> {
let temp_base = TempDir::new()?;
let temp_target = TempDir::new()?;
fs::write(temp_target.path().join("file.txt"), "content")?;
let mut manager = HistoryManager::new(temp_base.path().to_path_buf())?;
let op = manager.record_operation(
OperationType::Install {
profile: "test".into(),
source: None,
target: temp_target.path().to_path_buf(),
options: InstallOperationOptions::default(),
},
temp_target.path(),
)?;
let history = manager.list_history(None);
assert_eq!(history.len(), 1);
assert_eq!(history[0].id, op.id.as_str());
Ok(())
}
#[test]
fn detect_changes() -> Result<()> {
let temp_base = TempDir::new()?;
let temp_target = TempDir::new()?;
fs::write(temp_target.path().join("file.txt"), "content")?;
let mut manager = HistoryManager::new(temp_base.path().to_path_buf())?;
manager.init_cache(temp_target.path())?;
let result = manager.detect_changes(temp_target.path())?;
assert!(!result.has_changes);
fs::write(temp_target.path().join("new.txt"), "new content")?;
let result = manager.detect_changes(temp_target.path())?;
assert!(result.has_changes);
assert_eq!(result.added, 1);
Ok(())
}
#[test]
fn sync_user_edits() -> Result<()> {
let temp_base = TempDir::new()?;
let temp_target = TempDir::new()?;
fs::write(temp_target.path().join("file.txt"), "content")?;
let mut manager = HistoryManager::new(temp_base.path().to_path_buf())?;
manager.record_operation(
OperationType::Install {
profile: "test".into(),
source: None,
target: temp_target.path().to_path_buf(),
options: InstallOperationOptions::default(),
},
temp_target.path(),
)?;
fs::write(temp_target.path().join("user.txt"), "user content")?;
let op = manager.sync(temp_target.path())?;
assert!(op.is_some());
assert!(op.unwrap().is_auto_detected());
Ok(())
}
#[test]
fn rollback_operation() -> Result<()> {
let temp_base = TempDir::new()?;
let temp_target = TempDir::new()?;
fs::write(temp_target.path().join("file.txt"), "original")?;
let mut manager = HistoryManager::new(temp_base.path().to_path_buf())?;
let op1 = manager.record_operation(
OperationType::Install {
profile: "test".into(),
source: None,
target: temp_target.path().to_path_buf(),
options: InstallOperationOptions::default(),
},
temp_target.path(),
)?;
fs::write(temp_target.path().join("file.txt"), "modified")?;
manager.record_operation(
OperationType::UserEdit {
target: temp_target.path().to_path_buf(),
files_changed: vec!["file.txt".into()],
auto_detected: false,
},
temp_target.path(),
)?;
let result = manager.rollback(op1.id.as_str(), temp_target.path())?;
assert_eq!(result.operations_undone, 1);
let content = fs::read_to_string(temp_target.path().join("file.txt"))?;
assert_eq!(content, "original");
Ok(())
}
}