Skip to main content

st/file_history/
tracker.rs

1//! File history tracker implementation
2//!
3//! 🎸 The Cheet says: "Track it, log it, never forget it!"
4
5use super::*;
6use anyhow::Context;
7use std::collections::HashMap;
8use std::sync::{Arc, Mutex};
9
10/// File history log entry
11#[derive(Debug, Clone, Serialize, Deserialize)]
12pub struct LogEntry {
13    /// Unix timestamp
14    pub timestamp: u64,
15    /// Target file path
16    pub file_path: String,
17    /// Operation performed
18    pub operation: FileOperation,
19    /// Operation context
20    pub context: OperationContext,
21    /// AI agent identifier
22    pub agent: String,
23    /// Session ID for grouping related operations
24    pub session_id: String,
25}
26
27/// File history tracker
28pub struct FileHistoryTracker {
29    config: FileHistoryConfig,
30    /// Cache of current log files
31    log_cache: Arc<Mutex<HashMap<String, Vec<LogEntry>>>>,
32}
33
34impl FileHistoryTracker {
35    /// Create new tracker with default config
36    pub fn new() -> Result<Self> {
37        Self::with_config(FileHistoryConfig::default())
38    }
39
40    /// Create tracker with custom config
41    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    /// Log a file operation
54    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        // Get log file path
75        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        // Append to log file
83        self.append_to_log(&log_file, &entry)?;
84
85        // Update cache
86        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    /// Append entry to log file
95    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        // Write as JSON lines format
102        let json = serde_json::to_string(entry)?;
103        writeln!(file, "{}", json)?;
104
105        Ok(())
106    }
107
108    /// Track a file read operation
109    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    /// Track a file write operation with smart operation detection
122    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        // Suggest best operation
141        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    /// Get history for a specific file
158    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        // Read all log files in project directory
170        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        // Sort by timestamp
185        all_entries.sort_by_key(|e| e.timestamp);
186        Ok(all_entries)
187    }
188
189    /// Get project summary
190    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        // Read all log files
202        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/// Project summary statistics
231#[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); // YYYYMMDD_HHMM
247    }
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        // Create file and track
262        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        // Append and track
267        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}