Skip to main content

patchloom/
fallback.rs

1//! Multi-strategy fallback chain for edit resolution.
2//!
3//! When a primary edit operation fails (e.g., target text not found), this
4//! module provides progressively looser matching strategies before giving up.
5//!
6//! The fallback chain:
7//! 1. **Exact match** (primary, handled by the caller)
8//! 2. **Anchor-based matching** (structural landmarks around the edit target)
9//! 3. **Similarity scoring** (Jaro-Winkler on surrounding lines)
10//! 4. **Structured error** (diagnosis with suggestions)
11//!
12//! # Examples
13//!
14//! Suggest similar targets when a search misses:
15//!
16//! ```rust
17//! use patchloom::fallback::{EditError, EditErrorKind, find_similar_targets};
18//!
19//! let content = "fn process_request() {}\nfn process_response() {}\n";
20//! let similar = find_similar_targets(content, "process_requst", 3);
21//! assert!(similar.iter().any(|s| s.contains("process_request")));
22//! ```
23//!
24//! Run the full fallback chain (exact, anchor, similarity, structured error):
25//!
26//! ```rust
27//! use patchloom::fallback::{resolve_with_fallback, MatchStrategy};
28//!
29//! let content = "fn setup() {}\nfn proccess_data(x: i32) {}\nfn cleanup() {}\n";
30//! let result = resolve_with_fallback(
31//!     content,
32//!     "fn process_data(x: i32) {}",
33//!     Some("fn setup() {}"),
34//!     Some("fn cleanup() {}"),
35//!).expect("anchor match should succeed");
36//! assert_eq!(result.strategy, MatchStrategy::Anchor);
37//! assert!(result.matched_text.contains("proccess_data"));
38//! ```
39//!
40//! Validate an edit before applying it:
41//!
42//! ```rust
43//! use patchloom::fallback::validate_edit;
44//!
45//! let json = r#"{"key": "value"}"#;
46//! let result = validate_edit(json, "value", "new_value", Some("config.json"));
47//! assert!(result.valid);
48//! ```
49//!
50//! Validate with `--nth` semantics (only replace the Nth occurrence):
51//!
52//! ```rust
53//! use patchloom::fallback::validate_edit_nth;
54//!
55//! let json = r#"{"a": "val", "b": "val"}"#;
56//! let result = validate_edit_nth(json, "val", "new", Some("data.json"), Some(1));
57//! assert!(result.valid);
58//! ```
59//!
60//! size-waiver: accepted single-domain bulk (policy #1408). Multi-strategy edit recovery chain is one conceptual unit; do not split for LOC alone.
61
62use serde::{Deserialize, Serialize};
63
64/// Structured error type for edit operations with actionable diagnosis.
65#[derive(Debug, Clone, Serialize, Deserialize)]
66pub struct EditError {
67    /// What kind of error occurred.
68    pub kind: EditErrorKind,
69    /// Human-readable error message.
70    pub message: String,
71    /// A suggestion for how to fix the issue (if available).
72    pub suggestion: Option<String>,
73    /// Similar targets found in the file (for "did you mean?" hints).
74    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/// Classification of edit errors.
93///
94/// Marked `non_exhaustive` so new honesty kinds (for example [`Self::TypeError`])
95/// can land in minor releases without breaking external exhaustive matches.
96///
97/// **Append-only variants:** new kinds must be added **after** the last existing
98/// variant. Inserting in the middle shifts discriminants of later variants and
99/// fails `cargo-semver-checks` (`enum_no_repr_variant_discriminant_changed`),
100/// which blocks patch releases (see #1950 order fix for #1951).
101#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
102#[non_exhaustive]
103#[serde(rename_all = "snake_case")]
104pub enum EditErrorKind {
105    /// The edit target matched multiple locations in the file.
106    AmbiguousTarget,
107    /// The edit target was not found in the file.
108    NoMatch,
109    /// The edit would produce invalid syntax.
110    SyntaxInvalid,
111    /// Another edit already modified this region (for batch operations).
112    ConflictingEdit,
113    /// The file could not be parsed.
114    ParseError,
115    /// Path rejected by workspace PathGuard (#1492).
116    GuardRejected,
117    /// Invalid arguments or options for the edit.
118    InvalidInput,
119    /// Wrong value type for a doc selector (multi-document YAML bare key,
120    /// navigate into array as object, etc.). CLI JSON uses `error_kind:
121    /// "type_error"`. Distinct from [`Self::InvalidInput`] so hosts can
122    /// recover with `0.key` / `[0].key` without scraping English (#1883).
123    TypeError,
124    /// I/O or other operational failure.
125    OperationFailed,
126    /// Post-write format/lint hook failed (`format_failed` / #1663).
127    /// Distinct from generic [`Self::OperationFailed`] so hosts can branch
128    /// without scraping English.
129    FormatFailed,
130    /// Create/rename destination already exists without force.
131    /// CLI JSON uses `error_kind: "already_exists"`. Distinct from
132    /// [`Self::InvalidInput`] (empty path, directory target) and content SoftSkip
133    /// ([`Self::Binary`] / [`Self::InvalidEncoding`]) so hosts can hint
134    /// `overwrite`/`force` without scraping English (#1947 / #1963).
135    /// Appended after [`Self::FormatFailed`] so 0.18.0 discriminants stay stable.
136    AlreadyExists,
137    /// Path not found (`std::io::ErrorKind::NotFound`). CLI JSON uses
138    /// `error_kind: "not_found"`. Distinct from generic [`Self::OperationFailed`]
139    /// so hosts can treat missing paths separately from other I/O failures.
140    NotFound,
141    /// Patch/apply produced merge conflict markers. CLI JSON uses
142    /// `error_kind: "conflicts"`. Distinct from [`Self::ConflictingEdit`]
143    /// (batch region overlap) and generic [`Self::OperationFailed`].
144    Conflicts,
145    /// Check/preview reported pending changes, or soft assert-count mismatch
146    /// (`error_kind: "changes_detected"`, exit 2). Distinct from
147    /// [`Self::OperationFailed`] so hosts can mirror CLI exit-2 semantics
148    /// without scraping English.
149    ChangesDetected,
150    /// Target contains NUL in the binary probe window (not agent-editable text).
151    /// CLI JSON uses `error_kind: "binary"`. Distinct from [`Self::InvalidInput`]
152    /// (empty path, directory target, empty pattern) so hosts can recover
153    /// overwrite/delete/rename without treating all invalid input as binary (#1963).
154    /// Appended after [`Self::ChangesDetected`] so 0.18/0.19 discriminants stay stable.
155    Binary,
156    /// Target is not valid UTF-8 (and not binary by NUL probe). CLI JSON uses
157    /// `error_kind: "invalid_encoding"`. Distinct from [`Self::Binary`] and
158    /// [`Self::InvalidInput`] (#1963).
159    InvalidEncoding,
160    /// Fuzzy match span is over-wide vs `old` under [`crate::api::FuzzySpanPolicy`]
161    /// (or the host refused via `ReplaceOptions::refuse_suspicious_fuzzy`).
162    /// CLI/library JSON uses `error_kind: "fuzzy_span_suspicious"` (#2005).
163    /// Appended after [`Self::InvalidEncoding`] so 0.20/0.21 discriminants stay
164    /// stable.
165    FuzzySpanSuspicious,
166    /// Tree-sitter parse hit the per-file deadline (`error_kind: "parse_timeout"`).
167    /// Distinct from [`Self::ParseError`] so hosts can retry or skip without
168    /// treating a timeout as a missing grammar (#2406). Append-only after
169    /// [`Self::FuzzySpanSuspicious`].
170    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    /// Build a structured edit error.
200    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    /// PathGuard rejection as an `anyhow` error (`edit_error_kind` → [`EditErrorKind::GuardRejected`]).
210    ///
211    /// Prefer this over [`crate::exit::InvalidInputError`] for guard failures so
212    /// library hosts and CLI JSON can branch on `guard_rejected` without scraping
213    /// English (#1935 / engine PathGuard paths).
214    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    /// Attach similar-target suggestions (did-you-mean).
223    pub fn with_similar(mut self, similar: Vec<String>) -> Self {
224        self.similar_targets = similar;
225        self
226    }
227
228    /// Attach a single suggestion string.
229    pub fn with_suggestion(mut self, suggestion: impl Into<String>) -> Self {
230        self.suggestion = Some(suggestion.into());
231        self
232    }
233}
234
235/// Downcast an `anyhow` error chain to [`EditErrorKind`] when present.
236///
237/// Peels both [`EditError`] and the CLI/tx typed errors in [`crate::exit`]
238/// (`InvalidInputError`, `NoMatchError`, …) so library hosts can branch on
239/// kind without caring which construction path produced the failure.
240///
241/// Hosts that do **not** depend on `anyhow` should use [`classify_error`]
242/// on a `dyn Error` instead (#1659).
243pub 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
252/// Downcast an `anyhow` error chain to [`EditError`] when present.
253pub 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
262/// Classify a bare `dyn Error` (no `anyhow` required) into [`EditErrorKind`].
263///
264/// Walks `source()` and peels [`EditError`] plus the CLI/tx typed errors
265/// (`NoMatchError`, `InvalidInputError`, `AmbiguousError`, …). Use this from
266/// non-anyhow agent hosts that store `Box<dyn Error>` or `Arc<dyn Error>`.
267///
268/// ```rust
269/// use patchloom::fallback::{classify_error, EditError, EditErrorKind};
270///
271/// let err: Box<dyn std::error::Error + Send + Sync> =
272///     Box::new(EditError::new(EditErrorKind::NoMatch, "no matches for \"x\""));
273/// assert_eq!(classify_error(err.as_ref()), Some(EditErrorKind::NoMatch));
274/// ```
275///
276/// See #1659.
277pub 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/// Same stable kind string CLI JSON uses (`already_exists`, `guard_rejected`, …).
339///
340/// Library hosts that mirror agent JSON envelopes should use this instead of
341/// re-implementing [`crate::exit::classify_typed_error`] or scraping Display
342/// (#1948). Returns `None` when the chain has no recognized typed kind.
343///
344/// Prefer this over matching only [`edit_error_kind`] when you need CLI-stable
345/// strings (for example `no_matches` vs Display `no_match`).
346#[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/// Whether the error peels as dest-exists create/rename conflict.
352///
353/// True for both [`crate::exit::AlreadyExistsError`] and
354/// [`EditError`] with [`EditErrorKind::AlreadyExists`], so the bool matches
355/// what hosts get from [`edit_error_kind`] (#1947).
356#[must_use]
357pub fn is_already_exists(err: &anyhow::Error) -> bool {
358    edit_error_kind(err) == Some(EditErrorKind::AlreadyExists)
359}
360
361/// Whether the error peels as missing path I/O ([`EditErrorKind::NotFound`]).
362///
363/// Matches IO `NotFound` (including through `anyhow::Context`) and
364/// [`EditError`] with [`EditErrorKind::NotFound`]. Prefer this over scraping
365/// Display for host recovery when a path is simply absent.
366#[must_use]
367pub fn is_not_found(err: &anyhow::Error) -> bool {
368    edit_error_kind(err) == Some(EditErrorKind::NotFound)
369}
370
371/// Whether the error peels as patch/apply merge conflict markers
372/// ([`EditErrorKind::Conflicts`]). Distinct from batch
373/// [`EditErrorKind::ConflictingEdit`].
374#[must_use]
375pub fn is_conflicts(err: &anyhow::Error) -> bool {
376    edit_error_kind(err) == Some(EditErrorKind::Conflicts)
377}
378
379/// Whether the error peels as check/preview pending changes or soft
380/// assert-count mismatch ([`EditErrorKind::ChangesDetected`], CLI exit 2).
381#[must_use]
382pub fn is_changes_detected(err: &anyhow::Error) -> bool {
383    edit_error_kind(err) == Some(EditErrorKind::ChangesDetected)
384}
385
386/// Whether the error peels as multi-doc / wrong-root type mismatch
387/// ([`EditErrorKind::TypeError`]). Distinct from [`EditErrorKind::InvalidInput`].
388#[must_use]
389pub fn is_type_error(err: &anyhow::Error) -> bool {
390    edit_error_kind(err) == Some(EditErrorKind::TypeError)
391}
392
393/// Whether the error peels as post-write format/lint failure
394/// ([`EditErrorKind::FormatFailed`]). Files may already be written.
395#[must_use]
396pub fn is_format_failed(err: &anyhow::Error) -> bool {
397    edit_error_kind(err) == Some(EditErrorKind::FormatFailed)
398}
399
400/// Whether the error peels as PathGuard / `--contain` rejection
401/// ([`EditErrorKind::GuardRejected`]).
402#[must_use]
403pub fn is_guard_rejected(err: &anyhow::Error) -> bool {
404    edit_error_kind(err) == Some(EditErrorKind::GuardRejected)
405}
406
407/// Whether the error peels as invalid arguments / sole binary / bad options
408/// ([`EditErrorKind::InvalidInput`]).
409#[must_use]
410pub fn is_invalid_input(err: &anyhow::Error) -> bool {
411    edit_error_kind(err) == Some(EditErrorKind::InvalidInput)
412}
413
414/// Whether the error peels as soft no-match ([`EditErrorKind::NoMatch`]).
415/// CLI JSON kind is `no_matches` via [`error_kind_str`].
416#[must_use]
417pub fn is_no_match(err: &anyhow::Error) -> bool {
418    edit_error_kind(err) == Some(EditErrorKind::NoMatch)
419}
420
421/// Whether the error peels as multi-match / unique-mode ambiguity
422/// ([`EditErrorKind::AmbiguousTarget`]). Distinct from soft [`is_no_match`].
423///
424/// Hosts with `unique: true` / `require_change` multi-hit recovery should prefer
425/// this over scraping Display.
426#[must_use]
427pub fn is_ambiguous(err: &anyhow::Error) -> bool {
428    edit_error_kind(err) == Some(EditErrorKind::AmbiguousTarget)
429}
430
431/// Whether the error peels as binary/NUL content ([`EditErrorKind::Binary`]).
432///
433/// Distinct from [`is_invalid_input`] (argument mistakes) and
434/// [`is_invalid_encoding`] (non-UTF-8 text without NUL). Hosts that recover
435/// overwrite of non-text priors should branch on this (or [`is_invalid_encoding`])
436/// rather than all of `InvalidInput` (#1963).
437#[must_use]
438pub fn is_binary(err: &anyhow::Error) -> bool {
439    edit_error_kind(err) == Some(EditErrorKind::Binary)
440}
441
442/// Whether the error peels as invalid UTF-8 content
443/// ([`EditErrorKind::InvalidEncoding`]). Distinct from [`is_binary`] and
444/// [`is_invalid_input`] (#1963).
445#[must_use]
446pub fn is_invalid_encoding(err: &anyhow::Error) -> bool {
447    edit_error_kind(err) == Some(EditErrorKind::InvalidEncoding)
448}
449
450/// Whether the error peels as over-wide fuzzy span refuse
451/// ([`EditErrorKind::FuzzySpanSuspicious`]) from
452/// `ReplaceOptions::refuse_suspicious_fuzzy` / `for_agent` (#2005).
453#[must_use]
454pub fn is_fuzzy_span_suspicious(err: &anyhow::Error) -> bool {
455    edit_error_kind(err) == Some(EditErrorKind::FuzzySpanSuspicious)
456}
457
458/// One-shot peel of a library/CLI error for host tool envelopes (#1964).
459///
460/// Combines [`error_kind_str`], [`edit_error_ref`], and agent-facing message
461/// so embedders do not reimplement `edit_error_kind` + `error_kind_str` +
462/// Display formatting. Returns `None` when the chain has no recognized kind.
463#[derive(Debug, Clone)]
464pub struct PeeledError {
465    /// CLI-stable kind string (`already_exists`, `binary`, …).
466    pub kind_str: &'static str,
467    /// Agent-facing message (same as CLI JSON `error` when possible).
468    pub message: String,
469    /// Optional suggestion from [`EditError`].
470    pub suggestion: Option<String>,
471    /// Similar targets from [`EditError`] (did-you-mean).
472    pub similar_targets: Vec<String>,
473}
474
475/// Peel kind string + message + optional suggestion from an error chain (#1964).
476#[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
491/// Downcast a bare `dyn Error` chain to [`EditError`] when present (#1659).
492///
493/// Prefer this when you need `similar_targets` / `suggestion` without
494/// depending on `anyhow`.
495pub 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/// Result of `validate_edit()`: whether the edit would produce valid output.
507#[derive(Debug, Clone, Serialize, Deserialize)]
508pub struct ValidationResult {
509    /// Whether the edit produces valid output.
510    pub valid: bool,
511    /// Syntax errors found (if invalid).
512    pub errors: Vec<String>,
513    /// Suspicious but technically valid issues.
514    pub warnings: Vec<String>,
515}
516
517/// Validate whether a replacement would produce valid output, without applying.
518///
519/// Performs the replacement in memory and checks basic structural validity
520/// of the resulting content (JSON, YAML, TOML validation for structured files).
521///
522/// When `nth` is `Some(n)`, only the Nth (1-based) occurrence is replaced,
523/// matching the behavior of the replace command's `--nth` flag. When `None`,
524/// all occurrences are replaced.
525pub 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
534/// Like [`validate_edit`] but with an explicit `nth` occurrence parameter.
535pub 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            // Replace only the Nth (1-based) occurrence.
571            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 we can detect the file format, validate the result.
604    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    // Warn if the replacement creates unbalanced brackets/braces.
637    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
661/// Minimum Jaro-Winkler score for a did-you-mean candidate.
662///
663/// Kept in line with anchor matching (0.85). The previous 0.7 floor
664/// admitted noise like `nold` for long unrelated patterns when scanning
665/// large trees (fixrealloop stress, 2026-07-23).
666const SIMILAR_TARGET_MIN_SCORE: f64 = 0.85;
667
668/// Whether `candidate` is a plausible typo of `target` by length.
669///
670/// Jaro-Winkler can score short tokens above the floor against long
671/// strings (shared characters / prefixes). Reject extreme length skew
672/// so agents do not chase nonsense suggestions.
673fn 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    // Allow ~2x length difference, or a small absolute gap for short ids.
680    shorter.saturating_mul(2) >= longer || longer - shorter <= 3
681}
682
683/// Rank whole-string candidates by Jaro-Winkler similarity.
684///
685/// Unlike [`find_similar_targets`], this does not tokenize `candidates`.
686/// Use it for object keys and other already-atomic strings (hyphenated
687/// names stay intact).
688pub(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
720/// Find similar text targets in file content using Jaro-Winkler similarity.
721///
722/// Extracts identifiers and substrings from the content and returns the top
723/// `max_results` matches sorted by similarity score (descending).
724pub 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    // Dedup before materializing: HashSet<&str> over borrowed slices so
730    // repeated identifiers do not each allocate a String (#2292).
731    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    // Also try matching against whole lines for multi-word patterns.
741    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
752/// Try anchor-based matching: find the target text using surrounding context lines.
753///
754/// If `target` is not found exactly, looks for lines that share anchor text
755/// (the lines immediately before and after the target in the original context)
756/// and returns the matching region.
757pub 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 exact match exists, return it directly.
768    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    // Try anchor-based matching using before/after context.
778    let lines: Vec<&str> = content.lines().collect();
779    let target_lines: Vec<&str> = target.lines().collect();
780
781    // Compute byte offset of each line start for accurate slicing,
782    // avoiding CRLF vs LF mismatch (the old code used a global
783    // heuristic that broke on mixed or CRLF line endings).
784    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    // Anchor matching requires at least one piece of structural context
799    // (before_context or after_context). Without context, it would
800    // degenerate into the same Jaro-Winkler line scan that the
801    // similarity path already does, making the fallback chain redundant.
802    if before_context.is_none() && after_context.is_none() {
803        return None;
804    }
805
806    // Find candidate positions by matching the first line with anchors.
807    for (i, line) in lines.iter().enumerate() {
808        let trimmed = line.trim();
809
810        // Check if this line is similar to the first target line.
811        if strsim::jaro_winkler(trimmed, first_target) < 0.85 {
812            continue;
813        }
814
815        // For multi-line targets, also verify the last line of the
816        // candidate region is similar to the last target line.
817        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 we have before_context, verify the preceding line matches.
829        // For multi-line context, compare only the last line (the one
830        // immediately before the target region).
831        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 we have after_context, check the line after the candidate region.
843        // For multi-line context, compare only the first line (the one
844        // immediately after the target region).
845        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        // Found a match. Extract the matched region directly from the
858        // original content so line endings are preserved exactly.
859        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        // Strip exactly one trailing line ending to match the format
867        // of exact-match results (which carry no trailing newline).
868        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/// Result of anchor-based matching.
887#[derive(Debug, Clone)]
888pub struct AnchorMatchResult {
889    /// The text that was matched.
890    pub matched_text: String,
891    /// Byte offset of the match start in the content.
892    pub start_offset: usize,
893    /// Which matching strategy succeeded.
894    pub strategy: MatchStrategy,
895    /// Similarity score when [`MatchStrategy::Similarity`] succeeded (#1662).
896    pub score: Option<f64>,
897}
898
899/// Which matching strategy found the result.
900#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
901#[serde(rename_all = "snake_case")]
902pub enum MatchStrategy {
903    /// Exact literal match.
904    Exact,
905    /// Anchor-based matching using surrounding context.
906    Anchor,
907    /// Similarity-based fuzzy matching.
908    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
921/// Run the full fallback chain: exact -> anchor -> similarity -> structured error.
922///
923/// Returns the first successful match or a structured error with diagnosis.
924///
925/// Stable 4-arg surface. Callers that already enforced a stricter exact path
926/// (for example `--word-boundary`) should use [`resolve_with_fallback_skip_exact`]
927/// so bare `find` does not re-accept a substring the primary path rejected.
928pub 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
937/// Like [`resolve_with_fallback`], with control over the bare Exact tier.
938///
939/// When `skip_exact` is true, bare `find` Exact matching is skipped. Callers that
940/// already ran a stricter exact path (for example `--word-boundary`) must set
941/// this so fallback does not re-accept a substring that word boundaries rejected
942/// (#1755). Similarity and anchor strategies still run (whole-token typo recovery).
943pub 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    // Step 1: Exact match (unless caller already enforced a stricter exact path).
951    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    // Step 2: Anchor-based matching.
961    if let Some(result) = anchor_match(content, target, before_context, after_context) {
962        // anchor_match may still surface Exact via its own find; reject that when
963        // the caller asked to skip bare exact (word_boundary).
964        if !(skip_exact && result.strategy == MatchStrategy::Exact) {
965            return Ok(result);
966        }
967    }
968
969    // Step 3: Similarity-based matching (#1694).
970    //
971    // Single-token targets (identifiers / typos) must match **tokens**, not whole
972    // lines. Jaro-Winkler on a full source line vs a short identifier scores high
973    // because of shared prefixes, then the apply path replaces the entire line and
974    // deletes surrounding syntax (`const`, types, etc.).
975    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            // Do not fall through to whole-line similarity for token-like targets.
983        } else if let Some(hit) = best_line_similarity(content, target_trim) {
984            return Ok(hit);
985        }
986    }
987
988    // Step 4: Structured error with diagnosis.
989    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
1004/// Whether a fuzzy match should be rejected by `min_fuzzy_score` (#1687).
1005///
1006/// Fail closed when score is missing: hosts that set a floor must not apply
1007/// unscored fuzzy matches (anchor/legacy paths without a score cannot prove
1008/// they clear the floor).
1009pub(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
1016/// #1758: refuse Similarity/fuzzy apply when exact `old` was absent unless opt-in.
1017///
1018/// Anchored (explicit context) matches are not refused here — the host supplied
1019/// landmarks. Only when the host opted into `fuzzy` and the resolve path returns
1020/// Similarity/Fuzzy mode do we fail closed by default. Context-only fallback
1021/// (before/after without `fuzzy`) may still land Similarity without this gate
1022/// so structural recovery stays usable.
1023pub(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
1031/// Diagnostic when refusing fuzzy apply because exact `old` is not in the file.
1032pub(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
1049/// True when `target` is a single identifier-like token (no whitespace).
1050///
1051/// Multi-word / snippet targets keep whole-line similarity. Token-like targets
1052/// use identifier-level matching so fuzzy typo recovery does not expand the
1053/// replace span to an entire source line (#1694).
1054fn is_token_like_target(target: &str) -> bool {
1055    if target.is_empty() || target.chars().any(|c| c.is_whitespace()) {
1056        return false;
1057    }
1058    // Match extract_identifiers: alphanumeric + underscore only. Hyphens would
1059    // mark the target as "token-like" while extraction splits on `-`, so a
1060    // kebab-case typo never matched a full token and skipped line fallback.
1061    target.chars().any(|c| c.is_alphanumeric())
1062        && target.chars().all(|c| c.is_alphanumeric() || c == '_')
1063}
1064
1065/// Best identifier similarity hit, if score > 0.85.
1066fn 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
1097/// Whole-line similarity for multi-word / snippet targets.
1098///
1099/// Refuses a match when the line is much longer than the target (ratio > 2)
1100/// so accidental line expansion stays rare even for long snippets (#1694).
1101fn 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        // Too expansive: treat as no similarity match (suggestions still help).
1124        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    // Advance past the line content and the actual line ending (\r\n or \n).
1136    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
1146/// Extract identifier-like tokens from a line of code.
1147fn extract_identifiers(line: &str) -> Vec<&str> {
1148    extract_identifiers_with_offsets(line)
1149        .into_iter()
1150        .map(|(s, _)| s)
1151        .collect()
1152}
1153
1154/// Identifier tokens with byte offsets into `line` (#1694).
1155fn 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
1182/// Truncate a string for display in error messages.
1183///
1184/// Truncates at the last char boundary at or before `max_len` bytes,
1185/// so this is safe for multi-byte UTF-8 input.
1186pub(crate) fn truncate_str(s: &str, max_len: usize) -> &str {
1187    if s.len() <= max_len {
1188        s
1189    } else {
1190        // Find the last char boundary at or before max_len.
1191        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    /// Locks discriminants published in 0.18.0 so new kinds stay append-only
1249    /// (`enum_no_repr_variant_discriminant_changed` / release PR #1951).
1250    #[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        // #1947/#1950 kinds must follow FormatFailed (never insert above).
1263        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        // #1963 kinds append after ChangesDetected (never insert above).
1268        assert_eq!(EditErrorKind::Binary as u8, 14);
1269        assert_eq!(EditErrorKind::InvalidEncoding as u8, 15);
1270        // #2005 append after InvalidEncoding (never insert above).
1271        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(&not_found),
1427            Some(EditErrorKind::NotFound),
1428            "IO NotFound must peel as NotFound not OperationFailed"
1429        );
1430        assert_eq!(error_kind_str(&not_found), Some("not_found"));
1431        assert!(
1432            is_not_found(&not_found),
1433            "is_not_found must match IO NotFound peel"
1434        );
1435        assert!(!is_already_exists(&not_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        // Intermediate .context() must not hide the typed kind.
1459        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        // Valid replacement.
1497        let result = validate_edit(json, "value", "new_value", Some("config.json"));
1498        assert!(result.valid);
1499
1500        // Invalid replacement (breaks JSON).
1501        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); // Still valid (we can't know the language syntax).
1518        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        // Real multi-file no-match noise (fixrealloop stress): long invented
1537        // pattern vs short tokens like "nold" can clear a loose JW floor.
1538        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        // Simulate a case where the target line changed slightly.
1595        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        // Without context, anchor matching skips the fuzzy path and returns
1617        // None even if a similar line exists. This prevents anchor from
1618        // duplicating the similarity path in the fallback chain.
1619        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        // Multi-line anchor matching must check both the first and last
1627        // lines of the candidate region, not just the first.
1628        let content = "fn setup() {}\nfn process(x: i32) {}\nfn teardown() {}\nfn other() {}\n";
1629        // Target has a matching first line but wrong last line.
1630        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        // "line1\r\n" is 7 bytes, so line2 starts at offset 7.
1644        assert_eq!(result.start_offset, 7);
1645    }
1646
1647    /// Regression: anchor match on CRLF content must produce a matched_text
1648    /// that is an exact substring of the original content. The old code
1649    /// joined lines with "\n" which produced LF-only text, wrong for CRLF.
1650    #[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        // matched_text must be verifiable against the original content.
1662        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        // And it should contain the CRLF between lines.
1669        assert!(
1670            result.matched_text.contains("\r\n"),
1671            "matched text should preserve CRLF line endings"
1672        );
1673    }
1674
1675    /// Regression: anchor match on mixed line endings (some CRLF, some LF)
1676    /// must produce correct offsets for each line, not a global decision.
1677    #[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        // "header\r\n" is 8 bytes, so line 2 starts at offset 8.
1688        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    /// #1755: skip_exact must not re-accept a bare substring after a stricter
1701    /// primary path (e.g. word_boundary) already rejected it.
1702    #[test]
1703    fn resolve_with_fallback_skip_exact_rejects_substring() {
1704        let content = "process_data process_data_extra\n";
1705        // Without skip_exact, bare find would match "process_dat" inside process_data.
1706        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        // With skip_exact, Exact is skipped; token similarity may recover the
1711        // full identifier (process_data) or miss — but never the bare substring.
1712        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    /// Regression: similarity matching on CRLF content must compute
1729    /// correct byte offsets. The old code used `offset += line.len() + 1`
1730    /// which hardcoded LF (1 byte), wrong for CRLF (2 bytes).
1731    #[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        // "fn alpha() {}\r\n" is 15 bytes, so the match starts at offset 15.
1739        assert_eq!(result.start_offset, 15);
1740        // Verify the offset points to the correct position in content.
1741        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        // Without context, anchor matching is skipped (it requires at least
1750        // before_context or after_context to avoid degenerating into the same
1751        // Jaro-Winkler scan that similarity already does). The similarity
1752        // path catches the misspelled target instead.
1753        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        // Multi-word snippet → whole-line match (not a bare identifier).
1763        assert!(
1764            r.matched_text.contains("process_request"),
1765            "snippet match: {:?}",
1766            r.matched_text
1767        );
1768    }
1769
1770    /// #1694: single-token fuzzy must not expand to the whole source line.
1771    #[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        // Surrounding syntax must still be outside the match span.
1786        assert!(!r.matched_text.contains("const"));
1787        assert!(!r.matched_text.contains("i32"));
1788    }
1789
1790    /// #1694: intentional full-line target still uses line similarity.
1791    #[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    /// Embedder-style identifier typos across real source shapes (#1694 contract).
1833    #[test]
1834    fn resolve_token_typo_matrix_preserves_non_identifier_syntax() {
1835        // (content, typo_target, expected_matched_token, must_not_be_in_match)
1836        let cases: &[(&str, &str, &str, &[&str])] = &[
1837            // Rust const + use site (Bline #1694 repro shape).
1838            (
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            // Rust function name typo (token, not full signature).
1845            (
1846                "fn process_request(data: &str) -> Result<()> {\n    Ok(())\n}\n",
1847                "process_requets",
1848                "process_request",
1849                &["fn", "Result", "data"],
1850            ),
1851            // Python def.
1852            (
1853                "def load_configuration_value():\n    return 1\n",
1854                "load_configration_value",
1855                "load_configuration_value",
1856                &["def", "return"],
1857            ),
1858            // JS/TS const + call.
1859            (
1860                "const getUserProfile = () => null;\nexport { getUserProfile };\n",
1861                "getUserProfle",
1862                "getUserProfile",
1863                &["const", "export"],
1864            ),
1865            // YAML-ish key in a line with punctuation (identifier extraction).
1866            (
1867                "server_port_primary: 8080\n",
1868                "server_port_primry",
1869                "server_port_primary",
1870                &[":", "8080"],
1871            ),
1872            // camelCase method.
1873            (
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            // Token-like targets must never expand to multi-word spans.
1901            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        // First line occurrence (offset 0).
1915        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        // Hyphenated names are not identifier tokens (extraction splits on `-`).
1931        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    /// Kebab-case targets use line similarity (not broken token path).
1948    #[test]
1949    fn resolve_kebab_case_uses_line_similarity_not_broken_token_path() {
1950        let content = "font-weight-primary: bold;\n";
1951        // Near-full-line typo; must still resolve (line path), not dead-end as token.
1952        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        // When context is provided, anchor matching fires before similarity
1974        // and should win.
1975        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        // "café" has bytes: c(1) a(1) f(1) é(2) = 5 bytes, 4 chars.
2033        let s = "café";
2034        assert_eq!(s.len(), 5);
2035        // Truncate at 4 would land in the middle of 'é' (bytes 3-4).
2036        // Must not panic; should truncate before the multi-byte char.
2037        let t = truncate_str(s, 4);
2038        assert_eq!(t, "caf");
2039        // Truncate at 5 returns the whole string.
2040        assert_eq!(truncate_str(s, 5), "café");
2041        // Truncate at 3 is a clean boundary.
2042        assert_eq!(truncate_str(s, 3), "caf");
2043        // Truncate at 0.
2044        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        // Valid replacement.
2051        let result = validate_edit(toml, "localhost", "remotehost", Some("config.toml"));
2052        assert!(result.valid);
2053
2054        // Invalid replacement (breaks TOML).
2055        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        // Multi-word pattern triggers whole-line matching path.
2069        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        // Multi-line targets skip the similarity path and go to error.
2091        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        // Multi-line target with context should resolve via anchor matching
2104        // through the full fallback chain (not just the anchor_match helper).
2105        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        // The .yml extension (not just .yaml) should trigger YAML validation.
2121        let yaml = "key: value\nlist:\n  - item1\n";
2122        // Valid replacement.
2123        let result = validate_edit(yaml, "value", "new_value", Some("config.yml"));
2124        assert!(result.valid);
2125
2126        // Invalid replacement that breaks YAML structure.
2127        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    // -- validate_edit_nth (#1061) -------------------------------------------
2140
2141    #[test]
2142    fn validate_edit_nth_replaces_only_nth_occurrence() {
2143        // Two occurrences of "val"; replacing only the 1st is valid JSON.
2144        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        // nth=None replaces all occurrences (same as validate_edit).
2152        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        // nth=5 but only 2 occurrences: should return invalid with descriptive error.
2160        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        // nth=0 is invalid (1-based indexing); must not truncate content.
2176        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        // Replacing only the 1st "value" with a broken fragment is invalid.
2185        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    // -- anchor_match and resolve_with_fallback edge cases (#978) -----------
2191
2192    #[test]
2193    fn anchor_match_after_only_context() {
2194        // after_context without before_context hits the code path where
2195        // before_context is None but after_context is checked.
2196        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        // Target appears (fuzzily) twice; anchor_match returns the first match.
2211        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        // The first candidate contains "x: i32", not "y: bool".
2221        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        // Multi-line before_context should use the last line for matching,
2238        // not the entire multi-line string.
2239        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        // Multi-line after_context should use the first line for matching.
2256        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    // Static assertions: types must be Send + Sync.
2271    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}