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}
167
168impl std::fmt::Display for EditErrorKind {
169    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
170        match self {
171            EditErrorKind::AmbiguousTarget => write!(f, "ambiguous_target"),
172            EditErrorKind::NoMatch => write!(f, "no_match"),
173            EditErrorKind::SyntaxInvalid => write!(f, "syntax_invalid"),
174            EditErrorKind::ConflictingEdit => write!(f, "conflicting_edit"),
175            EditErrorKind::ParseError => write!(f, "parse_error"),
176            EditErrorKind::GuardRejected => write!(f, "guard_rejected"),
177            EditErrorKind::InvalidInput => write!(f, "invalid_input"),
178            EditErrorKind::TypeError => write!(f, "type_error"),
179            EditErrorKind::OperationFailed => write!(f, "operation_failed"),
180            EditErrorKind::FormatFailed => write!(f, "format_failed"),
181            EditErrorKind::AlreadyExists => write!(f, "already_exists"),
182            EditErrorKind::NotFound => write!(f, "not_found"),
183            EditErrorKind::Conflicts => write!(f, "conflicts"),
184            EditErrorKind::ChangesDetected => write!(f, "changes_detected"),
185            EditErrorKind::Binary => write!(f, "binary"),
186            EditErrorKind::InvalidEncoding => write!(f, "invalid_encoding"),
187            EditErrorKind::FuzzySpanSuspicious => write!(f, "fuzzy_span_suspicious"),
188        }
189    }
190}
191
192impl EditError {
193    /// Build a structured edit error.
194    pub fn new(kind: EditErrorKind, message: impl Into<String>) -> Self {
195        Self {
196            kind,
197            message: message.into(),
198            suggestion: None,
199            similar_targets: Vec::new(),
200        }
201    }
202
203    /// PathGuard rejection as an `anyhow` error (`edit_error_kind` → [`EditErrorKind::GuardRejected`]).
204    ///
205    /// Prefer this over [`crate::exit::InvalidInputError`] for guard failures so
206    /// library hosts and CLI JSON can branch on `guard_rejected` without scraping
207    /// English (#1935 / engine PathGuard paths).
208    pub fn guard_rejected(detail: impl std::fmt::Display) -> anyhow::Error {
209        Self::new(
210            EditErrorKind::GuardRejected,
211            format!("path rejected by workspace guard: {detail}"),
212        )
213        .into()
214    }
215
216    /// Attach similar-target suggestions (did-you-mean).
217    pub fn with_similar(mut self, similar: Vec<String>) -> Self {
218        self.similar_targets = similar;
219        self
220    }
221
222    /// Attach a single suggestion string.
223    pub fn with_suggestion(mut self, suggestion: impl Into<String>) -> Self {
224        self.suggestion = Some(suggestion.into());
225        self
226    }
227}
228
229/// Downcast an `anyhow` error chain to [`EditErrorKind`] when present.
230///
231/// Peels both [`EditError`] and the CLI/tx typed errors in [`crate::exit`]
232/// (`InvalidInputError`, `NoMatchError`, …) so library hosts can branch on
233/// kind without caring which construction path produced the failure.
234///
235/// Hosts that do **not** depend on `anyhow` should use [`classify_error`]
236/// on a `dyn Error` instead (#1659).
237pub fn edit_error_kind(err: &anyhow::Error) -> Option<EditErrorKind> {
238    for cause in err.chain() {
239        if let Some(kind) = classify_error(cause) {
240            return Some(kind);
241        }
242    }
243    None
244}
245
246/// Downcast an `anyhow` error chain to [`EditError`] when present.
247pub fn edit_error_ref(err: &anyhow::Error) -> Option<&EditError> {
248    for cause in err.chain() {
249        if let Some(e) = classify_error_ref(cause) {
250            return Some(e);
251        }
252    }
253    None
254}
255
256/// Classify a bare `dyn Error` (no `anyhow` required) into [`EditErrorKind`].
257///
258/// Walks `source()` and peels [`EditError`] plus the CLI/tx typed errors
259/// (`NoMatchError`, `InvalidInputError`, `AmbiguousError`, …). Use this from
260/// non-anyhow agent hosts that store `Box<dyn Error>` or `Arc<dyn Error>`.
261///
262/// ```rust
263/// use patchloom::fallback::{classify_error, EditError, EditErrorKind};
264///
265/// let err: Box<dyn std::error::Error + Send + Sync> =
266///     Box::new(EditError::new(EditErrorKind::NoMatch, "no matches for \"x\""));
267/// assert_eq!(classify_error(err.as_ref()), Some(EditErrorKind::NoMatch));
268/// ```
269///
270/// See #1659.
271pub fn classify_error(err: &(dyn std::error::Error + 'static)) -> Option<EditErrorKind> {
272    let mut current: Option<&(dyn std::error::Error + 'static)> = Some(err);
273    while let Some(e) = current {
274        if let Some(edit) = e.downcast_ref::<EditError>() {
275            return Some(edit.kind);
276        }
277        if e.downcast_ref::<crate::exit::NoMatchError>().is_some() {
278            return Some(EditErrorKind::NoMatch);
279        }
280        if e.downcast_ref::<crate::exit::AmbiguousError>().is_some() {
281            return Some(EditErrorKind::AmbiguousTarget);
282        }
283        if e.downcast_ref::<crate::exit::InvalidInputError>().is_some() {
284            return Some(EditErrorKind::InvalidInput);
285        }
286        if e.downcast_ref::<crate::exit::AlreadyExistsError>()
287            .is_some()
288        {
289            return Some(EditErrorKind::AlreadyExists);
290        }
291        if e.downcast_ref::<crate::exit::TypeErrorError>().is_some() {
292            return Some(EditErrorKind::TypeError);
293        }
294        if e.downcast_ref::<crate::exit::ParseErrorError>().is_some() {
295            return Some(EditErrorKind::ParseError);
296        }
297        if e.downcast_ref::<crate::exit::FormatFailedError>().is_some() {
298            return Some(EditErrorKind::FormatFailed);
299        }
300        if e.downcast_ref::<crate::exit::ConflictsError>().is_some() {
301            return Some(EditErrorKind::Conflicts);
302        }
303        if e.downcast_ref::<crate::exit::ChangesDetectedError>()
304            .is_some()
305        {
306            return Some(EditErrorKind::ChangesDetected);
307        }
308        if e.downcast_ref::<crate::exit::BinaryError>().is_some() {
309            return Some(EditErrorKind::Binary);
310        }
311        if e.downcast_ref::<crate::exit::InvalidEncodingError>()
312            .is_some()
313        {
314            return Some(EditErrorKind::InvalidEncoding);
315        }
316        if e.downcast_ref::<std::io::Error>()
317            .is_some_and(|io| io.kind() == std::io::ErrorKind::NotFound)
318        {
319            return Some(EditErrorKind::NotFound);
320        }
321        current = e.source();
322    }
323    None
324}
325
326/// Same stable kind string CLI JSON uses (`already_exists`, `guard_rejected`, …).
327///
328/// Library hosts that mirror agent JSON envelopes should use this instead of
329/// re-implementing [`crate::exit::classify_typed_error`] or scraping Display
330/// (#1948). Returns `None` when the chain has no recognized typed kind.
331///
332/// Prefer this over matching only [`edit_error_kind`] when you need CLI-stable
333/// strings (for example `no_matches` vs Display `no_match`).
334#[must_use]
335pub fn error_kind_str(err: &anyhow::Error) -> Option<&'static str> {
336    crate::exit::classify_typed_error(err).map(|(kind, _)| kind)
337}
338
339/// Whether the error peels as dest-exists create/rename conflict.
340///
341/// True for both [`crate::exit::AlreadyExistsError`] and
342/// [`EditError`] with [`EditErrorKind::AlreadyExists`], so the bool matches
343/// what hosts get from [`edit_error_kind`] (#1947).
344#[must_use]
345pub fn is_already_exists(err: &anyhow::Error) -> bool {
346    edit_error_kind(err) == Some(EditErrorKind::AlreadyExists)
347}
348
349/// Whether the error peels as missing path I/O ([`EditErrorKind::NotFound`]).
350///
351/// Matches IO `NotFound` (including through `anyhow::Context`) and
352/// [`EditError`] with [`EditErrorKind::NotFound`]. Prefer this over scraping
353/// Display for host recovery when a path is simply absent.
354#[must_use]
355pub fn is_not_found(err: &anyhow::Error) -> bool {
356    edit_error_kind(err) == Some(EditErrorKind::NotFound)
357}
358
359/// Whether the error peels as patch/apply merge conflict markers
360/// ([`EditErrorKind::Conflicts`]). Distinct from batch
361/// [`EditErrorKind::ConflictingEdit`].
362#[must_use]
363pub fn is_conflicts(err: &anyhow::Error) -> bool {
364    edit_error_kind(err) == Some(EditErrorKind::Conflicts)
365}
366
367/// Whether the error peels as check/preview pending changes or soft
368/// assert-count mismatch ([`EditErrorKind::ChangesDetected`], CLI exit 2).
369#[must_use]
370pub fn is_changes_detected(err: &anyhow::Error) -> bool {
371    edit_error_kind(err) == Some(EditErrorKind::ChangesDetected)
372}
373
374/// Whether the error peels as multi-doc / wrong-root type mismatch
375/// ([`EditErrorKind::TypeError`]). Distinct from [`EditErrorKind::InvalidInput`].
376#[must_use]
377pub fn is_type_error(err: &anyhow::Error) -> bool {
378    edit_error_kind(err) == Some(EditErrorKind::TypeError)
379}
380
381/// Whether the error peels as post-write format/lint failure
382/// ([`EditErrorKind::FormatFailed`]). Files may already be written.
383#[must_use]
384pub fn is_format_failed(err: &anyhow::Error) -> bool {
385    edit_error_kind(err) == Some(EditErrorKind::FormatFailed)
386}
387
388/// Whether the error peels as PathGuard / `--contain` rejection
389/// ([`EditErrorKind::GuardRejected`]).
390#[must_use]
391pub fn is_guard_rejected(err: &anyhow::Error) -> bool {
392    edit_error_kind(err) == Some(EditErrorKind::GuardRejected)
393}
394
395/// Whether the error peels as invalid arguments / sole binary / bad options
396/// ([`EditErrorKind::InvalidInput`]).
397#[must_use]
398pub fn is_invalid_input(err: &anyhow::Error) -> bool {
399    edit_error_kind(err) == Some(EditErrorKind::InvalidInput)
400}
401
402/// Whether the error peels as soft no-match ([`EditErrorKind::NoMatch`]).
403/// CLI JSON kind is `no_matches` via [`error_kind_str`].
404#[must_use]
405pub fn is_no_match(err: &anyhow::Error) -> bool {
406    edit_error_kind(err) == Some(EditErrorKind::NoMatch)
407}
408
409/// Whether the error peels as multi-match / unique-mode ambiguity
410/// ([`EditErrorKind::AmbiguousTarget`]). Distinct from soft [`is_no_match`].
411///
412/// Hosts with `unique: true` / `require_change` multi-hit recovery should prefer
413/// this over scraping Display.
414#[must_use]
415pub fn is_ambiguous(err: &anyhow::Error) -> bool {
416    edit_error_kind(err) == Some(EditErrorKind::AmbiguousTarget)
417}
418
419/// Whether the error peels as binary/NUL content ([`EditErrorKind::Binary`]).
420///
421/// Distinct from [`is_invalid_input`] (argument mistakes) and
422/// [`is_invalid_encoding`] (non-UTF-8 text without NUL). Hosts that recover
423/// overwrite of non-text priors should branch on this (or [`is_invalid_encoding`])
424/// rather than all of `InvalidInput` (#1963).
425#[must_use]
426pub fn is_binary(err: &anyhow::Error) -> bool {
427    edit_error_kind(err) == Some(EditErrorKind::Binary)
428}
429
430/// Whether the error peels as invalid UTF-8 content
431/// ([`EditErrorKind::InvalidEncoding`]). Distinct from [`is_binary`] and
432/// [`is_invalid_input`] (#1963).
433#[must_use]
434pub fn is_invalid_encoding(err: &anyhow::Error) -> bool {
435    edit_error_kind(err) == Some(EditErrorKind::InvalidEncoding)
436}
437
438/// Whether the error peels as over-wide fuzzy span refuse
439/// ([`EditErrorKind::FuzzySpanSuspicious`]) from
440/// `ReplaceOptions::refuse_suspicious_fuzzy` / `for_agent` (#2005).
441#[must_use]
442pub fn is_fuzzy_span_suspicious(err: &anyhow::Error) -> bool {
443    edit_error_kind(err) == Some(EditErrorKind::FuzzySpanSuspicious)
444}
445
446/// One-shot peel of a library/CLI error for host tool envelopes (#1964).
447///
448/// Combines [`error_kind_str`], [`edit_error_ref`], and agent-facing message
449/// so embedders do not reimplement `edit_error_kind` + `error_kind_str` +
450/// Display formatting. Returns `None` when the chain has no recognized kind.
451#[derive(Debug, Clone)]
452pub struct PeeledError {
453    /// CLI-stable kind string (`already_exists`, `binary`, …).
454    pub kind_str: &'static str,
455    /// Agent-facing message (same as CLI JSON `error` when possible).
456    pub message: String,
457    /// Optional suggestion from [`EditError`].
458    pub suggestion: Option<String>,
459    /// Similar targets from [`EditError`] (did-you-mean).
460    pub similar_targets: Vec<String>,
461}
462
463/// Peel kind string + message + optional suggestion from an error chain (#1964).
464#[must_use]
465pub fn peel_error(err: &anyhow::Error) -> Option<PeeledError> {
466    let kind_str = error_kind_str(err)?;
467    let (suggestion, similar_targets) = match edit_error_ref(err) {
468        Some(e) => (e.suggestion.clone(), e.similar_targets.clone()),
469        None => (None, Vec::new()),
470    };
471    Some(PeeledError {
472        kind_str,
473        message: crate::exit::agent_error_message(err),
474        suggestion,
475        similar_targets,
476    })
477}
478
479/// Downcast a bare `dyn Error` chain to [`EditError`] when present (#1659).
480///
481/// Prefer this when you need `similar_targets` / `suggestion` without
482/// depending on `anyhow`.
483pub fn classify_error_ref<'a>(err: &'a (dyn std::error::Error + 'static)) -> Option<&'a EditError> {
484    let mut current: Option<&'a (dyn std::error::Error + 'static)> = Some(err);
485    while let Some(e) = current {
486        if let Some(edit) = e.downcast_ref::<EditError>() {
487            return Some(edit);
488        }
489        current = e.source();
490    }
491    None
492}
493
494/// Result of `validate_edit()`: whether the edit would produce valid output.
495#[derive(Debug, Clone, Serialize, Deserialize)]
496pub struct ValidationResult {
497    /// Whether the edit produces valid output.
498    pub valid: bool,
499    /// Syntax errors found (if invalid).
500    pub errors: Vec<String>,
501    /// Suspicious but technically valid issues.
502    pub warnings: Vec<String>,
503}
504
505/// Validate whether a replacement would produce valid output, without applying.
506///
507/// Performs the replacement in memory and checks basic structural validity
508/// of the resulting content (JSON, YAML, TOML validation for structured files).
509///
510/// When `nth` is `Some(n)`, only the Nth (1-based) occurrence is replaced,
511/// matching the behavior of the replace command's `--nth` flag. When `None`,
512/// all occurrences are replaced.
513pub fn validate_edit(
514    content: &str,
515    from: &str,
516    to: &str,
517    file_path: Option<&str>,
518) -> ValidationResult {
519    validate_edit_nth(content, from, to, file_path, None)
520}
521
522/// Like [`validate_edit`] but with an explicit `nth` occurrence parameter.
523pub fn validate_edit_nth(
524    content: &str,
525    from: &str,
526    to: &str,
527    file_path: Option<&str>,
528    nth: Option<usize>,
529) -> ValidationResult {
530    if from.is_empty() {
531        return ValidationResult {
532            valid: false,
533            errors: vec!["empty search pattern".into()],
534            warnings: vec![],
535        };
536    }
537
538    if !content.contains(from) {
539        return ValidationResult {
540            valid: false,
541            errors: vec![format!(
542                "pattern '{}' not found in content",
543                truncate_str(from, 60)
544            )],
545            warnings: vec![],
546        };
547    }
548
549    let new_content = match nth {
550        Some(0) => {
551            return ValidationResult {
552                valid: false,
553                errors: vec!["nth must be >= 1 (1-based indexing)".into()],
554                warnings: vec![],
555            };
556        }
557        Some(n) => {
558            // Replace only the Nth (1-based) occurrence.
559            let mut count = 0usize;
560            let mut result = String::with_capacity(content.len());
561            let mut remaining = content;
562            while let Some(pos) = remaining.find(from) {
563                count += 1;
564                if count == n {
565                    result.push_str(&remaining[..pos]);
566                    result.push_str(to);
567                    result.push_str(&remaining[pos + from.len()..]);
568                    break;
569                }
570                result.push_str(&remaining[..pos + from.len()]);
571                remaining = &remaining[pos + from.len()..];
572            }
573            if count < n {
574                return ValidationResult {
575                    valid: false,
576                    errors: vec![format!(
577                        "occurrence {n} not found (only {count} occurrence{} exist{})",
578                        if count == 1 { "" } else { "s" },
579                        if count == 1 { "s" } else { "" },
580                    )],
581                    warnings: vec![],
582                };
583            }
584            result
585        }
586        None => content.replace(from, to),
587    };
588    let mut errors = Vec::new();
589    let mut warnings = Vec::new();
590
591    // If we can detect the file format, validate the result.
592    if let Some(path) = file_path
593        && let Ok(fmt) = crate::ops::doc::detect_format(path)
594    {
595        let parse_err = match fmt {
596            crate::ops::doc::FileFormat::Json => {
597                serde_json::from_str::<serde_json::Value>(&new_content)
598                    .err()
599                    .map(|e| format!("result would be invalid JSON: {e}"))
600            }
601            crate::ops::doc::FileFormat::Yaml => {
602                serde_yaml_ng::from_str::<serde_json::Value>(&new_content)
603                    .err()
604                    .map(|e| format!("result would be invalid YAML: {e}"))
605            }
606            crate::ops::doc::FileFormat::Toml => {
607                toml_edit::de::from_str::<serde_json::Value>(&new_content)
608                    .err()
609                    .map(|e| format!("result would be invalid TOML: {e}"))
610            }
611        };
612        if let Some(msg) = parse_err {
613            errors.push(msg);
614        }
615    }
616
617    // Warn if the replacement creates unbalanced brackets/braces.
618    let open_parens =
619        new_content.matches('(').count() as i64 - new_content.matches(')').count() as i64;
620    let open_braces =
621        new_content.matches('{').count() as i64 - new_content.matches('}').count() as i64;
622    let open_brackets =
623        new_content.matches('[').count() as i64 - new_content.matches(']').count() as i64;
624
625    if open_parens != 0 {
626        warnings.push(format!("unbalanced parentheses (delta: {open_parens})"));
627    }
628    if open_braces != 0 {
629        warnings.push(format!("unbalanced braces (delta: {open_braces})"));
630    }
631    if open_brackets != 0 {
632        warnings.push(format!("unbalanced brackets (delta: {open_brackets})"));
633    }
634
635    ValidationResult {
636        valid: errors.is_empty(),
637        errors,
638        warnings,
639    }
640}
641
642/// Minimum Jaro-Winkler score for a did-you-mean candidate.
643///
644/// Kept in line with anchor matching (0.85). The previous 0.7 floor
645/// admitted noise like `nold` for long unrelated patterns when scanning
646/// large trees (fixrealloop stress, 2026-07-23).
647const SIMILAR_TARGET_MIN_SCORE: f64 = 0.85;
648
649/// Whether `candidate` is a plausible typo of `target` by length.
650///
651/// Jaro-Winkler can score short tokens above the floor against long
652/// strings (shared characters / prefixes). Reject extreme length skew
653/// so agents do not chase nonsense suggestions.
654fn similar_target_length_plausible(candidate: &str, target: &str) -> bool {
655    let (ca, ta) = (candidate.len(), target.len());
656    if ca == 0 || ta == 0 {
657        return false;
658    }
659    let (shorter, longer) = if ca < ta { (ca, ta) } else { (ta, ca) };
660    // Allow ~2x length difference, or a small absolute gap for short ids.
661    shorter.saturating_mul(2) >= longer || longer - shorter <= 3
662}
663
664/// Find similar text targets in file content using Jaro-Winkler similarity.
665///
666/// Extracts identifiers and substrings from the content and returns the top
667/// `max_results` matches sorted by similarity score (descending).
668pub fn find_similar_targets(content: &str, target: &str, max_results: usize) -> Vec<String> {
669    if target.is_empty() || content.is_empty() {
670        return vec![];
671    }
672
673    let mut candidates: Vec<(String, f64)> = Vec::new();
674    let mut seen = std::collections::HashSet::new();
675
676    // Extract word-like tokens from the content.
677    for line in content.lines() {
678        for word in extract_identifiers(line) {
679            if !seen.insert(word.clone()) {
680                continue;
681            }
682            if word == target || !similar_target_length_plausible(&word, target) {
683                continue;
684            }
685            let score = strsim::jaro_winkler(&word, target);
686            if score > SIMILAR_TARGET_MIN_SCORE {
687                candidates.push((word, score));
688            }
689        }
690    }
691
692    // Also try matching against whole lines for multi-word patterns.
693    if target.contains(' ') || target.len() > 20 {
694        for line in content.lines() {
695            let trimmed = line.trim().to_string();
696            if trimmed.is_empty() || seen.contains(&trimmed) {
697                continue;
698            }
699            seen.insert(trimmed.clone());
700            if trimmed == target || !similar_target_length_plausible(&trimmed, target) {
701                continue;
702            }
703            let score = strsim::jaro_winkler(&trimmed, target);
704            if score > SIMILAR_TARGET_MIN_SCORE {
705                candidates.push((trimmed, score));
706            }
707        }
708    }
709
710    candidates.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
711    candidates.truncate(max_results);
712    candidates.into_iter().map(|(s, _)| s).collect()
713}
714
715/// Try anchor-based matching: find the target text using surrounding context lines.
716///
717/// If `target` is not found exactly, looks for lines that share anchor text
718/// (the lines immediately before and after the target in the original context)
719/// and returns the matching region.
720pub fn anchor_match(
721    content: &str,
722    target: &str,
723    before_context: Option<&str>,
724    after_context: Option<&str>,
725) -> Option<AnchorMatchResult> {
726    if target.is_empty() {
727        return None;
728    }
729
730    // If exact match exists, return it directly.
731    if let Some(pos) = content.find(target) {
732        return Some(AnchorMatchResult {
733            matched_text: target.to_string(),
734            start_offset: pos,
735            strategy: MatchStrategy::Exact,
736            score: None,
737        });
738    }
739
740    // Try anchor-based matching using before/after context.
741    let lines: Vec<&str> = content.lines().collect();
742    let target_lines: Vec<&str> = target.lines().collect();
743
744    // Compute byte offset of each line start for accurate slicing,
745    // avoiding CRLF vs LF mismatch (the old code used a global
746    // heuristic that broke on mixed or CRLF line endings).
747    let mut line_byte_starts: Vec<usize> = vec![0];
748    for (i, b) in content.bytes().enumerate() {
749        if b == b'\n' {
750            line_byte_starts.push(i + 1);
751        }
752    }
753
754    if target_lines.is_empty() {
755        return None;
756    }
757
758    let first_target = target_lines[0].trim();
759    let last_target = target_lines.last().map(|l| l.trim()).unwrap_or("");
760
761    // Anchor matching requires at least one piece of structural context
762    // (before_context or after_context). Without context, it would
763    // degenerate into the same Jaro-Winkler line scan that the
764    // similarity path already does, making the fallback chain redundant.
765    if before_context.is_none() && after_context.is_none() {
766        return None;
767    }
768
769    // Find candidate positions by matching the first line with anchors.
770    for (i, line) in lines.iter().enumerate() {
771        let trimmed = line.trim();
772
773        // Check if this line is similar to the first target line.
774        if strsim::jaro_winkler(trimmed, first_target) < 0.85 {
775            continue;
776        }
777
778        // For multi-line targets, also verify the last line of the
779        // candidate region is similar to the last target line.
780        if target_lines.len() > 1 {
781            let end_idx = i + target_lines.len();
782            if end_idx > lines.len() {
783                continue;
784            }
785            let candidate_last = lines[end_idx - 1].trim();
786            if strsim::jaro_winkler(candidate_last, last_target) < 0.85 {
787                continue;
788            }
789        }
790
791        // If we have before_context, verify the preceding line matches.
792        // For multi-line context, compare only the last line (the one
793        // immediately before the target region).
794        if let Some(before) = before_context {
795            if i == 0 {
796                continue;
797            }
798            let prev = lines[i - 1].trim();
799            let before_line = before.lines().last().unwrap_or(before).trim();
800            if strsim::jaro_winkler(prev, before_line) < 0.8 {
801                continue;
802            }
803        }
804
805        // If we have after_context, check the line after the candidate region.
806        // For multi-line context, compare only the first line (the one
807        // immediately after the target region).
808        if let Some(after) = after_context {
809            let end_idx = i + target_lines.len();
810            if end_idx >= lines.len() {
811                continue;
812            }
813            let next = lines[end_idx].trim();
814            let after_line = after.lines().next().unwrap_or(after).trim();
815            if strsim::jaro_winkler(next, after_line) < 0.8 {
816                continue;
817            }
818        }
819
820        // Found a match. Extract the matched region directly from the
821        // original content so line endings are preserved exactly.
822        let end_idx = (i + target_lines.len()).min(lines.len());
823        let start_offset = line_byte_starts[i];
824        let end_offset = if end_idx < line_byte_starts.len() {
825            line_byte_starts[end_idx]
826        } else {
827            content.len()
828        };
829        // Strip exactly one trailing line ending to match the format
830        // of exact-match results (which carry no trailing newline).
831        let slice = &content[start_offset..end_offset];
832        let matched_text = slice
833            .strip_suffix("\r\n")
834            .or_else(|| slice.strip_suffix('\n'))
835            .unwrap_or(slice)
836            .to_string();
837
838        return Some(AnchorMatchResult {
839            matched_text,
840            start_offset,
841            strategy: MatchStrategy::Anchor,
842            score: None,
843        });
844    }
845
846    None
847}
848
849/// Result of anchor-based matching.
850#[derive(Debug, Clone)]
851pub struct AnchorMatchResult {
852    /// The text that was matched.
853    pub matched_text: String,
854    /// Byte offset of the match start in the content.
855    pub start_offset: usize,
856    /// Which matching strategy succeeded.
857    pub strategy: MatchStrategy,
858    /// Similarity score when [`MatchStrategy::Similarity`] succeeded (#1662).
859    pub score: Option<f64>,
860}
861
862/// Which matching strategy found the result.
863#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
864#[serde(rename_all = "snake_case")]
865pub enum MatchStrategy {
866    /// Exact literal match.
867    Exact,
868    /// Anchor-based matching using surrounding context.
869    Anchor,
870    /// Similarity-based fuzzy matching.
871    Similarity,
872}
873
874impl std::fmt::Display for MatchStrategy {
875    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
876        match self {
877            MatchStrategy::Exact => write!(f, "exact"),
878            MatchStrategy::Anchor => write!(f, "anchor"),
879            MatchStrategy::Similarity => write!(f, "similarity"),
880        }
881    }
882}
883
884/// Run the full fallback chain: exact -> anchor -> similarity -> structured error.
885///
886/// Returns the first successful match or a structured error with diagnosis.
887///
888/// Stable 4-arg surface. Callers that already enforced a stricter exact path
889/// (for example `--word-boundary`) should use [`resolve_with_fallback_skip_exact`]
890/// so bare `find` does not re-accept a substring the primary path rejected.
891pub fn resolve_with_fallback(
892    content: &str,
893    target: &str,
894    before_context: Option<&str>,
895    after_context: Option<&str>,
896) -> Result<AnchorMatchResult, EditError> {
897    resolve_with_fallback_skip_exact(content, target, before_context, after_context, false)
898}
899
900/// Like [`resolve_with_fallback`], with control over the bare Exact tier.
901///
902/// When `skip_exact` is true, bare `find` Exact matching is skipped. Callers that
903/// already ran a stricter exact path (for example `--word-boundary`) must set
904/// this so fallback does not re-accept a substring that word boundaries rejected
905/// (#1755). Similarity and anchor strategies still run (whole-token typo recovery).
906pub fn resolve_with_fallback_skip_exact(
907    content: &str,
908    target: &str,
909    before_context: Option<&str>,
910    after_context: Option<&str>,
911    skip_exact: bool,
912) -> Result<AnchorMatchResult, EditError> {
913    // Step 1: Exact match (unless caller already enforced a stricter exact path).
914    if !skip_exact && let Some(pos) = content.find(target) {
915        return Ok(AnchorMatchResult {
916            matched_text: target.to_string(),
917            start_offset: pos,
918            strategy: MatchStrategy::Exact,
919            score: None,
920        });
921    }
922
923    // Step 2: Anchor-based matching.
924    if let Some(result) = anchor_match(content, target, before_context, after_context) {
925        // anchor_match may still surface Exact via its own find; reject that when
926        // the caller asked to skip bare exact (word_boundary).
927        if !(skip_exact && result.strategy == MatchStrategy::Exact) {
928            return Ok(result);
929        }
930    }
931
932    // Step 3: Similarity-based matching (#1694).
933    //
934    // Single-token targets (identifiers / typos) must match **tokens**, not whole
935    // lines. Jaro-Winkler on a full source line vs a short identifier scores high
936    // because of shared prefixes, then the apply path replaces the entire line and
937    // deletes surrounding syntax (`const`, types, etc.).
938    let target_lines: Vec<&str> = target.lines().collect();
939    if target_lines.len() == 1 {
940        let target_trim = target.trim();
941        if is_token_like_target(target_trim) {
942            if let Some(hit) = best_token_similarity(content, target_trim) {
943                return Ok(hit);
944            }
945            // Do not fall through to whole-line similarity for token-like targets.
946        } else if let Some(hit) = best_line_similarity(content, target_trim) {
947            return Ok(hit);
948        }
949    }
950
951    // Step 4: Structured error with diagnosis.
952    let similar = find_similar_targets(content, target.lines().next().unwrap_or(target), 5);
953    let suggestion = if !similar.is_empty() {
954        Some(format!("did you mean: {}?", similar[0]))
955    } else {
956        None
957    };
958
959    Err(EditError {
960        kind: EditErrorKind::NoMatch,
961        message: format!("target not found: '{}'", truncate_str(target, 80)),
962        suggestion,
963        similar_targets: similar,
964    })
965}
966
967/// Whether a fuzzy match should be rejected by `min_fuzzy_score` (#1687).
968///
969/// Fail closed when score is missing: hosts that set a floor must not apply
970/// unscored fuzzy matches (anchor/legacy paths without a score cannot prove
971/// they clear the floor).
972pub(crate) fn fuzzy_fails_min_floor(score: Option<f64>, min: f64) -> bool {
973    match score {
974        Some(s) => s < min,
975        None => true,
976    }
977}
978
979/// #1758: refuse Similarity/fuzzy apply when exact `old` was absent unless opt-in.
980///
981/// Anchored (explicit context) matches are not refused here — the host supplied
982/// landmarks. Only when the host opted into `fuzzy` and the resolve path returns
983/// Similarity/Fuzzy mode do we fail closed by default. Context-only fallback
984/// (before/after without `fuzzy`) may still land Similarity without this gate
985/// so structural recovery stays usable.
986pub(crate) fn should_refuse_fuzzy_absent_old(
987    fuzzy_requested: bool,
988    is_fuzzy_mode: bool,
989    allow_absent_old: bool,
990) -> bool {
991    fuzzy_requested && is_fuzzy_mode && !allow_absent_old
992}
993
994/// Diagnostic when refusing fuzzy apply because exact `old` is not in the file.
995pub(crate) fn fuzzy_absent_old_refuse_message(
996    old: &str,
997    matched_text: &str,
998    score: Option<f64>,
999) -> String {
1000    let score_s = score
1001        .map(|s| format!("{s:.3}"))
1002        .unwrap_or_else(|| "none".into());
1003    format!(
1004        "exact old absent for {:?}; best fuzzy candidate {:?} score {} \
1005         (set allow_absent_old / --allow-absent-old to apply)",
1006        truncate_str(old, 60),
1007        truncate_str(matched_text, 60),
1008        score_s
1009    )
1010}
1011
1012/// True when `target` is a single identifier-like token (no whitespace).
1013///
1014/// Multi-word / snippet targets keep whole-line similarity. Token-like targets
1015/// use identifier-level matching so fuzzy typo recovery does not expand the
1016/// replace span to an entire source line (#1694).
1017fn is_token_like_target(target: &str) -> bool {
1018    if target.is_empty() || target.chars().any(|c| c.is_whitespace()) {
1019        return false;
1020    }
1021    // Match extract_identifiers: alphanumeric + underscore only. Hyphens would
1022    // mark the target as "token-like" while extraction splits on `-`, so a
1023    // kebab-case typo never matched a full token and skipped line fallback.
1024    target.chars().any(|c| c.is_alphanumeric())
1025        && target.chars().all(|c| c.is_alphanumeric() || c == '_')
1026}
1027
1028/// Best identifier similarity hit, if score > 0.85.
1029fn best_token_similarity(content: &str, target: &str) -> Option<AnchorMatchResult> {
1030    let mut best_score = 0.0f64;
1031    let mut best_match = String::new();
1032    let mut best_offset = 0usize;
1033    let mut offset = 0usize;
1034    for line in content.lines() {
1035        for (ident, col) in extract_identifiers_with_offsets(line) {
1036            if ident == target {
1037                continue;
1038            }
1039            let score = strsim::jaro_winkler(&ident, target);
1040            if score > best_score {
1041                best_score = score;
1042                best_match = ident;
1043                best_offset = offset + col;
1044            }
1045        }
1046        offset = advance_line_offset(content, offset, line);
1047    }
1048    if best_score > 0.85 {
1049        Some(AnchorMatchResult {
1050            matched_text: best_match,
1051            start_offset: best_offset,
1052            strategy: MatchStrategy::Similarity,
1053            score: Some(best_score),
1054        })
1055    } else {
1056        None
1057    }
1058}
1059
1060/// Whole-line similarity for multi-word / snippet targets.
1061///
1062/// Refuses a match when the line is much longer than the target (ratio > 2)
1063/// so accidental line expansion stays rare even for long snippets (#1694).
1064fn best_line_similarity(content: &str, target: &str) -> Option<AnchorMatchResult> {
1065    let mut best_score = 0.0f64;
1066    let mut best_match = String::new();
1067    let mut best_offset = 0usize;
1068    let mut offset = 0usize;
1069    for line in content.lines() {
1070        let score = strsim::jaro_winkler(line.trim(), target);
1071        if score > best_score {
1072            best_score = score;
1073            best_match = line.to_string();
1074            best_offset = offset;
1075        }
1076        offset = advance_line_offset(content, offset, line);
1077    }
1078    if best_score <= 0.85 {
1079        return None;
1080    }
1081    let line_len = best_match.trim().len();
1082    let target_len = target.len().max(1);
1083    if line_len > target_len.saturating_mul(2) && line_len > target_len + 16 {
1084        // Too expansive: treat as no similarity match (suggestions still help).
1085        return None;
1086    }
1087    Some(AnchorMatchResult {
1088        matched_text: best_match,
1089        start_offset: best_offset,
1090        strategy: MatchStrategy::Similarity,
1091        score: Some(best_score),
1092    })
1093}
1094
1095fn advance_line_offset(content: &str, mut offset: usize, line: &str) -> usize {
1096    // Advance past the line content and the actual line ending (\r\n or \n).
1097    offset += line.len();
1098    if content.as_bytes().get(offset) == Some(&b'\r') {
1099        offset += 1;
1100    }
1101    if content.as_bytes().get(offset) == Some(&b'\n') {
1102        offset += 1;
1103    }
1104    offset
1105}
1106
1107/// Extract identifier-like tokens from a line of code.
1108fn extract_identifiers(line: &str) -> Vec<String> {
1109    extract_identifiers_with_offsets(line)
1110        .into_iter()
1111        .map(|(s, _)| s)
1112        .collect()
1113}
1114
1115/// Identifier tokens with byte offsets into `line` (#1694).
1116fn extract_identifiers_with_offsets(line: &str) -> Vec<(String, usize)> {
1117    let mut identifiers = Vec::new();
1118    let mut current = String::new();
1119    let mut start = 0usize;
1120    let mut i = 0usize;
1121
1122    for ch in line.chars() {
1123        let clen = ch.len_utf8();
1124        if ch.is_alphanumeric() || ch == '_' {
1125            if current.is_empty() {
1126                start = i;
1127            }
1128            current.push(ch);
1129        } else {
1130            if current.len() >= 3 {
1131                identifiers.push((std::mem::take(&mut current), start));
1132            } else {
1133                current.clear();
1134            }
1135        }
1136        i += clen;
1137    }
1138    if current.len() >= 3 {
1139        identifiers.push((current, start));
1140    }
1141
1142    identifiers
1143}
1144
1145/// Truncate a string for display in error messages.
1146///
1147/// Truncates at the last char boundary at or before `max_len` bytes,
1148/// so this is safe for multi-byte UTF-8 input.
1149pub(crate) fn truncate_str(s: &str, max_len: usize) -> &str {
1150    if s.len() <= max_len {
1151        s
1152    } else {
1153        // Find the last char boundary at or before max_len.
1154        let mut end = max_len;
1155        while end > 0 && !s.is_char_boundary(end) {
1156            end -= 1;
1157        }
1158        &s[..end]
1159    }
1160}
1161
1162#[cfg(test)]
1163mod tests {
1164    use super::*;
1165
1166    #[test]
1167    fn edit_error_display() {
1168        let err = EditError {
1169            kind: EditErrorKind::NoMatch,
1170            message: "target not found".into(),
1171            suggestion: Some("try 'process_request'".into()),
1172            similar_targets: vec!["process_request".into()],
1173        };
1174        let display = err.to_string();
1175        assert!(display.contains("no_match"));
1176        assert!(display.contains("target not found"));
1177        assert!(display.contains("process_request"));
1178    }
1179
1180    #[test]
1181    fn edit_error_kind_display() {
1182        assert_eq!(
1183            EditErrorKind::AmbiguousTarget.to_string(),
1184            "ambiguous_target"
1185        );
1186        assert_eq!(EditErrorKind::NoMatch.to_string(), "no_match");
1187        assert_eq!(EditErrorKind::SyntaxInvalid.to_string(), "syntax_invalid");
1188        assert_eq!(
1189            EditErrorKind::ConflictingEdit.to_string(),
1190            "conflicting_edit"
1191        );
1192        assert_eq!(EditErrorKind::ParseError.to_string(), "parse_error");
1193        assert_eq!(EditErrorKind::TypeError.to_string(), "type_error");
1194        assert_eq!(EditErrorKind::InvalidInput.to_string(), "invalid_input");
1195        assert_eq!(EditErrorKind::AlreadyExists.to_string(), "already_exists");
1196        assert_eq!(EditErrorKind::NotFound.to_string(), "not_found");
1197        assert_eq!(EditErrorKind::Conflicts.to_string(), "conflicts");
1198        assert_eq!(
1199            EditErrorKind::ChangesDetected.to_string(),
1200            "changes_detected"
1201        );
1202        assert_eq!(EditErrorKind::FormatFailed.to_string(), "format_failed");
1203        assert_eq!(EditErrorKind::Binary.to_string(), "binary");
1204        assert_eq!(
1205            EditErrorKind::InvalidEncoding.to_string(),
1206            "invalid_encoding"
1207        );
1208    }
1209
1210    /// Locks discriminants published in 0.18.0 so new kinds stay append-only
1211    /// (`enum_no_repr_variant_discriminant_changed` / release PR #1951).
1212    #[test]
1213    fn edit_error_kind_stable_discriminants_since_0_18_0() {
1214        assert_eq!(EditErrorKind::AmbiguousTarget as u8, 0);
1215        assert_eq!(EditErrorKind::NoMatch as u8, 1);
1216        assert_eq!(EditErrorKind::SyntaxInvalid as u8, 2);
1217        assert_eq!(EditErrorKind::ConflictingEdit as u8, 3);
1218        assert_eq!(EditErrorKind::ParseError as u8, 4);
1219        assert_eq!(EditErrorKind::GuardRejected as u8, 5);
1220        assert_eq!(EditErrorKind::InvalidInput as u8, 6);
1221        assert_eq!(EditErrorKind::TypeError as u8, 7);
1222        assert_eq!(EditErrorKind::OperationFailed as u8, 8);
1223        assert_eq!(EditErrorKind::FormatFailed as u8, 9);
1224        // #1947/#1950 kinds must follow FormatFailed (never insert above).
1225        assert_eq!(EditErrorKind::AlreadyExists as u8, 10);
1226        assert_eq!(EditErrorKind::NotFound as u8, 11);
1227        assert_eq!(EditErrorKind::Conflicts as u8, 12);
1228        assert_eq!(EditErrorKind::ChangesDetected as u8, 13);
1229        // #1963 kinds append after ChangesDetected (never insert above).
1230        assert_eq!(EditErrorKind::Binary as u8, 14);
1231        assert_eq!(EditErrorKind::InvalidEncoding as u8, 15);
1232        // #2005 append after InvalidEncoding (never insert above).
1233        assert_eq!(EditErrorKind::FuzzySpanSuspicious as u8, 16);
1234    }
1235
1236    #[test]
1237    fn classify_error_peels_edit_error_and_typed() {
1238        use std::error::Error;
1239        let e = EditError::new(EditErrorKind::AmbiguousTarget, "many");
1240        assert_eq!(
1241            classify_error(&e as &(dyn Error + 'static)),
1242            Some(EditErrorKind::AmbiguousTarget)
1243        );
1244        let e = crate::exit::NoMatchError { msg: "none".into() };
1245        assert_eq!(
1246            classify_error(&e as &(dyn Error + 'static)),
1247            Some(EditErrorKind::NoMatch)
1248        );
1249        let e = crate::exit::FormatFailedError::new("fmt");
1250        assert_eq!(
1251            classify_error(&e as &(dyn Error + 'static)),
1252            Some(EditErrorKind::FormatFailed)
1253        );
1254        let e = crate::exit::TypeErrorError {
1255            msg: "parent is an array".into(),
1256        };
1257        assert_eq!(
1258            classify_error(&e as &(dyn Error + 'static)),
1259            Some(EditErrorKind::TypeError)
1260        );
1261        let e = crate::exit::AlreadyExistsError {
1262            msg: "exists".into(),
1263        };
1264        assert_eq!(
1265            classify_error(&e as &(dyn Error + 'static)),
1266            Some(EditErrorKind::AlreadyExists)
1267        );
1268        let e = crate::exit::ConflictsError {
1269            msg: "conflict".into(),
1270        };
1271        assert_eq!(
1272            classify_error(&e as &(dyn Error + 'static)),
1273            Some(EditErrorKind::Conflicts)
1274        );
1275        let e = crate::exit::ChangesDetectedError {
1276            msg: "would change".into(),
1277        };
1278        assert_eq!(
1279            classify_error(&e as &(dyn Error + 'static)),
1280            Some(EditErrorKind::ChangesDetected)
1281        );
1282        let e = std::io::Error::new(std::io::ErrorKind::NotFound, "gone");
1283        assert_eq!(
1284            classify_error(&e as &(dyn Error + 'static)),
1285            Some(EditErrorKind::NotFound)
1286        );
1287    }
1288
1289    #[test]
1290    fn edit_error_kind_maps_exit_typed_errors() {
1291        let invalid: anyhow::Error = crate::exit::InvalidInputError {
1292            msg: "empty search pattern".into(),
1293        }
1294        .into();
1295        assert_eq!(edit_error_kind(&invalid), Some(EditErrorKind::InvalidInput));
1296        assert!(is_invalid_input(&invalid));
1297        assert!(!is_guard_rejected(&invalid));
1298
1299        let no_match: anyhow::Error = crate::exit::NoMatchError {
1300            msg: "no matches".into(),
1301        }
1302        .into();
1303        assert_eq!(edit_error_kind(&no_match), Some(EditErrorKind::NoMatch));
1304        assert!(is_no_match(&no_match));
1305        assert_eq!(error_kind_str(&no_match), Some("no_matches"));
1306        assert!(!is_not_found(&no_match));
1307
1308        let ambiguous: anyhow::Error = crate::exit::AmbiguousError {
1309            msg: "multiple matches".into(),
1310        }
1311        .into();
1312        assert_eq!(
1313            edit_error_kind(&ambiguous),
1314            Some(EditErrorKind::AmbiguousTarget)
1315        );
1316        assert!(is_ambiguous(&ambiguous));
1317        assert_eq!(error_kind_str(&ambiguous), Some("ambiguous"));
1318        assert!(!is_no_match(&ambiguous));
1319
1320        let parse: anyhow::Error = crate::exit::ParseErrorError {
1321            msg: "bad yaml".into(),
1322        }
1323        .into();
1324        assert_eq!(edit_error_kind(&parse), Some(EditErrorKind::ParseError));
1325
1326        let exists: anyhow::Error = crate::exit::AlreadyExistsError {
1327            msg: "file already exists".into(),
1328        }
1329        .into();
1330        assert_eq!(
1331            edit_error_kind(&exists),
1332            Some(EditErrorKind::AlreadyExists),
1333            "AlreadyExistsError must not collapse to InvalidInput (#1947)"
1334        );
1335        assert!(is_already_exists(&exists));
1336        assert_eq!(error_kind_str(&exists), Some("already_exists"));
1337
1338        let exists_edit: anyhow::Error =
1339            EditError::new(EditErrorKind::AlreadyExists, "dest taken").into();
1340        assert!(
1341            is_already_exists(&exists_edit),
1342            "is_already_exists must match EditErrorKind::AlreadyExists too"
1343        );
1344        assert_eq!(
1345            edit_error_kind(&exists_edit),
1346            Some(EditErrorKind::AlreadyExists)
1347        );
1348
1349        let type_err: anyhow::Error = crate::exit::TypeErrorError {
1350            msg: "parent is an array, not an object".into(),
1351        }
1352        .into();
1353        assert_eq!(
1354            edit_error_kind(&type_err),
1355            Some(EditErrorKind::TypeError),
1356            "TypeErrorError must not collapse to InvalidInput (#1883)"
1357        );
1358        assert!(is_type_error(&type_err));
1359        assert!(!is_invalid_input(&type_err));
1360
1361        let format_failed: anyhow::Error =
1362            crate::exit::FormatFailedError::new("format command failed").into();
1363        assert_eq!(
1364            edit_error_kind(&format_failed),
1365            Some(EditErrorKind::FormatFailed)
1366        );
1367        assert!(is_format_failed(&format_failed));
1368        assert!(!is_type_error(&format_failed));
1369
1370        let guard: anyhow::Error =
1371            EditError::new(EditErrorKind::GuardRejected, "path escapes").into();
1372        assert!(is_guard_rejected(&guard));
1373        assert_eq!(error_kind_str(&guard), Some("guard_rejected"));
1374        assert!(!is_invalid_input(&guard));
1375
1376        let not_found: anyhow::Error =
1377            std::io::Error::new(std::io::ErrorKind::NotFound, "missing").into();
1378        let not_found = not_found.context("failed to read path");
1379        assert_eq!(
1380            edit_error_kind(&not_found),
1381            Some(EditErrorKind::NotFound),
1382            "IO NotFound must peel as NotFound not OperationFailed"
1383        );
1384        assert_eq!(error_kind_str(&not_found), Some("not_found"));
1385        assert!(
1386            is_not_found(&not_found),
1387            "is_not_found must match IO NotFound peel"
1388        );
1389        assert!(!is_already_exists(&not_found));
1390
1391        let conflicts: anyhow::Error = crate::exit::ConflictsError {
1392            msg: "merge conflict".into(),
1393        }
1394        .into();
1395        assert_eq!(edit_error_kind(&conflicts), Some(EditErrorKind::Conflicts));
1396        assert_eq!(error_kind_str(&conflicts), Some("conflicts"));
1397        assert!(is_conflicts(&conflicts));
1398        assert!(!is_not_found(&conflicts));
1399
1400        let changes: anyhow::Error = crate::exit::ChangesDetectedError {
1401            msg: "would change".into(),
1402        }
1403        .into();
1404        assert_eq!(
1405            edit_error_kind(&changes),
1406            Some(EditErrorKind::ChangesDetected)
1407        );
1408        assert_eq!(error_kind_str(&changes), Some("changes_detected"));
1409        assert!(is_changes_detected(&changes));
1410        assert!(!is_conflicts(&changes));
1411
1412        // Intermediate .context() must not hide the typed kind.
1413        let wrapped = invalid.context("operation 1 (replace) failed");
1414        assert_eq!(edit_error_kind(&wrapped), Some(EditErrorKind::InvalidInput));
1415
1416        let type_wrapped = type_err.context("operation 1 (doc.set) failed");
1417        assert_eq!(
1418            edit_error_kind(&type_wrapped),
1419            Some(EditErrorKind::TypeError)
1420        );
1421
1422        let plain = anyhow::anyhow!("plain error");
1423        assert_eq!(edit_error_kind(&plain), None);
1424    }
1425
1426    #[test]
1427    fn validate_edit_empty_pattern() {
1428        let result = validate_edit("content", "", "replacement", None);
1429        assert!(!result.valid);
1430        assert!(result.errors[0].contains("empty search pattern"));
1431    }
1432
1433    #[test]
1434    fn validate_edit_pattern_not_found() {
1435        let result = validate_edit("hello world", "missing", "replacement", None);
1436        assert!(!result.valid);
1437        assert!(result.errors[0].contains("not found"));
1438    }
1439
1440    #[test]
1441    fn validate_edit_valid_replacement() {
1442        let result = validate_edit("hello world", "hello", "goodbye", None);
1443        assert!(result.valid);
1444        assert!(result.errors.is_empty());
1445    }
1446
1447    #[test]
1448    fn validate_edit_json_syntax_check() {
1449        let json = r#"{"key": "value"}"#;
1450        // Valid replacement.
1451        let result = validate_edit(json, "value", "new_value", Some("config.json"));
1452        assert!(result.valid);
1453
1454        // Invalid replacement (breaks JSON).
1455        let result = validate_edit(json, "\"key\":", "broken", Some("config.json"));
1456        assert!(!result.valid);
1457        assert!(result.errors[0].contains("invalid JSON"));
1458    }
1459
1460    #[test]
1461    fn validate_edit_yaml_syntax_check() {
1462        let yaml = "key: value\n";
1463        let result = validate_edit(yaml, "value", "new_value", Some("config.yaml"));
1464        assert!(result.valid);
1465    }
1466
1467    #[test]
1468    fn validate_edit_warns_unbalanced_braces() {
1469        let content = "fn main() { hello }";
1470        let result = validate_edit(content, "{ hello }", "{ hello", None);
1471        assert!(result.valid); // Still valid (we can't know the language syntax).
1472        assert!(
1473            result
1474                .warnings
1475                .iter()
1476                .any(|w| w.contains("unbalanced braces"))
1477        );
1478    }
1479
1480    #[test]
1481    fn find_similar_targets_finds_typos() {
1482        let content = "fn process_request() {}\nfn process_response() {}\nfn handle_error() {}\n";
1483        let similar = find_similar_targets(content, "process_requst", 3);
1484        assert!(!similar.is_empty());
1485        assert!(similar.iter().any(|s| s.contains("process_request")));
1486    }
1487
1488    #[test]
1489    fn find_similar_targets_rejects_length_skew_noise() {
1490        // Real multi-file no-match noise (fixrealloop stress): long invented
1491        // pattern vs short tokens like "nold" can clear a loose JW floor.
1492        let content = "fn nold() {}\nlet compute = 1;\nfn process_request() {}\n";
1493        let similar = find_similar_targets(content, "this_string_should_not_exist_xyzzy", 5);
1494        assert!(
1495            similar.is_empty(),
1496            "unrelated long pattern must not suggest short tokens: {similar:?}"
1497        );
1498        let similar = find_similar_targets(content, "completely_bogus_token_zzz", 5);
1499        assert!(
1500            !similar
1501                .iter()
1502                .any(|s| s == "compute" || s == "let" || s == "nold"),
1503            "bogus pattern must not surface weak short-token hints: {similar:?}"
1504        );
1505    }
1506
1507    #[test]
1508    fn find_similar_targets_empty_content() {
1509        let similar = find_similar_targets("", "target", 3);
1510        assert!(similar.is_empty());
1511    }
1512
1513    #[test]
1514    fn find_similar_targets_empty_target() {
1515        let similar = find_similar_targets("content", "", 3);
1516        assert!(similar.is_empty());
1517    }
1518
1519    #[test]
1520    fn anchor_match_exact() {
1521        let content = "line1\nline2\nline3\n";
1522        let result = anchor_match(content, "line2", None, None).unwrap();
1523        assert_eq!(result.matched_text, "line2");
1524        assert_eq!(result.strategy, MatchStrategy::Exact);
1525    }
1526
1527    #[test]
1528    fn anchor_match_with_context() {
1529        // Simulate a case where the target line changed slightly.
1530        let content = "fn setup() {}\nfn proccess_data(x: i32) {}\nfn cleanup() {}\n";
1531        let result = anchor_match(
1532            content,
1533            "fn process_data(x: i32) {}",
1534            Some("fn setup() {}"),
1535            Some("fn cleanup() {}"),
1536        );
1537        let r = result.expect("anchor match should find a fuzzy match");
1538        assert_eq!(r.strategy, MatchStrategy::Anchor);
1539        assert!(r.matched_text.contains("proccess_data"));
1540    }
1541
1542    #[test]
1543    fn anchor_match_no_match() {
1544        let content = "completely different content\n";
1545        let result = anchor_match(content, "not here at all", None, None);
1546        assert!(result.is_none());
1547    }
1548
1549    #[test]
1550    fn anchor_match_requires_context_for_fuzzy() {
1551        // Without context, anchor matching skips the fuzzy path and returns
1552        // None even if a similar line exists. This prevents anchor from
1553        // duplicating the similarity path in the fallback chain.
1554        let content = "fn process_request(x: i32) {}\n";
1555        let result = anchor_match(content, "fn process_requst(x: i32) {}", None, None);
1556        assert!(result.is_none());
1557    }
1558
1559    #[test]
1560    fn anchor_match_multi_line_verifies_last_line() {
1561        // Multi-line anchor matching must check both the first and last
1562        // lines of the candidate region, not just the first.
1563        let content = "fn setup() {}\nfn process(x: i32) {}\nfn teardown() {}\nfn other() {}\n";
1564        // Target has a matching first line but wrong last line.
1565        let result = anchor_match(
1566            content,
1567            "fn process(x: i32) {}\nfn completely_wrong() {}",
1568            Some("fn setup() {}"),
1569            None,
1570        );
1571        assert!(result.is_none());
1572    }
1573
1574    #[test]
1575    fn anchor_match_crlf_offset() {
1576        let content = "line1\r\nline2\r\nline3\r\n";
1577        let result = anchor_match(content, "line2", Some("line1"), None).unwrap();
1578        // "line1\r\n" is 7 bytes, so line2 starts at offset 7.
1579        assert_eq!(result.start_offset, 7);
1580    }
1581
1582    /// Regression: anchor match on CRLF content must produce a matched_text
1583    /// that is an exact substring of the original content. The old code
1584    /// joined lines with "\n" which produced LF-only text, wrong for CRLF.
1585    #[test]
1586    fn anchor_match_crlf_matched_text_preserves_endings() {
1587        let content =
1588            "fn setup() {}\r\nfn proccess_data(x: i32) {}\r\nfn more() {}\r\nfn cleanup() {}\r\n";
1589        let result = anchor_match(
1590            content,
1591            "fn process_data(x: i32) {}\nfn more() {}",
1592            Some("fn setup() {}"),
1593            Some("fn cleanup() {}"),
1594        )
1595        .unwrap();
1596        // matched_text must be verifiable against the original content.
1597        let end = result.start_offset + result.matched_text.len();
1598        assert_eq!(
1599            &content[result.start_offset..end],
1600            result.matched_text,
1601            "matched_text must be an exact slice of the original content"
1602        );
1603        // And it should contain the CRLF between lines.
1604        assert!(
1605            result.matched_text.contains("\r\n"),
1606            "matched text should preserve CRLF line endings"
1607        );
1608    }
1609
1610    /// Regression: anchor match on mixed line endings (some CRLF, some LF)
1611    /// must produce correct offsets for each line, not a global decision.
1612    #[test]
1613    fn anchor_match_mixed_endings_correct_offset() {
1614        let content = "header\r\nfn proccess(x: i32) {}\nfooter\n";
1615        let result = anchor_match(
1616            content,
1617            "fn process(x: i32) {}",
1618            Some("header"),
1619            Some("footer"),
1620        )
1621        .unwrap();
1622        // "header\r\n" is 8 bytes, so line 2 starts at offset 8.
1623        assert_eq!(result.start_offset, 8);
1624        let end = result.start_offset + result.matched_text.len();
1625        assert_eq!(&content[result.start_offset..end], result.matched_text);
1626    }
1627
1628    #[test]
1629    fn resolve_with_fallback_exact_match() {
1630        let content = "fn hello() {}\n";
1631        let result = resolve_with_fallback(content, "fn hello()", None, None).unwrap();
1632        assert_eq!(result.strategy, MatchStrategy::Exact);
1633    }
1634
1635    /// #1755: skip_exact must not re-accept a bare substring after a stricter
1636    /// primary path (e.g. word_boundary) already rejected it.
1637    #[test]
1638    fn resolve_with_fallback_skip_exact_rejects_substring() {
1639        let content = "process_data process_data_extra\n";
1640        // Without skip_exact, bare find would match "process_dat" inside process_data.
1641        let bare = resolve_with_fallback(content, "process_dat", None, None).unwrap();
1642        assert_eq!(bare.strategy, MatchStrategy::Exact);
1643        assert_eq!(bare.matched_text, "process_dat");
1644
1645        // With skip_exact, Exact is skipped; token similarity may recover the
1646        // full identifier (process_data) or miss — but never the bare substring.
1647        match resolve_with_fallback_skip_exact(content, "process_dat", None, None, true) {
1648            Ok(hit) => {
1649                assert_ne!(
1650                    hit.strategy,
1651                    MatchStrategy::Exact,
1652                    "skip_exact must not return Exact"
1653                );
1654                assert_ne!(
1655                    hit.matched_text, "process_dat",
1656                    "must not re-accept word_boundary-rejected substring"
1657                );
1658            }
1659            Err(e) => assert_eq!(e.kind, EditErrorKind::NoMatch),
1660        }
1661    }
1662
1663    /// Regression: similarity matching on CRLF content must compute
1664    /// correct byte offsets. The old code used `offset += line.len() + 1`
1665    /// which hardcoded LF (1 byte), wrong for CRLF (2 bytes).
1666    #[test]
1667    fn resolve_with_fallback_similarity_crlf_offset() {
1668        let content = "fn alpha() {}\r\nfn process_requets(data: &str) {}\r\nfn gamma() {}\r\n";
1669        let result =
1670            resolve_with_fallback(content, "fn process_requests(data: &str) {}", None, None)
1671                .unwrap();
1672        assert_eq!(result.strategy, MatchStrategy::Similarity);
1673        // "fn alpha() {}\r\n" is 15 bytes, so the match starts at offset 15.
1674        assert_eq!(result.start_offset, 15);
1675        // Verify the offset points to the correct position in content.
1676        assert!(
1677            content[result.start_offset..].starts_with(&result.matched_text),
1678            "start_offset must point to matched_text in content"
1679        );
1680    }
1681
1682    #[test]
1683    fn resolve_with_fallback_similarity_match() {
1684        // Without context, anchor matching is skipped (it requires at least
1685        // before_context or after_context to avoid degenerating into the same
1686        // Jaro-Winkler scan that similarity already does). The similarity
1687        // path catches the misspelled target instead.
1688        let content = "fn process_request(data: &str) -> Result<()> {\n    Ok(())\n}\n";
1689        let result = resolve_with_fallback(
1690            content,
1691            "fn process_requets(data: &str) -> Result<()> {",
1692            None,
1693            None,
1694        );
1695        let r = result.expect("similarity fallback should succeed");
1696        assert_eq!(r.strategy, MatchStrategy::Similarity);
1697        // Multi-word snippet → whole-line match (not a bare identifier).
1698        assert!(
1699            r.matched_text.contains("process_request"),
1700            "snippet match: {:?}",
1701            r.matched_text
1702        );
1703    }
1704
1705    /// #1694: single-token fuzzy must not expand to the whole source line.
1706    #[test]
1707    fn resolve_token_typo_does_not_match_whole_line() {
1708        let content = "const CONFIGURATION_VALUE_PRIMARY: i32 = 1;\nfn use_it() -> i32 { CONFIGURATION_VALUE_PRIMARY }\n";
1709        let r = resolve_with_fallback(content, "CONFIGURATION_VALUE_PRIMRY", None, None)
1710            .expect("token typo should fuzzy-match the identifier");
1711        assert_eq!(r.strategy, MatchStrategy::Similarity);
1712        assert_eq!(
1713            r.matched_text, "CONFIGURATION_VALUE_PRIMARY",
1714            "must match the identifier token only, not the whole line"
1715        );
1716        assert!(
1717            content[r.start_offset..].starts_with("CONFIGURATION_VALUE_PRIMARY"),
1718            "offset must point at the token"
1719        );
1720        // Surrounding syntax must still be outside the match span.
1721        assert!(!r.matched_text.contains("const"));
1722        assert!(!r.matched_text.contains("i32"));
1723    }
1724
1725    /// #1694: intentional full-line target still uses line similarity.
1726    #[test]
1727    fn resolve_full_line_snippet_still_matches_line() {
1728        let content = "const FOO: i32 = 1;\n";
1729        let r = resolve_with_fallback(content, "const FO: i32 = 1;", None, None)
1730            .expect("near-full-line snippet should match the line");
1731        assert_eq!(r.strategy, MatchStrategy::Similarity);
1732        assert!(
1733            r.matched_text.contains("const") && r.matched_text.contains("FOO"),
1734            "line match: {:?}",
1735            r.matched_text
1736        );
1737    }
1738
1739    /// Embedder-style identifier typos across real source shapes (#1694 contract).
1740    #[test]
1741    fn resolve_token_typo_matrix_preserves_non_identifier_syntax() {
1742        // (content, typo_target, expected_matched_token, must_not_be_in_match)
1743        let cases: &[(&str, &str, &str, &[&str])] = &[
1744            // Rust const + use site (Bline #1694 repro shape).
1745            (
1746                "const CONFIGURATION_VALUE_PRIMARY: i32 = 1;\nfn use_it() -> i32 { CONFIGURATION_VALUE_PRIMARY }\n",
1747                "CONFIGURATION_VALUE_PRIMRY",
1748                "CONFIGURATION_VALUE_PRIMARY",
1749                &["const", "i32", "fn"],
1750            ),
1751            // Rust function name typo (token, not full signature).
1752            (
1753                "fn process_request(data: &str) -> Result<()> {\n    Ok(())\n}\n",
1754                "process_requets",
1755                "process_request",
1756                &["fn", "Result", "data"],
1757            ),
1758            // Python def.
1759            (
1760                "def load_configuration_value():\n    return 1\n",
1761                "load_configration_value",
1762                "load_configuration_value",
1763                &["def", "return"],
1764            ),
1765            // JS/TS const + call.
1766            (
1767                "const getUserProfile = () => null;\nexport { getUserProfile };\n",
1768                "getUserProfle",
1769                "getUserProfile",
1770                &["const", "export"],
1771            ),
1772            // YAML-ish key in a line with punctuation (identifier extraction).
1773            (
1774                "server_port_primary: 8080\n",
1775                "server_port_primry",
1776                "server_port_primary",
1777                &[":", "8080"],
1778            ),
1779            // camelCase method.
1780            (
1781                "    obj.fetchUserDetails(id);\n",
1782                "fetchUserDetials",
1783                "fetchUserDetails",
1784                &["obj", "id"],
1785            ),
1786        ];
1787
1788        for (content, typo, expected, forbidden) in cases {
1789            let r = resolve_with_fallback(content, typo, None, None)
1790                .unwrap_or_else(|e| panic!("token typo {typo:?} should match in {content:?}: {e}"));
1791            assert_eq!(r.strategy, MatchStrategy::Similarity, "typo={typo:?}");
1792            assert_eq!(
1793                r.matched_text, *expected,
1794                "typo={typo:?} content={content:?}"
1795            );
1796            assert!(
1797                content[r.start_offset..].starts_with(expected),
1798                "offset wrong for {typo:?}"
1799            );
1800            for bad in *forbidden {
1801                assert!(
1802                    !r.matched_text.contains(bad),
1803                    "span leaked {bad:?} for typo={typo:?} match={:?}",
1804                    r.matched_text
1805                );
1806            }
1807            // Token-like targets must never expand to multi-word spans.
1808            assert!(
1809                !r.matched_text.contains(' '),
1810                "token match must not contain spaces: {:?}",
1811                r.matched_text
1812            );
1813        }
1814    }
1815
1816    #[test]
1817    fn resolve_token_typo_picks_first_best_occurrence() {
1818        let content = "FOO_PRIMARY=1\nFOO_PRIMARY=2\n";
1819        let r = resolve_with_fallback(content, "FOO_PRIMRY", None, None).unwrap();
1820        assert_eq!(r.matched_text, "FOO_PRIMARY");
1821        // First line occurrence (offset 0).
1822        assert_eq!(r.start_offset, 0);
1823    }
1824
1825    #[test]
1826    fn resolve_token_unrelated_is_no_match() {
1827        let content = "const ALPHA: i32 = 1;\nconst BETA: i32 = 2;\n";
1828        let err =
1829            resolve_with_fallback(content, "ZZZZ_COMPLETELY_UNRELATED", None, None).unwrap_err();
1830        assert_eq!(err.kind, EditErrorKind::NoMatch);
1831    }
1832
1833    #[test]
1834    fn is_token_like_target_classification() {
1835        assert!(is_token_like_target("CONFIGURATION_VALUE_PRIMRY"));
1836        assert!(is_token_like_target("getUserProfile"));
1837        // Hyphenated names are not identifier tokens (extraction splits on `-`).
1838        assert!(!is_token_like_target("kebab-case-name"));
1839        assert!(!is_token_like_target("fn process_data() {}"));
1840        assert!(!is_token_like_target("const FOO: i32 = 1;"));
1841        assert!(!is_token_like_target("server.port"));
1842        assert!(!is_token_like_target(""));
1843        assert!(!is_token_like_target("   "));
1844    }
1845
1846    #[test]
1847    fn fuzzy_fails_min_floor_fail_closed_without_score() {
1848        assert!(fuzzy_fails_min_floor(None, 0.8));
1849        assert!(fuzzy_fails_min_floor(Some(0.5), 0.8));
1850        assert!(!fuzzy_fails_min_floor(Some(0.9), 0.8));
1851        assert!(!fuzzy_fails_min_floor(Some(0.8), 0.8));
1852    }
1853
1854    /// Kebab-case targets use line similarity (not broken token path).
1855    #[test]
1856    fn resolve_kebab_case_uses_line_similarity_not_broken_token_path() {
1857        let content = "font-weight-primary: bold;\n";
1858        // Near-full-line typo; must still resolve (line path), not dead-end as token.
1859        let r = resolve_with_fallback(content, "font-weight-primry: bold;", None, None)
1860            .expect("kebab line snippet should match via line similarity");
1861        assert_eq!(r.strategy, MatchStrategy::Similarity);
1862        assert!(
1863            r.matched_text.contains("font-weight-primary"),
1864            "{:?}",
1865            r.matched_text
1866        );
1867    }
1868
1869    #[test]
1870    fn resolve_with_fallback_structured_error() {
1871        let content = "fn alpha() {}\nfn beta() {}\n";
1872        let result = resolve_with_fallback(content, "fn completely_unrelated_xyz()", None, None);
1873        let err = result.unwrap_err();
1874        assert_eq!(err.kind, EditErrorKind::NoMatch);
1875        assert!(err.message.contains("target not found"));
1876    }
1877
1878    #[test]
1879    fn resolve_with_fallback_anchor_over_similarity() {
1880        // When context is provided, anchor matching fires before similarity
1881        // and should win.
1882        let content = "fn setup() {}\nfn proces_data(x: i32) {}\nfn cleanup() {}\n";
1883        let result = resolve_with_fallback(
1884            content,
1885            "fn process_data(x: i32) {}",
1886            Some("fn setup() {}"),
1887            Some("fn cleanup() {}"),
1888        );
1889        let r = result.expect("anchor fallback should succeed");
1890        assert_eq!(r.strategy, MatchStrategy::Anchor);
1891    }
1892
1893    #[test]
1894    fn edit_error_serializes_to_json() {
1895        let err = EditError {
1896            kind: EditErrorKind::AmbiguousTarget,
1897            message: "found 3 matches".into(),
1898            suggestion: Some("use --nth to select one".into()),
1899            similar_targets: vec!["match1".into(), "match2".into()],
1900        };
1901        let json = serde_json::to_string(&err).unwrap();
1902        let deserialized: EditError = serde_json::from_str(&json).unwrap();
1903        assert_eq!(deserialized.kind, EditErrorKind::AmbiguousTarget);
1904        assert_eq!(deserialized.similar_targets.len(), 2);
1905    }
1906
1907    #[test]
1908    fn match_strategy_display() {
1909        assert_eq!(MatchStrategy::Exact.to_string(), "exact");
1910        assert_eq!(MatchStrategy::Anchor.to_string(), "anchor");
1911        assert_eq!(MatchStrategy::Similarity.to_string(), "similarity");
1912    }
1913
1914    #[test]
1915    fn extract_identifiers_from_code() {
1916        let line = "fn process_request(data: &str) -> Result<()> {";
1917        let ids = extract_identifiers(line);
1918        assert!(ids.contains(&"process_request".to_string()));
1919        assert!(ids.contains(&"data".to_string()));
1920        assert!(ids.contains(&"str".to_string()));
1921        assert!(ids.contains(&"Result".to_string()));
1922    }
1923
1924    #[test]
1925    fn validation_result_serializes() {
1926        let result = ValidationResult {
1927            valid: true,
1928            errors: vec![],
1929            warnings: vec!["some warning".into()],
1930        };
1931        let json = serde_json::to_string(&result).unwrap();
1932        let deserialized: ValidationResult = serde_json::from_str(&json).unwrap();
1933        assert!(deserialized.valid);
1934        assert_eq!(deserialized.warnings.len(), 1);
1935    }
1936
1937    #[test]
1938    fn truncate_safe_on_multibyte_utf8() {
1939        // "café" has bytes: c(1) a(1) f(1) é(2) = 5 bytes, 4 chars.
1940        let s = "café";
1941        assert_eq!(s.len(), 5);
1942        // Truncate at 4 would land in the middle of 'é' (bytes 3-4).
1943        // Must not panic; should truncate before the multi-byte char.
1944        let t = truncate_str(s, 4);
1945        assert_eq!(t, "caf");
1946        // Truncate at 5 returns the whole string.
1947        assert_eq!(truncate_str(s, 5), "café");
1948        // Truncate at 3 is a clean boundary.
1949        assert_eq!(truncate_str(s, 3), "caf");
1950        // Truncate at 0.
1951        assert_eq!(truncate_str(s, 0), "");
1952    }
1953
1954    #[test]
1955    fn validate_edit_toml_syntax_check() {
1956        let toml = "[database]\nhost = \"localhost\"\n";
1957        // Valid replacement.
1958        let result = validate_edit(toml, "localhost", "remotehost", Some("config.toml"));
1959        assert!(result.valid);
1960
1961        // Invalid replacement (breaks TOML).
1962        let result = validate_edit(
1963            toml,
1964            "[database]",
1965            "not valid toml {{{{",
1966            Some("config.toml"),
1967        );
1968        assert!(!result.valid);
1969        assert!(result.errors[0].contains("invalid TOML"));
1970    }
1971
1972    #[test]
1973    fn find_similar_targets_multi_word_pattern() {
1974        let content = "fn process_all_requests(data: &str) -> bool {\n    true\n}\n";
1975        // Multi-word pattern triggers whole-line matching path.
1976        let similar = find_similar_targets(content, "fn process_all_reqests(data: &str)", 3);
1977        assert!(
1978            !similar.is_empty(),
1979            "should find similar multi-word targets"
1980        );
1981    }
1982
1983    #[test]
1984    fn resolve_fallback_multi_line_no_similarity() {
1985        // Multi-line targets skip the similarity path and go to error.
1986        let content = "fn alpha() {}\nfn beta() {}\nfn gamma() {}\n";
1987        let result = resolve_with_fallback(content, "fn alphax() {}\nfn betax() {}", None, None);
1988        assert!(
1989            result.is_err(),
1990            "multi-line targets without context should not match via similarity"
1991        );
1992        let err = result.unwrap_err();
1993        assert_eq!(err.kind, EditErrorKind::NoMatch);
1994    }
1995
1996    #[test]
1997    fn resolve_with_fallback_multi_line_anchor_match() {
1998        // Multi-line target with context should resolve via anchor matching
1999        // through the full fallback chain (not just the anchor_match helper).
2000        let content = "fn header() {}\nfn proccess_data(x: i32) {\n    x + 1\n}\nfn footer() {}\n";
2001        let result = resolve_with_fallback(
2002            content,
2003            "fn process_data(x: i32) {\n    x + 1\n}",
2004            Some("fn header() {}"),
2005            Some("fn footer() {}"),
2006        );
2007        let r = result.expect("multi-line anchor should succeed");
2008        assert_eq!(r.strategy, MatchStrategy::Anchor);
2009        assert!(r.matched_text.contains("proccess_data"));
2010        assert!(r.matched_text.contains("x + 1"));
2011    }
2012
2013    #[test]
2014    fn validate_edit_yml_extension() {
2015        // The .yml extension (not just .yaml) should trigger YAML validation.
2016        let yaml = "key: value\nlist:\n  - item1\n";
2017        // Valid replacement.
2018        let result = validate_edit(yaml, "value", "new_value", Some("config.yml"));
2019        assert!(result.valid);
2020
2021        // Invalid replacement that breaks YAML structure.
2022        let result = validate_edit(yaml, "key: value", ":\n  :\n  - :", Some("config.yml"));
2023        assert!(
2024            !result.valid,
2025            ".yml extension should trigger YAML validation"
2026        );
2027        assert!(
2028            result.errors[0].contains("invalid YAML"),
2029            "expected YAML error, got: {}",
2030            result.errors[0]
2031        );
2032    }
2033
2034    // -- validate_edit_nth (#1061) -------------------------------------------
2035
2036    #[test]
2037    fn validate_edit_nth_replaces_only_nth_occurrence() {
2038        // Two occurrences of "val"; replacing only the 1st is valid JSON.
2039        let json = r#"{"a": "val", "b": "val"}"#;
2040        let result = validate_edit_nth(json, "val", "new", Some("data.json"), Some(1));
2041        assert!(result.valid, "nth=1 should produce valid JSON");
2042    }
2043
2044    #[test]
2045    fn validate_edit_nth_none_replaces_all() {
2046        // nth=None replaces all occurrences (same as validate_edit).
2047        let content = "aXbXc";
2048        let result = validate_edit_nth(content, "X", "Y", None, None);
2049        assert!(result.valid);
2050    }
2051
2052    #[test]
2053    fn validate_edit_nth_out_of_range() {
2054        // nth=5 but only 2 occurrences: should return invalid with descriptive error.
2055        let content = "aXbXc";
2056        let result = validate_edit_nth(content, "X", "Y", None, Some(5));
2057        assert!(
2058            !result.valid,
2059            "nth beyond occurrence count should be invalid"
2060        );
2061        assert!(
2062            result.errors[0].contains("occurrence 5 not found"),
2063            "error should mention the missing occurrence: {:?}",
2064            result.errors
2065        );
2066    }
2067
2068    #[test]
2069    fn validate_edit_nth_zero_rejected() {
2070        // nth=0 is invalid (1-based indexing); must not truncate content.
2071        let content = r#"{"a": "val"}"#;
2072        let result = validate_edit_nth(content, "val", "X", Some("f.json"), Some(0));
2073        assert!(!result.valid);
2074        assert!(result.errors[0].contains("nth must be >= 1"));
2075    }
2076
2077    #[test]
2078    fn validate_edit_nth_detects_invalid_json_for_single_occurrence() {
2079        // Replacing only the 1st "value" with a broken fragment is invalid.
2080        let json = r#"{"a": "value", "b": "value"}"#;
2081        let result = validate_edit_nth(json, "\"value\"", "broken}", Some("config.json"), Some(1));
2082        assert!(!result.valid, "nth=1 should detect broken JSON");
2083    }
2084
2085    // -- anchor_match and resolve_with_fallback edge cases (#978) -----------
2086
2087    #[test]
2088    fn anchor_match_after_only_context() {
2089        // after_context without before_context hits the code path where
2090        // before_context is None but after_context is checked.
2091        let content = "fn setup() {}\nfn proccess_data(x: i32) {}\nfn cleanup() {}\n";
2092        let result = anchor_match(
2093            content,
2094            "fn process_data(x: i32) {}",
2095            None,
2096            Some("fn cleanup() {}"),
2097        );
2098        let r = result.expect("after-only context should find anchor match");
2099        assert_eq!(r.strategy, MatchStrategy::Anchor);
2100        assert!(r.matched_text.contains("proccess_data"));
2101    }
2102
2103    #[test]
2104    fn anchor_match_multiple_candidates_returns_first() {
2105        // Target appears (fuzzily) twice; anchor_match returns the first match.
2106        let content = "fn header() {}\nfn proccess(x: i32) {}\nfn middle() {}\nfn proccess(y: bool) {}\nfn footer() {}\n";
2107        let result = anchor_match(
2108            content,
2109            "fn process(x: i32) {}",
2110            Some("fn header() {}"),
2111            Some("fn middle() {}"),
2112        );
2113        let r = result.expect("should match first candidate");
2114        assert_eq!(r.strategy, MatchStrategy::Anchor);
2115        // The first candidate contains "x: i32", not "y: bool".
2116        assert!(
2117            r.matched_text.contains("x: i32"),
2118            "should match first occurrence, got: {}",
2119            r.matched_text
2120        );
2121    }
2122
2123    #[test]
2124    fn resolve_with_fallback_empty_content() {
2125        let result = resolve_with_fallback("", "fn hello()", None, None);
2126        let err = result.unwrap_err();
2127        assert_eq!(err.kind, EditErrorKind::NoMatch);
2128    }
2129
2130    #[test]
2131    fn anchor_match_multiline_before_context() {
2132        // Multi-line before_context should use the last line for matching,
2133        // not the entire multi-line string.
2134        let content = "fn setup() {}\nfn target() {}\nfn cleanup() {}\n";
2135        let result = anchor_match(
2136            content,
2137            "fn target() {}",
2138            Some("fn other() {}\nfn setup() {}"),
2139            None,
2140        );
2141        assert!(
2142            result.is_some(),
2143            "anchor_match should match using the last line of multi-line before_context"
2144        );
2145        assert_eq!(result.unwrap().matched_text, "fn target() {}");
2146    }
2147
2148    #[test]
2149    fn anchor_match_multiline_after_context() {
2150        // Multi-line after_context should use the first line for matching.
2151        let content = "fn setup() {}\nfn target() {}\nfn cleanup() {}\n";
2152        let result = anchor_match(
2153            content,
2154            "fn target() {}",
2155            None,
2156            Some("fn cleanup() {}\nfn extra() {}"),
2157        );
2158        assert!(
2159            result.is_some(),
2160            "anchor_match should match using the first line of multi-line after_context"
2161        );
2162        assert_eq!(result.unwrap().matched_text, "fn target() {}");
2163    }
2164
2165    // Static assertions: types must be Send + Sync.
2166    const _: () = {
2167        fn _assert<T: Send + Sync>() {}
2168        let _ = _assert::<EditError>;
2169        let _ = _assert::<EditErrorKind>;
2170        let _ = _assert::<ValidationResult>;
2171        let _ = _assert::<AnchorMatchResult>;
2172        let _ = _assert::<MatchStrategy>;
2173    };
2174}