use std::collections::HashSet;
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex, PoisonError};
use std::time::{Duration, Instant};
use serde::Serialize;
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct Repo {
pub name: String,
pub path: PathBuf,
}
pub fn scan(roots: &[PathBuf]) -> Vec<Repo> {
let mut seen = HashSet::new();
let mut out = Vec::new();
for root in roots {
for host in subdirs(root) {
for owner in subdirs(&host) {
for dir in subdirs(&owner) {
if !dir.join(".git").exists() {
continue;
}
let path = dir.canonicalize().unwrap_or_else(|_| dir.clone());
if !seen.insert(path.clone()) {
continue;
}
let name = format!("{}/{}", file_name(&owner), file_name(&dir));
out.push(Repo { name, path });
}
}
}
}
out.sort_by(|a, b| a.name.cmp(&b.name).then_with(|| a.path.cmp(&b.path)));
out
}
fn subdirs(dir: &Path) -> Vec<PathBuf> {
std::fs::read_dir(dir)
.into_iter()
.flatten()
.flatten()
.map(|entry| entry.path())
.filter(|p| p.is_dir())
.collect()
}
fn file_name(path: &Path) -> std::borrow::Cow<'_, str> {
path.file_name()
.map(|n| n.to_string_lossy())
.unwrap_or_default()
}
#[derive(Debug, Clone, Default)]
pub struct Cache {
state: Arc<Mutex<State>>,
}
#[derive(Debug, Default)]
struct State {
repos: Vec<Repo>,
scanned_at: Option<Instant>,
}
impl Cache {
pub fn new() -> Self {
Self::default()
}
pub fn list(&self, roots: &[PathBuf], ttl: Duration, refresh: bool) -> Vec<Repo> {
let mut state = self.state.lock().unwrap_or_else(PoisonError::into_inner);
let stale =
refresh || ttl.is_zero() || state.scanned_at.is_none_or(|at| at.elapsed() >= ttl);
if stale {
state.repos = scan(roots);
state.scanned_at = Some(Instant::now());
}
state.repos.clone()
}
}
#[cfg(test)]
mod tests {
use super::*;
fn make(root: &Path, host: &str, owner: &str, repo: &str, git: bool) -> PathBuf {
let dir = root.join(host).join(owner).join(repo);
std::fs::create_dir_all(&dir).expect("create repo dir");
if git {
std::fs::create_dir_all(dir.join(".git")).expect("create .git");
}
dir
}
#[test]
fn scan_finds_only_git_checkouts_deduplicated_and_sorted_by_name() {
let tmp = tempfile::tempdir().expect("tempdir");
let root = tmp.path().to_owned();
make(&root, "github.com", "yukimemi", "rvpm", true);
make(&root, "github.com", "yukimemi", "magi", true);
make(&root, "github.com", "yukimemi", "not-a-checkout", false);
let repos = scan(&[root]);
let names: Vec<&str> = repos.iter().map(|r| r.name.as_str()).collect();
assert_eq!(names, ["yukimemi/magi", "yukimemi/rvpm"]);
assert!(repos.iter().all(|r| r.path.is_absolute()));
}
#[test]
fn a_missing_root_does_not_empty_the_results_of_the_others() {
let tmp = tempfile::tempdir().expect("tempdir");
let good = tmp.path().join("good");
std::fs::create_dir_all(&good).expect("good root");
make(&good, "github.com", "yukimemi", "magi", true);
let missing = tmp.path().join("does-not-exist");
let repos = scan(&[missing, good]);
assert_eq!(repos.len(), 1);
assert_eq!(repos[0].name, "yukimemi/magi");
}
#[test]
fn duplicate_paths_across_roots_are_counted_once() {
let tmp = tempfile::tempdir().expect("tempdir");
let root = tmp.path().to_owned();
make(&root, "github.com", "yukimemi", "magi", true);
let repos = scan(&[root.clone(), root]);
assert_eq!(repos.len(), 1);
}
#[test]
fn the_cache_does_not_rescan_within_the_ttl_but_refresh_forces_it() {
let tmp = tempfile::tempdir().expect("tempdir");
let root = tmp.path().to_owned();
make(&root, "github.com", "yukimemi", "magi", true);
let roots = [root.clone()];
let cache = Cache::new();
let first = cache.list(&roots, Duration::from_secs(3600), false);
assert_eq!(first.len(), 1);
make(&root, "github.com", "yukimemi", "rvpm", true);
let second = cache.list(&roots, Duration::from_secs(3600), false);
assert_eq!(second.len(), 1, "a fresh cache must not rescan");
let refreshed = cache.list(&roots, Duration::from_secs(3600), true);
assert_eq!(refreshed.len(), 2, "an explicit refresh must rescan");
make(&root, "github.com", "yukimemi", "third", true);
let still_cached = cache.list(&roots, Duration::from_secs(3600), false);
assert_eq!(still_cached.len(), 2);
}
#[test]
fn a_zero_ttl_always_rescans() {
let tmp = tempfile::tempdir().expect("tempdir");
let root = tmp.path().to_owned();
make(&root, "github.com", "yukimemi", "magi", true);
let roots = [root.clone()];
let cache = Cache::new();
assert_eq!(cache.list(&roots, Duration::from_secs(0), false).len(), 1);
make(&root, "github.com", "yukimemi", "rvpm", true);
assert_eq!(cache.list(&roots, Duration::from_secs(0), false).len(), 2);
}
}