1use crate::breakpoint::{AstBreakpointValidator, BreakpointValidator};
19use crate::protocol::{Breakpoint, SetBreakpointsArguments};
20use std::collections::HashMap;
21use std::sync::{Arc, Mutex};
22
23#[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#[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#[derive(Debug, Clone)]
61pub struct BreakpointRecord {
62 pub id: i64,
64 pub line: i64,
66 pub column: Option<i64>,
68 pub condition: Option<String>,
70 pub hit_condition: Option<String>,
72 pub log_message: Option<String>,
74 pub hit_count: u64,
76 pub verified: bool,
78 pub message: Option<String>,
80}
81
82impl BreakpointRecord {
83 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#[derive(Debug, Clone, Default, PartialEq, Eq)]
97pub struct BreakpointHitOutcome {
98 pub matched: bool,
100 pub should_stop: bool,
102 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
160pub fn interpolate_logpoint_message(
184 template: &str,
185 variables: &std::collections::HashMap<String, String>,
186) -> String {
187 let mut result = String::new();
191 let mut chars = template.chars().peekable();
192
193 while let Some(ch) = chars.next() {
194 if ch == '{' {
195 let mut expr = String::new();
197 let mut found_close = false;
198
199 while let Some(next_ch) = chars.peek() {
201 if *next_ch == '}' {
202 chars.next(); found_close = true;
204 break;
205 }
206 expr.push(*next_ch);
207 chars.next();
208 }
209
210 if found_close {
211 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 result.push('{');
222 result.push_str(trimmed);
223 result.push('}');
224 }
225 } else {
226 result.push('{');
228 result.push_str(&expr);
229 result.push('}');
230 }
231 } else {
232 result.push('{');
234 result.push_str(&expr);
235 }
236 } else {
237 result.push(ch);
238 }
239 }
240
241 result
242}
243
244#[derive(Debug, Clone)]
249pub struct BreakpointStore {
250 breakpoints: Arc<Mutex<HashMap<String, Vec<BreakpointRecord>>>>,
252 next_id: Arc<Mutex<i64>>,
254}
255
256impl BreakpointStore {
257 pub fn new() -> Self {
267 Self { breakpoints: Arc::new(Mutex::new(HashMap::new())), next_id: Arc::new(Mutex::new(1)) }
268 }
269
270 pub fn set_breakpoints(&self, args: &SetBreakpointsArguments) -> Vec<Breakpoint> {
307 let source_path = match &args.source.path {
309 Some(path) => path.clone(),
310 None => {
311 return Vec::new();
313 }
314 };
315
316 let source_breakpoints = args.breakpoints.as_deref().unwrap_or(&[]);
318
319 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 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 breakpoints_map.remove(&source_path);
333
334 let mut records = Vec::new();
335 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 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 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 (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 if !records.is_empty() {
466 breakpoints_map.insert(source_path.clone(), records.clone());
467 }
468
469 records.iter().map(|r| r.to_protocol()).collect()
471 }
472
473 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 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 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 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 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 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 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 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 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 if record.line >= start_line {
618 record.line += lines_delta;
619 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 fn create_test_perl_file() -> (NamedTempFile, String) {
648 let mut file = must(NamedTempFile::with_suffix(".pl"));
649 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 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 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 assert_eq!(breakpoints.len(), 2);
771 assert_eq!(breakpoints[0].line, 20);
772 assert_eq!(breakpoints[1].line, 26);
773
774 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 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 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 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 store.clear_breakpoints(source_path);
874
875 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 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 store.clear_all();
919
920 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 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 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 let store = BreakpointStore::new();
1005 let source_path = "/workspace/script.pl";
1006
1007 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 store.adjust_breakpoints_for_edit(source_path, 5, 5);
1027 assert_eq!(store.get_breakpoints(source_path)[0].line, 15);
1028
1029 store.adjust_breakpoints_for_edit(source_path, 5, -3);
1031 assert_eq!(store.get_breakpoints(source_path)[0].line, 12);
1032
1033 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 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 let outcome = store.register_breakpoint_hit(&source_path, 10);
1124 assert!(outcome.matched);
1125 assert!(!outcome.should_stop); 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 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 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 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 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 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}"); }
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 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 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 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 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 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 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 let mut vars = std::collections::HashMap::new();
1273 vars.insert("x".to_string(), "99".to_string());
1274 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 let vars = std::collections::HashMap::new();
1283 assert_eq!(interpolate_logpoint_message("val: { $y }", &vars), "val: {$y}");
1285 }
1286
1287 #[test]
1288 fn test_interpolate_logpoint_message_bare_dollar_sign() {
1289 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 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 let source = r#"use strict;
1306# This is a comment
1307my $x = 1;
1308
1309
1310print "hello";
1311<<EOF;
1312heredoc content
1313EOF
1314"#;
1315 let (v1, _) = validate_breakpoint_line(source, 1);
1317 assert!(v1, "Line 1 should be valid");
1318
1319 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 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 let (v5, _) = validate_breakpoint_line(source, 5);
1337 assert!(!v5, "Line 5 should be invalid");
1338
1339 let (v8, _) = validate_breakpoint_line(source, 8);
1342 let _ = v8;
1345 }
1346
1347 #[test]
1348 fn test_file_paths_match_no_basename_cross_match() {
1349 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 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 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 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 store.register_breakpoint_hit(&path_a, 5);
1415
1416 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 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 #[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 condition: None,
1454 hit_condition: None,
1455 log_message: None,
1456 },
1457 SourceBreakpoint {
1458 line: 10,
1459 column: None,
1460 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 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 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}