1use serde::{Deserialize, Serialize};
63
64#[derive(Debug, Clone, Serialize, Deserialize)]
66pub struct EditError {
67 pub kind: EditErrorKind,
69 pub message: String,
71 pub suggestion: Option<String>,
73 pub similar_targets: Vec<String>,
75}
76
77impl std::fmt::Display for EditError {
78 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
79 write!(f, "{}: {}", self.kind, self.message)?;
80 if let Some(ref suggestion) = self.suggestion {
81 write!(f, " (suggestion: {suggestion})")?;
82 }
83 if !self.similar_targets.is_empty() {
84 write!(f, " [similar: {}]", self.similar_targets.join(", "))?;
85 }
86 Ok(())
87 }
88}
89
90impl std::error::Error for EditError {}
91
92#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
102#[non_exhaustive]
103#[serde(rename_all = "snake_case")]
104pub enum EditErrorKind {
105 AmbiguousTarget,
107 NoMatch,
109 SyntaxInvalid,
111 ConflictingEdit,
113 ParseError,
115 GuardRejected,
117 InvalidInput,
119 TypeError,
124 OperationFailed,
126 FormatFailed,
130 AlreadyExists,
137 NotFound,
141 Conflicts,
145 ChangesDetected,
150 Binary,
156 InvalidEncoding,
160 FuzzySpanSuspicious,
166}
167
168impl std::fmt::Display for EditErrorKind {
169 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
170 match self {
171 EditErrorKind::AmbiguousTarget => write!(f, "ambiguous_target"),
172 EditErrorKind::NoMatch => write!(f, "no_match"),
173 EditErrorKind::SyntaxInvalid => write!(f, "syntax_invalid"),
174 EditErrorKind::ConflictingEdit => write!(f, "conflicting_edit"),
175 EditErrorKind::ParseError => write!(f, "parse_error"),
176 EditErrorKind::GuardRejected => write!(f, "guard_rejected"),
177 EditErrorKind::InvalidInput => write!(f, "invalid_input"),
178 EditErrorKind::TypeError => write!(f, "type_error"),
179 EditErrorKind::OperationFailed => write!(f, "operation_failed"),
180 EditErrorKind::FormatFailed => write!(f, "format_failed"),
181 EditErrorKind::AlreadyExists => write!(f, "already_exists"),
182 EditErrorKind::NotFound => write!(f, "not_found"),
183 EditErrorKind::Conflicts => write!(f, "conflicts"),
184 EditErrorKind::ChangesDetected => write!(f, "changes_detected"),
185 EditErrorKind::Binary => write!(f, "binary"),
186 EditErrorKind::InvalidEncoding => write!(f, "invalid_encoding"),
187 EditErrorKind::FuzzySpanSuspicious => write!(f, "fuzzy_span_suspicious"),
188 }
189 }
190}
191
192impl EditError {
193 pub fn new(kind: EditErrorKind, message: impl Into<String>) -> Self {
195 Self {
196 kind,
197 message: message.into(),
198 suggestion: None,
199 similar_targets: Vec::new(),
200 }
201 }
202
203 pub fn guard_rejected(detail: impl std::fmt::Display) -> anyhow::Error {
209 Self::new(
210 EditErrorKind::GuardRejected,
211 format!("path rejected by workspace guard: {detail}"),
212 )
213 .into()
214 }
215
216 pub fn with_similar(mut self, similar: Vec<String>) -> Self {
218 self.similar_targets = similar;
219 self
220 }
221
222 pub fn with_suggestion(mut self, suggestion: impl Into<String>) -> Self {
224 self.suggestion = Some(suggestion.into());
225 self
226 }
227}
228
229pub fn edit_error_kind(err: &anyhow::Error) -> Option<EditErrorKind> {
238 for cause in err.chain() {
239 if let Some(kind) = classify_error(cause) {
240 return Some(kind);
241 }
242 }
243 None
244}
245
246pub fn edit_error_ref(err: &anyhow::Error) -> Option<&EditError> {
248 for cause in err.chain() {
249 if let Some(e) = classify_error_ref(cause) {
250 return Some(e);
251 }
252 }
253 None
254}
255
256pub fn classify_error(err: &(dyn std::error::Error + 'static)) -> Option<EditErrorKind> {
272 let mut current: Option<&(dyn std::error::Error + 'static)> = Some(err);
273 while let Some(e) = current {
274 if let Some(edit) = e.downcast_ref::<EditError>() {
275 return Some(edit.kind);
276 }
277 if e.downcast_ref::<crate::exit::NoMatchError>().is_some() {
278 return Some(EditErrorKind::NoMatch);
279 }
280 if e.downcast_ref::<crate::exit::AmbiguousError>().is_some() {
281 return Some(EditErrorKind::AmbiguousTarget);
282 }
283 if e.downcast_ref::<crate::exit::InvalidInputError>().is_some() {
284 return Some(EditErrorKind::InvalidInput);
285 }
286 if e.downcast_ref::<crate::exit::AlreadyExistsError>()
287 .is_some()
288 {
289 return Some(EditErrorKind::AlreadyExists);
290 }
291 if e.downcast_ref::<crate::exit::TypeErrorError>().is_some() {
292 return Some(EditErrorKind::TypeError);
293 }
294 if e.downcast_ref::<crate::exit::ParseErrorError>().is_some() {
295 return Some(EditErrorKind::ParseError);
296 }
297 if e.downcast_ref::<crate::exit::FormatFailedError>().is_some() {
298 return Some(EditErrorKind::FormatFailed);
299 }
300 if e.downcast_ref::<crate::exit::ConflictsError>().is_some() {
301 return Some(EditErrorKind::Conflicts);
302 }
303 if e.downcast_ref::<crate::exit::ChangesDetectedError>()
304 .is_some()
305 {
306 return Some(EditErrorKind::ChangesDetected);
307 }
308 if e.downcast_ref::<crate::exit::BinaryError>().is_some() {
309 return Some(EditErrorKind::Binary);
310 }
311 if e.downcast_ref::<crate::exit::InvalidEncodingError>()
312 .is_some()
313 {
314 return Some(EditErrorKind::InvalidEncoding);
315 }
316 if e.downcast_ref::<std::io::Error>()
317 .is_some_and(|io| io.kind() == std::io::ErrorKind::NotFound)
318 {
319 return Some(EditErrorKind::NotFound);
320 }
321 current = e.source();
322 }
323 None
324}
325
326#[must_use]
335pub fn error_kind_str(err: &anyhow::Error) -> Option<&'static str> {
336 crate::exit::classify_typed_error(err).map(|(kind, _)| kind)
337}
338
339#[must_use]
345pub fn is_already_exists(err: &anyhow::Error) -> bool {
346 edit_error_kind(err) == Some(EditErrorKind::AlreadyExists)
347}
348
349#[must_use]
355pub fn is_not_found(err: &anyhow::Error) -> bool {
356 edit_error_kind(err) == Some(EditErrorKind::NotFound)
357}
358
359#[must_use]
363pub fn is_conflicts(err: &anyhow::Error) -> bool {
364 edit_error_kind(err) == Some(EditErrorKind::Conflicts)
365}
366
367#[must_use]
370pub fn is_changes_detected(err: &anyhow::Error) -> bool {
371 edit_error_kind(err) == Some(EditErrorKind::ChangesDetected)
372}
373
374#[must_use]
377pub fn is_type_error(err: &anyhow::Error) -> bool {
378 edit_error_kind(err) == Some(EditErrorKind::TypeError)
379}
380
381#[must_use]
384pub fn is_format_failed(err: &anyhow::Error) -> bool {
385 edit_error_kind(err) == Some(EditErrorKind::FormatFailed)
386}
387
388#[must_use]
391pub fn is_guard_rejected(err: &anyhow::Error) -> bool {
392 edit_error_kind(err) == Some(EditErrorKind::GuardRejected)
393}
394
395#[must_use]
398pub fn is_invalid_input(err: &anyhow::Error) -> bool {
399 edit_error_kind(err) == Some(EditErrorKind::InvalidInput)
400}
401
402#[must_use]
405pub fn is_no_match(err: &anyhow::Error) -> bool {
406 edit_error_kind(err) == Some(EditErrorKind::NoMatch)
407}
408
409#[must_use]
415pub fn is_ambiguous(err: &anyhow::Error) -> bool {
416 edit_error_kind(err) == Some(EditErrorKind::AmbiguousTarget)
417}
418
419#[must_use]
426pub fn is_binary(err: &anyhow::Error) -> bool {
427 edit_error_kind(err) == Some(EditErrorKind::Binary)
428}
429
430#[must_use]
434pub fn is_invalid_encoding(err: &anyhow::Error) -> bool {
435 edit_error_kind(err) == Some(EditErrorKind::InvalidEncoding)
436}
437
438#[must_use]
442pub fn is_fuzzy_span_suspicious(err: &anyhow::Error) -> bool {
443 edit_error_kind(err) == Some(EditErrorKind::FuzzySpanSuspicious)
444}
445
446#[derive(Debug, Clone)]
452pub struct PeeledError {
453 pub kind_str: &'static str,
455 pub message: String,
457 pub suggestion: Option<String>,
459 pub similar_targets: Vec<String>,
461}
462
463#[must_use]
465pub fn peel_error(err: &anyhow::Error) -> Option<PeeledError> {
466 let kind_str = error_kind_str(err)?;
467 let (suggestion, similar_targets) = match edit_error_ref(err) {
468 Some(e) => (e.suggestion.clone(), e.similar_targets.clone()),
469 None => (None, Vec::new()),
470 };
471 Some(PeeledError {
472 kind_str,
473 message: crate::exit::agent_error_message(err),
474 suggestion,
475 similar_targets,
476 })
477}
478
479pub fn classify_error_ref<'a>(err: &'a (dyn std::error::Error + 'static)) -> Option<&'a EditError> {
484 let mut current: Option<&'a (dyn std::error::Error + 'static)> = Some(err);
485 while let Some(e) = current {
486 if let Some(edit) = e.downcast_ref::<EditError>() {
487 return Some(edit);
488 }
489 current = e.source();
490 }
491 None
492}
493
494#[derive(Debug, Clone, Serialize, Deserialize)]
496pub struct ValidationResult {
497 pub valid: bool,
499 pub errors: Vec<String>,
501 pub warnings: Vec<String>,
503}
504
505pub fn validate_edit(
514 content: &str,
515 from: &str,
516 to: &str,
517 file_path: Option<&str>,
518) -> ValidationResult {
519 validate_edit_nth(content, from, to, file_path, None)
520}
521
522pub fn validate_edit_nth(
524 content: &str,
525 from: &str,
526 to: &str,
527 file_path: Option<&str>,
528 nth: Option<usize>,
529) -> ValidationResult {
530 if from.is_empty() {
531 return ValidationResult {
532 valid: false,
533 errors: vec!["empty search pattern".into()],
534 warnings: vec![],
535 };
536 }
537
538 if !content.contains(from) {
539 return ValidationResult {
540 valid: false,
541 errors: vec![format!(
542 "pattern '{}' not found in content",
543 truncate_str(from, 60)
544 )],
545 warnings: vec![],
546 };
547 }
548
549 let new_content = match nth {
550 Some(0) => {
551 return ValidationResult {
552 valid: false,
553 errors: vec!["nth must be >= 1 (1-based indexing)".into()],
554 warnings: vec![],
555 };
556 }
557 Some(n) => {
558 let mut count = 0usize;
560 let mut result = String::with_capacity(content.len());
561 let mut remaining = content;
562 while let Some(pos) = remaining.find(from) {
563 count += 1;
564 if count == n {
565 result.push_str(&remaining[..pos]);
566 result.push_str(to);
567 result.push_str(&remaining[pos + from.len()..]);
568 break;
569 }
570 result.push_str(&remaining[..pos + from.len()]);
571 remaining = &remaining[pos + from.len()..];
572 }
573 if count < n {
574 return ValidationResult {
575 valid: false,
576 errors: vec![format!(
577 "occurrence {n} not found (only {count} occurrence{} exist{})",
578 if count == 1 { "" } else { "s" },
579 if count == 1 { "s" } else { "" },
580 )],
581 warnings: vec![],
582 };
583 }
584 result
585 }
586 None => content.replace(from, to),
587 };
588 let mut errors = Vec::new();
589 let mut warnings = Vec::new();
590
591 if let Some(path) = file_path
593 && let Ok(fmt) = crate::ops::doc::detect_format(path)
594 {
595 let parse_err = match fmt {
596 crate::ops::doc::FileFormat::Json => {
597 serde_json::from_str::<serde_json::Value>(&new_content)
598 .err()
599 .map(|e| format!("result would be invalid JSON: {e}"))
600 }
601 crate::ops::doc::FileFormat::Yaml => {
602 serde_yaml_ng::from_str::<serde_json::Value>(&new_content)
603 .err()
604 .map(|e| format!("result would be invalid YAML: {e}"))
605 }
606 crate::ops::doc::FileFormat::Toml => {
607 toml_edit::de::from_str::<serde_json::Value>(&new_content)
608 .err()
609 .map(|e| format!("result would be invalid TOML: {e}"))
610 }
611 };
612 if let Some(msg) = parse_err {
613 errors.push(msg);
614 }
615 }
616
617 let open_parens =
619 new_content.matches('(').count() as i64 - new_content.matches(')').count() as i64;
620 let open_braces =
621 new_content.matches('{').count() as i64 - new_content.matches('}').count() as i64;
622 let open_brackets =
623 new_content.matches('[').count() as i64 - new_content.matches(']').count() as i64;
624
625 if open_parens != 0 {
626 warnings.push(format!("unbalanced parentheses (delta: {open_parens})"));
627 }
628 if open_braces != 0 {
629 warnings.push(format!("unbalanced braces (delta: {open_braces})"));
630 }
631 if open_brackets != 0 {
632 warnings.push(format!("unbalanced brackets (delta: {open_brackets})"));
633 }
634
635 ValidationResult {
636 valid: errors.is_empty(),
637 errors,
638 warnings,
639 }
640}
641
642const SIMILAR_TARGET_MIN_SCORE: f64 = 0.85;
648
649fn similar_target_length_plausible(candidate: &str, target: &str) -> bool {
655 let (ca, ta) = (candidate.len(), target.len());
656 if ca == 0 || ta == 0 {
657 return false;
658 }
659 let (shorter, longer) = if ca < ta { (ca, ta) } else { (ta, ca) };
660 shorter.saturating_mul(2) >= longer || longer - shorter <= 3
662}
663
664pub fn find_similar_targets(content: &str, target: &str, max_results: usize) -> Vec<String> {
669 if target.is_empty() || content.is_empty() {
670 return vec![];
671 }
672
673 let mut candidates: Vec<(String, f64)> = Vec::new();
674 let mut seen = std::collections::HashSet::new();
675
676 for line in content.lines() {
678 for word in extract_identifiers(line) {
679 if !seen.insert(word.clone()) {
680 continue;
681 }
682 if word == target || !similar_target_length_plausible(&word, target) {
683 continue;
684 }
685 let score = strsim::jaro_winkler(&word, target);
686 if score > SIMILAR_TARGET_MIN_SCORE {
687 candidates.push((word, score));
688 }
689 }
690 }
691
692 if target.contains(' ') || target.len() > 20 {
694 for line in content.lines() {
695 let trimmed = line.trim().to_string();
696 if trimmed.is_empty() || seen.contains(&trimmed) {
697 continue;
698 }
699 seen.insert(trimmed.clone());
700 if trimmed == target || !similar_target_length_plausible(&trimmed, target) {
701 continue;
702 }
703 let score = strsim::jaro_winkler(&trimmed, target);
704 if score > SIMILAR_TARGET_MIN_SCORE {
705 candidates.push((trimmed, score));
706 }
707 }
708 }
709
710 candidates.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
711 candidates.truncate(max_results);
712 candidates.into_iter().map(|(s, _)| s).collect()
713}
714
715pub fn anchor_match(
721 content: &str,
722 target: &str,
723 before_context: Option<&str>,
724 after_context: Option<&str>,
725) -> Option<AnchorMatchResult> {
726 if target.is_empty() {
727 return None;
728 }
729
730 if let Some(pos) = content.find(target) {
732 return Some(AnchorMatchResult {
733 matched_text: target.to_string(),
734 start_offset: pos,
735 strategy: MatchStrategy::Exact,
736 score: None,
737 });
738 }
739
740 let lines: Vec<&str> = content.lines().collect();
742 let target_lines: Vec<&str> = target.lines().collect();
743
744 let mut line_byte_starts: Vec<usize> = vec![0];
748 for (i, b) in content.bytes().enumerate() {
749 if b == b'\n' {
750 line_byte_starts.push(i + 1);
751 }
752 }
753
754 if target_lines.is_empty() {
755 return None;
756 }
757
758 let first_target = target_lines[0].trim();
759 let last_target = target_lines.last().map(|l| l.trim()).unwrap_or("");
760
761 if before_context.is_none() && after_context.is_none() {
766 return None;
767 }
768
769 for (i, line) in lines.iter().enumerate() {
771 let trimmed = line.trim();
772
773 if strsim::jaro_winkler(trimmed, first_target) < 0.85 {
775 continue;
776 }
777
778 if target_lines.len() > 1 {
781 let end_idx = i + target_lines.len();
782 if end_idx > lines.len() {
783 continue;
784 }
785 let candidate_last = lines[end_idx - 1].trim();
786 if strsim::jaro_winkler(candidate_last, last_target) < 0.85 {
787 continue;
788 }
789 }
790
791 if let Some(before) = before_context {
795 if i == 0 {
796 continue;
797 }
798 let prev = lines[i - 1].trim();
799 let before_line = before.lines().last().unwrap_or(before).trim();
800 if strsim::jaro_winkler(prev, before_line) < 0.8 {
801 continue;
802 }
803 }
804
805 if let Some(after) = after_context {
809 let end_idx = i + target_lines.len();
810 if end_idx >= lines.len() {
811 continue;
812 }
813 let next = lines[end_idx].trim();
814 let after_line = after.lines().next().unwrap_or(after).trim();
815 if strsim::jaro_winkler(next, after_line) < 0.8 {
816 continue;
817 }
818 }
819
820 let end_idx = (i + target_lines.len()).min(lines.len());
823 let start_offset = line_byte_starts[i];
824 let end_offset = if end_idx < line_byte_starts.len() {
825 line_byte_starts[end_idx]
826 } else {
827 content.len()
828 };
829 let slice = &content[start_offset..end_offset];
832 let matched_text = slice
833 .strip_suffix("\r\n")
834 .or_else(|| slice.strip_suffix('\n'))
835 .unwrap_or(slice)
836 .to_string();
837
838 return Some(AnchorMatchResult {
839 matched_text,
840 start_offset,
841 strategy: MatchStrategy::Anchor,
842 score: None,
843 });
844 }
845
846 None
847}
848
849#[derive(Debug, Clone)]
851pub struct AnchorMatchResult {
852 pub matched_text: String,
854 pub start_offset: usize,
856 pub strategy: MatchStrategy,
858 pub score: Option<f64>,
860}
861
862#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
864#[serde(rename_all = "snake_case")]
865pub enum MatchStrategy {
866 Exact,
868 Anchor,
870 Similarity,
872}
873
874impl std::fmt::Display for MatchStrategy {
875 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
876 match self {
877 MatchStrategy::Exact => write!(f, "exact"),
878 MatchStrategy::Anchor => write!(f, "anchor"),
879 MatchStrategy::Similarity => write!(f, "similarity"),
880 }
881 }
882}
883
884pub fn resolve_with_fallback(
892 content: &str,
893 target: &str,
894 before_context: Option<&str>,
895 after_context: Option<&str>,
896) -> Result<AnchorMatchResult, EditError> {
897 resolve_with_fallback_skip_exact(content, target, before_context, after_context, false)
898}
899
900pub fn resolve_with_fallback_skip_exact(
907 content: &str,
908 target: &str,
909 before_context: Option<&str>,
910 after_context: Option<&str>,
911 skip_exact: bool,
912) -> Result<AnchorMatchResult, EditError> {
913 if !skip_exact && let Some(pos) = content.find(target) {
915 return Ok(AnchorMatchResult {
916 matched_text: target.to_string(),
917 start_offset: pos,
918 strategy: MatchStrategy::Exact,
919 score: None,
920 });
921 }
922
923 if let Some(result) = anchor_match(content, target, before_context, after_context) {
925 if !(skip_exact && result.strategy == MatchStrategy::Exact) {
928 return Ok(result);
929 }
930 }
931
932 let target_lines: Vec<&str> = target.lines().collect();
939 if target_lines.len() == 1 {
940 let target_trim = target.trim();
941 if is_token_like_target(target_trim) {
942 if let Some(hit) = best_token_similarity(content, target_trim) {
943 return Ok(hit);
944 }
945 } else if let Some(hit) = best_line_similarity(content, target_trim) {
947 return Ok(hit);
948 }
949 }
950
951 let similar = find_similar_targets(content, target.lines().next().unwrap_or(target), 5);
953 let suggestion = if !similar.is_empty() {
954 Some(format!("did you mean: {}?", similar[0]))
955 } else {
956 None
957 };
958
959 Err(EditError {
960 kind: EditErrorKind::NoMatch,
961 message: format!("target not found: '{}'", truncate_str(target, 80)),
962 suggestion,
963 similar_targets: similar,
964 })
965}
966
967pub(crate) fn fuzzy_fails_min_floor(score: Option<f64>, min: f64) -> bool {
973 match score {
974 Some(s) => s < min,
975 None => true,
976 }
977}
978
979pub(crate) fn should_refuse_fuzzy_absent_old(
987 fuzzy_requested: bool,
988 is_fuzzy_mode: bool,
989 allow_absent_old: bool,
990) -> bool {
991 fuzzy_requested && is_fuzzy_mode && !allow_absent_old
992}
993
994pub(crate) fn fuzzy_absent_old_refuse_message(
996 old: &str,
997 matched_text: &str,
998 score: Option<f64>,
999) -> String {
1000 let score_s = score
1001 .map(|s| format!("{s:.3}"))
1002 .unwrap_or_else(|| "none".into());
1003 format!(
1004 "exact old absent for {:?}; best fuzzy candidate {:?} score {} \
1005 (set allow_absent_old / --allow-absent-old to apply)",
1006 truncate_str(old, 60),
1007 truncate_str(matched_text, 60),
1008 score_s
1009 )
1010}
1011
1012fn is_token_like_target(target: &str) -> bool {
1018 if target.is_empty() || target.chars().any(|c| c.is_whitespace()) {
1019 return false;
1020 }
1021 target.chars().any(|c| c.is_alphanumeric())
1025 && target.chars().all(|c| c.is_alphanumeric() || c == '_')
1026}
1027
1028fn best_token_similarity(content: &str, target: &str) -> Option<AnchorMatchResult> {
1030 let mut best_score = 0.0f64;
1031 let mut best_match = String::new();
1032 let mut best_offset = 0usize;
1033 let mut offset = 0usize;
1034 for line in content.lines() {
1035 for (ident, col) in extract_identifiers_with_offsets(line) {
1036 if ident == target {
1037 continue;
1038 }
1039 let score = strsim::jaro_winkler(&ident, target);
1040 if score > best_score {
1041 best_score = score;
1042 best_match = ident;
1043 best_offset = offset + col;
1044 }
1045 }
1046 offset = advance_line_offset(content, offset, line);
1047 }
1048 if best_score > 0.85 {
1049 Some(AnchorMatchResult {
1050 matched_text: best_match,
1051 start_offset: best_offset,
1052 strategy: MatchStrategy::Similarity,
1053 score: Some(best_score),
1054 })
1055 } else {
1056 None
1057 }
1058}
1059
1060fn best_line_similarity(content: &str, target: &str) -> Option<AnchorMatchResult> {
1065 let mut best_score = 0.0f64;
1066 let mut best_match = String::new();
1067 let mut best_offset = 0usize;
1068 let mut offset = 0usize;
1069 for line in content.lines() {
1070 let score = strsim::jaro_winkler(line.trim(), target);
1071 if score > best_score {
1072 best_score = score;
1073 best_match = line.to_string();
1074 best_offset = offset;
1075 }
1076 offset = advance_line_offset(content, offset, line);
1077 }
1078 if best_score <= 0.85 {
1079 return None;
1080 }
1081 let line_len = best_match.trim().len();
1082 let target_len = target.len().max(1);
1083 if line_len > target_len.saturating_mul(2) && line_len > target_len + 16 {
1084 return None;
1086 }
1087 Some(AnchorMatchResult {
1088 matched_text: best_match,
1089 start_offset: best_offset,
1090 strategy: MatchStrategy::Similarity,
1091 score: Some(best_score),
1092 })
1093}
1094
1095fn advance_line_offset(content: &str, mut offset: usize, line: &str) -> usize {
1096 offset += line.len();
1098 if content.as_bytes().get(offset) == Some(&b'\r') {
1099 offset += 1;
1100 }
1101 if content.as_bytes().get(offset) == Some(&b'\n') {
1102 offset += 1;
1103 }
1104 offset
1105}
1106
1107fn extract_identifiers(line: &str) -> Vec<String> {
1109 extract_identifiers_with_offsets(line)
1110 .into_iter()
1111 .map(|(s, _)| s)
1112 .collect()
1113}
1114
1115fn extract_identifiers_with_offsets(line: &str) -> Vec<(String, usize)> {
1117 let mut identifiers = Vec::new();
1118 let mut current = String::new();
1119 let mut start = 0usize;
1120 let mut i = 0usize;
1121
1122 for ch in line.chars() {
1123 let clen = ch.len_utf8();
1124 if ch.is_alphanumeric() || ch == '_' {
1125 if current.is_empty() {
1126 start = i;
1127 }
1128 current.push(ch);
1129 } else {
1130 if current.len() >= 3 {
1131 identifiers.push((std::mem::take(&mut current), start));
1132 } else {
1133 current.clear();
1134 }
1135 }
1136 i += clen;
1137 }
1138 if current.len() >= 3 {
1139 identifiers.push((current, start));
1140 }
1141
1142 identifiers
1143}
1144
1145pub(crate) fn truncate_str(s: &str, max_len: usize) -> &str {
1150 if s.len() <= max_len {
1151 s
1152 } else {
1153 let mut end = max_len;
1155 while end > 0 && !s.is_char_boundary(end) {
1156 end -= 1;
1157 }
1158 &s[..end]
1159 }
1160}
1161
1162#[cfg(test)]
1163mod tests {
1164 use super::*;
1165
1166 #[test]
1167 fn edit_error_display() {
1168 let err = EditError {
1169 kind: EditErrorKind::NoMatch,
1170 message: "target not found".into(),
1171 suggestion: Some("try 'process_request'".into()),
1172 similar_targets: vec!["process_request".into()],
1173 };
1174 let display = err.to_string();
1175 assert!(display.contains("no_match"));
1176 assert!(display.contains("target not found"));
1177 assert!(display.contains("process_request"));
1178 }
1179
1180 #[test]
1181 fn edit_error_kind_display() {
1182 assert_eq!(
1183 EditErrorKind::AmbiguousTarget.to_string(),
1184 "ambiguous_target"
1185 );
1186 assert_eq!(EditErrorKind::NoMatch.to_string(), "no_match");
1187 assert_eq!(EditErrorKind::SyntaxInvalid.to_string(), "syntax_invalid");
1188 assert_eq!(
1189 EditErrorKind::ConflictingEdit.to_string(),
1190 "conflicting_edit"
1191 );
1192 assert_eq!(EditErrorKind::ParseError.to_string(), "parse_error");
1193 assert_eq!(EditErrorKind::TypeError.to_string(), "type_error");
1194 assert_eq!(EditErrorKind::InvalidInput.to_string(), "invalid_input");
1195 assert_eq!(EditErrorKind::AlreadyExists.to_string(), "already_exists");
1196 assert_eq!(EditErrorKind::NotFound.to_string(), "not_found");
1197 assert_eq!(EditErrorKind::Conflicts.to_string(), "conflicts");
1198 assert_eq!(
1199 EditErrorKind::ChangesDetected.to_string(),
1200 "changes_detected"
1201 );
1202 assert_eq!(EditErrorKind::FormatFailed.to_string(), "format_failed");
1203 assert_eq!(EditErrorKind::Binary.to_string(), "binary");
1204 assert_eq!(
1205 EditErrorKind::InvalidEncoding.to_string(),
1206 "invalid_encoding"
1207 );
1208 }
1209
1210 #[test]
1213 fn edit_error_kind_stable_discriminants_since_0_18_0() {
1214 assert_eq!(EditErrorKind::AmbiguousTarget as u8, 0);
1215 assert_eq!(EditErrorKind::NoMatch as u8, 1);
1216 assert_eq!(EditErrorKind::SyntaxInvalid as u8, 2);
1217 assert_eq!(EditErrorKind::ConflictingEdit as u8, 3);
1218 assert_eq!(EditErrorKind::ParseError as u8, 4);
1219 assert_eq!(EditErrorKind::GuardRejected as u8, 5);
1220 assert_eq!(EditErrorKind::InvalidInput as u8, 6);
1221 assert_eq!(EditErrorKind::TypeError as u8, 7);
1222 assert_eq!(EditErrorKind::OperationFailed as u8, 8);
1223 assert_eq!(EditErrorKind::FormatFailed as u8, 9);
1224 assert_eq!(EditErrorKind::AlreadyExists as u8, 10);
1226 assert_eq!(EditErrorKind::NotFound as u8, 11);
1227 assert_eq!(EditErrorKind::Conflicts as u8, 12);
1228 assert_eq!(EditErrorKind::ChangesDetected as u8, 13);
1229 assert_eq!(EditErrorKind::Binary as u8, 14);
1231 assert_eq!(EditErrorKind::InvalidEncoding as u8, 15);
1232 assert_eq!(EditErrorKind::FuzzySpanSuspicious as u8, 16);
1234 }
1235
1236 #[test]
1237 fn classify_error_peels_edit_error_and_typed() {
1238 use std::error::Error;
1239 let e = EditError::new(EditErrorKind::AmbiguousTarget, "many");
1240 assert_eq!(
1241 classify_error(&e as &(dyn Error + 'static)),
1242 Some(EditErrorKind::AmbiguousTarget)
1243 );
1244 let e = crate::exit::NoMatchError { msg: "none".into() };
1245 assert_eq!(
1246 classify_error(&e as &(dyn Error + 'static)),
1247 Some(EditErrorKind::NoMatch)
1248 );
1249 let e = crate::exit::FormatFailedError::new("fmt");
1250 assert_eq!(
1251 classify_error(&e as &(dyn Error + 'static)),
1252 Some(EditErrorKind::FormatFailed)
1253 );
1254 let e = crate::exit::TypeErrorError {
1255 msg: "parent is an array".into(),
1256 };
1257 assert_eq!(
1258 classify_error(&e as &(dyn Error + 'static)),
1259 Some(EditErrorKind::TypeError)
1260 );
1261 let e = crate::exit::AlreadyExistsError {
1262 msg: "exists".into(),
1263 };
1264 assert_eq!(
1265 classify_error(&e as &(dyn Error + 'static)),
1266 Some(EditErrorKind::AlreadyExists)
1267 );
1268 let e = crate::exit::ConflictsError {
1269 msg: "conflict".into(),
1270 };
1271 assert_eq!(
1272 classify_error(&e as &(dyn Error + 'static)),
1273 Some(EditErrorKind::Conflicts)
1274 );
1275 let e = crate::exit::ChangesDetectedError {
1276 msg: "would change".into(),
1277 };
1278 assert_eq!(
1279 classify_error(&e as &(dyn Error + 'static)),
1280 Some(EditErrorKind::ChangesDetected)
1281 );
1282 let e = std::io::Error::new(std::io::ErrorKind::NotFound, "gone");
1283 assert_eq!(
1284 classify_error(&e as &(dyn Error + 'static)),
1285 Some(EditErrorKind::NotFound)
1286 );
1287 }
1288
1289 #[test]
1290 fn edit_error_kind_maps_exit_typed_errors() {
1291 let invalid: anyhow::Error = crate::exit::InvalidInputError {
1292 msg: "empty search pattern".into(),
1293 }
1294 .into();
1295 assert_eq!(edit_error_kind(&invalid), Some(EditErrorKind::InvalidInput));
1296 assert!(is_invalid_input(&invalid));
1297 assert!(!is_guard_rejected(&invalid));
1298
1299 let no_match: anyhow::Error = crate::exit::NoMatchError {
1300 msg: "no matches".into(),
1301 }
1302 .into();
1303 assert_eq!(edit_error_kind(&no_match), Some(EditErrorKind::NoMatch));
1304 assert!(is_no_match(&no_match));
1305 assert_eq!(error_kind_str(&no_match), Some("no_matches"));
1306 assert!(!is_not_found(&no_match));
1307
1308 let ambiguous: anyhow::Error = crate::exit::AmbiguousError {
1309 msg: "multiple matches".into(),
1310 }
1311 .into();
1312 assert_eq!(
1313 edit_error_kind(&ambiguous),
1314 Some(EditErrorKind::AmbiguousTarget)
1315 );
1316 assert!(is_ambiguous(&ambiguous));
1317 assert_eq!(error_kind_str(&ambiguous), Some("ambiguous"));
1318 assert!(!is_no_match(&ambiguous));
1319
1320 let parse: anyhow::Error = crate::exit::ParseErrorError {
1321 msg: "bad yaml".into(),
1322 }
1323 .into();
1324 assert_eq!(edit_error_kind(&parse), Some(EditErrorKind::ParseError));
1325
1326 let exists: anyhow::Error = crate::exit::AlreadyExistsError {
1327 msg: "file already exists".into(),
1328 }
1329 .into();
1330 assert_eq!(
1331 edit_error_kind(&exists),
1332 Some(EditErrorKind::AlreadyExists),
1333 "AlreadyExistsError must not collapse to InvalidInput (#1947)"
1334 );
1335 assert!(is_already_exists(&exists));
1336 assert_eq!(error_kind_str(&exists), Some("already_exists"));
1337
1338 let exists_edit: anyhow::Error =
1339 EditError::new(EditErrorKind::AlreadyExists, "dest taken").into();
1340 assert!(
1341 is_already_exists(&exists_edit),
1342 "is_already_exists must match EditErrorKind::AlreadyExists too"
1343 );
1344 assert_eq!(
1345 edit_error_kind(&exists_edit),
1346 Some(EditErrorKind::AlreadyExists)
1347 );
1348
1349 let type_err: anyhow::Error = crate::exit::TypeErrorError {
1350 msg: "parent is an array, not an object".into(),
1351 }
1352 .into();
1353 assert_eq!(
1354 edit_error_kind(&type_err),
1355 Some(EditErrorKind::TypeError),
1356 "TypeErrorError must not collapse to InvalidInput (#1883)"
1357 );
1358 assert!(is_type_error(&type_err));
1359 assert!(!is_invalid_input(&type_err));
1360
1361 let format_failed: anyhow::Error =
1362 crate::exit::FormatFailedError::new("format command failed").into();
1363 assert_eq!(
1364 edit_error_kind(&format_failed),
1365 Some(EditErrorKind::FormatFailed)
1366 );
1367 assert!(is_format_failed(&format_failed));
1368 assert!(!is_type_error(&format_failed));
1369
1370 let guard: anyhow::Error =
1371 EditError::new(EditErrorKind::GuardRejected, "path escapes").into();
1372 assert!(is_guard_rejected(&guard));
1373 assert_eq!(error_kind_str(&guard), Some("guard_rejected"));
1374 assert!(!is_invalid_input(&guard));
1375
1376 let not_found: anyhow::Error =
1377 std::io::Error::new(std::io::ErrorKind::NotFound, "missing").into();
1378 let not_found = not_found.context("failed to read path");
1379 assert_eq!(
1380 edit_error_kind(¬_found),
1381 Some(EditErrorKind::NotFound),
1382 "IO NotFound must peel as NotFound not OperationFailed"
1383 );
1384 assert_eq!(error_kind_str(¬_found), Some("not_found"));
1385 assert!(
1386 is_not_found(¬_found),
1387 "is_not_found must match IO NotFound peel"
1388 );
1389 assert!(!is_already_exists(¬_found));
1390
1391 let conflicts: anyhow::Error = crate::exit::ConflictsError {
1392 msg: "merge conflict".into(),
1393 }
1394 .into();
1395 assert_eq!(edit_error_kind(&conflicts), Some(EditErrorKind::Conflicts));
1396 assert_eq!(error_kind_str(&conflicts), Some("conflicts"));
1397 assert!(is_conflicts(&conflicts));
1398 assert!(!is_not_found(&conflicts));
1399
1400 let changes: anyhow::Error = crate::exit::ChangesDetectedError {
1401 msg: "would change".into(),
1402 }
1403 .into();
1404 assert_eq!(
1405 edit_error_kind(&changes),
1406 Some(EditErrorKind::ChangesDetected)
1407 );
1408 assert_eq!(error_kind_str(&changes), Some("changes_detected"));
1409 assert!(is_changes_detected(&changes));
1410 assert!(!is_conflicts(&changes));
1411
1412 let wrapped = invalid.context("operation 1 (replace) failed");
1414 assert_eq!(edit_error_kind(&wrapped), Some(EditErrorKind::InvalidInput));
1415
1416 let type_wrapped = type_err.context("operation 1 (doc.set) failed");
1417 assert_eq!(
1418 edit_error_kind(&type_wrapped),
1419 Some(EditErrorKind::TypeError)
1420 );
1421
1422 let plain = anyhow::anyhow!("plain error");
1423 assert_eq!(edit_error_kind(&plain), None);
1424 }
1425
1426 #[test]
1427 fn validate_edit_empty_pattern() {
1428 let result = validate_edit("content", "", "replacement", None);
1429 assert!(!result.valid);
1430 assert!(result.errors[0].contains("empty search pattern"));
1431 }
1432
1433 #[test]
1434 fn validate_edit_pattern_not_found() {
1435 let result = validate_edit("hello world", "missing", "replacement", None);
1436 assert!(!result.valid);
1437 assert!(result.errors[0].contains("not found"));
1438 }
1439
1440 #[test]
1441 fn validate_edit_valid_replacement() {
1442 let result = validate_edit("hello world", "hello", "goodbye", None);
1443 assert!(result.valid);
1444 assert!(result.errors.is_empty());
1445 }
1446
1447 #[test]
1448 fn validate_edit_json_syntax_check() {
1449 let json = r#"{"key": "value"}"#;
1450 let result = validate_edit(json, "value", "new_value", Some("config.json"));
1452 assert!(result.valid);
1453
1454 let result = validate_edit(json, "\"key\":", "broken", Some("config.json"));
1456 assert!(!result.valid);
1457 assert!(result.errors[0].contains("invalid JSON"));
1458 }
1459
1460 #[test]
1461 fn validate_edit_yaml_syntax_check() {
1462 let yaml = "key: value\n";
1463 let result = validate_edit(yaml, "value", "new_value", Some("config.yaml"));
1464 assert!(result.valid);
1465 }
1466
1467 #[test]
1468 fn validate_edit_warns_unbalanced_braces() {
1469 let content = "fn main() { hello }";
1470 let result = validate_edit(content, "{ hello }", "{ hello", None);
1471 assert!(result.valid); assert!(
1473 result
1474 .warnings
1475 .iter()
1476 .any(|w| w.contains("unbalanced braces"))
1477 );
1478 }
1479
1480 #[test]
1481 fn find_similar_targets_finds_typos() {
1482 let content = "fn process_request() {}\nfn process_response() {}\nfn handle_error() {}\n";
1483 let similar = find_similar_targets(content, "process_requst", 3);
1484 assert!(!similar.is_empty());
1485 assert!(similar.iter().any(|s| s.contains("process_request")));
1486 }
1487
1488 #[test]
1489 fn find_similar_targets_rejects_length_skew_noise() {
1490 let content = "fn nold() {}\nlet compute = 1;\nfn process_request() {}\n";
1493 let similar = find_similar_targets(content, "this_string_should_not_exist_xyzzy", 5);
1494 assert!(
1495 similar.is_empty(),
1496 "unrelated long pattern must not suggest short tokens: {similar:?}"
1497 );
1498 let similar = find_similar_targets(content, "completely_bogus_token_zzz", 5);
1499 assert!(
1500 !similar
1501 .iter()
1502 .any(|s| s == "compute" || s == "let" || s == "nold"),
1503 "bogus pattern must not surface weak short-token hints: {similar:?}"
1504 );
1505 }
1506
1507 #[test]
1508 fn find_similar_targets_empty_content() {
1509 let similar = find_similar_targets("", "target", 3);
1510 assert!(similar.is_empty());
1511 }
1512
1513 #[test]
1514 fn find_similar_targets_empty_target() {
1515 let similar = find_similar_targets("content", "", 3);
1516 assert!(similar.is_empty());
1517 }
1518
1519 #[test]
1520 fn anchor_match_exact() {
1521 let content = "line1\nline2\nline3\n";
1522 let result = anchor_match(content, "line2", None, None).unwrap();
1523 assert_eq!(result.matched_text, "line2");
1524 assert_eq!(result.strategy, MatchStrategy::Exact);
1525 }
1526
1527 #[test]
1528 fn anchor_match_with_context() {
1529 let content = "fn setup() {}\nfn proccess_data(x: i32) {}\nfn cleanup() {}\n";
1531 let result = anchor_match(
1532 content,
1533 "fn process_data(x: i32) {}",
1534 Some("fn setup() {}"),
1535 Some("fn cleanup() {}"),
1536 );
1537 let r = result.expect("anchor match should find a fuzzy match");
1538 assert_eq!(r.strategy, MatchStrategy::Anchor);
1539 assert!(r.matched_text.contains("proccess_data"));
1540 }
1541
1542 #[test]
1543 fn anchor_match_no_match() {
1544 let content = "completely different content\n";
1545 let result = anchor_match(content, "not here at all", None, None);
1546 assert!(result.is_none());
1547 }
1548
1549 #[test]
1550 fn anchor_match_requires_context_for_fuzzy() {
1551 let content = "fn process_request(x: i32) {}\n";
1555 let result = anchor_match(content, "fn process_requst(x: i32) {}", None, None);
1556 assert!(result.is_none());
1557 }
1558
1559 #[test]
1560 fn anchor_match_multi_line_verifies_last_line() {
1561 let content = "fn setup() {}\nfn process(x: i32) {}\nfn teardown() {}\nfn other() {}\n";
1564 let result = anchor_match(
1566 content,
1567 "fn process(x: i32) {}\nfn completely_wrong() {}",
1568 Some("fn setup() {}"),
1569 None,
1570 );
1571 assert!(result.is_none());
1572 }
1573
1574 #[test]
1575 fn anchor_match_crlf_offset() {
1576 let content = "line1\r\nline2\r\nline3\r\n";
1577 let result = anchor_match(content, "line2", Some("line1"), None).unwrap();
1578 assert_eq!(result.start_offset, 7);
1580 }
1581
1582 #[test]
1586 fn anchor_match_crlf_matched_text_preserves_endings() {
1587 let content =
1588 "fn setup() {}\r\nfn proccess_data(x: i32) {}\r\nfn more() {}\r\nfn cleanup() {}\r\n";
1589 let result = anchor_match(
1590 content,
1591 "fn process_data(x: i32) {}\nfn more() {}",
1592 Some("fn setup() {}"),
1593 Some("fn cleanup() {}"),
1594 )
1595 .unwrap();
1596 let end = result.start_offset + result.matched_text.len();
1598 assert_eq!(
1599 &content[result.start_offset..end],
1600 result.matched_text,
1601 "matched_text must be an exact slice of the original content"
1602 );
1603 assert!(
1605 result.matched_text.contains("\r\n"),
1606 "matched text should preserve CRLF line endings"
1607 );
1608 }
1609
1610 #[test]
1613 fn anchor_match_mixed_endings_correct_offset() {
1614 let content = "header\r\nfn proccess(x: i32) {}\nfooter\n";
1615 let result = anchor_match(
1616 content,
1617 "fn process(x: i32) {}",
1618 Some("header"),
1619 Some("footer"),
1620 )
1621 .unwrap();
1622 assert_eq!(result.start_offset, 8);
1624 let end = result.start_offset + result.matched_text.len();
1625 assert_eq!(&content[result.start_offset..end], result.matched_text);
1626 }
1627
1628 #[test]
1629 fn resolve_with_fallback_exact_match() {
1630 let content = "fn hello() {}\n";
1631 let result = resolve_with_fallback(content, "fn hello()", None, None).unwrap();
1632 assert_eq!(result.strategy, MatchStrategy::Exact);
1633 }
1634
1635 #[test]
1638 fn resolve_with_fallback_skip_exact_rejects_substring() {
1639 let content = "process_data process_data_extra\n";
1640 let bare = resolve_with_fallback(content, "process_dat", None, None).unwrap();
1642 assert_eq!(bare.strategy, MatchStrategy::Exact);
1643 assert_eq!(bare.matched_text, "process_dat");
1644
1645 match resolve_with_fallback_skip_exact(content, "process_dat", None, None, true) {
1648 Ok(hit) => {
1649 assert_ne!(
1650 hit.strategy,
1651 MatchStrategy::Exact,
1652 "skip_exact must not return Exact"
1653 );
1654 assert_ne!(
1655 hit.matched_text, "process_dat",
1656 "must not re-accept word_boundary-rejected substring"
1657 );
1658 }
1659 Err(e) => assert_eq!(e.kind, EditErrorKind::NoMatch),
1660 }
1661 }
1662
1663 #[test]
1667 fn resolve_with_fallback_similarity_crlf_offset() {
1668 let content = "fn alpha() {}\r\nfn process_requets(data: &str) {}\r\nfn gamma() {}\r\n";
1669 let result =
1670 resolve_with_fallback(content, "fn process_requests(data: &str) {}", None, None)
1671 .unwrap();
1672 assert_eq!(result.strategy, MatchStrategy::Similarity);
1673 assert_eq!(result.start_offset, 15);
1675 assert!(
1677 content[result.start_offset..].starts_with(&result.matched_text),
1678 "start_offset must point to matched_text in content"
1679 );
1680 }
1681
1682 #[test]
1683 fn resolve_with_fallback_similarity_match() {
1684 let content = "fn process_request(data: &str) -> Result<()> {\n Ok(())\n}\n";
1689 let result = resolve_with_fallback(
1690 content,
1691 "fn process_requets(data: &str) -> Result<()> {",
1692 None,
1693 None,
1694 );
1695 let r = result.expect("similarity fallback should succeed");
1696 assert_eq!(r.strategy, MatchStrategy::Similarity);
1697 assert!(
1699 r.matched_text.contains("process_request"),
1700 "snippet match: {:?}",
1701 r.matched_text
1702 );
1703 }
1704
1705 #[test]
1707 fn resolve_token_typo_does_not_match_whole_line() {
1708 let content = "const CONFIGURATION_VALUE_PRIMARY: i32 = 1;\nfn use_it() -> i32 { CONFIGURATION_VALUE_PRIMARY }\n";
1709 let r = resolve_with_fallback(content, "CONFIGURATION_VALUE_PRIMRY", None, None)
1710 .expect("token typo should fuzzy-match the identifier");
1711 assert_eq!(r.strategy, MatchStrategy::Similarity);
1712 assert_eq!(
1713 r.matched_text, "CONFIGURATION_VALUE_PRIMARY",
1714 "must match the identifier token only, not the whole line"
1715 );
1716 assert!(
1717 content[r.start_offset..].starts_with("CONFIGURATION_VALUE_PRIMARY"),
1718 "offset must point at the token"
1719 );
1720 assert!(!r.matched_text.contains("const"));
1722 assert!(!r.matched_text.contains("i32"));
1723 }
1724
1725 #[test]
1727 fn resolve_full_line_snippet_still_matches_line() {
1728 let content = "const FOO: i32 = 1;\n";
1729 let r = resolve_with_fallback(content, "const FO: i32 = 1;", None, None)
1730 .expect("near-full-line snippet should match the line");
1731 assert_eq!(r.strategy, MatchStrategy::Similarity);
1732 assert!(
1733 r.matched_text.contains("const") && r.matched_text.contains("FOO"),
1734 "line match: {:?}",
1735 r.matched_text
1736 );
1737 }
1738
1739 #[test]
1741 fn resolve_token_typo_matrix_preserves_non_identifier_syntax() {
1742 let cases: &[(&str, &str, &str, &[&str])] = &[
1744 (
1746 "const CONFIGURATION_VALUE_PRIMARY: i32 = 1;\nfn use_it() -> i32 { CONFIGURATION_VALUE_PRIMARY }\n",
1747 "CONFIGURATION_VALUE_PRIMRY",
1748 "CONFIGURATION_VALUE_PRIMARY",
1749 &["const", "i32", "fn"],
1750 ),
1751 (
1753 "fn process_request(data: &str) -> Result<()> {\n Ok(())\n}\n",
1754 "process_requets",
1755 "process_request",
1756 &["fn", "Result", "data"],
1757 ),
1758 (
1760 "def load_configuration_value():\n return 1\n",
1761 "load_configration_value",
1762 "load_configuration_value",
1763 &["def", "return"],
1764 ),
1765 (
1767 "const getUserProfile = () => null;\nexport { getUserProfile };\n",
1768 "getUserProfle",
1769 "getUserProfile",
1770 &["const", "export"],
1771 ),
1772 (
1774 "server_port_primary: 8080\n",
1775 "server_port_primry",
1776 "server_port_primary",
1777 &[":", "8080"],
1778 ),
1779 (
1781 " obj.fetchUserDetails(id);\n",
1782 "fetchUserDetials",
1783 "fetchUserDetails",
1784 &["obj", "id"],
1785 ),
1786 ];
1787
1788 for (content, typo, expected, forbidden) in cases {
1789 let r = resolve_with_fallback(content, typo, None, None)
1790 .unwrap_or_else(|e| panic!("token typo {typo:?} should match in {content:?}: {e}"));
1791 assert_eq!(r.strategy, MatchStrategy::Similarity, "typo={typo:?}");
1792 assert_eq!(
1793 r.matched_text, *expected,
1794 "typo={typo:?} content={content:?}"
1795 );
1796 assert!(
1797 content[r.start_offset..].starts_with(expected),
1798 "offset wrong for {typo:?}"
1799 );
1800 for bad in *forbidden {
1801 assert!(
1802 !r.matched_text.contains(bad),
1803 "span leaked {bad:?} for typo={typo:?} match={:?}",
1804 r.matched_text
1805 );
1806 }
1807 assert!(
1809 !r.matched_text.contains(' '),
1810 "token match must not contain spaces: {:?}",
1811 r.matched_text
1812 );
1813 }
1814 }
1815
1816 #[test]
1817 fn resolve_token_typo_picks_first_best_occurrence() {
1818 let content = "FOO_PRIMARY=1\nFOO_PRIMARY=2\n";
1819 let r = resolve_with_fallback(content, "FOO_PRIMRY", None, None).unwrap();
1820 assert_eq!(r.matched_text, "FOO_PRIMARY");
1821 assert_eq!(r.start_offset, 0);
1823 }
1824
1825 #[test]
1826 fn resolve_token_unrelated_is_no_match() {
1827 let content = "const ALPHA: i32 = 1;\nconst BETA: i32 = 2;\n";
1828 let err =
1829 resolve_with_fallback(content, "ZZZZ_COMPLETELY_UNRELATED", None, None).unwrap_err();
1830 assert_eq!(err.kind, EditErrorKind::NoMatch);
1831 }
1832
1833 #[test]
1834 fn is_token_like_target_classification() {
1835 assert!(is_token_like_target("CONFIGURATION_VALUE_PRIMRY"));
1836 assert!(is_token_like_target("getUserProfile"));
1837 assert!(!is_token_like_target("kebab-case-name"));
1839 assert!(!is_token_like_target("fn process_data() {}"));
1840 assert!(!is_token_like_target("const FOO: i32 = 1;"));
1841 assert!(!is_token_like_target("server.port"));
1842 assert!(!is_token_like_target(""));
1843 assert!(!is_token_like_target(" "));
1844 }
1845
1846 #[test]
1847 fn fuzzy_fails_min_floor_fail_closed_without_score() {
1848 assert!(fuzzy_fails_min_floor(None, 0.8));
1849 assert!(fuzzy_fails_min_floor(Some(0.5), 0.8));
1850 assert!(!fuzzy_fails_min_floor(Some(0.9), 0.8));
1851 assert!(!fuzzy_fails_min_floor(Some(0.8), 0.8));
1852 }
1853
1854 #[test]
1856 fn resolve_kebab_case_uses_line_similarity_not_broken_token_path() {
1857 let content = "font-weight-primary: bold;\n";
1858 let r = resolve_with_fallback(content, "font-weight-primry: bold;", None, None)
1860 .expect("kebab line snippet should match via line similarity");
1861 assert_eq!(r.strategy, MatchStrategy::Similarity);
1862 assert!(
1863 r.matched_text.contains("font-weight-primary"),
1864 "{:?}",
1865 r.matched_text
1866 );
1867 }
1868
1869 #[test]
1870 fn resolve_with_fallback_structured_error() {
1871 let content = "fn alpha() {}\nfn beta() {}\n";
1872 let result = resolve_with_fallback(content, "fn completely_unrelated_xyz()", None, None);
1873 let err = result.unwrap_err();
1874 assert_eq!(err.kind, EditErrorKind::NoMatch);
1875 assert!(err.message.contains("target not found"));
1876 }
1877
1878 #[test]
1879 fn resolve_with_fallback_anchor_over_similarity() {
1880 let content = "fn setup() {}\nfn proces_data(x: i32) {}\nfn cleanup() {}\n";
1883 let result = resolve_with_fallback(
1884 content,
1885 "fn process_data(x: i32) {}",
1886 Some("fn setup() {}"),
1887 Some("fn cleanup() {}"),
1888 );
1889 let r = result.expect("anchor fallback should succeed");
1890 assert_eq!(r.strategy, MatchStrategy::Anchor);
1891 }
1892
1893 #[test]
1894 fn edit_error_serializes_to_json() {
1895 let err = EditError {
1896 kind: EditErrorKind::AmbiguousTarget,
1897 message: "found 3 matches".into(),
1898 suggestion: Some("use --nth to select one".into()),
1899 similar_targets: vec!["match1".into(), "match2".into()],
1900 };
1901 let json = serde_json::to_string(&err).unwrap();
1902 let deserialized: EditError = serde_json::from_str(&json).unwrap();
1903 assert_eq!(deserialized.kind, EditErrorKind::AmbiguousTarget);
1904 assert_eq!(deserialized.similar_targets.len(), 2);
1905 }
1906
1907 #[test]
1908 fn match_strategy_display() {
1909 assert_eq!(MatchStrategy::Exact.to_string(), "exact");
1910 assert_eq!(MatchStrategy::Anchor.to_string(), "anchor");
1911 assert_eq!(MatchStrategy::Similarity.to_string(), "similarity");
1912 }
1913
1914 #[test]
1915 fn extract_identifiers_from_code() {
1916 let line = "fn process_request(data: &str) -> Result<()> {";
1917 let ids = extract_identifiers(line);
1918 assert!(ids.contains(&"process_request".to_string()));
1919 assert!(ids.contains(&"data".to_string()));
1920 assert!(ids.contains(&"str".to_string()));
1921 assert!(ids.contains(&"Result".to_string()));
1922 }
1923
1924 #[test]
1925 fn validation_result_serializes() {
1926 let result = ValidationResult {
1927 valid: true,
1928 errors: vec![],
1929 warnings: vec!["some warning".into()],
1930 };
1931 let json = serde_json::to_string(&result).unwrap();
1932 let deserialized: ValidationResult = serde_json::from_str(&json).unwrap();
1933 assert!(deserialized.valid);
1934 assert_eq!(deserialized.warnings.len(), 1);
1935 }
1936
1937 #[test]
1938 fn truncate_safe_on_multibyte_utf8() {
1939 let s = "café";
1941 assert_eq!(s.len(), 5);
1942 let t = truncate_str(s, 4);
1945 assert_eq!(t, "caf");
1946 assert_eq!(truncate_str(s, 5), "café");
1948 assert_eq!(truncate_str(s, 3), "caf");
1950 assert_eq!(truncate_str(s, 0), "");
1952 }
1953
1954 #[test]
1955 fn validate_edit_toml_syntax_check() {
1956 let toml = "[database]\nhost = \"localhost\"\n";
1957 let result = validate_edit(toml, "localhost", "remotehost", Some("config.toml"));
1959 assert!(result.valid);
1960
1961 let result = validate_edit(
1963 toml,
1964 "[database]",
1965 "not valid toml {{{{",
1966 Some("config.toml"),
1967 );
1968 assert!(!result.valid);
1969 assert!(result.errors[0].contains("invalid TOML"));
1970 }
1971
1972 #[test]
1973 fn find_similar_targets_multi_word_pattern() {
1974 let content = "fn process_all_requests(data: &str) -> bool {\n true\n}\n";
1975 let similar = find_similar_targets(content, "fn process_all_reqests(data: &str)", 3);
1977 assert!(
1978 !similar.is_empty(),
1979 "should find similar multi-word targets"
1980 );
1981 }
1982
1983 #[test]
1984 fn resolve_fallback_multi_line_no_similarity() {
1985 let content = "fn alpha() {}\nfn beta() {}\nfn gamma() {}\n";
1987 let result = resolve_with_fallback(content, "fn alphax() {}\nfn betax() {}", None, None);
1988 assert!(
1989 result.is_err(),
1990 "multi-line targets without context should not match via similarity"
1991 );
1992 let err = result.unwrap_err();
1993 assert_eq!(err.kind, EditErrorKind::NoMatch);
1994 }
1995
1996 #[test]
1997 fn resolve_with_fallback_multi_line_anchor_match() {
1998 let content = "fn header() {}\nfn proccess_data(x: i32) {\n x + 1\n}\nfn footer() {}\n";
2001 let result = resolve_with_fallback(
2002 content,
2003 "fn process_data(x: i32) {\n x + 1\n}",
2004 Some("fn header() {}"),
2005 Some("fn footer() {}"),
2006 );
2007 let r = result.expect("multi-line anchor should succeed");
2008 assert_eq!(r.strategy, MatchStrategy::Anchor);
2009 assert!(r.matched_text.contains("proccess_data"));
2010 assert!(r.matched_text.contains("x + 1"));
2011 }
2012
2013 #[test]
2014 fn validate_edit_yml_extension() {
2015 let yaml = "key: value\nlist:\n - item1\n";
2017 let result = validate_edit(yaml, "value", "new_value", Some("config.yml"));
2019 assert!(result.valid);
2020
2021 let result = validate_edit(yaml, "key: value", ":\n :\n - :", Some("config.yml"));
2023 assert!(
2024 !result.valid,
2025 ".yml extension should trigger YAML validation"
2026 );
2027 assert!(
2028 result.errors[0].contains("invalid YAML"),
2029 "expected YAML error, got: {}",
2030 result.errors[0]
2031 );
2032 }
2033
2034 #[test]
2037 fn validate_edit_nth_replaces_only_nth_occurrence() {
2038 let json = r#"{"a": "val", "b": "val"}"#;
2040 let result = validate_edit_nth(json, "val", "new", Some("data.json"), Some(1));
2041 assert!(result.valid, "nth=1 should produce valid JSON");
2042 }
2043
2044 #[test]
2045 fn validate_edit_nth_none_replaces_all() {
2046 let content = "aXbXc";
2048 let result = validate_edit_nth(content, "X", "Y", None, None);
2049 assert!(result.valid);
2050 }
2051
2052 #[test]
2053 fn validate_edit_nth_out_of_range() {
2054 let content = "aXbXc";
2056 let result = validate_edit_nth(content, "X", "Y", None, Some(5));
2057 assert!(
2058 !result.valid,
2059 "nth beyond occurrence count should be invalid"
2060 );
2061 assert!(
2062 result.errors[0].contains("occurrence 5 not found"),
2063 "error should mention the missing occurrence: {:?}",
2064 result.errors
2065 );
2066 }
2067
2068 #[test]
2069 fn validate_edit_nth_zero_rejected() {
2070 let content = r#"{"a": "val"}"#;
2072 let result = validate_edit_nth(content, "val", "X", Some("f.json"), Some(0));
2073 assert!(!result.valid);
2074 assert!(result.errors[0].contains("nth must be >= 1"));
2075 }
2076
2077 #[test]
2078 fn validate_edit_nth_detects_invalid_json_for_single_occurrence() {
2079 let json = r#"{"a": "value", "b": "value"}"#;
2081 let result = validate_edit_nth(json, "\"value\"", "broken}", Some("config.json"), Some(1));
2082 assert!(!result.valid, "nth=1 should detect broken JSON");
2083 }
2084
2085 #[test]
2088 fn anchor_match_after_only_context() {
2089 let content = "fn setup() {}\nfn proccess_data(x: i32) {}\nfn cleanup() {}\n";
2092 let result = anchor_match(
2093 content,
2094 "fn process_data(x: i32) {}",
2095 None,
2096 Some("fn cleanup() {}"),
2097 );
2098 let r = result.expect("after-only context should find anchor match");
2099 assert_eq!(r.strategy, MatchStrategy::Anchor);
2100 assert!(r.matched_text.contains("proccess_data"));
2101 }
2102
2103 #[test]
2104 fn anchor_match_multiple_candidates_returns_first() {
2105 let content = "fn header() {}\nfn proccess(x: i32) {}\nfn middle() {}\nfn proccess(y: bool) {}\nfn footer() {}\n";
2107 let result = anchor_match(
2108 content,
2109 "fn process(x: i32) {}",
2110 Some("fn header() {}"),
2111 Some("fn middle() {}"),
2112 );
2113 let r = result.expect("should match first candidate");
2114 assert_eq!(r.strategy, MatchStrategy::Anchor);
2115 assert!(
2117 r.matched_text.contains("x: i32"),
2118 "should match first occurrence, got: {}",
2119 r.matched_text
2120 );
2121 }
2122
2123 #[test]
2124 fn resolve_with_fallback_empty_content() {
2125 let result = resolve_with_fallback("", "fn hello()", None, None);
2126 let err = result.unwrap_err();
2127 assert_eq!(err.kind, EditErrorKind::NoMatch);
2128 }
2129
2130 #[test]
2131 fn anchor_match_multiline_before_context() {
2132 let content = "fn setup() {}\nfn target() {}\nfn cleanup() {}\n";
2135 let result = anchor_match(
2136 content,
2137 "fn target() {}",
2138 Some("fn other() {}\nfn setup() {}"),
2139 None,
2140 );
2141 assert!(
2142 result.is_some(),
2143 "anchor_match should match using the last line of multi-line before_context"
2144 );
2145 assert_eq!(result.unwrap().matched_text, "fn target() {}");
2146 }
2147
2148 #[test]
2149 fn anchor_match_multiline_after_context() {
2150 let content = "fn setup() {}\nfn target() {}\nfn cleanup() {}\n";
2152 let result = anchor_match(
2153 content,
2154 "fn target() {}",
2155 None,
2156 Some("fn cleanup() {}\nfn extra() {}"),
2157 );
2158 assert!(
2159 result.is_some(),
2160 "anchor_match should match using the first line of multi-line after_context"
2161 );
2162 assert_eq!(result.unwrap().matched_text, "fn target() {}");
2163 }
2164
2165 const _: () = {
2167 fn _assert<T: Send + Sync>() {}
2168 let _ = _assert::<EditError>;
2169 let _ = _assert::<EditErrorKind>;
2170 let _ = _assert::<ValidationResult>;
2171 let _ = _assert::<AnchorMatchResult>;
2172 let _ = _assert::<MatchStrategy>;
2173 };
2174}