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();
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;
}
}
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; const SAMPLE_SIZE: usize = 8000;
let metadata = fs::metadata(path)?;
if metadata.len() > MAX_SIZE {
return Ok(true);
}
if metadata.len() > 0 {
let content = fs::read(path)?;
let sample = &content[..content.len().min(SAMPLE_SIZE)];
if sample.contains(&0) {
return Ok(true);
}
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();
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()),
});
}
}
}
}
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
}