1use 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 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 if !st_folder.exists() {
24 fs::create_dir(&st_folder).context("Failed to create .st_bumpers folder")?;
25 }
26
27 Self::ensure_gitignore(&project_root)?;
29
30 Ok(DiffStorage {
31 project_root,
32 st_folder,
33 })
34 }
35
36 fn ensure_gitignore(project_root: &Path) -> Result<()> {
38 let gitignore_path = project_root.join(".gitignore");
39
40 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 let mut file = fs::OpenOptions::new()
53 .create(true)
54 .append(true)
55 .open(&gitignore_path)?;
56
57 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 pub fn store_diff(
73 &self,
74 file_path: &Path,
75 original_content: &str,
76 new_content: &str,
77 ) -> Result<PathBuf> {
78 let relative_path = file_path
80 .strip_prefix(&self.project_root)
81 .unwrap_or(file_path);
82
83 let diff = TextDiff::from_lines(original_content, new_content);
85
86 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 let mut file = File::create(&diff_path)?;
99
100 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 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 if !original_path.exists() {
124 fs::write(&original_path, content)?;
125 }
126
127 Ok(())
128 }
129
130 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 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 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 !diffs.is_empty() {
159 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 fs::read_to_string(file_path)?
166 };
167
168 for _diff_entry in diffs.iter().rev() {
170 }
173
174 return Ok(Some(content));
175 }
176
177 Ok(None)
178 }
179
180 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 if !file_name_str.contains('-') {
195 continue;
196 }
197
198 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 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 diffs.sort_by_key(|d| d.timestamp);
239 diffs.reverse();
240
241 Ok(diffs)
242 }
243
244 pub fn cleanup_old_diffs(&self, keep_count: usize) -> Result<usize> {
246 let mut removed_count = 0;
247
248 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 if !name.contains('-') {
258 continue;
259 }
260
261 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 for (_, mut diffs) in file_diffs {
273 if diffs.len() > keep_count {
274 diffs.sort();
276
277 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 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 assert!(temp_dir.path().join(".st_bumpers").exists());
319
320 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}