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