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 ParseTimeout,
171}
172
173impl std::fmt::Display for EditErrorKind {
174 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
175 match self {
176 EditErrorKind::AmbiguousTarget => write!(f, "ambiguous_target"),
177 EditErrorKind::NoMatch => write!(f, "no_match"),
178 EditErrorKind::SyntaxInvalid => write!(f, "syntax_invalid"),
179 EditErrorKind::ConflictingEdit => write!(f, "conflicting_edit"),
180 EditErrorKind::ParseError => write!(f, "parse_error"),
181 EditErrorKind::GuardRejected => write!(f, "guard_rejected"),
182 EditErrorKind::InvalidInput => write!(f, "invalid_input"),
183 EditErrorKind::TypeError => write!(f, "type_error"),
184 EditErrorKind::OperationFailed => write!(f, "operation_failed"),
185 EditErrorKind::FormatFailed => write!(f, "format_failed"),
186 EditErrorKind::AlreadyExists => write!(f, "already_exists"),
187 EditErrorKind::NotFound => write!(f, "not_found"),
188 EditErrorKind::Conflicts => write!(f, "conflicts"),
189 EditErrorKind::ChangesDetected => write!(f, "changes_detected"),
190 EditErrorKind::Binary => write!(f, "binary"),
191 EditErrorKind::InvalidEncoding => write!(f, "invalid_encoding"),
192 EditErrorKind::FuzzySpanSuspicious => write!(f, "fuzzy_span_suspicious"),
193 EditErrorKind::ParseTimeout => write!(f, "parse_timeout"),
194 }
195 }
196}
197
198impl EditError {
199 pub fn new(kind: EditErrorKind, message: impl Into<String>) -> Self {
201 Self {
202 kind,
203 message: message.into(),
204 suggestion: None,
205 similar_targets: Vec::new(),
206 }
207 }
208
209 pub fn guard_rejected(detail: impl std::fmt::Display) -> anyhow::Error {
215 Self::new(
216 EditErrorKind::GuardRejected,
217 format!("path rejected by workspace guard: {detail}"),
218 )
219 .into()
220 }
221
222 pub fn with_similar(mut self, similar: Vec<String>) -> Self {
224 self.similar_targets = similar;
225 self
226 }
227
228 pub fn with_suggestion(mut self, suggestion: impl Into<String>) -> Self {
230 self.suggestion = Some(suggestion.into());
231 self
232 }
233}
234
235pub fn edit_error_kind(err: &anyhow::Error) -> Option<EditErrorKind> {
244 for cause in err.chain() {
245 if let Some(kind) = classify_error(cause) {
246 return Some(kind);
247 }
248 }
249 None
250}
251
252pub fn edit_error_ref(err: &anyhow::Error) -> Option<&EditError> {
254 for cause in err.chain() {
255 if let Some(e) = classify_error_ref(cause) {
256 return Some(e);
257 }
258 }
259 None
260}
261
262pub fn classify_error(err: &(dyn std::error::Error + 'static)) -> Option<EditErrorKind> {
278 let mut current: Option<&(dyn std::error::Error + 'static)> = Some(err);
279 while let Some(e) = current {
280 if let Some(edit) = e.downcast_ref::<EditError>() {
281 return Some(edit.kind);
282 }
283 if e.downcast_ref::<crate::exit::NoMatchError>().is_some() {
284 return Some(EditErrorKind::NoMatch);
285 }
286 if e.downcast_ref::<crate::exit::AmbiguousError>().is_some() {
287 return Some(EditErrorKind::AmbiguousTarget);
288 }
289 if e.downcast_ref::<crate::exit::InvalidInputError>().is_some()
290 || e.downcast_ref::<crate::exit::InvalidInputHintError>()
291 .is_some()
292 {
293 return Some(EditErrorKind::InvalidInput);
294 }
295 if e.downcast_ref::<crate::exit::AlreadyExistsError>()
296 .is_some()
297 {
298 return Some(EditErrorKind::AlreadyExists);
299 }
300 if e.downcast_ref::<crate::exit::TypeErrorError>().is_some() {
301 return Some(EditErrorKind::TypeError);
302 }
303 if e.downcast_ref::<crate::exit::ParseErrorError>().is_some() {
304 return Some(EditErrorKind::ParseError);
305 }
306 if e.downcast_ref::<crate::exit::ParseTimeoutError>().is_some() {
307 return Some(EditErrorKind::ParseTimeout);
308 }
309 if e.downcast_ref::<crate::exit::FormatFailedError>().is_some() {
310 return Some(EditErrorKind::FormatFailed);
311 }
312 if e.downcast_ref::<crate::exit::ConflictsError>().is_some() {
313 return Some(EditErrorKind::Conflicts);
314 }
315 if e.downcast_ref::<crate::exit::ChangesDetectedError>()
316 .is_some()
317 {
318 return Some(EditErrorKind::ChangesDetected);
319 }
320 if e.downcast_ref::<crate::exit::BinaryError>().is_some() {
321 return Some(EditErrorKind::Binary);
322 }
323 if e.downcast_ref::<crate::exit::InvalidEncodingError>()
324 .is_some()
325 {
326 return Some(EditErrorKind::InvalidEncoding);
327 }
328 if e.downcast_ref::<std::io::Error>()
329 .is_some_and(|io| io.kind() == std::io::ErrorKind::NotFound)
330 {
331 return Some(EditErrorKind::NotFound);
332 }
333 current = e.source();
334 }
335 None
336}
337
338#[must_use]
347pub fn error_kind_str(err: &anyhow::Error) -> Option<&'static str> {
348 crate::exit::classify_typed_error(err).map(|(kind, _)| kind)
349}
350
351#[must_use]
357pub fn is_already_exists(err: &anyhow::Error) -> bool {
358 edit_error_kind(err) == Some(EditErrorKind::AlreadyExists)
359}
360
361#[must_use]
367pub fn is_not_found(err: &anyhow::Error) -> bool {
368 edit_error_kind(err) == Some(EditErrorKind::NotFound)
369}
370
371#[must_use]
375pub fn is_conflicts(err: &anyhow::Error) -> bool {
376 edit_error_kind(err) == Some(EditErrorKind::Conflicts)
377}
378
379#[must_use]
382pub fn is_changes_detected(err: &anyhow::Error) -> bool {
383 edit_error_kind(err) == Some(EditErrorKind::ChangesDetected)
384}
385
386#[must_use]
389pub fn is_type_error(err: &anyhow::Error) -> bool {
390 edit_error_kind(err) == Some(EditErrorKind::TypeError)
391}
392
393#[must_use]
396pub fn is_format_failed(err: &anyhow::Error) -> bool {
397 edit_error_kind(err) == Some(EditErrorKind::FormatFailed)
398}
399
400#[must_use]
403pub fn is_guard_rejected(err: &anyhow::Error) -> bool {
404 edit_error_kind(err) == Some(EditErrorKind::GuardRejected)
405}
406
407#[must_use]
410pub fn is_invalid_input(err: &anyhow::Error) -> bool {
411 edit_error_kind(err) == Some(EditErrorKind::InvalidInput)
412}
413
414#[must_use]
417pub fn is_no_match(err: &anyhow::Error) -> bool {
418 edit_error_kind(err) == Some(EditErrorKind::NoMatch)
419}
420
421#[must_use]
427pub fn is_ambiguous(err: &anyhow::Error) -> bool {
428 edit_error_kind(err) == Some(EditErrorKind::AmbiguousTarget)
429}
430
431#[must_use]
438pub fn is_binary(err: &anyhow::Error) -> bool {
439 edit_error_kind(err) == Some(EditErrorKind::Binary)
440}
441
442#[must_use]
446pub fn is_invalid_encoding(err: &anyhow::Error) -> bool {
447 edit_error_kind(err) == Some(EditErrorKind::InvalidEncoding)
448}
449
450#[must_use]
454pub fn is_fuzzy_span_suspicious(err: &anyhow::Error) -> bool {
455 edit_error_kind(err) == Some(EditErrorKind::FuzzySpanSuspicious)
456}
457
458#[derive(Debug, Clone)]
464pub struct PeeledError {
465 pub kind_str: &'static str,
467 pub message: String,
469 pub suggestion: Option<String>,
471 pub similar_targets: Vec<String>,
473}
474
475#[must_use]
477pub fn peel_error(err: &anyhow::Error) -> Option<PeeledError> {
478 let kind_str = error_kind_str(err)?;
479 let (suggestion, similar_targets) = match edit_error_ref(err) {
480 Some(e) => (e.suggestion.clone(), e.similar_targets.clone()),
481 None => (None, Vec::new()),
482 };
483 Some(PeeledError {
484 kind_str,
485 message: crate::exit::agent_error_message(err),
486 suggestion,
487 similar_targets,
488 })
489}
490
491pub fn classify_error_ref<'a>(err: &'a (dyn std::error::Error + 'static)) -> Option<&'a EditError> {
496 let mut current: Option<&'a (dyn std::error::Error + 'static)> = Some(err);
497 while let Some(e) = current {
498 if let Some(edit) = e.downcast_ref::<EditError>() {
499 return Some(edit);
500 }
501 current = e.source();
502 }
503 None
504}
505
506#[derive(Debug, Clone, Serialize, Deserialize)]
508pub struct ValidationResult {
509 pub valid: bool,
511 pub errors: Vec<String>,
513 pub warnings: Vec<String>,
515}
516
517pub fn validate_edit(
526 content: &str,
527 from: &str,
528 to: &str,
529 file_path: Option<&str>,
530) -> ValidationResult {
531 validate_edit_nth(content, from, to, file_path, None)
532}
533
534pub fn validate_edit_nth(
536 content: &str,
537 from: &str,
538 to: &str,
539 file_path: Option<&str>,
540 nth: Option<usize>,
541) -> ValidationResult {
542 if from.is_empty() {
543 return ValidationResult {
544 valid: false,
545 errors: vec!["empty search pattern".into()],
546 warnings: vec![],
547 };
548 }
549
550 if !content.contains(from) {
551 return ValidationResult {
552 valid: false,
553 errors: vec![format!(
554 "pattern '{}' not found in content",
555 truncate_str(from, 60)
556 )],
557 warnings: vec![],
558 };
559 }
560
561 let new_content = match nth {
562 Some(0) => {
563 return ValidationResult {
564 valid: false,
565 errors: vec!["nth must be >= 1 (1-based indexing)".into()],
566 warnings: vec![],
567 };
568 }
569 Some(n) => {
570 let mut count = 0usize;
572 let mut result = String::with_capacity(content.len());
573 let mut remaining = content;
574 while let Some(pos) = remaining.find(from) {
575 count += 1;
576 if count == n {
577 result.push_str(&remaining[..pos]);
578 result.push_str(to);
579 result.push_str(&remaining[pos + from.len()..]);
580 break;
581 }
582 result.push_str(&remaining[..pos + from.len()]);
583 remaining = &remaining[pos + from.len()..];
584 }
585 if count < n {
586 return ValidationResult {
587 valid: false,
588 errors: vec![format!(
589 "occurrence {n} not found (only {count} occurrence{} exist{})",
590 if count == 1 { "" } else { "s" },
591 if count == 1 { "s" } else { "" },
592 )],
593 warnings: vec![],
594 };
595 }
596 result
597 }
598 None => content.replace(from, to),
599 };
600 let mut errors = Vec::new();
601 let mut warnings = Vec::new();
602
603 if let Some(path) = file_path
605 && let Ok(fmt) = crate::ops::doc::detect_format(path)
606 {
607 let parse_err = match fmt {
608 crate::ops::doc::FileFormat::Json => {
609 crate::ops::doc::parse_doc(&new_content, &crate::ops::doc::FileFormat::Json)
610 .err()
611 .map(|e| format!("result would be invalid JSON: {e}"))
612 }
613 crate::ops::doc::FileFormat::Yaml => {
614 serde_yaml_ng::from_str::<serde_json::Value>(&new_content)
615 .err()
616 .map(|e| format!("result would be invalid YAML: {e}"))
617 }
618 crate::ops::doc::FileFormat::Toml => {
619 toml_edit::de::from_str::<serde_json::Value>(&new_content)
620 .err()
621 .map(|e| format!("result would be invalid TOML: {e}"))
622 }
623 crate::ops::doc::FileFormat::Env
624 | crate::ops::doc::FileFormat::Ini
625 | crate::ops::doc::FileFormat::Properties => {
626 crate::ops::doc::parse_doc(&new_content, &fmt)
627 .err()
628 .map(|e| format!("result would be invalid document: {e}"))
629 }
630 };
631 if let Some(msg) = parse_err {
632 errors.push(msg);
633 }
634 }
635
636 let open_parens =
638 new_content.matches('(').count() as i64 - new_content.matches(')').count() as i64;
639 let open_braces =
640 new_content.matches('{').count() as i64 - new_content.matches('}').count() as i64;
641 let open_brackets =
642 new_content.matches('[').count() as i64 - new_content.matches(']').count() as i64;
643
644 if open_parens != 0 {
645 warnings.push(format!("unbalanced parentheses (delta: {open_parens})"));
646 }
647 if open_braces != 0 {
648 warnings.push(format!("unbalanced braces (delta: {open_braces})"));
649 }
650 if open_brackets != 0 {
651 warnings.push(format!("unbalanced brackets (delta: {open_brackets})"));
652 }
653
654 ValidationResult {
655 valid: errors.is_empty(),
656 errors,
657 warnings,
658 }
659}
660
661const SIMILAR_TARGET_MIN_SCORE: f64 = 0.85;
667
668fn similar_target_length_plausible(candidate: &str, target: &str) -> bool {
674 let (ca, ta) = (candidate.len(), target.len());
675 if ca == 0 || ta == 0 {
676 return false;
677 }
678 let (shorter, longer) = if ca < ta { (ca, ta) } else { (ta, ca) };
679 shorter.saturating_mul(2) >= longer || longer - shorter <= 3
681}
682
683pub(crate) fn find_similar_among<'a, I>(
689 candidates: I,
690 target: &str,
691 max_results: usize,
692) -> Vec<String>
693where
694 I: IntoIterator<Item = &'a str>,
695{
696 if target.is_empty() || max_results == 0 {
697 return vec![];
698 }
699
700 let mut scored: Vec<(String, f64)> = Vec::new();
701 let mut seen = std::collections::HashSet::new();
702 for candidate in candidates {
703 if candidate.is_empty() || candidate == target || !seen.insert(candidate) {
704 continue;
705 }
706 if !similar_target_length_plausible(candidate, target) {
707 continue;
708 }
709 let score = strsim::jaro_winkler(candidate, target);
710 if score > SIMILAR_TARGET_MIN_SCORE {
711 scored.push((candidate.to_string(), score));
712 }
713 }
714
715 scored.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
716 scored.truncate(max_results);
717 scored.into_iter().map(|(s, _)| s).collect()
718}
719
720pub fn find_similar_targets(content: &str, target: &str, max_results: usize) -> Vec<String> {
725 if target.is_empty() || content.is_empty() {
726 return vec![];
727 }
728
729 let mut seen = std::collections::HashSet::new();
732 let mut tokens: Vec<&str> = Vec::new();
733 for line in content.lines() {
734 for ident in extract_identifiers(line) {
735 if seen.insert(ident) {
736 tokens.push(ident);
737 }
738 }
739 }
740 if target.contains(' ') || target.len() > 20 {
742 for line in content.lines() {
743 let trimmed = line.trim();
744 if !trimmed.is_empty() && seen.insert(trimmed) {
745 tokens.push(trimmed);
746 }
747 }
748 }
749 find_similar_among(tokens, target, max_results)
750}
751
752pub fn anchor_match(
758 content: &str,
759 target: &str,
760 before_context: Option<&str>,
761 after_context: Option<&str>,
762) -> Option<AnchorMatchResult> {
763 if target.is_empty() {
764 return None;
765 }
766
767 if let Some(pos) = content.find(target) {
769 return Some(AnchorMatchResult {
770 matched_text: target.to_string(),
771 start_offset: pos,
772 strategy: MatchStrategy::Exact,
773 score: None,
774 });
775 }
776
777 let lines: Vec<&str> = content.lines().collect();
779 let target_lines: Vec<&str> = target.lines().collect();
780
781 let mut line_byte_starts: Vec<usize> = vec![0];
785 for (i, b) in content.bytes().enumerate() {
786 if b == b'\n' {
787 line_byte_starts.push(i + 1);
788 }
789 }
790
791 if target_lines.is_empty() {
792 return None;
793 }
794
795 let first_target = target_lines[0].trim();
796 let last_target = target_lines.last().map(|l| l.trim()).unwrap_or("");
797
798 if before_context.is_none() && after_context.is_none() {
803 return None;
804 }
805
806 for (i, line) in lines.iter().enumerate() {
808 let trimmed = line.trim();
809
810 if strsim::jaro_winkler(trimmed, first_target) < 0.85 {
812 continue;
813 }
814
815 if target_lines.len() > 1 {
818 let end_idx = i + target_lines.len();
819 if end_idx > lines.len() {
820 continue;
821 }
822 let candidate_last = lines[end_idx - 1].trim();
823 if strsim::jaro_winkler(candidate_last, last_target) < 0.85 {
824 continue;
825 }
826 }
827
828 if let Some(before) = before_context {
832 if i == 0 {
833 continue;
834 }
835 let prev = lines[i - 1].trim();
836 let before_line = before.lines().last().unwrap_or(before).trim();
837 if strsim::jaro_winkler(prev, before_line) < 0.8 {
838 continue;
839 }
840 }
841
842 if let Some(after) = after_context {
846 let end_idx = i + target_lines.len();
847 if end_idx >= lines.len() {
848 continue;
849 }
850 let next = lines[end_idx].trim();
851 let after_line = after.lines().next().unwrap_or(after).trim();
852 if strsim::jaro_winkler(next, after_line) < 0.8 {
853 continue;
854 }
855 }
856
857 let end_idx = (i + target_lines.len()).min(lines.len());
860 let start_offset = line_byte_starts[i];
861 let end_offset = if end_idx < line_byte_starts.len() {
862 line_byte_starts[end_idx]
863 } else {
864 content.len()
865 };
866 let slice = &content[start_offset..end_offset];
869 let matched_text = slice
870 .strip_suffix("\r\n")
871 .or_else(|| slice.strip_suffix('\n'))
872 .unwrap_or(slice)
873 .to_string();
874
875 return Some(AnchorMatchResult {
876 matched_text,
877 start_offset,
878 strategy: MatchStrategy::Anchor,
879 score: None,
880 });
881 }
882
883 None
884}
885
886#[derive(Debug, Clone)]
888pub struct AnchorMatchResult {
889 pub matched_text: String,
891 pub start_offset: usize,
893 pub strategy: MatchStrategy,
895 pub score: Option<f64>,
897}
898
899#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
901#[serde(rename_all = "snake_case")]
902pub enum MatchStrategy {
903 Exact,
905 Anchor,
907 Similarity,
909}
910
911impl std::fmt::Display for MatchStrategy {
912 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
913 match self {
914 MatchStrategy::Exact => write!(f, "exact"),
915 MatchStrategy::Anchor => write!(f, "anchor"),
916 MatchStrategy::Similarity => write!(f, "similarity"),
917 }
918 }
919}
920
921pub fn resolve_with_fallback(
929 content: &str,
930 target: &str,
931 before_context: Option<&str>,
932 after_context: Option<&str>,
933) -> Result<AnchorMatchResult, EditError> {
934 resolve_with_fallback_skip_exact(content, target, before_context, after_context, false)
935}
936
937pub fn resolve_with_fallback_skip_exact(
944 content: &str,
945 target: &str,
946 before_context: Option<&str>,
947 after_context: Option<&str>,
948 skip_exact: bool,
949) -> Result<AnchorMatchResult, EditError> {
950 if !skip_exact && let Some(pos) = content.find(target) {
952 return Ok(AnchorMatchResult {
953 matched_text: target.to_string(),
954 start_offset: pos,
955 strategy: MatchStrategy::Exact,
956 score: None,
957 });
958 }
959
960 if let Some(result) = anchor_match(content, target, before_context, after_context) {
962 if !(skip_exact && result.strategy == MatchStrategy::Exact) {
965 return Ok(result);
966 }
967 }
968
969 let target_lines: Vec<&str> = target.lines().collect();
976 if target_lines.len() == 1 {
977 let target_trim = target.trim();
978 if is_token_like_target(target_trim) {
979 if let Some(hit) = best_token_similarity(content, target_trim) {
980 return Ok(hit);
981 }
982 } else if let Some(hit) = best_line_similarity(content, target_trim) {
984 return Ok(hit);
985 }
986 }
987
988 let similar = find_similar_targets(content, target.lines().next().unwrap_or(target), 5);
990 let suggestion = if !similar.is_empty() {
991 Some(format!("did you mean: {}?", similar[0]))
992 } else {
993 None
994 };
995
996 Err(EditError {
997 kind: EditErrorKind::NoMatch,
998 message: format!("target not found: '{}'", truncate_str(target, 80)),
999 suggestion,
1000 similar_targets: similar,
1001 })
1002}
1003
1004pub(crate) fn fuzzy_fails_min_floor(score: Option<f64>, min: f64) -> bool {
1010 match score {
1011 Some(s) => s < min,
1012 None => true,
1013 }
1014}
1015
1016pub(crate) fn should_refuse_fuzzy_absent_old(
1024 fuzzy_requested: bool,
1025 is_fuzzy_mode: bool,
1026 allow_absent_old: bool,
1027) -> bool {
1028 fuzzy_requested && is_fuzzy_mode && !allow_absent_old
1029}
1030
1031pub(crate) fn fuzzy_absent_old_refuse_message(
1033 old: &str,
1034 matched_text: &str,
1035 score: Option<f64>,
1036) -> String {
1037 let score_s = score
1038 .map(|s| format!("{s:.3}"))
1039 .unwrap_or_else(|| "none".into());
1040 format!(
1041 "exact old absent for {:?}; best fuzzy candidate {:?} score {} \
1042 (set allow_absent_old / --allow-absent-old to apply)",
1043 truncate_str(old, 60),
1044 truncate_str(matched_text, 60),
1045 score_s
1046 )
1047}
1048
1049fn is_token_like_target(target: &str) -> bool {
1055 if target.is_empty() || target.chars().any(|c| c.is_whitespace()) {
1056 return false;
1057 }
1058 target.chars().any(|c| c.is_alphanumeric())
1062 && target.chars().all(|c| c.is_alphanumeric() || c == '_')
1063}
1064
1065fn best_token_similarity(content: &str, target: &str) -> Option<AnchorMatchResult> {
1067 let mut best_score = 0.0f64;
1068 let mut best_match = String::new();
1069 let mut best_offset = 0usize;
1070 let mut offset = 0usize;
1071 for line in content.lines() {
1072 for (ident, col) in extract_identifiers_with_offsets(line) {
1073 if ident == target {
1074 continue;
1075 }
1076 let score = strsim::jaro_winkler(ident, target);
1077 if score > best_score {
1078 best_score = score;
1079 best_match = ident.to_string();
1080 best_offset = offset + col;
1081 }
1082 }
1083 offset = advance_line_offset(content, offset, line);
1084 }
1085 if best_score > 0.85 {
1086 Some(AnchorMatchResult {
1087 matched_text: best_match,
1088 start_offset: best_offset,
1089 strategy: MatchStrategy::Similarity,
1090 score: Some(best_score),
1091 })
1092 } else {
1093 None
1094 }
1095}
1096
1097fn best_line_similarity(content: &str, target: &str) -> Option<AnchorMatchResult> {
1102 let mut best_score = 0.0f64;
1103 let mut best_match = String::new();
1104 let mut best_offset = 0usize;
1105 let mut offset = 0usize;
1106 for line in content.lines() {
1107 let trimmed = line.trim();
1108 let score = strsim::jaro_winkler(trimmed, target);
1109 if score > best_score {
1110 best_score = score;
1111 let lead = line.len() - line.trim_start().len();
1112 best_match = trimmed.to_string();
1113 best_offset = offset + lead;
1114 }
1115 offset = advance_line_offset(content, offset, line);
1116 }
1117 if best_score <= 0.85 {
1118 return None;
1119 }
1120 let line_len = best_match.trim().len();
1121 let target_len = target.len().max(1);
1122 if line_len > target_len.saturating_mul(2) && line_len > target_len + 16 {
1123 return None;
1125 }
1126 Some(AnchorMatchResult {
1127 matched_text: best_match,
1128 start_offset: best_offset,
1129 strategy: MatchStrategy::Similarity,
1130 score: Some(best_score),
1131 })
1132}
1133
1134fn advance_line_offset(content: &str, mut offset: usize, line: &str) -> usize {
1135 offset += line.len();
1137 if content.as_bytes().get(offset) == Some(&b'\r') {
1138 offset += 1;
1139 }
1140 if content.as_bytes().get(offset) == Some(&b'\n') {
1141 offset += 1;
1142 }
1143 offset
1144}
1145
1146fn extract_identifiers(line: &str) -> Vec<&str> {
1148 extract_identifiers_with_offsets(line)
1149 .into_iter()
1150 .map(|(s, _)| s)
1151 .collect()
1152}
1153
1154fn extract_identifiers_with_offsets(line: &str) -> Vec<(&str, usize)> {
1156 let mut identifiers = Vec::new();
1157 let mut start: Option<usize> = None;
1158 let mut i = 0usize;
1159
1160 for ch in line.chars() {
1161 let clen = ch.len_utf8();
1162 if ch.is_alphanumeric() || ch == '_' {
1163 if start.is_none() {
1164 start = Some(i);
1165 }
1166 } else if let Some(s) = start.take()
1167 && i - s >= 3
1168 {
1169 identifiers.push((&line[s..i], s));
1170 }
1171 i += clen;
1172 }
1173 if let Some(s) = start
1174 && i - s >= 3
1175 {
1176 identifiers.push((&line[s..i], s));
1177 }
1178
1179 identifiers
1180}
1181
1182pub(crate) fn truncate_str(s: &str, max_len: usize) -> &str {
1187 if s.len() <= max_len {
1188 s
1189 } else {
1190 let mut end = max_len;
1192 while end > 0 && !s.is_char_boundary(end) {
1193 end -= 1;
1194 }
1195 &s[..end]
1196 }
1197}
1198
1199#[cfg(test)]
1200mod tests {
1201 use super::*;
1202
1203 #[test]
1204 fn edit_error_display() {
1205 let err = EditError {
1206 kind: EditErrorKind::NoMatch,
1207 message: "target not found".into(),
1208 suggestion: Some("try 'process_request'".into()),
1209 similar_targets: vec!["process_request".into()],
1210 };
1211 let display = err.to_string();
1212 assert!(display.contains("no_match"));
1213 assert!(display.contains("target not found"));
1214 assert!(display.contains("process_request"));
1215 }
1216
1217 #[test]
1218 fn edit_error_kind_display() {
1219 assert_eq!(
1220 EditErrorKind::AmbiguousTarget.to_string(),
1221 "ambiguous_target"
1222 );
1223 assert_eq!(EditErrorKind::NoMatch.to_string(), "no_match");
1224 assert_eq!(EditErrorKind::SyntaxInvalid.to_string(), "syntax_invalid");
1225 assert_eq!(
1226 EditErrorKind::ConflictingEdit.to_string(),
1227 "conflicting_edit"
1228 );
1229 assert_eq!(EditErrorKind::ParseError.to_string(), "parse_error");
1230 assert_eq!(EditErrorKind::ParseTimeout.to_string(), "parse_timeout");
1231 assert_eq!(EditErrorKind::TypeError.to_string(), "type_error");
1232 assert_eq!(EditErrorKind::InvalidInput.to_string(), "invalid_input");
1233 assert_eq!(EditErrorKind::AlreadyExists.to_string(), "already_exists");
1234 assert_eq!(EditErrorKind::NotFound.to_string(), "not_found");
1235 assert_eq!(EditErrorKind::Conflicts.to_string(), "conflicts");
1236 assert_eq!(
1237 EditErrorKind::ChangesDetected.to_string(),
1238 "changes_detected"
1239 );
1240 assert_eq!(EditErrorKind::FormatFailed.to_string(), "format_failed");
1241 assert_eq!(EditErrorKind::Binary.to_string(), "binary");
1242 assert_eq!(
1243 EditErrorKind::InvalidEncoding.to_string(),
1244 "invalid_encoding"
1245 );
1246 }
1247
1248 #[test]
1251 fn edit_error_kind_stable_discriminants_since_0_18_0() {
1252 assert_eq!(EditErrorKind::AmbiguousTarget as u8, 0);
1253 assert_eq!(EditErrorKind::NoMatch as u8, 1);
1254 assert_eq!(EditErrorKind::SyntaxInvalid as u8, 2);
1255 assert_eq!(EditErrorKind::ConflictingEdit as u8, 3);
1256 assert_eq!(EditErrorKind::ParseError as u8, 4);
1257 assert_eq!(EditErrorKind::GuardRejected as u8, 5);
1258 assert_eq!(EditErrorKind::InvalidInput as u8, 6);
1259 assert_eq!(EditErrorKind::TypeError as u8, 7);
1260 assert_eq!(EditErrorKind::OperationFailed as u8, 8);
1261 assert_eq!(EditErrorKind::FormatFailed as u8, 9);
1262 assert_eq!(EditErrorKind::AlreadyExists as u8, 10);
1264 assert_eq!(EditErrorKind::NotFound as u8, 11);
1265 assert_eq!(EditErrorKind::Conflicts as u8, 12);
1266 assert_eq!(EditErrorKind::ChangesDetected as u8, 13);
1267 assert_eq!(EditErrorKind::Binary as u8, 14);
1269 assert_eq!(EditErrorKind::InvalidEncoding as u8, 15);
1270 assert_eq!(EditErrorKind::FuzzySpanSuspicious as u8, 16);
1272 assert_eq!(EditErrorKind::ParseTimeout as u8, 17);
1273 }
1274
1275 #[test]
1276 fn classify_error_peels_edit_error_and_typed() {
1277 use std::error::Error;
1278 let e = EditError::new(EditErrorKind::AmbiguousTarget, "many");
1279 assert_eq!(
1280 classify_error(&e as &(dyn Error + 'static)),
1281 Some(EditErrorKind::AmbiguousTarget)
1282 );
1283 let e = crate::exit::NoMatchError { msg: "none".into() };
1284 assert_eq!(
1285 classify_error(&e as &(dyn Error + 'static)),
1286 Some(EditErrorKind::NoMatch)
1287 );
1288 let e = crate::exit::FormatFailedError::new("fmt");
1289 assert_eq!(
1290 classify_error(&e as &(dyn Error + 'static)),
1291 Some(EditErrorKind::FormatFailed)
1292 );
1293 let e = crate::exit::TypeErrorError {
1294 msg: "parent is an array".into(),
1295 };
1296 assert_eq!(
1297 classify_error(&e as &(dyn Error + 'static)),
1298 Some(EditErrorKind::TypeError)
1299 );
1300 let e = crate::exit::AlreadyExistsError {
1301 msg: "exists".into(),
1302 };
1303 assert_eq!(
1304 classify_error(&e as &(dyn Error + 'static)),
1305 Some(EditErrorKind::AlreadyExists)
1306 );
1307 let e = crate::exit::ConflictsError {
1308 msg: "conflict".into(),
1309 };
1310 assert_eq!(
1311 classify_error(&e as &(dyn Error + 'static)),
1312 Some(EditErrorKind::Conflicts)
1313 );
1314 let e = crate::exit::ChangesDetectedError {
1315 msg: "would change".into(),
1316 };
1317 assert_eq!(
1318 classify_error(&e as &(dyn Error + 'static)),
1319 Some(EditErrorKind::ChangesDetected)
1320 );
1321 let e = std::io::Error::new(std::io::ErrorKind::NotFound, "gone");
1322 assert_eq!(
1323 classify_error(&e as &(dyn Error + 'static)),
1324 Some(EditErrorKind::NotFound)
1325 );
1326 }
1327
1328 #[test]
1329 fn edit_error_kind_maps_exit_typed_errors() {
1330 let invalid: anyhow::Error = crate::exit::InvalidInputError {
1331 msg: "empty search pattern".into(),
1332 }
1333 .into();
1334 assert_eq!(edit_error_kind(&invalid), Some(EditErrorKind::InvalidInput));
1335 assert!(is_invalid_input(&invalid));
1336 assert!(!is_guard_rejected(&invalid));
1337
1338 let no_match: anyhow::Error = crate::exit::NoMatchError {
1339 msg: "no matches".into(),
1340 }
1341 .into();
1342 assert_eq!(edit_error_kind(&no_match), Some(EditErrorKind::NoMatch));
1343 assert!(is_no_match(&no_match));
1344 assert_eq!(error_kind_str(&no_match), Some("no_matches"));
1345 assert!(!is_not_found(&no_match));
1346
1347 let ambiguous: anyhow::Error = crate::exit::AmbiguousError {
1348 msg: "multiple matches".into(),
1349 }
1350 .into();
1351 assert_eq!(
1352 edit_error_kind(&ambiguous),
1353 Some(EditErrorKind::AmbiguousTarget)
1354 );
1355 assert!(is_ambiguous(&ambiguous));
1356 assert_eq!(error_kind_str(&ambiguous), Some("ambiguous"));
1357 assert!(!is_no_match(&ambiguous));
1358
1359 let parse: anyhow::Error = crate::exit::ParseErrorError {
1360 msg: "bad yaml".into(),
1361 }
1362 .into();
1363 assert_eq!(edit_error_kind(&parse), Some(EditErrorKind::ParseError));
1364
1365 let timeout: anyhow::Error = crate::exit::ParseTimeoutError {
1366 msg: "deadline".into(),
1367 }
1368 .into();
1369 assert_eq!(edit_error_kind(&timeout), Some(EditErrorKind::ParseTimeout));
1370 assert_eq!(error_kind_str(&timeout), Some("parse_timeout"));
1371
1372 let exists: anyhow::Error = crate::exit::AlreadyExistsError {
1373 msg: "file already exists".into(),
1374 }
1375 .into();
1376 assert_eq!(
1377 edit_error_kind(&exists),
1378 Some(EditErrorKind::AlreadyExists),
1379 "AlreadyExistsError must not collapse to InvalidInput (#1947)"
1380 );
1381 assert!(is_already_exists(&exists));
1382 assert_eq!(error_kind_str(&exists), Some("already_exists"));
1383
1384 let exists_edit: anyhow::Error =
1385 EditError::new(EditErrorKind::AlreadyExists, "dest taken").into();
1386 assert!(
1387 is_already_exists(&exists_edit),
1388 "is_already_exists must match EditErrorKind::AlreadyExists too"
1389 );
1390 assert_eq!(
1391 edit_error_kind(&exists_edit),
1392 Some(EditErrorKind::AlreadyExists)
1393 );
1394
1395 let type_err: anyhow::Error = crate::exit::TypeErrorError {
1396 msg: "parent is an array, not an object".into(),
1397 }
1398 .into();
1399 assert_eq!(
1400 edit_error_kind(&type_err),
1401 Some(EditErrorKind::TypeError),
1402 "TypeErrorError must not collapse to InvalidInput (#1883)"
1403 );
1404 assert!(is_type_error(&type_err));
1405 assert!(!is_invalid_input(&type_err));
1406
1407 let format_failed: anyhow::Error =
1408 crate::exit::FormatFailedError::new("format command failed").into();
1409 assert_eq!(
1410 edit_error_kind(&format_failed),
1411 Some(EditErrorKind::FormatFailed)
1412 );
1413 assert!(is_format_failed(&format_failed));
1414 assert!(!is_type_error(&format_failed));
1415
1416 let guard: anyhow::Error =
1417 EditError::new(EditErrorKind::GuardRejected, "path escapes").into();
1418 assert!(is_guard_rejected(&guard));
1419 assert_eq!(error_kind_str(&guard), Some("guard_rejected"));
1420 assert!(!is_invalid_input(&guard));
1421
1422 let not_found: anyhow::Error =
1423 std::io::Error::new(std::io::ErrorKind::NotFound, "missing").into();
1424 let not_found = not_found.context("failed to read path");
1425 assert_eq!(
1426 edit_error_kind(¬_found),
1427 Some(EditErrorKind::NotFound),
1428 "IO NotFound must peel as NotFound not OperationFailed"
1429 );
1430 assert_eq!(error_kind_str(¬_found), Some("not_found"));
1431 assert!(
1432 is_not_found(¬_found),
1433 "is_not_found must match IO NotFound peel"
1434 );
1435 assert!(!is_already_exists(¬_found));
1436
1437 let conflicts: anyhow::Error = crate::exit::ConflictsError {
1438 msg: "merge conflict".into(),
1439 }
1440 .into();
1441 assert_eq!(edit_error_kind(&conflicts), Some(EditErrorKind::Conflicts));
1442 assert_eq!(error_kind_str(&conflicts), Some("conflicts"));
1443 assert!(is_conflicts(&conflicts));
1444 assert!(!is_not_found(&conflicts));
1445
1446 let changes: anyhow::Error = crate::exit::ChangesDetectedError {
1447 msg: "would change".into(),
1448 }
1449 .into();
1450 assert_eq!(
1451 edit_error_kind(&changes),
1452 Some(EditErrorKind::ChangesDetected)
1453 );
1454 assert_eq!(error_kind_str(&changes), Some("changes_detected"));
1455 assert!(is_changes_detected(&changes));
1456 assert!(!is_conflicts(&changes));
1457
1458 let wrapped = invalid.context("operation 1 (replace) failed");
1460 assert_eq!(edit_error_kind(&wrapped), Some(EditErrorKind::InvalidInput));
1461
1462 let type_wrapped = type_err.context("operation 1 (doc.set) failed");
1463 assert_eq!(
1464 edit_error_kind(&type_wrapped),
1465 Some(EditErrorKind::TypeError)
1466 );
1467
1468 let plain = anyhow::anyhow!("plain error");
1469 assert_eq!(edit_error_kind(&plain), None);
1470 }
1471
1472 #[test]
1473 fn validate_edit_empty_pattern() {
1474 let result = validate_edit("content", "", "replacement", None);
1475 assert!(!result.valid);
1476 assert!(result.errors[0].contains("empty search pattern"));
1477 }
1478
1479 #[test]
1480 fn validate_edit_pattern_not_found() {
1481 let result = validate_edit("hello world", "missing", "replacement", None);
1482 assert!(!result.valid);
1483 assert!(result.errors[0].contains("not found"));
1484 }
1485
1486 #[test]
1487 fn validate_edit_valid_replacement() {
1488 let result = validate_edit("hello world", "hello", "goodbye", None);
1489 assert!(result.valid);
1490 assert!(result.errors.is_empty());
1491 }
1492
1493 #[test]
1494 fn validate_edit_json_syntax_check() {
1495 let json = r#"{"key": "value"}"#;
1496 let result = validate_edit(json, "value", "new_value", Some("config.json"));
1498 assert!(result.valid);
1499
1500 let result = validate_edit(json, "\"key\":", "broken", Some("config.json"));
1502 assert!(!result.valid);
1503 assert!(result.errors[0].contains("invalid JSON"));
1504 }
1505
1506 #[test]
1507 fn validate_edit_yaml_syntax_check() {
1508 let yaml = "key: value\n";
1509 let result = validate_edit(yaml, "value", "new_value", Some("config.yaml"));
1510 assert!(result.valid);
1511 }
1512
1513 #[test]
1514 fn validate_edit_warns_unbalanced_braces() {
1515 let content = "fn main() { hello }";
1516 let result = validate_edit(content, "{ hello }", "{ hello", None);
1517 assert!(result.valid); assert!(
1519 result
1520 .warnings
1521 .iter()
1522 .any(|w| w.contains("unbalanced braces"))
1523 );
1524 }
1525
1526 #[test]
1527 fn find_similar_targets_finds_typos() {
1528 let content = "fn process_request() {}\nfn process_response() {}\nfn handle_error() {}\n";
1529 let similar = find_similar_targets(content, "process_requst", 3);
1530 assert!(!similar.is_empty());
1531 assert!(similar.iter().any(|s| s.contains("process_request")));
1532 }
1533
1534 #[test]
1535 fn find_similar_targets_rejects_length_skew_noise() {
1536 let content = "fn nold() {}\nlet compute = 1;\nfn process_request() {}\n";
1539 let similar = find_similar_targets(content, "this_string_should_not_exist_xyzzy", 5);
1540 assert!(
1541 similar.is_empty(),
1542 "unrelated long pattern must not suggest short tokens: {similar:?}"
1543 );
1544 let similar = find_similar_targets(content, "completely_bogus_token_zzz", 5);
1545 assert!(
1546 !similar
1547 .iter()
1548 .any(|s| s == "compute" || s == "let" || s == "nold"),
1549 "bogus pattern must not surface weak short-token hints: {similar:?}"
1550 );
1551 }
1552
1553 #[test]
1554 fn find_similar_targets_empty_content() {
1555 let similar = find_similar_targets("", "target", 3);
1556 assert!(similar.is_empty());
1557 }
1558
1559 #[test]
1560 fn find_similar_targets_empty_target() {
1561 let similar = find_similar_targets("content", "", 3);
1562 assert!(similar.is_empty());
1563 }
1564
1565 #[test]
1566 fn find_similar_among_keeps_hyphenated_keys() {
1567 let keys = ["database-url", "name"];
1568 let similar = find_similar_among(keys, "databse-url", 1);
1569 assert_eq!(similar, vec!["database-url".to_string()]);
1570 assert!(!similar.iter().any(|s| s == "database"));
1571 }
1572
1573 #[test]
1574 fn find_similar_among_does_not_invent_absent_token() {
1575 let keys = ["database-url"];
1576 let similar = find_similar_among(keys, "databse-url", 3);
1577 assert_ne!(similar.first().map(String::as_str), Some("database"));
1578 assert!(
1579 similar.is_empty() || similar == ["database-url"],
1580 "whole-key rank must stay on present keys, got {similar:?}"
1581 );
1582 }
1583
1584 #[test]
1585 fn anchor_match_exact() {
1586 let content = "line1\nline2\nline3\n";
1587 let result = anchor_match(content, "line2", None, None).unwrap();
1588 assert_eq!(result.matched_text, "line2");
1589 assert_eq!(result.strategy, MatchStrategy::Exact);
1590 }
1591
1592 #[test]
1593 fn anchor_match_with_context() {
1594 let content = "fn setup() {}\nfn proccess_data(x: i32) {}\nfn cleanup() {}\n";
1596 let result = anchor_match(
1597 content,
1598 "fn process_data(x: i32) {}",
1599 Some("fn setup() {}"),
1600 Some("fn cleanup() {}"),
1601 );
1602 let r = result.expect("anchor match should find a fuzzy match");
1603 assert_eq!(r.strategy, MatchStrategy::Anchor);
1604 assert!(r.matched_text.contains("proccess_data"));
1605 }
1606
1607 #[test]
1608 fn anchor_match_no_match() {
1609 let content = "completely different content\n";
1610 let result = anchor_match(content, "not here at all", None, None);
1611 assert!(result.is_none());
1612 }
1613
1614 #[test]
1615 fn anchor_match_requires_context_for_fuzzy() {
1616 let content = "fn process_request(x: i32) {}\n";
1620 let result = anchor_match(content, "fn process_requst(x: i32) {}", None, None);
1621 assert!(result.is_none());
1622 }
1623
1624 #[test]
1625 fn anchor_match_multi_line_verifies_last_line() {
1626 let content = "fn setup() {}\nfn process(x: i32) {}\nfn teardown() {}\nfn other() {}\n";
1629 let result = anchor_match(
1631 content,
1632 "fn process(x: i32) {}\nfn completely_wrong() {}",
1633 Some("fn setup() {}"),
1634 None,
1635 );
1636 assert!(result.is_none());
1637 }
1638
1639 #[test]
1640 fn anchor_match_crlf_offset() {
1641 let content = "line1\r\nline2\r\nline3\r\n";
1642 let result = anchor_match(content, "line2", Some("line1"), None).unwrap();
1643 assert_eq!(result.start_offset, 7);
1645 }
1646
1647 #[test]
1651 fn anchor_match_crlf_matched_text_preserves_endings() {
1652 let content =
1653 "fn setup() {}\r\nfn proccess_data(x: i32) {}\r\nfn more() {}\r\nfn cleanup() {}\r\n";
1654 let result = anchor_match(
1655 content,
1656 "fn process_data(x: i32) {}\nfn more() {}",
1657 Some("fn setup() {}"),
1658 Some("fn cleanup() {}"),
1659 )
1660 .unwrap();
1661 let end = result.start_offset + result.matched_text.len();
1663 assert_eq!(
1664 &content[result.start_offset..end],
1665 result.matched_text,
1666 "matched_text must be an exact slice of the original content"
1667 );
1668 assert!(
1670 result.matched_text.contains("\r\n"),
1671 "matched text should preserve CRLF line endings"
1672 );
1673 }
1674
1675 #[test]
1678 fn anchor_match_mixed_endings_correct_offset() {
1679 let content = "header\r\nfn proccess(x: i32) {}\nfooter\n";
1680 let result = anchor_match(
1681 content,
1682 "fn process(x: i32) {}",
1683 Some("header"),
1684 Some("footer"),
1685 )
1686 .unwrap();
1687 assert_eq!(result.start_offset, 8);
1689 let end = result.start_offset + result.matched_text.len();
1690 assert_eq!(&content[result.start_offset..end], result.matched_text);
1691 }
1692
1693 #[test]
1694 fn resolve_with_fallback_exact_match() {
1695 let content = "fn hello() {}\n";
1696 let result = resolve_with_fallback(content, "fn hello()", None, None).unwrap();
1697 assert_eq!(result.strategy, MatchStrategy::Exact);
1698 }
1699
1700 #[test]
1703 fn resolve_with_fallback_skip_exact_rejects_substring() {
1704 let content = "process_data process_data_extra\n";
1705 let bare = resolve_with_fallback(content, "process_dat", None, None).unwrap();
1707 assert_eq!(bare.strategy, MatchStrategy::Exact);
1708 assert_eq!(bare.matched_text, "process_dat");
1709
1710 match resolve_with_fallback_skip_exact(content, "process_dat", None, None, true) {
1713 Ok(hit) => {
1714 assert_ne!(
1715 hit.strategy,
1716 MatchStrategy::Exact,
1717 "skip_exact must not return Exact"
1718 );
1719 assert_ne!(
1720 hit.matched_text, "process_dat",
1721 "must not re-accept word_boundary-rejected substring"
1722 );
1723 }
1724 Err(e) => assert_eq!(e.kind, EditErrorKind::NoMatch),
1725 }
1726 }
1727
1728 #[test]
1732 fn resolve_with_fallback_similarity_crlf_offset() {
1733 let content = "fn alpha() {}\r\nfn process_requets(data: &str) {}\r\nfn gamma() {}\r\n";
1734 let result =
1735 resolve_with_fallback(content, "fn process_requests(data: &str) {}", None, None)
1736 .unwrap();
1737 assert_eq!(result.strategy, MatchStrategy::Similarity);
1738 assert_eq!(result.start_offset, 15);
1740 assert!(
1742 content[result.start_offset..].starts_with(&result.matched_text),
1743 "start_offset must point to matched_text in content"
1744 );
1745 }
1746
1747 #[test]
1748 fn resolve_with_fallback_similarity_match() {
1749 let content = "fn process_request(data: &str) -> Result<()> {\n Ok(())\n}\n";
1754 let result = resolve_with_fallback(
1755 content,
1756 "fn process_requets(data: &str) -> Result<()> {",
1757 None,
1758 None,
1759 );
1760 let r = result.expect("similarity fallback should succeed");
1761 assert_eq!(r.strategy, MatchStrategy::Similarity);
1762 assert!(
1764 r.matched_text.contains("process_request"),
1765 "snippet match: {:?}",
1766 r.matched_text
1767 );
1768 }
1769
1770 #[test]
1772 fn resolve_token_typo_does_not_match_whole_line() {
1773 let content = "const CONFIGURATION_VALUE_PRIMARY: i32 = 1;\nfn use_it() -> i32 { CONFIGURATION_VALUE_PRIMARY }\n";
1774 let r = resolve_with_fallback(content, "CONFIGURATION_VALUE_PRIMRY", None, None)
1775 .expect("token typo should fuzzy-match the identifier");
1776 assert_eq!(r.strategy, MatchStrategy::Similarity);
1777 assert_eq!(
1778 r.matched_text, "CONFIGURATION_VALUE_PRIMARY",
1779 "must match the identifier token only, not the whole line"
1780 );
1781 assert!(
1782 content[r.start_offset..].starts_with("CONFIGURATION_VALUE_PRIMARY"),
1783 "offset must point at the token"
1784 );
1785 assert!(!r.matched_text.contains("const"));
1787 assert!(!r.matched_text.contains("i32"));
1788 }
1789
1790 #[test]
1792 fn resolve_full_line_snippet_still_matches_line() {
1793 let content = "const FOO: i32 = 1;\n";
1794 let r = resolve_with_fallback(content, "const FO: i32 = 1;", None, None)
1795 .expect("near-full-line snippet should match the line");
1796 assert_eq!(r.strategy, MatchStrategy::Similarity);
1797 assert!(
1798 r.matched_text.contains("const") && r.matched_text.contains("FOO"),
1799 "line match: {:?}",
1800 r.matched_text
1801 );
1802 }
1803
1804 #[test]
1805 fn line_similarity_span_excludes_space_indent() {
1806 let content = " foo(x);\n";
1807 let r = resolve_with_fallback(content, "foo(x) ;", None, None)
1808 .expect("indented snippet should fuzzy-match");
1809 assert_eq!(r.strategy, MatchStrategy::Similarity);
1810 assert_eq!(r.matched_text, "foo(x);");
1811 assert_eq!(r.start_offset, 4);
1812 assert_eq!(
1813 &content[r.start_offset..r.start_offset + r.matched_text.len()],
1814 "foo(x);"
1815 );
1816 }
1817
1818 #[test]
1819 fn line_similarity_span_excludes_tab_indent() {
1820 let content = "\tfoo(x);\n";
1821 let r = resolve_with_fallback(content, "foo(x) ;", None, None)
1822 .expect("tab-indented snippet should fuzzy-match");
1823 assert_eq!(r.strategy, MatchStrategy::Similarity);
1824 assert_eq!(r.matched_text, "foo(x);");
1825 assert_eq!(r.start_offset, 1);
1826 assert_eq!(
1827 &content[r.start_offset..r.start_offset + r.matched_text.len()],
1828 "foo(x);"
1829 );
1830 }
1831
1832 #[test]
1834 fn resolve_token_typo_matrix_preserves_non_identifier_syntax() {
1835 let cases: &[(&str, &str, &str, &[&str])] = &[
1837 (
1839 "const CONFIGURATION_VALUE_PRIMARY: i32 = 1;\nfn use_it() -> i32 { CONFIGURATION_VALUE_PRIMARY }\n",
1840 "CONFIGURATION_VALUE_PRIMRY",
1841 "CONFIGURATION_VALUE_PRIMARY",
1842 &["const", "i32", "fn"],
1843 ),
1844 (
1846 "fn process_request(data: &str) -> Result<()> {\n Ok(())\n}\n",
1847 "process_requets",
1848 "process_request",
1849 &["fn", "Result", "data"],
1850 ),
1851 (
1853 "def load_configuration_value():\n return 1\n",
1854 "load_configration_value",
1855 "load_configuration_value",
1856 &["def", "return"],
1857 ),
1858 (
1860 "const getUserProfile = () => null;\nexport { getUserProfile };\n",
1861 "getUserProfle",
1862 "getUserProfile",
1863 &["const", "export"],
1864 ),
1865 (
1867 "server_port_primary: 8080\n",
1868 "server_port_primry",
1869 "server_port_primary",
1870 &[":", "8080"],
1871 ),
1872 (
1874 " obj.fetchUserDetails(id);\n",
1875 "fetchUserDetials",
1876 "fetchUserDetails",
1877 &["obj", "id"],
1878 ),
1879 ];
1880
1881 for (content, typo, expected, forbidden) in cases {
1882 let r = resolve_with_fallback(content, typo, None, None)
1883 .unwrap_or_else(|e| panic!("token typo {typo:?} should match in {content:?}: {e}"));
1884 assert_eq!(r.strategy, MatchStrategy::Similarity, "typo={typo:?}");
1885 assert_eq!(
1886 r.matched_text, *expected,
1887 "typo={typo:?} content={content:?}"
1888 );
1889 assert!(
1890 content[r.start_offset..].starts_with(expected),
1891 "offset wrong for {typo:?}"
1892 );
1893 for bad in *forbidden {
1894 assert!(
1895 !r.matched_text.contains(bad),
1896 "span leaked {bad:?} for typo={typo:?} match={:?}",
1897 r.matched_text
1898 );
1899 }
1900 assert!(
1902 !r.matched_text.contains(' '),
1903 "token match must not contain spaces: {:?}",
1904 r.matched_text
1905 );
1906 }
1907 }
1908
1909 #[test]
1910 fn resolve_token_typo_picks_first_best_occurrence() {
1911 let content = "FOO_PRIMARY=1\nFOO_PRIMARY=2\n";
1912 let r = resolve_with_fallback(content, "FOO_PRIMRY", None, None).unwrap();
1913 assert_eq!(r.matched_text, "FOO_PRIMARY");
1914 assert_eq!(r.start_offset, 0);
1916 }
1917
1918 #[test]
1919 fn resolve_token_unrelated_is_no_match() {
1920 let content = "const ALPHA: i32 = 1;\nconst BETA: i32 = 2;\n";
1921 let err =
1922 resolve_with_fallback(content, "ZZZZ_COMPLETELY_UNRELATED", None, None).unwrap_err();
1923 assert_eq!(err.kind, EditErrorKind::NoMatch);
1924 }
1925
1926 #[test]
1927 fn is_token_like_target_classification() {
1928 assert!(is_token_like_target("CONFIGURATION_VALUE_PRIMRY"));
1929 assert!(is_token_like_target("getUserProfile"));
1930 assert!(!is_token_like_target("kebab-case-name"));
1932 assert!(!is_token_like_target("fn process_data() {}"));
1933 assert!(!is_token_like_target("const FOO: i32 = 1;"));
1934 assert!(!is_token_like_target("server.port"));
1935 assert!(!is_token_like_target(""));
1936 assert!(!is_token_like_target(" "));
1937 }
1938
1939 #[test]
1940 fn fuzzy_fails_min_floor_fail_closed_without_score() {
1941 assert!(fuzzy_fails_min_floor(None, 0.8));
1942 assert!(fuzzy_fails_min_floor(Some(0.5), 0.8));
1943 assert!(!fuzzy_fails_min_floor(Some(0.9), 0.8));
1944 assert!(!fuzzy_fails_min_floor(Some(0.8), 0.8));
1945 }
1946
1947 #[test]
1949 fn resolve_kebab_case_uses_line_similarity_not_broken_token_path() {
1950 let content = "font-weight-primary: bold;\n";
1951 let r = resolve_with_fallback(content, "font-weight-primry: bold;", None, None)
1953 .expect("kebab line snippet should match via line similarity");
1954 assert_eq!(r.strategy, MatchStrategy::Similarity);
1955 assert!(
1956 r.matched_text.contains("font-weight-primary"),
1957 "{:?}",
1958 r.matched_text
1959 );
1960 }
1961
1962 #[test]
1963 fn resolve_with_fallback_structured_error() {
1964 let content = "fn alpha() {}\nfn beta() {}\n";
1965 let result = resolve_with_fallback(content, "fn completely_unrelated_xyz()", None, None);
1966 let err = result.unwrap_err();
1967 assert_eq!(err.kind, EditErrorKind::NoMatch);
1968 assert!(err.message.contains("target not found"));
1969 }
1970
1971 #[test]
1972 fn resolve_with_fallback_anchor_over_similarity() {
1973 let content = "fn setup() {}\nfn proces_data(x: i32) {}\nfn cleanup() {}\n";
1976 let result = resolve_with_fallback(
1977 content,
1978 "fn process_data(x: i32) {}",
1979 Some("fn setup() {}"),
1980 Some("fn cleanup() {}"),
1981 );
1982 let r = result.expect("anchor fallback should succeed");
1983 assert_eq!(r.strategy, MatchStrategy::Anchor);
1984 }
1985
1986 #[test]
1987 fn edit_error_serializes_to_json() {
1988 let err = EditError {
1989 kind: EditErrorKind::AmbiguousTarget,
1990 message: "found 3 matches".into(),
1991 suggestion: Some("use --nth to select one".into()),
1992 similar_targets: vec!["match1".into(), "match2".into()],
1993 };
1994 let json = serde_json::to_string(&err).unwrap();
1995 let deserialized: EditError = serde_json::from_str(&json).unwrap();
1996 assert_eq!(deserialized.kind, EditErrorKind::AmbiguousTarget);
1997 assert_eq!(deserialized.similar_targets.len(), 2);
1998 }
1999
2000 #[test]
2001 fn match_strategy_display() {
2002 assert_eq!(MatchStrategy::Exact.to_string(), "exact");
2003 assert_eq!(MatchStrategy::Anchor.to_string(), "anchor");
2004 assert_eq!(MatchStrategy::Similarity.to_string(), "similarity");
2005 }
2006
2007 #[test]
2008 fn extract_identifiers_from_code() {
2009 let line = "fn process_request(data: &str) -> Result<()> {";
2010 let ids = extract_identifiers(line);
2011 assert!(ids.contains(&"process_request"));
2012 assert!(ids.contains(&"data"));
2013 assert!(ids.contains(&"str"));
2014 assert!(ids.contains(&"Result"));
2015 }
2016
2017 #[test]
2018 fn validation_result_serializes() {
2019 let result = ValidationResult {
2020 valid: true,
2021 errors: vec![],
2022 warnings: vec!["some warning".into()],
2023 };
2024 let json = serde_json::to_string(&result).unwrap();
2025 let deserialized: ValidationResult = serde_json::from_str(&json).unwrap();
2026 assert!(deserialized.valid);
2027 assert_eq!(deserialized.warnings.len(), 1);
2028 }
2029
2030 #[test]
2031 fn truncate_safe_on_multibyte_utf8() {
2032 let s = "café";
2034 assert_eq!(s.len(), 5);
2035 let t = truncate_str(s, 4);
2038 assert_eq!(t, "caf");
2039 assert_eq!(truncate_str(s, 5), "café");
2041 assert_eq!(truncate_str(s, 3), "caf");
2043 assert_eq!(truncate_str(s, 0), "");
2045 }
2046
2047 #[test]
2048 fn validate_edit_toml_syntax_check() {
2049 let toml = "[database]\nhost = \"localhost\"\n";
2050 let result = validate_edit(toml, "localhost", "remotehost", Some("config.toml"));
2052 assert!(result.valid);
2053
2054 let result = validate_edit(
2056 toml,
2057 "[database]",
2058 "not valid toml {{{{",
2059 Some("config.toml"),
2060 );
2061 assert!(!result.valid);
2062 assert!(result.errors[0].contains("invalid TOML"));
2063 }
2064
2065 #[test]
2066 fn find_similar_targets_multi_word_pattern() {
2067 let content = "fn process_all_requests(data: &str) -> bool {\n true\n}\n";
2068 let similar = find_similar_targets(content, "fn process_all_reqests(data: &str)", 3);
2070 assert!(
2071 !similar.is_empty(),
2072 "should find similar multi-word targets"
2073 );
2074 }
2075
2076 #[test]
2077 fn find_similar_targets_dedups_repeated_tokens() {
2078 let content = "fn process_request() {}\nfn process_request() {}\nfn process_request() {}\n";
2079 let similar = find_similar_targets(content, "process_requst", 3);
2080 let hits: Vec<_> = similar.iter().filter(|s| *s == "process_request").collect();
2081 assert_eq!(
2082 hits.len(),
2083 1,
2084 "repeated identifiers must appear once: {similar:?}"
2085 );
2086 }
2087
2088 #[test]
2089 fn resolve_fallback_multi_line_no_similarity() {
2090 let content = "fn alpha() {}\nfn beta() {}\nfn gamma() {}\n";
2092 let result = resolve_with_fallback(content, "fn alphax() {}\nfn betax() {}", None, None);
2093 assert!(
2094 result.is_err(),
2095 "multi-line targets without context should not match via similarity"
2096 );
2097 let err = result.unwrap_err();
2098 assert_eq!(err.kind, EditErrorKind::NoMatch);
2099 }
2100
2101 #[test]
2102 fn resolve_with_fallback_multi_line_anchor_match() {
2103 let content = "fn header() {}\nfn proccess_data(x: i32) {\n x + 1\n}\nfn footer() {}\n";
2106 let result = resolve_with_fallback(
2107 content,
2108 "fn process_data(x: i32) {\n x + 1\n}",
2109 Some("fn header() {}"),
2110 Some("fn footer() {}"),
2111 );
2112 let r = result.expect("multi-line anchor should succeed");
2113 assert_eq!(r.strategy, MatchStrategy::Anchor);
2114 assert!(r.matched_text.contains("proccess_data"));
2115 assert!(r.matched_text.contains("x + 1"));
2116 }
2117
2118 #[test]
2119 fn validate_edit_yml_extension() {
2120 let yaml = "key: value\nlist:\n - item1\n";
2122 let result = validate_edit(yaml, "value", "new_value", Some("config.yml"));
2124 assert!(result.valid);
2125
2126 let result = validate_edit(yaml, "key: value", ":\n :\n - :", Some("config.yml"));
2128 assert!(
2129 !result.valid,
2130 ".yml extension should trigger YAML validation"
2131 );
2132 assert!(
2133 result.errors[0].contains("invalid YAML"),
2134 "expected YAML error, got: {}",
2135 result.errors[0]
2136 );
2137 }
2138
2139 #[test]
2142 fn validate_edit_nth_replaces_only_nth_occurrence() {
2143 let json = r#"{"a": "val", "b": "val"}"#;
2145 let result = validate_edit_nth(json, "val", "new", Some("data.json"), Some(1));
2146 assert!(result.valid, "nth=1 should produce valid JSON");
2147 }
2148
2149 #[test]
2150 fn validate_edit_nth_none_replaces_all() {
2151 let content = "aXbXc";
2153 let result = validate_edit_nth(content, "X", "Y", None, None);
2154 assert!(result.valid);
2155 }
2156
2157 #[test]
2158 fn validate_edit_nth_out_of_range() {
2159 let content = "aXbXc";
2161 let result = validate_edit_nth(content, "X", "Y", None, Some(5));
2162 assert!(
2163 !result.valid,
2164 "nth beyond occurrence count should be invalid"
2165 );
2166 assert!(
2167 result.errors[0].contains("occurrence 5 not found"),
2168 "error should mention the missing occurrence: {:?}",
2169 result.errors
2170 );
2171 }
2172
2173 #[test]
2174 fn validate_edit_nth_zero_rejected() {
2175 let content = r#"{"a": "val"}"#;
2177 let result = validate_edit_nth(content, "val", "X", Some("f.json"), Some(0));
2178 assert!(!result.valid);
2179 assert!(result.errors[0].contains("nth must be >= 1"));
2180 }
2181
2182 #[test]
2183 fn validate_edit_nth_detects_invalid_json_for_single_occurrence() {
2184 let json = r#"{"a": "value", "b": "value"}"#;
2186 let result = validate_edit_nth(json, "\"value\"", "broken}", Some("config.json"), Some(1));
2187 assert!(!result.valid, "nth=1 should detect broken JSON");
2188 }
2189
2190 #[test]
2193 fn anchor_match_after_only_context() {
2194 let content = "fn setup() {}\nfn proccess_data(x: i32) {}\nfn cleanup() {}\n";
2197 let result = anchor_match(
2198 content,
2199 "fn process_data(x: i32) {}",
2200 None,
2201 Some("fn cleanup() {}"),
2202 );
2203 let r = result.expect("after-only context should find anchor match");
2204 assert_eq!(r.strategy, MatchStrategy::Anchor);
2205 assert!(r.matched_text.contains("proccess_data"));
2206 }
2207
2208 #[test]
2209 fn anchor_match_multiple_candidates_returns_first() {
2210 let content = "fn header() {}\nfn proccess(x: i32) {}\nfn middle() {}\nfn proccess(y: bool) {}\nfn footer() {}\n";
2212 let result = anchor_match(
2213 content,
2214 "fn process(x: i32) {}",
2215 Some("fn header() {}"),
2216 Some("fn middle() {}"),
2217 );
2218 let r = result.expect("should match first candidate");
2219 assert_eq!(r.strategy, MatchStrategy::Anchor);
2220 assert!(
2222 r.matched_text.contains("x: i32"),
2223 "should match first occurrence, got: {}",
2224 r.matched_text
2225 );
2226 }
2227
2228 #[test]
2229 fn resolve_with_fallback_empty_content() {
2230 let result = resolve_with_fallback("", "fn hello()", None, None);
2231 let err = result.unwrap_err();
2232 assert_eq!(err.kind, EditErrorKind::NoMatch);
2233 }
2234
2235 #[test]
2236 fn anchor_match_multiline_before_context() {
2237 let content = "fn setup() {}\nfn target() {}\nfn cleanup() {}\n";
2240 let result = anchor_match(
2241 content,
2242 "fn target() {}",
2243 Some("fn other() {}\nfn setup() {}"),
2244 None,
2245 );
2246 assert!(
2247 result.is_some(),
2248 "anchor_match should match using the last line of multi-line before_context"
2249 );
2250 assert_eq!(result.unwrap().matched_text, "fn target() {}");
2251 }
2252
2253 #[test]
2254 fn anchor_match_multiline_after_context() {
2255 let content = "fn setup() {}\nfn target() {}\nfn cleanup() {}\n";
2257 let result = anchor_match(
2258 content,
2259 "fn target() {}",
2260 None,
2261 Some("fn cleanup() {}\nfn extra() {}"),
2262 );
2263 assert!(
2264 result.is_some(),
2265 "anchor_match should match using the first line of multi-line after_context"
2266 );
2267 assert_eq!(result.unwrap().matched_text, "fn target() {}");
2268 }
2269
2270 const _: () = {
2272 fn _assert<T: Send + Sync>() {}
2273 let _ = _assert::<EditError>;
2274 let _ = _assert::<EditErrorKind>;
2275 let _ = _assert::<ValidationResult>;
2276 let _ = _assert::<AnchorMatchResult>;
2277 let _ = _assert::<MatchStrategy>;
2278 };
2279}