use std::collections::{BTreeMap, BTreeSet};
use std::fs;
use std::path::{Path, PathBuf};
use colored::Colorize;
use crate::error::Error;
const CONTEXT_LINES: usize = 3;
pub fn run(snapshot: Option<&str>, paths: &[String]) -> Result<(), Error> {
let pairs = match snapshot {
Some(snap) => build_pairs_via_snapshot(Path::new(snap), paths)?,
None => collect_pairs(Path::new(&paths[0]), Path::new(&paths[1]))?,
};
let mut files_compared = 0usize;
let mut files_with_diffs = 0usize;
let mut any_missing = false;
for pair in &pairs {
match pair {
FilePair::Both(o, n) => {
files_compared += 1;
let hunks = compare_files(o, n)?;
if !hunks.is_empty() {
files_with_diffs += 1;
print_file_report(o, n, &hunks);
}
}
FilePair::OnlyOld(p) => {
println!("{}", format!("- only in OLD: {}", p.display()).red());
any_missing = true;
}
FilePair::OnlyNew(p) => {
println!("{}", format!("+ only in NEW: {}", p.display()).green());
any_missing = true;
}
}
}
println!(
"{} files compared, {} with differences",
files_compared, files_with_diffs
);
if files_with_diffs > 0 || any_missing {
std::process::exit(1);
}
Ok(())
}
enum FilePair {
Both(PathBuf, PathBuf),
OnlyOld(PathBuf),
OnlyNew(PathBuf),
}
fn collect_pairs(old: &Path, new: &Path) -> Result<Vec<FilePair>, Error> {
if old.is_file() && new.is_file() {
return Ok(vec![FilePair::Both(old.to_path_buf(), new.to_path_buf())]);
}
if old.is_dir() && new.is_dir() {
let old_files = index_dir(old);
let new_files = index_dir(new);
let mut all_keys: BTreeSet<PathBuf> = BTreeSet::new();
all_keys.extend(old_files.keys().cloned());
all_keys.extend(new_files.keys().cloned());
let mut pairs = Vec::new();
for key in all_keys {
match (old_files.get(&key), new_files.get(&key)) {
(Some(o), Some(n)) => pairs.push(FilePair::Both(o.clone(), n.clone())),
(Some(o), None) => pairs.push(FilePair::OnlyOld(o.clone())),
(None, Some(n)) => pairs.push(FilePair::OnlyNew(n.clone())),
(None, None) => unreachable!(),
}
}
return Ok(pairs);
}
Err(Error::from(format!(
"mixed types: {} is {}, {} is {}",
old.display(),
describe(old),
new.display(),
describe(new),
)))
}
fn build_pairs_via_snapshot(
snapshot_root: &Path,
paths: &[String],
) -> Result<Vec<FilePair>, Error> {
if !snapshot_root.is_dir() {
return Err(Error::from(format!(
"snapshot root {} is not a directory",
snapshot_root.display(),
)));
}
let working_paths: Vec<PathBuf> = if paths.is_empty() {
vec![std::env::current_dir()
.map_err(|e| Error::from(format!("getcwd failed: {}", e)))?]
} else {
paths.iter().map(PathBuf::from).collect()
};
let mut out = Vec::new();
for work in &working_paths {
let abs = work
.canonicalize()
.map_err(|e| Error::from(format!("resolve {}: {}", work.display(), e)))?;
let matched = longest_suffix_match(snapshot_root, &abs).ok_or_else(|| {
Error::from(format!(
"no matching path under {} for {}",
snapshot_root.display(),
abs.display(),
))
})?;
let pair_is_dir_both = matched.is_dir() && abs.is_dir();
let pair_is_file_both = matched.is_file() && abs.is_file();
if !(pair_is_dir_both || pair_is_file_both) {
return Err(Error::from(format!(
"{} and {} are not the same kind (file vs directory)",
matched.display(),
abs.display(),
)));
}
if matched.is_file() {
out.push(FilePair::Both(matched, abs));
} else {
let snap_files = index_dir(&matched);
let work_files = index_dir(&abs);
let mut all_keys: BTreeSet<PathBuf> = BTreeSet::new();
all_keys.extend(snap_files.keys().cloned());
all_keys.extend(work_files.keys().cloned());
for key in all_keys {
match (snap_files.get(&key), work_files.get(&key)) {
(Some(o), Some(n)) => out.push(FilePair::Both(o.clone(), n.clone())),
(Some(o), None) => out.push(FilePair::OnlyOld(o.clone())),
(None, Some(n)) => out.push(FilePair::OnlyNew(n.clone())),
(None, None) => unreachable!(),
}
}
}
}
Ok(out)
}
fn longest_suffix_match(snapshot_root: &Path, work_abs: &Path) -> Option<PathBuf> {
let components: Vec<&std::ffi::OsStr> = work_abs
.components()
.filter_map(|c| match c {
std::path::Component::Normal(s) => Some(s),
_ => None,
})
.collect();
for skip in 0..=components.len() {
let mut candidate = snapshot_root.to_path_buf();
for seg in &components[skip..] {
candidate.push(seg);
}
if candidate.exists() {
return Some(candidate);
}
}
None
}
fn describe(p: &Path) -> &'static str {
if p.is_file() {
"a file"
} else if p.is_dir() {
"a directory"
} else {
"missing"
}
}
fn index_dir(root: &Path) -> BTreeMap<PathBuf, PathBuf> {
let mut out = BTreeMap::new();
let mut stack: Vec<PathBuf> = vec![root.to_path_buf()];
while let Some(dir) = stack.pop() {
let Ok(rd) = fs::read_dir(&dir) else { continue };
for entry in rd.flatten() {
let path = entry.path();
if path.is_dir() {
stack.push(path);
} else if crate::is_journal_file(&path) {
if let Ok(rel) = path.strip_prefix(root) {
out.insert(rel.to_path_buf(), path);
}
}
}
}
out
}
fn normalise(line: &str) -> String {
line.chars().filter(|c| !c.is_whitespace()).collect()
}
#[derive(Clone, Copy)]
enum Op {
Keep,
Remove,
Add,
}
fn compare_files(old_path: &Path, new_path: &Path) -> Result<Vec<Hunk>, Error> {
let old_src = fs::read_to_string(old_path)
.map_err(|e| Error::from(format!("read {}: {}", old_path.display(), e)))?;
let new_src = fs::read_to_string(new_path)
.map_err(|e| Error::from(format!("read {}: {}", new_path.display(), e)))?;
if old_src.chars().all(|c| c.is_whitespace())
&& new_src.chars().all(|c| c.is_whitespace())
{
return Ok(Vec::new());
}
let old_lines: Vec<&str> = old_src.lines().collect();
let new_lines: Vec<&str> = new_src.lines().collect();
let old_norm: Vec<String> = old_lines.iter().map(|l| normalise(l)).collect();
let new_norm: Vec<String> = new_lines.iter().map(|l| normalise(l)).collect();
let ops = lcs_walk(&old_norm, &new_norm);
Ok(build_hunks(&old_lines, &new_lines, &ops))
}
fn lcs_walk(a: &[String], b: &[String]) -> Vec<Op> {
let n = a.len();
let m = b.len();
let mut dp = vec![vec![0u32; m + 1]; n + 1];
for i in 0..n {
for j in 0..m {
dp[i + 1][j + 1] = if a[i] == b[j] {
dp[i][j] + 1
} else {
dp[i + 1][j].max(dp[i][j + 1])
};
}
}
let mut ops = Vec::with_capacity(n + m);
let (mut i, mut j) = (n, m);
while i > 0 || j > 0 {
if i > 0 && j > 0 && a[i - 1] == b[j - 1] {
ops.push(Op::Keep);
i -= 1;
j -= 1;
} else if j > 0 && (i == 0 || dp[i][j - 1] >= dp[i - 1][j]) {
ops.push(Op::Add);
j -= 1;
} else {
ops.push(Op::Remove);
i -= 1;
}
}
ops.reverse();
ops
}
struct Hunk {
old_start: usize, old_count: usize,
new_start: usize,
new_count: usize,
lines: Vec<HunkLine>,
}
enum HunkLine {
Context(String),
Removed(String),
Added(String),
}
fn build_hunks(old: &[&str], new: &[&str], ops: &[Op]) -> Vec<Hunk> {
let mut hunks = Vec::new();
let mut pending: Vec<(Op, usize, usize)> = Vec::new(); let mut i = 0usize;
let mut j = 0usize;
for &op in ops {
let (oi, nj) = (i + 1, j + 1);
match op {
Op::Keep => {
pending.push((op, oi, nj));
i += 1;
j += 1;
}
Op::Remove => {
pending.push((op, oi, 0));
i += 1;
}
Op::Add => {
pending.push((op, 0, nj));
j += 1;
}
}
}
let mut idx = 0usize;
while idx < pending.len() {
while idx < pending.len() && matches!(pending[idx].0, Op::Keep) {
idx += 1;
}
if idx >= pending.len() {
break;
}
let mut start = idx;
let mut back = 0;
while start > 0 && back < CONTEXT_LINES && matches!(pending[start - 1].0, Op::Keep) {
start -= 1;
back += 1;
}
let mut end = idx;
loop {
while end < pending.len() && !matches!(pending[end].0, Op::Keep) {
end += 1;
}
let keeps_start = end;
while end < pending.len()
&& matches!(pending[end].0, Op::Keep)
&& (end - keeps_start) < 2 * CONTEXT_LINES
{
end += 1;
}
if end < pending.len() && !matches!(pending[end].0, Op::Keep) {
continue;
}
let mut trailing = 0;
while end > keeps_start && trailing < CONTEXT_LINES {
trailing += 1;
if keeps_start + trailing == end {
break;
}
}
let capped_end = keeps_start + CONTEXT_LINES.min(end - keeps_start);
end = capped_end;
break;
}
let slice = &pending[start..end];
let old_start = slice
.iter()
.find_map(|(op, oi, _)| match op {
Op::Add => None,
_ => Some(*oi),
})
.unwrap_or(1);
let new_start = slice
.iter()
.find_map(|(op, _, nj)| match op {
Op::Remove => None,
_ => Some(*nj),
})
.unwrap_or(1);
let old_count = slice
.iter()
.filter(|(op, _, _)| !matches!(op, Op::Add))
.count();
let new_count = slice
.iter()
.filter(|(op, _, _)| !matches!(op, Op::Remove))
.count();
let mut lines = Vec::new();
for (op, oi, nj) in slice {
match op {
Op::Keep => lines.push(HunkLine::Context(old[oi - 1].to_string())),
Op::Remove => lines.push(HunkLine::Removed(old[oi - 1].to_string())),
Op::Add => lines.push(HunkLine::Added(new[nj - 1].to_string())),
}
}
hunks.push(Hunk {
old_start,
old_count,
new_start,
new_count,
lines,
});
idx = end;
}
hunks
}
fn print_file_report(old_path: &Path, new_path: &Path, hunks: &[Hunk]) {
println!("{}", format!("--- {}", old_path.display()).red().bold());
println!("{}", format!("+++ {}", new_path.display()).green().bold());
for hunk in hunks {
println!(
"{}",
format!(
"@@ -{},{} +{},{} @@",
hunk.old_start, hunk.old_count, hunk.new_start, hunk.new_count
)
.cyan()
);
for l in &hunk.lines {
match l {
HunkLine::Context(s) => println!(" {}", s),
HunkLine::Removed(s) => println!("{}", format!("-{}", s).red()),
HunkLine::Added(s) => println!("{}", format!("+{}", s).green()),
}
}
}
println!();
}