use std::collections::HashMap;
use crate::domain::common::{FileRef, Mtime};
use crate::domain::corpus::blake3_file;
#[derive(Debug, Default, Clone, PartialEq, Eq)]
pub struct FileSnapshot {
pub file_ref: String,
pub corpus: String,
pub mtime_ms: i64,
pub content_hash: String,
}
#[derive(Debug, Default, Clone, PartialEq, Eq)]
pub struct IndexPlan {
pub upserts: Vec<Upsert>,
pub mtime_touches: Vec<MtimeCandidate>,
pub deletes: Vec<FileSnapshot>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Upsert {
pub file: FileRef,
pub mtime: Mtime,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct MtimeCandidate {
pub file: FileRef,
pub snap: FileSnapshot,
pub new_mtime: Mtime,
pub known_hash: Option<String>,
}
pub fn plan(disk: Vec<(FileRef, Mtime)>, mut db: HashMap<FileRef, FileSnapshot>) -> IndexPlan {
let disk: HashMap<FileRef, Mtime> = disk.into_iter().collect();
let mut out = IndexPlan::default();
for (file, mtime) in disk {
match db.remove(&file) {
None => out.upserts.push(Upsert { file, mtime }),
Some(snap) if snap.mtime_ms == mtime.0 => {
match blake3_file(file.as_path()) {
Ok(hash) if hash == snap.content_hash => continue,
Ok(hash) => out.mtime_touches.push(MtimeCandidate {
file,
snap,
new_mtime: mtime,
known_hash: Some(hash),
}),
Err(e) => {
tracing::warn!(
target: "hallouminate::indexer",
file = %file.as_path().display(),
error = %e,
"skipping hash verification: file unreadable, keeping existing snapshot"
);
}
}
}
Some(snap) => out.mtime_touches.push(MtimeCandidate {
file,
snap,
new_mtime: mtime,
known_hash: None,
}),
}
}
let mut leftover: Vec<FileSnapshot> = db.into_values().collect();
leftover.sort_by(|a, b| a.file_ref.cmp(&b.file_ref));
out.deletes = leftover;
out
}
#[cfg(test)]
mod tests {
use std::path::PathBuf;
use super::*;
fn fref(path: &str) -> FileRef {
FileRef::new(PathBuf::from(path))
}
fn snap(file_ref: &str, mtime_ms: i64, hash: &str) -> FileSnapshot {
FileSnapshot {
file_ref: file_ref.to_string(),
corpus: "docs".to_string(),
mtime_ms,
content_hash: hash.to_string(),
}
}
fn write_file(dir: &std::path::Path, name: &str, content: &[u8]) -> (PathBuf, String) {
let path = dir.join(name);
std::fs::write(&path, content).expect("write fixture file");
let hash = blake3_file(&path).expect("hash fixture file");
(path, hash)
}
#[test]
fn plan_routes_new_file_into_upserts() {
let disk = vec![(fref("/tmp/new.md"), Mtime(42))];
let db = HashMap::new();
let p = plan(disk, db);
assert_eq!(p.upserts.len(), 1);
assert_eq!(p.upserts[0].file, fref("/tmp/new.md"));
assert_eq!(p.upserts[0].mtime, Mtime(42));
assert!(p.mtime_touches.is_empty());
assert!(p.deletes.is_empty());
}
#[test]
fn plan_skips_files_with_unchanged_mtime_and_matching_hash() {
let dir = tempfile::tempdir().expect("tempdir");
let (path, hash) = write_file(dir.path(), "stable.md", b"stable content");
let file = FileRef::new(path.clone());
let disk = vec![(file.clone(), Mtime(100))];
let mut db = HashMap::new();
db.insert(file, snap(path.to_str().unwrap(), 100, &hash));
let p = plan(disk, db);
assert!(p.upserts.is_empty());
assert!(p.mtime_touches.is_empty());
assert!(p.deletes.is_empty());
}
#[test]
fn plan_reindexes_when_content_hash_differs_despite_unchanged_mtime() {
let dir = tempfile::tempdir().expect("tempdir");
let (path, real_hash) = write_file(dir.path(), "changed-in-place.md", b"new content");
let file = FileRef::new(path.clone());
let disk = vec![(file.clone(), Mtime(100))];
let mut db = HashMap::new();
db.insert(
file.clone(),
snap(path.to_str().unwrap(), 100, "stale-hash-from-old-content"),
);
let p = plan(disk, db);
assert!(p.upserts.is_empty());
assert!(p.deletes.is_empty());
assert_eq!(p.mtime_touches.len(), 1);
let cand = &p.mtime_touches[0];
assert_eq!(cand.file, file);
assert_eq!(cand.new_mtime, Mtime(100));
assert_eq!(cand.snap.content_hash, "stale-hash-from-old-content");
assert_eq!(
cand.known_hash,
Some(real_hash),
"plan() must carry the hash it already computed so apply() doesn't re-hash the file for the touch-vs-upsert decision"
);
}
#[test]
fn plan_routes_mtime_change_into_touch_candidates() {
let file = fref("/tmp/changed.md");
let disk = vec![(file.clone(), Mtime(200))];
let mut db = HashMap::new();
db.insert(file.clone(), snap("/tmp/changed.md", 100, "cafebabe"));
let p = plan(disk, db);
assert!(p.upserts.is_empty());
assert!(p.deletes.is_empty());
assert_eq!(p.mtime_touches.len(), 1);
let cand = &p.mtime_touches[0];
assert_eq!(cand.file, file);
assert_eq!(cand.new_mtime, Mtime(200));
assert_eq!(cand.snap.mtime_ms, 100);
assert_eq!(cand.snap.content_hash, "cafebabe");
assert_eq!(cand.known_hash, None);
}
#[test]
fn plan_routes_vanished_files_into_deletes() {
let disk: Vec<(FileRef, Mtime)> = Vec::new();
let mut db = HashMap::new();
db.insert(fref("/tmp/gone.md"), snap("/tmp/gone.md", 50, "f00d"));
let p = plan(disk, db);
assert!(p.upserts.is_empty());
assert!(p.mtime_touches.is_empty());
assert_eq!(p.deletes.len(), 1);
assert_eq!(p.deletes[0].file_ref, "/tmp/gone.md");
}
#[cfg(unix)]
fn nix_getuid_is_zero() -> bool {
if let Ok(s) = std::fs::read_to_string("/proc/self/status")
&& let Some(line) = s.lines().find(|l| l.starts_with("Uid:"))
{
return line.split_whitespace().nth(1) == Some("0");
}
std::process::Command::new("id")
.arg("-u")
.output()
.ok()
.and_then(|o| String::from_utf8(o.stdout).ok())
.map(|s| s.trim() == "0")
.unwrap_or(false)
}
#[cfg(unix)]
#[test]
fn plan_keeps_existing_snapshot_when_same_mtime_file_is_unreadable() {
use std::os::unix::fs::PermissionsExt;
let dir = tempfile::tempdir().expect("tempdir");
let (path, hash) = write_file(dir.path(), "locked.md", b"stable content");
let is_root = nix_getuid_is_zero();
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o000)).expect("chmod");
let file = FileRef::new(path.clone());
let disk = vec![(file.clone(), Mtime(100))];
let mut db = HashMap::new();
db.insert(file, snap(path.to_str().unwrap(), 100, &hash));
let p = plan(disk, db);
let _ = std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644));
if is_root {
return; }
assert!(p.upserts.is_empty());
assert!(
p.mtime_touches.is_empty(),
"unreadable same-mtime file must not route to apply(); got {:?}",
p.mtime_touches
);
assert!(p.deletes.is_empty());
}
#[test]
fn plan_handles_full_matrix_simultaneously() {
let dir = tempfile::tempdir().expect("tempdir");
let (stable_path, stable_hash) = write_file(dir.path(), "stable.md", b"stable content");
let new = fref("/tmp/new.md");
let stable = FileRef::new(stable_path.clone());
let changed = fref("/tmp/changed.md");
let gone = fref("/tmp/gone.md");
let disk = vec![
(new.clone(), Mtime(1)),
(stable.clone(), Mtime(2)),
(changed.clone(), Mtime(30)),
];
let mut db = HashMap::new();
db.insert(stable, snap(stable_path.to_str().unwrap(), 2, &stable_hash));
db.insert(changed, snap("/tmp/changed.md", 20, "bb"));
db.insert(gone, snap("/tmp/gone.md", 5, "cc"));
let p = plan(disk, db);
assert_eq!(p.upserts.len(), 1);
assert_eq!(p.upserts[0].file, new);
assert_eq!(p.mtime_touches.len(), 1);
assert_eq!(p.mtime_touches[0].snap.content_hash, "bb");
assert_eq!(p.deletes.len(), 1);
assert_eq!(p.deletes[0].file_ref, "/tmp/gone.md");
}
#[test]
fn plan_deletes_are_sorted_by_file_ref_for_determinism() {
let disk: Vec<(FileRef, Mtime)> = Vec::new();
let mut db = HashMap::new();
db.insert(fref("/tmp/z.md"), snap("/tmp/z.md", 1, "a"));
db.insert(fref("/tmp/a.md"), snap("/tmp/a.md", 1, "b"));
db.insert(fref("/tmp/m.md"), snap("/tmp/m.md", 1, "c"));
let p = plan(disk, db);
let refs: Vec<_> = p.deletes.iter().map(|r| r.file_ref.clone()).collect();
assert_eq!(refs, vec!["/tmp/a.md", "/tmp/m.md", "/tmp/z.md"]);
}
}