use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::mpsc::{self, Receiver};
use std::sync::Arc;
use std::time::{Duration, Instant, SystemTime};
use crate::store::Kind;
pub const DEEP: usize = 5;
const TIME_BUDGET: Duration = Duration::from_secs(20);
const MAX_ENTRIES: usize = 1_000_000;
const SNIFF: usize = 64 * 1024;
const BIG_FILE: u64 = 8 * 1024 * 1024;
const SKIP_DIRS: &[&str] = &[
".git",
".npm",
".pnpm-store",
".yarn",
".bun",
".deno",
".docker",
".gem",
".pyenv",
".nvm",
".rbenv",
"Library",
"Applications",
"Cache",
"Cache_Data",
"Code Cache",
"GPUCache",
"GrShaderCache",
"ShaderCache",
"DawnGraphiteCache",
"DawnWebGPUCache",
"IndexedDB",
"Service Worker",
"Local Storage",
"Session Storage",
"blob_storage",
"component_crx_cache",
"extensions_crx_cache",
"Crashpad",
".hg",
".svn",
"node_modules",
"target",
"build",
"dist",
".rustup",
".cargo",
".venv",
"venv",
"__pycache__",
".Trash",
"Trash",
".terraform",
".gradle",
".m2",
];
const CANDIDATE_EXTS: &[&str] = &[
"db", "sqlite", "sqlite3", "sqlitedb", "rkyv", "bin", "cache", "shard", "dat", "zwc",
];
pub const CACHE_TTL: Duration = Duration::from_secs(24 * 60 * 60);
const CACHE_VERSION: u32 = 1;
#[derive(Debug, Clone)]
pub struct Root {
pub path: PathBuf,
pub depth: usize,
}
impl Root {
pub fn new(path: PathBuf, depth: usize) -> Self {
Root { path, depth }
}
}
#[derive(Debug, Clone)]
pub struct Hit {
pub path: PathBuf,
pub kind: Kind,
pub format: Option<&'static str>,
pub size: u64,
pub modified: SystemTime,
pub rank: u8,
}
pub struct Scan {
pub rx: Receiver<Hit>,
cancel: Arc<AtomicBool>,
pub running: bool,
pub found: usize,
}
impl Scan {
pub fn drain(&mut self) -> Vec<Hit> {
let mut out = Vec::new();
loop {
match self.rx.try_recv() {
Ok(hit) => out.push(hit),
Err(mpsc::TryRecvError::Empty) => break,
Err(mpsc::TryRecvError::Disconnected) => {
self.running = false;
break;
}
}
}
self.found += out.len();
out
}
pub fn cancel(&self) {
self.cancel.store(true, Ordering::Relaxed);
}
}
pub struct Cached {
pub hits: Vec<Hit>,
pub scanned_at: SystemTime,
}
impl Cached {
pub fn age(&self) -> Duration {
SystemTime::now()
.duration_since(self.scanned_at)
.unwrap_or_default()
}
pub fn fresh(&self) -> bool {
self.age() < CACHE_TTL
}
}
fn cache_file() -> Option<PathBuf> {
let base = std::env::var_os("XDG_CACHE_HOME")
.map(PathBuf::from)
.or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".cache")))?;
Some(base.join("zdbview").join("scan"))
}
pub fn load_cache() -> Option<Cached> {
load_cache_from(&cache_file()?)
}
pub(crate) fn load_cache_from(path: &Path) -> Option<Cached> {
let text = std::fs::read_to_string(path).ok()?;
let mut lines = text.lines();
let header: Vec<&str> = lines.next()?.split_whitespace().collect();
if header.len() != 5 || header[1] != "zdbview" || header[2] != "scan" {
return None;
}
if header[3].parse::<u32>().ok()? != CACHE_VERSION {
return None;
}
let secs: u64 = header[4].parse().ok()?;
let scanned_at = SystemTime::UNIX_EPOCH + Duration::from_secs(secs);
let mut hits = Vec::new();
for line in lines {
let f: Vec<&str> = line.split('\t').collect();
if f.len() != 6 {
continue;
}
let kind = match f[0] {
"sqlite" => Kind::Sqlite,
"rkyv" => Kind::Rkyv,
_ => continue,
};
let path = PathBuf::from(f[5]);
if !path.is_file() {
continue;
}
hits.push(Hit {
path,
kind,
format: crate::formats::magic_label(f[4]),
size: f[2].parse().unwrap_or(0),
modified: SystemTime::UNIX_EPOCH + Duration::from_secs(f[3].parse().unwrap_or(0)),
rank: f[1].parse().unwrap_or(3),
});
}
Some(Cached { hits, scanned_at })
}
pub fn save_cache(hits: &[Hit]) {
if let Some(path) = cache_file() {
save_cache_to(&path, hits);
}
}
pub(crate) fn save_cache_to(path: &Path, hits: &[Hit]) {
let now = SystemTime::now()
.duration_since(SystemTime::UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
let mut body = format!("# zdbview scan {CACHE_VERSION} {now}\n");
for h in hits {
let mtime = h
.modified
.duration_since(SystemTime::UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
let p = match h.path.to_str() {
Some(p) if !p.contains('\t') && !p.contains('\n') => p,
_ => continue,
};
body.push_str(&format!(
"{}\t{}\t{}\t{}\t{}\t{}\n",
match h.kind {
Kind::Sqlite => "sqlite",
Kind::Rkyv => "rkyv",
},
h.rank,
h.size,
mtime,
h.format.unwrap_or(""),
p
));
}
if let Some(dir) = path.parent() {
let _ = std::fs::create_dir_all(dir);
}
let tmp = path.with_extension("tmp");
if std::fs::write(&tmp, body).is_ok() {
let _ = std::fs::rename(&tmp, path);
}
}
pub fn clear_cache() {
if let Some(path) = cache_file() {
let _ = std::fs::remove_file(path);
}
}
pub fn default_roots() -> Vec<Root> {
let home = std::env::var_os("HOME").map(PathBuf::from);
let mut roots: Vec<Root> = Vec::new();
if let Some(home) = &home {
let mut dots: Vec<(SystemTime, PathBuf)> = Vec::new();
if let Ok(entries) = std::fs::read_dir(home) {
for e in entries.flatten() {
let name = e.file_name();
let name = name.to_string_lossy();
if !name.starts_with('.') || SKIP_DIRS.contains(&name.as_ref()) {
continue;
}
if e.file_type().map(|t| t.is_dir()).unwrap_or(false) {
let mtime = e
.metadata()
.and_then(|m| m.modified())
.unwrap_or(SystemTime::UNIX_EPOCH);
dots.push((mtime, e.path()));
}
}
}
dots.sort_by_key(|(mtime, _)| std::cmp::Reverse(*mtime));
roots.extend(dots.into_iter().map(|(_, p)| Root::new(p, DEEP)));
}
let xdg = |var: &str, fallback: &str| -> Option<PathBuf> {
std::env::var_os(var)
.map(PathBuf::from)
.or_else(|| home.as_ref().map(|h| h.join(fallback)))
};
roots.extend(xdg("XDG_CACHE_HOME", ".cache").map(|p| Root::new(p, DEEP)));
roots.extend(xdg("XDG_DATA_HOME", ".local/share").map(|p| Root::new(p, DEEP)));
if let Ok(cwd) = std::env::current_dir() {
roots.push(Root::new(cwd, DEEP));
}
roots.extend(home.as_ref().map(|h| Root::new(h.clone(), 0)));
roots.extend(home.map(|h| Root::new(h.join("Library/Application Support"), DEEP)));
roots.retain(|r| r.path.is_dir());
let mut kept: Vec<Root> = Vec::new();
for mut r in roots {
r.path = r.path.canonicalize().unwrap_or(r.path);
if !kept
.iter()
.any(|k| r.path.starts_with(&k.path) && k.depth >= r.depth)
{
kept.push(r);
}
}
kept
}
pub fn spawn(roots: Vec<Root>) -> Scan {
let (tx, rx) = mpsc::channel();
let cancel = Arc::new(AtomicBool::new(false));
let flag = Arc::clone(&cancel);
std::thread::spawn(move || {
let mut budget = Budget {
examined: 0,
deadline: Instant::now() + TIME_BUDGET,
};
for root in roots {
if walk(&root.path, 0, root.depth, &tx, &flag, &mut budget).is_break() {
break;
}
}
});
Scan {
rx,
cancel,
running: true,
found: 0,
}
}
use std::ops::ControlFlow;
struct Budget {
examined: usize,
deadline: Instant,
}
impl Budget {
fn spent(&self) -> bool {
self.examined > MAX_ENTRIES || Instant::now() > self.deadline
}
}
fn walk(
dir: &Path,
depth: usize,
max_depth: usize,
tx: &mpsc::Sender<Hit>,
cancel: &AtomicBool,
budget: &mut Budget,
) -> ControlFlow<()> {
if depth > max_depth || cancel.load(Ordering::Relaxed) {
return ControlFlow::Continue(());
}
let entries = match std::fs::read_dir(dir) {
Ok(e) => e,
Err(_) => return ControlFlow::Continue(()),
};
let mut subdirs: Vec<PathBuf> = Vec::new();
for entry in entries.flatten() {
budget.examined += 1;
if budget.spent() || cancel.load(Ordering::Relaxed) {
return ControlFlow::Break(());
}
let path = entry.path();
let ft = match entry.file_type() {
Ok(ft) => ft,
Err(_) => continue,
};
if ft.is_dir() {
let name = entry.file_name();
let name = name.to_string_lossy();
if !SKIP_DIRS.contains(&name.as_ref()) {
subdirs.push(path);
}
} else if ft.is_file() {
if let Some(hit) = classify(&path) {
if tx.send(hit).is_err() {
return ControlFlow::Break(());
}
}
}
}
for sub in subdirs {
walk(&sub, depth + 1, max_depth, tx, cancel, budget)?;
}
ControlFlow::Continue(())
}
fn classify(path: &Path) -> Option<Hit> {
let ext = path
.extension()
.and_then(|e| e.to_str())
.map(|e| e.to_ascii_lowercase());
let is_candidate = match &ext {
Some(e) => CANDIDATE_EXTS.contains(&e.as_str()),
None => path
.parent()
.and_then(|p| p.to_str())
.is_some_and(|p| p.contains("cache")),
};
if !is_candidate {
return None;
}
let meta = path.metadata().ok()?;
if meta.len() == 0 {
return None;
}
let (kind, format) = sniff(path, meta.len(), ext.as_deref())?;
let rank = match (kind, format) {
(Kind::Rkyv, Some(_)) => 0,
(Kind::Rkyv, None) => 1,
(Kind::Sqlite, _) => 2,
};
Some(Hit {
path: path.to_path_buf(),
kind,
format,
size: meta.len(),
modified: meta.modified().unwrap_or(SystemTime::UNIX_EPOCH),
rank,
})
}
fn sniff(path: &Path, len: u64, ext: Option<&str>) -> Option<(Kind, Option<&'static str>)> {
use std::io::{Read, Seek, SeekFrom};
let mut f = std::fs::File::open(path).ok()?;
let mut head = vec![0u8; SNIFF.min(len as usize)];
let n = f.read(&mut head).ok()?;
head.truncate(n);
if crate::store::is_sqlite_header(&head) {
return Some((Kind::Sqlite, None));
}
if let Some(name) = crate::formats::magic_in(&head) {
return Some((Kind::Rkyv, Some(name)));
}
if len > BIG_FILE {
let mut tail = vec![0u8; SNIFF];
if f.seek(SeekFrom::End(-(SNIFF as i64))).is_ok() {
let n = f.read(&mut tail).ok()?;
tail.truncate(n);
if let Some(name) = crate::formats::magic_in(&tail) {
return Some((Kind::Rkyv, Some(name)));
}
}
}
(ext == Some("rkyv")).then_some((Kind::Rkyv, None))
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
fn tmpdir(name: &str) -> PathBuf {
let mut p = std::env::temp_dir();
p.push(format!("zdbview_scan_{}_{}", std::process::id(), name));
let _ = std::fs::remove_dir_all(&p);
std::fs::create_dir_all(&p).unwrap();
p
}
fn write(path: &Path, bytes: &[u8]) {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).unwrap();
}
let mut f = std::fs::File::create(path).unwrap();
f.write_all(bytes).unwrap();
}
fn collect(root: &Path) -> Vec<Hit> {
let scan = spawn(vec![Root::new(root.to_path_buf(), DEEP)]);
let mut out = Vec::new();
while let Ok(hit) = scan.rx.recv() {
out.push(hit);
}
out.sort_by(|a, b| a.path.cmp(&b.path));
out
}
fn sqlite_bytes() -> Vec<u8> {
let mut v = b"SQLite format 3\0".to_vec();
v.extend(std::iter::repeat_n(0u8, 100));
v
}
#[test]
fn finds_sqlite_and_rkyv_and_ignores_the_rest() {
let root = tmpdir("mixed");
write(&root.join("real.db"), &sqlite_bytes());
write(
&root.join("plugins.db"),
&crate::formats::test_script_shard_bytes("/tmp/x.sh", b"blob"),
);
write(&root.join("hashes.rkyv"), b"not a magic but named rkyv");
write(&root.join("notes.txt"), b"SQLite format 3\0 in a text file");
write(&root.join("empty.db"), b"");
write(&root.join("script.sh"), b"#!/bin/sh\necho hi\n");
let hits = collect(&root);
let names: Vec<String> = hits
.iter()
.map(|h| h.path.file_name().unwrap().to_string_lossy().into_owned())
.collect();
assert_eq!(
names,
vec!["hashes.rkyv", "plugins.db", "real.db"],
"only openable files, and no empty or wrong-extension ones"
);
let by_name = |n: &str| hits.iter().find(|h| h.path.ends_with(n)).unwrap();
assert_eq!(by_name("real.db").kind, Kind::Sqlite);
assert!(by_name("real.db").format.is_none());
assert_eq!(by_name("plugins.db").kind, Kind::Rkyv);
assert_eq!(
by_name("plugins.db").format,
Some("zshrs script cache (ZRSC)")
);
assert_eq!(by_name("hashes.rkyv").kind, Kind::Rkyv);
assert!(by_name("hashes.rkyv").format.is_none());
assert!(by_name("real.db").size > 0);
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn walks_into_subdirectories_but_skips_noise_dirs() {
let root = tmpdir("nested");
write(&root.join("a/b/c/deep.db"), &sqlite_bytes());
write(&root.join("node_modules/pkg/pkg.db"), &sqlite_bytes());
write(&root.join("target/debug/build.db"), &sqlite_bytes());
write(&root.join(".git/index.db"), &sqlite_bytes());
let hits = collect(&root);
assert_eq!(hits.len(), 1, "got {:?}", hits);
assert!(hits[0].path.ends_with("a/b/c/deep.db"));
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn stops_at_the_depth_limit() {
let root = tmpdir("deep");
let mut p = root.clone();
for i in 0..(DEEP + 3) {
p = p.join(format!("d{i}"));
}
write(&p.join("too_deep.db"), &sqlite_bytes());
write(&root.join("shallow.db"), &sqlite_bytes());
let hits = collect(&root);
let names: Vec<_> = hits.iter().map(|h| h.path.clone()).collect();
assert!(names.iter().any(|p| p.ends_with("shallow.db")));
assert!(
!names.iter().any(|p| p.ends_with("too_deep.db")),
"the depth limit must hold: {names:?}"
);
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn extensionless_files_only_count_inside_a_cache_dir() {
let root = tmpdir("extless");
write(&root.join("cache/shard0"), &sqlite_bytes());
write(&root.join("elsewhere/shard0"), &sqlite_bytes());
let hits = collect(&root);
assert_eq!(hits.len(), 1, "got {:?}", hits);
assert!(hits[0].path.ends_with("cache/shard0"));
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn cancel_stops_the_walk() {
let root = tmpdir("cancel");
for i in 0..50 {
write(&root.join(format!("f{i}.db")), &sqlite_bytes());
}
let scan = spawn(vec![Root::new(root.clone(), DEEP)]);
scan.cancel();
let mut scan = scan;
while scan.running {
scan.drain();
if scan
.rx
.recv_timeout(std::time::Duration::from_millis(200))
.is_err()
{
break;
}
}
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn cache_round_trips_every_field() {
let dir = tmpdir("cache_rt");
let db = dir.join("real.db");
write(&db, &sqlite_bytes());
let shard = dir.join("scripts.rkyv");
write(
&shard,
&crate::formats::test_script_shard_bytes("/tmp/x.sh", b"blob"),
);
let hits = collect(&dir);
assert_eq!(hits.len(), 2);
let file = dir.join("scan-cache");
save_cache_to(&file, &hits);
let back = load_cache_from(&file).expect("cache must load");
assert_eq!(back.hits.len(), 2);
for (a, b) in hits.iter().zip(&back.hits) {
assert_eq!(a.path, b.path);
assert_eq!(a.kind, b.kind);
assert_eq!(a.format, b.format, "format label must survive");
assert_eq!(a.size, b.size);
assert_eq!(a.rank, b.rank);
let secs = |t: SystemTime| {
t.duration_since(SystemTime::UNIX_EPOCH)
.unwrap_or_default()
.as_secs()
};
assert_eq!(secs(a.modified), secs(b.modified));
}
assert!(back.fresh(), "a scan saved just now is fresh");
assert!(back.age() < Duration::from_secs(5));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn cache_drops_vanished_files() {
let dir = tmpdir("cache_gone");
let db = dir.join("gone.db");
write(&db, &sqlite_bytes());
let hits = collect(&dir);
assert_eq!(hits.len(), 1);
let file = dir.join("scan-cache");
save_cache_to(&file, &hits);
std::fs::remove_file(&db).unwrap();
let back = load_cache_from(&file).expect("header still parses");
assert!(back.hits.is_empty(), "a deleted file must be dropped");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn cache_older_than_the_ttl_is_not_fresh() {
let cached = Cached {
hits: Vec::new(),
scanned_at: SystemTime::now() - CACHE_TTL - Duration::from_secs(60),
};
assert!(!cached.fresh());
assert!(cached.age() > CACHE_TTL);
}
#[test]
fn unreadable_or_foreign_cache_yields_none() {
let dir = tmpdir("cache_bad");
let f = dir.join("c");
write(&f, b"not a zdbview cache\n");
assert!(load_cache_from(&f).is_none());
write(&f, b"# zdbview scan 99 1700000000\n");
assert!(load_cache_from(&f).is_none(), "version must be checked");
assert!(load_cache_from(&dir.join("absent")).is_none());
write(&f, b"# zdbview scan 1 1700000000\nnonsense\n");
let back = load_cache_from(&f).expect("header parses");
assert!(back.hits.is_empty());
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn default_roots_cover_home_dot_dirs_without_redundancy() {
let roots = default_roots();
for r in &roots {
assert!(r.path.is_dir(), "{} is not a directory", r.path.display());
}
for (i, a) in roots.iter().enumerate() {
for b in roots.iter().take(i) {
assert!(
!(a.path.starts_with(&b.path) && b.depth >= a.depth),
"{} is already covered by {}",
a.path.display(),
b.path.display()
);
}
}
if let Some(home) = std::env::var_os("HOME").map(PathBuf::from) {
let canon_roots: Vec<PathBuf> = roots.iter().map(|r| r.path.clone()).collect();
for e in std::fs::read_dir(&home).into_iter().flatten().flatten() {
let name = e.file_name();
let name = name.to_string_lossy().into_owned();
let is_dir = e.file_type().map(|t| t.is_dir()).unwrap_or(false);
if !is_dir || !name.starts_with('.') || SKIP_DIRS.contains(&name.as_str()) {
continue;
}
let path = e.path().canonicalize().unwrap_or_else(|_| e.path());
assert!(
canon_roots.iter().any(|r| path.starts_with(r)),
"{} is not covered by any root",
path.display()
);
}
let home_c = home.canonicalize().unwrap_or(home);
assert!(
roots.iter().any(|r| r.path == home_c && r.depth == 0),
"expected a files-only $HOME root"
);
}
}
#[test]
fn depth_zero_root_reads_only_its_own_files() {
let root = tmpdir("depth0");
write(&root.join("top.db"), &sqlite_bytes());
write(&root.join("sub/inner.db"), &sqlite_bytes());
let scan = spawn(vec![Root::new(root.clone(), 0)]);
let mut hits = Vec::new();
while let Ok(h) = scan.rx.recv() {
hits.push(h);
}
assert_eq!(hits.len(), 1, "got {hits:?}");
assert!(hits[0].path.ends_with("top.db"));
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn application_support_is_the_last_default_root() {
let Some(home) = std::env::var_os("HOME").map(PathBuf::from) else {
return;
};
let want = home.join("Library/Application Support");
if !want.is_dir() {
return; }
let roots = default_roots();
let want = want.canonicalize().unwrap_or(want);
assert_eq!(
roots.last().map(|r| (r.path.clone(), r.depth)),
Some((want, DEEP)),
"roots: {:?}",
roots.iter().map(|r| &r.path).collect::<Vec<_>>()
);
}
#[test]
fn hits_do_not_exhaust_the_budget() {
let root = tmpdir("many_hits");
for i in 0..600 {
write(&root.join(format!("f{i}.db")), &sqlite_bytes());
}
assert_eq!(collect(&root).len(), 600);
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn the_entry_cap_is_a_backstop_not_the_binding_limit() {
const {
assert!(
MAX_ENTRIES >= 500_000,
"an entry cap this low stops the walk long before TIME_BUDGET"
)
};
}
}