use std::collections::HashMap;
use rayon::prelude::*;
use tracing::warn;
use crate::discover::FileInfo;
use crate::index::hash::compute_file_hash;
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct FileDiff {
pub changed: Vec<FileInfo>,
pub added: Vec<FileInfo>,
pub unchanged: Vec<FileInfo>,
pub deleted: Vec<String>,
pub hashes: HashMap<String, String>,
}
impl FileDiff {
#[must_use]
pub fn new() -> Self {
Self::default()
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.changed.is_empty()
&& self.added.is_empty()
&& self.unchanged.is_empty()
&& self.deleted.is_empty()
}
#[must_use]
pub fn total(&self) -> usize {
self.changed.len() + self.added.len() + self.unchanged.len() + self.deleted.len()
}
#[must_use]
pub fn to_parse(&self) -> Vec<&FileInfo> {
self.changed.iter().chain(self.added.iter()).collect()
}
}
enum FileClass {
Changed,
Added,
Unchanged,
}
pub fn diff_files(
disk_files: &[FileInfo],
db_hashes: &[(String, String)],
force: bool,
) -> Result<FileDiff, std::io::Error> {
Ok(diff_files_with_hints(disk_files, db_hashes, force, &HashMap::new())?.0)
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct FileHashHint {
pub size: u64,
pub mtime_nanos: i128,
pub hash: String,
}
fn mtime_nanos(path: &std::path::Path) -> Option<i128> {
let meta = std::fs::metadata(path).ok()?;
Some(
meta.modified()
.ok()?
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos() as i128)
.unwrap_or(0),
)
}
type HashClassification = (FileClass, String, Option<FileHashHint>);
pub fn diff_files_with_hints(
disk_files: &[FileInfo],
db_hashes: &[(String, String)],
force: bool,
hints: &HashMap<String, FileHashHint>,
) -> Result<(FileDiff, HashMap<String, FileHashHint>), std::io::Error> {
let mut db_map: HashMap<&str, &str> = HashMap::with_capacity(db_hashes.len());
for (path, hash) in db_hashes {
db_map.insert(path.as_str(), hash.as_str());
}
let classifications: Result<Vec<Option<HashClassification>>, std::io::Error> = disk_files
.par_iter()
.map(|file| {
let mtime = mtime_nanos(&file.path);
let hint = hints.get(file.relative_path.as_str());
let cached_hash = match (hint, &mtime) {
(Some(h), Some(mt)) if !force && h.size == file.size && h.mtime_nanos == *mt => {
Some(h.hash.clone())
}
_ => None,
};
let disk_hash = match cached_hash {
Some(h) => h,
None => match compute_file_hash(&file.path) {
Ok(h) => h,
Err(err) if err.kind() == std::io::ErrorKind::InvalidInput => {
warn!(
file = %file.relative_path,
error = %err,
"skipping file during hash classification \
(symlink or exceeds MAX_FILE_SIZE)"
);
return Ok(None);
}
Err(err) => return Err(err),
},
};
let fresh_hint = mtime.map(|mtime_nanos| FileHashHint {
size: file.size,
mtime_nanos,
hash: disk_hash.clone(),
});
if force {
return Ok(Some((FileClass::Changed, disk_hash, fresh_hint)));
}
let class = match db_map.get(file.relative_path.as_str()) {
None => FileClass::Added,
Some(db_hash) => {
if *db_hash == disk_hash {
FileClass::Unchanged
} else {
FileClass::Changed
}
}
};
Ok(Some((class, disk_hash, fresh_hint)))
})
.collect();
let classifications = classifications?;
let mut diff = FileDiff::new();
let mut fresh_hints: HashMap<String, FileHashHint> = HashMap::with_capacity(disk_files.len());
let mut seen_on_disk: HashMap<&str, ()> = HashMap::with_capacity(disk_files.len());
for (file, class) in disk_files.iter().zip(classifications) {
seen_on_disk.insert(file.relative_path.as_str(), ());
match class {
Some((FileClass::Changed, hash, hint)) => {
diff.hashes.insert(file.relative_path.clone(), hash);
if let Some(hint) = hint {
fresh_hints.insert(file.relative_path.clone(), hint);
}
diff.changed.push(file.clone());
}
Some((FileClass::Added, hash, hint)) => {
diff.hashes.insert(file.relative_path.clone(), hash);
if let Some(hint) = hint {
fresh_hints.insert(file.relative_path.clone(), hint);
}
diff.added.push(file.clone());
}
Some((FileClass::Unchanged, hash, hint)) => {
diff.hashes.insert(file.relative_path.clone(), hash);
if let Some(hint) = hint {
fresh_hints.insert(file.relative_path.clone(), hint);
}
diff.unchanged.push(file.clone());
}
None => {
}
}
}
for (path, _) in db_hashes {
if !seen_on_disk.contains_key(path.as_str()) {
diff.deleted.push(path.clone());
}
}
Ok((diff, fresh_hints))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::model::Language;
use std::fs;
use std::path::{Path, PathBuf};
use tempfile::TempDir;
fn make_file(dir: &Path, rel: &str, content: &str, language: Language) -> FileInfo {
let path = dir.join(rel);
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).unwrap();
}
fs::write(&path, content).unwrap();
let metadata = fs::metadata(&path).unwrap();
FileInfo {
path,
relative_path: rel.to_string(),
language: Some(language),
size: metadata.len(),
}
}
fn hash_of(dir: &Path, rel: &str) -> String {
compute_file_hash(&dir.join(rel)).unwrap()
}
#[test]
fn file_diff_new_is_empty() {
let diff = FileDiff::new();
assert!(diff.changed.is_empty());
assert!(diff.added.is_empty());
assert!(diff.unchanged.is_empty());
assert!(diff.deleted.is_empty());
assert!(diff.is_empty());
assert_eq!(diff.total(), 0);
}
#[test]
fn file_diff_is_empty_false_when_populated() {
let mut diff = FileDiff::new();
diff.added.push(FileInfo {
path: PathBuf::from("/x.rs"),
relative_path: "x.rs".to_string(),
language: Some(Language::Rust),
size: 0,
});
assert!(!diff.is_empty());
assert_eq!(diff.total(), 1);
}
#[test]
fn file_diff_total_sums_all_buckets() {
use std::path::PathBuf;
let mut diff = FileDiff::new();
diff.changed.push(FileInfo {
path: PathBuf::from("/a.rs"),
relative_path: "a.rs".to_string(),
language: Some(Language::Rust),
size: 0,
});
diff.added.push(FileInfo {
path: PathBuf::from("/b.rs"),
relative_path: "b.rs".to_string(),
language: Some(Language::Rust),
size: 0,
});
diff.unchanged.push(FileInfo {
path: PathBuf::from("/c.rs"),
relative_path: "c.rs".to_string(),
language: Some(Language::Rust),
size: 0,
});
diff.deleted.push("d.rs".to_string());
assert_eq!(diff.total(), 4);
}
#[test]
fn file_diff_to_parse_combines_changed_and_added() {
use std::path::PathBuf;
let mut diff = FileDiff::new();
diff.changed.push(FileInfo {
path: PathBuf::from("/a.rs"),
relative_path: "a.rs".to_string(),
language: Some(Language::Rust),
size: 0,
});
diff.added.push(FileInfo {
path: PathBuf::from("/b.rs"),
relative_path: "b.rs".to_string(),
language: Some(Language::Rust),
size: 0,
});
diff.unchanged.push(FileInfo {
path: PathBuf::from("/c.rs"),
relative_path: "c.rs".to_string(),
language: Some(Language::Rust),
size: 0,
});
let to_parse = diff.to_parse();
assert_eq!(to_parse.len(), 2);
let paths: Vec<&str> = to_parse.iter().map(|f| f.relative_path.as_str()).collect();
assert!(paths.contains(&"a.rs"));
assert!(paths.contains(&"b.rs"));
}
#[test]
fn diff_files_all_new_files_go_to_added() {
let tmp = TempDir::new().unwrap();
let f1 = make_file(tmp.path(), "a.rs", "fn a() {}", Language::Rust);
let f2 = make_file(tmp.path(), "b.rs", "fn b() {}", Language::Rust);
let disk = vec![f1, f2];
let db: Vec<(String, String)> = vec![];
let diff = diff_files(&disk, &db, false).unwrap();
assert_eq!(diff.added.len(), 2, "both files should be added");
assert!(diff.changed.is_empty());
assert!(diff.unchanged.is_empty());
assert!(diff.deleted.is_empty());
}
#[test]
fn diff_files_with_hints_reuses_unchanged_files() {
let tmp = TempDir::new().unwrap();
let f = make_file(tmp.path(), "a.rs", "fn a() {}", Language::Rust);
let disk = vec![f];
let db: Vec<(String, String)> = vec![];
let (_, fresh) = diff_files_with_hints(&disk, &db, false, &HashMap::new()).unwrap();
let hash1 = fresh
.get("a.rs")
.expect("first run must record a hint")
.clone();
assert_eq!(hash1.hash, compute_file_hash(&disk[0].path).unwrap());
let (diff2, fresh2) = diff_files_with_hints(&disk, &db, false, &fresh).unwrap();
assert_eq!(diff2.added.len(), 1, "still added (not in DB)");
assert_eq!(
fresh2
.get("a.rs")
.expect("second run must refresh the hint"),
&hash1,
"unchanged file → identical hint"
);
}
#[test]
fn diff_files_with_hints_falls_back_to_full_hash_on_change() {
let tmp = TempDir::new().unwrap();
let f = make_file(tmp.path(), "a.rs", "fn a() {}", Language::Rust);
let disk = vec![f];
let db: Vec<(String, String)> = vec![];
let (_, fresh) = diff_files_with_hints(&disk, &db, false, &HashMap::new()).unwrap();
std::thread::sleep(std::time::Duration::from_millis(20));
fs::write(&disk[0].path, "fn b() {}").unwrap();
let (diff, fresh2) = diff_files_with_hints(&disk, &db, false, &fresh).unwrap();
assert_eq!(diff.added.len(), 1, "modified file must be re-hashed");
let new_hash = fresh2.get("a.rs").unwrap().hash.clone();
assert_eq!(new_hash, compute_file_hash(&disk[0].path).unwrap());
assert_ne!(
new_hash,
fresh.get("a.rs").unwrap().hash,
"stale hint must not leak into the new hint"
);
}
#[test]
fn diff_files_matching_hash_goes_to_unchanged() {
let tmp = TempDir::new().unwrap();
let f = make_file(tmp.path(), "a.rs", "fn a() {}", Language::Rust);
let disk = vec![f];
let db = vec![("a.rs".to_string(), hash_of(tmp.path(), "a.rs"))];
let diff = diff_files(&disk, &db, false).unwrap();
assert_eq!(
diff.unchanged.len(),
1,
"matching hash → unchanged (BR-INDEX-001)"
);
assert!(diff.changed.is_empty());
assert!(diff.added.is_empty());
assert!(diff.deleted.is_empty());
}
#[test]
fn diff_files_different_hash_goes_to_changed() {
let tmp = TempDir::new().unwrap();
let f = make_file(
tmp.path(),
"a.rs",
"fn a() { /* modified */ }",
Language::Rust,
);
let disk = vec![f];
let db = vec![("a.rs".to_string(), "0".repeat(64))];
let diff = diff_files(&disk, &db, false).unwrap();
assert_eq!(diff.changed.len(), 1, "different hash → changed");
assert!(diff.unchanged.is_empty());
assert!(diff.added.is_empty());
assert!(diff.deleted.is_empty());
}
#[test]
fn diff_files_in_db_not_on_disk_goes_to_deleted() {
let tmp = TempDir::new().unwrap();
let f = make_file(tmp.path(), "a.rs", "fn a() {}", Language::Rust);
let disk = vec![f];
let db = vec![
("a.rs".to_string(), hash_of(tmp.path(), "a.rs")),
("deleted.rs".to_string(), "deadbeef".to_string()),
];
let diff = diff_files(&disk, &db, false).unwrap();
assert_eq!(
diff.deleted.len(),
1,
"BR-INDEX-002: in DB not on disk → deleted"
);
assert_eq!(diff.deleted[0], "deleted.rs");
assert_eq!(diff.unchanged.len(), 1);
}
#[test]
fn diff_files_force_puts_all_disk_files_in_changed() {
let tmp = TempDir::new().unwrap();
let f1 = make_file(tmp.path(), "a.rs", "fn a() {}", Language::Rust);
let f2 = make_file(tmp.path(), "b.rs", "fn b() {}", Language::Rust);
let disk = vec![f1, f2];
let db = vec![
("a.rs".to_string(), hash_of(tmp.path(), "a.rs")),
("b.rs".to_string(), hash_of(tmp.path(), "b.rs")),
];
let diff = diff_files(&disk, &db, true).unwrap();
assert_eq!(diff.changed.len(), 2, "BR-INDEX-003: force → all changed");
assert!(
diff.unchanged.is_empty(),
"force must skip the unchanged bucket"
);
assert!(diff.added.is_empty());
assert!(
diff.deleted.is_empty(),
"force does not affect deleted detection"
);
}
#[test]
fn diff_files_force_with_new_file_goes_to_changed() {
let tmp = TempDir::new().unwrap();
let f = make_file(tmp.path(), "new.rs", "fn new() {}", Language::Rust);
let disk = vec![f];
let db: Vec<(String, String)> = vec![];
let diff = diff_files(&disk, &db, true).unwrap();
assert_eq!(diff.changed.len(), 1);
assert!(diff.added.is_empty());
}
#[test]
fn diff_files_empty_disk_nonempty_db_all_deleted() {
let disk: Vec<FileInfo> = vec![];
let db = vec![
("a.rs".to_string(), "hash_a".to_string()),
("b.rs".to_string(), "hash_b".to_string()),
("c.rs".to_string(), "hash_c".to_string()),
];
let diff = diff_files(&disk, &db, false).unwrap();
assert_eq!(diff.deleted.len(), 3, "all DB files should be deleted");
let deleted_paths: Vec<&str> = diff.deleted.iter().map(|s| s.as_str()).collect();
assert!(deleted_paths.contains(&"a.rs"));
assert!(deleted_paths.contains(&"b.rs"));
assert!(deleted_paths.contains(&"c.rs"));
assert!(diff.changed.is_empty());
assert!(diff.added.is_empty());
assert!(diff.unchanged.is_empty());
}
#[test]
fn diff_files_empty_disk_empty_db_empty_diff() {
let disk: Vec<FileInfo> = vec![];
let db: Vec<(String, String)> = vec![];
let diff = diff_files(&disk, &db, false).unwrap();
assert!(diff.is_empty());
assert_eq!(diff.total(), 0);
}
#[test]
fn diff_files_mixed_scenario() {
let tmp = TempDir::new().unwrap();
let a = make_file(tmp.path(), "a.rs", "fn a() {}", Language::Rust);
let b = make_file(tmp.path(), "b.rs", "fn b() { /* new */ }", Language::Rust);
let c = make_file(tmp.path(), "c.rs", "fn c() {}", Language::Rust);
let disk = vec![a, b, c];
let db = vec![
("a.rs".to_string(), hash_of(tmp.path(), "a.rs")), ("b.rs".to_string(), "0".repeat(64)), ("deleted.rs".to_string(), "old_hash".to_string()), ];
let diff = diff_files(&disk, &db, false).unwrap();
assert_eq!(diff.unchanged.len(), 1, "a.rs unchanged");
assert_eq!(diff.changed.len(), 1, "b.rs changed");
assert_eq!(diff.added.len(), 1, "c.rs added");
assert_eq!(diff.deleted.len(), 1, "deleted.rs deleted");
let unchanged_paths: Vec<&str> = diff
.unchanged
.iter()
.map(|f| f.relative_path.as_str())
.collect();
let changed_paths: Vec<&str> = diff
.changed
.iter()
.map(|f| f.relative_path.as_str())
.collect();
let added_paths: Vec<&str> = diff
.added
.iter()
.map(|f| f.relative_path.as_str())
.collect();
assert!(unchanged_paths.contains(&"a.rs"));
assert!(changed_paths.contains(&"b.rs"));
assert!(added_paths.contains(&"c.rs"));
assert_eq!(diff.deleted[0], "deleted.rs");
}
#[test]
fn diff_files_returns_error_when_disk_file_disappears() {
let file = FileInfo {
path: PathBuf::from("/nonexistent/missing.rs"),
relative_path: "missing.rs".to_string(),
language: Some(Language::Rust),
size: 0,
};
let disk = vec![file];
let db: Vec<(String, String)> = vec![];
let result = diff_files(&disk, &db, false);
assert!(result.is_err(), "missing disk file should error");
let err = result.unwrap_err();
assert_eq!(err.kind(), std::io::ErrorKind::NotFound);
}
#[test]
fn diff_files_force_still_computes_deleted() {
let tmp = TempDir::new().unwrap();
let f = make_file(tmp.path(), "a.rs", "fn a() {}", Language::Rust);
let disk = vec![f];
let db = vec![
("a.rs".to_string(), hash_of(tmp.path(), "a.rs")),
("gone.rs".to_string(), "old".to_string()),
];
let diff = diff_files(&disk, &db, true).unwrap();
assert_eq!(diff.changed.len(), 1);
assert_eq!(diff.deleted.len(), 1);
assert_eq!(diff.deleted[0], "gone.rs");
}
#[test]
fn diff_files_handles_nested_paths() {
let tmp = TempDir::new().unwrap();
let f1 = make_file(tmp.path(), "src/main.rs", "fn main() {}", Language::Rust);
let f2 = make_file(
tmp.path(),
"src/sub/mod.rs",
"fn mod_fn() {}",
Language::Rust,
);
let disk = vec![f1, f2];
let db = vec![
(
"src/main.rs".to_string(),
hash_of(tmp.path(), "src/main.rs"),
),
];
let diff = diff_files(&disk, &db, false).unwrap();
assert_eq!(diff.unchanged.len(), 1);
assert_eq!(diff.added.len(), 1);
let added_paths: Vec<&str> = diff
.added
.iter()
.map(|f| f.relative_path.as_str())
.collect();
assert!(added_paths.contains(&"src/sub/mod.rs"));
}
#[test]
fn diff_files_uses_relative_path_as_key() {
let tmp = TempDir::new().unwrap();
let f = make_file(tmp.path(), "main.rs", "fn main() {}", Language::Rust);
let disk = vec![f];
let db = vec![("main.rs".to_string(), hash_of(tmp.path(), "main.rs"))];
let diff = diff_files(&disk, &db, false).unwrap();
assert_eq!(diff.unchanged.len(), 1);
}
#[test]
fn diff_files_force_with_empty_db_all_changed() {
let tmp = TempDir::new().unwrap();
let f1 = make_file(tmp.path(), "a.rs", "fn a() {}", Language::Rust);
let f2 = make_file(tmp.path(), "b.rs", "fn b() {}", Language::Rust);
let disk = vec![f1, f2];
let db: Vec<(String, String)> = vec![];
let diff = diff_files(&disk, &db, true).unwrap();
assert_eq!(diff.changed.len(), 2);
assert!(diff.added.is_empty(), "force overrides added → changed");
assert!(diff.unchanged.is_empty());
assert!(diff.deleted.is_empty());
}
}