use anyhow::Result;
use std::collections::HashMap;
use std::io::{BufReader, Read};
use std::time::UNIX_EPOCH;
use walkdir::WalkDir;
use crate::model::{Entry, Manifest};
use crate::repo::Repo;
pub fn hash_file(path: &std::path::Path) -> Result<String> {
let mut hasher = blake3::Hasher::new();
let mut reader = BufReader::new(std::fs::File::open(path)?);
let mut buf = [0u8; 65536];
loop {
let n = reader.read(&mut buf)?;
if n == 0 {
break;
}
hasher.update(&buf[..n]);
}
Ok(hasher.finalize().to_hex().to_string())
}
struct Cached {
size: u64,
mtime: i64,
hash: String,
fp: Option<String>,
}
fn cache_from(manifest: &Manifest) -> HashMap<String, Cached> {
manifest
.iter()
.map(|e| {
(
e.path.clone(),
Cached {
size: e.size,
mtime: e.mtime,
hash: e.hash.clone(),
fp: e.fp.clone(),
},
)
})
.collect()
}
pub fn scan(repo: &Repo, cache_source: &Manifest) -> Result<Manifest> {
let cache = cache_from(cache_source);
let mut out: Manifest = Vec::new();
for entry in WalkDir::new(&repo.root).into_iter().filter_entry(|e| {
e.file_name() != ".stowe"
}) {
let entry = entry?;
if !entry.file_type().is_file() {
continue;
}
let abs = entry.path();
let rel = abs
.strip_prefix(&repo.root)
.unwrap_or(abs)
.to_string_lossy()
.replace('\\', "/");
let meta = entry.metadata()?;
let size = meta.len();
let mtime = meta
.modified()?
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
.unwrap_or(0);
let (hash, fp) = match cache.get(&rel) {
Some(c) if c.size == size && c.mtime == mtime => (c.hash.clone(), c.fp.clone()),
_ => (hash_file(abs)?, crate::audio::fingerprint(abs)?),
};
out.push(Entry {
path: rel,
size,
mtime,
hash,
fp,
});
}
out.sort_by(|a, b| a.path.cmp(&b.path));
Ok(out)
}
#[derive(Default)]
pub struct Diff {
pub added: Vec<String>,
pub removed: Vec<String>,
pub modified: Vec<String>,
pub moved: Vec<(String, String)>,
}
impl Diff {
pub fn is_empty(&self) -> bool {
self.added.is_empty()
&& self.removed.is_empty()
&& self.modified.is_empty()
&& self.moved.is_empty()
}
}
pub fn diff(old: &Manifest, new: &Manifest) -> Diff {
let old_by_path: HashMap<&str, &Entry> =
old.iter().map(|e| (e.path.as_str(), e)).collect();
let new_by_path: HashMap<&str, &Entry> =
new.iter().map(|e| (e.path.as_str(), e)).collect();
let mut d = Diff::default();
for e in new {
if let Some(old_e) = old_by_path.get(e.path.as_str())
&& old_e.hash != e.hash
{
d.modified.push(e.path.clone());
}
}
let gone: Vec<&Entry> = old
.iter()
.filter(|e| !new_by_path.contains_key(e.path.as_str()))
.collect();
let fresh: Vec<&Entry> = new
.iter()
.filter(|e| !old_by_path.contains_key(e.path.as_str()))
.collect();
let mut by_hash: HashMap<&str, Vec<usize>> = HashMap::new();
let mut by_fp: HashMap<&str, Vec<usize>> = HashMap::new();
for (i, e) in fresh.iter().enumerate() {
by_hash.entry(e.hash.as_str()).or_default().push(i);
if let Some(fp) = &e.fp {
by_fp.entry(fp.as_str()).or_default().push(i);
}
}
let mut taken = vec![false; fresh.len()];
let mut claimed = vec![false; gone.len()];
let claim = |q: &mut Vec<usize>, taken: &[bool]| -> Option<usize> {
while let Some(i) = q.pop() {
if !taken[i] {
return Some(i);
}
}
None
};
for (gi, g) in gone.iter().enumerate() {
if let Some(fi) = by_hash.get_mut(g.hash.as_str()).and_then(|q| claim(q, &taken)) {
taken[fi] = true;
claimed[gi] = true;
d.moved.push((g.path.clone(), fresh[fi].path.clone()));
}
}
for (gi, g) in gone.iter().enumerate() {
if claimed[gi] {
continue;
}
let Some(fp) = g.fp.as_deref() else { continue };
if let Some(fi) = by_fp.get_mut(fp).and_then(|q| claim(q, &taken)) {
taken[fi] = true;
claimed[gi] = true;
d.moved.push((g.path.clone(), fresh[fi].path.clone()));
}
}
for (gi, g) in gone.iter().enumerate() {
if !claimed[gi] {
d.removed.push(g.path.clone());
}
}
for (fi, f) in fresh.iter().enumerate() {
if !taken[fi] {
d.added.push(f.path.clone());
}
}
d.added.sort();
d.removed.sort();
d.modified.sort();
d.moved.sort();
d
}
pub fn print_diff(d: &Diff) -> bool {
if d.is_empty() {
println!("No changes.");
return false;
}
let group = |label: &str, items: &[String]| {
if !items.is_empty() {
println!("\n{label} ({}):", items.len());
for i in items {
println!(" {i}");
}
}
};
group("🟢 added", &d.added);
group("🔴 removed", &d.removed);
group("🟡 modified", &d.modified);
if !d.moved.is_empty() {
println!("\n🔵 moved/renamed ({}):", d.moved.len());
for (from, to) in &d.moved {
println!(" {from} -> {to}");
}
}
println!(
"\nsummary: +{} added, -{} removed, ~{} modified, {} moved",
d.added.len(),
d.removed.len(),
d.modified.len(),
d.moved.len()
);
true
}