1use super::*;
6use anyhow::Context;
7use std::collections::HashMap;
8use std::sync::{Arc, Mutex};
9
10#[derive(Debug, Clone, Serialize, Deserialize)]
12pub struct LogEntry {
13 pub timestamp: u64,
15 pub file_path: String,
17 pub operation: FileOperation,
19 pub context: OperationContext,
21 pub agent: String,
23 pub session_id: String,
25}
26
27pub struct FileHistoryTracker {
29 config: FileHistoryConfig,
30 log_cache: Arc<Mutex<HashMap<String, Vec<LogEntry>>>>,
32}
33
34impl FileHistoryTracker {
35 pub fn new() -> Result<Self> {
37 Self::with_config(FileHistoryConfig::default())
38 }
39
40 pub fn with_config(config: FileHistoryConfig) -> Result<Self> {
42 if config.auto_create {
43 fs::create_dir_all(&config.base_dir)
44 .context("Failed to create file history directory")?;
45 }
46
47 Ok(Self {
48 config,
49 log_cache: Arc::new(Mutex::new(HashMap::new())),
50 })
51 }
52
53 pub fn log_operation(
55 &self,
56 file_path: &Path,
57 operation: FileOperation,
58 context: OperationContext,
59 agent: &str,
60 session_id: &str,
61 ) -> Result<()> {
62 let (time_bucket, timestamp) = get_time_bucket();
63 let project_id = get_project_id(file_path)?;
64
65 let entry = LogEntry {
66 timestamp,
67 file_path: file_path.to_string_lossy().to_string(),
68 operation,
69 context,
70 agent: agent.to_string(),
71 session_id: session_id.to_string(),
72 };
73
74 let log_dir = self.config.base_dir.join(&project_id);
76 if self.config.auto_create {
77 fs::create_dir_all(&log_dir)?;
78 }
79
80 let log_file = log_dir.join(format!("{}.flg", time_bucket));
81
82 self.append_to_log(&log_file, &entry)?;
84
85 if let Ok(mut cache) = self.log_cache.lock() {
87 let key = format!("{}/{}", project_id, time_bucket);
88 cache.entry(key).or_insert_with(Vec::new).push(entry);
89 }
90
91 Ok(())
92 }
93
94 fn append_to_log(&self, log_file: &Path, entry: &LogEntry) -> Result<()> {
96 let mut file = OpenOptions::new()
97 .create(true)
98 .append(true)
99 .open(log_file)?;
100
101 let json = serde_json::to_string(entry)?;
103 writeln!(file, "{}", json)?;
104
105 Ok(())
106 }
107
108 pub fn track_read(&self, file_path: &Path, agent: &str, session_id: &str) -> Result<String> {
110 let hash = hash_file(file_path)?;
111 let size = fs::metadata(file_path)?.len() as usize;
112
113 let context = OperationContext::new(FileOperation::Read)
114 .with_bytes(size)
115 .with_hashes(Some(hash.clone()), Some(hash.clone()));
116
117 self.log_operation(file_path, FileOperation::Read, context, agent, session_id)?;
118 Ok(hash)
119 }
120
121 pub fn track_write(
123 &self,
124 file_path: &Path,
125 old_content: Option<&str>,
126 new_content: &str,
127 agent: &str,
128 session_id: &str,
129 ) -> Result<FileOperation> {
130 let old_hash = old_content.map(|c| {
131 let mut hasher = Sha256::new();
132 hasher.update(c.as_bytes());
133 format!("{:x}", hasher.finalize())
134 });
135
136 let mut hasher = Sha256::new();
137 hasher.update(new_content.as_bytes());
138 let new_hash = format!("{:x}", hasher.finalize());
139
140 let operation = suggest_operation(old_content, new_content, self.config.prefer_append);
142
143 let bytes_affected = match operation {
144 FileOperation::Append => new_content.len() - old_content.map(|s| s.len()).unwrap_or(0),
145 FileOperation::Create => new_content.len(),
146 _ => new_content.len(),
147 };
148
149 let context = OperationContext::new(operation)
150 .with_bytes(bytes_affected)
151 .with_hashes(old_hash, Some(new_hash));
152
153 self.log_operation(file_path, operation, context, agent, session_id)?;
154 Ok(operation)
155 }
156
157 pub fn get_file_history(&self, file_path: &Path) -> Result<Vec<LogEntry>> {
159 let project_id = get_project_id(file_path)?;
160 let log_dir = self.config.base_dir.join(&project_id);
161
162 if !log_dir.exists() {
163 return Ok(Vec::new());
164 }
165
166 let mut all_entries = Vec::new();
167 let target_path = file_path.to_string_lossy();
168
169 for entry in fs::read_dir(&log_dir)? {
171 let entry = entry?;
172 if entry.path().extension().and_then(|s| s.to_str()) == Some("flg") {
173 let contents = fs::read_to_string(entry.path())?;
174 for line in contents.lines() {
175 if let Ok(log_entry) = serde_json::from_str::<LogEntry>(line) {
176 if log_entry.file_path == target_path {
177 all_entries.push(log_entry);
178 }
179 }
180 }
181 }
182 }
183
184 all_entries.sort_by_key(|e| e.timestamp);
186 Ok(all_entries)
187 }
188
189 pub fn get_project_summary(&self, project_path: &Path) -> Result<ProjectSummary> {
191 let project_id = get_project_id(project_path)?;
192 let log_dir = self.config.base_dir.join(&project_id);
193
194 if !log_dir.exists() {
195 return Ok(ProjectSummary::default());
196 }
197
198 let mut summary = ProjectSummary::default();
199 let mut file_ops: HashMap<String, Vec<FileOperation>> = HashMap::new();
200
201 for entry in fs::read_dir(&log_dir)? {
203 let entry = entry?;
204 if entry.path().extension().and_then(|s| s.to_str()) == Some("flg") {
205 let contents = fs::read_to_string(entry.path())?;
206 for line in contents.lines() {
207 if let Ok(log_entry) = serde_json::from_str::<LogEntry>(line) {
208 summary.total_operations += 1;
209
210 file_ops
211 .entry(log_entry.file_path.clone())
212 .or_default()
213 .push(log_entry.operation);
214
215 summary
216 .operation_counts
217 .entry(log_entry.operation)
218 .and_modify(|c| *c += 1)
219 .or_insert(1);
220 }
221 }
222 }
223 }
224
225 summary.files_modified = file_ops.len();
226 Ok(summary)
227 }
228}
229
230#[derive(Debug, Default, Serialize, Deserialize)]
232pub struct ProjectSummary {
233 pub total_operations: usize,
234 pub files_modified: usize,
235 pub operation_counts: HashMap<FileOperation, usize>,
236}
237
238#[cfg(test)]
239mod tests {
240 use super::*;
241 use tempfile::TempDir;
242
243 #[test]
244 fn test_time_bucket() {
245 let (bucket, _) = get_time_bucket();
246 assert_eq!(bucket.len(), 13); }
248
249 #[test]
250 fn test_tracker_basic() -> Result<()> {
251 let temp_dir = TempDir::new()?;
252 let config = FileHistoryConfig {
253 base_dir: temp_dir.path().to_path_buf(),
254 auto_create: true,
255 prefer_append: true,
256 };
257
258 let tracker = FileHistoryTracker::with_config(config)?;
259 let test_file = temp_dir.path().join("test.txt");
260
261 fs::write(&test_file, "hello")?;
263 let op = tracker.track_write(&test_file, None, "hello", "test-agent", "session-1")?;
264 assert_eq!(op, FileOperation::Create);
265
266 let op = tracker.track_write(
268 &test_file,
269 Some("hello"),
270 "hello world",
271 "test-agent",
272 "session-1",
273 )?;
274 assert_eq!(op, FileOperation::Append);
275
276 Ok(())
277 }
278}