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