collet 0.1.0

Relentless agentic coding orchestrator with zero-drop agent loops
Documentation
//! File checkpointing for `/rewind` support.
//!
//! Before the agent modifies files, we snapshot their contents.
//! The user can `/rewind` to restore files to their pre-edit state.

use std::collections::HashMap;
use std::path::{Path, PathBuf};

/// Stores file snapshots for a single agent turn.
#[derive(Debug, Clone)]
pub struct Checkpoint {
    /// Snapshot ID (incrementing per session).
    pub id: u32,
    /// Timestamp when checkpoint was created.
    pub timestamp: String,
    /// User message that triggered this agent turn.
    pub user_message: String,
    /// Map of file path → original content (before modification).
    pub snapshots: HashMap<String, FileSnapshot>,
}

/// A snapshot of a single file's state.
#[derive(Debug, Clone)]
pub struct FileSnapshot {
    /// File content before modification (None = file didn't exist).
    pub content: Option<String>,
    /// Whether the file existed before this turn.
    pub existed: bool,
}

/// Manages checkpoints across the session.
#[derive(Debug)]
pub struct CheckpointManager {
    /// Working directory.
    working_dir: String,
    /// Stack of checkpoints (most recent last).
    checkpoints: Vec<Checkpoint>,
    /// Next checkpoint ID.
    next_id: u32,
    /// Current (in-progress) checkpoint being built during an agent turn.
    current: Option<Checkpoint>,
}

impl CheckpointManager {
    pub fn new(working_dir: &str) -> Self {
        Self {
            working_dir: working_dir.to_string(),
            checkpoints: Vec::new(),
            next_id: 1,
            current: None,
        }
    }

    /// Begin a new checkpoint for an agent turn.
    pub fn begin(&mut self, user_message: &str) {
        self.current = Some(Checkpoint {
            id: self.next_id,
            timestamp: chrono::Utc::now().to_rfc3339(),
            user_message: user_message.to_string(),
            snapshots: HashMap::new(),
        });
    }

    /// Record a file's state before modification.
    /// Should be called when `AgentEvent::FileModified` is received.
    pub fn snapshot_file(&mut self, path: &str) {
        let Some(ref mut checkpoint) = self.current else {
            return;
        };

        // Don't re-snapshot if already captured this turn
        if checkpoint.snapshots.contains_key(path) {
            return;
        }

        let full_path = if path.starts_with('/') {
            PathBuf::from(path)
        } else {
            Path::new(&self.working_dir).join(path)
        };

        let snapshot = if full_path.exists() {
            match std::fs::read_to_string(&full_path) {
                Ok(content) => FileSnapshot {
                    content: Some(content),
                    existed: true,
                },
                Err(_) => FileSnapshot {
                    content: None,
                    existed: true,
                },
            }
        } else {
            FileSnapshot {
                content: None,
                existed: false,
            }
        };

        checkpoint.snapshots.insert(path.to_string(), snapshot);
    }

    /// Finalize the current checkpoint (called when agent turn completes).
    /// Only stores the checkpoint if files were actually modified.
    pub fn commit(&mut self) {
        if let Some(checkpoint) = self.current.take()
            && !checkpoint.snapshots.is_empty()
        {
            self.next_id += 1;
            self.checkpoints.push(checkpoint);

            // Keep at most 20 checkpoints
            if self.checkpoints.len() > 20 {
                self.checkpoints.remove(0);
            }
        }
    }

    /// Rewind to the most recent checkpoint, restoring all files.
    /// Returns a description of what was restored.
    pub fn rewind(&mut self) -> Option<String> {
        let checkpoint = self.checkpoints.pop()?;
        let mut restored = Vec::new();
        let mut errors = Vec::new();

        for (path, snapshot) in &checkpoint.snapshots {
            let full_path = if path.starts_with('/') {
                PathBuf::from(path)
            } else {
                Path::new(&self.working_dir).join(path)
            };

            if snapshot.existed {
                if let Some(ref content) = snapshot.content {
                    match std::fs::write(&full_path, content) {
                        Ok(_) => restored.push(format!("  ✓ Restored: {path}")),
                        Err(e) => errors.push(format!("  ✗ Failed to restore {path}: {e}")),
                    }
                }
            } else {
                // File didn't exist before — delete it
                match std::fs::remove_file(&full_path) {
                    Ok(_) => restored.push(format!("  ✓ Removed: {path} (didn't exist before)")),
                    Err(e) => errors.push(format!("  ✗ Failed to remove {path}: {e}")),
                }
            }
        }

        let mut parts = vec![format!(
            "⏪ **Rewound checkpoint #{}** (\"{}\")",
            checkpoint.id,
            truncate_msg(&checkpoint.user_message, 60),
        )];

        if !restored.is_empty() {
            parts.push(restored.join("\n"));
        }
        if !errors.is_empty() {
            parts.push(errors.join("\n"));
        }

        Some(parts.join("\n"))
    }

    /// Rewind to a specific checkpoint by ID, restoring all checkpoints from that point.
    pub fn rewind_to(&mut self, target_id: u32) -> Option<String> {
        // Find the index of the target checkpoint
        let idx = self.checkpoints.iter().position(|c| c.id == target_id)?;

        let mut results = Vec::new();

        // Rewind from most recent back to target (inclusive)
        while self.checkpoints.len() > idx {
            if let Some(msg) = self.rewind() {
                results.push(msg);
            }
        }

        if results.is_empty() {
            None
        } else {
            Some(results.join("\n\n"))
        }
    }

    /// List all available checkpoints.
    pub fn list(&self) -> Vec<(u32, &str, usize, &str)> {
        self.checkpoints
            .iter()
            .rev()
            .map(|c| {
                (
                    c.id,
                    c.timestamp.as_str(),
                    c.snapshots.len(),
                    c.user_message.as_str(),
                )
            })
            .collect()
    }

    /// How many checkpoints are stored.
    pub fn count(&self) -> usize {
        self.checkpoints.len()
    }
}

fn truncate_msg(s: &str, max: usize) -> String {
    let first_line = s.lines().next().unwrap_or(s);
    if first_line.len() <= max {
        first_line.to_string()
    } else {
        format!("{}...", crate::util::truncate_bytes(first_line, max))
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::fs;

    #[test]
    fn test_checkpoint_rewind() {
        let dir = tempfile::tempdir().unwrap();
        let working_dir = dir.path().to_str().unwrap();
        let mut mgr = CheckpointManager::new(working_dir);

        // Create a test file
        let test_file = dir.path().join("test.txt");
        fs::write(&test_file, "original content").unwrap();

        // Begin checkpoint and snapshot the file
        mgr.begin("modify test.txt");
        mgr.snapshot_file("test.txt");

        // Simulate modification
        fs::write(&test_file, "modified content").unwrap();

        // Commit checkpoint
        mgr.commit();

        assert_eq!(mgr.count(), 1);
        assert_eq!(fs::read_to_string(&test_file).unwrap(), "modified content");

        // Rewind
        let result = mgr.rewind();
        assert!(result.is_some());
        assert_eq!(fs::read_to_string(&test_file).unwrap(), "original content");
        assert_eq!(mgr.count(), 0);
    }
}