Skip to main content

st/
smart_edit_diff.rs

1// smart_edit_diff.rs - Local diff storage for Smart Edit operations
2// Stores diffs in .st_bumpers folder with timestamps for audit trail
3
4use anyhow::{Context, Result};
5use similar::TextDiff;
6use std::fs::{self, File};
7use std::io::Write;
8use std::path::{Path, PathBuf};
9use std::time::{SystemTime, UNIX_EPOCH};
10
11pub struct DiffStorage {
12    project_root: PathBuf,
13    pub st_folder: PathBuf,
14}
15
16impl DiffStorage {
17    /// Initialize diff storage for a project
18    pub fn new(project_root: impl AsRef<Path>) -> Result<Self> {
19        let project_root = project_root.as_ref().to_path_buf();
20        let st_folder = project_root.join(".st_bumpers");
21
22        // Create .st_bumpers folder if it doesn't exist
23        if !st_folder.exists() {
24            fs::create_dir(&st_folder).context("Failed to create .st_bumpers folder")?;
25        }
26
27        // Ensure .st_bumpers is in .gitignore
28        Self::ensure_gitignore(&project_root)?;
29
30        Ok(DiffStorage {
31            project_root,
32            st_folder,
33        })
34    }
35
36    /// Ensure .st_bumpers/ is in .gitignore
37    fn ensure_gitignore(project_root: &Path) -> Result<()> {
38        let gitignore_path = project_root.join(".gitignore");
39
40        // Check if .gitignore exists and contains .st_bumpers/
41        let needs_update = if gitignore_path.exists() {
42            let content = fs::read_to_string(&gitignore_path)?;
43            !content
44                .lines()
45                .any(|line| line.trim() == ".st_bumpers/" || line.trim() == ".st_bumpers")
46        } else {
47            true
48        };
49
50        if needs_update {
51            // Append .st_bumpers/ to .gitignore
52            let mut file = fs::OpenOptions::new()
53                .create(true)
54                .append(true)
55                .open(&gitignore_path)?;
56
57            // Add newline if file exists and doesn't end with one
58            if gitignore_path.exists() {
59                let content = fs::read_to_string(&gitignore_path)?;
60                if !content.is_empty() && !content.ends_with('\n') {
61                    writeln!(file)?;
62                }
63            }
64
65            writeln!(file, ".st_bumpers/")?;
66        }
67
68        Ok(())
69    }
70
71    /// Store a diff for a file before Smart Edit operation
72    pub fn store_diff(
73        &self,
74        file_path: &Path,
75        original_content: &str,
76        new_content: &str,
77    ) -> Result<PathBuf> {
78        // Get relative path from project root
79        let relative_path = file_path
80            .strip_prefix(&self.project_root)
81            .unwrap_or(file_path);
82
83        // Create diff
84        let diff = TextDiff::from_lines(original_content, new_content);
85
86        // Generate filename with timestamp
87        let timestamp = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs();
88
89        let filename = format!(
90            "{}-{}",
91            relative_path.to_string_lossy().replace('/', "-"),
92            timestamp
93        );
94
95        let diff_path = self.st_folder.join(&filename);
96
97        // Write unified diff format
98        let mut file = File::create(&diff_path)?;
99
100        // Use the simple unified diff format
101        let mut unified_diff = diff.unified_diff();
102        let unified = unified_diff.context_radius(3).header(
103            &format!("a/{}", relative_path.display()),
104            &format!("b/{}", relative_path.display()),
105        );
106
107        write!(file, "{}", unified)?;
108
109        Ok(diff_path)
110    }
111
112    /// Store the original file before any edits (for first edit)
113    pub fn store_original(&self, file_path: &Path, content: &str) -> Result<()> {
114        let relative_path = file_path
115            .strip_prefix(&self.project_root)
116            .unwrap_or(file_path);
117
118        let original_path = self
119            .st_folder
120            .join(relative_path.to_string_lossy().replace('/', "-"));
121
122        // Only store if it doesn't exist
123        if !original_path.exists() {
124            fs::write(&original_path, content)?;
125        }
126
127        Ok(())
128    }
129
130    /// Get the latest stored version of a file
131    pub fn get_latest_version(&self, file_path: &Path) -> Result<Option<String>> {
132        let relative_path = file_path
133            .strip_prefix(&self.project_root)
134            .unwrap_or(file_path);
135
136        let base_name = relative_path.to_string_lossy().replace('/', "-");
137
138        // Find all diffs for this file
139        let mut diffs: Vec<_> = fs::read_dir(&self.st_folder)?
140            .filter_map(|entry| entry.ok())
141            .filter(|entry| {
142                let name = entry.file_name().to_string_lossy().to_string();
143                name.starts_with(&base_name) && name.contains('-')
144            })
145            .collect();
146
147        // Sort by timestamp (newest first)
148        diffs.sort_by_key(|entry| {
149            let name = entry.file_name().to_string_lossy().to_string();
150            name.split('-')
151                .next_back()
152                .and_then(|ts| ts.parse::<u64>().ok())
153                .unwrap_or(0)
154        });
155        diffs.reverse();
156
157        // If we have diffs, reconstruct the latest version
158        if !diffs.is_empty() {
159            // Start with original if it exists
160            let original_path = self.st_folder.join(&base_name);
161            let content = if original_path.exists() {
162                fs::read_to_string(&original_path)?
163            } else {
164                // Try to get from actual file
165                fs::read_to_string(file_path)?
166            };
167
168            // Apply diffs in order (oldest to newest)
169            for _diff_entry in diffs.iter().rev() {
170                // This is simplified - in production you'd parse and apply the diff
171                // For now, we'll just return that we have history
172            }
173
174            return Ok(Some(content));
175        }
176
177        Ok(None)
178    }
179
180    /// List all diffs for all files in the .st_bumpers folder
181    pub fn list_all_diffs(&self) -> Result<Vec<(String, u64)>> {
182        let mut all_diffs = Vec::new();
183
184        if !self.st_folder.exists() {
185            return Ok(all_diffs);
186        }
187
188        for entry in fs::read_dir(&self.st_folder)? {
189            let entry = entry?;
190            let file_name = entry.file_name();
191            let file_name_str = file_name.to_string_lossy();
192
193            // Skip the original files (those without timestamps)
194            if !file_name_str.contains('-') {
195                continue;
196            }
197
198            // Extract timestamp from filename
199            if let Some(dash_pos) = file_name_str.rfind('-') {
200                if let Ok(timestamp) = file_name_str[dash_pos + 1..].parse::<u64>() {
201                    let file_path = file_name_str[..dash_pos].replace('-', "/");
202                    all_diffs.push((file_path, timestamp));
203                }
204            }
205        }
206
207        Ok(all_diffs)
208    }
209
210    /// List all stored diffs for a file
211    pub fn list_diffs(&self, file_path: &Path) -> Result<Vec<DiffInfo>> {
212        let relative_path = file_path
213            .strip_prefix(&self.project_root)
214            .unwrap_or(file_path);
215
216        let base_name = relative_path.to_string_lossy().replace('/', "-");
217
218        let mut diffs = Vec::new();
219
220        for entry in fs::read_dir(&self.st_folder)? {
221            let entry = entry?;
222            let name = entry.file_name().to_string_lossy().to_string();
223
224            if name.starts_with(&base_name) && name.contains('-') {
225                if let Some(timestamp_str) = name.split('-').next_back() {
226                    if let Ok(timestamp) = timestamp_str.parse::<u64>() {
227                        diffs.push(DiffInfo {
228                            path: entry.path(),
229                            timestamp,
230                            file_path: file_path.to_path_buf(),
231                        });
232                    }
233                }
234            }
235        }
236
237        // Sort by timestamp (newest first)
238        diffs.sort_by_key(|d| d.timestamp);
239        diffs.reverse();
240
241        Ok(diffs)
242    }
243
244    /// Clean up old diffs (keep last N diffs per file)
245    pub fn cleanup_old_diffs(&self, keep_count: usize) -> Result<usize> {
246        let mut removed_count = 0;
247
248        // Group diffs by file
249        let mut file_diffs: std::collections::HashMap<String, Vec<PathBuf>> =
250            std::collections::HashMap::new();
251
252        for entry in fs::read_dir(&self.st_folder)? {
253            let entry = entry?;
254            let name = entry.file_name().to_string_lossy().to_string();
255
256            // Skip non-diff files (like originals)
257            if !name.contains('-') {
258                continue;
259            }
260
261            // Extract base filename
262            if let Some(pos) = name.rfind('-') {
263                let base = &name[..pos];
264                file_diffs
265                    .entry(base.to_string())
266                    .or_default()
267                    .push(entry.path());
268            }
269        }
270
271        // Remove old diffs for each file
272        for (_, mut diffs) in file_diffs {
273            if diffs.len() > keep_count {
274                // Sort by timestamp (embedded in filename)
275                diffs.sort();
276
277                // Remove oldest diffs
278                let to_remove = diffs.len() - keep_count;
279                for diff_path in diffs.into_iter().take(to_remove) {
280                    fs::remove_file(diff_path)?;
281                    removed_count += 1;
282                }
283            }
284        }
285
286        Ok(removed_count)
287    }
288}
289
290#[derive(Debug)]
291pub struct DiffInfo {
292    pub path: PathBuf,
293    pub timestamp: u64,
294    pub file_path: PathBuf,
295}
296
297impl DiffInfo {
298    /// Get human-readable timestamp
299    pub fn timestamp_str(&self) -> String {
300        use chrono::{DateTime, Utc};
301        let datetime =
302            DateTime::<Utc>::from_timestamp(self.timestamp as i64, 0).unwrap_or_else(Utc::now);
303        datetime.format("%Y-%m-%d %H:%M:%S").to_string()
304    }
305}
306
307#[cfg(test)]
308mod tests {
309    use super::*;
310    use tempfile::TempDir;
311
312    #[test]
313    fn test_diff_storage_creation() {
314        let temp_dir = TempDir::new().unwrap();
315        let _storage = DiffStorage::new(temp_dir.path()).unwrap();
316
317        // Check .st_bumpers folder was created
318        assert!(temp_dir.path().join(".st_bumpers").exists());
319
320        // Check .gitignore was updated
321        let gitignore = fs::read_to_string(temp_dir.path().join(".gitignore")).unwrap();
322        assert!(gitignore.contains(".st_bumpers/"));
323    }
324
325    #[test]
326    fn test_store_diff() {
327        let temp_dir = TempDir::new().unwrap();
328        let storage = DiffStorage::new(temp_dir.path()).unwrap();
329
330        let file_path = temp_dir.path().join("test.rs");
331        let original = "fn main() {\n    println!(\"Hello\");\n}";
332        let modified = "fn main() {\n    println!(\"Hello, World!\");\n}";
333
334        let diff_path = storage.store_diff(&file_path, original, modified).unwrap();
335        assert!(diff_path.exists());
336
337        let diff_content = fs::read_to_string(&diff_path).unwrap();
338        assert!(diff_content.contains("--- a/test.rs"));
339        assert!(diff_content.contains("+++ b/test.rs"));
340        assert!(diff_content.contains("-    println!(\"Hello\");"));
341        assert!(diff_content.contains("+    println!(\"Hello, World!\");"));
342    }
343}