use std::collections::HashSet;
use std::sync::{Arc, Mutex};
use crate::debug::source_map::canonicalize_chunk_name;
use crate::debug::types::{Breakpoint, ResolvedBreakpoint, SourceRef};
#[derive(Clone, Default)]
pub(crate) struct BreakpointSet {
inner: Arc<Mutex<HashSet<Breakpoint>>>,
}
impl BreakpointSet {
pub(crate) fn new() -> Self {
Self::default()
}
pub(crate) fn should_pause(&self, chunk: &str, line: u32) -> bool {
let Ok(guard) = self.inner.lock() else {
return false;
};
let hook_canon = canonicalize_chunk_name(chunk);
guard
.iter()
.any(|bp| bp.lua_line == line && canonicalize_chunk_name(&bp.chunk) == hook_canon)
}
pub(crate) fn set_breakpoints(
&self,
source: &SourceRef,
lines: &[u32],
) -> Vec<ResolvedBreakpoint> {
let path = source.path.as_str();
let entries: Vec<Breakpoint> = lines
.iter()
.map(|&line| Breakpoint::new(path, path, line))
.collect();
self.register(path, entries);
lines
.iter()
.map(|&line| ResolvedBreakpoint {
source: source.clone(),
line,
verified: true,
})
.collect()
}
pub(crate) fn register(&self, present_source: &str, entries: Vec<Breakpoint>) {
if let Ok(mut guard) = self.inner.lock() {
guard.retain(|bp| bp.present_source != present_source);
for bp in entries {
guard.insert(bp);
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn src(path: &str) -> SourceRef {
SourceRef::new(path)
}
#[test]
fn should_pause_matches_by_execution_coordinate() {
let set = BreakpointSet::new();
set.register(
"x.pasta",
vec![Breakpoint::new("x.pasta", "@C:/proj/a.lua", 5)],
);
assert!(
set.should_pause("@C:/proj/a.lua", 5),
"exact (chunk,line) execution-coord match must pause"
);
assert!(
!set.should_pause("@C:/proj/a.lua", 4),
"same chunk but different line must NOT pause"
);
assert!(
!set.should_pause("@C:/proj/b.lua", 5),
"same line but different chunk must NOT pause"
);
}
#[test]
fn no_cross_present_source_eviction_when_sharing_a_chunk() {
let set = BreakpointSet::new();
let chunk = "@C:/proj/scene.lua";
set.register("scene.lua", vec![Breakpoint::new("scene.lua", chunk, 10)]);
set.register("x.pasta", vec![Breakpoint::new("x.pasta", chunk, 10)]);
assert!(set.should_pause(chunk, 10), "both BPs fire at the shared coord");
set.register("x.pasta", vec![Breakpoint::new("x.pasta", chunk, 20)]);
assert!(
set.should_pause(chunk, 10),
"the `.lua`-origin BP in the shared chunk must survive replacing the \
`.pasta` present source (4.4/8.2 no cross-source eviction)"
);
assert!(
set.should_pause(chunk, 20),
"the `.pasta` present source's new coord is registered"
);
set.register("scene.lua", vec![Breakpoint::new("scene.lua", chunk, 30)]);
assert!(
set.should_pause(chunk, 20),
"the `.pasta`-origin BP must survive replacing the `.lua` present source"
);
assert!(set.should_pause(chunk, 30), "the `.lua` present source's new coord is registered");
assert!(
!set.should_pause(chunk, 10),
"the `.lua` present source's OLD coord was authoritatively replaced"
);
}
#[test]
fn one_present_line_registers_multiple_execution_coords() {
let set = BreakpointSet::new();
let chunk = "@C:/proj/expanded.lua";
set.register(
"x.pasta",
vec![
Breakpoint::new("x.pasta", chunk, 7),
Breakpoint::new("x.pasta", chunk, 8),
Breakpoint::new("x.pasta", chunk, 9),
],
);
assert!(set.should_pause(chunk, 7), "each expanded `.lua` line fires (8.2)");
assert!(set.should_pause(chunk, 8), "each expanded `.lua` line fires (8.2)");
assert!(set.should_pause(chunk, 9), "each expanded `.lua` line fires (8.2)");
assert!(!set.should_pause(chunk, 6), "non-registered line does not fire");
}
#[test]
fn should_pause_matches_canonicalized_chunk_for_raw_hook_source() {
let set = BreakpointSet::new();
let canonical_chunk = if cfg!(windows) {
"c:/proj/cache/scene.lua"
} else {
"C:/proj/cache/scene.lua"
};
set.register(
"scene.pasta",
vec![Breakpoint::new("scene.pasta", canonical_chunk, 12)],
);
assert!(
set.should_pause(r"@C:\proj\cache\scene.lua", 12),
"a `.pasta`-translated BP (canonical chunk) must fire for the raw hook \
source after `should_pause` canonicalizes both sides (4.2)"
);
assert!(
!set.should_pause(r"@C:\proj\cache\scene.lua", 11),
"same chunk, different line must not pause"
);
assert!(
!set.should_pause(r"@C:\proj\cache\other.lua", 12),
"different chunk must not pause even with canonicalization"
);
}
#[test]
fn should_pause_lua_path_unchanged_after_canonicalization() {
let set = BreakpointSet::new();
set.set_breakpoints(&src("@e2e_scenario"), &[7]);
assert!(
set.should_pause("@e2e_scenario", 7),
"an existing `.lua` BP must still fire after canonicalization (7.2)"
);
assert!(!set.should_pause("@e2e_scenario", 6));
}
#[test]
fn empty_set_never_pauses() {
let set = BreakpointSet::new();
assert!(!set.should_pause("@s", 1));
assert!(!set.should_pause("", 0));
}
#[test]
fn set_breakpoints_lua_path_registers_execution_coords() {
let set = BreakpointSet::new();
set.set_breakpoints(&src("@s"), &[3]);
assert!(set.should_pause("@s", 3), "exact .lua (chunk,line) match must pause");
assert!(!set.should_pause("@s", 2), "same .lua chunk but different line must NOT pause");
assert!(!set.should_pause("@other", 3), "same line but different .lua chunk must NOT pause");
}
#[test]
fn set_breakpoints_replaces_only_target_source_and_preserves_others() {
let set = BreakpointSet::new();
set.set_breakpoints(&src("@a"), &[1, 2, 3]);
let resolved_b = set.set_breakpoints(&src("@b"), &[10, 20]);
assert_eq!(
resolved_b,
vec![
ResolvedBreakpoint {
source: src("@b"),
line: 10,
verified: true
},
ResolvedBreakpoint {
source: src("@b"),
line: 20,
verified: true
},
],
"resolved breakpoints must mirror requested lines as verified"
);
assert!(set.should_pause("@a", 2));
assert!(set.should_pause("@b", 10));
let resolved_a = set.set_breakpoints(&src("@a"), &[5]);
assert_eq!(
resolved_a,
vec![ResolvedBreakpoint {
source: src("@a"),
line: 5,
verified: true
}]
);
assert!(
!set.should_pause("@a", 2),
"replaced source must drop its previous lines"
);
assert!(
!set.should_pause("@a", 1),
"replaced source must drop ALL its previous lines"
);
assert!(set.should_pause("@a", 5), "replaced source gets the new line");
assert!(
set.should_pause("@b", 10) && set.should_pause("@b", 20),
"replacing one source must preserve the other source's breakpoints"
);
}
#[test]
fn set_breakpoints_with_empty_lines_clears_that_source_only() {
let set = BreakpointSet::new();
set.set_breakpoints(&src("@a"), &[1]);
set.set_breakpoints(&src("@b"), &[2]);
let resolved = set.set_breakpoints(&src("@a"), &[]);
assert!(resolved.is_empty(), "clearing returns no resolved entries");
assert!(!set.should_pause("@a", 1), "@a is cleared");
assert!(set.should_pause("@b", 2), "@b is preserved");
}
#[test]
fn clone_observes_cross_thread_update() {
use std::sync::mpsc;
let original = BreakpointSet::new();
let reader = original.clone();
let (go_tx, go_rx) = mpsc::channel::<()>();
let handle = std::thread::spawn(move || {
go_rx.recv().expect("go signal");
reader.should_pause("@s", 7)
});
original.set_breakpoints(&src("@s"), &[7]);
go_tx.send(()).expect("send go");
let observed = handle.join().expect("reader thread must not panic");
assert!(
observed,
"a clone on another thread must observe an update made via the \
original handle (Arc<Mutex> sharing)"
);
}
#[test]
fn poisoned_lock_degrades_without_panicking() {
let set = BreakpointSet::new();
set.set_breakpoints(&src("@s"), &[7]);
assert!(set.should_pause("@s", 7), "pre-poison: the BP fires");
let clone = set.clone();
let _ = std::thread::spawn(move || {
let _guard = clone.inner.lock().unwrap();
panic!("poison the breakpoint store");
})
.join();
assert!(set.inner.lock().is_err(), "the inner mutex must be poisoned");
assert!(
!set.should_pause("@s", 7),
"poisoned lock → should_pause degrades to false (do not pause)"
);
set.register("@t", vec![Breakpoint::new("@t", "@t", 1)]);
let resolved = set.set_breakpoints(&src("@u"), &[3]);
assert_eq!(
resolved,
vec![ResolvedBreakpoint {
source: src("@u"),
line: 3,
verified: true,
}],
"set_breakpoints still resolves the requested lines after poisoning"
);
}
#[test]
fn set_breakpoints_duplicate_lines_mirror_request_order() {
let set = BreakpointSet::new();
let resolved = set.set_breakpoints(&src("@s"), &[5, 5, 2]);
assert_eq!(
resolved,
vec![
ResolvedBreakpoint {
source: src("@s"),
line: 5,
verified: true
},
ResolvedBreakpoint {
source: src("@s"),
line: 5,
verified: true
},
ResolvedBreakpoint {
source: src("@s"),
line: 2,
verified: true
},
],
"resolved entries mirror the requested lines order, duplicates kept"
);
assert!(set.should_pause("@s", 5), "the duplicated coordinate fires");
assert!(set.should_pause("@s", 2));
assert!(!set.should_pause("@s", 4));
}
}