use std::collections::BTreeSet;
use std::path::{Path, PathBuf};
use crate::Diagnostic;
use crate::overlay::{OverlayOp, OverlaySet, diff};
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum LspVerb {
DidOpen { path: PathBuf, content: String },
DidChange { path: PathBuf, content: String },
DidClose { path: PathBuf },
}
#[derive(Debug, Clone, Default)]
pub struct OverlayMultiplexer {
open: BTreeSet<PathBuf>,
applied: OverlaySet,
}
impl OverlayMultiplexer {
pub fn new() -> Self {
Self::default()
}
fn lower(&mut self, ops: &[OverlayOp]) -> Vec<LspVerb> {
let mut out = Vec::with_capacity(ops.len());
for op in ops {
match op {
OverlayOp::Close { path } => {
self.open.remove(path);
out.push(LspVerb::DidClose { path: path.clone() });
}
OverlayOp::Apply { path, content } => {
if self.open.insert(path.clone()) {
out.push(LspVerb::DidOpen {
path: path.clone(),
content: content.clone(),
});
} else {
out.push(LspVerb::DidChange {
path: path.clone(),
content: content.clone(),
});
}
}
}
}
out
}
pub fn switch_to(&mut self, target: &OverlaySet) -> Vec<LspVerb> {
let ops = diff(&self.applied, target);
let verbs = self.lower(&ops);
self.applied = target.clone();
verbs
}
pub fn open_paths(&self) -> impl Iterator<Item = &Path> {
self.open.iter().map(PathBuf::as_path)
}
pub fn reset(&mut self) {
self.open.clear();
self.applied = OverlaySet::new();
}
}
pub fn tag_for_worktree<W: Clone>(wt: &W, diags: &[Diagnostic]) -> Vec<(W, Diagnostic)> {
diags.iter().map(|d| (wt.clone(), d.clone())).collect()
}
#[cfg(test)]
mod tests {
use super::*;
fn oset(pairs: &[(&str, &str)]) -> OverlaySet {
OverlaySet::from_pairs(pairs.iter().map(|(p, c)| (*p, *c)))
}
fn opened(m: &OverlayMultiplexer) -> Vec<String> {
m.open_paths()
.map(|p| p.to_string_lossy().into_owned())
.collect()
}
#[test]
fn base_to_worktree_opens_all_first_time() {
let mut m = OverlayMultiplexer::new();
let verbs = m.switch_to(&oset(&[("a.rs", "A"), ("z.rs", "Z")]));
assert_eq!(
verbs,
vec![
LspVerb::DidOpen {
path: "a.rs".into(),
content: "A".into()
},
LspVerb::DidOpen {
path: "z.rs".into(),
content: "Z".into()
},
]
);
assert_eq!(opened(&m), vec!["a.rs", "z.rs"]);
}
#[test]
fn reselect_same_set_emits_nothing() {
let mut m = OverlayMultiplexer::new();
let s = oset(&[("a.rs", "A")]);
m.switch_to(&s);
assert_eq!(
m.switch_to(&s),
vec![],
"re-select ⇒ zero verbs (minimality)"
);
}
#[test]
fn content_change_of_open_file_is_didchange_not_didopen() {
let mut m = OverlayMultiplexer::new();
m.switch_to(&oset(&[("a.rs", "old")]));
let verbs = m.switch_to(&oset(&[("a.rs", "new")]));
assert_eq!(
verbs,
vec![LspVerb::DidChange {
path: "a.rs".into(),
content: "new".into()
}],
"already-open path ⇒ DidChange"
);
}
#[test]
fn cross_worktree_switch_no_stale_open_isolation() {
let mut m = OverlayMultiplexer::new();
m.switch_to(&oset(&[
("only_v.rs", "V"),
("shared.rs", "v-ver"),
("common.rs", "same"),
]));
let verbs = m.switch_to(&oset(&[
("shared.rs", "w-ver"),
("common.rs", "same"),
("only_w.rs", "W"),
]));
assert_eq!(
verbs,
vec![
LspVerb::DidClose {
path: "only_v.rs".into()
},
LspVerb::DidOpen {
path: "only_w.rs".into(),
content: "W".into()
},
LspVerb::DidChange {
path: "shared.rs".into(),
content: "w-ver".into()
},
]
);
assert_eq!(opened(&m), vec!["common.rs", "only_w.rs", "shared.rs"]);
assert!(
!opened(&m).iter().any(|p| p == "only_v.rs"),
"no stale V-only overlay may remain open after switching to W"
);
}
#[test]
fn close_then_reapply_reopens() {
let mut m = OverlayMultiplexer::new();
m.switch_to(&oset(&[("a.rs", "A1")]));
m.switch_to(&OverlaySet::new()); assert!(opened(&m).is_empty(), "base state ⇒ nothing open");
let verbs = m.switch_to(&oset(&[("a.rs", "A2")]));
assert_eq!(
verbs,
vec![LspVerb::DidOpen {
path: "a.rs".into(),
content: "A2".into()
}],
"re-apply after close ⇒ DidOpen (fresh), not DidChange"
);
}
#[test]
fn switch_to_base_closes_everything() {
let mut m = OverlayMultiplexer::new();
m.switch_to(&oset(&[("a.rs", "A"), ("b.rs", "B")]));
let verbs = m.switch_to(&OverlaySet::new());
assert_eq!(
verbs,
vec![
LspVerb::DidClose {
path: "a.rs".into()
},
LspVerb::DidClose {
path: "b.rs".into()
},
]
);
assert!(opened(&m).is_empty());
}
#[test]
fn tag_for_worktree_attributes_each_diagnostic() {
use crate::Severity;
let d = Diagnostic {
file_path: "physics/src/orbit.rs".into(),
line: 1,
col: 1,
severity: Severity::Error,
code: None,
message: "x".into(),
source: None,
};
let tagged = tag_for_worktree(&"wt-A".to_string(), std::slice::from_ref(&d));
assert_eq!(tagged, vec![("wt-A".to_string(), d.clone())]);
assert!(tag_for_worktree(&"wt-A".to_string(), &[]).is_empty());
}
#[test]
fn reset_then_switch_reopens_all_not_didchange() {
let mut m = OverlayMultiplexer::new();
let s = oset(&[("a.rs", "A"), ("z.rs", "Z")]);
m.switch_to(&s);
assert_eq!(opened(&m), vec!["a.rs", "z.rs"]);
m.reset();
assert!(
opened(&m).is_empty(),
"reset clears the open-set (fresh RA has nothing open)"
);
let verbs = m.switch_to(&s);
assert_eq!(
verbs,
vec![
LspVerb::DidOpen {
path: "a.rs".into(),
content: "A".into()
},
LspVerb::DidOpen {
path: "z.rs".into(),
content: "Z".into()
},
],
"post-reset, the same set re-DidOpens every path (never no-op/DidChange)"
);
assert_eq!(opened(&m), vec!["a.rs", "z.rs"]);
}
#[test]
fn reset_then_changed_content_is_didopen_not_didchange() {
let mut m = OverlayMultiplexer::new();
m.switch_to(&oset(&[("a.rs", "old")]));
m.reset();
let verbs = m.switch_to(&oset(&[("a.rs", "new")]));
assert_eq!(
verbs,
vec![LspVerb::DidOpen {
path: "a.rs".into(),
content: "new".into()
}],
"post-reset a changed file is a fresh DidOpen, not DidChange"
);
}
#[test]
fn reset_on_fresh_multiplexer_is_a_noop() {
let mut m = OverlayMultiplexer::new();
m.reset();
assert!(opened(&m).is_empty());
assert_eq!(m.switch_to(&OverlaySet::new()), vec![]);
m.reset();
assert!(opened(&m).is_empty());
}
#[test]
fn reset_then_base_switch_emits_no_stale_close() {
let mut m = OverlayMultiplexer::new();
m.switch_to(&oset(&[("a.rs", "A"), ("b.rs", "B")]));
m.reset();
assert_eq!(
m.switch_to(&OverlaySet::new()),
vec![],
"post-reset→base: no stale DidClose for never-opened (fresh-RA) files"
);
}
}