use std::collections::{HashMap, HashSet};
#[derive(Debug, PartialEq)]
pub enum DiffEntry {
Added(String), Removed(String), MovedExplicit(String), MovedImplicit(String), Unchanged(String), }
#[derive(Debug)]
pub struct PathDiff {
pub entries: Vec<DiffEntry>,
}
impl PathDiff {
#[allow(dead_code)]
pub fn is_empty(&self) -> bool {
self.entries
.iter()
.all(|e| matches!(e, DiffEntry::Unchanged(_)))
}
}
pub fn compute_diff(
current: &str,
saved: &str,
affected_paths: &HashSet<String>,
deleted_paths: &[String],
full: bool,
) -> PathDiff {
let current_entries: Vec<String> = current
.split(':')
.filter(|s| !s.is_empty())
.map(String::from)
.collect();
let saved_entries: Vec<String> = saved
.split(':')
.filter(|s| !s.is_empty())
.map(String::from)
.collect();
let mut saved_positions: HashMap<String, usize> = HashMap::new();
for (idx, entry) in saved_entries.iter().enumerate() {
saved_positions.entry(entry.clone()).or_insert(idx);
}
let mut current_positions: HashMap<String, usize> = HashMap::new();
for (idx, entry) in current_entries.iter().enumerate() {
current_positions.entry(entry.clone()).or_insert(idx);
}
let saved_set: HashSet<String> = saved_entries.iter().cloned().collect();
let current_set: HashSet<String> = current_entries.iter().cloned().collect();
let mut diff_entries = Vec::new();
for path in deleted_paths {
diff_entries.push(DiffEntry::Removed(path.clone()));
}
for entry in &saved_entries {
if !current_set.contains(entry) && !deleted_paths.contains(entry) {
diff_entries.push(DiffEntry::Removed(entry.clone()));
}
}
for entry in ¤t_entries {
if !saved_set.contains(entry) {
diff_entries.push(DiffEntry::Added(entry.clone()));
continue;
}
let saved_pos = saved_positions[entry];
let current_pos = current_positions[entry];
if saved_pos != current_pos {
if affected_paths.contains(entry) {
diff_entries.push(DiffEntry::MovedExplicit(entry.clone()));
} else if full {
diff_entries.push(DiffEntry::MovedImplicit(entry.clone()));
}
} else if full {
diff_entries.push(DiffEntry::Unchanged(entry.clone()));
}
}
PathDiff {
entries: diff_entries,
}
}
pub fn format_diff(diff: &PathDiff, use_color: bool) -> String {
let has_changes = diff
.entries
.iter()
.any(|e| !matches!(e, DiffEntry::Unchanged(_)));
if !has_changes {
return "No differences".to_string();
}
let mut output = Vec::new();
let (red, green, cyan, reset) = if use_color {
("\x1b[31m", "\x1b[32m", "\x1b[36m", "\x1b[0m")
} else {
("", "", "", "")
};
for entry in &diff.entries {
match entry {
DiffEntry::Added(path) => {
output.push(format!("{green}+ {path}{reset}"));
}
DiffEntry::Removed(path) => {
output.push(format!("{red}- {path}{reset}"));
}
DiffEntry::MovedExplicit(path) => {
output.push(format!("{cyan}↕ {path}{reset}"));
}
DiffEntry::MovedImplicit(path) => {
output.push(format!("M {path}"));
}
DiffEntry::Unchanged(path) => {
output.push(format!("U {path}"));
}
}
}
output.join("\n")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_compute_diff_no_changes() {
let saved = "/a:/b:/c";
let current = "/a:/b:/c";
let affected = HashSet::new();
let deleted = vec![];
let diff = compute_diff(current, saved, &affected, &deleted, false);
assert!(diff.is_empty());
}
#[test]
fn test_compute_diff_addition() {
let saved = "/a:/b";
let current = "/a:/b:/c";
let affected = HashSet::new();
let deleted = vec![];
let diff = compute_diff(current, saved, &affected, &deleted, false);
assert_eq!(diff.entries.len(), 1);
assert!(matches!(diff.entries[0], DiffEntry::Added(_)));
}
#[test]
fn test_compute_diff_removal() {
let saved = "/a:/b:/c";
let current = "/a:/b";
let affected = HashSet::new();
let deleted = vec![];
let diff = compute_diff(current, saved, &affected, &deleted, false);
assert_eq!(diff.entries.len(), 1);
assert!(matches!(diff.entries[0], DiffEntry::Removed(_)));
}
#[test]
fn test_compute_diff_explicit_move() {
let saved = "/a:/b:/c";
let current = "/c:/a:/b";
let mut affected = HashSet::new();
affected.insert("/c".to_string());
let deleted = vec![];
let diff = compute_diff(current, saved, &affected, &deleted, false);
assert!(diff
.entries
.iter()
.any(|e| matches!(e, DiffEntry::MovedExplicit(p) if p == "/c")));
}
#[test]
fn test_compute_diff_full_mode() {
let saved = "/a:/b:/c";
let current = "/c:/a:/b";
let mut affected = HashSet::new();
affected.insert("/c".to_string());
let deleted = vec![];
let diff = compute_diff(current, saved, &affected, &deleted, true);
assert!(diff
.entries
.iter()
.any(|e| matches!(e, DiffEntry::MovedExplicit(_))));
assert!(diff
.entries
.iter()
.any(|e| matches!(e, DiffEntry::MovedImplicit(_))));
}
}