1use std::collections::HashSet;
30use std::path::{Path, PathBuf};
31use std::sync::{Arc, Mutex, PoisonError};
32use std::time::{Duration, Instant};
33
34use anyhow::{Result, bail};
35use serde::Serialize;
36
37#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
39pub struct Repo {
40 pub name: String,
43 pub path: PathBuf,
45}
46
47pub fn scan(roots: &[PathBuf]) -> Vec<Repo> {
60 let mut seen = HashSet::new();
61 let mut out = Vec::new();
62 for root in roots {
63 for host in subdirs(root) {
64 for owner in subdirs(&host) {
65 for dir in subdirs(&owner) {
66 if !dir.join(".git").exists() {
67 continue;
68 }
69 let path = dir.canonicalize().unwrap_or_else(|_| dir.clone());
70 if !seen.insert(path.clone()) {
71 continue;
72 }
73 let name = format!("{}/{}", file_name(&owner), file_name(&dir));
74 out.push(Repo { name, path });
75 }
76 }
77 }
78 }
79 out.sort_by(|a, b| a.name.cmp(&b.name).then_with(|| a.path.cmp(&b.path)));
80 out
81}
82
83pub fn resolve(roots: &[PathBuf], name: &str) -> Result<PathBuf> {
90 let hits: Vec<Repo> = scan(roots).into_iter().filter(|r| r.name == name).collect();
91 match hits.len() {
92 1 => Ok(hits.into_iter().next().expect("exactly one hit").path),
93 0 => bail!("no repository named `{name}` found under the configured [repos] roots"),
94 _ => bail!(
95 "`{name}` matches {} repositories:\n{}",
96 hits.len(),
97 hits.iter()
98 .map(|r| format!(" {}", r.path.display()))
99 .collect::<Vec<_>>()
100 .join("\n")
101 ),
102 }
103}
104
105fn subdirs(dir: &Path) -> Vec<PathBuf> {
107 std::fs::read_dir(dir)
108 .into_iter()
109 .flatten()
110 .flatten()
111 .map(|entry| entry.path())
112 .filter(|p| p.is_dir())
113 .collect()
114}
115
116fn file_name(path: &Path) -> std::borrow::Cow<'_, str> {
117 path.file_name()
118 .map(|n| n.to_string_lossy())
119 .unwrap_or_default()
120}
121
122#[derive(Debug, Clone, Default)]
128pub struct Cache {
129 state: Arc<Mutex<State>>,
130}
131
132#[derive(Debug, Default)]
133struct State {
134 repos: Vec<Repo>,
135 scanned_at: Option<Instant>,
136}
137
138impl Cache {
139 pub fn new() -> Self {
141 Self::default()
142 }
143
144 pub fn list(&self, roots: &[PathBuf], ttl: Duration, refresh: bool) -> Vec<Repo> {
148 let mut state = self.state.lock().unwrap_or_else(PoisonError::into_inner);
149 let stale =
150 refresh || ttl.is_zero() || state.scanned_at.is_none_or(|at| at.elapsed() >= ttl);
151 if stale {
152 state.repos = scan(roots);
153 state.scanned_at = Some(Instant::now());
154 }
155 state.repos.clone()
156 }
157}
158
159#[cfg(test)]
160mod tests {
161 use super::*;
162
163 fn make(root: &Path, host: &str, owner: &str, repo: &str, git: bool) -> PathBuf {
166 let dir = root.join(host).join(owner).join(repo);
167 std::fs::create_dir_all(&dir).expect("create repo dir");
168 if git {
169 std::fs::create_dir_all(dir.join(".git")).expect("create .git");
170 }
171 dir
172 }
173
174 #[test]
175 fn scan_finds_only_git_checkouts_deduplicated_and_sorted_by_name() {
176 let tmp = tempfile::tempdir().expect("tempdir");
177 let root = tmp.path().to_owned();
178 make(&root, "github.com", "yukimemi", "rvpm", true);
179 make(&root, "github.com", "yukimemi", "magi", true);
180 make(&root, "github.com", "yukimemi", "not-a-checkout", false);
183
184 let repos = scan(&[root]);
185 let names: Vec<&str> = repos.iter().map(|r| r.name.as_str()).collect();
186 assert_eq!(names, ["yukimemi/magi", "yukimemi/rvpm"]);
187 assert!(repos.iter().all(|r| r.path.is_absolute()));
188 }
189
190 #[test]
191 fn a_missing_root_does_not_empty_the_results_of_the_others() {
192 let tmp = tempfile::tempdir().expect("tempdir");
193 let good = tmp.path().join("good");
194 std::fs::create_dir_all(&good).expect("good root");
195 make(&good, "github.com", "yukimemi", "magi", true);
196 let missing = tmp.path().join("does-not-exist");
197
198 let repos = scan(&[missing, good]);
199 assert_eq!(repos.len(), 1);
200 assert_eq!(repos[0].name, "yukimemi/magi");
201 }
202
203 #[test]
204 fn duplicate_paths_across_roots_are_counted_once() {
205 let tmp = tempfile::tempdir().expect("tempdir");
206 let root = tmp.path().to_owned();
207 make(&root, "github.com", "yukimemi", "magi", true);
208
209 let repos = scan(&[root.clone(), root]);
213 assert_eq!(repos.len(), 1);
214 }
215
216 #[test]
217 fn resolve_finds_the_one_match() {
218 let tmp = tempfile::tempdir().expect("tempdir");
219 let root = tmp.path().to_owned();
220 let expected = make(&root, "github.com", "yukimemi", "magi", true);
221
222 let path = resolve(&[root], "yukimemi/magi").expect("must resolve");
223 assert_eq!(path, expected.canonicalize().expect("canonicalize"));
224 }
225
226 #[test]
227 fn resolve_refuses_an_ambiguous_name_and_lists_every_candidate() {
228 let tmp = tempfile::tempdir().expect("tempdir");
229 let root = tmp.path().to_owned();
230 let a = make(&root, "github.com", "yukimemi", "magi", true);
231 let b = make(&root, "gitlab.com", "yukimemi", "magi", true);
232
233 let err = resolve(&[root], "yukimemi/magi")
234 .expect_err("two hosts share the name")
235 .to_string();
236 assert!(err.contains("2 repositories"), "{err}");
237 assert!(
238 err.contains(&a.canonicalize().unwrap().display().to_string()),
239 "{err}"
240 );
241 assert!(
242 err.contains(&b.canonicalize().unwrap().display().to_string()),
243 "{err}"
244 );
245 }
246
247 #[test]
248 fn resolve_names_the_missing_name_when_nothing_matches() {
249 let tmp = tempfile::tempdir().expect("tempdir");
250 let err = resolve(&[tmp.path().to_owned()], "nope/nope")
251 .expect_err("nothing to find")
252 .to_string();
253 assert!(err.contains("nope/nope"), "{err}");
254 }
255
256 #[test]
257 fn the_cache_does_not_rescan_within_the_ttl_but_refresh_forces_it() {
258 let tmp = tempfile::tempdir().expect("tempdir");
259 let root = tmp.path().to_owned();
260 make(&root, "github.com", "yukimemi", "magi", true);
261 let roots = [root.clone()];
262 let cache = Cache::new();
263
264 let first = cache.list(&roots, Duration::from_secs(3600), false);
265 assert_eq!(first.len(), 1);
266
267 make(&root, "github.com", "yukimemi", "rvpm", true);
270 let second = cache.list(&roots, Duration::from_secs(3600), false);
271 assert_eq!(second.len(), 1, "a fresh cache must not rescan");
272
273 let refreshed = cache.list(&roots, Duration::from_secs(3600), true);
274 assert_eq!(refreshed.len(), 2, "an explicit refresh must rescan");
275
276 make(&root, "github.com", "yukimemi", "third", true);
278 let still_cached = cache.list(&roots, Duration::from_secs(3600), false);
279 assert_eq!(still_cached.len(), 2);
280 }
281
282 #[test]
283 fn a_zero_ttl_always_rescans() {
284 let tmp = tempfile::tempdir().expect("tempdir");
285 let root = tmp.path().to_owned();
286 make(&root, "github.com", "yukimemi", "magi", true);
287 let roots = [root.clone()];
288 let cache = Cache::new();
289
290 assert_eq!(cache.list(&roots, Duration::from_secs(0), false).len(), 1);
291 make(&root, "github.com", "yukimemi", "rvpm", true);
292 assert_eq!(cache.list(&roots, Duration::from_secs(0), false).len(), 2);
293 }
294}