use crate::breakpoint::{AstBreakpointValidator, BreakpointValidator};
use crate::protocol::{Breakpoint, SetBreakpointsArguments};
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
#[cfg(test)]
fn validate_breakpoint_line_with_column(
source: &str,
line: i64,
column: Option<i64>,
) -> (bool, i64, Option<String>) {
if line <= 0 {
return (false, line, Some("Line number must be positive".to_string()));
}
match AstBreakpointValidator::new(source) {
Ok(validator) => {
let result = validator.validate_with_column(line, column);
(result.verified, result.line, result.message)
}
Err(error) => (false, line, Some(error.to_string())),
}
}
#[cfg(test)]
fn validate_breakpoint_line(source: &str, line: i64) -> (bool, Option<String>) {
let (verified, _resolved_line, message) =
validate_breakpoint_line_with_column(source, line, None);
(verified, message)
}
#[derive(Debug, Clone)]
pub struct BreakpointRecord {
pub id: i64,
pub line: i64,
pub column: Option<i64>,
pub condition: Option<String>,
pub hit_condition: Option<String>,
pub log_message: Option<String>,
pub hit_count: u64,
pub verified: bool,
pub message: Option<String>,
}
impl BreakpointRecord {
pub fn to_protocol(&self) -> Breakpoint {
Breakpoint {
id: self.id,
verified: self.verified,
line: self.line,
column: self.column,
message: self.message.clone(),
}
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct BreakpointHitOutcome {
pub matched: bool,
pub should_stop: bool,
pub log_messages: Vec<String>,
}
fn parse_hit_condition_operand(raw: &str) -> Option<u64> {
raw.trim().parse::<u64>().ok()
}
fn is_valid_hit_condition(raw: &str) -> bool {
evaluate_hit_condition(Some(raw), 1).is_some()
}
fn evaluate_hit_condition(raw: Option<&str>, hit_count: u64) -> Option<bool> {
let Some(raw) = raw else {
return Some(true);
};
let expr = raw.trim();
if expr.is_empty() {
return Some(true);
}
if let Some(rest) = expr.strip_prefix(">=") {
return parse_hit_condition_operand(rest).map(|n| hit_count >= n);
}
if let Some(rest) = expr.strip_prefix("<=") {
return parse_hit_condition_operand(rest).map(|n| hit_count <= n);
}
if let Some(rest) = expr.strip_prefix("==") {
return parse_hit_condition_operand(rest).map(|n| hit_count == n);
}
if let Some(rest) = expr.strip_prefix('=') {
return parse_hit_condition_operand(rest).map(|n| hit_count == n);
}
if let Some(rest) = expr.strip_prefix('>') {
return parse_hit_condition_operand(rest).map(|n| hit_count > n);
}
if let Some(rest) = expr.strip_prefix('<') {
return parse_hit_condition_operand(rest).map(|n| hit_count < n);
}
if let Some(rest) = expr.strip_prefix('%') {
return parse_hit_condition_operand(rest)
.and_then(|n| if n == 0 { None } else { Some(hit_count.is_multiple_of(n)) });
}
parse_hit_condition_operand(expr).map(|n| hit_count == n)
}
fn file_paths_match(stored: &str, observed: &str) -> bool {
if stored == observed {
return true;
}
if stored.ends_with(observed) || observed.ends_with(stored) {
return true;
}
false
}
#[derive(Debug, Clone)]
pub struct BreakpointStore {
breakpoints: Arc<Mutex<HashMap<String, Vec<BreakpointRecord>>>>,
next_id: Arc<Mutex<i64>>,
}
impl BreakpointStore {
pub fn new() -> Self {
Self { breakpoints: Arc::new(Mutex::new(HashMap::new())), next_id: Arc::new(Mutex::new(1)) }
}
pub fn set_breakpoints(&self, args: &SetBreakpointsArguments) -> Vec<Breakpoint> {
let source_path = match &args.source.path {
Some(path) => path.clone(),
None => {
return Vec::new();
}
};
let source_breakpoints = args.breakpoints.as_deref().unwrap_or(&[]);
let source_content = std::fs::read_to_string(&source_path).ok();
let validator = source_content
.as_ref()
.map(|content| AstBreakpointValidator::new(content).map_err(|e| e.to_string()));
let mut validation_cache: HashMap<(i64, Option<i64>), (bool, i64, Option<String>)> =
HashMap::new();
let mut breakpoints_map = self.breakpoints.lock().unwrap_or_else(|e| e.into_inner());
let mut next_id = self.next_id.lock().unwrap_or_else(|e| e.into_inner());
breakpoints_map.remove(&source_path);
let mut records = Vec::new();
for bp in source_breakpoints {
let id = *next_id;
*next_id += 1;
if bp.line <= 0 {
records.push(BreakpointRecord {
id,
line: bp.line,
column: bp.column,
condition: bp.condition.clone(),
hit_condition: bp.hit_condition.clone(),
log_message: bp.log_message.clone(),
hit_count: 0,
verified: false,
message: Some("Line number must be positive".to_string()),
});
continue;
}
if let Some(ref condition) = bp.condition {
if condition.contains('\n') || condition.contains('\r') {
let record = BreakpointRecord {
id,
line: bp.line,
column: bp.column,
condition: bp.condition.clone(),
hit_condition: bp.hit_condition.clone(),
log_message: bp.log_message.clone(),
hit_count: 0,
verified: false,
message: Some("Breakpoint condition cannot contain newlines".to_string()),
};
records.push(record);
continue;
}
}
if let Some(ref hit_condition) = bp.hit_condition {
let hit_condition = hit_condition.trim();
if hit_condition.contains('\n') || hit_condition.contains('\r') {
let record = BreakpointRecord {
id,
line: bp.line,
column: bp.column,
condition: bp.condition.clone(),
hit_condition: bp.hit_condition.clone(),
log_message: bp.log_message.clone(),
hit_count: 0,
verified: false,
message: Some("Hit condition cannot contain newlines".to_string()),
};
records.push(record);
continue;
}
if !is_valid_hit_condition(hit_condition) {
let record = BreakpointRecord {
id,
line: bp.line,
column: bp.column,
condition: bp.condition.clone(),
hit_condition: bp.hit_condition.clone(),
log_message: bp.log_message.clone(),
hit_count: 0,
verified: false,
message: Some(format!(
"Invalid hitCondition `{hit_condition}` (expected numeric expression like `10`, `>= 5`, `%2`)"
)),
};
records.push(record);
continue;
}
}
let (verified, resolved_line, message) =
if let Some(cached) = validation_cache.get(&(bp.line, bp.column)) {
cached.clone()
} else {
let computed = match &validator {
Some(Ok(v)) => {
let result = v.validate_with_column(bp.line, bp.column);
(result.verified, result.line, result.message)
}
Some(Err(error)) => (false, bp.line, Some(error.clone())),
None => {
(false, bp.line, Some("Unable to read source file".to_string()))
}
};
validation_cache.insert((bp.line, bp.column), computed.clone());
computed
};
let mut verified = verified;
let message = if verified
&& bp.condition.is_some()
&& let Some(Ok(v)) = &validator
&& let Some(condition) = bp.condition.as_deref()
{
let condition_validation = v.validate_condition(resolved_line, condition);
if condition_validation.verified {
message
} else {
verified = false;
Some("Conditional breakpoint expression is invalid".to_string())
}
} else {
message
};
let record = BreakpointRecord {
id,
line: resolved_line,
column: bp.column,
condition: bp.condition.clone(),
hit_condition: bp.hit_condition.clone(),
log_message: bp.log_message.clone(),
hit_count: 0,
verified,
message,
};
records.push(record);
}
if !records.is_empty() {
breakpoints_map.insert(source_path.clone(), records.clone());
}
records.iter().map(|r| r.to_protocol()).collect()
}
pub fn get_breakpoints(&self, source_path: &str) -> Vec<BreakpointRecord> {
let breakpoints_map = self.breakpoints.lock().unwrap_or_else(|e| e.into_inner());
breakpoints_map.get(source_path).map_or(Vec::new(), |bps| bps.clone())
}
pub fn clear_breakpoints(&self, source_path: &str) {
let mut breakpoints_map = self.breakpoints.lock().unwrap_or_else(|e| e.into_inner());
breakpoints_map.remove(source_path);
}
pub fn clear_all(&self) {
let mut breakpoints_map = self.breakpoints.lock().unwrap_or_else(|e| e.into_inner());
breakpoints_map.clear();
}
pub fn is_empty(&self) -> bool {
let breakpoints_map = self.breakpoints.lock().unwrap_or_else(|e| e.into_inner());
breakpoints_map.is_empty()
}
pub fn get_breakpoint_by_id(&self, id: i64) -> Option<BreakpointRecord> {
let breakpoints_map = self.breakpoints.lock().unwrap_or_else(|e| e.into_inner());
for records in breakpoints_map.values() {
if let Some(record) = records.iter().find(|r| r.id == id) {
return Some(record.clone());
}
}
None
}
pub fn register_breakpoint_hit(&self, source_path: &str, line: i64) -> BreakpointHitOutcome {
let mut breakpoints_map = self.breakpoints.lock().unwrap_or_else(|e| e.into_inner());
let mut outcome = BreakpointHitOutcome::default();
for (stored_path, records) in &mut *breakpoints_map {
if !file_paths_match(stored_path, source_path) {
continue;
}
for record in records {
if !record.verified || record.line != line {
continue;
}
outcome.matched = true;
record.hit_count = record.hit_count.saturating_add(1);
let hit_condition_match =
evaluate_hit_condition(record.hit_condition.as_deref(), record.hit_count)
.unwrap_or(false);
if !hit_condition_match {
continue;
}
if let Some(message) = record.log_message.clone() {
outcome.log_messages.push(message);
} else {
outcome.should_stop = true;
}
}
}
outcome
}
pub fn adjust_breakpoints_for_edit(
&self,
source_path: &str,
start_line: i64,
lines_delta: i64,
) {
let mut breakpoints_map = self.breakpoints.lock().unwrap_or_else(|e| e.into_inner());
if let Some(records) = breakpoints_map.get_mut(source_path) {
for record in records {
if record.line >= start_line {
record.line += lines_delta;
if record.line < 1 {
record.line = 1;
record.verified = false;
record.message = Some("Breakpoint invalidated by edit".to_string());
}
}
}
}
}
}
impl Default for BreakpointStore {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::protocol::{SetBreakpointsArguments, Source, SourceBreakpoint};
use perl_tdd_support::must;
use std::io::Write;
use tempfile::NamedTempFile;
fn create_test_perl_file() -> (NamedTempFile, String) {
let mut file = must(NamedTempFile::with_suffix(".pl"));
let perl_code = r#"#!/usr/bin/perl
use strict;
use warnings;
my $x = 1;
my $y = 2;
my $z = $x + $y;
if ($x > 0) {
print "positive\n";
}
my @arr = (1, 2, 3);
while (my $item = shift @arr) {
my $doubled = $item * 2;
print "$doubled\n";
}
sub process {
my ($value) = @_;
my $result = $value * 2;
return $result;
}
print "done\n";
my $final = process($x);
print "result: $final\n";
"#;
must(file.write_all(perl_code.as_bytes()));
must(file.flush());
let path = file.path().to_string_lossy().to_string();
(file, path)
}
#[test]
fn test_breakpoint_store_new() {
let store = BreakpointStore::new();
let breakpoints = store.get_breakpoints("/workspace/test.pl");
assert_eq!(breakpoints.len(), 0);
}
#[test]
fn test_set_breakpoints_creates_records() {
let (_file, source_path) = create_test_perl_file();
let store = BreakpointStore::new();
let args = SetBreakpointsArguments {
source: Source { path: Some(source_path.clone()), name: Some("script.pl".to_string()) },
breakpoints: Some(vec![
SourceBreakpoint {
line: 10,
column: None,
condition: None,
hit_condition: None,
log_message: None,
},
SourceBreakpoint {
line: 25,
column: Some(5),
condition: Some("$x > 10".to_string()),
hit_condition: None,
log_message: None,
},
]),
source_modified: None,
};
let breakpoints = store.set_breakpoints(&args);
assert_eq!(breakpoints.len(), 2);
assert_eq!(breakpoints[0].line, 10);
assert!(breakpoints[0].verified);
assert_eq!(breakpoints[1].line, 25);
assert_eq!(breakpoints[1].column, Some(5));
assert!(breakpoints[1].verified);
}
#[test]
fn test_set_breakpoints_replace_semantics() {
let (_file, source_path) = create_test_perl_file();
let store = BreakpointStore::new();
let args1 = SetBreakpointsArguments {
source: Source { path: Some(source_path.clone()), name: Some("script.pl".to_string()) },
breakpoints: Some(vec![SourceBreakpoint {
line: 10,
column: None,
condition: None,
hit_condition: None,
log_message: None,
}]),
source_modified: None,
};
store.set_breakpoints(&args1);
let args2 = SetBreakpointsArguments {
source: Source { path: Some(source_path.clone()), name: Some("script.pl".to_string()) },
breakpoints: Some(vec![
SourceBreakpoint {
line: 20,
column: None,
condition: None,
hit_condition: None,
log_message: None,
},
SourceBreakpoint {
line: 26,
column: None,
condition: None,
hit_condition: None,
log_message: None,
},
]),
source_modified: None,
};
let breakpoints = store.set_breakpoints(&args2);
assert_eq!(breakpoints.len(), 2);
assert_eq!(breakpoints[0].line, 20);
assert_eq!(breakpoints[1].line, 26);
let stored = store.get_breakpoints(&source_path);
assert_eq!(stored.len(), 2);
}
#[test]
fn test_set_breakpoints_unique_ids() {
let (_file, source_path) = create_test_perl_file();
let store = BreakpointStore::new();
let args = SetBreakpointsArguments {
source: Source { path: Some(source_path), name: Some("script.pl".to_string()) },
breakpoints: Some(vec![
SourceBreakpoint {
line: 10,
column: None,
condition: None,
hit_condition: None,
log_message: None,
},
SourceBreakpoint {
line: 20,
column: None,
condition: None,
hit_condition: None,
log_message: None,
},
]),
source_modified: None,
};
let breakpoints = store.set_breakpoints(&args);
assert_ne!(breakpoints[0].id, breakpoints[1].id);
}
#[test]
fn test_set_breakpoints_preserves_order() {
let (_file, source_path) = create_test_perl_file();
let store = BreakpointStore::new();
let args = SetBreakpointsArguments {
source: Source { path: Some(source_path), name: Some("script.pl".to_string()) },
breakpoints: Some(vec![
SourceBreakpoint {
line: 25,
column: None,
condition: None,
hit_condition: None,
log_message: None,
},
SourceBreakpoint {
line: 10,
column: None,
condition: None,
hit_condition: None,
log_message: None,
},
SourceBreakpoint {
line: 15,
column: None,
condition: None,
hit_condition: None,
log_message: None,
},
]),
source_modified: None,
};
let breakpoints = store.set_breakpoints(&args);
assert_eq!(breakpoints[0].line, 25);
assert_eq!(breakpoints[1].line, 10);
assert_eq!(breakpoints[2].line, 15);
}
#[test]
fn test_clear_breakpoints() {
let store = BreakpointStore::new();
let source_path = "/workspace/script.pl";
let args = SetBreakpointsArguments {
source: Source {
path: Some(source_path.to_string()),
name: Some("script.pl".to_string()),
},
breakpoints: Some(vec![SourceBreakpoint {
line: 10,
column: None,
condition: None,
hit_condition: None,
log_message: None,
}]),
source_modified: None,
};
store.set_breakpoints(&args);
store.clear_breakpoints(source_path);
let breakpoints = store.get_breakpoints(source_path);
assert_eq!(breakpoints.len(), 0);
}
#[test]
fn test_clear_all() {
let store = BreakpointStore::new();
let args1 = SetBreakpointsArguments {
source: Source {
path: Some("/workspace/file1.pl".to_string()),
name: Some("file1.pl".to_string()),
},
breakpoints: Some(vec![SourceBreakpoint {
line: 10,
column: None,
condition: None,
hit_condition: None,
log_message: None,
}]),
source_modified: None,
};
store.set_breakpoints(&args1);
let args2 = SetBreakpointsArguments {
source: Source {
path: Some("/workspace/file2.pl".to_string()),
name: Some("file2.pl".to_string()),
},
breakpoints: Some(vec![SourceBreakpoint {
line: 20,
column: None,
condition: None,
hit_condition: None,
log_message: None,
}]),
source_modified: None,
};
store.set_breakpoints(&args2);
store.clear_all();
assert_eq!(store.get_breakpoints("/workspace/file1.pl").len(), 0);
assert_eq!(store.get_breakpoints("/workspace/file2.pl").len(), 0);
}
#[test]
fn test_get_breakpoint_by_id() -> Result<(), Box<dyn std::error::Error>> {
let store = BreakpointStore::new();
let args = SetBreakpointsArguments {
source: Source {
path: Some("/workspace/script.pl".to_string()),
name: Some("script.pl".to_string()),
},
breakpoints: Some(vec![
SourceBreakpoint {
line: 10,
column: None,
condition: None,
hit_condition: None,
log_message: None,
},
SourceBreakpoint {
line: 25,
column: None,
condition: None,
hit_condition: None,
log_message: None,
},
]),
source_modified: None,
};
let breakpoints = store.set_breakpoints(&args);
let id = breakpoints[0].id;
let record = store.get_breakpoint_by_id(id);
assert!(record.is_some());
assert_eq!(record.ok_or("Expected record")?.line, 10);
let not_found = store.get_breakpoint_by_id(999999);
assert!(not_found.is_none());
Ok(())
}
#[test]
fn test_empty_breakpoints_array() {
let store = BreakpointStore::new();
let args = SetBreakpointsArguments {
source: Source {
path: Some("/workspace/script.pl".to_string()),
name: Some("script.pl".to_string()),
},
breakpoints: Some(vec![]),
source_modified: None,
};
let breakpoints = store.set_breakpoints(&args);
assert_eq!(breakpoints.len(), 0);
}
#[test]
fn test_no_source_path() {
let store = BreakpointStore::new();
let args = SetBreakpointsArguments {
source: Source { path: None, name: Some("script.pl".to_string()) },
breakpoints: Some(vec![SourceBreakpoint {
line: 10,
column: None,
condition: None,
hit_condition: None,
log_message: None,
}]),
source_modified: None,
};
let breakpoints = store.set_breakpoints(&args);
assert_eq!(breakpoints.len(), 0);
}
#[test]
fn test_adjust_breakpoints_for_edit() {
let store = BreakpointStore::new();
let source_path = "/workspace/script.pl";
let record = BreakpointRecord {
id: 1,
line: 10,
column: None,
condition: None,
hit_condition: None,
log_message: None,
hit_count: 0,
verified: true,
message: None,
};
store
.breakpoints
.lock()
.unwrap_or_else(|e| e.into_inner())
.insert(source_path.to_string(), vec![record]);
store.adjust_breakpoints_for_edit(source_path, 5, 5);
assert_eq!(store.get_breakpoints(source_path)[0].line, 15);
store.adjust_breakpoints_for_edit(source_path, 5, -3);
assert_eq!(store.get_breakpoints(source_path)[0].line, 12);
store.adjust_breakpoints_for_edit(source_path, 20, 10);
assert_eq!(store.get_breakpoints(source_path)[0].line, 12);
}
#[test]
fn test_hit_condition_parser_variants() {
assert_eq!(evaluate_hit_condition(None, 1), Some(true));
assert_eq!(evaluate_hit_condition(Some(""), 1), Some(true));
assert_eq!(evaluate_hit_condition(Some("3"), 3), Some(true));
assert_eq!(evaluate_hit_condition(Some("=3"), 2), Some(false));
assert_eq!(evaluate_hit_condition(Some(">= 2"), 2), Some(true));
assert_eq!(evaluate_hit_condition(Some(">2"), 2), Some(false));
assert_eq!(evaluate_hit_condition(Some("%2"), 4), Some(true));
assert_eq!(evaluate_hit_condition(Some("%0"), 4), None);
assert_eq!(evaluate_hit_condition(Some("invalid"), 1), None);
}
#[test]
fn test_register_breakpoint_hit_respects_hit_conditions_and_logpoints() {
let (_file, source_path) = create_test_perl_file();
let store = BreakpointStore::new();
let args = SetBreakpointsArguments {
source: Source { path: Some(source_path.clone()), name: Some("script.pl".to_string()) },
breakpoints: Some(vec![
SourceBreakpoint {
line: 10,
column: None,
condition: None,
hit_condition: Some(">= 2".to_string()),
log_message: None,
},
SourceBreakpoint {
line: 15,
column: None,
condition: None,
hit_condition: Some("%2".to_string()),
log_message: Some("loop tick".to_string()),
},
]),
source_modified: None,
};
let responses = store.set_breakpoints(&args);
assert_eq!(responses.len(), 2);
assert!(responses.iter().all(|bp| bp.verified));
let first_hit = store.register_breakpoint_hit(&source_path, 10);
assert!(first_hit.matched);
assert!(!first_hit.should_stop);
assert!(first_hit.log_messages.is_empty());
let second_hit = store.register_breakpoint_hit(&source_path, 10);
assert!(second_hit.matched);
assert!(second_hit.should_stop);
assert!(second_hit.log_messages.is_empty());
let logpoint_first = store.register_breakpoint_hit(&source_path, 15);
assert!(logpoint_first.matched);
assert!(!logpoint_first.should_stop);
assert!(logpoint_first.log_messages.is_empty());
let logpoint_second = store.register_breakpoint_hit(&source_path, 15);
assert!(logpoint_second.matched);
assert!(!logpoint_second.should_stop);
assert_eq!(logpoint_second.log_messages, vec!["loop tick".to_string()]);
}
#[test]
fn test_validate_breakpoint_line_scenarios() {
let source = r#"use strict;
# This is a comment
my $x = 1;
print "hello";
<<EOF;
heredoc content
EOF
"#;
let (v1, _) = validate_breakpoint_line(source, 1);
assert!(v1, "Line 1 should be valid");
let (v2, m2) = validate_breakpoint_line(source, 2);
assert!(!v2, "Line 2 should be invalid");
assert!(
m2.as_ref().is_some_and(|s| s.contains("comment")),
"Expected comment error message"
);
let (v4, m4) = validate_breakpoint_line(source, 4);
assert!(!v4, "Line 4 should be invalid");
assert!(
m4.as_ref().is_some_and(|s| s.contains("blank")),
"Expected blank line error message"
);
let (v5, _) = validate_breakpoint_line(source, 5);
assert!(!v5, "Line 5 should be invalid");
let (v8, _) = validate_breakpoint_line(source, 8);
let _ = v8;
}
#[test]
fn test_file_paths_match_no_basename_cross_match() {
assert!(!file_paths_match("/a/main.pl", "/b/main.pl"));
assert!(!file_paths_match("/workspace/a/lib.pm", "/workspace/b/lib.pm"));
}
#[test]
fn test_file_paths_match_suffix_still_works() {
assert!(file_paths_match("/workspace/lib/main.pl", "lib/main.pl"));
assert!(file_paths_match("lib/main.pl", "/workspace/lib/main.pl"));
}
#[test]
fn test_file_paths_match_exact() {
assert!(file_paths_match("/workspace/main.pl", "/workspace/main.pl"));
}
#[test]
fn test_breakpoint_hit_count_isolated_by_directory() -> Result<(), Box<dyn std::error::Error>> {
let dir_a = must(tempfile::tempdir());
let dir_b = must(tempfile::tempdir());
let file_a = dir_a.path().join("main.pl");
let file_b = dir_b.path().join("main.pl");
let perl_code = "#!/usr/bin/perl\nuse strict;\nmy $x = 1;\nmy $y = 2;\nmy $z = 3;\n\
print $x;\nprint $y;\nprint $z;\nmy $a = 4;\nmy $b = 5;\n\
my $c = 6;\nmy $d = 7;\nmy $e = 8;\nmy $f = 9;\nmy $g = 10;\n";
must(std::fs::write(&file_a, perl_code));
must(std::fs::write(&file_b, perl_code));
let path_a = file_a.to_string_lossy().to_string();
let path_b = file_b.to_string_lossy().to_string();
let store = BreakpointStore::new();
let args_a = SetBreakpointsArguments {
source: Source { path: Some(path_a.clone()), name: Some("main.pl".to_string()) },
breakpoints: Some(vec![SourceBreakpoint {
line: 5,
column: None,
condition: None,
hit_condition: None,
log_message: None,
}]),
source_modified: None,
};
let args_b = SetBreakpointsArguments {
source: Source { path: Some(path_b.clone()), name: Some("main.pl".to_string()) },
breakpoints: Some(vec![SourceBreakpoint {
line: 5,
column: None,
condition: None,
hit_condition: None,
log_message: None,
}]),
source_modified: None,
};
store.set_breakpoints(&args_a);
store.set_breakpoints(&args_b);
store.register_breakpoint_hit(&path_a, 5);
let bps_a = store.get_breakpoints(&path_a);
let bp_a = bps_a
.iter()
.find(|bp| bp.line == 5)
.ok_or("breakpoint in file_a not found")
.map_err(std::io::Error::other)?;
assert_eq!(bp_a.hit_count, 1);
let bps_b = store.get_breakpoints(&path_b);
let bp_b = bps_b
.iter()
.find(|bp| bp.line == 5)
.ok_or("breakpoint in file_b not found")
.map_err(std::io::Error::other)?;
assert_eq!(bp_b.hit_count, 0);
Ok(())
}
#[test]
fn test_same_line_different_conditions_both_validated() {
let (_file, source_path) = create_test_perl_file();
let store = BreakpointStore::new();
let args = SetBreakpointsArguments {
source: Source { path: Some(source_path.clone()), name: Some("script.pl".to_string()) },
breakpoints: Some(vec![
SourceBreakpoint {
line: 10,
column: None,
condition: None,
hit_condition: None,
log_message: None,
},
SourceBreakpoint {
line: 10,
column: None,
condition: Some("$x > 0\nB *".to_string()),
hit_condition: None,
log_message: None,
},
]),
source_modified: None,
};
let result = store.set_breakpoints(&args);
assert_eq!(result.len(), 2, "both breakpoints must appear in the response");
assert!(
!result[1].verified,
"breakpoint with newline in condition must be unverified (security guard applies \
independently of the AST validation cache); got verified={}",
result[1].verified
);
let records = store.get_breakpoints(&source_path);
assert_eq!(records.len(), 2, "both records must be stored (unverified ones are kept)");
assert!(!records[1].verified, "stored second record must be unverified");
assert!(
records[1].message.as_deref().unwrap_or("").contains("newline"),
"stored record must carry the newline-rejection message"
);
}
}