1use std::collections::HashMap;
2use std::fs;
3use std::path::{Path, PathBuf};
4
5use chrono::{DateTime, Utc};
6
7use super::checkpoint::{Checkpoint, CheckpointManager};
8use super::delta::{compute_delta, compute_file_hash};
9use super::graph::{GraphManager, OperationGraph};
10use super::operation::{Operation, OperationId, OperationType};
11use crate::error::Result;
12
13pub struct HistoryManager {
15 #[allow(dead_code)]
17 base_dir: PathBuf,
18 graph_manager: GraphManager,
20 checkpoint_manager: CheckpointManager,
22 cached_hashes: HashMap<PathBuf, FileHashCache>,
24}
25
26#[derive(Debug, Clone, Default)]
28struct FileHashCache {
29 hashes: HashMap<String, String>,
30 #[allow(dead_code)]
32 captured_at: Option<DateTime<Utc>>,
33}
34
35impl HistoryManager {
36 pub fn new(base_dir: PathBuf) -> Result<Self> {
38 let history_dir = base_dir.join("history");
39 fs::create_dir_all(&history_dir)?;
40
41 let graph_manager = GraphManager::new(&history_dir)?;
42 let checkpoint_manager = CheckpointManager::new(history_dir.join("checkpoints"))?;
43
44 Ok(Self {
45 base_dir,
46 graph_manager,
47 checkpoint_manager,
48 cached_hashes: HashMap::new(),
49 })
50 }
51
52 pub fn graph(&self) -> &OperationGraph {
54 self.graph_manager.graph()
55 }
56
57 pub fn record_operation(
59 &mut self,
60 operation_type: OperationType,
61 target_dir: &Path,
62 ) -> Result<Operation> {
63 let parent = self
65 .graph_manager
66 .graph()
67 .head
68 .clone()
69 .map(OperationId::from_string);
70
71 let temp_op_id = OperationId::new();
73 let checkpoint = self
74 .checkpoint_manager
75 .create_checkpoint(temp_op_id.as_str(), target_dir)?;
76
77 let operation = Operation::new(operation_type, parent, checkpoint.id.clone());
79
80 self.graph_manager.add_operation(operation.clone())?;
82
83 self.cached_hashes.insert(
85 target_dir.to_path_buf(),
86 FileHashCache {
87 hashes: checkpoint.file_hashes,
88 captured_at: Some(Utc::now()),
89 },
90 );
91
92 Ok(operation)
93 }
94
95 pub fn rollback(&mut self, operation_id: &str, target_dir: &Path) -> Result<RollbackResult> {
97 let operation = self
98 .graph_manager
99 .graph()
100 .get_operation(operation_id)
101 .ok_or_else(|| crate::error::DotAgentError::NotFound(operation_id.to_string()))?;
102
103 let checkpoint_id = operation.checkpoint_id.clone();
104
105 let op_ids_to_undo: Vec<String> = self
107 .graph_manager
108 .operations_since(operation_id)
109 .iter()
110 .map(|op| op.id.as_str().to_string())
111 .collect();
112 let undo_count = op_ids_to_undo.len();
113
114 self.checkpoint_manager
116 .restore_checkpoint(&checkpoint_id, target_dir)?;
117
118 for op_id in &op_ids_to_undo {
120 self.graph_manager.graph_mut().remove_operation_tree(op_id);
121 }
122 self.graph_manager.save()?;
123
124 if let Some(checkpoint) = self.checkpoint_manager.load_checkpoint(&checkpoint_id)? {
126 self.cached_hashes.insert(
127 target_dir.to_path_buf(),
128 FileHashCache {
129 hashes: checkpoint.file_hashes,
130 captured_at: Some(Utc::now()),
131 },
132 );
133 }
134
135 Ok(RollbackResult {
136 restored_to: operation_id.to_string(),
137 operations_undone: undo_count,
138 })
139 }
140
141 pub fn restore(&self, checkpoint_id: &str, target_dir: &Path) -> Result<()> {
143 self.checkpoint_manager
144 .restore_checkpoint(checkpoint_id, target_dir)
145 }
146
147 pub fn detect_changes(&self, target_dir: &Path) -> Result<ChangeDetectionResult> {
149 let cached = self
150 .cached_hashes
151 .get(target_dir)
152 .cloned()
153 .unwrap_or_default();
154
155 let delta = compute_delta(&cached.hashes, target_dir)?;
156
157 Ok(ChangeDetectionResult {
158 has_changes: !delta.is_empty(),
159 added: delta.added_count(),
160 modified: delta.modified_count(),
161 deleted: delta.deleted_count(),
162 files_changed: delta.entries.iter().map(|e| e.path.clone()).collect(),
163 })
164 }
165
166 pub fn sync(&mut self, target_dir: &Path) -> Result<Option<Operation>> {
168 let changes = self.detect_changes(target_dir)?;
169
170 if !changes.has_changes {
171 return Ok(None);
172 }
173
174 let operation = self.record_operation(
175 OperationType::UserEdit {
176 target: target_dir.to_path_buf(),
177 files_changed: changes.files_changed,
178 auto_detected: true,
179 },
180 target_dir,
181 )?;
182
183 Ok(Some(operation))
184 }
185
186 pub fn list_history(&self, limit: Option<usize>) -> Vec<HistoryEntry> {
188 let ops = self
189 .graph_manager
190 .graph()
191 .operations_reverse_chronological();
192 let iter = ops.iter();
193
194 let entries: Vec<_> = if let Some(limit) = limit {
195 iter.take(limit)
196 .map(|op| HistoryEntry::from_operation(op))
197 .collect()
198 } else {
199 iter.map(|op| HistoryEntry::from_operation(op)).collect()
200 };
201
202 entries
203 }
204
205 pub fn get_operation(&self, operation_id: &str) -> Option<&Operation> {
207 self.graph_manager.graph().get_operation(operation_id)
208 }
209
210 pub fn get_checkpoint(&self, operation_id: &str) -> Result<Option<Checkpoint>> {
212 let operation = match self.graph_manager.graph().get_operation(operation_id) {
213 Some(op) => op,
214 None => return Ok(None),
215 };
216
217 self.checkpoint_manager
218 .load_checkpoint(&operation.checkpoint_id)
219 }
220
221 pub fn prune_checkpoints(&mut self, keep_count: usize) -> Result<usize> {
223 self.checkpoint_manager.prune(keep_count)
224 }
225
226 pub fn get_ancestry(&self, operation_id: &str) -> Vec<&Operation> {
228 self.graph_manager.graph().get_ancestry(operation_id)
229 }
230
231 pub fn init_cache(&mut self, target_dir: &Path) -> Result<()> {
233 let mut hashes = HashMap::new();
234 Self::walk_and_hash(target_dir, target_dir, &mut hashes)?;
235
236 self.cached_hashes.insert(
237 target_dir.to_path_buf(),
238 FileHashCache {
239 hashes,
240 captured_at: Some(Utc::now()),
241 },
242 );
243
244 Ok(())
245 }
246
247 fn walk_and_hash(
248 root: &Path,
249 current: &Path,
250 hashes: &mut HashMap<String, String>,
251 ) -> Result<()> {
252 if !current.is_dir() {
253 return Ok(());
254 }
255
256 for entry in fs::read_dir(current)? {
257 let entry = entry?;
258 let path = entry.path();
259
260 if path.file_name().and_then(|n| n.to_str()) == Some(".dot-agent-history") {
262 continue;
263 }
264
265 if path.is_dir() {
266 Self::walk_and_hash(root, &path, hashes)?;
267 } else if path.is_file() {
268 let relative = path
269 .strip_prefix(root)
270 .map_err(|e| crate::error::DotAgentError::Internal(e.to_string()))?;
271
272 if let Ok(hash) = compute_file_hash(&path) {
273 hashes.insert(relative.to_string_lossy().to_string(), hash);
274 }
275 }
276 }
277
278 Ok(())
279 }
280}
281
282#[derive(Debug, Clone)]
284pub struct RollbackResult {
285 pub restored_to: String,
286 pub operations_undone: usize,
287}
288
289#[derive(Debug, Clone)]
291pub struct ChangeDetectionResult {
292 pub has_changes: bool,
293 pub added: usize,
294 pub modified: usize,
295 pub deleted: usize,
296 pub files_changed: Vec<String>,
297}
298
299#[derive(Debug, Clone)]
301pub struct HistoryEntry {
302 pub id: String,
303 pub summary: String,
304 pub timestamp: DateTime<Utc>,
305 pub checkpoint_id: String,
306 pub is_auto_detected: bool,
307}
308
309impl HistoryEntry {
310 fn from_operation(op: &Operation) -> Self {
311 Self {
312 id: op.id.as_str().to_string(),
313 summary: op.summary(),
314 timestamp: op.timestamp,
315 checkpoint_id: op.checkpoint_id.clone(),
316 is_auto_detected: op.is_auto_detected(),
317 }
318 }
319}
320
321#[cfg(test)]
322mod tests {
323 use super::*;
324 use crate::history::operation::InstallOperationOptions;
325 use tempfile::TempDir;
326
327 #[test]
328 fn history_manager_init() -> Result<()> {
329 let temp = TempDir::new()?;
330 let manager = HistoryManager::new(temp.path().to_path_buf())?;
331
332 assert!(manager.graph().is_empty());
333 Ok(())
334 }
335
336 #[test]
337 fn record_and_list_operations() -> Result<()> {
338 let temp_base = TempDir::new()?;
339 let temp_target = TempDir::new()?;
340
341 fs::write(temp_target.path().join("file.txt"), "content")?;
343
344 let mut manager = HistoryManager::new(temp_base.path().to_path_buf())?;
345
346 let op = manager.record_operation(
348 OperationType::Install {
349 profile: "test".into(),
350 source: None,
351 target: temp_target.path().to_path_buf(),
352 options: InstallOperationOptions::default(),
353 },
354 temp_target.path(),
355 )?;
356
357 let history = manager.list_history(None);
359 assert_eq!(history.len(), 1);
360 assert_eq!(history[0].id, op.id.as_str());
361
362 Ok(())
363 }
364
365 #[test]
366 fn detect_changes() -> Result<()> {
367 let temp_base = TempDir::new()?;
368 let temp_target = TempDir::new()?;
369
370 fs::write(temp_target.path().join("file.txt"), "content")?;
371
372 let mut manager = HistoryManager::new(temp_base.path().to_path_buf())?;
373 manager.init_cache(temp_target.path())?;
374
375 let result = manager.detect_changes(temp_target.path())?;
377 assert!(!result.has_changes);
378
379 fs::write(temp_target.path().join("new.txt"), "new content")?;
381
382 let result = manager.detect_changes(temp_target.path())?;
384 assert!(result.has_changes);
385 assert_eq!(result.added, 1);
386
387 Ok(())
388 }
389
390 #[test]
391 fn sync_user_edits() -> Result<()> {
392 let temp_base = TempDir::new()?;
393 let temp_target = TempDir::new()?;
394
395 fs::write(temp_target.path().join("file.txt"), "content")?;
396
397 let mut manager = HistoryManager::new(temp_base.path().to_path_buf())?;
398
399 manager.record_operation(
401 OperationType::Install {
402 profile: "test".into(),
403 source: None,
404 target: temp_target.path().to_path_buf(),
405 options: InstallOperationOptions::default(),
406 },
407 temp_target.path(),
408 )?;
409
410 fs::write(temp_target.path().join("user.txt"), "user content")?;
412
413 let op = manager.sync(temp_target.path())?;
415 assert!(op.is_some());
416 assert!(op.unwrap().is_auto_detected());
417
418 Ok(())
419 }
420
421 #[test]
422 fn rollback_operation() -> Result<()> {
423 let temp_base = TempDir::new()?;
424 let temp_target = TempDir::new()?;
425
426 fs::write(temp_target.path().join("file.txt"), "original")?;
428
429 let mut manager = HistoryManager::new(temp_base.path().to_path_buf())?;
430
431 let op1 = manager.record_operation(
433 OperationType::Install {
434 profile: "test".into(),
435 source: None,
436 target: temp_target.path().to_path_buf(),
437 options: InstallOperationOptions::default(),
438 },
439 temp_target.path(),
440 )?;
441
442 fs::write(temp_target.path().join("file.txt"), "modified")?;
444
445 manager.record_operation(
447 OperationType::UserEdit {
448 target: temp_target.path().to_path_buf(),
449 files_changed: vec!["file.txt".into()],
450 auto_detected: false,
451 },
452 temp_target.path(),
453 )?;
454
455 let result = manager.rollback(op1.id.as_str(), temp_target.path())?;
457 assert_eq!(result.operations_undone, 1);
458
459 let content = fs::read_to_string(temp_target.path().join("file.txt"))?;
461 assert_eq!(content, "original");
462
463 Ok(())
464 }
465}