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 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 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}