use std::collections::{HashMap, HashSet};
use std::hash::{Hash, Hasher};
use std::path::Path;
use std::process::Command;
use std::time::{Duration, Instant, UNIX_EPOCH};
use ignore::WalkBuilder;
use crate::core::RepoIdentity;
use crate::lang;
use crate::store::Store;
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
pub(crate) struct Stats {
pub files_seen: usize,
pub files_indexed: usize,
pub symbols: usize,
}
#[cfg(test)]
pub(crate) fn index_path(
store: &mut Store,
root: &Path,
) -> Result<Stats, Box<dyn std::error::Error>> {
index_under(store, root, &[])
}
pub(crate) fn index_under(
store: &mut Store,
root: &Path,
subdirs: &[String],
) -> Result<Stats, Box<dyn std::error::Error>> {
run_index(store, root, &[], subdirs, None, None, None)
}
fn alnum_lower(s: &str) -> String {
s.chars()
.filter(|c| c.is_alphanumeric())
.map(|c| c.to_ascii_lowercase())
.collect()
}
fn prioritize_by_path(
paths: Vec<std::path::PathBuf>,
_root: &Path,
query: Option<&str>,
) -> Vec<std::path::PathBuf> {
let needle = alnum_lower(query.unwrap_or(""));
let k = needle.len().min(4);
if k == 0 {
return paths;
}
let kgrams: std::collections::HashSet<&[u8]> = needle.as_bytes().windows(k).collect();
let mut prio = Vec::new();
let mut rest = Vec::new();
let mut stem = String::new();
for p in paths {
stem.clear();
if let Some(s) = p.file_stem() {
stem.extend(
s.to_string_lossy()
.chars()
.filter(|c| c.is_alphanumeric())
.map(|c| c.to_ascii_lowercase()),
);
}
if stem.as_bytes().windows(k).any(|w| kgrams.contains(w)) {
prio.push(p);
} else {
rest.push(p);
}
}
prio.extend(rest);
prio
}
pub(crate) fn index_budgeted(
store: &mut Store,
root: &Path,
active: &[String],
budget: Duration,
query: Option<&str>,
) -> Result<Stats, Box<dyn std::error::Error>> {
run_index(store, root, active, &[], Some(budget), query, None)
}
pub(crate) fn index_budgeted_cancellable(
store: &mut Store,
root: &Path,
active: &[String],
budget: Duration,
query: Option<&str>,
cancel: &std::sync::atomic::AtomicBool,
) -> Result<Stats, Box<dyn std::error::Error>> {
run_index(store, root, active, &[], Some(budget), query, Some(cancel))
}
const COLLECT_CAP: usize = 50_000;
fn collect_cap() -> usize {
std::env::var("RQ_COLLECT_CAP")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(COLLECT_CAP)
}
static PARSE_JOBS: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
pub(crate) fn set_parse_jobs(n: usize) {
PARSE_JOBS.store(n, std::sync::atomic::Ordering::Relaxed);
}
pub(crate) fn parse_jobs() -> usize {
let configured = PARSE_JOBS.load(std::sync::atomic::Ordering::Relaxed);
if configured > 0 {
return configured;
}
if let Some(n) = std::env::var("RQ_JOBS").ok().and_then(|v| v.parse().ok())
&& n > 0
{
return n;
}
let cores = std::thread::available_parallelism()
.map(|n| n.get())
.unwrap_or(1);
cores.clamp(1, 8)
}
const WRITE_BATCH: usize = 512;
struct BatchWriter<'a> {
store: &'a mut Store,
repo_id: i64,
buf: Vec<crate::store::FileSymbols>,
files: usize,
symbols: usize,
write_time: Duration,
}
impl<'a> BatchWriter<'a> {
fn new(store: &'a mut Store, repo_id: i64) -> Self {
Self {
store,
repo_id,
buf: Vec::new(),
files: 0,
symbols: 0,
write_time: Duration::ZERO,
}
}
fn push(&mut self, fs: crate::store::FileSymbols) -> Result<(), Box<dyn std::error::Error>> {
self.buf.push(fs);
if self.buf.len() >= WRITE_BATCH {
self.flush()?;
}
Ok(())
}
fn flush(&mut self) -> Result<(), Box<dyn std::error::Error>> {
if !self.buf.is_empty() {
let t = Instant::now();
let (f, sy) = self.store.replace_files(self.repo_id, &self.buf)?;
self.write_time += t.elapsed();
self.files += f;
self.symbols += sy;
self.buf.clear();
}
Ok(())
}
}
fn git_source_candidates(root: &Path) -> Option<Vec<std::path::PathBuf>> {
if !is_git_repo(root) {
return None;
}
let globs: Vec<String> = lang::registry()
.iter()
.flat_map(|p| p.extensions().iter().map(|e| format!("*.{e}")))
.collect();
let mut cmd = Command::new("git");
cmd.arg("-C")
.arg(root)
.args(["ls-files", "-z", "--cached", "--"])
.args(&globs);
let out = cmd.output().ok()?;
if !out.status.success() {
return None;
}
Some(
out.stdout
.split(|&b| b == 0)
.filter(|s| !s.is_empty())
.map(|s| root.join(String::from_utf8_lossy(s).as_ref()))
.collect(),
)
}
fn fs_walk_candidates(roots: Vec<std::path::PathBuf>) -> impl Iterator<Item = std::path::PathBuf> {
roots.into_iter().flat_map(|root| {
WalkBuilder::new(&root)
.build()
.filter_map(Result::ok)
.filter(|e| e.file_type().is_some_and(|t| t.is_file()))
.map(ignore::DirEntry::into_path)
})
}
#[allow(clippy::too_many_arguments)]
fn stream_walk(
root: &Path,
candidates: impl Iterator<Item = std::path::PathBuf> + Send,
deadline: Option<Instant>,
cap: Option<usize>,
needle: Option<&[u8]>,
seen: HashSet<String>,
keep: impl Fn(&str, &Path) -> bool + Send,
cancel: Option<&std::sync::atomic::AtomicBool>,
mut sink: impl FnMut(crate::store::FileSymbols) -> Result<(), Box<dyn std::error::Error>>,
) -> Result<(HashSet<String>, bool), Box<dyn std::error::Error>> {
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
let workers = parse_jobs();
let parse_incomplete = AtomicBool::new(false);
let (path_tx, path_rx) = std::sync::mpsc::sync_channel::<std::path::PathBuf>(1024);
let (res_tx, res_rx) = std::sync::mpsc::sync_channel::<crate::store::FileSymbols>(1024);
let path_rx = Arc::new(Mutex::new(path_rx));
let (seen, walk_finished) = std::thread::scope(|s| -> Result<_, Box<dyn std::error::Error>> {
let walk = s.spawn(move || {
let mut seen = seen;
let mut finished = true;
let mut processed = 0usize;
for path in candidates {
if past(deadline) || cancel.is_some_and(|c| c.load(Ordering::Relaxed)) {
finished = false;
break;
}
let Some(ext) = path.extension().and_then(|e| e.to_str()) else {
continue;
};
if lang::plugin_for_extension(ext).is_none() {
continue;
}
let rel = path
.strip_prefix(root)
.unwrap_or(&path)
.to_string_lossy()
.into_owned();
if !seen.insert(rel.clone()) {
continue; }
if !keep(&rel, &path) {
continue; }
if path_tx.send(path).is_err() {
finished = false; break;
}
processed += 1;
if cap.is_some_and(|c| processed >= c) {
finished = false;
break;
}
}
drop(path_tx); (seen, finished)
});
let parse_incomplete = &parse_incomplete;
for _ in 0..workers {
let path_rx = Arc::clone(&path_rx);
let res_tx = res_tx.clone();
s.spawn(move || {
loop {
let got = { path_rx.lock().unwrap().recv() };
let Ok(path) = got else { break }; if past(deadline) || cancel.is_some_and(|c| c.load(Ordering::Relaxed)) {
parse_incomplete.store(true, Ordering::Relaxed); break;
}
if let Some(fs) = parse_file(root, &path, needle)
&& res_tx.send(fs).is_err()
{
break;
}
}
});
}
drop(res_tx);
while let Ok(fs) = res_rx.recv() {
sink(fs)?;
}
Ok(walk.join().unwrap())
})?;
Ok((
seen,
walk_finished && !parse_incomplete.load(Ordering::Relaxed),
))
}
fn sweep_outcome(
completed: bool,
whole_repo: bool,
seen_empty: bool,
had_stored: bool,
budgeted: bool,
) -> (bool, &'static str) {
if !whole_repo {
return (false, "warming");
}
if budgeted && completed && seen_empty && had_stored {
return (false, "warming"); }
if completed {
(true, "complete")
} else {
(false, "warming")
}
}
fn run_index(
store: &mut Store,
root: &Path,
active: &[String],
subdirs: &[String],
budget: Option<Duration>,
query: Option<&str>,
cancel: Option<&std::sync::atomic::AtomicBool>,
) -> Result<Stats, Box<dyn std::error::Error>> {
let identity = detect_identity(root);
let branch = git_output(root, &["rev-parse", "--abbrev-ref", "HEAD"]);
let repo_id = store.upsert_repository(&identity, branch.as_deref())?;
let root_display = root.canonicalize().unwrap_or_else(|_| root.to_path_buf());
store.upsert_checkout(repo_id, &root_display.to_string_lossy(), branch.as_deref())?;
for stale in store.checkout_roots(repo_id).unwrap_or_default() {
if !Path::new(&stale).exists() {
let _ = store.forget_checkout(&stale);
}
}
let stored = store.file_mtimes(repo_id)?;
let mut seen: HashSet<String> = HashSet::new();
let bulk_fts = budget.is_none() && stored.is_empty();
if bulk_fts {
store.defer_fts_insert()?;
} else if store.fts_trigger_missing().unwrap_or(false) {
let _ = store.rebuild_fts();
}
let mut active_to_parse: Vec<std::path::PathBuf> = Vec::new();
for rel in active {
note_candidate(
root,
&root.join(rel),
&stored,
&mut seen,
&mut active_to_parse,
);
}
let (active_parsed, _) = parse_files(root, &active_to_parse, None, None);
let (mut files_indexed, mut symbols) = store.replace_files(repo_id, &active_parsed)?;
let walk_roots: Vec<std::path::PathBuf> = if subdirs.is_empty() {
vec![root.to_path_buf()]
} else {
subdirs.iter().map(|s| root.join(s)).collect()
};
let git_candidates = budget
.and_then(|_| git_source_candidates(root))
.filter(|paths| !paths.is_empty());
let candidates: Box<dyn Iterator<Item = std::path::PathBuf> + Send> = match git_candidates {
Some(paths) => Box::new(prioritize_by_path(paths, root, query).into_iter()),
None => Box::new(fs_walk_candidates(walk_roots)),
};
let deadline = budget.map(|b| Instant::now() + b);
let cap = budget.map(|_| collect_cap());
let stored_ref = &stored;
let keep = |rel: &str, path: &Path| match stored_ref.get(rel) {
Some(&Some(m)) => Some(m) != file_mtime(path),
_ => true, };
let stream_start = Instant::now();
let (seen, completed, walk_files, walk_symbols, write_time) = {
let mut writer = BatchWriter::new(&mut *store, repo_id);
let (seen, completed) = stream_walk(
root,
candidates,
deadline,
cap,
None,
seen,
keep,
cancel,
|fs| writer.push(fs),
)?;
writer.flush()?;
(
seen,
completed,
writer.files,
writer.symbols,
writer.write_time,
)
};
if crate::trace::enabled() {
let elapsed = stream_start.elapsed();
crate::trace!(
"walk+parse+write {} file(s)/{} symbol(s) in {} ms ({} ms in store writes, {} parse jobs)",
walk_files,
walk_symbols,
elapsed.as_millis(),
write_time.as_millis(),
parse_jobs(),
);
}
if bulk_fts {
let t = crate::trace::Timer::start("fts bulk rebuild");
store.rebuild_fts()?;
drop(t);
}
files_indexed += walk_files;
symbols += walk_symbols;
let stats = Stats {
files_seen: seen.len(),
files_indexed,
symbols,
};
let whole_repo = subdirs.is_empty();
let (finalize, status) = sweep_outcome(
completed,
whole_repo,
seen.is_empty(),
!stored.is_empty(),
budget.is_some(),
);
if finalize {
let mut forgotten = 0;
for path in stored.keys() {
if !seen.contains(path) {
store.forget_file(repo_id, path)?;
forgotten += 1;
}
}
if forgotten > 0 {
crate::trace!(
"reconcile {}: forgot {forgotten} file(s) not seen on disk",
crate::trace::abbrev(&root_display)
);
}
if let Some(head) = git_head(root) {
let _ = store.set_indexed_head(repo_id, &head);
}
}
if stats.files_indexed > 0 && repo_root(root).is_some_and(|r| r == root_display) {
capture_commit_times(store, repo_id, root);
}
let total_files = store.repo_totals(repo_id).map(|(f, _)| f).unwrap_or(0);
let status = if status == "complete" && total_files == 0 {
"warming"
} else {
status
};
store.set_coverage(
repo_id,
stats.files_seen as i64,
stats.files_indexed as i64,
status,
)?;
crate::trace!(
"index {} (budget {budget:?}): {} seen, {} indexed, {} symbols → {status}",
crate::trace::abbrev(&root_display),
stats.files_seen,
stats.files_indexed,
stats.symbols,
);
Ok(stats)
}
fn note_candidate(
root: &Path,
file: &Path,
stored: &HashMap<String, Option<i64>>,
seen: &mut HashSet<String>,
to_parse: &mut Vec<std::path::PathBuf>,
) {
let Some(ext) = file.extension().and_then(|e| e.to_str()) else {
return;
};
if lang::plugin_for_extension(ext).is_none() {
return;
}
let rel = file
.strip_prefix(root)
.unwrap_or(file)
.to_string_lossy()
.into_owned();
if !seen.insert(rel.clone()) {
return; }
if let Some(&Some(m)) = stored.get(&rel)
&& Some(m) == file_mtime(file)
{
return;
}
to_parse.push(file.to_path_buf());
}
fn parse_file(
root: &Path,
file: &Path,
needle: Option<&[u8]>,
) -> Option<crate::store::FileSymbols> {
let ext = file.extension().and_then(|e| e.to_str())?;
let plugin = lang::plugin_for_extension(ext)?;
let rel = file
.strip_prefix(root)
.unwrap_or(file)
.to_string_lossy()
.into_owned();
let source = std::fs::read_to_string(file).ok()?;
if let Some(n) = needle
&& !contains_ascii_ci(source.as_bytes(), n)
{
return None;
}
let content_hash = content_hash(&source);
let symbols = plugin.extract(&rel, &source);
Some(crate::store::FileSymbols {
path: rel,
language: plugin.language().to_string(),
mtime: file_mtime(file),
content_hash,
symbols,
})
}
fn past(deadline: Option<Instant>) -> bool {
deadline.is_some_and(|d| Instant::now() >= d)
}
fn parse_files(
root: &Path,
paths: &[std::path::PathBuf],
deadline: Option<Instant>,
needle: Option<&[u8]>,
) -> (Vec<crate::store::FileSymbols>, bool) {
use std::sync::atomic::{AtomicBool, Ordering};
let workers = parse_jobs().min(paths.len());
if workers <= 1 {
let mut out = Vec::new();
for p in paths {
if past(deadline) {
return (out, false);
}
if let Some(parsed) = parse_file(root, p, needle) {
out.push(parsed);
}
}
return (out, true);
}
let bailed = AtomicBool::new(false);
let chunk_size = paths.len().div_ceil(workers);
let mut out = Vec::new();
std::thread::scope(|s| {
let handles: Vec<_> = paths
.chunks(chunk_size)
.map(|chunk| {
let bailed = &bailed;
s.spawn(move || {
let mut local = Vec::new();
for p in chunk {
if past(deadline) {
bailed.store(true, Ordering::Relaxed);
break;
}
if let Some(parsed) = parse_file(root, p, needle) {
local.push(parsed);
}
}
local
})
})
.collect();
for h in handles {
out.extend(h.join().unwrap_or_default());
}
});
(out, !bailed.load(Ordering::Relaxed))
}
fn capture_commit_times(store: &mut Store, repo_id: i64, root: &Path) {
let Some(head) = git_head(root) else { return };
let last = store.git_ts_head(repo_id).ok().flatten();
if last.as_deref() == Some(head.as_str()) {
return; }
let first = last.is_none();
let times = last
.and_then(|old| git_commit_times_range(root, &old, 1000))
.unwrap_or_else(|| git_commit_times(root, 1000));
if !times.is_empty() {
if store.set_file_git_ts(repo_id, ×).is_err() {
return; }
} else if first {
return; }
let _ = store.set_git_ts_head(repo_id, &head);
}
fn git_commit_times(root: &Path, limit: usize) -> HashMap<String, i64> {
match git_output(
root,
&[
"log",
&format!("-n{limit}"),
"--name-only",
"--pretty=format:%ct",
],
) {
Some(text) => parse_git_log(&text),
None => HashMap::new(),
}
}
fn git_commit_times_range(root: &Path, old: &str, limit: usize) -> Option<HashMap<String, i64>> {
git_output(
root,
&[
"log",
&format!("-n{limit}"),
"--name-only",
"--pretty=format:%ct",
&format!("{old}..HEAD"),
],
)
.map(|text| parse_git_log(&text))
}
fn parse_git_log(text: &str) -> HashMap<String, i64> {
let mut map = HashMap::new();
let mut current_ts = 0i64;
for line in text.lines() {
if line.is_empty() {
continue;
}
if let Ok(ts) = line.parse::<i64>() {
current_ts = ts;
} else {
map.entry(line.to_string()).or_insert(current_ts);
}
}
map
}
pub(crate) fn scan(
root: &Path,
skip: &HashSet<String>,
deadline: Option<Instant>,
needle: Option<&[u8]>,
) -> Vec<crate::store::FileSymbols> {
let needle = needle.filter(|n| !n.is_empty());
let candidates: Box<dyn Iterator<Item = std::path::PathBuf> + Send> =
match git_source_candidates(root).filter(|paths| !paths.is_empty()) {
Some(paths) => Box::new(paths.into_iter()),
None => Box::new(fs_walk_candidates(vec![root.to_path_buf()])),
};
let mut out: Vec<crate::store::FileSymbols> = Vec::new();
let keep = |rel: &str, _: &Path| !skip.contains(rel); let _ = stream_walk(
root,
candidates,
deadline,
None,
needle,
HashSet::new(),
keep,
None,
|fs| {
out.push(fs);
Ok(())
},
);
out
}
fn contains_ascii_ci(haystack: &[u8], needle: &[u8]) -> bool {
if needle.len() > haystack.len() {
return false;
}
haystack
.windows(needle.len())
.any(|w| w.eq_ignore_ascii_case(needle))
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum Refresh {
Unchanged,
Updated,
}
pub(crate) fn is_git_repo(root: &Path) -> bool {
repo_root(root).is_some()
}
pub(crate) fn repo_root(path: &Path) -> Option<std::path::PathBuf> {
let start = path.canonicalize().ok()?;
start
.ancestors()
.find(|a| a.join(".git").exists())
.map(Path::to_path_buf)
}
pub(crate) fn git_head(root: &Path) -> Option<String> {
let git_dir = root.join(".git");
if !git_dir.is_dir() {
return git_output(root, &["rev-parse", "HEAD"]);
}
let head = std::fs::read_to_string(git_dir.join("HEAD")).ok()?;
let head = head.trim();
let Some(git_ref) = head.strip_prefix("ref: ") else {
return (!head.is_empty()).then(|| head.to_string());
};
if let Ok(sha) = std::fs::read_to_string(git_dir.join(git_ref)) {
let sha = sha.trim();
if !sha.is_empty() {
return Some(sha.to_string());
}
}
let packed = std::fs::read_to_string(git_dir.join("packed-refs")).ok()?;
packed
.lines()
.find_map(|l| l.strip_suffix(&format!(" {git_ref}")))
.map(|sha| sha.trim().to_string())
.filter(|s| !s.is_empty())
}
pub(crate) fn is_dirty(root: &Path) -> bool {
git_output(root, &["status", "--porcelain", "--untracked-files=no"]).is_some()
}
pub(crate) fn branch_changed_files(root: &Path) -> Vec<String> {
let Some(branch) = head_branch(root) else {
return Vec::new();
};
if is_trunk(&branch) {
return Vec::new();
}
let Some(trunk) = trunk_ref(root) else {
return Vec::new();
};
let committed = {
let root = root.to_path_buf();
let spec = format!("{trunk}...HEAD");
std::thread::spawn(move || git_output(&root, &["diff", "--name-only", &spec]))
};
let working = git_output(root, &["diff", "--name-only", "HEAD"]);
let mut files: HashMap<String, ()> = HashMap::new();
for out in [committed.join().ok().flatten(), working]
.into_iter()
.flatten()
{
files.extend(
out.lines()
.filter(|l| !l.is_empty())
.map(|l| (l.to_string(), ())),
);
}
files.into_keys().collect()
}
pub(crate) fn branch_files_stamp(root: &Path) -> Option<String> {
let git_dir = root.join(".git");
if !git_dir.is_dir() {
return None;
}
let stamp = |name: &str| -> u64 {
std::fs::metadata(git_dir.join(name))
.and_then(|m| m.modified())
.ok()
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
.map(|d| d.as_secs())
.unwrap_or(0)
};
Some(format!("{}:{}", stamp("HEAD"), stamp("index")))
}
fn head_branch(root: &Path) -> Option<String> {
let git_dir = root.join(".git");
if !git_dir.is_dir() {
return git_output(root, &["rev-parse", "--abbrev-ref", "HEAD"]);
}
let head = std::fs::read_to_string(git_dir.join("HEAD")).ok()?;
let branch = head.trim().strip_prefix("ref: refs/heads/")?;
(!branch.is_empty()).then(|| branch.to_string())
}
fn is_trunk(branch: &str) -> bool {
matches!(branch, "main" | "master" | "trunk")
}
fn trunk_ref(root: &Path) -> Option<String> {
let git_dir = root.join(".git");
if !git_dir.is_dir() {
return ["main", "master"]
.into_iter()
.find(|name| git_output(root, &["rev-parse", "--verify", "--quiet", name]).is_some())
.map(str::to_string);
}
let packed = std::fs::read_to_string(git_dir.join("packed-refs")).unwrap_or_default();
["main", "master"].into_iter().find_map(|name| {
let loose = git_dir.join("refs/heads").join(name).exists();
let is_packed = packed
.lines()
.any(|l| l.ends_with(&format!(" refs/heads/{name}")));
(loose || is_packed).then(|| name.to_string())
})
}
pub(crate) fn refresh_file(
store: &mut Store,
repository_id: i64,
root: &Path,
rel: &str,
) -> Result<Refresh, Box<dyn std::error::Error>> {
let path = root.join(rel);
let source = match std::fs::read_to_string(&path) {
Ok(s) => s,
Err(_) => return Ok(Refresh::Unchanged), };
let hash = content_hash(&source);
if store.file_unchanged(repository_id, rel, &hash)? {
return Ok(Refresh::Unchanged);
}
let ext = path
.extension()
.and_then(|e| e.to_str())
.unwrap_or_default();
let plugin = lang::plugin_for_extension(ext);
let symbols = match plugin {
Some(plugin) => plugin.extract(rel, &source),
None => Vec::new(),
};
let language = plugin.map_or("unknown", |p| p.language());
let mtime = file_mtime(&path);
store.replace_file_symbols(repository_id, rel, language, mtime, &hash, &symbols)?;
Ok(Refresh::Updated)
}
pub(crate) fn detect_identity(root: &Path) -> RepoIdentity {
for remote in ["origin", "upstream"] {
if let Some(url) = git_output(root, &["remote", "get-url", remote])
&& let Some(id) = RepoIdentity::from_remote_url(&url)
{
return id;
}
}
let abs = root.canonicalize().unwrap_or_else(|_| root.to_path_buf());
RepoIdentity::local(&abs.to_string_lossy())
}
fn git_output(root: &Path, args: &[&str]) -> Option<String> {
let out = Command::new("git")
.arg("-C")
.arg(root)
.args(args)
.output()
.ok()?;
if !out.status.success() {
return None;
}
let s = String::from_utf8(out.stdout).ok()?.trim().to_string();
if s.is_empty() { None } else { Some(s) }
}
fn content_hash(source: &str) -> String {
let mut hasher = std::collections::hash_map::DefaultHasher::new();
source.hash(&mut hasher);
format!("{:016x}", hasher.finish())
}
fn file_mtime(path: &Path) -> Option<i64> {
let modified = std::fs::metadata(path).ok()?.modified().ok()?;
let nanos = modified.duration_since(UNIX_EPOCH).ok()?.as_nanos();
Some(nanos as i64)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn sweep_outcome_guards_against_a_failed_empty_walk() {
assert_eq!(
sweep_outcome(true, true, false, true, true),
(true, "complete")
);
assert_eq!(
sweep_outcome(true, true, true, false, true),
(true, "complete")
);
assert_eq!(
sweep_outcome(true, true, true, true, true),
(false, "warming")
);
assert_eq!(
sweep_outcome(true, true, true, true, false),
(true, "complete")
);
assert_eq!(
sweep_outcome(false, true, false, true, true),
(false, "warming")
);
assert_eq!(
sweep_outcome(true, false, false, true, true),
(false, "warming")
);
}
#[test]
fn content_hash_is_stable_and_distinguishes() {
assert_eq!(
content_hash("class Foo\nend"),
content_hash("class Foo\nend")
);
assert_ne!(
content_hash("class Foo\nend"),
content_hash("class Bar\nend")
);
}
#[test]
fn trunk_names_are_recognized() {
assert!(is_trunk("main"));
assert!(is_trunk("master"));
assert!(!is_trunk("feature/x"));
assert!(!is_trunk("dpep/fix"));
}
#[test]
fn prioritize_by_path_is_loose_but_targeted() {
let root = Path::new("/repo");
let paths: Vec<std::path::PathBuf> = [
"companies.rb", "app/employee.rb", "lib/EmpController.rb", "employers.rb", "app/controllers/x.rb", ]
.iter()
.map(|p| root.join(p))
.collect();
let out = prioritize_by_path(paths.clone(), root, Some("employeescontroller"));
let name = |p: &std::path::PathBuf| p.file_name().unwrap().to_str().unwrap().to_string();
let front: Vec<String> = out[..3].iter().map(name).collect();
assert!(front.contains(&"employee.rb".to_string()), "{front:?}");
assert!(front.contains(&"EmpController.rb".to_string()), "{front:?}");
assert!(front.contains(&"employers.rb".to_string()), "{front:?}");
let tail: Vec<String> = out[3..].iter().map(name).collect();
assert!(tail.contains(&"companies.rb".to_string()), "{tail:?}");
assert!(tail.contains(&"x.rb".to_string()), "{tail:?}"); assert_eq!(prioritize_by_path(paths.clone(), root, None), paths);
}
#[test]
fn detects_git_work_tree_natively() {
let dir = std::env::temp_dir().join(format!("rq-reporoot-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(dir.join("sub")).unwrap();
assert!(!is_git_repo(&dir), "no .git yet");
std::fs::create_dir_all(dir.join(".git")).unwrap();
assert!(is_git_repo(&dir), "a .git entry marks a work tree");
assert_eq!(
repo_root(&dir.join("sub")).unwrap(),
dir.canonicalize().unwrap()
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn parses_git_log_keeping_most_recent_commit_per_file() {
let log = "1700000000\n\na.rb\nb.rb\n1699990000\n\na.rb\nc.rb\n";
let map = parse_git_log(log);
assert_eq!(map.get("a.rb"), Some(&1700000000));
assert_eq!(map.get("b.rb"), Some(&1700000000));
assert_eq!(map.get("c.rb"), Some(&1699990000));
assert_eq!(map.len(), 3);
}
}