Skip to main content

perl_dap/
breakpoints.rs

1//! Breakpoint Management
2//!
3//! This module provides breakpoint storage and management for the DAP adapter.
4//! It implements REPLACE semantics for `setBreakpoints` requests and tracks
5//! breakpoints by source file path.
6//!
7//! # Architecture
8//!
9//! - **BreakpointStore**: Thread-safe storage mapping source paths to breakpoint records
10//! - **BreakpointRecord**: Individual breakpoint with unique ID, location, and verification status
11//! - **REPLACE Semantics**: Each `setBreakpoints` call clears existing breakpoints for that source
12//!
13//! # References
14//!
15//! - [DAP Protocol Schema](../../docs/reference/DAP_PROTOCOL_SCHEMA.md#4-breakpoint-requests)
16//! - [DAP Implementation Spec](../../docs/reference/DAP_IMPLEMENTATION_SPECIFICATION.md#ac7-breakpoint-management)
17
18use crate::breakpoint::{AstBreakpointValidator, BreakpointValidator};
19use crate::protocol::{Breakpoint, SetBreakpointsArguments};
20use std::collections::HashMap;
21use std::sync::{Arc, Mutex};
22
23// ============= AST Validation Utilities (AC7) =============
24
25/// Validate a breakpoint against source using the dedicated breakpoint microcrate.
26///
27/// Returns `(verified, resolved_line, message)` where `resolved_line` may differ from
28/// the requested line if the validator adjusts the location.
29#[cfg(test)]
30fn validate_breakpoint_line_with_column(
31    source: &str,
32    line: i64,
33    column: Option<i64>,
34) -> (bool, i64, Option<String>) {
35    if line <= 0 {
36        return (false, line, Some("Line number must be positive".to_string()));
37    }
38
39    match AstBreakpointValidator::new(source) {
40        Ok(validator) => {
41            let result = validator.validate_with_column(line, column);
42            (result.verified, result.line, result.message)
43        }
44        Err(error) => (false, line, Some(error.to_string())),
45    }
46}
47
48/// Backward-compatible helper used by unit tests in this module.
49#[cfg(test)]
50fn validate_breakpoint_line(source: &str, line: i64) -> (bool, Option<String>) {
51    let (verified, _resolved_line, message) =
52        validate_breakpoint_line_with_column(source, line, None);
53    (verified, message)
54}
55
56/// Individual breakpoint record
57///
58/// Stores the breakpoint metadata including unique ID, location,
59/// verification status, and optional condition.
60#[derive(Debug, Clone)]
61pub struct BreakpointRecord {
62    /// Unique breakpoint identifier (monotonically increasing)
63    pub id: i64,
64    /// Line number (1-based)
65    pub line: i64,
66    /// Column number (0-based, optional)
67    pub column: Option<i64>,
68    /// Breakpoint condition (e.g., "$x > 10")
69    pub condition: Option<String>,
70    /// Breakpoint hit-count condition (e.g., ">= 5", "%2")
71    pub hit_condition: Option<String>,
72    /// Logpoint message. When present, hit events log output and continue.
73    pub log_message: Option<String>,
74    /// Number of times this breakpoint has been hit in the current session.
75    pub hit_count: u64,
76    /// Whether breakpoint was successfully verified
77    pub verified: bool,
78    /// Verification message (error/warning if not verified or adjusted)
79    pub message: Option<String>,
80}
81
82impl BreakpointRecord {
83    /// Convert to DAP protocol Breakpoint type
84    pub fn to_protocol(&self) -> Breakpoint {
85        Breakpoint {
86            id: self.id,
87            verified: self.verified,
88            line: self.line,
89            column: self.column,
90            message: self.message.clone(),
91        }
92    }
93}
94
95/// Result of applying a runtime breakpoint hit to stored breakpoint metadata.
96#[derive(Debug, Clone, Default, PartialEq, Eq)]
97pub struct BreakpointHitOutcome {
98    /// True when at least one verified breakpoint matched the file/line location.
99    pub matched: bool,
100    /// True when execution should stop and emit a `stopped` event.
101    pub should_stop: bool,
102    /// Logpoint messages to emit as `output` events.
103    pub log_messages: Vec<String>,
104}
105
106fn parse_hit_condition_operand(raw: &str) -> Option<u64> {
107    raw.trim().parse::<u64>().ok()
108}
109
110fn is_valid_hit_condition(raw: &str) -> bool {
111    evaluate_hit_condition(Some(raw), 1).is_some()
112}
113
114fn evaluate_hit_condition(raw: Option<&str>, hit_count: u64) -> Option<bool> {
115    let Some(raw) = raw else {
116        return Some(true);
117    };
118
119    let expr = raw.trim();
120    if expr.is_empty() {
121        return Some(true);
122    }
123
124    if let Some(rest) = expr.strip_prefix(">=") {
125        return parse_hit_condition_operand(rest).map(|n| hit_count >= n);
126    }
127    if let Some(rest) = expr.strip_prefix("<=") {
128        return parse_hit_condition_operand(rest).map(|n| hit_count <= n);
129    }
130    if let Some(rest) = expr.strip_prefix("==") {
131        return parse_hit_condition_operand(rest).map(|n| hit_count == n);
132    }
133    if let Some(rest) = expr.strip_prefix('=') {
134        return parse_hit_condition_operand(rest).map(|n| hit_count == n);
135    }
136    if let Some(rest) = expr.strip_prefix('>') {
137        return parse_hit_condition_operand(rest).map(|n| hit_count > n);
138    }
139    if let Some(rest) = expr.strip_prefix('<') {
140        return parse_hit_condition_operand(rest).map(|n| hit_count < n);
141    }
142    if let Some(rest) = expr.strip_prefix('%') {
143        return parse_hit_condition_operand(rest)
144            .and_then(|n| if n == 0 { None } else { Some(hit_count.is_multiple_of(n)) });
145    }
146
147    parse_hit_condition_operand(expr).map(|n| hit_count == n)
148}
149
150fn file_paths_match(stored: &str, observed: &str) -> bool {
151    if stored == observed {
152        return true;
153    }
154    if stored.ends_with(observed) || observed.ends_with(stored) {
155        return true;
156    }
157    false
158}
159
160/// Interpolate logpoint message template with variable values.
161///
162/// Parses `{expression}` patterns in the message template and substitutes
163/// them with values from the provided variable map.
164///
165/// # Arguments
166///
167/// * `template` - Message template with `{$variable}` expressions
168/// * `variables` - HashMap of variable names to their string values
169///
170/// # Returns
171///
172/// The interpolated message with expressions replaced by variable values.
173/// If a variable is not found, the expression remains as-is.
174///
175/// # Examples
176///
177/// ```
178/// let mut vars = std::collections::HashMap::new();
179/// vars.insert("x".to_string(), "42".to_string());
180/// let result = interpolate_logpoint_message("value: {$x}", &vars);
181/// assert_eq!(result, "value: 42");
182/// ```
183pub fn interpolate_logpoint_message(
184    template: &str,
185    variables: &std::collections::HashMap<String, String>,
186) -> String {
187    // Use a simple regex-based approach to find and replace {$var} patterns
188    // For now, we'll use a manual character-by-character parser to avoid regex dependency
189
190    let mut result = String::new();
191    let mut chars = template.chars().peekable();
192
193    while let Some(ch) = chars.next() {
194        if ch == '{' {
195            // Look ahead for a variable reference like $name
196            let mut expr = String::new();
197            let mut found_close = false;
198
199            // Collect everything until }
200            while let Some(next_ch) = chars.peek() {
201                if *next_ch == '}' {
202                    chars.next(); // consume the }
203                    found_close = true;
204                    break;
205                }
206                expr.push(*next_ch);
207                chars.next();
208            }
209
210            if found_close {
211                // Only scalar variables ($name) are interpolated; @array/%hash and
212                // arithmetic expressions are kept verbatim (full expression evaluation
213                // requires a live debugger context and is deferred to a follow-up).
214                let trimmed = expr.trim();
215                if let Some(var_name) = trimmed.strip_prefix('$') {
216                    if let Some(value) = variables.get(var_name) {
217                        result.push_str(value);
218                    } else {
219                        // Variable not found in caller-supplied map: keep original
220                        // expression so the template remains readable in the output.
221                        result.push('{');
222                        result.push_str(trimmed);
223                        result.push('}');
224                    }
225                } else {
226                    // Non-scalar expression (arithmetic, @array, etc.): keep verbatim.
227                    result.push('{');
228                    result.push_str(&expr);
229                    result.push('}');
230                }
231            } else {
232                // No closing }, keep opening brace
233                result.push('{');
234                result.push_str(&expr);
235            }
236        } else {
237            result.push(ch);
238        }
239    }
240
241    result
242}
243
244/// Thread-safe breakpoint storage
245///
246/// Stores breakpoints indexed by source file path. Provides methods for
247/// setting, clearing, and retrieving breakpoints with REPLACE semantics.
248#[derive(Debug, Clone)]
249pub struct BreakpointStore {
250    /// Map of source path -> list of breakpoints
251    breakpoints: Arc<Mutex<HashMap<String, Vec<BreakpointRecord>>>>,
252    /// Next breakpoint ID (monotonically increasing)
253    next_id: Arc<Mutex<i64>>,
254}
255
256impl BreakpointStore {
257    /// Create a new empty breakpoint store
258    ///
259    /// # Examples
260    ///
261    /// ```
262    /// use perl_dap::breakpoints::BreakpointStore;
263    ///
264    /// let store = BreakpointStore::new();
265    /// ```
266    pub fn new() -> Self {
267        Self { breakpoints: Arc::new(Mutex::new(HashMap::new())), next_id: Arc::new(Mutex::new(1)) }
268    }
269
270    /// Set breakpoints for a source file (REPLACE semantics)
271    ///
272    /// This method clears all existing breakpoints for the source file
273    /// and sets the new breakpoints from the request. Each breakpoint
274    /// is assigned a unique ID and verified status.
275    ///
276    /// # Arguments
277    ///
278    /// * `args` - SetBreakpoints request arguments containing source and breakpoint list
279    ///
280    /// # Returns
281    ///
282    /// Array of verified breakpoints in SAME ORDER as the request.
283    ///
284    /// # Examples
285    ///
286    /// ```no_run
287    /// use perl_dap::breakpoints::BreakpointStore;
288    /// use perl_dap::protocol::{SetBreakpointsArguments, Source, SourceBreakpoint};
289    ///
290    /// let store = BreakpointStore::new();
291    /// let args = SetBreakpointsArguments {
292    ///     source: Source {
293    ///         path: Some("/workspace/script.pl".to_string()),
294    ///         name: Some("script.pl".to_string()),
295    ///     },
296    ///     breakpoints: Some(vec![
297    ///         SourceBreakpoint { line: 10, column: None, condition: None, hit_condition: None, log_message: None },
298    ///         SourceBreakpoint { line: 25, column: None, condition: None, hit_condition: None, log_message: None },
299    ///     ]),
300    ///     source_modified: None,
301    /// };
302    ///
303    /// let breakpoints = store.set_breakpoints(&args);
304    /// assert_eq!(breakpoints.len(), 2);
305    /// ```
306    pub fn set_breakpoints(&self, args: &SetBreakpointsArguments) -> Vec<Breakpoint> {
307        // Extract source path (required for breakpoint storage)
308        let source_path = match &args.source.path {
309            Some(path) => path.clone(),
310            None => {
311                // No source path provided - return empty array
312                return Vec::new();
313            }
314        };
315
316        // Get breakpoint request slice (empty if not provided)
317        let source_breakpoints = args.breakpoints.as_deref().unwrap_or(&[]);
318
319        // Read source file and parse once for AST validation (AC7).
320        let source_content = std::fs::read_to_string(&source_path).ok();
321        let validator = source_content
322            .as_ref()
323            .map(|content| AstBreakpointValidator::new(content).map_err(|e| e.to_string()));
324        let mut validation_cache: HashMap<(i64, Option<i64>), (bool, i64, Option<String>)> =
325            HashMap::new();
326
327        // Lock stores for atomic replacement + id allocation.
328        let mut breakpoints_map = self.breakpoints.lock().unwrap_or_else(|e| e.into_inner());
329        let mut next_id = self.next_id.lock().unwrap_or_else(|e| e.into_inner());
330
331        // Clear existing breakpoints for this source (REPLACE semantics)
332        breakpoints_map.remove(&source_path);
333
334        let mut records = Vec::new();
335        // Create new breakpoint records
336        for bp in source_breakpoints {
337            let id = *next_id;
338            *next_id += 1;
339
340            if bp.line <= 0 {
341                records.push(BreakpointRecord {
342                    id,
343                    line: bp.line,
344                    column: bp.column,
345                    condition: bp.condition.clone(),
346                    hit_condition: bp.hit_condition.clone(),
347                    log_message: bp.log_message.clone(),
348                    hit_count: 0,
349                    verified: false,
350                    message: Some("Line number must be positive".to_string()),
351                });
352                continue;
353            }
354
355            // AC7: Security validation - Reject conditions with newlines
356            // The Perl debugger protocol is line-based, so a newline in a condition
357            // allows injecting arbitrary debugger commands.
358            if let Some(ref condition) = bp.condition {
359                if condition.contains('\n') || condition.contains('\r') {
360                    let record = BreakpointRecord {
361                        id,
362                        line: bp.line,
363                        column: bp.column,
364                        condition: bp.condition.clone(),
365                        hit_condition: bp.hit_condition.clone(),
366                        log_message: bp.log_message.clone(),
367                        hit_count: 0,
368                        verified: false,
369                        message: Some("Breakpoint condition cannot contain newlines".to_string()),
370                    };
371                    records.push(record);
372                    continue;
373                }
374            }
375
376            if let Some(ref hit_condition) = bp.hit_condition {
377                let hit_condition = hit_condition.trim();
378                if hit_condition.contains('\n') || hit_condition.contains('\r') {
379                    let record = BreakpointRecord {
380                        id,
381                        line: bp.line,
382                        column: bp.column,
383                        condition: bp.condition.clone(),
384                        hit_condition: bp.hit_condition.clone(),
385                        log_message: bp.log_message.clone(),
386                        hit_count: 0,
387                        verified: false,
388                        message: Some("Hit condition cannot contain newlines".to_string()),
389                    };
390                    records.push(record);
391                    continue;
392                }
393                if !is_valid_hit_condition(hit_condition) {
394                    let record = BreakpointRecord {
395                        id,
396                        line: bp.line,
397                        column: bp.column,
398                        condition: bp.condition.clone(),
399                        hit_condition: bp.hit_condition.clone(),
400                        log_message: bp.log_message.clone(),
401                        hit_count: 0,
402                        verified: false,
403                        message: Some(format!(
404                            "Invalid hitCondition `{hit_condition}` (expected numeric expression like `10`, `>= 5`, `%2`)"
405                        )),
406                    };
407                    records.push(record);
408                    continue;
409                }
410            }
411
412            // AC7: AST-based breakpoint validation via `perl-dap-breakpoint` microcrate.
413            let (verified, resolved_line, message) =
414                if let Some(cached) = validation_cache.get(&(bp.line, bp.column)) {
415                    cached.clone()
416                } else {
417                    let computed = match &validator {
418                        Some(Ok(v)) => {
419                            let result = v.validate_with_column(bp.line, bp.column);
420                            (result.verified, result.line, result.message)
421                        }
422                        Some(Err(error)) => (false, bp.line, Some(error.clone())),
423                        None => {
424                            // Can't read file - mark as unverified but still create breakpoint.
425                            (false, bp.line, Some("Unable to read source file".to_string()))
426                        }
427                    };
428                    validation_cache.insert((bp.line, bp.column), computed.clone());
429                    computed
430                };
431
432            let mut verified = verified;
433            let message = if verified
434                && bp.condition.is_some()
435                && let Some(Ok(v)) = &validator
436                && let Some(condition) = bp.condition.as_deref()
437            {
438                let condition_validation = v.validate_condition(resolved_line, condition);
439                if condition_validation.verified {
440                    message
441                } else {
442                    verified = false;
443                    Some("Conditional breakpoint expression is invalid".to_string())
444                }
445            } else {
446                message
447            };
448
449            let record = BreakpointRecord {
450                id,
451                line: resolved_line,
452                column: bp.column,
453                condition: bp.condition.clone(),
454                hit_condition: bp.hit_condition.clone(),
455                log_message: bp.log_message.clone(),
456                hit_count: 0,
457                verified,
458                message,
459            };
460
461            records.push(record);
462        }
463
464        // Store breakpoints for this source
465        if !records.is_empty() {
466            breakpoints_map.insert(source_path.clone(), records.clone());
467        }
468
469        // Convert to protocol format (preserving order)
470        records.iter().map(|r| r.to_protocol()).collect()
471    }
472
473    /// Get all breakpoints for a source file
474    ///
475    /// # Arguments
476    ///
477    /// * `source_path` - Absolute path to source file
478    ///
479    /// # Returns
480    ///
481    /// Array of breakpoint records for the source, or empty if none exist.
482    pub fn get_breakpoints(&self, source_path: &str) -> Vec<BreakpointRecord> {
483        let breakpoints_map = self.breakpoints.lock().unwrap_or_else(|e| e.into_inner());
484        breakpoints_map.get(source_path).map_or(Vec::new(), |bps| bps.clone())
485    }
486
487    /// Clear all breakpoints for a source file
488    ///
489    /// # Arguments
490    ///
491    /// * `source_path` - Absolute path to source file
492    pub fn clear_breakpoints(&self, source_path: &str) {
493        let mut breakpoints_map = self.breakpoints.lock().unwrap_or_else(|e| e.into_inner());
494        breakpoints_map.remove(source_path);
495    }
496
497    /// Clear all breakpoints in all source files
498    pub fn clear_all(&self) {
499        let mut breakpoints_map = self.breakpoints.lock().unwrap_or_else(|e| e.into_inner());
500        breakpoints_map.clear();
501    }
502
503    /// Check if the store is empty
504    pub fn is_empty(&self) -> bool {
505        let breakpoints_map = self.breakpoints.lock().unwrap_or_else(|e| e.into_inner());
506        breakpoints_map.is_empty()
507    }
508
509    /// Get breakpoint by ID across all sources
510    ///
511    /// # Arguments
512    ///
513    /// * `id` - Unique breakpoint identifier
514    ///
515    /// # Returns
516    ///
517    /// Breakpoint record if found, None otherwise.
518    pub fn get_breakpoint_by_id(&self, id: i64) -> Option<BreakpointRecord> {
519        let breakpoints_map = self.breakpoints.lock().unwrap_or_else(|e| e.into_inner());
520        for records in breakpoints_map.values() {
521            if let Some(record) = records.iter().find(|r| r.id == id) {
522                return Some(record.clone());
523            }
524        }
525        None
526    }
527
528    /// Register a runtime breakpoint hit and return stop/logpoint behavior.
529    ///
530    /// This method updates per-breakpoint hit counters and evaluates DAP hit
531    /// conditions. For logpoints, execution continues after emitting output.
532    pub fn register_breakpoint_hit(&self, source_path: &str, line: i64) -> BreakpointHitOutcome {
533        self.register_breakpoint_hit_with_variables(source_path, line, None)
534    }
535
536    /// Register a breakpoint hit with optional variable interpolation.
537    ///
538    /// Similar to `register_breakpoint_hit`, but accepts optional variable values
539    /// for logpoint message interpolation. If variables are provided, logpoint
540    /// messages with `{$variable}` expressions will be interpolated.
541    ///
542    /// # Arguments
543    ///
544    /// * `source_path` - Path to the source file
545    /// * `line` - Line number where the breakpoint was hit (1-based)
546    /// * `variables` - Optional map of variable names to their string values
547    ///
548    /// # Returns
549    ///
550    /// BreakpointHitOutcome with interpolated log messages if variables provided
551    pub fn register_breakpoint_hit_with_variables(
552        &self,
553        source_path: &str,
554        line: i64,
555        variables: Option<&std::collections::HashMap<String, String>>,
556    ) -> BreakpointHitOutcome {
557        let mut breakpoints_map = self.breakpoints.lock().unwrap_or_else(|e| e.into_inner());
558        let mut outcome = BreakpointHitOutcome::default();
559
560        for (stored_path, records) in &mut *breakpoints_map {
561            if !file_paths_match(stored_path, source_path) {
562                continue;
563            }
564
565            for record in records {
566                if !record.verified || record.line != line {
567                    continue;
568                }
569
570                outcome.matched = true;
571                record.hit_count = record.hit_count.saturating_add(1);
572
573                let hit_condition_match =
574                    evaluate_hit_condition(record.hit_condition.as_deref(), record.hit_count)
575                        .unwrap_or(false);
576                if !hit_condition_match {
577                    continue;
578                }
579
580                if let Some(message) = record.log_message.clone() {
581                    // Interpolate message if variables are available
582                    let interpolated = if let Some(vars) = variables {
583                        interpolate_logpoint_message(&message, vars)
584                    } else {
585                        message
586                    };
587                    outcome.log_messages.push(interpolated);
588                } else {
589                    outcome.should_stop = true;
590                }
591            }
592        }
593
594        outcome
595    }
596
597    /// AC7.4: Adjust breakpoints for a file edit
598    ///
599    /// This method shifts breakpoint lines based on content changes.
600    /// It provides <1ms performance by avoiding full AST re-parsing.
601    ///
602    /// # Arguments
603    ///
604    /// * `source_path` - Path to the modified file
605    /// * `start_line` - Line where the edit started (1-based)
606    /// * `lines_delta` - Number of lines added (positive) or removed (negative)
607    pub fn adjust_breakpoints_for_edit(
608        &self,
609        source_path: &str,
610        start_line: i64,
611        lines_delta: i64,
612    ) {
613        let mut breakpoints_map = self.breakpoints.lock().unwrap_or_else(|e| e.into_inner());
614        if let Some(records) = breakpoints_map.get_mut(source_path) {
615            for record in records {
616                // Shift breakpoints that are at or after the edit line
617                if record.line >= start_line {
618                    record.line += lines_delta;
619                    // Ensure line number stays valid (min 1)
620                    if record.line < 1 {
621                        record.line = 1;
622                        record.verified = false;
623                        record.message = Some("Breakpoint invalidated by edit".to_string());
624                    }
625                }
626            }
627        }
628    }
629}
630
631impl Default for BreakpointStore {
632    fn default() -> Self {
633        Self::new()
634    }
635}
636
637#[cfg(test)]
638mod tests {
639    use super::*;
640    use crate::protocol::{SetBreakpointsArguments, Source, SourceBreakpoint};
641    use perl_tdd_support::must;
642    use std::io::Write;
643    use tempfile::NamedTempFile;
644
645    /// Create a temp file with valid Perl code for testing breakpoints.
646    /// Returns the temp file (keeps it alive) and its path.
647    fn create_test_perl_file() -> (NamedTempFile, String) {
648        let mut file = must(NamedTempFile::with_suffix(".pl"));
649        // Create 30 lines of valid Perl code for breakpoint testing
650        // NOTE: Avoid sub immediately followed by for loop (triggers parser hang - known issue)
651        let perl_code = r#"#!/usr/bin/perl
652use strict;
653use warnings;
654
655my $x = 1;
656my $y = 2;
657my $z = $x + $y;
658
659if ($x > 0) {
660    print "positive\n";
661}
662
663my @arr = (1, 2, 3);
664while (my $item = shift @arr) {
665    my $doubled = $item * 2;
666    print "$doubled\n";
667}
668
669sub process {
670    my ($value) = @_;
671    my $result = $value * 2;
672    return $result;
673}
674
675print "done\n";
676my $final = process($x);
677print "result: $final\n";
678"#;
679        must(file.write_all(perl_code.as_bytes()));
680        must(file.flush());
681        let path = file.path().to_string_lossy().to_string();
682        (file, path)
683    }
684
685    #[test]
686    fn test_breakpoint_store_new() {
687        let store = BreakpointStore::new();
688        let breakpoints = store.get_breakpoints("/workspace/test.pl");
689        assert_eq!(breakpoints.len(), 0);
690    }
691
692    #[test]
693    fn test_set_breakpoints_creates_records() {
694        let (_file, source_path) = create_test_perl_file();
695        let store = BreakpointStore::new();
696        let args = SetBreakpointsArguments {
697            source: Source { path: Some(source_path.clone()), name: Some("script.pl".to_string()) },
698            breakpoints: Some(vec![
699                SourceBreakpoint {
700                    line: 10,
701                    column: None,
702                    condition: None,
703                    hit_condition: None,
704                    log_message: None,
705                },
706                SourceBreakpoint {
707                    line: 25,
708                    column: Some(5),
709                    condition: Some("$x > 10".to_string()),
710                    hit_condition: None,
711                    log_message: None,
712                },
713            ]),
714            source_modified: None,
715        };
716
717        let breakpoints = store.set_breakpoints(&args);
718
719        assert_eq!(breakpoints.len(), 2);
720        assert_eq!(breakpoints[0].line, 10);
721        assert!(breakpoints[0].verified);
722        assert_eq!(breakpoints[1].line, 25);
723        assert_eq!(breakpoints[1].column, Some(5));
724        assert!(breakpoints[1].verified);
725    }
726
727    #[test]
728    fn test_set_breakpoints_replace_semantics() {
729        let (_file, source_path) = create_test_perl_file();
730        let store = BreakpointStore::new();
731
732        // Set initial breakpoints
733        let args1 = SetBreakpointsArguments {
734            source: Source { path: Some(source_path.clone()), name: Some("script.pl".to_string()) },
735            breakpoints: Some(vec![SourceBreakpoint {
736                line: 10,
737                column: None,
738                condition: None,
739                hit_condition: None,
740                log_message: None,
741            }]),
742            source_modified: None,
743        };
744        store.set_breakpoints(&args1);
745
746        // Replace with new breakpoints
747        let args2 = SetBreakpointsArguments {
748            source: Source { path: Some(source_path.clone()), name: Some("script.pl".to_string()) },
749            breakpoints: Some(vec![
750                SourceBreakpoint {
751                    line: 20,
752                    column: None,
753                    condition: None,
754                    hit_condition: None,
755                    log_message: None,
756                },
757                SourceBreakpoint {
758                    line: 26,
759                    column: None,
760                    condition: None,
761                    hit_condition: None,
762                    log_message: None,
763                },
764            ]),
765            source_modified: None,
766        };
767        let breakpoints = store.set_breakpoints(&args2);
768
769        // Should have only the new breakpoints
770        assert_eq!(breakpoints.len(), 2);
771        assert_eq!(breakpoints[0].line, 20);
772        assert_eq!(breakpoints[1].line, 26);
773
774        // Verify stored breakpoints
775        let stored = store.get_breakpoints(&source_path);
776        assert_eq!(stored.len(), 2);
777    }
778
779    #[test]
780    fn test_set_breakpoints_unique_ids() {
781        let (_file, source_path) = create_test_perl_file();
782        let store = BreakpointStore::new();
783        let args = SetBreakpointsArguments {
784            source: Source { path: Some(source_path), name: Some("script.pl".to_string()) },
785            breakpoints: Some(vec![
786                SourceBreakpoint {
787                    line: 10,
788                    column: None,
789                    condition: None,
790                    hit_condition: None,
791                    log_message: None,
792                },
793                SourceBreakpoint {
794                    line: 20,
795                    column: None,
796                    condition: None,
797                    hit_condition: None,
798                    log_message: None,
799                },
800            ]),
801            source_modified: None,
802        };
803
804        let breakpoints = store.set_breakpoints(&args);
805
806        // IDs should be unique
807        assert_ne!(breakpoints[0].id, breakpoints[1].id);
808    }
809
810    #[test]
811    fn test_set_breakpoints_preserves_order() {
812        let (_file, source_path) = create_test_perl_file();
813        let store = BreakpointStore::new();
814        let args = SetBreakpointsArguments {
815            source: Source { path: Some(source_path), name: Some("script.pl".to_string()) },
816            // Use lines within our 30-line test file, but out of order
817            breakpoints: Some(vec![
818                SourceBreakpoint {
819                    line: 25,
820                    column: None,
821                    condition: None,
822                    hit_condition: None,
823                    log_message: None,
824                },
825                SourceBreakpoint {
826                    line: 10,
827                    column: None,
828                    condition: None,
829                    hit_condition: None,
830                    log_message: None,
831                },
832                SourceBreakpoint {
833                    line: 15,
834                    column: None,
835                    condition: None,
836                    hit_condition: None,
837                    log_message: None,
838                },
839            ]),
840            source_modified: None,
841        };
842
843        let breakpoints = store.set_breakpoints(&args);
844
845        // Order must match request (not sorted by line number)
846        assert_eq!(breakpoints[0].line, 25);
847        assert_eq!(breakpoints[1].line, 10);
848        assert_eq!(breakpoints[2].line, 15);
849    }
850
851    #[test]
852    fn test_clear_breakpoints() {
853        let store = BreakpointStore::new();
854        let source_path = "/workspace/script.pl";
855
856        let args = SetBreakpointsArguments {
857            source: Source {
858                path: Some(source_path.to_string()),
859                name: Some("script.pl".to_string()),
860            },
861            breakpoints: Some(vec![SourceBreakpoint {
862                line: 10,
863                column: None,
864                condition: None,
865                hit_condition: None,
866                log_message: None,
867            }]),
868            source_modified: None,
869        };
870        store.set_breakpoints(&args);
871
872        // Clear breakpoints
873        store.clear_breakpoints(source_path);
874
875        // Should be empty
876        let breakpoints = store.get_breakpoints(source_path);
877        assert_eq!(breakpoints.len(), 0);
878    }
879
880    #[test]
881    fn test_clear_all() {
882        let store = BreakpointStore::new();
883
884        // Set breakpoints in multiple files
885        let args1 = SetBreakpointsArguments {
886            source: Source {
887                path: Some("/workspace/file1.pl".to_string()),
888                name: Some("file1.pl".to_string()),
889            },
890            breakpoints: Some(vec![SourceBreakpoint {
891                line: 10,
892                column: None,
893                condition: None,
894                hit_condition: None,
895                log_message: None,
896            }]),
897            source_modified: None,
898        };
899        store.set_breakpoints(&args1);
900
901        let args2 = SetBreakpointsArguments {
902            source: Source {
903                path: Some("/workspace/file2.pl".to_string()),
904                name: Some("file2.pl".to_string()),
905            },
906            breakpoints: Some(vec![SourceBreakpoint {
907                line: 20,
908                column: None,
909                condition: None,
910                hit_condition: None,
911                log_message: None,
912            }]),
913            source_modified: None,
914        };
915        store.set_breakpoints(&args2);
916
917        // Clear all
918        store.clear_all();
919
920        // Both should be empty
921        assert_eq!(store.get_breakpoints("/workspace/file1.pl").len(), 0);
922        assert_eq!(store.get_breakpoints("/workspace/file2.pl").len(), 0);
923    }
924
925    #[test]
926    fn test_get_breakpoint_by_id() -> Result<(), Box<dyn std::error::Error>> {
927        let store = BreakpointStore::new();
928        let args = SetBreakpointsArguments {
929            source: Source {
930                path: Some("/workspace/script.pl".to_string()),
931                name: Some("script.pl".to_string()),
932            },
933            breakpoints: Some(vec![
934                SourceBreakpoint {
935                    line: 10,
936                    column: None,
937                    condition: None,
938                    hit_condition: None,
939                    log_message: None,
940                },
941                SourceBreakpoint {
942                    line: 25,
943                    column: None,
944                    condition: None,
945                    hit_condition: None,
946                    log_message: None,
947                },
948            ]),
949            source_modified: None,
950        };
951
952        let breakpoints = store.set_breakpoints(&args);
953        let id = breakpoints[0].id;
954
955        // Retrieve by ID
956        let record = store.get_breakpoint_by_id(id);
957        assert!(record.is_some());
958        assert_eq!(record.ok_or("Expected record")?.line, 10);
959
960        // Non-existent ID
961        let not_found = store.get_breakpoint_by_id(999999);
962        assert!(not_found.is_none());
963        Ok(())
964    }
965
966    #[test]
967    fn test_empty_breakpoints_array() {
968        let store = BreakpointStore::new();
969        let args = SetBreakpointsArguments {
970            source: Source {
971                path: Some("/workspace/script.pl".to_string()),
972                name: Some("script.pl".to_string()),
973            },
974            breakpoints: Some(vec![]),
975            source_modified: None,
976        };
977
978        let breakpoints = store.set_breakpoints(&args);
979        assert_eq!(breakpoints.len(), 0);
980    }
981
982    #[test]
983    fn test_no_source_path() {
984        let store = BreakpointStore::new();
985        let args = SetBreakpointsArguments {
986            source: Source { path: None, name: Some("script.pl".to_string()) },
987            breakpoints: Some(vec![SourceBreakpoint {
988                line: 10,
989                column: None,
990                condition: None,
991                hit_condition: None,
992                log_message: None,
993            }]),
994            source_modified: None,
995        };
996
997        let breakpoints = store.set_breakpoints(&args);
998        assert_eq!(breakpoints.len(), 0);
999    }
1000
1001    #[test]
1002    fn test_adjust_breakpoints_for_edit() {
1003        // AC:7.4
1004        let store = BreakpointStore::new();
1005        let source_path = "/workspace/script.pl";
1006
1007        // Mock store with manual insertion to avoid FS dependencies
1008        let record = BreakpointRecord {
1009            id: 1,
1010            line: 10,
1011            column: None,
1012            condition: None,
1013            hit_condition: None,
1014            log_message: None,
1015            hit_count: 0,
1016            verified: true,
1017            message: None,
1018        };
1019        store
1020            .breakpoints
1021            .lock()
1022            .unwrap_or_else(|e| e.into_inner())
1023            .insert(source_path.to_string(), vec![record]);
1024
1025        // 1. Add 5 lines at line 5 (shift down)
1026        store.adjust_breakpoints_for_edit(source_path, 5, 5);
1027        assert_eq!(store.get_breakpoints(source_path)[0].line, 15);
1028
1029        // 2. Remove 3 lines at line 5 (shift up)
1030        store.adjust_breakpoints_for_edit(source_path, 5, -3);
1031        assert_eq!(store.get_breakpoints(source_path)[0].line, 12);
1032
1033        // 3. Edit after breakpoint (no shift)
1034        store.adjust_breakpoints_for_edit(source_path, 20, 10);
1035        assert_eq!(store.get_breakpoints(source_path)[0].line, 12);
1036    }
1037
1038    #[test]
1039    fn test_hit_condition_parser_variants() {
1040        assert_eq!(evaluate_hit_condition(None, 1), Some(true));
1041        assert_eq!(evaluate_hit_condition(Some(""), 1), Some(true));
1042        assert_eq!(evaluate_hit_condition(Some("3"), 3), Some(true));
1043        assert_eq!(evaluate_hit_condition(Some("=3"), 2), Some(false));
1044        assert_eq!(evaluate_hit_condition(Some(">= 2"), 2), Some(true));
1045        assert_eq!(evaluate_hit_condition(Some(">2"), 2), Some(false));
1046        assert_eq!(evaluate_hit_condition(Some("%2"), 4), Some(true));
1047        assert_eq!(evaluate_hit_condition(Some("%0"), 4), None);
1048        assert_eq!(evaluate_hit_condition(Some("invalid"), 1), None);
1049    }
1050
1051    #[test]
1052    fn test_register_breakpoint_hit_respects_hit_conditions_and_logpoints() {
1053        let (_file, source_path) = create_test_perl_file();
1054        let store = BreakpointStore::new();
1055
1056        let args = SetBreakpointsArguments {
1057            source: Source { path: Some(source_path.clone()), name: Some("script.pl".to_string()) },
1058            breakpoints: Some(vec![
1059                SourceBreakpoint {
1060                    line: 10,
1061                    column: None,
1062                    condition: None,
1063                    hit_condition: Some(">= 2".to_string()),
1064                    log_message: None,
1065                },
1066                SourceBreakpoint {
1067                    line: 15,
1068                    column: None,
1069                    condition: None,
1070                    hit_condition: Some("%2".to_string()),
1071                    log_message: Some("loop tick".to_string()),
1072                },
1073            ]),
1074            source_modified: None,
1075        };
1076        let responses = store.set_breakpoints(&args);
1077        assert_eq!(responses.len(), 2);
1078        assert!(responses.iter().all(|bp| bp.verified));
1079
1080        let first_hit = store.register_breakpoint_hit(&source_path, 10);
1081        assert!(first_hit.matched);
1082        assert!(!first_hit.should_stop);
1083        assert!(first_hit.log_messages.is_empty());
1084
1085        let second_hit = store.register_breakpoint_hit(&source_path, 10);
1086        assert!(second_hit.matched);
1087        assert!(second_hit.should_stop);
1088        assert!(second_hit.log_messages.is_empty());
1089
1090        let logpoint_first = store.register_breakpoint_hit(&source_path, 15);
1091        assert!(logpoint_first.matched);
1092        assert!(!logpoint_first.should_stop);
1093        assert!(logpoint_first.log_messages.is_empty());
1094
1095        let logpoint_second = store.register_breakpoint_hit(&source_path, 15);
1096        assert!(logpoint_second.matched);
1097        assert!(!logpoint_second.should_stop);
1098        assert_eq!(logpoint_second.log_messages, vec!["loop tick".to_string()]);
1099    }
1100
1101    #[test]
1102    fn test_logpoint_message_interpolation() {
1103        // Test basic logpoint message interpolation with {$variable} syntax
1104        let (_file, source_path) = create_test_perl_file();
1105        let store = BreakpointStore::new();
1106
1107        let args = SetBreakpointsArguments {
1108            source: Source { path: Some(source_path.clone()), name: Some("script.pl".to_string()) },
1109            breakpoints: Some(vec![SourceBreakpoint {
1110                line: 10,
1111                column: None,
1112                condition: None,
1113                hit_condition: None,
1114                log_message: Some("x = {$x}".to_string()),
1115            }]),
1116            source_modified: None,
1117        };
1118        let responses = store.set_breakpoints(&args);
1119        assert_eq!(responses.len(), 1);
1120        assert!(responses[0].verified);
1121
1122        // Register hit and verify interpolation occurs
1123        let outcome = store.register_breakpoint_hit(&source_path, 10);
1124        assert!(outcome.matched);
1125        assert!(!outcome.should_stop); // logpoint doesn't stop
1126
1127        // Interpolate the message with variable values
1128        let mut variables = std::collections::HashMap::new();
1129        variables.insert("x".to_string(), "42".to_string());
1130
1131        let interpolated = interpolate_logpoint_message(&outcome.log_messages[0], &variables);
1132        assert_eq!(interpolated, "x = 42");
1133    }
1134
1135    #[test]
1136    fn test_register_breakpoint_hit_with_variables_interpolates() {
1137        // Test register_breakpoint_hit_with_variables interpolates messages
1138        let (_file, source_path) = create_test_perl_file();
1139        let store = BreakpointStore::new();
1140
1141        let args = SetBreakpointsArguments {
1142            source: Source { path: Some(source_path.clone()), name: Some("script.pl".to_string()) },
1143            breakpoints: Some(vec![SourceBreakpoint {
1144                line: 10,
1145                column: None,
1146                condition: None,
1147                hit_condition: None,
1148                log_message: Some("value: {$x}, count: {$count}".to_string()),
1149            }]),
1150            source_modified: None,
1151        };
1152        let responses = store.set_breakpoints(&args);
1153        assert!(responses[0].verified);
1154
1155        // Create variable map
1156        let mut variables = std::collections::HashMap::new();
1157        variables.insert("x".to_string(), "42".to_string());
1158        variables.insert("count".to_string(), "7".to_string());
1159
1160        // Register hit with variables - should interpolate
1161        let outcome =
1162            store.register_breakpoint_hit_with_variables(&source_path, 10, Some(&variables));
1163        assert!(outcome.matched);
1164        assert!(!outcome.should_stop);
1165        assert_eq!(outcome.log_messages.len(), 1);
1166        assert_eq!(outcome.log_messages[0], "value: 42, count: 7");
1167    }
1168
1169    #[test]
1170    fn test_register_breakpoint_hit_without_variables_preserves_template() {
1171        // Test that without variables, messages are preserved as-is
1172        let (_file, source_path) = create_test_perl_file();
1173        let store = BreakpointStore::new();
1174
1175        let args = SetBreakpointsArguments {
1176            source: Source { path: Some(source_path.clone()), name: Some("script.pl".to_string()) },
1177            breakpoints: Some(vec![SourceBreakpoint {
1178                line: 10,
1179                column: None,
1180                condition: None,
1181                hit_condition: None,
1182                log_message: Some("x = {$x}".to_string()),
1183            }]),
1184            source_modified: None,
1185        };
1186        let responses = store.set_breakpoints(&args);
1187        assert!(responses[0].verified);
1188
1189        // Register hit without variables - should preserve template
1190        let outcome = store.register_breakpoint_hit(&source_path, 10);
1191        assert!(outcome.matched);
1192        assert_eq!(outcome.log_messages.len(), 1);
1193        assert_eq!(outcome.log_messages[0], "x = {$x}"); // Template preserved
1194    }
1195
1196    #[test]
1197    fn test_interpolate_logpoint_message_single_variable() {
1198        let mut vars = std::collections::HashMap::new();
1199        vars.insert("x".to_string(), "42".to_string());
1200
1201        assert_eq!(interpolate_logpoint_message("value: {$x}", &vars), "value: 42");
1202    }
1203
1204    #[test]
1205    fn test_interpolate_logpoint_message_multiple_variables() {
1206        let mut vars = std::collections::HashMap::new();
1207        vars.insert("x".to_string(), "10".to_string());
1208        vars.insert("y".to_string(), "20".to_string());
1209
1210        assert_eq!(interpolate_logpoint_message("x={$x}, y={$y}", &vars), "x=10, y=20");
1211    }
1212
1213    #[test]
1214    fn test_interpolate_logpoint_message_missing_variable() {
1215        let vars = std::collections::HashMap::new();
1216        // Variable not in map - expression should remain as-is
1217        assert_eq!(interpolate_logpoint_message("value: {$x}", &vars), "value: {$x}");
1218    }
1219
1220    #[test]
1221    fn test_interpolate_logpoint_message_no_substitution() {
1222        let mut vars = std::collections::HashMap::new();
1223        vars.insert("x".to_string(), "42".to_string());
1224
1225        // No variables to interpolate
1226        assert_eq!(interpolate_logpoint_message("simple message", &vars), "simple message");
1227    }
1228
1229    #[test]
1230    fn test_interpolate_logpoint_message_non_variable_expression() {
1231        let mut vars = std::collections::HashMap::new();
1232        vars.insert("x".to_string(), "42".to_string());
1233
1234        // Non-variable expressions are left unchanged
1235        assert_eq!(
1236            interpolate_logpoint_message("expression: {5 + 3}", &vars),
1237            "expression: {5 + 3}"
1238        );
1239    }
1240
1241    #[test]
1242    fn test_interpolate_logpoint_message_unclosed_brace() {
1243        let mut vars = std::collections::HashMap::new();
1244        vars.insert("x".to_string(), "42".to_string());
1245
1246        // Unclosed brace - keep as-is
1247        assert_eq!(interpolate_logpoint_message("value: {$x", &vars), "value: {$x");
1248    }
1249
1250    #[test]
1251    fn test_interpolate_logpoint_message_empty_braces() {
1252        let vars = std::collections::HashMap::new();
1253        // Empty braces should be kept as-is
1254        assert_eq!(interpolate_logpoint_message("value: {}", &vars), "value: {}");
1255    }
1256
1257    #[test]
1258    fn test_interpolate_logpoint_message_repeated_variable() {
1259        let mut vars = std::collections::HashMap::new();
1260        vars.insert("count".to_string(), "5".to_string());
1261
1262        // Same variable multiple times
1263        assert_eq!(
1264            interpolate_logpoint_message("count is {$count}, repeat {$count}", &vars),
1265            "count is 5, repeat 5"
1266        );
1267    }
1268
1269    #[test]
1270    fn test_interpolate_logpoint_message_variable_with_spaces_found() {
1271        // Braces with whitespace around $var — trimming finds the variable
1272        let mut vars = std::collections::HashMap::new();
1273        vars.insert("x".to_string(), "99".to_string());
1274        // { $x } trims to $x → looks up "x" → found
1275        assert_eq!(interpolate_logpoint_message("val: { $x }", &vars), "val: 99");
1276    }
1277
1278    #[test]
1279    fn test_interpolate_logpoint_message_variable_with_spaces_not_found() {
1280        // Braces with whitespace around $var — trimming applies before key lookup;
1281        // when not found the expression is re-emitted in trimmed form.
1282        let vars = std::collections::HashMap::new();
1283        // { $y } → trimmed → "$y" → not found → emitted as {$y} (trimmed, no extra spaces)
1284        assert_eq!(interpolate_logpoint_message("val: { $y }", &vars), "val: {$y}");
1285    }
1286
1287    #[test]
1288    fn test_interpolate_logpoint_message_bare_dollar_sign() {
1289        // {$} — dollar sign with empty name: not found → kept as-is
1290        let vars = std::collections::HashMap::new();
1291        assert_eq!(interpolate_logpoint_message("{$}", &vars), "{$}");
1292    }
1293
1294    #[test]
1295    fn test_interpolate_logpoint_message_array_syntax_kept_verbatim() {
1296        // {@arr} — only $scalar is interpolated; @array syntax is not handled and kept as-is
1297        let mut vars = std::collections::HashMap::new();
1298        vars.insert("arr".to_string(), "should_not_appear".to_string());
1299        assert_eq!(interpolate_logpoint_message("{@arr}", &vars), "{@arr}");
1300    }
1301
1302    #[test]
1303    fn test_validate_breakpoint_line_scenarios() {
1304        // AC:7.3
1305        let source = r#"use strict;
1306# This is a comment
1307my $x = 1;
1308
1309    
1310print "hello";
1311<<EOF;
1312heredoc content
1313EOF
1314"#;
1315        // Line 1: use strict; (Valid)
1316        let (v1, _) = validate_breakpoint_line(source, 1);
1317        assert!(v1, "Line 1 should be valid");
1318
1319        // Line 2: # comment (Invalid)
1320        let (v2, m2) = validate_breakpoint_line(source, 2);
1321        assert!(!v2, "Line 2 should be invalid");
1322        assert!(
1323            m2.as_ref().is_some_and(|s| s.contains("comment")),
1324            "Expected comment error message"
1325        );
1326
1327        // Line 4: blank line (Invalid)
1328        let (v4, m4) = validate_breakpoint_line(source, 4);
1329        assert!(!v4, "Line 4 should be invalid");
1330        assert!(
1331            m4.as_ref().is_some_and(|s| s.contains("blank")),
1332            "Expected blank line error message"
1333        );
1334
1335        // Line 5: line with whitespace (Invalid)
1336        let (v5, _) = validate_breakpoint_line(source, 5);
1337        assert!(!v5, "Line 5 should be invalid");
1338
1339        // Line 8: heredoc interior (Invalid)
1340        // Note: depends on parser support for NodeKind::Heredoc with body_span
1341        let (v8, _) = validate_breakpoint_line(source, 8);
1342        // If parser supports it, it should be invalid.
1343        // For now we just verify it doesn't panic.
1344        let _ = v8;
1345    }
1346
1347    #[test]
1348    fn test_file_paths_match_no_basename_cross_match() {
1349        // Same basename in different directories must NOT match
1350        assert!(!file_paths_match("/a/main.pl", "/b/main.pl"));
1351        assert!(!file_paths_match("/workspace/a/lib.pm", "/workspace/b/lib.pm"));
1352    }
1353
1354    #[test]
1355    fn test_file_paths_match_suffix_still_works() {
1356        // Suffix matching handles relative-vs-absolute
1357        assert!(file_paths_match("/workspace/lib/main.pl", "lib/main.pl"));
1358        assert!(file_paths_match("lib/main.pl", "/workspace/lib/main.pl"));
1359    }
1360
1361    #[test]
1362    fn test_file_paths_match_exact() {
1363        assert!(file_paths_match("/workspace/main.pl", "/workspace/main.pl"));
1364    }
1365
1366    #[test]
1367    fn test_breakpoint_hit_count_isolated_by_directory() -> Result<(), Box<dyn std::error::Error>> {
1368        // Integration: two temp files with same basename in different dirs
1369        let dir_a = must(tempfile::tempdir());
1370        let dir_b = must(tempfile::tempdir());
1371
1372        let file_a = dir_a.path().join("main.pl");
1373        let file_b = dir_b.path().join("main.pl");
1374
1375        let perl_code = "#!/usr/bin/perl\nuse strict;\nmy $x = 1;\nmy $y = 2;\nmy $z = 3;\n\
1376            print $x;\nprint $y;\nprint $z;\nmy $a = 4;\nmy $b = 5;\n\
1377            my $c = 6;\nmy $d = 7;\nmy $e = 8;\nmy $f = 9;\nmy $g = 10;\n";
1378        must(std::fs::write(&file_a, perl_code));
1379        must(std::fs::write(&file_b, perl_code));
1380
1381        let path_a = file_a.to_string_lossy().to_string();
1382        let path_b = file_b.to_string_lossy().to_string();
1383
1384        let store = BreakpointStore::new();
1385
1386        // Set breakpoints on both files at line 5
1387        let args_a = SetBreakpointsArguments {
1388            source: Source { path: Some(path_a.clone()), name: Some("main.pl".to_string()) },
1389            breakpoints: Some(vec![SourceBreakpoint {
1390                line: 5,
1391                column: None,
1392                condition: None,
1393                hit_condition: None,
1394                log_message: None,
1395            }]),
1396            source_modified: None,
1397        };
1398        let args_b = SetBreakpointsArguments {
1399            source: Source { path: Some(path_b.clone()), name: Some("main.pl".to_string()) },
1400            breakpoints: Some(vec![SourceBreakpoint {
1401                line: 5,
1402                column: None,
1403                condition: None,
1404                hit_condition: None,
1405                log_message: None,
1406            }]),
1407            source_modified: None,
1408        };
1409
1410        store.set_breakpoints(&args_a);
1411        store.set_breakpoints(&args_b);
1412
1413        // Record a hit on file_a's breakpoint
1414        store.register_breakpoint_hit(&path_a, 5);
1415
1416        // file_a's breakpoint should have hit_count=1
1417        let bps_a = store.get_breakpoints(&path_a);
1418        let bp_a = bps_a
1419            .iter()
1420            .find(|bp| bp.line == 5)
1421            .ok_or("breakpoint in file_a not found")
1422            .map_err(std::io::Error::other)?;
1423        assert_eq!(bp_a.hit_count, 1);
1424
1425        // file_b's breakpoint should still have hit_count=0
1426        let bps_b = store.get_breakpoints(&path_b);
1427        let bp_b = bps_b
1428            .iter()
1429            .find(|bp| bp.line == 5)
1430            .ok_or("breakpoint in file_b not found")
1431            .map_err(std::io::Error::other)?;
1432        assert_eq!(bp_b.hit_count, 0);
1433        Ok(())
1434    }
1435
1436    /// Two breakpoints at the same (line, column) with different conditions must each be
1437    /// validated independently.  The validation_cache is keyed by (line, column) and only
1438    /// caches the AST line-validity result; condition validation is applied per-breakpoint
1439    /// AFTER the cache lookup.  This test would fail if condition validation were accidentally
1440    /// skipped for the second breakpoint due to a cache hit.
1441    #[test]
1442    fn test_same_line_different_conditions_both_validated() {
1443        let (_file, source_path) = create_test_perl_file();
1444        let store = BreakpointStore::new();
1445
1446        let args = SetBreakpointsArguments {
1447            source: Source { path: Some(source_path.clone()), name: Some("script.pl".to_string()) },
1448            breakpoints: Some(vec![
1449                SourceBreakpoint {
1450                    line: 10,
1451                    column: None,
1452                    // No condition on the first entry — prime the cache for (line=10, col=None).
1453                    condition: None,
1454                    hit_condition: None,
1455                    log_message: None,
1456                },
1457                SourceBreakpoint {
1458                    line: 10,
1459                    column: None,
1460                    // Newline injection — must be rejected by the security guard even though
1461                    // (line=10, col=None) is already in the validation_cache from entry 1.
1462                    condition: Some("$x > 0\nB *".to_string()),
1463                    hit_condition: None,
1464                    log_message: None,
1465                },
1466            ]),
1467            source_modified: None,
1468        };
1469
1470        let result = store.set_breakpoints(&args);
1471        assert_eq!(result.len(), 2, "both breakpoints must appear in the response");
1472
1473        // Second breakpoint has a newline in the condition — security guard must fire.
1474        assert!(
1475            !result[1].verified,
1476            "breakpoint with newline in condition must be unverified (security guard applies \
1477             independently of the AST validation cache); got verified={}",
1478            result[1].verified
1479        );
1480
1481        // The stored records must also reflect two entries (both stored, second unverified).
1482        let records = store.get_breakpoints(&source_path);
1483        assert_eq!(records.len(), 2, "both records must be stored (unverified ones are kept)");
1484        assert!(!records[1].verified, "stored second record must be unverified");
1485        assert!(
1486            records[1].message.as_deref().unwrap_or("").contains("newline"),
1487            "stored record must carry the newline-rejection message"
1488        );
1489    }
1490}