use std::collections::HashSet;
use std::path::Path;
use std::process::{Command, Stdio};
use std::sync::Arc;
use std::sync::mpsc;
use std::thread;
use std::time::{Duration, Instant, SystemTime};
const TTL: Duration = Duration::from_secs(30);
const MAX_INDEXED: usize = 50_000;
const GIT_TIMEOUT: Duration = Duration::from_secs(3);
struct IndexedFile {
path: String,
modified: SystemTime,
}
pub struct FileIndex {
paths: Arc<Vec<String>>,
built_at: Instant,
}
impl FileIndex {
#[must_use]
pub fn build(root: &Path) -> Self {
let mut files = Vec::new();
let walker = ignore::WalkBuilder::new(root)
.hidden(true) .ignore(true)
.git_ignore(true)
.build();
for entry in walker.flatten() {
if entry.file_type().is_some_and(|ft| ft.is_file()) {
let path = entry.path();
let rel = path.strip_prefix(root).unwrap_or(path);
if let Some(s) = rel.to_str() {
let path = s.replace('\\', "/");
let modified = entry
.metadata()
.ok()
.and_then(|m| m.modified().ok())
.unwrap_or(SystemTime::UNIX_EPOCH);
files.push(IndexedFile { path, modified });
}
if files.len() >= MAX_INDEXED {
tracing::warn!(
max = MAX_INDEXED,
root = %root.display(),
"file index cap reached; some files will not be searchable"
);
break;
}
}
}
let modified = git_modified_set(root);
files.sort_by(|a, b| {
let a_modified = modified.contains(&a.path);
let b_modified = modified.contains(&b.path);
b_modified
.cmp(&a_modified)
.then_with(|| b.modified.cmp(&a.modified))
.then_with(|| a.path.cmp(&b.path))
});
let paths = files.into_iter().map(|f| f.path).collect();
Self {
paths: Arc::new(paths),
built_at: Instant::now(),
}
}
#[must_use]
pub fn is_stale(&self) -> bool {
self.built_at.elapsed() > TTL
}
#[must_use]
pub fn paths(&self) -> &[String] {
&self.paths
}
#[must_use]
pub fn paths_arc(&self) -> Arc<Vec<String>> {
Arc::clone(&self.paths)
}
}
fn run_git(root: &Path, args: &[&str]) -> Option<std::process::Output> {
let root = root.to_path_buf();
let owned_args: Vec<String> = args.iter().map(|&s| s.to_owned()).collect();
let (tx, rx) = mpsc::channel();
let handle = thread::spawn(move || {
let result = Command::new("git")
.arg("-C")
.arg(&root)
.args(&owned_args)
.stdin(Stdio::null())
.output();
let _ = tx.send(result);
});
match rx.recv_timeout(GIT_TIMEOUT) {
Ok(result) => {
let _ = handle.join();
result.ok()
}
Err(_) => None,
}
}
fn git_repo_prefix(root: &Path) -> Option<String> {
let output = run_git(root, &["rev-parse", "--show-prefix"])?;
if !output.status.success() {
return None;
}
let prefix = String::from_utf8(output.stdout).ok()?;
Some(prefix.trim_end_matches(['\n', '\r']).replace('\\', "/"))
}
fn git_modified_set(root: &Path) -> HashSet<String> {
let Some(prefix) = git_repo_prefix(root) else {
return HashSet::new();
};
let Some(output) = run_git(
root,
&[
"--no-optional-locks",
"status",
"--porcelain=v1",
"-z",
"--untracked-files=all",
],
) else {
return HashSet::new();
};
if !output.status.success() {
return HashSet::new();
}
let mut set = HashSet::new();
let mut tokens = output.stdout.split(|&b| b == 0).filter(|t| !t.is_empty());
while let Some(token) = tokens.next() {
if token.len() < 3 {
continue;
}
let status = &token[..2];
if let Ok(path) = std::str::from_utf8(&token[3..]) {
let normalized = path.replace('\\', "/");
if let Some(rel) = normalized.strip_prefix(&prefix) {
set.insert(rel.to_owned());
}
}
if status.contains(&b'R') || status.contains(&b'C') {
tokens.next();
}
}
set
}
#[cfg(test)]
mod tests {
use std::fs;
use super::*;
fn make_index(files: &[&str]) -> FileIndex {
let dir = tempfile::tempdir().unwrap();
for &f in files {
let path = dir.path().join(f);
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).unwrap();
}
fs::write(&path, "").unwrap();
}
FileIndex::build(dir.path())
}
#[test]
fn build_collects_files() {
let idx = make_index(&["src/main.rs", "src/lib.rs", "README.md"]);
assert_eq!(idx.paths().len(), 3);
assert!(idx.paths().iter().any(|p| p.ends_with("main.rs")));
}
#[test]
fn is_stale_false_when_fresh() {
let idx = make_index(&["a.rs"]);
assert!(!idx.is_stale());
}
#[test]
fn unicode_paths_are_indexed_and_searchable() {
let idx = make_index(&["src/данные.rs", "データ/main.rs", "normal.rs"]);
assert!(idx.paths().iter().any(|p| p.contains("данные")));
assert!(idx.paths().iter().any(|p| p.contains("main")));
}
#[test]
fn arc_paths_shared_not_cloned() {
let idx = make_index(&["a.rs", "b.rs"]);
let arc1 = idx.paths_arc();
let arc2 = idx.paths_arc();
assert!(Arc::ptr_eq(&arc1, &arc2));
}
fn set_mtime(path: &Path, secs_ago: u64) {
let time = SystemTime::now() - Duration::from_secs(secs_ago);
let file = fs::OpenOptions::new().write(true).open(path).unwrap();
file.set_modified(time).unwrap();
}
fn run_git_setup(root: &Path, args: &[&str]) {
let null_device = if cfg!(windows) { "NUL" } else { "/dev/null" };
let status = Command::new("git")
.arg("-C")
.arg(root)
.env("GIT_CONFIG_GLOBAL", null_device)
.env("GIT_CONFIG_SYSTEM", null_device)
.args(args)
.status()
.expect("git must be on PATH for this test");
assert!(status.success(), "git {args:?} failed");
}
#[test]
fn empty_query_orders_by_mtime_desc_without_git() {
let dir = tempfile::tempdir().unwrap();
for f in ["old.rs", "mid.rs", "new.rs"] {
fs::write(dir.path().join(f), "").unwrap();
}
set_mtime(&dir.path().join("old.rs"), 300);
set_mtime(&dir.path().join("mid.rs"), 150);
set_mtime(&dir.path().join("new.rs"), 10);
let idx = FileIndex::build(dir.path());
assert_eq!(idx.paths(), &["new.rs", "mid.rs", "old.rs"]);
}
#[test]
fn git_modified_set_empty_outside_repo() {
let dir = tempfile::tempdir().unwrap();
assert!(git_modified_set(dir.path()).is_empty());
}
#[test]
fn empty_query_ranks_git_modified_files_first() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
run_git_setup(root, &["init", "-q"]);
run_git_setup(root, &["config", "user.email", "test@example.com"]);
run_git_setup(root, &["config", "user.name", "Test"]);
fs::write(root.join("committed_old.rs"), "").unwrap();
fs::write(root.join("committed_recent.rs"), "").unwrap();
run_git_setup(root, &["add", "-A"]);
run_git_setup(root, &["commit", "-q", "-m", "init"]);
fs::write(root.join("committed_old.rs"), "changed").unwrap();
fs::write(root.join("untracked.rs"), "").unwrap();
set_mtime(&root.join("committed_recent.rs"), 10);
set_mtime(&root.join("committed_old.rs"), 500);
set_mtime(&root.join("untracked.rs"), 800);
let idx = FileIndex::build(root);
let paths = idx.paths();
let pos = |name: &str| paths.iter().position(|p| p == name).unwrap();
assert!(
pos("untracked.rs") < pos("committed_recent.rs"),
"untracked.rs is git-modified (mtime 800s-ago) and must still \
outrank clean committed_recent.rs (mtime 10s-ago): {paths:?}"
);
assert!(
pos("committed_old.rs") < pos("committed_recent.rs"),
"committed_old.rs is git-modified (mtime 500s-ago) and must still \
outrank clean committed_recent.rs (mtime 10s-ago): {paths:?}"
);
assert!(pos("committed_old.rs") < pos("untracked.rs"));
}
#[test]
fn empty_query_ranks_git_modified_files_first_from_subdirectory_root() {
let dir = tempfile::tempdir().unwrap();
let repo_root = dir.path();
run_git_setup(repo_root, &["init", "-q"]);
run_git_setup(repo_root, &["config", "user.email", "test@example.com"]);
run_git_setup(repo_root, &["config", "user.name", "Test"]);
let sub = repo_root.join("crates").join("zeph-tui");
fs::create_dir_all(&sub).unwrap();
fs::write(sub.join("committed_old.rs"), "").unwrap();
fs::write(sub.join("committed_recent.rs"), "").unwrap();
run_git_setup(repo_root, &["add", "-A"]);
run_git_setup(repo_root, &["commit", "-q", "-m", "init"]);
fs::write(sub.join("committed_old.rs"), "changed").unwrap();
set_mtime(&sub.join("committed_recent.rs"), 10);
set_mtime(&sub.join("committed_old.rs"), 500);
let idx = FileIndex::build(&sub);
let paths = idx.paths();
let pos = |name: &str| paths.iter().position(|p| p == name).unwrap();
assert!(
pos("committed_old.rs") < pos("committed_recent.rs"),
"committed_old.rs is git-modified (mtime 500s-ago) and must still \
outrank clean committed_recent.rs (mtime 10s-ago) even though the \
walk root is a repo subdirectory: {paths:?}"
);
}
#[test]
fn empty_query_boosts_new_files_inside_an_untracked_directory() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
run_git_setup(root, &["init", "-q"]);
run_git_setup(root, &["config", "user.email", "test@example.com"]);
run_git_setup(root, &["config", "user.name", "Test"]);
fs::write(root.join("committed.rs"), "").unwrap();
run_git_setup(root, &["add", "-A"]);
run_git_setup(root, &["commit", "-q", "-m", "init"]);
set_mtime(&root.join("committed.rs"), 10);
let newdir = root.join("newdir");
fs::create_dir_all(&newdir).unwrap();
fs::write(newdir.join("fresh.rs"), "").unwrap();
set_mtime(&newdir.join("fresh.rs"), 500);
let idx = FileIndex::build(root);
let paths = idx.paths();
let pos = |name: &str| paths.iter().position(|p| p == name).unwrap();
assert!(
pos("newdir/fresh.rs") < pos("committed.rs"),
"fresh.rs is a new file inside a wholly untracked directory (mtime \
500s-ago) and must still outrank clean committed.rs (mtime \
10s-ago): {paths:?}"
);
}
}