1use std::collections::HashSet;
24use std::path::{Path, PathBuf};
25use std::sync::{Arc, Mutex, PoisonError};
26use std::time::{Duration, Instant};
27
28use serde::Serialize;
29
30#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
32pub struct Repo {
33 pub name: String,
36 pub path: PathBuf,
38}
39
40pub fn scan(roots: &[PathBuf]) -> Vec<Repo> {
53 let mut seen = HashSet::new();
54 let mut out = Vec::new();
55 for root in roots {
56 for host in subdirs(root) {
57 for owner in subdirs(&host) {
58 for dir in subdirs(&owner) {
59 if !dir.join(".git").exists() {
60 continue;
61 }
62 let path = dir.canonicalize().unwrap_or_else(|_| dir.clone());
63 if !seen.insert(path.clone()) {
64 continue;
65 }
66 let name = format!("{}/{}", file_name(&owner), file_name(&dir));
67 out.push(Repo { name, path });
68 }
69 }
70 }
71 }
72 out.sort_by(|a, b| a.name.cmp(&b.name).then_with(|| a.path.cmp(&b.path)));
73 out
74}
75
76fn subdirs(dir: &Path) -> Vec<PathBuf> {
78 std::fs::read_dir(dir)
79 .into_iter()
80 .flatten()
81 .flatten()
82 .map(|entry| entry.path())
83 .filter(|p| p.is_dir())
84 .collect()
85}
86
87fn file_name(path: &Path) -> std::borrow::Cow<'_, str> {
88 path.file_name()
89 .map(|n| n.to_string_lossy())
90 .unwrap_or_default()
91}
92
93#[derive(Debug, Clone, Default)]
99pub struct Cache {
100 state: Arc<Mutex<State>>,
101}
102
103#[derive(Debug, Default)]
104struct State {
105 repos: Vec<Repo>,
106 scanned_at: Option<Instant>,
107}
108
109impl Cache {
110 pub fn new() -> Self {
112 Self::default()
113 }
114
115 pub fn list(&self, roots: &[PathBuf], ttl: Duration, refresh: bool) -> Vec<Repo> {
119 let mut state = self.state.lock().unwrap_or_else(PoisonError::into_inner);
120 let stale =
121 refresh || ttl.is_zero() || state.scanned_at.is_none_or(|at| at.elapsed() >= ttl);
122 if stale {
123 state.repos = scan(roots);
124 state.scanned_at = Some(Instant::now());
125 }
126 state.repos.clone()
127 }
128}
129
130#[cfg(test)]
131mod tests {
132 use super::*;
133
134 fn make(root: &Path, host: &str, owner: &str, repo: &str, git: bool) -> PathBuf {
137 let dir = root.join(host).join(owner).join(repo);
138 std::fs::create_dir_all(&dir).expect("create repo dir");
139 if git {
140 std::fs::create_dir_all(dir.join(".git")).expect("create .git");
141 }
142 dir
143 }
144
145 #[test]
146 fn scan_finds_only_git_checkouts_deduplicated_and_sorted_by_name() {
147 let tmp = tempfile::tempdir().expect("tempdir");
148 let root = tmp.path().to_owned();
149 make(&root, "github.com", "yukimemi", "rvpm", true);
150 make(&root, "github.com", "yukimemi", "magi", true);
151 make(&root, "github.com", "yukimemi", "not-a-checkout", false);
154
155 let repos = scan(&[root]);
156 let names: Vec<&str> = repos.iter().map(|r| r.name.as_str()).collect();
157 assert_eq!(names, ["yukimemi/magi", "yukimemi/rvpm"]);
158 assert!(repos.iter().all(|r| r.path.is_absolute()));
159 }
160
161 #[test]
162 fn a_missing_root_does_not_empty_the_results_of_the_others() {
163 let tmp = tempfile::tempdir().expect("tempdir");
164 let good = tmp.path().join("good");
165 std::fs::create_dir_all(&good).expect("good root");
166 make(&good, "github.com", "yukimemi", "magi", true);
167 let missing = tmp.path().join("does-not-exist");
168
169 let repos = scan(&[missing, good]);
170 assert_eq!(repos.len(), 1);
171 assert_eq!(repos[0].name, "yukimemi/magi");
172 }
173
174 #[test]
175 fn duplicate_paths_across_roots_are_counted_once() {
176 let tmp = tempfile::tempdir().expect("tempdir");
177 let root = tmp.path().to_owned();
178 make(&root, "github.com", "yukimemi", "magi", true);
179
180 let repos = scan(&[root.clone(), root]);
184 assert_eq!(repos.len(), 1);
185 }
186
187 #[test]
188 fn the_cache_does_not_rescan_within_the_ttl_but_refresh_forces_it() {
189 let tmp = tempfile::tempdir().expect("tempdir");
190 let root = tmp.path().to_owned();
191 make(&root, "github.com", "yukimemi", "magi", true);
192 let roots = [root.clone()];
193 let cache = Cache::new();
194
195 let first = cache.list(&roots, Duration::from_secs(3600), false);
196 assert_eq!(first.len(), 1);
197
198 make(&root, "github.com", "yukimemi", "rvpm", true);
201 let second = cache.list(&roots, Duration::from_secs(3600), false);
202 assert_eq!(second.len(), 1, "a fresh cache must not rescan");
203
204 let refreshed = cache.list(&roots, Duration::from_secs(3600), true);
205 assert_eq!(refreshed.len(), 2, "an explicit refresh must rescan");
206
207 make(&root, "github.com", "yukimemi", "third", true);
209 let still_cached = cache.list(&roots, Duration::from_secs(3600), false);
210 assert_eq!(still_cached.len(), 2);
211 }
212
213 #[test]
214 fn a_zero_ttl_always_rescans() {
215 let tmp = tempfile::tempdir().expect("tempdir");
216 let root = tmp.path().to_owned();
217 make(&root, "github.com", "yukimemi", "magi", true);
218 let roots = [root.clone()];
219 let cache = Cache::new();
220
221 assert_eq!(cache.list(&roots, Duration::from_secs(0), false).len(), 1);
222 make(&root, "github.com", "yukimemi", "rvpm", true);
223 assert_eq!(cache.list(&roots, Duration::from_secs(0), false).len(), 2);
224 }
225}