use anyhow::Result;
use rayon::prelude::*;
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())
}
pub fn rel_path(root: &std::path::Path, abs: &std::path::Path) -> String {
abs.strip_prefix(root)
.unwrap_or(abs)
.to_string_lossy()
.replace('\\', "/")
}
pub fn entry_for(root: &std::path::Path, abs: &std::path::Path, fingerprint: bool) -> Result<Entry> {
let meta = std::fs::metadata(abs)?;
let mtime = meta
.modified()?
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
.unwrap_or(0);
let fp = if fingerprint {
crate::audio::fingerprint(abs)?
} else {
None
};
Ok(Entry {
path: rel_path(root, abs),
size: meta.len(),
mtime,
hash: hash_file(abs)?,
fp,
})
}
pub fn files_under(dir: &std::path::Path) -> Result<Vec<std::path::PathBuf>> {
let mut out = Vec::new();
for entry in WalkDir::new(dir)
.into_iter()
.filter_entry(|e| e.file_name() != ".stowe")
{
let entry = entry?;
if entry.file_type().is_file() {
out.push(entry.path().to_path_buf());
}
}
Ok(out)
}
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()
}
struct Found {
rel: String,
abs: std::path::PathBuf,
size: u64,
mtime: i64,
}
pub fn scan(repo: &Repo, cache_source: &Manifest, fingerprint: bool) -> Result<Manifest> {
let cache = cache_from(cache_source);
let mut found: Vec<Found> = 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);
found.push(Found {
rel,
abs: abs.to_path_buf(),
size,
mtime,
});
}
let mut out: Manifest = found
.par_iter()
.map(|f| -> Result<Entry> {
let (hash, fp) = match cache.get(&f.rel) {
Some(c) if c.size == f.size && c.mtime == f.mtime => (c.hash.clone(), c.fp.clone()),
_ => {
let hash = hash_file(&f.abs)?;
let fp = if fingerprint {
crate::audio::fingerprint(&f.abs)?
} else {
None
};
(hash, fp)
}
};
Ok(Entry {
path: f.rel.clone(),
size: f.size,
mtime: f.mtime,
hash,
fp,
})
})
.collect::<Result<_>>()?;
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_status(staged: &Diff, unstaged: &Diff, summary: &Diff) {
use colored::Colorize;
println!("On branch {}", "main".green());
let unstaged_changes =
!unstaged.modified.is_empty() || !unstaged.removed.is_empty() || !unstaged.moved.is_empty();
if staged.is_empty() && !unstaged_changes && unstaged.added.is_empty() {
println!("nothing to commit, working tree clean");
return;
}
let added = |s: String| s.green();
let modified = |s: String| s.yellow();
let deleted = |s: String| s.red();
let renamed = |s: String| s.blue();
let line = |label: &str, text: &str, paint: &dyn Fn(String) -> colored::ColoredString| {
println!(" {}", paint(format!("{label:<12}{text}")));
};
let rename = |from: &str, to: &str| {
println!(" {}", renamed(format!("{:<12}{from}", "renamed:")));
println!(" {}", renamed(format!(" -> {to}")));
};
if !staged.is_empty() {
println!("\nChanges to be committed:");
for p in &staged.removed {
line("deleted:", p, &deleted);
}
for p in &staged.modified {
line("modified:", p, &modified);
}
for (from, to) in &staged.moved {
rename(from, to);
}
for p in &staged.added {
line("new file:", p, &added);
}
}
if unstaged_changes {
println!("\nChanges not staged for commit:");
println!(" {}", "(use \"stowe add <file>...\" to stage changes)".dimmed());
for p in &unstaged.removed {
line("deleted:", p, &deleted);
}
for p in &unstaged.modified {
line("modified:", p, &modified);
}
for (from, to) in &unstaged.moved {
rename(from, to);
}
}
if !unstaged.added.is_empty() {
println!("\nUntracked files:");
println!(" {}", "(use \"stowe add <file>...\" to include in commit)".dimmed());
for p in &unstaged.added {
println!(" {}", added(p.clone()));
}
}
println!(
"\n{} {} {} {} {}",
"summary:".dimmed(),
format!("+{}", summary.added.len()).green(),
format!("-{}", summary.removed.len()).red(),
format!("~{}", summary.modified.len()).yellow(),
format!("⇄{}", summary.moved.len()).blue(),
);
}
pub fn print_diff(d: &Diff) -> bool {
use colored::Colorize;
if d.is_empty() {
println!("{}", "No changes.".dimmed());
return false;
}
let group = |title: colored::ColoredString, items: &[String], paint: &dyn Fn(&str) -> colored::ColoredString| {
if items.is_empty() {
return;
}
println!("\n{} {}", title, format!("({})", items.len()).dimmed());
for i in items {
println!(" {}", paint(i));
}
};
group("added".green().bold(), &d.added, &|s| format!("+ {s}").green());
group("removed".red().bold(), &d.removed, &|s| format!("- {s}").red());
group("modified".yellow().bold(), &d.modified, &|s| {
format!("~ {s}").yellow()
});
if !d.moved.is_empty() {
println!(
"\n{} {}",
"moved/renamed".blue().bold(),
format!("({})", d.moved.len()).dimmed()
);
for (from, to) in &d.moved {
println!(" {}", from.dimmed());
println!(" {} {}", "→".blue(), to.cyan());
}
}
println!(
"\n{} {} {} {} {}",
"summary:".dimmed(),
format!("+{}", d.added.len()).green(),
format!("-{}", d.removed.len()).red(),
format!("~{}", d.modified.len()).yellow(),
format!("⇄{}", d.moved.len()).blue(),
);
true
}