Skip to main content

auto_commit_rs/
cache.rs

1use anyhow::{Context, Result};
2use colored::Colorize;
3use inquire::Select;
4use serde::{Deserialize, Serialize};
5use std::collections::hash_map::DefaultHasher;
6use std::hash::{Hash, Hasher};
7use std::path::PathBuf;
8
9#[derive(Debug, Clone, Serialize, Deserialize)]
10pub struct CachedCommit {
11    pub hash: String,
12    pub message_preview: String,
13}
14
15#[derive(Debug, Clone, Serialize, Deserialize, Default)]
16pub struct RepoCache {
17    pub repo_path: String,
18    pub commits: Vec<CachedCommit>,
19}
20
21#[derive(Debug, Clone, Serialize, Deserialize, Default)]
22pub struct CacheIndex {
23    pub repos: Vec<CacheIndexEntry>,
24}
25
26#[derive(Debug, Clone, Serialize, Deserialize)]
27pub struct CacheIndexEntry {
28    pub repo_path: String,
29    pub cache_file: String,
30}
31
32fn cache_dir() -> Option<PathBuf> {
33    crate::config::global_config_path().map(|p| {
34        p.parent()
35            .expect("global config path should have a parent")
36            .join("cache")
37    })
38}
39
40fn repo_path_hash(path: &str) -> String {
41    let mut hasher = DefaultHasher::new();
42    path.hash(&mut hasher);
43    format!("{:016x}", hasher.finish())
44}
45
46fn index_path() -> Option<PathBuf> {
47    cache_dir().map(|d| d.join("index.toml"))
48}
49
50fn load_index() -> Result<CacheIndex> {
51    let path = match index_path() {
52        Some(p) => p,
53        None => return Ok(CacheIndex::default()),
54    };
55    if !path.exists() {
56        return Ok(CacheIndex::default());
57    }
58    let content = std::fs::read_to_string(&path)
59        .with_context(|| format!("Failed to read {}", path.display()))?;
60    let idx: CacheIndex =
61        toml::from_str(&content).with_context(|| format!("Failed to parse {}", path.display()))?;
62    Ok(idx)
63}
64
65fn save_index(index: &CacheIndex) -> Result<()> {
66    let dir = cache_dir().context("Could not determine cache directory")?;
67    std::fs::create_dir_all(&dir)
68        .with_context(|| format!("Failed to create {}", dir.display()))?;
69    let path = dir.join("index.toml");
70    let content = toml::to_string_pretty(index).context("Failed to serialize cache index")?;
71    let tmp_path = path.with_extension("toml.tmp");
72    std::fs::write(&tmp_path, &content)
73        .with_context(|| format!("Failed to write {}", tmp_path.display()))?;
74    std::fs::rename(&tmp_path, &path)
75        .with_context(|| format!("Failed to rename temp file to {}", path.display()))?;
76    Ok(())
77}
78
79fn load_repo_cache(repo_path: &str) -> Result<RepoCache> {
80    let dir = match cache_dir() {
81        Some(d) => d,
82        None => {
83            return Ok(RepoCache {
84                repo_path: repo_path.into(),
85                commits: Vec::new(),
86            })
87        }
88    };
89    let hash = repo_path_hash(repo_path);
90    let path = dir.join(format!("{hash}.toml"));
91    if !path.exists() {
92        return Ok(RepoCache {
93            repo_path: repo_path.into(),
94            commits: Vec::new(),
95        });
96    }
97    let content = std::fs::read_to_string(&path)
98        .with_context(|| format!("Failed to read {}", path.display()))?;
99    let cache: RepoCache =
100        toml::from_str(&content).with_context(|| format!("Failed to parse {}", path.display()))?;
101    Ok(cache)
102}
103
104fn save_repo_cache(cache: &RepoCache) -> Result<()> {
105    let dir = cache_dir().context("Could not determine cache directory")?;
106    std::fs::create_dir_all(&dir)
107        .with_context(|| format!("Failed to create {}", dir.display()))?;
108    let hash = repo_path_hash(&cache.repo_path);
109    let path = dir.join(format!("{hash}.toml"));
110    let content = toml::to_string_pretty(cache).context("Failed to serialize repo cache")?;
111    let tmp_path = path.with_extension("toml.tmp");
112    std::fs::write(&tmp_path, &content)
113        .with_context(|| format!("Failed to write {}", tmp_path.display()))?;
114    std::fs::rename(&tmp_path, &path)
115        .with_context(|| format!("Failed to rename temp file to {}", path.display()))?;
116    Ok(())
117}
118
119pub fn record_commit(repo_path: &str, hash: &str, message_preview: &str) -> Result<()> {
120    let mut index = load_index()?;
121    let cache_file = format!("{}.toml", repo_path_hash(repo_path));
122
123    if !index.repos.iter().any(|e| e.repo_path == repo_path) {
124        index.repos.push(CacheIndexEntry {
125            repo_path: repo_path.into(),
126            cache_file,
127        });
128        save_index(&index)?;
129    }
130
131    let mut cache = load_repo_cache(repo_path)?;
132    cache.commits.push(CachedCommit {
133        hash: hash.into(),
134        message_preview: message_preview.into(),
135    });
136    save_repo_cache(&cache)?;
137    Ok(())
138}
139
140pub fn get_head_hash() -> Result<String> {
141    let output = std::process::Command::new("git")
142        .args(["rev-parse", "HEAD"])
143        .output()
144        .context("Failed to run git rev-parse HEAD")?;
145    if !output.status.success() {
146        anyhow::bail!(
147            "git rev-parse HEAD failed: {}",
148            String::from_utf8_lossy(&output.stderr)
149        );
150    }
151    Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
152}
153
154fn show_repo_commits(cache: &RepoCache) -> Result<()> {
155    if cache.commits.is_empty() {
156        println!("{}", "No tracked commits for this repository.".dimmed());
157        return Ok(());
158    }
159
160    loop {
161        let mut options: Vec<String> = cache
162            .commits
163            .iter()
164            .rev()
165            .map(|c| {
166                let short = if c.hash.len() >= 7 {
167                    &c.hash[..7]
168                } else {
169                    &c.hash
170                };
171                format!("{} {}", short, c.message_preview)
172            })
173            .collect();
174        options.push("Back".into());
175
176        let choice = match Select::new("Select commit to view:", options.clone()).prompt() {
177            Ok(c) => c,
178            Err(_) => break,
179        };
180
181        if choice == "Back" {
182            break;
183        }
184
185        let idx = options.iter().position(|o| o == &choice).unwrap();
186        let commit = &cache.commits[cache.commits.len() - 1 - idx];
187
188        let status = std::process::Command::new("git")
189            .args(["show", &commit.hash])
190            .status();
191
192        match status {
193            Ok(s) if !s.success() => {
194                println!(
195                    "  {} Could not show commit {} (it may have been garbage collected)",
196                    "error:".red().bold(),
197                    &commit.hash[..7.min(commit.hash.len())]
198                );
199            }
200            Err(e) => {
201                println!("  {} {}", "error:".red().bold(), e);
202            }
203            _ => {}
204        }
205    }
206    Ok(())
207}
208
209pub fn interactive_history() -> Result<()> {
210    match crate::git::find_repo_root() {
211        Ok(repo_root) => {
212            let cache = load_repo_cache(&repo_root)?;
213            show_repo_commits(&cache)?;
214        }
215        Err(_) => {
216            let index = load_index()?;
217            if index.repos.is_empty() {
218                println!("{}", "No tracked repositories found.".dimmed());
219                return Ok(());
220            }
221
222            let options: Vec<String> = index.repos.iter().map(|e| e.repo_path.clone()).collect();
223            if let Ok(repo_path) = Select::new("Select repository:", options).prompt() {
224                let cache = load_repo_cache(&repo_path)?;
225                show_repo_commits(&cache)?;
226            }
227        }
228    }
229    Ok(())
230}
231
232#[cfg(test)]
233mod tests {
234    use super::*;
235
236    #[test]
237    fn test_repo_path_hash_deterministic() {
238        let h1 = repo_path_hash("/home/user/project");
239        let h2 = repo_path_hash("/home/user/project");
240        assert_eq!(h1, h2);
241        assert_eq!(h1.len(), 16);
242    }
243
244    #[test]
245    fn test_repo_path_hash_different_paths() {
246        let h1 = repo_path_hash("/home/user/project-a");
247        let h2 = repo_path_hash("/home/user/project-b");
248        assert_ne!(h1, h2);
249    }
250
251    #[test]
252    fn test_cached_commit_serde() {
253        let commit = CachedCommit {
254            hash: "abc123def456".into(),
255            message_preview: "feat: add login".into(),
256        };
257        let toml_str = toml::to_string(&commit).unwrap();
258        let parsed: CachedCommit = toml::from_str(&toml_str).unwrap();
259        assert_eq!(parsed.hash, commit.hash);
260        assert_eq!(parsed.message_preview, commit.message_preview);
261    }
262
263    #[test]
264    fn test_repo_cache_serde() {
265        let cache = RepoCache {
266            repo_path: "/home/user/project".into(),
267            commits: vec![
268                CachedCommit {
269                    hash: "aaa".into(),
270                    message_preview: "first".into(),
271                },
272                CachedCommit {
273                    hash: "bbb".into(),
274                    message_preview: "second".into(),
275                },
276            ],
277        };
278        let toml_str = toml::to_string_pretty(&cache).unwrap();
279        let parsed: RepoCache = toml::from_str(&toml_str).unwrap();
280        assert_eq!(parsed.commits.len(), 2);
281        assert_eq!(parsed.repo_path, "/home/user/project");
282    }
283
284    #[test]
285    fn test_cache_index_serde() {
286        let index = CacheIndex {
287            repos: vec![CacheIndexEntry {
288                repo_path: "/home/user/project".into(),
289                cache_file: "a1b2c3d4e5f67890.toml".into(),
290            }],
291        };
292        let toml_str = toml::to_string_pretty(&index).unwrap();
293        let parsed: CacheIndex = toml::from_str(&toml_str).unwrap();
294        assert_eq!(parsed.repos.len(), 1);
295        assert_eq!(parsed.repos[0].cache_file, "a1b2c3d4e5f67890.toml");
296    }
297
298    #[test]
299    fn test_cache_index_default() {
300        let index = CacheIndex::default();
301        assert!(index.repos.is_empty());
302    }
303
304    #[test]
305    fn test_repo_cache_default() {
306        let cache = RepoCache::default();
307        assert!(cache.repo_path.is_empty());
308        assert!(cache.commits.is_empty());
309    }
310
311    #[test]
312    fn test_cached_commit_clone() {
313        let commit = CachedCommit {
314            hash: "abc123".into(),
315            message_preview: "test commit".into(),
316        };
317        let cloned = commit.clone();
318        assert_eq!(commit.hash, cloned.hash);
319        assert_eq!(commit.message_preview, cloned.message_preview);
320    }
321
322    #[test]
323    fn test_cache_index_entry_clone() {
324        let entry = CacheIndexEntry {
325            repo_path: "/path/to/repo".into(),
326            cache_file: "hash.toml".into(),
327        };
328        let cloned = entry.clone();
329        assert_eq!(entry.repo_path, cloned.repo_path);
330        assert_eq!(entry.cache_file, cloned.cache_file);
331    }
332
333    #[test]
334    fn test_repo_cache_clone() {
335        let cache = RepoCache {
336            repo_path: "/repo".into(),
337            commits: vec![CachedCommit {
338                hash: "abc".into(),
339                message_preview: "msg".into(),
340            }],
341        };
342        let cloned = cache.clone();
343        assert_eq!(cache.repo_path, cloned.repo_path);
344        assert_eq!(cache.commits.len(), cloned.commits.len());
345    }
346
347    #[test]
348    fn test_cache_index_clone() {
349        let index = CacheIndex {
350            repos: vec![CacheIndexEntry {
351                repo_path: "/repo".into(),
352                cache_file: "file.toml".into(),
353            }],
354        };
355        let cloned = index.clone();
356        assert_eq!(index.repos.len(), cloned.repos.len());
357    }
358
359    #[test]
360    fn test_repo_path_hash_consistency() {
361        // Same path should always produce same hash
362        let path = "/some/long/path/to/repository";
363        let hash1 = repo_path_hash(path);
364        let hash2 = repo_path_hash(path);
365        let hash3 = repo_path_hash(path);
366        assert_eq!(hash1, hash2);
367        assert_eq!(hash2, hash3);
368    }
369
370    #[test]
371    fn test_repo_path_hash_format() {
372        let hash = repo_path_hash("/test/path");
373        // Should be 16 hex characters
374        assert_eq!(hash.len(), 16);
375        assert!(hash.chars().all(|c| c.is_ascii_hexdigit()));
376    }
377
378    #[test]
379    fn test_multiple_commits_serde() {
380        let cache = RepoCache {
381            repo_path: "/repo".into(),
382            commits: vec![
383                CachedCommit {
384                    hash: "aaa111".into(),
385                    message_preview: "first".into(),
386                },
387                CachedCommit {
388                    hash: "bbb222".into(),
389                    message_preview: "second".into(),
390                },
391                CachedCommit {
392                    hash: "ccc333".into(),
393                    message_preview: "third".into(),
394                },
395            ],
396        };
397        let toml_str = toml::to_string_pretty(&cache).unwrap();
398        let parsed: RepoCache = toml::from_str(&toml_str).unwrap();
399        assert_eq!(parsed.commits.len(), 3);
400        assert_eq!(parsed.commits[0].hash, "aaa111");
401        assert_eq!(parsed.commits[2].message_preview, "third");
402    }
403
404    #[test]
405    fn test_cache_index_multiple_repos() {
406        let index = CacheIndex {
407            repos: vec![
408                CacheIndexEntry {
409                    repo_path: "/repo1".into(),
410                    cache_file: "hash1.toml".into(),
411                },
412                CacheIndexEntry {
413                    repo_path: "/repo2".into(),
414                    cache_file: "hash2.toml".into(),
415                },
416            ],
417        };
418        let toml_str = toml::to_string_pretty(&index).unwrap();
419        let parsed: CacheIndex = toml::from_str(&toml_str).unwrap();
420        assert_eq!(parsed.repos.len(), 2);
421    }
422}