use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct OverlaySet {
files: BTreeMap<PathBuf, String>,
}
impl OverlaySet {
pub fn new() -> Self {
Self::default()
}
pub fn from_pairs<I, P, S>(pairs: I) -> Self
where
I: IntoIterator<Item = (P, S)>,
P: Into<PathBuf>,
S: Into<String>,
{
Self {
files: pairs
.into_iter()
.map(|(p, s)| (p.into(), s.into()))
.collect(),
}
}
pub fn set(&mut self, path: impl Into<PathBuf>, content: impl Into<String>) {
self.files.insert(path.into(), content.into());
}
pub fn is_empty(&self) -> bool {
self.files.is_empty()
}
pub fn len(&self) -> usize {
self.files.len()
}
pub fn get(&self, path: &Path) -> Option<&str> {
self.files.get(path).map(String::as_str)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum OverlayOp {
Apply { path: PathBuf, content: String },
Close { path: PathBuf },
}
pub fn diff(prev: &OverlaySet, next: &OverlaySet) -> Vec<OverlayOp> {
let mut ops = Vec::new();
for path in prev.files.keys() {
if !next.files.contains_key(path) {
ops.push(OverlayOp::Close { path: path.clone() });
}
}
for (path, content) in &next.files {
match prev.files.get(path) {
Some(prev_content) if prev_content == content => {} _ => ops.push(OverlayOp::Apply {
path: path.clone(),
content: content.clone(),
}),
}
}
ops
}
#[cfg(test)]
mod tests {
use super::*;
fn set(pairs: &[(&str, &str)]) -> OverlaySet {
OverlaySet::from_pairs(pairs.iter().map(|(p, c)| (*p, *c)))
}
#[test]
fn empty_to_empty_is_no_ops() {
assert_eq!(diff(&OverlaySet::new(), &OverlaySet::new()), vec![]);
}
#[test]
fn idempotent_same_set_is_no_ops() {
let s = set(&[("a.rs", "fn a() {}"), ("b.rs", "fn b() {}")]);
assert_eq!(diff(&s, &s), vec![], "diff(s, s) must be empty");
}
#[test]
fn base_to_worktree_applies_all() {
let next = set(&[("z.rs", "Z"), ("a.rs", "A")]);
assert_eq!(
diff(&OverlaySet::new(), &next),
vec![
OverlayOp::Apply {
path: "a.rs".into(),
content: "A".into()
},
OverlayOp::Apply {
path: "z.rs".into(),
content: "Z".into()
},
]
);
}
#[test]
fn worktree_to_base_closes_all() {
let prev = set(&[("z.rs", "Z"), ("a.rs", "A")]);
assert_eq!(
diff(&prev, &OverlaySet::new()),
vec![
OverlayOp::Close {
path: "a.rs".into()
},
OverlayOp::Close {
path: "z.rs".into()
},
]
);
}
#[test]
fn content_change_applies_only_changed() {
let prev = set(&[("a.rs", "old"), ("b.rs", "same")]);
let next = set(&[("a.rs", "new"), ("b.rs", "same")]);
assert_eq!(
diff(&prev, &next),
vec![OverlayOp::Apply {
path: "a.rs".into(),
content: "new".into()
}]
);
}
#[test]
fn cross_worktree_switch_closes_stale_and_applies_new() {
let v = set(&[
("only_v.rs", "V"), ("shared.rs", "v-ver"), ("common.rs", "same"), ]);
let w = set(&[
("shared.rs", "w-ver"), ("common.rs", "same"), ("only_w.rs", "W"), ]);
let ops = diff(&v, &w);
assert_eq!(
ops,
vec![
OverlayOp::Close {
path: "only_v.rs".into()
},
OverlayOp::Apply {
path: "only_w.rs".into(),
content: "W".into()
},
OverlayOp::Apply {
path: "shared.rs".into(),
content: "w-ver".into()
},
],
"stale V-only file closed; shared re-applied; identical untouched; W-only applied"
);
assert!(
!ops.iter().any(|op| matches!(
op,
OverlayOp::Apply { content, .. } if content == "V" || content == "v-ver"
)),
"no V-specific content may survive the switch to W"
);
}
#[test]
fn closes_strictly_precede_applies() {
let prev = set(&[("gone.rs", "x"), ("kept.rs", "old")]);
let next = set(&[("kept.rs", "new"), ("added.rs", "y")]);
let ops = diff(&prev, &next);
let first_apply = ops
.iter()
.position(|o| matches!(o, OverlayOp::Apply { .. }));
let last_close = ops
.iter()
.rposition(|o| matches!(o, OverlayOp::Close { .. }));
if let (Some(fa), Some(lc)) = (first_apply, last_close) {
assert!(lc < fa, "all Close ops must precede all Apply ops");
}
assert_eq!(
ops,
vec![
OverlayOp::Close {
path: "gone.rs".into()
},
OverlayOp::Apply {
path: "added.rs".into(),
content: "y".into()
},
OverlayOp::Apply {
path: "kept.rs".into(),
content: "new".into()
},
]
);
}
#[test]
fn overlayset_accessors() {
let mut s = OverlaySet::new();
assert!(s.is_empty());
s.set("a.rs", "A");
assert!(!s.is_empty());
assert_eq!(s.len(), 1);
assert_eq!(s.get(Path::new("a.rs")), Some("A"));
assert_eq!(s.get(Path::new("missing.rs")), None);
}
}