use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::sync::RwLock;
use crate::error::{read_or_recover, write_or_recover};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FileSnapshot {
pub version: usize,
pub path: String,
pub content: String,
pub timestamp: DateTime<Utc>,
pub tool_name: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct VersionSummary {
pub version: usize,
pub path: String,
pub timestamp: DateTime<Utc>,
pub tool_name: String,
pub size: usize,
}
impl From<&FileSnapshot> for VersionSummary {
fn from(snapshot: &FileSnapshot) -> Self {
Self {
version: snapshot.version,
path: snapshot.path.clone(),
timestamp: snapshot.timestamp,
tool_name: snapshot.tool_name.clone(),
size: snapshot.content.len(),
}
}
}
pub struct FileHistory {
snapshots: RwLock<HashMap<String, Vec<FileSnapshot>>>,
max_snapshots: usize,
}
impl FileHistory {
pub fn new(max_snapshots: usize) -> Self {
Self {
snapshots: RwLock::new(HashMap::new()),
max_snapshots,
}
}
pub fn save_snapshot(&self, path: &str, content: &str, tool_name: &str) -> usize {
let mut snapshots = write_or_recover(&self.snapshots);
let file_versions = snapshots.entry(path.to_string()).or_default();
let version = file_versions.len();
file_versions.push(FileSnapshot {
version,
path: path.to_string(),
content: content.to_string(),
timestamp: Utc::now(),
tool_name: tool_name.to_string(),
});
self.evict_if_needed(&mut snapshots);
version
}
pub fn list_versions(&self, path: &str) -> Vec<VersionSummary> {
let snapshots = read_or_recover(&self.snapshots);
snapshots
.get(path)
.map(|versions| versions.iter().map(VersionSummary::from).collect())
.unwrap_or_default()
}
fn evict_if_needed(&self, snapshots: &mut HashMap<String, Vec<FileSnapshot>>) {
let total: usize = snapshots.values().map(|v| v.len()).sum();
if total <= self.max_snapshots {
return;
}
let to_remove = total - self.max_snapshots;
let mut all_entries: Vec<(String, usize, DateTime<Utc>)> = Vec::new();
for (path, versions) in snapshots.iter() {
for snapshot in versions {
all_entries.push((path.clone(), snapshot.version, snapshot.timestamp));
}
}
all_entries.sort_by_key(|e| e.2);
for (path, version, _) in all_entries.into_iter().take(to_remove) {
if let Some(versions) = snapshots.get_mut(&path) {
versions.retain(|s| s.version != version);
if versions.is_empty() {
snapshots.remove(&path);
}
}
}
}
}
pub fn is_file_modifying_tool(tool_name: &str) -> bool {
matches!(tool_name, "write" | "edit" | "patch")
}
pub fn extract_file_path(tool_name: &str, args: &serde_json::Value) -> Option<String> {
if is_file_modifying_tool(tool_name) {
args.get("file_path")
.and_then(|v| v.as_str())
.map(|s| s.to_string())
} else {
None
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn new_history_starts_empty() {
let history = FileHistory::new(100);
assert!(history.list_versions("missing.rs").is_empty());
}
#[test]
fn save_snapshot_records_summary() {
let history = FileHistory::new(100);
let v = history.save_snapshot("test.rs", "fn main() {}", "write");
assert_eq!(v, 0);
let versions = history.list_versions("test.rs");
assert_eq!(versions.len(), 1);
assert_eq!(versions[0].version, 0);
assert_eq!(versions[0].path, "test.rs");
assert_eq!(versions[0].tool_name, "write");
assert_eq!(versions[0].size, "fn main() {}".len());
}
#[test]
fn save_multiple_snapshots_keeps_versions_per_file() {
let history = FileHistory::new(100);
let v0 = history.save_snapshot("test.rs", "version 0", "write");
let v1 = history.save_snapshot("test.rs", "version 1", "edit");
let v2 = history.save_snapshot("test.rs", "version 2", "patch");
assert_eq!(v0, 0);
assert_eq!(v1, 1);
assert_eq!(v2, 2);
let versions = history.list_versions("test.rs");
assert_eq!(versions.len(), 3);
assert_eq!(versions[0].version, 0);
assert_eq!(versions[1].version, 1);
assert_eq!(versions[2].version, 2);
}
#[test]
fn eviction_keeps_history_within_limit() {
let history = FileHistory::new(2);
history.save_snapshot("test.rs", "v0", "write");
history.save_snapshot("test.rs", "v1", "edit");
history.save_snapshot("test.rs", "v2", "patch");
assert!(history.list_versions("test.rs").len() <= 2);
}
#[test]
fn version_summary_is_content_free() {
let snapshot = FileSnapshot {
version: 5,
path: "test.rs".to_string(),
content: "hello world".to_string(),
timestamp: Utc::now(),
tool_name: "edit".to_string(),
};
let summary = VersionSummary::from(&snapshot);
assert_eq!(summary.version, 5);
assert_eq!(summary.path, "test.rs");
assert_eq!(summary.tool_name, "edit");
assert_eq!(summary.size, 11);
}
#[test]
fn detects_file_modifying_tools() {
assert!(is_file_modifying_tool("write"));
assert!(is_file_modifying_tool("edit"));
assert!(is_file_modifying_tool("patch"));
assert!(!is_file_modifying_tool("read"));
assert!(!is_file_modifying_tool("bash"));
assert!(!is_file_modifying_tool("grep"));
assert!(!is_file_modifying_tool("glob"));
assert!(!is_file_modifying_tool("ls"));
}
#[test]
fn extracts_file_path_only_for_modifying_tools() {
let args = serde_json::json!({"file_path": "src/main.rs", "content": "hello"});
assert_eq!(
extract_file_path("write", &args),
Some("src/main.rs".to_string())
);
assert_eq!(
extract_file_path("edit", &args),
Some("src/main.rs".to_string())
);
assert_eq!(
extract_file_path("patch", &args),
Some("src/main.rs".to_string())
);
assert_eq!(extract_file_path("read", &args), None);
assert_eq!(extract_file_path("bash", &args), None);
}
#[test]
fn missing_file_path_returns_none() {
let args = serde_json::json!({"content": "hello"});
assert_eq!(extract_file_path("write", &args), None);
}
}