use std::collections::HashMap;
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>,
}
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()
}
}
pub fn diff_files(
disk_files: &[FileInfo],
db_hashes: &[(String, String)],
force: bool,
) -> Result<FileDiff, 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 mut diff = FileDiff::new();
let mut seen_on_disk: HashMap<&str, ()> = HashMap::with_capacity(disk_files.len());
for file in disk_files {
seen_on_disk.insert(file.relative_path.as_str(), ());
let disk_hash = compute_file_hash(&file.path)?;
if force {
diff.changed.push(file.clone());
continue;
}
match db_map.get(file.relative_path.as_str()) {
None => {
diff.added.push(file.clone());
}
Some(db_hash) => {
if *db_hash == disk_hash {
diff.unchanged.push(file.clone());
} else {
diff.changed.push(file.clone());
}
}
}
}
for (path, _) in db_hashes {
if !seen_on_disk.contains_key(path.as_str()) {
diff.deleted.push(path.clone());
}
}
Ok(diff)
}
#[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_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());
}
}