agentlog 0.1.2

CLI flight recorder for AI coding agents - capture, store, and replay AI sessions
Documentation
use anyhow::Result;
use std::collections::HashMap;
use std::fs;
use std::path::{Path, PathBuf};
use walkdir::WalkDir;

#[derive(Debug, Clone)]
pub struct FileSnapshot {
    #[allow(dead_code)]
    pub path: PathBuf,
    pub content: String,
    #[allow(dead_code)]
    pub modified: std::time::SystemTime,
}

pub struct Snapshot {
    #[allow(dead_code)]
    pub timestamp: chrono::DateTime<chrono::Utc>,
    pub files: HashMap<PathBuf, FileSnapshot>,
}

impl Default for Snapshot {
    fn default() -> Self {
        Self {
            timestamp: chrono::Utc::now(),
            files: HashMap::new(),
        }
    }
}

impl Snapshot {
    pub fn new() -> Self {
        Self::default()
    }

    pub fn capture<P: AsRef<Path>>(root: P, ignore_patterns: &[String]) -> Result<Self> {
        let mut snapshot = Self::new();

        for entry in WalkDir::new(&root)
            .follow_links(false)
            .into_iter()
            .filter_entry(|e| !should_ignore_path(e.path(), ignore_patterns))
        {
            let entry = entry?;

            if !entry.file_type().is_file() {
                continue;
            }

            let path = entry.path();

            // Skip binary files and very large files
            if is_binary_or_large(path)? {
                continue;
            }

            if let Ok(content) = fs::read_to_string(path) {
                let metadata = fs::metadata(path)?;
                let modified = metadata.modified()?;

                let relative_path = path.strip_prefix(&root)?.to_path_buf();

                snapshot.files.insert(
                    relative_path.clone(),
                    FileSnapshot {
                        path: relative_path,
                        content,
                        modified,
                    },
                );
            }
        }

        Ok(snapshot)
    }

    pub fn get(&self, path: &Path) -> Option<&FileSnapshot> {
        self.files.get(path)
    }
}

pub fn should_ignore_path(path: &Path, ignore_patterns: &[String]) -> bool {
    let path_str = path.to_string_lossy();

    for pattern in ignore_patterns {
        if path_str.contains(pattern) {
            return true;
        }
    }

    // Always ignore these
    let always_ignore = [
        ".git",
        ".agentlog",
        "node_modules",
        "target",
        ".idea",
        ".vscode",
    ];
    for ignore in &always_ignore {
        if path_str.contains(ignore) {
            return true;
        }
    }

    false
}

fn is_binary_or_large(path: &Path) -> Result<bool> {
    const MAX_SIZE: u64 = 1024 * 1024; // 1MB
    const SAMPLE_SIZE: usize = 8000;

    let metadata = fs::metadata(path)?;

    // Skip large files
    if metadata.len() > MAX_SIZE {
        return Ok(true);
    }

    // Check if binary by sampling first 8KB
    if metadata.len() > 0 {
        let content = fs::read(path)?;
        let sample = &content[..content.len().min(SAMPLE_SIZE)];

        // Check for null bytes (common in binary files)
        if sample.contains(&0) {
            return Ok(true);
        }

        // Check for high ratio of non-printable characters
        let non_printable = sample
            .iter()
            .filter(|&&b| b < 0x20 && b != 0x0A && b != 0x0D && b != 0x09)
            .count();
        if non_printable > sample.len() / 10 {
            return Ok(true);
        }
    }

    Ok(false)
}

#[derive(Debug, Clone)]
pub struct Change {
    pub path: PathBuf,
    pub change_type: ChangeType,
    pub old_content: Option<String>,
    pub new_content: Option<String>,
}

#[derive(Debug, Clone)]
pub enum ChangeType {
    Created,
    Modified,
    Deleted,
}

pub fn compute_changes(old: &Snapshot, new: &Snapshot) -> Vec<Change> {
    let mut changes = Vec::new();

    // Check for created and modified files
    for (path, new_snapshot) in &new.files {
        match old.get(path) {
            None => {
                changes.push(Change {
                    path: path.clone(),
                    change_type: ChangeType::Created,
                    old_content: None,
                    new_content: Some(new_snapshot.content.clone()),
                });
            }
            Some(old_snapshot) => {
                if old_snapshot.content != new_snapshot.content {
                    changes.push(Change {
                        path: path.clone(),
                        change_type: ChangeType::Modified,
                        old_content: Some(old_snapshot.content.clone()),
                        new_content: Some(new_snapshot.content.clone()),
                    });
                }
            }
        }
    }

    // Check for deleted files
    for (path, old_snapshot) in &old.files {
        if !new.files.contains_key(path) {
            changes.push(Change {
                path: path.clone(),
                change_type: ChangeType::Deleted,
                old_content: Some(old_snapshot.content.clone()),
                new_content: None,
            });
        }
    }

    changes
}