Skip to main content

dot_agent_core/history/
manager.rs

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
13/// Main API for history management
14pub struct HistoryManager {
15    /// Base directory for history storage (reserved for future features)
16    #[allow(dead_code)]
17    base_dir: PathBuf,
18    /// Graph manager
19    graph_manager: GraphManager,
20    /// Checkpoint manager
21    checkpoint_manager: CheckpointManager,
22    /// Cached file hashes for change detection
23    cached_hashes: HashMap<PathBuf, FileHashCache>,
24}
25
26/// Cache for file hashes at a specific point
27#[derive(Debug, Clone, Default)]
28struct FileHashCache {
29    hashes: HashMap<String, String>,
30    /// When the cache was captured (reserved for cache invalidation)
31    #[allow(dead_code)]
32    captured_at: Option<DateTime<Utc>>,
33}
34
35impl HistoryManager {
36    /// Create a new history manager
37    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    /// Get the operation graph
53    pub fn graph(&self) -> &OperationGraph {
54        self.graph_manager.graph()
55    }
56
57    /// Record an operation and create a checkpoint
58    pub fn record_operation(
59        &mut self,
60        operation_type: OperationType,
61        target_dir: &Path,
62    ) -> Result<Operation> {
63        // Get parent (current head)
64        let parent = self
65            .graph_manager
66            .graph()
67            .head
68            .clone()
69            .map(OperationId::from_string);
70
71        // Create checkpoint
72        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        // Create operation
78        let operation = Operation::new(operation_type, parent, checkpoint.id.clone());
79
80        // Add to graph
81        self.graph_manager.add_operation(operation.clone())?;
82
83        // Update cached hashes
84        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    /// Rollback to a specific operation (undo operations after it)
96    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        // Get operations that will be undone (collect IDs to avoid borrow conflict)
106        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        // Restore checkpoint
115        self.checkpoint_manager
116            .restore_checkpoint(&checkpoint_id, target_dir)?;
117
118        // Remove undone operations from graph
119        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        // Update cached hashes
125        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    /// Restore to a specific checkpoint (without modifying graph)
142    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    /// Detect user edits since last recorded operation
148    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    /// Sync: detect and record user edits as an operation
167    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    /// List recent operations
187    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    /// Get operation details
206    pub fn get_operation(&self, operation_id: &str) -> Option<&Operation> {
207        self.graph_manager.graph().get_operation(operation_id)
208    }
209
210    /// Get checkpoint for an operation
211    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    /// Prune old checkpoints
222    pub fn prune_checkpoints(&mut self, keep_count: usize) -> Result<usize> {
223        self.checkpoint_manager.prune(keep_count)
224    }
225
226    /// Get ancestry of an operation
227    pub fn get_ancestry(&self, operation_id: &str) -> Vec<&Operation> {
228        self.graph_manager.graph().get_ancestry(operation_id)
229    }
230
231    /// Initialize cache for a target directory
232    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            // Skip history directory
261            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/// Result of a rollback operation
283#[derive(Debug, Clone)]
284pub struct RollbackResult {
285    pub restored_to: String,
286    pub operations_undone: usize,
287}
288
289/// Result of change detection
290#[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/// History entry for display
300#[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        // Create target files
342        fs::write(temp_target.path().join("file.txt"), "content")?;
343
344        let mut manager = HistoryManager::new(temp_base.path().to_path_buf())?;
345
346        // Record operation
347        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        // List history
358        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        // No changes initially
376        let result = manager.detect_changes(temp_target.path())?;
377        assert!(!result.has_changes);
378
379        // Make a change
380        fs::write(temp_target.path().join("new.txt"), "new content")?;
381
382        // Now there should be changes
383        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        // Initial record
400        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        // Make user edit
411        fs::write(temp_target.path().join("user.txt"), "user content")?;
412
413        // Sync
414        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        // Initial state
427        fs::write(temp_target.path().join("file.txt"), "original")?;
428
429        let mut manager = HistoryManager::new(temp_base.path().to_path_buf())?;
430
431        // First operation
432        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        // Modify file
443        fs::write(temp_target.path().join("file.txt"), "modified")?;
444
445        // Second operation
446        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        // Rollback to first operation
456        let result = manager.rollback(op1.id.as_str(), temp_target.path())?;
457        assert_eq!(result.operations_undone, 1);
458
459        // Verify content restored
460        let content = fs::read_to_string(temp_target.path().join("file.txt"))?;
461        assert_eq!(content, "original");
462
463        Ok(())
464    }
465}