use std::path::{Path, PathBuf};
pub(super) const MAX_HITS: usize = 6;
fn split(expanded: &str) -> Option<(PathBuf, String)> {
if expanded.ends_with('/') || expanded.ends_with(std::path::MAIN_SEPARATOR) {
return Some((PathBuf::from(expanded), String::new()));
}
let path = Path::new(expanded);
let parent = path.parent()?;
if parent.as_os_str().is_empty() {
return None;
}
let name = path.file_name()?.to_string_lossy().into_owned();
Some((parent.to_path_buf(), name))
}
fn children(parent: &Path, fragment: &str) -> Vec<PathBuf> {
let Ok(entries) = std::fs::read_dir(parent) else {
return Vec::new();
};
let wanted = fragment.to_lowercase();
let mut out: Vec<PathBuf> = entries
.flatten()
.filter(|e| e.file_type().is_ok_and(|t| t.is_dir()))
.filter_map(|e| {
let name = e.file_name().to_string_lossy().into_owned();
if name.starts_with('.') && !fragment.starts_with('.') {
return None;
}
name.to_lowercase()
.starts_with(&wanted)
.then(|| e.path().to_path_buf())
})
.collect();
out.sort();
out
}
pub(super) fn suggest(typed: &str, known: &[PathBuf]) -> Vec<PathBuf> {
let typed = typed.trim();
let expanded = crate::util::untildify(typed);
match split(&expanded) {
Some((parent, fragment)) => children(&parent, &fragment)
.into_iter()
.take(MAX_HITS)
.collect(),
None => {
let wanted = typed.to_lowercase();
known
.iter()
.filter(|dir| dir.to_string_lossy().to_lowercase().contains(&wanted))
.take(MAX_HITS)
.cloned()
.collect()
}
}
}
pub(super) fn complete(typed: &str, hits: &[PathBuf]) -> Option<String> {
let expanded = crate::util::untildify(typed.trim());
let filled = match hits {
[] => return None,
[one] => {
let mut s = crate::util::tildify(&one.to_string_lossy());
s.push('/');
s
}
many => {
let shared = shared_prefix(many);
if shared.chars().count() <= expanded.chars().count() {
return None;
}
crate::util::tildify(&shared)
}
};
let sep = std::path::MAIN_SEPARATOR;
let filled = match sep != '/' && typed.contains('/') && !typed.contains(sep) {
true => filled.replace(sep, "/"),
false => filled,
};
(filled != typed).then_some(filled)
}
fn shared_prefix(paths: &[PathBuf]) -> String {
let spellings: Vec<String> = paths.iter().map(|p| p.to_string_lossy().into()).collect();
let mut prefix = String::new();
let Some(first) = spellings.first() else {
return prefix;
};
for (i, c) in first.char_indices() {
let upto = i + c.len_utf8();
if spellings
.iter()
.all(|s| s.len() >= upto && s[..upto] == first[..upto])
{
prefix.push(c);
} else {
break;
}
}
prefix
}
fn path_is_dir(path: &Path) -> bool {
std::fs::metadata(path).is_ok_and(|m| m.is_dir())
}
pub(super) fn repos_under(home: &Path) -> Vec<PathBuf> {
let mut found: Vec<(u64, PathBuf)> = Vec::new();
let mut frontier = vec![home.to_path_buf()];
for _ in 0..2 {
let mut next = Vec::new();
for dir in frontier {
let Ok(entries) = std::fs::read_dir(&dir) else {
continue;
};
for entry in entries.flatten() {
let Ok(kind) = entry.file_type() else {
continue;
};
if kind.is_symlink() {
if !path_is_dir(&entry.path()) {
continue;
}
} else if !kind.is_dir() {
continue;
}
let name = entry.file_name();
if name.to_string_lossy().starts_with('.') {
continue;
}
let path = entry.path();
if path.join(".git").exists() {
found.push((crate::config::file_mtime_ms(&path.join(".git")), path));
} else {
next.push(path);
}
}
}
frontier = next;
}
found.sort_by(|a, b| b.0.cmp(&a.0).then_with(|| a.1.cmp(&b.1)));
found.into_iter().map(|(_, path)| path).collect()
}
#[cfg(test)]
mod tests {
struct Home {
dir: tempfile::TempDir,
}
impl Home {
fn new() -> Self {
Home {
dir: tempfile::tempdir().expect("tempdir"),
}
}
fn path(&self) -> &Path {
self.dir.path()
}
fn repo(&self, rel: &str) -> PathBuf {
let path = self.path().join(rel);
std::fs::create_dir_all(path.join(".git")).expect("repo");
std::fs::write(path.join(".git/HEAD"), "ref: refs/heads/main\n").expect("HEAD");
path
}
fn dir(&self, rel: &str) -> PathBuf {
let path = self.path().join(rel);
std::fs::create_dir_all(&path).expect("dir");
path
}
}
fn filetime_set(path: &Path, when: std::time::SystemTime) {
let secs = when
.duration_since(std::time::UNIX_EPOCH)
.expect("after 1970")
.as_secs() as libc::time_t;
let times = [
libc::timespec {
tv_sec: secs,
tv_nsec: 0,
},
libc::timespec {
tv_sec: secs,
tv_nsec: 0,
},
];
let c = std::ffi::CString::new(path.as_os_str().as_encoded_bytes())
.expect("a path with no interior NUL");
let rc = unsafe { libc::utimensat(libc::AT_FDCWD, c.as_ptr(), times.as_ptr(), 0) };
assert_eq!(
rc,
0,
"utimensat on {}: {}",
path.display(),
std::io::Error::last_os_error()
);
}
#[test]
fn a_repository_is_found_whether_it_sits_at_the_top_or_one_level_down() {
let home = Home::new();
let top = home.repo("project");
let nested = home.repo("code/other");
assert_eq!(repos_under(home.path()), vec![nested, top]);
}
#[test]
fn a_directory_merely_holding_repositories_is_not_itself_one() {
let home = Home::new();
home.repo("code/project");
let plain = home.dir("notes");
let found = repos_under(home.path());
assert!(!found.contains(&plain), "{found:?}");
assert!(!found.contains(&home.path().join("code")), "{found:?}");
}
#[test]
fn hidden_directories_are_not_scanned() {
let home = Home::new();
let hidden = home.repo(".config/thing");
let cache = home.repo(".cache/vcs/thing");
assert_eq!(repos_under(home.path()), Vec::<PathBuf>::new());
assert!(!repos_under(home.path()).contains(&hidden));
assert!(!repos_under(home.path()).contains(&cache));
}
#[test]
fn the_scan_stops_at_two_levels() {
let home = Home::new();
let too_deep = home.repo("code/project/vendor/thing");
assert_eq!(repos_under(home.path()), Vec::<PathBuf>::new());
assert!(!repos_under(home.path()).contains(&too_deep));
}
#[test]
fn a_repository_reached_through_a_symlink_is_offered() {
let outside = tempfile::tempdir().expect("outside");
let repo = outside.path().join("elsewhere");
std::fs::create_dir_all(repo.join(".git")).expect("repo");
let home = Home::new();
let link = home.path().join("linked");
std::os::unix::fs::symlink(&repo, &link).expect("symlink");
assert_eq!(repos_under(home.path()), vec![link]);
}
#[test]
fn a_repository_inside_a_repository_is_not_a_separate_answer() {
let home = Home::new();
let outer = home.repo("project");
let inner = home.repo("project/vendor/sub");
assert_eq!(repos_under(home.path()), vec![outer]);
assert!(!repos_under(home.path()).contains(&inner));
}
#[test]
fn a_repository_is_offered_before_one_pulled_long_ago() {
let home = Home::new();
let old = home.repo("old");
let new = home.repo("new");
let long_ago = std::time::SystemTime::now() - std::time::Duration::from_secs(60 * 60 * 24);
filetime_set(&old.join(".git"), long_ago);
assert_eq!(repos_under(home.path()), vec![new, old]);
}
use super::*;
#[test]
fn a_typed_path_offers_the_directories_under_it() {
let root = tempfile::tempdir().expect("tempdir");
for name in ["alpha", "album", "beta", ".hidden"] {
std::fs::create_dir(root.path().join(name)).expect("mkdir");
}
std::fs::write(root.path().join("afile"), "").expect("write");
let base = root
.path()
.to_string_lossy()
.replace(std::path::MAIN_SEPARATOR, "/");
let all = suggest(&format!("{base}/"), &[]);
let names: Vec<String> = all
.iter()
.map(|p| p.file_name().unwrap().to_string_lossy().into_owned())
.collect();
assert_eq!(names, vec!["album", "alpha", "beta"]);
let hidden = suggest(&format!("{base}/.h"), &[]);
assert_eq!(hidden.len(), 1, "asked for by its dot: {hidden:?}");
let hits = suggest(&format!("{base}/al"), &[]);
assert_eq!(hits.len(), 2);
assert_eq!(
complete(&format!("{base}/a"), &hits),
Some(format!("{base}/al"))
);
let one = suggest(&format!("{base}/be"), &[]);
assert_eq!(
complete(&format!("{base}/be"), &one),
Some(format!("{base}/beta/"))
);
assert_eq!(complete(&format!("{base}/al"), &hits), None);
}
#[test]
fn completing_keeps_the_separator_that_was_typed() {
let root = tempfile::tempdir().expect("tempdir");
std::fs::create_dir(root.path().join("beta")).expect("mkdir");
let base = root
.path()
.to_string_lossy()
.replace(std::path::MAIN_SEPARATOR, "/");
let hits = suggest(&format!("{base}/be"), &[]);
assert_eq!(hits.len(), 1, "{hits:?}");
let filled = complete(&format!("{base}/be"), &hits).expect("something to add");
assert_eq!(filled, format!("{base}/beta/"));
assert_eq!(complete(&filled, &suggest(&filled, &[])), None);
}
#[test]
fn a_bare_word_matches_the_projects_already_known() {
let known = vec![
PathBuf::from("/home/x/cctop"),
PathBuf::from("/home/x/work/api"),
PathBuf::from("/srv/cctop-fork"),
];
assert_eq!(suggest("", &known), known);
assert_eq!(
suggest("cctop", &known),
vec![
PathBuf::from("/home/x/cctop"),
PathBuf::from("/srv/cctop-fork")
]
);
assert_eq!(suggest("API", &known).len(), 1);
assert!(suggest("nothing-like-it", &known).is_empty());
}
#[test]
fn a_bare_word_never_reads_the_filesystem() {
assert_eq!(split("cctop"), None);
assert!(suggest("src", &[]).is_empty());
}
}