Skip to main content

ripsed_core/
engine.rs

1use crate::diff::{Change, ChangeContext, FileChanges, OpResult};
2use crate::error::RipsedError;
3use crate::matcher::{MatchSpan, Matcher};
4use crate::operation::{LineRange, Op, RangeSpec, ReplaceCount, TransformMode};
5use crate::undo::UndoEntry;
6
7/// The result of applying operations to a text buffer.
8#[derive(Debug)]
9pub struct EngineOutput {
10    /// The modified text (None if unchanged).
11    pub text: Option<String>,
12    /// Structured diff of changes made.
13    pub changes: Vec<Change>,
14    /// Undo entry to reverse this operation.
15    pub undo: Option<UndoEntry>,
16}
17
18/// The result of applying a single operation to one line.
19///
20/// Each helper function returns this to tell the main loop what to do with
21/// the line and whether a change was recorded.
22enum LineAction {
23    /// Line is unchanged; push the original.
24    Unchanged,
25    /// Line was replaced by a new value; push `new_line` and record the change.
26    Replaced { new_line: String, change: Change },
27    /// Line was deleted; do NOT push anything, but record the change.
28    Deleted { change: Change },
29    /// A new line was inserted after the original; push both and record the change.
30    InsertedAfter { content: String, change: Change },
31    /// A new line was inserted before the original; push both and record the change.
32    InsertedBefore { content: String, change: Change },
33}
34
35/// Stateful line filter for a [`RangeSpec`].
36///
37/// Numeric ranges are a pure line-number check. Pattern ranges implement
38/// sed's `/start/,/end/` semantics: a region opens on a start-matching
39/// line, the end pattern is tested only on *later* lines (so `/a/,/a/`
40/// spans to the next `a`), boundary lines are inside the region, and an
41/// unclosed region runs to EOF.
42enum RangeFilter {
43    All,
44    Lines(LineRange),
45    Patterns {
46        start: regex::Regex,
47        end: regex::Regex,
48        active: bool,
49    },
50}
51
52impl RangeFilter {
53    fn new(range: Option<&RangeSpec>) -> Result<Self, RipsedError> {
54        match range {
55            None => Ok(RangeFilter::All),
56            Some(RangeSpec::Lines(lines)) => Ok(RangeFilter::Lines(*lines)),
57            Some(RangeSpec::Patterns(patterns)) => {
58                let compile = |which: &str, pattern: &str| {
59                    regex::Regex::new(pattern).map_err(|e| {
60                        let mut err = RipsedError::invalid_regex(0, pattern, &e.to_string());
61                        err.operation_index = None;
62                        err.message = format!("Range {which} pattern failed to compile: {e}.");
63                        err
64                    })
65                };
66                Ok(RangeFilter::Patterns {
67                    start: compile("start", &patterns.start_pattern)?,
68                    end: compile("end", &patterns.end_pattern)?,
69                    active: false,
70                })
71            }
72        }
73    }
74
75    /// Whether the operation applies to this line. Must be called once per
76    /// line, in order — pattern ranges are stateful.
77    fn admits(&mut self, line_num: usize, line: &str) -> bool {
78        match self {
79            RangeFilter::All => true,
80            RangeFilter::Lines(range) => range.contains(line_num),
81            RangeFilter::Patterns { start, end, active } => {
82                if *active {
83                    // End-boundary line is still inside the region; the
84                    // region closes after it.
85                    if end.is_match(line) {
86                        *active = false;
87                    }
88                    true
89                } else if start.is_match(line) {
90                    *active = true;
91                    true
92                } else {
93                    false
94                }
95            }
96        }
97    }
98}
99
100/// Detect whether the text predominantly uses CRLF line endings.
101///
102/// Uses a majority-vote heuristic: counts CRLF (`\r\n`) vs bare LF (`\n`)
103/// occurrences and returns `true` only when CRLF is strictly more common.
104/// When counts are equal (including zero), prefers LF as the more portable
105/// default.  This prevents a file with mixed line endings from being
106/// silently normalized to all-CRLF.
107#[cfg(test)]
108fn uses_crlf(text: &str) -> bool {
109    let (crlf, bare_lf) = line_ending_counts(text);
110    crlf > bare_lf
111}
112
113/// Count `\r\n` and bare-`\n` terminators in one memchr-driven pass
114/// (the previous two full `matches` scans showed up on large-file
115/// profiles). Returns `(crlf, bare_lf)`.
116fn line_ending_counts(text: &str) -> (usize, usize) {
117    let bytes = text.as_bytes();
118    let mut crlf = 0usize;
119    let mut bare_lf = 0usize;
120    for pos in memchr::memchr_iter(b'\n', bytes) {
121        if pos > 0 && bytes[pos - 1] == b'\r' {
122            crlf += 1;
123        } else {
124            bare_lf += 1;
125        }
126    }
127    (crlf, bare_lf)
128}
129
130/// Context is attached to at most this many changes per file (both the
131/// line path and the splice path). Past this, a diff is no longer
132/// something anyone reads hunk-by-hunk — and at six context lines per
133/// change, a million-change file would otherwise allocate hundreds of
134/// megabytes of strings nobody displays.
135const MAX_CONTEXTED_CHANGES: usize = 1000;
136
137// ---------------------------------------------------------------------------
138// Per-operation helper functions
139//
140// Each helper inspects one line and returns a `LineAction` indicating what the
141// main `apply()` loop should do.  Keeping the logic in dedicated functions
142// makes it easy to add new `Op` variants without bloating `apply()`.
143// ---------------------------------------------------------------------------
144
145/// Shared context passed to each per-line operation helper.
146struct LineCtx<'a> {
147    line: &'a str,
148    line_num: usize,
149    matcher: &'a Matcher,
150    lines: &'a [&'a str],
151    idx: usize,
152    context_lines: usize,
153    /// The file's detected line separator — multi-line `Change` metadata
154    /// must use this so it matches the bytes actually written.
155    line_sep: &'a str,
156}
157
158impl LineCtx<'_> {
159    /// Context lines around the change, or `None` when the caller asked
160    /// for zero — callers that never display context (`--quiet`,
161    /// `--count`) shouldn't pay per-change allocations for it.
162    fn maybe_context(&self) -> Option<ChangeContext> {
163        if self.context_lines == 0 {
164            return None;
165        }
166        Some(build_context(self.lines, self.idx, self.context_lines))
167    }
168}
169
170/// Handle `Op::Replace` — substitute matched text within the line,
171/// honoring the operation's [`ReplaceCount`] via the shared `budget`
172/// (remaining file-wide occurrences for `FirstInFile`/`Max`, `None`
173/// when unlimited).
174fn apply_replace(
175    cx: &LineCtx,
176    replace: &str,
177    count: ReplaceCount,
178    budget: &mut Option<usize>,
179) -> LineAction {
180    let line_limit = match (count, budget.as_ref()) {
181        (ReplaceCount::FirstPerLine, _) => 1,
182        (_, Some(0)) => return LineAction::Unchanged, // budget exhausted
183        (_, Some(remaining)) => *remaining,
184        (_, None) => 0, // unlimited
185    };
186    if let Some((replaced, occurrences)) = cx.matcher.replace_n(cx.line, replace, line_limit) {
187        if let Some(remaining) = budget {
188            *remaining = remaining.saturating_sub(occurrences);
189        }
190        LineAction::Replaced {
191            new_line: replaced.clone(),
192            change: Change {
193                line: cx.line_num,
194                before: cx.line.to_string(),
195                after: Some(replaced),
196                context: cx.maybe_context(),
197            },
198        }
199    } else {
200        LineAction::Unchanged
201    }
202}
203
204/// Initial file-wide occurrence budget for a Replace's [`ReplaceCount`]
205/// (`None` = unlimited; per-line caps are handled in [`apply_replace`]).
206fn replace_budget(op: &Op) -> Option<usize> {
207    match op {
208        Op::Replace { count, .. } => match count {
209            ReplaceCount::All | ReplaceCount::FirstPerLine => None,
210            ReplaceCount::FirstInFile => Some(1),
211            ReplaceCount::Max(n) => Some(*n),
212        },
213        _ => None,
214    }
215}
216
217/// Handle `Op::Delete` — remove the line entirely if matched.
218fn apply_delete(cx: &LineCtx) -> LineAction {
219    if cx.matcher.is_match(cx.line) {
220        LineAction::Deleted {
221            change: Change {
222                line: cx.line_num,
223                before: cx.line.to_string(),
224                after: None,
225                context: cx.maybe_context(),
226            },
227        }
228    } else {
229        LineAction::Unchanged
230    }
231}
232
233/// Handle `Op::InsertAfter` — insert new content after a matched line.
234fn apply_insert_after(cx: &LineCtx, content: &str) -> LineAction {
235    if cx.matcher.is_match(cx.line) {
236        LineAction::InsertedAfter {
237            content: content.to_string(),
238            change: Change {
239                line: cx.line_num,
240                before: cx.line.to_string(),
241                after: Some(format!("{}{}{content}", cx.line, cx.line_sep)),
242                context: cx.maybe_context(),
243            },
244        }
245    } else {
246        LineAction::Unchanged
247    }
248}
249
250/// Handle `Op::InsertBefore` — insert new content before a matched line.
251fn apply_insert_before(cx: &LineCtx, content: &str) -> LineAction {
252    if cx.matcher.is_match(cx.line) {
253        LineAction::InsertedBefore {
254            content: content.to_string(),
255            change: Change {
256                line: cx.line_num,
257                before: cx.line.to_string(),
258                after: Some(format!("{content}{}{}", cx.line_sep, cx.line)),
259                context: cx.maybe_context(),
260            },
261        }
262    } else {
263        LineAction::Unchanged
264    }
265}
266
267/// Handle `Op::ReplaceLine` — replace the entire line with new content.
268fn apply_replace_line(cx: &LineCtx, content: &str) -> LineAction {
269    if cx.matcher.is_match(cx.line) {
270        LineAction::Replaced {
271            new_line: content.to_string(),
272            change: Change {
273                line: cx.line_num,
274                before: cx.line.to_string(),
275                after: Some(content.to_string()),
276                context: cx.maybe_context(),
277            },
278        }
279    } else {
280        LineAction::Unchanged
281    }
282}
283
284/// Handle `Op::Transform` — apply a case/naming transformation to matched text.
285fn apply_transform_op(cx: &LineCtx, mode: TransformMode) -> LineAction {
286    if cx.matcher.is_match(cx.line) {
287        let new_line = apply_transform(cx.line, cx.matcher, mode);
288        if new_line != cx.line {
289            LineAction::Replaced {
290                new_line: new_line.clone(),
291                change: Change {
292                    line: cx.line_num,
293                    before: cx.line.to_string(),
294                    after: Some(new_line),
295                    context: cx.maybe_context(),
296                },
297            }
298        } else {
299            LineAction::Unchanged
300        }
301    } else {
302        LineAction::Unchanged
303    }
304}
305
306/// Handle `Op::Surround` — wrap matched lines with a prefix and suffix.
307fn apply_surround(cx: &LineCtx, prefix: &str, suffix: &str) -> LineAction {
308    if cx.matcher.is_match(cx.line) {
309        let new_line = format!("{prefix}{}{suffix}", cx.line);
310        if new_line != cx.line {
311            LineAction::Replaced {
312                new_line: new_line.clone(),
313                change: Change {
314                    line: cx.line_num,
315                    before: cx.line.to_string(),
316                    after: Some(new_line),
317                    context: cx.maybe_context(),
318                },
319            }
320        } else {
321            LineAction::Unchanged
322        }
323    } else {
324        LineAction::Unchanged
325    }
326}
327
328/// Handle `Op::Indent` — prepend whitespace to matched lines.
329fn apply_indent(cx: &LineCtx, amount: usize, use_tabs: bool) -> LineAction {
330    if cx.matcher.is_match(cx.line) {
331        let indent = if use_tabs {
332            "\t".repeat(amount)
333        } else {
334            " ".repeat(amount)
335        };
336        let new_line = format!("{indent}{}", cx.line);
337        if new_line != cx.line {
338            LineAction::Replaced {
339                new_line: new_line.clone(),
340                change: Change {
341                    line: cx.line_num,
342                    before: cx.line.to_string(),
343                    after: Some(new_line),
344                    context: cx.maybe_context(),
345                },
346            }
347        } else {
348            LineAction::Unchanged
349        }
350    } else {
351        LineAction::Unchanged
352    }
353}
354
355/// Handle `Op::Dedent` — remove leading whitespace from matched lines.
356fn apply_dedent(cx: &LineCtx, amount: usize, use_tabs: bool) -> LineAction {
357    if cx.matcher.is_match(cx.line) {
358        let new_line = dedent_line(cx.line, amount, use_tabs);
359        if new_line != cx.line {
360            LineAction::Replaced {
361                new_line: new_line.clone(),
362                change: Change {
363                    line: cx.line_num,
364                    before: cx.line.to_string(),
365                    after: Some(new_line),
366                    context: cx.maybe_context(),
367                },
368            }
369        } else {
370            LineAction::Unchanged
371        }
372    } else {
373        LineAction::Unchanged
374    }
375}
376
377/// Apply a single operation to a text buffer.
378///
379/// Returns the modified text and a structured diff.
380/// If `dry_run` is true, the text is computed but flagged as preview-only.
381pub fn apply(
382    text: &str,
383    op: &Op,
384    matcher: &Matcher,
385    range: Option<RangeSpec>,
386    context_lines: usize,
387) -> Result<EngineOutput, RipsedError> {
388    if op.is_multiline() {
389        if range.is_some() {
390            return Err(RipsedError::invalid_request(
391                "ranges are not supported in multiline mode",
392                "multiline patterns match against the whole buffer; remove the range or the multiline flag",
393            ));
394        }
395        if matches!(
396            op,
397            Op::Replace {
398                count: ReplaceCount::FirstPerLine,
399                ..
400            }
401        ) {
402            return Err(RipsedError::invalid_request(
403                "first_per_line count is not supported in multiline mode",
404                "per-line counting has no meaning when matching the whole buffer; use first_in_file or {\"max\": n} instead",
405            ));
406        }
407        return Ok(apply_multiline(text, op, matcher, context_lines));
408    }
409
410    let mut range_filter = RangeFilter::new(range.as_ref())?;
411
412    // Fast reject: if the pattern can't match anywhere in the buffer, no
413    // line can match it — skip the per-line loop entirely. (Prescreen
414    // false positives are fine; false negatives would be a bug, locked
415    // down by `prop_prescreen_never_false_skips`.) Runs after range
416    // validation so invalid range regexes still error.
417    if !matcher.prescreen(text) {
418        return Ok(EngineOutput {
419            text: None,
420            changes: Vec::new(),
421            undo: None,
422        });
423    }
424
425    let (crlf_count, bare_lf_count) = line_ending_counts(text);
426    let crlf = crlf_count > bare_lf_count;
427    let line_sep = if crlf { "\r\n" } else { "\n" };
428
429    // Whole-buffer splice fast path: O(input + matches) instead of an
430    // owned String per line, when provably byte-identical to the loop.
431    if splice_eligible(op, &range, crlf_count, bare_lf_count) {
432        return Ok(apply_spliced(text, op, matcher, context_lines));
433    }
434
435    let lines: Vec<&str> = text.lines().collect();
436    let mut result_lines: Vec<String> = Vec::with_capacity(lines.len());
437    let mut changes: Vec<Change> = Vec::new();
438    let mut budget = replace_budget(op);
439
440    for (idx, &line) in lines.iter().enumerate() {
441        let line_num = idx + 1; // 1-indexed
442
443        // Skip lines outside the range (numeric or pattern-addressed)
444        if !range_filter.admits(line_num, line) {
445            result_lines.push(line.to_string());
446            continue;
447        }
448
449        let cx = LineCtx {
450            line,
451            line_num,
452            matcher,
453            lines: &lines,
454            idx,
455            // Past the cap, changes carry no context (see the constant).
456            context_lines: if changes.len() >= MAX_CONTEXTED_CHANGES {
457                0
458            } else {
459                context_lines
460            },
461            line_sep,
462        };
463
464        let action = dispatch_op(op, &cx, &mut budget);
465
466        match action {
467            LineAction::Unchanged => {
468                result_lines.push(line.to_string());
469            }
470            LineAction::Replaced { new_line, change } => {
471                changes.push(change);
472                result_lines.push(new_line);
473            }
474            LineAction::Deleted { change } => {
475                changes.push(change);
476                // Don't push — line is deleted
477            }
478            LineAction::InsertedAfter { content, change } => {
479                result_lines.push(line.to_string());
480                changes.push(change);
481                result_lines.push(content);
482            }
483            LineAction::InsertedBefore { content, change } => {
484                changes.push(change);
485                result_lines.push(content);
486                result_lines.push(line.to_string());
487            }
488        }
489    }
490
491    let modified_text = if changes.is_empty() {
492        None
493    } else {
494        // Preserve line ending style and trailing newline — but only when
495        // the result has content. If every line was deleted, the output is
496        // a genuinely empty file, not a file containing a single empty line.
497        let mut joined = result_lines.join(line_sep);
498        if !result_lines.is_empty() && (text.ends_with('\n') || text.ends_with("\r\n")) {
499            joined.push_str(line_sep);
500        }
501        Some(joined)
502    };
503
504    let undo = if !changes.is_empty() {
505        Some(UndoEntry {
506            original_text: text.to_string(),
507        })
508    } else {
509        None
510    };
511
512    Ok(EngineOutput {
513        text: modified_text,
514        changes,
515        undo,
516    })
517}
518
519/// Whether this operation + buffer can take the whole-buffer splice fast
520/// path with output and metadata **byte-identical** to the per-line loop.
521///
522/// The soundness argument requires all of:
523/// - `Replace` with a **literal** pattern (case-insensitive literals
524///   included — their escaped shadow can't gain metacharacters). Regexes
525///   are excluded: classes like `\s`, `\W`, or `[^x]` match `\n` against
526///   a whole buffer but never per line, and textual eligibility analysis
527///   of that is unsound.
528/// - The pattern contains no `\n`/`\r`, so no match can touch a line
529///   boundary and the match sets coincide.
530/// - No range filter (those are inherently line-driven).
531/// - Not `FirstPerLine` (per-line budgets need the loop); `FirstInFile`
532///   and `Max` truncate the span list to the same occurrence set.
533/// - **Uniform line endings**: the per-line loop rejoins with the
534///   majority separator, silently normalizing mixed-ending files;
535///   splice preserves bytes, so mixed files must take the loop.
536fn splice_eligible(op: &Op, range: &Option<RangeSpec>, crlf: usize, bare_lf: usize) -> bool {
537    if range.is_some() || (crlf > 0 && bare_lf > 0) {
538        return false;
539    }
540    match op {
541        Op::Replace {
542            find, regex, count, ..
543        } => {
544            // Empty patterns are excluded: their zero-width matches land
545            // on positions that don't exist per line (the bytes of the
546            // terminator itself), so splice and loop semantics diverge —
547            // and a match sitting exactly on a newline would never be
548            // consumed by the line-grouping walk. Found by fuzz_engine
549            // (OOM via infinite Change accumulation).
550            !find.is_empty()
551                && !*regex
552                && *count != ReplaceCount::FirstPerLine
553                && !find.contains('\n')
554                && !find.contains('\r')
555        }
556        _ => false,
557    }
558}
559
560/// Whole-buffer splice for eligible Replaces: find all spans once, copy
561/// the unmatched stretches through untouched, and reconstruct the
562/// line-shaped `Change` metadata only for affected lines — O(input +
563/// matches) rather than an owned `String` for every line of the file.
564fn apply_spliced(text: &str, op: &Op, matcher: &Matcher, context_lines: usize) -> EngineOutput {
565    let replace = match op {
566        Op::Replace { replace, .. } => replace.as_str(),
567        // Guarded by splice_eligible.
568        _ => unreachable!("splice path only handles Replace"),
569    };
570
571    let mut spans = matcher.find_replacements(text, replace);
572    if let Some(limit) = replace_budget(op) {
573        spans.truncate(limit);
574    }
575    if spans.is_empty() {
576        return EngineOutput {
577            text: None,
578            changes: Vec::new(),
579            undo: None,
580        };
581    }
582
583    let bytes = text.as_bytes();
584    let mut out = String::with_capacity(text.len());
585    let mut changes: Vec<Change> = Vec::new();
586    let mut last_end = 0usize; // output copy cursor
587    let mut line_num = 1usize; // 1-indexed line of `scan`
588    let mut scan = 0usize; // newline-counting cursor
589
590    let mut i = 0;
591    while i < spans.len() {
592        let group_first = spans[i].start;
593        // Advance the line counter to this span's line.
594        line_num += memchr::memchr_iter(b'\n', &bytes[scan..group_first]).count();
595        scan = group_first;
596
597        // Bounds of the containing line. Content excludes the terminator
598        // (and its `\r`), matching what `lines()` yields in the loop path.
599        let line_begin = memchr::memrchr(b'\n', &bytes[..group_first]).map_or(0, |p| p + 1);
600        let line_end =
601            memchr::memchr(b'\n', &bytes[group_first..]).map_or(text.len(), |p| group_first + p);
602        let content_end = if line_end > line_begin && bytes[line_end - 1] == b'\r' {
603            line_end - 1
604        } else {
605            line_end
606        };
607
608        // Splice every span on this line, building the after-line as we go.
609        // Eligibility guarantees at least one span lands inside the line
610        // (non-empty patterns without `\n` can't match at or past the
611        // terminator) — guard against no-progress anyway so a future
612        // eligibility bug degrades into a skipped span, not an infinite
613        // loop (fuzz_engine found exactly that with empty patterns).
614        debug_assert!(
615            spans[i].start < line_end,
616            "splice span must fall inside its line"
617        );
618        if spans[i].start >= line_end {
619            i += 1;
620            continue;
621        }
622        let before_line = &text[line_begin..content_end];
623        let mut after_line = String::with_capacity(before_line.len());
624        let mut line_cursor = line_begin;
625        while i < spans.len() && spans[i].start < line_end {
626            let span = &spans[i];
627            after_line.push_str(&text[line_cursor..span.start]);
628            after_line.push_str(&span.replacement);
629            out.push_str(&text[last_end..span.start]);
630            out.push_str(&span.replacement);
631            line_cursor = span.end;
632            last_end = span.end;
633            i += 1;
634        }
635        after_line.push_str(&text[line_cursor..content_end]);
636
637        let context = if context_lines == 0 || changes.len() >= MAX_CONTEXTED_CHANGES {
638            None
639        } else {
640            Some(splice_line_context(
641                text,
642                line_begin,
643                line_end,
644                context_lines,
645            ))
646        };
647        changes.push(Change {
648            line: line_num,
649            before: before_line.to_string(),
650            after: Some(after_line),
651            context,
652        });
653    }
654    out.push_str(&text[last_end..]);
655
656    EngineOutput {
657        text: Some(out),
658        changes,
659        undo: Some(UndoEntry {
660            original_text: text.to_string(),
661        }),
662    }
663}
664
665/// Context lines around `line_begin..line_end` gathered by scanning for
666/// neighboring newlines — O(context) per call, used only for changes
667/// that will actually carry context.
668fn splice_line_context(
669    text: &str,
670    line_begin: usize,
671    line_end: usize,
672    context_lines: usize,
673) -> ChangeContext {
674    let bytes = text.as_bytes();
675    let strip_cr = |s: &str| s.strip_suffix('\r').unwrap_or(s).to_string();
676
677    let mut before = Vec::new();
678    let mut end = line_begin; // exclusive end of the previous line's terminator
679    for _ in 0..context_lines {
680        if end == 0 {
681            break;
682        }
683        // `end` sits just past a '\n'; the previous line spans back to the
684        // newline before that (or the start of the buffer).
685        let term = end - 1; // index of the '\n'
686        let begin = memchr::memrchr(b'\n', &bytes[..term]).map_or(0, |p| p + 1);
687        let content = &text[begin..term];
688        before.push(strip_cr(content));
689        end = begin;
690    }
691    before.reverse();
692
693    let mut after = Vec::new();
694    let mut begin = match line_end {
695        e if e >= text.len() => text.len(),
696        e => e + 1, // skip the '\n'
697    };
698    for _ in 0..context_lines {
699        if begin >= text.len() {
700            break;
701        }
702        let term = memchr::memchr(b'\n', &bytes[begin..]).map_or(text.len(), |p| begin + p);
703        after.push(strip_cr(&text[begin..term]));
704        begin = if term >= text.len() {
705            text.len()
706        } else {
707            term + 1
708        };
709    }
710
711    ChangeContext { before, after }
712}
713
714/// Route one line to the per-operation helper for line-scoped ops.
715/// Shared by the buffered [`apply`] loop and the streaming
716/// [`LineProcessor`]. Multiline ops never reach this (both callers
717/// reject them first).
718fn dispatch_op(op: &Op, cx: &LineCtx, budget: &mut Option<usize>) -> LineAction {
719    match op {
720        Op::Replace { replace, count, .. } => apply_replace(cx, replace, *count, budget),
721        Op::Delete { .. } => apply_delete(cx),
722        Op::InsertAfter { content, .. } => apply_insert_after(cx, content),
723        Op::InsertBefore { content, .. } => apply_insert_before(cx, content),
724        Op::ReplaceLine { content, .. } => apply_replace_line(cx, content),
725        Op::Transform { mode, .. } => apply_transform_op(cx, *mode),
726        Op::Surround { prefix, suffix, .. } => apply_surround(cx, prefix, suffix),
727        Op::Indent {
728            amount, use_tabs, ..
729        } => apply_indent(cx, *amount, *use_tabs),
730        Op::Dedent {
731            amount, use_tabs, ..
732        } => apply_dedent(cx, *amount, *use_tabs),
733    }
734}
735
736/// What [`LineProcessor::process_line`] decided for one input line.
737#[derive(Debug, PartialEq, Eq)]
738pub struct StreamedLine {
739    /// Lines to emit in place of the input line: empty for a deletion,
740    /// one for unchanged/replaced, two for an insert plus the original.
741    pub lines: Vec<String>,
742    /// Whether the operation changed this line.
743    pub changed: bool,
744}
745
746/// Incremental line-by-line processor for streaming inputs (pipe mode).
747///
748/// Wraps the same per-line logic as [`apply`] without buffering the whole
749/// input: feed lines in order (terminators stripped), emit what comes
750/// back. Carries the stateful pieces — pattern-range filter, replacement
751/// budget, line counter — across calls, so `--range`, `--first-in-file`,
752/// and `--max-replacements` all work on streams.
753///
754/// Multiline operations need the whole buffer and are rejected at
755/// construction; callers fall back to buffered processing for those.
756pub struct LineProcessor<'a> {
757    op: &'a Op,
758    matcher: &'a Matcher,
759    range_filter: RangeFilter,
760    budget: Option<usize>,
761    line_num: usize,
762}
763
764impl<'a> LineProcessor<'a> {
765    pub fn new(
766        op: &'a Op,
767        matcher: &'a Matcher,
768        range: Option<RangeSpec>,
769    ) -> Result<Self, RipsedError> {
770        if op.is_multiline() {
771            return Err(RipsedError::invalid_request(
772                "multiline operations cannot be streamed",
773                "multiline patterns match against the whole buffer; buffer the input instead",
774            ));
775        }
776        Ok(Self {
777            op,
778            matcher,
779            range_filter: RangeFilter::new(range.as_ref())?,
780            budget: replace_budget(op),
781            line_num: 0,
782        })
783    }
784
785    /// Process the next input line (without its terminator).
786    pub fn process_line(&mut self, line: &str) -> StreamedLine {
787        self.line_num += 1;
788        if !self.range_filter.admits(self.line_num, line) {
789            return StreamedLine {
790                lines: vec![line.to_string()],
791                changed: false,
792            };
793        }
794        // Streaming has no surrounding-lines buffer; context_lines is 0 and
795        // the context slice is just this line, which build_context handles.
796        let lines_slice = [line];
797        let cx = LineCtx {
798            line,
799            line_num: self.line_num,
800            matcher: self.matcher,
801            lines: &lines_slice,
802            idx: 0,
803            context_lines: 0,
804            line_sep: "\n",
805        };
806        match dispatch_op(self.op, &cx, &mut self.budget) {
807            LineAction::Unchanged => StreamedLine {
808                lines: vec![line.to_string()],
809                changed: false,
810            },
811            LineAction::Replaced { new_line, .. } => StreamedLine {
812                lines: vec![new_line],
813                changed: true,
814            },
815            LineAction::Deleted { .. } => StreamedLine {
816                lines: Vec::new(),
817                changed: true,
818            },
819            LineAction::InsertedAfter { content, .. } => StreamedLine {
820                lines: vec![line.to_string(), content],
821                changed: true,
822            },
823            LineAction::InsertedBefore { content, .. } => StreamedLine {
824                lines: vec![content, line.to_string()],
825                changed: true,
826            },
827        }
828    }
829}
830
831/// Apply a text transformation to matched portions of a line.
832fn apply_transform(line: &str, matcher: &Matcher, mode: TransformMode) -> String {
833    match matcher {
834        Matcher::Literal { pattern, .. } => {
835            line.replace(pattern.as_str(), &transform_text(pattern, mode))
836        }
837        Matcher::Regex { re, .. } => {
838            let result = re.replace_all(line, |caps: &regex::Captures| {
839                transform_text(&caps[0], mode)
840            });
841            result.into_owned()
842        }
843    }
844}
845
846/// Transform a text string according to the given mode.
847fn transform_text(text: &str, mode: TransformMode) -> String {
848    match mode {
849        TransformMode::Upper => text.to_uppercase(),
850        TransformMode::Lower => text.to_lowercase(),
851        TransformMode::Title => {
852            let mut result = String::with_capacity(text.len());
853            let mut capitalize_next = true;
854            for ch in text.chars() {
855                if ch.is_whitespace() || ch == '_' || ch == '-' {
856                    result.push(ch);
857                    capitalize_next = true;
858                } else if capitalize_next {
859                    for upper in ch.to_uppercase() {
860                        result.push(upper);
861                    }
862                    capitalize_next = false;
863                } else {
864                    result.push(ch);
865                }
866            }
867            result
868        }
869        TransformMode::SnakeCase => {
870            let mut result = String::with_capacity(text.len() + 4);
871            let mut prev_was_lower = false;
872            for ch in text.chars() {
873                if ch.is_uppercase() {
874                    if prev_was_lower {
875                        result.push('_');
876                    }
877                    for lower in ch.to_lowercase() {
878                        result.push(lower);
879                    }
880                    prev_was_lower = false;
881                } else if ch == '-' || ch == ' ' {
882                    result.push('_');
883                    prev_was_lower = false;
884                } else {
885                    result.push(ch);
886                    prev_was_lower = ch.is_lowercase();
887                }
888            }
889            result
890        }
891        TransformMode::CamelCase => {
892            let mut result = String::with_capacity(text.len());
893            let mut capitalize_next = false;
894            let mut first = true;
895            for ch in text.chars() {
896                if ch == '_' || ch == '-' || ch == ' ' {
897                    capitalize_next = true;
898                } else if capitalize_next {
899                    for upper in ch.to_uppercase() {
900                        result.push(upper);
901                    }
902                    capitalize_next = false;
903                } else if first {
904                    for lower in ch.to_lowercase() {
905                        result.push(lower);
906                    }
907                    first = false;
908                } else {
909                    result.push(ch);
910                    first = false;
911                }
912            }
913            result
914        }
915    }
916}
917
918/// Remove up to `amount` leading whitespace characters from a line.
919/// When `use_tabs` is true, strips leading tabs; otherwise strips leading spaces.
920fn dedent_line(line: &str, amount: usize, use_tabs: bool) -> String {
921    let ch = if use_tabs { '\t' } else { ' ' };
922    let leading = line.len() - line.trim_start_matches(ch).len();
923    let remove = leading.min(amount);
924    line[remove..].to_string()
925}
926
927/// Apply a multiline (whole-buffer) Replace or Delete operation.
928///
929/// Unlike the line-by-line path, the buffer is never split and rejoined:
930/// each match span's replacement is spliced into the original text, so line
931/// separators outside the matched spans are untouched byte-for-byte and the
932/// trailing-newline state is preserved naturally.
933///
934/// `Change` metadata for a span: `line` is the 1-indexed line where the span
935/// starts, `before`/`after` carry the raw span bytes (including any `\r\n`
936/// inside it), and `context` holds the lines around the span — so the
937/// metadata always matches the bytes actually written.
938fn apply_multiline(text: &str, op: &Op, matcher: &Matcher, context_lines: usize) -> EngineOutput {
939    // `is_multiline()` is true only for Replace and Delete; Delete removes
940    // the matched span (replacement = "").
941    let (replacement, is_delete) = match op {
942        Op::Replace { replace, .. } => (replace.as_str(), false),
943        _ => ("", true),
944    };
945
946    let mut spans = matcher.find_replacements(text, replacement);
947    // FirstPerLine is rejected before dispatch; FirstInFile and Max
948    // simply truncate the span list (occurrence-counted).
949    if let Some(limit) = replace_budget(op) {
950        spans.truncate(limit);
951    }
952    if spans.is_empty() {
953        return EngineOutput {
954            text: None,
955            changes: Vec::new(),
956            undo: None,
957        };
958    }
959
960    let lines: Vec<&str> = text.lines().collect();
961    let mut out = String::with_capacity(text.len());
962    let mut changes = Vec::with_capacity(spans.len());
963    let mut last_end = 0usize;
964
965    for MatchSpan {
966        start,
967        end,
968        replacement,
969    } in spans
970    {
971        out.push_str(&text[last_end..start]);
972        let before = &text[start..end];
973        let start_line_idx = text[..start].matches('\n').count();
974        let end_line_idx = start_line_idx + before.matches('\n').count();
975        changes.push(Change {
976            line: start_line_idx + 1,
977            before: before.to_string(),
978            after: if is_delete {
979                None
980            } else {
981                Some(replacement.clone())
982            },
983            context: if context_lines == 0 {
984                None
985            } else {
986                Some(build_span_context(
987                    &lines,
988                    start_line_idx,
989                    end_line_idx,
990                    context_lines,
991                ))
992            },
993        });
994        out.push_str(&replacement);
995        last_end = end;
996    }
997    out.push_str(&text[last_end..]);
998
999    EngineOutput {
1000        text: Some(out),
1001        changes,
1002        undo: Some(UndoEntry {
1003            original_text: text.to_string(),
1004        }),
1005    }
1006}
1007
1008/// Build display context around a span covering `start_idx..=end_idx`
1009/// (0-indexed line indices): up to `context_lines` lines before the span's
1010/// first line and after its last line.
1011fn build_span_context(
1012    lines: &[&str],
1013    start_idx: usize,
1014    end_idx: usize,
1015    context_lines: usize,
1016) -> ChangeContext {
1017    let before_start = start_idx.saturating_sub(context_lines);
1018    let before = lines[before_start..start_idx.min(lines.len())]
1019        .iter()
1020        .map(|s| s.to_string())
1021        .collect();
1022    let after_start = (end_idx + 1).min(lines.len());
1023    let after_end = (end_idx + 1 + context_lines).min(lines.len());
1024    let after = lines[after_start..after_end]
1025        .iter()
1026        .map(|s| s.to_string())
1027        .collect();
1028    ChangeContext { before, after }
1029}
1030
1031fn build_context(lines: &[&str], idx: usize, context_lines: usize) -> ChangeContext {
1032    let start = idx.saturating_sub(context_lines);
1033    let end = (idx + context_lines + 1).min(lines.len());
1034
1035    let before = lines[start..idx].iter().map(|s| s.to_string()).collect();
1036    let after = if idx + 1 < end {
1037        lines[idx + 1..end].iter().map(|s| s.to_string()).collect()
1038    } else {
1039        vec![]
1040    };
1041
1042    ChangeContext { before, after }
1043}
1044
1045/// Build an OpResult from file-level changes.
1046pub fn build_op_result(operation_index: usize, path: &str, changes: Vec<Change>) -> OpResult {
1047    OpResult {
1048        operation_index,
1049        files: if changes.is_empty() {
1050            vec![]
1051        } else {
1052            vec![FileChanges {
1053                path: path.to_string(),
1054                changes,
1055            }]
1056        },
1057    }
1058}
1059
1060#[cfg(test)]
1061mod tests {
1062    use super::*;
1063    use crate::matcher::Matcher;
1064
1065    #[test]
1066    fn test_simple_replace() {
1067        let text = "hello world\nfoo bar\nhello again\n";
1068        let op = Op::Replace {
1069            count: Default::default(),
1070            multiline: false,
1071            find: "hello".to_string(),
1072            replace: "hi".to_string(),
1073            regex: false,
1074            case_insensitive: false,
1075        };
1076        let matcher = Matcher::new(&op).unwrap();
1077        let result = apply(text, &op, &matcher, None, 2).unwrap();
1078        assert_eq!(result.text.unwrap(), "hi world\nfoo bar\nhi again\n");
1079        assert_eq!(result.changes.len(), 2);
1080    }
1081
1082    #[test]
1083    fn test_delete_lines() {
1084        let text = "keep\ndelete me\nkeep too\n";
1085        let op = Op::Delete {
1086            multiline: false,
1087            find: "delete".to_string(),
1088            regex: false,
1089            case_insensitive: false,
1090        };
1091        let matcher = Matcher::new(&op).unwrap();
1092        let result = apply(text, &op, &matcher, None, 0).unwrap();
1093        assert_eq!(result.text.unwrap(), "keep\nkeep too\n");
1094    }
1095
1096    #[test]
1097    fn test_no_changes() {
1098        let text = "nothing matches here\n";
1099        let op = Op::Replace {
1100            count: Default::default(),
1101            multiline: false,
1102            find: "zzz".to_string(),
1103            replace: "aaa".to_string(),
1104            regex: false,
1105            case_insensitive: false,
1106        };
1107        let matcher = Matcher::new(&op).unwrap();
1108        let result = apply(text, &op, &matcher, None, 0).unwrap();
1109        assert!(result.text.is_none());
1110        assert!(result.changes.is_empty());
1111    }
1112
1113    #[test]
1114    fn test_line_range() {
1115        let text = "line1\nline2\nline3\nline4\n";
1116        let op = Op::Replace {
1117            count: Default::default(),
1118            multiline: false,
1119            find: "line".to_string(),
1120            replace: "row".to_string(),
1121            regex: false,
1122            case_insensitive: false,
1123        };
1124        let range = Some(LineRange {
1125            start: 2,
1126            end: Some(3),
1127        });
1128        let matcher = Matcher::new(&op).unwrap();
1129        let result = apply(text, &op, &matcher, range.map(RangeSpec::Lines), 0).unwrap();
1130        assert_eq!(result.text.unwrap(), "line1\nrow2\nrow3\nline4\n");
1131    }
1132
1133    // ---------------------------------------------------------------
1134    // CRLF handling tests
1135    // ---------------------------------------------------------------
1136
1137    #[test]
1138    fn test_crlf_replace_preserves_crlf() {
1139        let text = "hello world\r\nfoo bar\r\nhello again\r\n";
1140        let op = Op::Replace {
1141            count: Default::default(),
1142            multiline: false,
1143            find: "hello".to_string(),
1144            replace: "hi".to_string(),
1145            regex: false,
1146            case_insensitive: false,
1147        };
1148        let matcher = Matcher::new(&op).unwrap();
1149        let result = apply(text, &op, &matcher, None, 0).unwrap();
1150        assert_eq!(result.text.unwrap(), "hi world\r\nfoo bar\r\nhi again\r\n");
1151    }
1152
1153    #[test]
1154    fn test_crlf_delete_preserves_crlf() {
1155        let text = "keep\r\ndelete me\r\nkeep too\r\n";
1156        let op = Op::Delete {
1157            multiline: false,
1158            find: "delete".to_string(),
1159            regex: false,
1160            case_insensitive: false,
1161        };
1162        let matcher = Matcher::new(&op).unwrap();
1163        let result = apply(text, &op, &matcher, None, 0).unwrap();
1164        assert_eq!(result.text.unwrap(), "keep\r\nkeep too\r\n");
1165    }
1166
1167    #[test]
1168    fn test_crlf_no_trailing_newline() {
1169        let text = "hello world\r\nfoo bar";
1170        let op = Op::Replace {
1171            count: Default::default(),
1172            multiline: false,
1173            find: "hello".to_string(),
1174            replace: "hi".to_string(),
1175            regex: false,
1176            case_insensitive: false,
1177        };
1178        let matcher = Matcher::new(&op).unwrap();
1179        let result = apply(text, &op, &matcher, None, 0).unwrap();
1180        let output = result.text.unwrap();
1181        assert_eq!(output, "hi world\r\nfoo bar");
1182        // No trailing CRLF since original didn't have one
1183        assert!(!output.ends_with("\r\n"));
1184    }
1185
1186    #[test]
1187    fn test_crlf_insert_after_metadata_uses_crlf() {
1188        let text = "alpha\r\nbeta\r\n";
1189        let op = Op::InsertAfter {
1190            find: "alpha".to_string(),
1191            content: "inserted".to_string(),
1192            regex: false,
1193            case_insensitive: false,
1194        };
1195        let matcher = Matcher::new(&op).unwrap();
1196        let result = apply(text, &op, &matcher, None, 0).unwrap();
1197
1198        let output = result.text.unwrap();
1199        assert_eq!(output, "alpha\r\ninserted\r\nbeta\r\n");
1200
1201        // The Change.after metadata must show the same bytes the file gets:
1202        // CRLF between the matched line and the inserted content, not LF.
1203        let after = result.changes[0].after.as_deref().unwrap();
1204        assert_eq!(after, "alpha\r\ninserted");
1205        assert!(
1206            output.contains(after),
1207            "metadata {after:?} must appear verbatim in output {output:?}"
1208        );
1209    }
1210
1211    #[test]
1212    fn test_crlf_insert_before_metadata_uses_crlf() {
1213        let text = "alpha\r\nbeta\r\n";
1214        let op = Op::InsertBefore {
1215            find: "beta".to_string(),
1216            content: "inserted".to_string(),
1217            regex: false,
1218            case_insensitive: false,
1219        };
1220        let matcher = Matcher::new(&op).unwrap();
1221        let result = apply(text, &op, &matcher, None, 0).unwrap();
1222
1223        let output = result.text.unwrap();
1224        assert_eq!(output, "alpha\r\ninserted\r\nbeta\r\n");
1225
1226        let after = result.changes[0].after.as_deref().unwrap();
1227        assert_eq!(after, "inserted\r\nbeta");
1228        assert!(
1229            output.contains(after),
1230            "metadata {after:?} must appear verbatim in output {output:?}"
1231        );
1232    }
1233
1234    #[test]
1235    fn test_lf_insert_after_metadata_uses_lf() {
1236        // Guard the default: LF files keep LF in the metadata.
1237        let text = "alpha\nbeta\n";
1238        let op = Op::InsertAfter {
1239            find: "alpha".to_string(),
1240            content: "inserted".to_string(),
1241            regex: false,
1242            case_insensitive: false,
1243        };
1244        let matcher = Matcher::new(&op).unwrap();
1245        let result = apply(text, &op, &matcher, None, 0).unwrap();
1246        let after = result.changes[0].after.as_deref().unwrap();
1247        assert_eq!(after, "alpha\ninserted");
1248    }
1249
1250    // ── Multiline (whole-buffer) mode ──
1251
1252    fn multiline_replace_op(find: &str, replace: &str, regex: bool) -> Op {
1253        Op::Replace {
1254            count: Default::default(),
1255            find: find.to_string(),
1256            replace: replace.to_string(),
1257            regex,
1258            case_insensitive: false,
1259            multiline: true,
1260        }
1261    }
1262
1263    fn multiline_delete_op(find: &str, regex: bool) -> Op {
1264        Op::Delete {
1265            find: find.to_string(),
1266            regex,
1267            case_insensitive: false,
1268            multiline: true,
1269        }
1270    }
1271
1272    #[test]
1273    fn test_multiline_literal_replace_across_lines() {
1274        let text = "fn old(\n    x: u32,\n) {}\n";
1275        let op = multiline_replace_op("old(\n    x: u32,\n)", "new(x: u32)", false);
1276        let matcher = Matcher::new(&op).unwrap();
1277        let result = apply(text, &op, &matcher, None, 0).unwrap();
1278        assert_eq!(result.text.unwrap(), "fn new(x: u32) {}\n");
1279        assert_eq!(result.changes.len(), 1);
1280        assert_eq!(result.changes[0].line, 1);
1281        assert_eq!(result.changes[0].before, "old(\n    x: u32,\n)");
1282        assert_eq!(result.changes[0].after.as_deref(), Some("new(x: u32)"));
1283    }
1284
1285    #[test]
1286    fn test_multiline_regex_captures_across_lines() {
1287        let text = "alpha\nbeta\ngamma\n";
1288        // Swap the first two lines using a cross-line capture.
1289        let op = multiline_replace_op(r"(\w+)\n(\w+)\n", "$2\n$1\n", true);
1290        let matcher = Matcher::new(&op).unwrap();
1291        let result = apply(text, &op, &matcher, None, 0).unwrap();
1292        assert_eq!(result.text.unwrap(), "beta\nalpha\ngamma\n");
1293        assert_eq!(result.changes[0].after.as_deref(), Some("beta\nalpha\n"));
1294    }
1295
1296    #[test]
1297    fn test_multiline_delete_removes_span_not_lines() {
1298        let text = "keep [START]\ndoomed\n[END] keep\n";
1299        let op = multiline_delete_op("[START]\ndoomed\n[END]", false);
1300        let matcher = Matcher::new(&op).unwrap();
1301        let result = apply(text, &op, &matcher, None, 0).unwrap();
1302        // Only the span is removed; surrounding text on the boundary lines stays.
1303        assert_eq!(result.text.unwrap(), "keep  keep\n");
1304        assert_eq!(result.changes[0].after, None);
1305        assert_eq!(result.changes[0].before, "[START]\ndoomed\n[END]");
1306    }
1307
1308    #[test]
1309    fn test_multiline_crlf_metadata_matches_output_bytes() {
1310        let text = "alpha\r\nbeta\r\ngamma\r\n";
1311        let op = multiline_replace_op("alpha\r\nbeta", "one\r\ntwo", false);
1312        let matcher = Matcher::new(&op).unwrap();
1313        let result = apply(text, &op, &matcher, None, 0).unwrap();
1314        let output = result.text.unwrap();
1315        assert_eq!(output, "one\r\ntwo\r\ngamma\r\n");
1316        let change = &result.changes[0];
1317        assert_eq!(change.before, "alpha\r\nbeta");
1318        let after = change.after.as_deref().unwrap();
1319        assert_eq!(after, "one\r\ntwo");
1320        assert!(
1321            output.contains(after),
1322            "metadata {after:?} must appear verbatim in output {output:?}"
1323        );
1324    }
1325
1326    #[test]
1327    fn test_multiline_match_at_eof_without_trailing_newline() {
1328        let text = "head\ntail";
1329        let op = multiline_replace_op("head\ntail", "joined", false);
1330        let matcher = Matcher::new(&op).unwrap();
1331        let result = apply(text, &op, &matcher, None, 0).unwrap();
1332        let output = result.text.unwrap();
1333        assert_eq!(output, "joined");
1334        assert!(!output.ends_with('\n'));
1335    }
1336
1337    #[test]
1338    fn test_multiline_preserves_untouched_separators() {
1339        // Mixed line endings outside the match must pass through untouched —
1340        // buffer mode never rejoins lines, so no majority-vote normalization.
1341        let text = "a\r\nMARK\nb\n";
1342        let op = multiline_replace_op("MARK", "X", false);
1343        let matcher = Matcher::new(&op).unwrap();
1344        let result = apply(text, &op, &matcher, None, 0).unwrap();
1345        assert_eq!(result.text.unwrap(), "a\r\nX\nb\n");
1346    }
1347
1348    #[test]
1349    fn test_multiline_with_line_range_is_rejected() {
1350        let op = multiline_replace_op("a", "b", false);
1351        let matcher = Matcher::new(&op).unwrap();
1352        let range = LineRange {
1353            start: 1,
1354            end: Some(2),
1355        };
1356        let err = apply("a\n", &op, &matcher, Some(RangeSpec::Lines(range)), 0).unwrap_err();
1357        assert_eq!(err.code, crate::error::ErrorCode::InvalidRequest);
1358    }
1359
1360    #[test]
1361    fn test_multiline_change_line_numbers_ascending_and_correct() {
1362        let text = "x\nfoo\nx\nfoo\nx\nfoo\n";
1363        let op = multiline_replace_op("foo\nx", "bar\nx", false);
1364        let matcher = Matcher::new(&op).unwrap();
1365        let result = apply(text, &op, &matcher, None, 0).unwrap();
1366        // Non-overlapping left-to-right: matches start on lines 2 and 4.
1367        let line_numbers: Vec<usize> = result.changes.iter().map(|c| c.line).collect();
1368        assert_eq!(line_numbers, vec![2, 4]);
1369        assert_eq!(result.text.unwrap(), "x\nbar\nx\nbar\nx\nfoo\n");
1370    }
1371
1372    #[test]
1373    fn test_multiline_no_match_returns_none() {
1374        let op = multiline_replace_op("absent\npattern", "x", false);
1375        let matcher = Matcher::new(&op).unwrap();
1376        let result = apply("some\ntext\n", &op, &matcher, None, 0).unwrap();
1377        assert!(result.text.is_none());
1378        assert!(result.changes.is_empty());
1379        assert!(result.undo.is_none());
1380    }
1381
1382    #[test]
1383    fn test_multiline_delete_everything_yields_empty_string() {
1384        let text = "all\ngone\n";
1385        let op = multiline_delete_op("all\ngone\n", false);
1386        let matcher = Matcher::new(&op).unwrap();
1387        let result = apply(text, &op, &matcher, None, 0).unwrap();
1388        assert_eq!(result.text.unwrap(), "");
1389    }
1390
1391    #[test]
1392    fn test_multiline_undo_roundtrip() {
1393        let text = "one\ntwo\nthree\n";
1394        let op = multiline_replace_op("one\ntwo", "1\n2", false);
1395        let matcher = Matcher::new(&op).unwrap();
1396        let result = apply(text, &op, &matcher, None, 0).unwrap();
1397        assert_eq!(result.text.unwrap(), "1\n2\nthree\n");
1398        assert_eq!(result.undo.unwrap().original_text, text);
1399    }
1400
1401    #[test]
1402    fn test_multiline_output_equals_matcher_replace() {
1403        // Buffer-mode splicing must reproduce Matcher::replace on the whole
1404        // text exactly — they share semantics by contract.
1405        let text = "aaa\nbbb aaa\nccc\n";
1406        let op = multiline_replace_op("aa", "Z", false);
1407        let matcher = Matcher::new(&op).unwrap();
1408        let result = apply(text, &op, &matcher, None, 0).unwrap();
1409        assert_eq!(
1410            result.text.unwrap(),
1411            matcher.replace(text, "Z").unwrap(),
1412            "span splicing must match replace_all output"
1413        );
1414    }
1415
1416    #[test]
1417    fn test_multiline_span_context() {
1418        let text = "ctx1\nctx2\nA\nB\nctx3\nctx4\n";
1419        let op = multiline_replace_op("A\nB", "AB", false);
1420        let matcher = Matcher::new(&op).unwrap();
1421        let result = apply(text, &op, &matcher, None, 1).unwrap();
1422        let ctx = result.changes[0].context.as_ref().unwrap();
1423        assert_eq!(ctx.before, vec!["ctx2".to_string()]);
1424        assert_eq!(ctx.after, vec!["ctx3".to_string()]);
1425    }
1426
1427    // ── Replacement count control ──
1428
1429    fn counted_replace_op(find: &str, replace: &str, count: ReplaceCount) -> Op {
1430        Op::Replace {
1431            find: find.to_string(),
1432            replace: replace.to_string(),
1433            regex: false,
1434            case_insensitive: false,
1435            multiline: false,
1436            count,
1437        }
1438    }
1439
1440    #[test]
1441    fn test_count_first_per_line_replaces_one_per_line() {
1442        let text = "a a a\nx\na a\n";
1443        let op = counted_replace_op("a", "B", ReplaceCount::FirstPerLine);
1444        let matcher = Matcher::new(&op).unwrap();
1445        let result = apply(text, &op, &matcher, None, 0).unwrap();
1446        assert_eq!(result.text.unwrap(), "B a a\nx\nB a\n");
1447        assert_eq!(result.changes.len(), 2);
1448    }
1449
1450    #[test]
1451    fn test_count_first_in_file_replaces_only_first_occurrence() {
1452        let text = "a a\na\na\n";
1453        let op = counted_replace_op("a", "B", ReplaceCount::FirstInFile);
1454        let matcher = Matcher::new(&op).unwrap();
1455        let result = apply(text, &op, &matcher, None, 0).unwrap();
1456        assert_eq!(result.text.unwrap(), "B a\na\na\n");
1457        assert_eq!(result.changes.len(), 1);
1458        assert_eq!(result.changes[0].line, 1);
1459    }
1460
1461    #[test]
1462    fn test_count_max_spans_lines_and_counts_occurrences() {
1463        // Budget of 3 occurrences: line 1 consumes 2, line 2 consumes the
1464        // last 1 (partial line), line 3 is untouched.
1465        let text = "a a\na a\na\n";
1466        let op = counted_replace_op("a", "B", ReplaceCount::Max(3));
1467        let matcher = Matcher::new(&op).unwrap();
1468        let result = apply(text, &op, &matcher, None, 0).unwrap();
1469        assert_eq!(result.text.unwrap(), "B B\nB a\na\n");
1470        assert_eq!(result.changes.len(), 2);
1471    }
1472
1473    #[test]
1474    fn test_count_all_is_default_behavior() {
1475        let text = "a a\na\n";
1476        let op = counted_replace_op("a", "B", ReplaceCount::All);
1477        let matcher = Matcher::new(&op).unwrap();
1478        let result = apply(text, &op, &matcher, None, 0).unwrap();
1479        assert_eq!(result.text.unwrap(), "B B\nB\n");
1480    }
1481
1482    #[test]
1483    fn test_count_first_per_line_with_regex_captures() {
1484        let text = "x1 x2 x3\n";
1485        let op = Op::Replace {
1486            find: r"x(\d)".to_string(),
1487            replace: "y$1".to_string(),
1488            regex: true,
1489            case_insensitive: false,
1490            multiline: false,
1491            count: ReplaceCount::FirstPerLine,
1492        };
1493        let matcher = Matcher::new(&op).unwrap();
1494        let result = apply(text, &op, &matcher, None, 0).unwrap();
1495        assert_eq!(result.text.unwrap(), "y1 x2 x3\n");
1496    }
1497
1498    #[test]
1499    fn test_count_max_in_multiline_mode_truncates_spans() {
1500        let text = "a\na\na\n";
1501        let op = Op::Replace {
1502            find: "a\n".to_string(),
1503            replace: "B\n".to_string(),
1504            regex: false,
1505            case_insensitive: false,
1506            multiline: true,
1507            count: ReplaceCount::Max(2),
1508        };
1509        let matcher = Matcher::new(&op).unwrap();
1510        let result = apply(text, &op, &matcher, None, 0).unwrap();
1511        assert_eq!(result.text.unwrap(), "B\nB\na\n");
1512        assert_eq!(result.changes.len(), 2);
1513    }
1514
1515    #[test]
1516    fn test_count_first_in_file_in_multiline_mode() {
1517        let text = "a\na\n";
1518        let op = Op::Replace {
1519            find: "a".to_string(),
1520            replace: "B".to_string(),
1521            regex: false,
1522            case_insensitive: false,
1523            multiline: true,
1524            count: ReplaceCount::FirstInFile,
1525        };
1526        let matcher = Matcher::new(&op).unwrap();
1527        let result = apply(text, &op, &matcher, None, 0).unwrap();
1528        assert_eq!(result.text.unwrap(), "B\na\n");
1529    }
1530
1531    #[test]
1532    fn test_count_first_per_line_rejected_in_multiline_mode() {
1533        let op = Op::Replace {
1534            find: "a".to_string(),
1535            replace: "B".to_string(),
1536            regex: false,
1537            case_insensitive: false,
1538            multiline: true,
1539            count: ReplaceCount::FirstPerLine,
1540        };
1541        let matcher = Matcher::new(&op).unwrap();
1542        let err = apply("a\n", &op, &matcher, None, 0).unwrap_err();
1543        assert_eq!(err.code, crate::error::ErrorCode::InvalidRequest);
1544    }
1545
1546    #[test]
1547    fn test_count_budget_exhausted_skips_remaining_lines() {
1548        // Once the budget hits zero, later matching lines are untouched and
1549        // produce no Change entries.
1550        let text = "a\na\na\na\n";
1551        let op = counted_replace_op("a", "B", ReplaceCount::Max(1));
1552        let matcher = Matcher::new(&op).unwrap();
1553        let result = apply(text, &op, &matcher, None, 0).unwrap();
1554        assert_eq!(result.text.unwrap(), "B\na\na\na\n");
1555        assert_eq!(result.changes.len(), 1);
1556    }
1557
1558    // ── Pattern-addressed ranges (sed /start/,/end/) ──
1559
1560    fn pattern_range(start: &str, end: &str) -> Option<RangeSpec> {
1561        Some(RangeSpec::Patterns(crate::operation::PatternRange {
1562            start_pattern: start.to_string(),
1563            end_pattern: end.to_string(),
1564        }))
1565    }
1566
1567    fn simple_replace(find: &str, replace: &str) -> Op {
1568        Op::Replace {
1569            find: find.to_string(),
1570            replace: replace.to_string(),
1571            regex: false,
1572            case_insensitive: false,
1573            multiline: false,
1574            count: ReplaceCount::All,
1575        }
1576    }
1577
1578    #[test]
1579    fn test_pattern_range_single_region_boundaries_inclusive() {
1580        let text = "x\nBEGIN x\nx\nEND x\nx\n";
1581        let op = simple_replace("x", "y");
1582        let matcher = Matcher::new(&op).unwrap();
1583        let result = apply(text, &op, &matcher, pattern_range("BEGIN", "END"), 0).unwrap();
1584        // Lines 2-4 (inclusive boundaries) are in range; 1 and 5 are not.
1585        assert_eq!(result.text.unwrap(), "x\nBEGIN y\ny\nEND y\nx\n");
1586    }
1587
1588    #[test]
1589    fn test_pattern_range_multiple_regions() {
1590        let text = "x\nA\nx\nB\nx\nA\nx\nB\nx\n";
1591        let op = simple_replace("x", "y");
1592        let matcher = Matcher::new(&op).unwrap();
1593        let result = apply(text, &op, &matcher, pattern_range("A", "B"), 0).unwrap();
1594        // Two A..B regions; the x's between them (lines 3 and 7) are inside,
1595        // the x's outside (lines 1, 5, 9) are not.
1596        assert_eq!(result.text.unwrap(), "x\nA\ny\nB\nx\nA\ny\nB\nx\n");
1597    }
1598
1599    #[test]
1600    fn test_pattern_range_unterminated_extends_to_eof() {
1601        let text = "x\nBEGIN\nx\nx\n";
1602        let op = simple_replace("x", "y");
1603        let matcher = Matcher::new(&op).unwrap();
1604        let result = apply(text, &op, &matcher, pattern_range("BEGIN", "NEVER"), 0).unwrap();
1605        assert_eq!(result.text.unwrap(), "x\nBEGIN\ny\ny\n");
1606    }
1607
1608    #[test]
1609    fn test_pattern_range_same_start_and_end_spans_to_next_match() {
1610        // sed semantics: /a/,/a/ opens at the first 'a' and closes at the
1611        // NEXT 'a' — the end pattern is never tested on the opening line.
1612        let text = "MARK\nx\nMARK\nx\n";
1613        let op = simple_replace("x", "y");
1614        let matcher = Matcher::new(&op).unwrap();
1615        let result = apply(text, &op, &matcher, pattern_range("MARK", "MARK"), 0).unwrap();
1616        assert_eq!(result.text.unwrap(), "MARK\ny\nMARK\nx\n");
1617    }
1618
1619    #[test]
1620    fn test_pattern_range_delete_can_remove_boundary_lines() {
1621        let text = "keep\nSTART\ngone\nSTOP\nkeep\n";
1622        let op = Op::Delete {
1623            find: ".*".to_string(),
1624            regex: true,
1625            case_insensitive: false,
1626            multiline: false,
1627        };
1628        let matcher = Matcher::new(&op).unwrap();
1629        let result = apply(text, &op, &matcher, pattern_range("START", "STOP"), 0).unwrap();
1630        assert_eq!(result.text.unwrap(), "keep\nkeep\n");
1631    }
1632
1633    #[test]
1634    fn test_pattern_range_start_regex_anchors() {
1635        let text = "prefix BEGIN\nx\nBEGIN\nx\nEND\n";
1636        let op = simple_replace("x", "y");
1637        let matcher = Matcher::new(&op).unwrap();
1638        // Anchored start: only the line that IS exactly BEGIN opens a region.
1639        let result = apply(text, &op, &matcher, pattern_range("^BEGIN$", "END"), 0).unwrap();
1640        assert_eq!(result.text.unwrap(), "prefix BEGIN\nx\nBEGIN\ny\nEND\n");
1641    }
1642
1643    #[test]
1644    fn test_pattern_range_invalid_regex_is_rejected() {
1645        let op = simple_replace("x", "y");
1646        let matcher = Matcher::new(&op).unwrap();
1647        let err = apply("x\n", &op, &matcher, pattern_range("(unclosed", "END"), 0).unwrap_err();
1648        assert_eq!(err.code, crate::error::ErrorCode::InvalidRegex);
1649        assert!(err.message.contains("start"));
1650    }
1651
1652    #[test]
1653    fn test_pattern_range_rejected_in_multiline_mode() {
1654        let op = Op::Replace {
1655            find: "x".to_string(),
1656            replace: "y".to_string(),
1657            regex: false,
1658            case_insensitive: false,
1659            multiline: true,
1660            count: ReplaceCount::All,
1661        };
1662        let matcher = Matcher::new(&op).unwrap();
1663        let err = apply("x\n", &op, &matcher, pattern_range("A", "B"), 0).unwrap_err();
1664        assert_eq!(err.code, crate::error::ErrorCode::InvalidRequest);
1665    }
1666
1667    #[test]
1668    fn test_pattern_range_no_region_means_no_changes() {
1669        let op = simple_replace("x", "y");
1670        let matcher = Matcher::new(&op).unwrap();
1671        let result = apply("x\nx\n", &op, &matcher, pattern_range("NEVER", "END"), 0).unwrap();
1672        assert!(result.text.is_none());
1673        assert!(result.changes.is_empty());
1674    }
1675
1676    // ── Streaming LineProcessor ──
1677
1678    #[test]
1679    fn test_line_processor_matches_buffered_apply() {
1680        // The streaming path must produce the same lines as buffered apply
1681        // for every line-scoped op shape.
1682        let text = "alpha x\nplain\nx x\n";
1683        let op = simple_replace("x", "y");
1684        let matcher = Matcher::new(&op).unwrap();
1685
1686        let buffered = apply(text, &op, &matcher, None, 0).unwrap().text.unwrap();
1687
1688        let mut processor = LineProcessor::new(&op, &matcher, None).unwrap();
1689        let mut streamed = String::new();
1690        for line in text.lines() {
1691            for out in processor.process_line(line).lines {
1692                streamed.push_str(&out);
1693                streamed.push('\n');
1694            }
1695        }
1696        assert_eq!(streamed, buffered);
1697    }
1698
1699    #[test]
1700    fn test_line_processor_rejects_multiline_ops() {
1701        let op = Op::Replace {
1702            find: "a\nb".to_string(),
1703            replace: "ab".to_string(),
1704            regex: false,
1705            case_insensitive: false,
1706            multiline: true,
1707            count: ReplaceCount::All,
1708        };
1709        let matcher = Matcher::new(&op).unwrap();
1710        let err = match LineProcessor::new(&op, &matcher, None) {
1711            Err(e) => e,
1712            Ok(_) => panic!("multiline op must be rejected by LineProcessor"),
1713        };
1714        assert_eq!(err.code, crate::error::ErrorCode::InvalidRequest);
1715    }
1716
1717    #[test]
1718    fn test_line_processor_budget_spans_calls() {
1719        let op = counted_replace_op("x", "y", ReplaceCount::Max(2));
1720        let matcher = Matcher::new(&op).unwrap();
1721        let mut processor = LineProcessor::new(&op, &matcher, None).unwrap();
1722        assert!(processor.process_line("x x").changed); // consumes 2
1723        let third = processor.process_line("x");
1724        assert!(!third.changed, "budget exhausted across calls");
1725        assert_eq!(third.lines, vec!["x".to_string()]);
1726    }
1727
1728    #[test]
1729    fn test_line_processor_pattern_range_state_spans_calls() {
1730        let op = simple_replace("x", "y");
1731        let matcher = Matcher::new(&op).unwrap();
1732        let range = pattern_range("BEGIN", "END");
1733        let mut processor = LineProcessor::new(&op, &matcher, range).unwrap();
1734        assert_eq!(processor.process_line("x").lines, vec!["x"]); // before region
1735        processor.process_line("BEGIN");
1736        assert_eq!(processor.process_line("x").lines, vec!["y"]); // inside
1737        processor.process_line("END");
1738        assert_eq!(processor.process_line("x").lines, vec!["x"]); // after
1739    }
1740
1741    #[test]
1742    fn test_line_processor_insert_and_delete_shapes() {
1743        let op = Op::InsertAfter {
1744            find: "mark".to_string(),
1745            content: "inserted".to_string(),
1746            regex: false,
1747            case_insensitive: false,
1748        };
1749        let matcher = Matcher::new(&op).unwrap();
1750        let mut processor = LineProcessor::new(&op, &matcher, None).unwrap();
1751        assert_eq!(
1752            processor.process_line("mark").lines,
1753            vec!["mark".to_string(), "inserted".to_string()]
1754        );
1755
1756        let op = Op::Delete {
1757            find: "gone".to_string(),
1758            regex: false,
1759            case_insensitive: false,
1760            multiline: false,
1761        };
1762        let matcher = Matcher::new(&op).unwrap();
1763        let mut processor = LineProcessor::new(&op, &matcher, None).unwrap();
1764        let out = processor.process_line("gone");
1765        assert!(out.lines.is_empty());
1766        assert!(out.changed);
1767    }
1768
1769    // ── Splice fast path ──
1770
1771    /// Force the per-line loop for an otherwise splice-eligible op: a
1772    /// full-file numeric range has identical semantics but is a range,
1773    /// which splice_eligible rejects.
1774    fn line_path_range() -> Option<RangeSpec> {
1775        Some(RangeSpec::Lines(LineRange {
1776            start: 1,
1777            end: None,
1778        }))
1779    }
1780
1781    #[test]
1782    fn test_splice_matches_line_path_exactly() {
1783        for (text, find, replace) in [
1784            ("a x b\nx\nno match\nx x x\n", "x", "YY"),
1785            ("x at start\nend x", "x", ""),
1786            ("a\r\nx here\r\nx x\r\n", "x", "longer-replacement"),
1787            ("only\none\nmatch deep in file x\n", "x", "y"),
1788            ("x", "x", "swap"),
1789            ("multi x on x one x line\n", "x", "[]"),
1790        ] {
1791            let op = simple_replace(find, replace);
1792            let matcher = Matcher::new(&op).unwrap();
1793            let spliced = apply(text, &op, &matcher, None, 2).unwrap();
1794            let looped = apply(text, &op, &matcher, line_path_range(), 2).unwrap();
1795            assert_eq!(spliced.text, looped.text, "text for {text:?}");
1796            assert_eq!(spliced.changes, looped.changes, "changes for {text:?}");
1797        }
1798    }
1799
1800    #[test]
1801    fn test_splice_case_insensitive_literal() {
1802        let text = "Foo bar\nFOO\nplain\n";
1803        let op = Op::Replace {
1804            find: "foo".to_string(),
1805            replace: "baz".to_string(),
1806            regex: false,
1807            case_insensitive: true,
1808            multiline: false,
1809            count: ReplaceCount::All,
1810        };
1811        let matcher = Matcher::new(&op).unwrap();
1812        let spliced = apply(text, &op, &matcher, None, 1).unwrap();
1813        let looped = apply(text, &op, &matcher, line_path_range(), 1).unwrap();
1814        assert_eq!(spliced.text, looped.text);
1815        assert_eq!(spliced.changes, looped.changes);
1816        assert_eq!(spliced.text.unwrap(), "baz bar\nbaz\nplain\n");
1817    }
1818
1819    #[test]
1820    fn test_splice_count_parity() {
1821        let text = "x x\nx x\nx\n";
1822        for count in [ReplaceCount::FirstInFile, ReplaceCount::Max(3)] {
1823            let op = counted_replace_op("x", "y", count);
1824            let matcher = Matcher::new(&op).unwrap();
1825            let spliced = apply(text, &op, &matcher, None, 0).unwrap();
1826            let looped = apply(text, &op, &matcher, line_path_range(), 0).unwrap();
1827            assert_eq!(spliced.text, looped.text, "{count:?}");
1828            assert_eq!(spliced.changes, looped.changes, "{count:?}");
1829        }
1830    }
1831
1832    #[test]
1833    fn test_empty_find_takes_line_path_and_terminates() {
1834        // Exact fuzz_engine OOM reproducer: empty find on CRLF text with
1835        // no trailing newline. Empty patterns must stay on the line path
1836        // (zero-width buffer matches land on terminator bytes that don't
1837        // exist per line) — and must never loop forever.
1838        let text = "a\r\nb";
1839        let op = simple_replace("", "");
1840        let matcher = Matcher::new(&op).unwrap();
1841        let spliced = apply(text, &op, &matcher, None, 0).unwrap();
1842        let looped = apply(text, &op, &matcher, line_path_range(), 0).unwrap();
1843        assert_eq!(spliced.text, looped.text);
1844        assert_eq!(spliced.changes, looped.changes);
1845
1846        // And the historical empty-pattern insertion behavior still holds.
1847        let op = simple_replace("", "X");
1848        let matcher = Matcher::new(&op).unwrap();
1849        let result = apply("ab\n", &op, &matcher, None, 0).unwrap();
1850        assert_eq!(result.text.unwrap(), "XaXbX\n");
1851    }
1852
1853    #[test]
1854    fn test_splice_mixed_endings_falls_back_and_normalizes() {
1855        // Mixed-ending files must take the per-line loop, which normalizes
1856        // to the majority separator — the long-standing documented behavior.
1857        let text = "x\r\nx\r\nx\n";
1858        let op = simple_replace("x", "y");
1859        let matcher = Matcher::new(&op).unwrap();
1860        let result = apply(text, &op, &matcher, None, 0).unwrap();
1861        assert_eq!(result.text.unwrap(), "y\r\ny\r\ny\r\n");
1862    }
1863
1864    #[test]
1865    fn test_splice_replacement_with_newline() {
1866        let text = "a SPLIT b\nplain\n";
1867        let op = simple_replace("SPLIT", "one\ntwo");
1868        let matcher = Matcher::new(&op).unwrap();
1869        let spliced = apply(text, &op, &matcher, None, 0).unwrap();
1870        let looped = apply(text, &op, &matcher, line_path_range(), 0).unwrap();
1871        assert_eq!(spliced.text, looped.text);
1872        assert_eq!(spliced.text.unwrap(), "a one\ntwo b\nplain\n");
1873    }
1874
1875    #[test]
1876    fn test_context_capped_after_threshold_on_both_paths() {
1877        // 1100 matching lines: the first 1000 changes carry context, the
1878        // rest don't — on the splice path and the line path alike.
1879        let text = "x\n".repeat(1100);
1880        let op = simple_replace("x", "y");
1881        let matcher = Matcher::new(&op).unwrap();
1882        for range in [None, line_path_range()] {
1883            let result = apply(&text, &op, &matcher, range, 1).unwrap();
1884            assert_eq!(result.changes.len(), 1100);
1885            assert!(result.changes[0].context.is_some());
1886            assert!(result.changes[999].context.is_some());
1887            assert!(result.changes[1000].context.is_none());
1888            assert!(result.changes[1099].context.is_none());
1889        }
1890    }
1891
1892    #[test]
1893    fn test_uses_crlf_detection() {
1894        assert!(uses_crlf("a\r\nb\r\n"));
1895        assert!(uses_crlf("a\r\n"));
1896        assert!(!uses_crlf("a\nb\n"));
1897        assert!(!uses_crlf("no newline at all"));
1898        assert!(!uses_crlf(""));
1899    }
1900
1901    // ---------------------------------------------------------------
1902    // Edge-case tests
1903    // ---------------------------------------------------------------
1904
1905    #[test]
1906    fn test_empty_input_text() {
1907        let text = "";
1908        let op = Op::Replace {
1909            count: Default::default(),
1910            multiline: false,
1911            find: "anything".to_string(),
1912            replace: "something".to_string(),
1913            regex: false,
1914            case_insensitive: false,
1915        };
1916        let matcher = Matcher::new(&op).unwrap();
1917        let result = apply(text, &op, &matcher, None, 0).unwrap();
1918        assert!(result.text.is_none());
1919        assert!(result.changes.is_empty());
1920    }
1921
1922    #[test]
1923    fn test_single_line_no_trailing_newline() {
1924        let text = "hello world";
1925        let op = Op::Replace {
1926            count: Default::default(),
1927            multiline: false,
1928            find: "hello".to_string(),
1929            replace: "hi".to_string(),
1930            regex: false,
1931            case_insensitive: false,
1932        };
1933        let matcher = Matcher::new(&op).unwrap();
1934        let result = apply(text, &op, &matcher, None, 0).unwrap();
1935        let output = result.text.unwrap();
1936        assert_eq!(output, "hi world");
1937        // Should NOT add a trailing newline that wasn't there
1938        assert!(!output.ends_with('\n'));
1939    }
1940
1941    #[test]
1942    fn test_whitespace_only_lines() {
1943        let text = "  \n\t\n   \t  \n";
1944        let op = Op::Replace {
1945            count: Default::default(),
1946            multiline: false,
1947            find: "\t".to_string(),
1948            replace: "TAB".to_string(),
1949            regex: false,
1950            case_insensitive: false,
1951        };
1952        let matcher = Matcher::new(&op).unwrap();
1953        let result = apply(text, &op, &matcher, None, 0).unwrap();
1954        let output = result.text.unwrap();
1955        assert!(output.contains("TAB"));
1956        assert_eq!(result.changes.len(), 2); // lines 2 and 3 have tabs
1957    }
1958
1959    #[test]
1960    fn test_very_long_line() {
1961        let long_word = "x".repeat(100_000);
1962        let text = format!("before\n{long_word}\nafter\n");
1963        let op = Op::Replace {
1964            count: Default::default(),
1965            multiline: false,
1966            find: "x".to_string(),
1967            replace: "y".to_string(),
1968            regex: false,
1969            case_insensitive: false,
1970        };
1971        let matcher = Matcher::new(&op).unwrap();
1972        let result = apply(&text, &op, &matcher, None, 0).unwrap();
1973        let output = result.text.unwrap();
1974        let expected_long = "y".repeat(100_000);
1975        assert!(output.contains(&expected_long));
1976    }
1977
1978    #[test]
1979    fn test_unicode_emoji() {
1980        let text = "hello world\n";
1981        let op = Op::Replace {
1982            count: Default::default(),
1983            multiline: false,
1984            find: "world".to_string(),
1985            replace: "\u{1F30D}".to_string(), // earth globe emoji
1986            regex: false,
1987            case_insensitive: false,
1988        };
1989        let matcher = Matcher::new(&op).unwrap();
1990        let result = apply(text, &op, &matcher, None, 0).unwrap();
1991        assert_eq!(result.text.unwrap(), "hello \u{1F30D}\n");
1992    }
1993
1994    #[test]
1995    fn test_unicode_cjk() {
1996        let text = "\u{4F60}\u{597D}\u{4E16}\u{754C}\n"; // "hello world" in Chinese
1997        let op = Op::Replace {
1998            count: Default::default(),
1999            multiline: false,
2000            find: "\u{4E16}\u{754C}".to_string(),    // "world"
2001            replace: "\u{5730}\u{7403}".to_string(), // "earth"
2002            regex: false,
2003            case_insensitive: false,
2004        };
2005        let matcher = Matcher::new(&op).unwrap();
2006        let result = apply(text, &op, &matcher, None, 0).unwrap();
2007        assert_eq!(result.text.unwrap(), "\u{4F60}\u{597D}\u{5730}\u{7403}\n");
2008    }
2009
2010    #[test]
2011    fn test_unicode_combining_characters() {
2012        // e + combining acute accent = e-acute
2013        let text = "caf\u{0065}\u{0301}\n";
2014        let op = Op::Replace {
2015            count: Default::default(),
2016            multiline: false,
2017            find: "caf\u{0065}\u{0301}".to_string(),
2018            replace: "coffee".to_string(),
2019            regex: false,
2020            case_insensitive: false,
2021        };
2022        let matcher = Matcher::new(&op).unwrap();
2023        let result = apply(text, &op, &matcher, None, 0).unwrap();
2024        assert_eq!(result.text.unwrap(), "coffee\n");
2025    }
2026
2027    #[test]
2028    fn test_regex_special_chars_in_literal_mode() {
2029        // In literal mode, regex metacharacters should be treated as literals
2030        let text = "price is $10.00 (USD)\n";
2031        let op = Op::Replace {
2032            count: Default::default(),
2033            multiline: false,
2034            find: "$10.00".to_string(),
2035            replace: "$20.00".to_string(),
2036            regex: false,
2037            case_insensitive: false,
2038        };
2039        let matcher = Matcher::new(&op).unwrap();
2040        let result = apply(text, &op, &matcher, None, 0).unwrap();
2041        assert_eq!(result.text.unwrap(), "price is $20.00 (USD)\n");
2042    }
2043
2044    #[test]
2045    fn test_overlapping_matches_in_single_line() {
2046        // "aaa" with pattern "aa" — standard str::replace does non-overlapping left-to-right
2047        let text = "aaa\n";
2048        let op = Op::Replace {
2049            count: Default::default(),
2050            multiline: false,
2051            find: "aa".to_string(),
2052            replace: "b".to_string(),
2053            regex: false,
2054            case_insensitive: false,
2055        };
2056        let matcher = Matcher::new(&op).unwrap();
2057        let result = apply(text, &op, &matcher, None, 0).unwrap();
2058        // Rust's str::replace: "aaa".replace("aa", "b") == "ba"
2059        assert_eq!(result.text.unwrap(), "ba\n");
2060    }
2061
2062    #[test]
2063    fn test_replace_line_count_preserved() {
2064        let text = "line1\nline2\nline3\nline4\nline5\n";
2065        let input_line_count = text.lines().count();
2066        let op = Op::Replace {
2067            count: Default::default(),
2068            multiline: false,
2069            find: "line".to_string(),
2070            replace: "row".to_string(),
2071            regex: false,
2072            case_insensitive: false,
2073        };
2074        let matcher = Matcher::new(&op).unwrap();
2075        let result = apply(text, &op, &matcher, None, 0).unwrap();
2076        let output = result.text.unwrap();
2077        let output_line_count = output.lines().count();
2078        assert_eq!(input_line_count, output_line_count);
2079    }
2080
2081    #[test]
2082    fn test_replace_preserves_empty_result_on_non_match() {
2083        // Pattern that exists nowhere in text
2084        let text = "alpha\nbeta\ngamma\n";
2085        let op = Op::Replace {
2086            count: Default::default(),
2087            multiline: false,
2088            find: "zzzzzz".to_string(),
2089            replace: "y".to_string(),
2090            regex: false,
2091            case_insensitive: false,
2092        };
2093        let matcher = Matcher::new(&op).unwrap();
2094        let result = apply(text, &op, &matcher, None, 0).unwrap();
2095        assert!(result.text.is_none());
2096        assert!(result.undo.is_none());
2097    }
2098
2099    #[test]
2100    fn test_undo_entry_stores_original() {
2101        let text = "hello\nworld\n";
2102        let op = Op::Replace {
2103            count: Default::default(),
2104            multiline: false,
2105            find: "hello".to_string(),
2106            replace: "hi".to_string(),
2107            regex: false,
2108            case_insensitive: false,
2109        };
2110        let matcher = Matcher::new(&op).unwrap();
2111        let result = apply(text, &op, &matcher, None, 0).unwrap();
2112        let undo = result.undo.unwrap();
2113        assert_eq!(undo.original_text, text);
2114    }
2115
2116    #[test]
2117    fn test_determinism_same_input_same_output() {
2118        let text = "foo bar baz\nhello world\nfoo again\n";
2119        let op = Op::Replace {
2120            count: Default::default(),
2121            multiline: false,
2122            find: "foo".to_string(),
2123            replace: "qux".to_string(),
2124            regex: false,
2125            case_insensitive: false,
2126        };
2127        let matcher = Matcher::new(&op).unwrap();
2128        let r1 = apply(text, &op, &matcher, None, 0).unwrap();
2129        let r2 = apply(text, &op, &matcher, None, 0).unwrap();
2130        assert_eq!(r1.text, r2.text);
2131        assert_eq!(r1.changes.len(), r2.changes.len());
2132        for (c1, c2) in r1.changes.iter().zip(r2.changes.iter()) {
2133            assert_eq!(c1, c2);
2134        }
2135    }
2136
2137    // ---------------------------------------------------------------
2138    // Transform operation tests
2139    // ---------------------------------------------------------------
2140
2141    #[test]
2142    fn test_transform_upper() {
2143        let text = "hello world\nfoo bar\n";
2144        let op = Op::Transform {
2145            find: "hello".to_string(),
2146            mode: TransformMode::Upper,
2147            regex: false,
2148            case_insensitive: false,
2149        };
2150        let matcher = Matcher::new(&op).unwrap();
2151        let result = apply(text, &op, &matcher, None, 0).unwrap();
2152        assert_eq!(result.text.unwrap(), "HELLO world\nfoo bar\n");
2153        assert_eq!(result.changes.len(), 1);
2154        assert_eq!(result.changes[0].line, 1);
2155    }
2156
2157    #[test]
2158    fn test_transform_lower() {
2159        let text = "HELLO WORLD\nFOO BAR\n";
2160        let op = Op::Transform {
2161            find: "HELLO".to_string(),
2162            mode: TransformMode::Lower,
2163            regex: false,
2164            case_insensitive: false,
2165        };
2166        let matcher = Matcher::new(&op).unwrap();
2167        let result = apply(text, &op, &matcher, None, 0).unwrap();
2168        assert_eq!(result.text.unwrap(), "hello WORLD\nFOO BAR\n");
2169        assert_eq!(result.changes.len(), 1);
2170    }
2171
2172    #[test]
2173    fn test_transform_noop_when_already_target_case() {
2174        // Transforming already-lowercase text to Lower should produce no changes
2175        let text = "hello world\nfoo bar\n";
2176        let op = Op::Transform {
2177            find: "hello".to_string(),
2178            mode: TransformMode::Lower,
2179            regex: false,
2180            case_insensitive: false,
2181        };
2182        let matcher = Matcher::new(&op).unwrap();
2183        let result = apply(text, &op, &matcher, None, 0).unwrap();
2184        assert!(result.text.is_none(), "No text modification expected");
2185        assert!(result.changes.is_empty(), "No changes expected");
2186    }
2187
2188    #[test]
2189    fn test_transform_title() {
2190        let text = "hello world\nfoo bar\n";
2191        let op = Op::Transform {
2192            find: "hello world".to_string(),
2193            mode: TransformMode::Title,
2194            regex: false,
2195            case_insensitive: false,
2196        };
2197        let matcher = Matcher::new(&op).unwrap();
2198        let result = apply(text, &op, &matcher, None, 0).unwrap();
2199        assert_eq!(result.text.unwrap(), "Hello World\nfoo bar\n");
2200        assert_eq!(result.changes.len(), 1);
2201    }
2202
2203    #[test]
2204    fn test_transform_snake_case() {
2205        let text = "let myVariable = 1;\nother line\n";
2206        let op = Op::Transform {
2207            find: "myVariable".to_string(),
2208            mode: TransformMode::SnakeCase,
2209            regex: false,
2210            case_insensitive: false,
2211        };
2212        let matcher = Matcher::new(&op).unwrap();
2213        let result = apply(text, &op, &matcher, None, 0).unwrap();
2214        assert_eq!(result.text.unwrap(), "let my_variable = 1;\nother line\n");
2215        assert_eq!(result.changes.len(), 1);
2216    }
2217
2218    #[test]
2219    fn test_transform_camel_case() {
2220        let text = "let my_variable = 1;\nother line\n";
2221        let op = Op::Transform {
2222            find: "my_variable".to_string(),
2223            mode: TransformMode::CamelCase,
2224            regex: false,
2225            case_insensitive: false,
2226        };
2227        let matcher = Matcher::new(&op).unwrap();
2228        let result = apply(text, &op, &matcher, None, 0).unwrap();
2229        assert_eq!(result.text.unwrap(), "let myVariable = 1;\nother line\n");
2230        assert_eq!(result.changes.len(), 1);
2231    }
2232
2233    #[test]
2234    fn test_transform_upper_multiple_matches_on_line() {
2235        let text = "hello and hello again\n";
2236        let op = Op::Transform {
2237            find: "hello".to_string(),
2238            mode: TransformMode::Upper,
2239            regex: false,
2240            case_insensitive: false,
2241        };
2242        let matcher = Matcher::new(&op).unwrap();
2243        let result = apply(text, &op, &matcher, None, 0).unwrap();
2244        assert_eq!(result.text.unwrap(), "HELLO and HELLO again\n");
2245    }
2246
2247    #[test]
2248    fn test_transform_no_match() {
2249        let text = "hello world\n";
2250        let op = Op::Transform {
2251            find: "zzz".to_string(),
2252            mode: TransformMode::Upper,
2253            regex: false,
2254            case_insensitive: false,
2255        };
2256        let matcher = Matcher::new(&op).unwrap();
2257        let result = apply(text, &op, &matcher, None, 0).unwrap();
2258        assert!(result.text.is_none());
2259        assert!(result.changes.is_empty());
2260    }
2261
2262    #[test]
2263    fn test_transform_empty_text() {
2264        let text = "";
2265        let op = Op::Transform {
2266            find: "anything".to_string(),
2267            mode: TransformMode::Upper,
2268            regex: false,
2269            case_insensitive: false,
2270        };
2271        let matcher = Matcher::new(&op).unwrap();
2272        let result = apply(text, &op, &matcher, None, 0).unwrap();
2273        assert!(result.text.is_none());
2274        assert!(result.changes.is_empty());
2275    }
2276
2277    #[test]
2278    fn test_transform_with_regex() {
2279        let text = "let fooBar = 1;\nlet bazQux = 2;\n";
2280        let op = Op::Transform {
2281            find: r"[a-z]+[A-Z]\w*".to_string(),
2282            mode: TransformMode::SnakeCase,
2283            regex: true,
2284            case_insensitive: false,
2285        };
2286        let matcher = Matcher::new(&op).unwrap();
2287        let result = apply(text, &op, &matcher, None, 0).unwrap();
2288        let output = result.text.unwrap();
2289        assert!(output.contains("foo_bar"));
2290        assert!(output.contains("baz_qux"));
2291        assert_eq!(result.changes.len(), 2);
2292    }
2293
2294    #[test]
2295    fn test_transform_case_insensitive() {
2296        let text = "Hello HELLO hello\n";
2297        let op = Op::Transform {
2298            find: "hello".to_string(),
2299            mode: TransformMode::Upper,
2300            regex: false,
2301            case_insensitive: true,
2302        };
2303        let matcher = Matcher::new(&op).unwrap();
2304        let result = apply(text, &op, &matcher, None, 0).unwrap();
2305        assert_eq!(result.text.unwrap(), "HELLO HELLO HELLO\n");
2306    }
2307
2308    #[test]
2309    fn test_transform_crlf_preserved() {
2310        let text = "hello world\r\nfoo bar\r\n";
2311        let op = Op::Transform {
2312            find: "hello".to_string(),
2313            mode: TransformMode::Upper,
2314            regex: false,
2315            case_insensitive: false,
2316        };
2317        let matcher = Matcher::new(&op).unwrap();
2318        let result = apply(text, &op, &matcher, None, 0).unwrap();
2319        assert_eq!(result.text.unwrap(), "HELLO world\r\nfoo bar\r\n");
2320    }
2321
2322    #[test]
2323    fn test_transform_with_line_range() {
2324        let text = "hello\nhello\nhello\nhello\n";
2325        let op = Op::Transform {
2326            find: "hello".to_string(),
2327            mode: TransformMode::Upper,
2328            regex: false,
2329            case_insensitive: false,
2330        };
2331        let range = Some(LineRange {
2332            start: 2,
2333            end: Some(3),
2334        });
2335        let matcher = Matcher::new(&op).unwrap();
2336        let result = apply(text, &op, &matcher, range.map(RangeSpec::Lines), 0).unwrap();
2337        assert_eq!(result.text.unwrap(), "hello\nHELLO\nHELLO\nhello\n");
2338        assert_eq!(result.changes.len(), 2);
2339    }
2340
2341    #[test]
2342    fn test_transform_title_with_underscores() {
2343        let text = "my_func_name\n";
2344        let op = Op::Transform {
2345            find: "my_func_name".to_string(),
2346            mode: TransformMode::Title,
2347            regex: false,
2348            case_insensitive: false,
2349        };
2350        let matcher = Matcher::new(&op).unwrap();
2351        let result = apply(text, &op, &matcher, None, 0).unwrap();
2352        // Title case capitalizes after underscores
2353        assert_eq!(result.text.unwrap(), "My_Func_Name\n");
2354    }
2355
2356    #[test]
2357    fn test_transform_snake_case_from_multi_word() {
2358        let text = "my-kebab-case\n";
2359        let op = Op::Transform {
2360            find: "my-kebab-case".to_string(),
2361            mode: TransformMode::SnakeCase,
2362            regex: false,
2363            case_insensitive: false,
2364        };
2365        let matcher = Matcher::new(&op).unwrap();
2366        let result = apply(text, &op, &matcher, None, 0).unwrap();
2367        assert_eq!(result.text.unwrap(), "my_kebab_case\n");
2368    }
2369
2370    #[test]
2371    fn test_transform_camel_case_from_snake() {
2372        let text = "my_var_name\n";
2373        let op = Op::Transform {
2374            find: "my_var_name".to_string(),
2375            mode: TransformMode::CamelCase,
2376            regex: false,
2377            case_insensitive: false,
2378        };
2379        let matcher = Matcher::new(&op).unwrap();
2380        let result = apply(text, &op, &matcher, None, 0).unwrap();
2381        assert_eq!(result.text.unwrap(), "myVarName\n");
2382    }
2383
2384    #[test]
2385    fn test_transform_camel_case_from_kebab() {
2386        let text = "my-var-name\n";
2387        let op = Op::Transform {
2388            find: "my-var-name".to_string(),
2389            mode: TransformMode::CamelCase,
2390            regex: false,
2391            case_insensitive: false,
2392        };
2393        let matcher = Matcher::new(&op).unwrap();
2394        let result = apply(text, &op, &matcher, None, 0).unwrap();
2395        assert_eq!(result.text.unwrap(), "myVarName\n");
2396    }
2397
2398    // ---------------------------------------------------------------
2399    // Surround operation tests
2400    // ---------------------------------------------------------------
2401
2402    #[test]
2403    fn test_surround_basic() {
2404        let text = "hello world\nfoo bar\n";
2405        let op = Op::Surround {
2406            find: "hello".to_string(),
2407            prefix: "<<".to_string(),
2408            suffix: ">>".to_string(),
2409            regex: false,
2410            case_insensitive: false,
2411        };
2412        let matcher = Matcher::new(&op).unwrap();
2413        let result = apply(text, &op, &matcher, None, 0).unwrap();
2414        assert_eq!(result.text.unwrap(), "<<hello world>>\nfoo bar\n");
2415        assert_eq!(result.changes.len(), 1);
2416        assert_eq!(result.changes[0].line, 1);
2417    }
2418
2419    #[test]
2420    fn test_surround_multiple_lines() {
2421        let text = "foo line 1\nbar line 2\nfoo line 3\n";
2422        let op = Op::Surround {
2423            find: "foo".to_string(),
2424            prefix: "[".to_string(),
2425            suffix: "]".to_string(),
2426            regex: false,
2427            case_insensitive: false,
2428        };
2429        let matcher = Matcher::new(&op).unwrap();
2430        let result = apply(text, &op, &matcher, None, 0).unwrap();
2431        assert_eq!(
2432            result.text.unwrap(),
2433            "[foo line 1]\nbar line 2\n[foo line 3]\n"
2434        );
2435        assert_eq!(result.changes.len(), 2);
2436    }
2437
2438    #[test]
2439    fn test_surround_no_match() {
2440        let text = "hello world\n";
2441        let op = Op::Surround {
2442            find: "zzz".to_string(),
2443            prefix: "<".to_string(),
2444            suffix: ">".to_string(),
2445            regex: false,
2446            case_insensitive: false,
2447        };
2448        let matcher = Matcher::new(&op).unwrap();
2449        let result = apply(text, &op, &matcher, None, 0).unwrap();
2450        assert!(result.text.is_none());
2451        assert!(result.changes.is_empty());
2452    }
2453
2454    #[test]
2455    fn test_surround_empty_text() {
2456        let text = "";
2457        let op = Op::Surround {
2458            find: "anything".to_string(),
2459            prefix: "<".to_string(),
2460            suffix: ">".to_string(),
2461            regex: false,
2462            case_insensitive: false,
2463        };
2464        let matcher = Matcher::new(&op).unwrap();
2465        let result = apply(text, &op, &matcher, None, 0).unwrap();
2466        assert!(result.text.is_none());
2467        assert!(result.changes.is_empty());
2468    }
2469
2470    #[test]
2471    fn test_surround_with_regex() {
2472        let text = "fn main() {\n    let x = 1;\n}\n";
2473        let op = Op::Surround {
2474            find: r"fn\s+\w+".to_string(),
2475            prefix: "/* ".to_string(),
2476            suffix: " */".to_string(),
2477            regex: true,
2478            case_insensitive: false,
2479        };
2480        let matcher = Matcher::new(&op).unwrap();
2481        let result = apply(text, &op, &matcher, None, 0).unwrap();
2482        assert_eq!(
2483            result.text.unwrap(),
2484            "/* fn main() { */\n    let x = 1;\n}\n"
2485        );
2486    }
2487
2488    #[test]
2489    fn test_surround_case_insensitive() {
2490        let text = "Hello world\nhello world\nHELLO world\n";
2491        let op = Op::Surround {
2492            find: "hello".to_string(),
2493            prefix: "(".to_string(),
2494            suffix: ")".to_string(),
2495            regex: false,
2496            case_insensitive: true,
2497        };
2498        let matcher = Matcher::new(&op).unwrap();
2499        let result = apply(text, &op, &matcher, None, 0).unwrap();
2500        let output = result.text.unwrap();
2501        assert_eq!(output, "(Hello world)\n(hello world)\n(HELLO world)\n");
2502        assert_eq!(result.changes.len(), 3);
2503    }
2504
2505    #[test]
2506    fn test_surround_crlf_preserved() {
2507        let text = "hello world\r\nfoo bar\r\n";
2508        let op = Op::Surround {
2509            find: "hello".to_string(),
2510            prefix: "[".to_string(),
2511            suffix: "]".to_string(),
2512            regex: false,
2513            case_insensitive: false,
2514        };
2515        let matcher = Matcher::new(&op).unwrap();
2516        let result = apply(text, &op, &matcher, None, 0).unwrap();
2517        assert_eq!(result.text.unwrap(), "[hello world]\r\nfoo bar\r\n");
2518    }
2519
2520    #[test]
2521    fn test_surround_with_line_range() {
2522        let text = "foo\nfoo\nfoo\nfoo\n";
2523        let op = Op::Surround {
2524            find: "foo".to_string(),
2525            prefix: "<".to_string(),
2526            suffix: ">".to_string(),
2527            regex: false,
2528            case_insensitive: false,
2529        };
2530        let range = Some(LineRange {
2531            start: 2,
2532            end: Some(3),
2533        });
2534        let matcher = Matcher::new(&op).unwrap();
2535        let result = apply(text, &op, &matcher, range.map(RangeSpec::Lines), 0).unwrap();
2536        assert_eq!(result.text.unwrap(), "foo\n<foo>\n<foo>\nfoo\n");
2537        assert_eq!(result.changes.len(), 2);
2538    }
2539
2540    #[test]
2541    fn test_surround_with_empty_prefix_and_suffix() {
2542        let text = "hello world\n";
2543        let op = Op::Surround {
2544            find: "hello".to_string(),
2545            prefix: String::new(),
2546            suffix: String::new(),
2547            regex: false,
2548            case_insensitive: false,
2549        };
2550        let matcher = Matcher::new(&op).unwrap();
2551        let result = apply(text, &op, &matcher, None, 0).unwrap();
2552        // Surround with empty prefix and suffix is a no-op — no change recorded.
2553        assert!(result.text.is_none());
2554        assert!(result.changes.is_empty());
2555    }
2556
2557    // ---------------------------------------------------------------
2558    // Indent operation tests
2559    // ---------------------------------------------------------------
2560
2561    #[test]
2562    fn test_indent_basic() {
2563        let text = "hello\nworld\n";
2564        let op = Op::Indent {
2565            find: "hello".to_string(),
2566            amount: 4,
2567            use_tabs: false,
2568            regex: false,
2569            case_insensitive: false,
2570        };
2571        let matcher = Matcher::new(&op).unwrap();
2572        let result = apply(text, &op, &matcher, None, 0).unwrap();
2573        assert_eq!(result.text.unwrap(), "    hello\nworld\n");
2574        assert_eq!(result.changes.len(), 1);
2575    }
2576
2577    #[test]
2578    fn test_indent_multiple_lines() {
2579        let text = "foo line 1\nbar line 2\nfoo line 3\n";
2580        let op = Op::Indent {
2581            find: "foo".to_string(),
2582            amount: 2,
2583            use_tabs: false,
2584            regex: false,
2585            case_insensitive: false,
2586        };
2587        let matcher = Matcher::new(&op).unwrap();
2588        let result = apply(text, &op, &matcher, None, 0).unwrap();
2589        assert_eq!(
2590            result.text.unwrap(),
2591            "  foo line 1\nbar line 2\n  foo line 3\n"
2592        );
2593        assert_eq!(result.changes.len(), 2);
2594    }
2595
2596    #[test]
2597    fn test_indent_with_tabs() {
2598        let text = "hello\nworld\n";
2599        let op = Op::Indent {
2600            find: "hello".to_string(),
2601            amount: 2,
2602            use_tabs: true,
2603            regex: false,
2604            case_insensitive: false,
2605        };
2606        let matcher = Matcher::new(&op).unwrap();
2607        let result = apply(text, &op, &matcher, None, 0).unwrap();
2608        assert_eq!(result.text.unwrap(), "\t\thello\nworld\n");
2609    }
2610
2611    #[test]
2612    fn test_indent_no_match() {
2613        let text = "hello world\n";
2614        let op = Op::Indent {
2615            find: "zzz".to_string(),
2616            amount: 4,
2617            use_tabs: false,
2618            regex: false,
2619            case_insensitive: false,
2620        };
2621        let matcher = Matcher::new(&op).unwrap();
2622        let result = apply(text, &op, &matcher, None, 0).unwrap();
2623        assert!(result.text.is_none());
2624        assert!(result.changes.is_empty());
2625    }
2626
2627    #[test]
2628    fn test_indent_empty_text() {
2629        let text = "";
2630        let op = Op::Indent {
2631            find: "anything".to_string(),
2632            amount: 4,
2633            use_tabs: false,
2634            regex: false,
2635            case_insensitive: false,
2636        };
2637        let matcher = Matcher::new(&op).unwrap();
2638        let result = apply(text, &op, &matcher, None, 0).unwrap();
2639        assert!(result.text.is_none());
2640        assert!(result.changes.is_empty());
2641    }
2642
2643    #[test]
2644    fn test_indent_zero_amount() {
2645        let text = "hello\n";
2646        let op = Op::Indent {
2647            find: "hello".to_string(),
2648            amount: 0,
2649            use_tabs: false,
2650            regex: false,
2651            case_insensitive: false,
2652        };
2653        let matcher = Matcher::new(&op).unwrap();
2654        let result = apply(text, &op, &matcher, None, 0).unwrap();
2655        // Indent by 0 is a no-op — no change recorded.
2656        assert!(result.text.is_none());
2657        assert!(result.changes.is_empty());
2658    }
2659
2660    #[test]
2661    fn test_indent_with_regex() {
2662        let text = "fn main() {\nlet x = 1;\n}\n";
2663        let op = Op::Indent {
2664            find: r"let\s+".to_string(),
2665            amount: 4,
2666            use_tabs: false,
2667            regex: true,
2668            case_insensitive: false,
2669        };
2670        let matcher = Matcher::new(&op).unwrap();
2671        let result = apply(text, &op, &matcher, None, 0).unwrap();
2672        assert_eq!(result.text.unwrap(), "fn main() {\n    let x = 1;\n}\n");
2673        assert_eq!(result.changes.len(), 1);
2674    }
2675
2676    #[test]
2677    fn test_indent_case_insensitive() {
2678        let text = "Hello\nhello\nHELLO\n";
2679        let op = Op::Indent {
2680            find: "hello".to_string(),
2681            amount: 2,
2682            use_tabs: false,
2683            regex: false,
2684            case_insensitive: true,
2685        };
2686        let matcher = Matcher::new(&op).unwrap();
2687        let result = apply(text, &op, &matcher, None, 0).unwrap();
2688        assert_eq!(result.text.unwrap(), "  Hello\n  hello\n  HELLO\n");
2689        assert_eq!(result.changes.len(), 3);
2690    }
2691
2692    #[test]
2693    fn test_indent_crlf_preserved() {
2694        let text = "hello\r\nworld\r\n";
2695        let op = Op::Indent {
2696            find: "hello".to_string(),
2697            amount: 4,
2698            use_tabs: false,
2699            regex: false,
2700            case_insensitive: false,
2701        };
2702        let matcher = Matcher::new(&op).unwrap();
2703        let result = apply(text, &op, &matcher, None, 0).unwrap();
2704        assert_eq!(result.text.unwrap(), "    hello\r\nworld\r\n");
2705    }
2706
2707    #[test]
2708    fn test_indent_with_line_range() {
2709        let text = "foo\nfoo\nfoo\nfoo\n";
2710        let op = Op::Indent {
2711            find: "foo".to_string(),
2712            amount: 4,
2713            use_tabs: false,
2714            regex: false,
2715            case_insensitive: false,
2716        };
2717        let range = Some(LineRange {
2718            start: 2,
2719            end: Some(3),
2720        });
2721        let matcher = Matcher::new(&op).unwrap();
2722        let result = apply(text, &op, &matcher, range.map(RangeSpec::Lines), 0).unwrap();
2723        assert_eq!(result.text.unwrap(), "foo\n    foo\n    foo\nfoo\n");
2724        assert_eq!(result.changes.len(), 2);
2725    }
2726
2727    // ---------------------------------------------------------------
2728    // Dedent operation tests
2729    // ---------------------------------------------------------------
2730
2731    #[test]
2732    fn test_dedent_basic() {
2733        let text = "    hello\nworld\n";
2734        let op = Op::Dedent {
2735            find: "hello".to_string(),
2736            amount: 4,
2737            use_tabs: false,
2738            regex: false,
2739            case_insensitive: false,
2740        };
2741        let matcher = Matcher::new(&op).unwrap();
2742        let result = apply(text, &op, &matcher, None, 0).unwrap();
2743        assert_eq!(result.text.unwrap(), "hello\nworld\n");
2744        assert_eq!(result.changes.len(), 1);
2745    }
2746
2747    #[test]
2748    fn test_dedent_partial() {
2749        // Only 2 spaces of leading whitespace, dedent by 4 should remove only 2
2750        let text = "  hello\n";
2751        let op = Op::Dedent {
2752            find: "hello".to_string(),
2753            amount: 4,
2754            use_tabs: false,
2755            regex: false,
2756            case_insensitive: false,
2757        };
2758        let matcher = Matcher::new(&op).unwrap();
2759        let result = apply(text, &op, &matcher, None, 0).unwrap();
2760        assert_eq!(result.text.unwrap(), "hello\n");
2761    }
2762
2763    #[test]
2764    fn test_dedent_no_leading_spaces() {
2765        // Line matches but has no leading spaces -- nothing to remove
2766        let text = "hello\n";
2767        let op = Op::Dedent {
2768            find: "hello".to_string(),
2769            amount: 4,
2770            use_tabs: false,
2771            regex: false,
2772            case_insensitive: false,
2773        };
2774        let matcher = Matcher::new(&op).unwrap();
2775        let result = apply(text, &op, &matcher, None, 0).unwrap();
2776        // No actual change because line has no leading spaces
2777        assert!(result.text.is_none());
2778        assert!(result.changes.is_empty());
2779    }
2780
2781    #[test]
2782    fn test_dedent_multiple_lines() {
2783        let text = "    foo line 1\n    bar line 2\n    foo line 3\n";
2784        let op = Op::Dedent {
2785            find: "foo".to_string(),
2786            amount: 4,
2787            use_tabs: false,
2788            regex: false,
2789            case_insensitive: false,
2790        };
2791        let matcher = Matcher::new(&op).unwrap();
2792        let result = apply(text, &op, &matcher, None, 0).unwrap();
2793        assert_eq!(
2794            result.text.unwrap(),
2795            "foo line 1\n    bar line 2\nfoo line 3\n"
2796        );
2797        assert_eq!(result.changes.len(), 2);
2798    }
2799
2800    #[test]
2801    fn test_dedent_no_match() {
2802        let text = "    hello world\n";
2803        let op = Op::Dedent {
2804            find: "zzz".to_string(),
2805            amount: 4,
2806            use_tabs: false,
2807            regex: false,
2808            case_insensitive: false,
2809        };
2810        let matcher = Matcher::new(&op).unwrap();
2811        let result = apply(text, &op, &matcher, None, 0).unwrap();
2812        assert!(result.text.is_none());
2813        assert!(result.changes.is_empty());
2814    }
2815
2816    #[test]
2817    fn test_dedent_empty_text() {
2818        let text = "";
2819        let op = Op::Dedent {
2820            find: "anything".to_string(),
2821            amount: 4,
2822            use_tabs: false,
2823            regex: false,
2824            case_insensitive: false,
2825        };
2826        let matcher = Matcher::new(&op).unwrap();
2827        let result = apply(text, &op, &matcher, None, 0).unwrap();
2828        assert!(result.text.is_none());
2829        assert!(result.changes.is_empty());
2830    }
2831
2832    #[test]
2833    fn test_dedent_with_regex() {
2834        let text = "    let x = 1;\n    fn main() {\n";
2835        let op = Op::Dedent {
2836            find: r"let\s+".to_string(),
2837            amount: 4,
2838            use_tabs: false,
2839            regex: true,
2840            case_insensitive: false,
2841        };
2842        let matcher = Matcher::new(&op).unwrap();
2843        let result = apply(text, &op, &matcher, None, 0).unwrap();
2844        assert_eq!(result.text.unwrap(), "let x = 1;\n    fn main() {\n");
2845        assert_eq!(result.changes.len(), 1);
2846    }
2847
2848    #[test]
2849    fn test_dedent_case_insensitive() {
2850        let text = "    Hello\n    hello\n    HELLO\n";
2851        let op = Op::Dedent {
2852            find: "hello".to_string(),
2853            amount: 2,
2854            use_tabs: false,
2855            regex: false,
2856            case_insensitive: true,
2857        };
2858        let matcher = Matcher::new(&op).unwrap();
2859        let result = apply(text, &op, &matcher, None, 0).unwrap();
2860        assert_eq!(result.text.unwrap(), "  Hello\n  hello\n  HELLO\n");
2861        assert_eq!(result.changes.len(), 3);
2862    }
2863
2864    #[test]
2865    fn test_dedent_crlf_preserved() {
2866        let text = "    hello\r\nworld\r\n";
2867        let op = Op::Dedent {
2868            find: "hello".to_string(),
2869            amount: 4,
2870            use_tabs: false,
2871            regex: false,
2872            case_insensitive: false,
2873        };
2874        let matcher = Matcher::new(&op).unwrap();
2875        let result = apply(text, &op, &matcher, None, 0).unwrap();
2876        assert_eq!(result.text.unwrap(), "hello\r\nworld\r\n");
2877    }
2878
2879    #[test]
2880    fn test_dedent_with_line_range() {
2881        let text = "    foo\n    foo\n    foo\n    foo\n";
2882        let op = Op::Dedent {
2883            find: "foo".to_string(),
2884            amount: 4,
2885            use_tabs: false,
2886            regex: false,
2887            case_insensitive: false,
2888        };
2889        let range = Some(LineRange {
2890            start: 2,
2891            end: Some(3),
2892        });
2893        let matcher = Matcher::new(&op).unwrap();
2894        let result = apply(text, &op, &matcher, range.map(RangeSpec::Lines), 0).unwrap();
2895        assert_eq!(result.text.unwrap(), "    foo\nfoo\nfoo\n    foo\n");
2896        assert_eq!(result.changes.len(), 2);
2897    }
2898
2899    #[test]
2900    fn test_dedent_only_removes_spaces_not_tabs() {
2901        // Dedent only strips leading spaces, not tabs
2902        let text = "\t\thello\n";
2903        let op = Op::Dedent {
2904            find: "hello".to_string(),
2905            amount: 4,
2906            use_tabs: false,
2907            regex: false,
2908            case_insensitive: false,
2909        };
2910        let matcher = Matcher::new(&op).unwrap();
2911        let result = apply(text, &op, &matcher, None, 0).unwrap();
2912        // The dedent_line function only strips spaces (trim_start_matches(' ')),
2913        // tabs are not removed.
2914        assert!(result.text.is_none());
2915    }
2916
2917    // ---------------------------------------------------------------
2918    // Indent then Dedent roundtrip
2919    // ---------------------------------------------------------------
2920
2921    #[test]
2922    fn test_indent_then_dedent_roundtrip() {
2923        let original = "hello world\nfoo bar\n";
2924
2925        // Step 1: Indent by 4
2926        let indent_op = Op::Indent {
2927            find: "hello".to_string(),
2928            amount: 4,
2929            use_tabs: false,
2930            regex: false,
2931            case_insensitive: false,
2932        };
2933        let indent_matcher = Matcher::new(&indent_op).unwrap();
2934        let indented = apply(original, &indent_op, &indent_matcher, None, 0).unwrap();
2935        let indented_text = indented.text.unwrap();
2936        assert_eq!(indented_text, "    hello world\nfoo bar\n");
2937
2938        // Step 2: Dedent by 4 (the find still matches because "hello" is in the line)
2939        let dedent_op = Op::Dedent {
2940            find: "hello".to_string(),
2941            amount: 4,
2942            use_tabs: false,
2943            regex: false,
2944            case_insensitive: false,
2945        };
2946        let dedent_matcher = Matcher::new(&dedent_op).unwrap();
2947        let dedented = apply(&indented_text, &dedent_op, &dedent_matcher, None, 0).unwrap();
2948        assert_eq!(dedented.text.unwrap(), original);
2949    }
2950
2951    // ---------------------------------------------------------------
2952    // Undo entry tests for new ops
2953    // ---------------------------------------------------------------
2954
2955    #[test]
2956    fn test_transform_undo_stores_original() {
2957        let text = "hello world\n";
2958        let op = Op::Transform {
2959            find: "hello".to_string(),
2960            mode: TransformMode::Upper,
2961            regex: false,
2962            case_insensitive: false,
2963        };
2964        let matcher = Matcher::new(&op).unwrap();
2965        let result = apply(text, &op, &matcher, None, 0).unwrap();
2966        assert_eq!(result.undo.unwrap().original_text, text);
2967    }
2968
2969    #[test]
2970    fn test_surround_undo_stores_original() {
2971        let text = "hello world\n";
2972        let op = Op::Surround {
2973            find: "hello".to_string(),
2974            prefix: "<".to_string(),
2975            suffix: ">".to_string(),
2976            regex: false,
2977            case_insensitive: false,
2978        };
2979        let matcher = Matcher::new(&op).unwrap();
2980        let result = apply(text, &op, &matcher, None, 0).unwrap();
2981        assert_eq!(result.undo.unwrap().original_text, text);
2982    }
2983
2984    #[test]
2985    fn test_indent_undo_stores_original() {
2986        let text = "hello world\n";
2987        let op = Op::Indent {
2988            find: "hello".to_string(),
2989            amount: 4,
2990            use_tabs: false,
2991            regex: false,
2992            case_insensitive: false,
2993        };
2994        let matcher = Matcher::new(&op).unwrap();
2995        let result = apply(text, &op, &matcher, None, 0).unwrap();
2996        assert_eq!(result.undo.unwrap().original_text, text);
2997    }
2998
2999    #[test]
3000    fn test_dedent_undo_stores_original() {
3001        let text = "    hello world\n";
3002        let op = Op::Dedent {
3003            find: "hello".to_string(),
3004            amount: 4,
3005            use_tabs: false,
3006            regex: false,
3007            case_insensitive: false,
3008        };
3009        let matcher = Matcher::new(&op).unwrap();
3010        let result = apply(text, &op, &matcher, None, 0).unwrap();
3011        assert_eq!(result.undo.unwrap().original_text, text);
3012    }
3013
3014    // ---------------------------------------------------------------
3015    // Line preservation tests for new ops
3016    // ---------------------------------------------------------------
3017
3018    #[test]
3019    fn test_transform_preserves_line_count() {
3020        let text = "hello\nworld\nfoo\n";
3021        let op = Op::Transform {
3022            find: "hello".to_string(),
3023            mode: TransformMode::Upper,
3024            regex: false,
3025            case_insensitive: false,
3026        };
3027        let matcher = Matcher::new(&op).unwrap();
3028        let result = apply(text, &op, &matcher, None, 0).unwrap();
3029        let output = result.text.unwrap();
3030        assert_eq!(text.lines().count(), output.lines().count());
3031    }
3032
3033    #[test]
3034    fn test_surround_preserves_line_count() {
3035        let text = "hello\nworld\nfoo\n";
3036        let op = Op::Surround {
3037            find: "hello".to_string(),
3038            prefix: "<".to_string(),
3039            suffix: ">".to_string(),
3040            regex: false,
3041            case_insensitive: false,
3042        };
3043        let matcher = Matcher::new(&op).unwrap();
3044        let result = apply(text, &op, &matcher, None, 0).unwrap();
3045        let output = result.text.unwrap();
3046        assert_eq!(text.lines().count(), output.lines().count());
3047    }
3048
3049    #[test]
3050    fn test_indent_preserves_line_count() {
3051        let text = "hello\nworld\nfoo\n";
3052        let op = Op::Indent {
3053            find: "hello".to_string(),
3054            amount: 4,
3055            use_tabs: false,
3056            regex: false,
3057            case_insensitive: false,
3058        };
3059        let matcher = Matcher::new(&op).unwrap();
3060        let result = apply(text, &op, &matcher, None, 0).unwrap();
3061        let output = result.text.unwrap();
3062        assert_eq!(text.lines().count(), output.lines().count());
3063    }
3064
3065    #[test]
3066    fn test_dedent_preserves_line_count() {
3067        let text = "    hello\n    world\n    foo\n";
3068        let op = Op::Dedent {
3069            find: "hello".to_string(),
3070            amount: 4,
3071            use_tabs: false,
3072            regex: false,
3073            case_insensitive: false,
3074        };
3075        let matcher = Matcher::new(&op).unwrap();
3076        let result = apply(text, &op, &matcher, None, 0).unwrap();
3077        let output = result.text.unwrap();
3078        assert_eq!(text.lines().count(), output.lines().count());
3079    }
3080
3081    // ---------------------------------------------------------------
3082    // Adversarial: line number correctness under odd line endings
3083    // ---------------------------------------------------------------
3084
3085    /// **Adversarial**: in a file with mixed line endings, the change
3086    /// reported for line 3 should correspond to the THIRD *logical line*
3087    /// (1-indexed), not a byte offset or a line under some alternative
3088    /// counting scheme. Agents use this line number to navigate.
3089    #[test]
3090    fn test_line_numbers_with_mixed_line_endings() {
3091        // Line 1 = "alpha" (LF)
3092        // Line 2 = "beta" (CRLF)
3093        // Line 3 = "gamma match" (LF) — this is where the change should be reported
3094        // Line 4 = "delta" (CRLF)
3095        let text = "alpha\nbeta\r\ngamma match\ndelta\r\n";
3096        let op = Op::Replace {
3097            count: Default::default(),
3098            multiline: false,
3099            find: "match".to_string(),
3100            replace: "HIT".to_string(),
3101            regex: false,
3102            case_insensitive: false,
3103        };
3104        let matcher = Matcher::new(&op).unwrap();
3105        let result = apply(text, &op, &matcher, None, 0).unwrap();
3106        assert_eq!(result.changes.len(), 1);
3107        assert_eq!(
3108            result.changes[0].line, 3,
3109            "Line number must be 3 regardless of the mixed CR/LF / CRLF endings above it; got {}",
3110            result.changes[0].line
3111        );
3112        assert_eq!(result.changes[0].before, "gamma match");
3113    }
3114
3115    /// **Adversarial**: a file consisting entirely of a single line without
3116    /// any trailing newline still has "line 1" — if that line matches, the
3117    /// reported change must be on line 1.
3118    #[test]
3119    fn test_line_number_for_single_line_no_newline() {
3120        let text = "only line matches here";
3121        let op = Op::Replace {
3122            count: Default::default(),
3123            multiline: false,
3124            find: "matches".to_string(),
3125            replace: "OK".to_string(),
3126            regex: false,
3127            case_insensitive: false,
3128        };
3129        let matcher = Matcher::new(&op).unwrap();
3130        let result = apply(text, &op, &matcher, None, 0).unwrap();
3131        assert_eq!(result.changes.len(), 1);
3132        assert_eq!(result.changes[0].line, 1);
3133    }
3134
3135    /// **Adversarial**: the first line of a file, when matched, must be
3136    /// reported as line 1, not line 0 (off-by-one regression guard).
3137    #[test]
3138    fn test_first_line_is_one_not_zero() {
3139        let text = "match first\nother\nother\n";
3140        let op = Op::Replace {
3141            count: Default::default(),
3142            multiline: false,
3143            find: "match".to_string(),
3144            replace: "X".to_string(),
3145            regex: false,
3146            case_insensitive: false,
3147        };
3148        let matcher = Matcher::new(&op).unwrap();
3149        let result = apply(text, &op, &matcher, None, 0).unwrap();
3150        assert_eq!(
3151            result.changes[0].line, 1,
3152            "First-line change must be reported as line 1, not 0"
3153        );
3154    }
3155
3156    /// **Adversarial**: when a Delete operation removes the middle line of
3157    /// a three-line file, the single reported change must reference the
3158    /// DELETED line's original line number (2), not the new position of
3159    /// the following line. Undo needs this invariant to restore correctly.
3160    #[test]
3161    fn test_delete_reports_original_line_number() {
3162        let text = "keep1\ndelete_me\nkeep2\n";
3163        let op = Op::Delete {
3164            multiline: false,
3165            find: "delete_me".to_string(),
3166            regex: false,
3167            case_insensitive: false,
3168        };
3169        let matcher = Matcher::new(&op).unwrap();
3170        let result = apply(text, &op, &matcher, None, 0).unwrap();
3171        assert_eq!(result.changes.len(), 1);
3172        assert_eq!(
3173            result.changes[0].line, 2,
3174            "Deleted line's reported line must be its original position (2)"
3175        );
3176        assert_eq!(result.changes[0].before, "delete_me");
3177        assert_eq!(result.changes[0].after, None);
3178    }
3179
3180    /// **Adversarial regression**: deleting the single line of a one-line
3181    /// file must produce an empty file (""), not a file containing one
3182    /// empty line ("\n"). The old engine preserved the trailing newline
3183    /// unconditionally, which inverted the POSIX "non-existent file vs.
3184    /// file with empty line" distinction — `wc -l` would say "1" for a
3185    /// file the user thought they had emptied.
3186    #[test]
3187    fn test_delete_all_lines_produces_empty_file() {
3188        let text = "only line\n";
3189        let op = Op::Delete {
3190            multiline: false,
3191            find: "only".to_string(),
3192            regex: false,
3193            case_insensitive: false,
3194        };
3195        let matcher = Matcher::new(&op).unwrap();
3196        let result = apply(text, &op, &matcher, None, 0).unwrap();
3197        let output = result.text.unwrap();
3198        assert_eq!(
3199            output, "",
3200            "Deleting every line must yield an empty file, got {output:?}"
3201        );
3202    }
3203
3204    /// Same invariant for CRLF input.
3205    #[test]
3206    fn test_delete_all_lines_crlf_produces_empty_file() {
3207        let text = "only line\r\n";
3208        let op = Op::Delete {
3209            multiline: false,
3210            find: "only".to_string(),
3211            regex: false,
3212            case_insensitive: false,
3213        };
3214        let matcher = Matcher::new(&op).unwrap();
3215        let result = apply(text, &op, &matcher, None, 0).unwrap();
3216        let output = result.text.unwrap();
3217        assert_eq!(
3218            output, "",
3219            "Deleting every CRLF line must yield an empty file"
3220        );
3221    }
3222}
3223
3224// ---------------------------------------------------------------
3225// Property-based tests (proptest)
3226// ---------------------------------------------------------------
3227#[cfg(test)]
3228mod proptests {
3229    use super::*;
3230    use crate::matcher::Matcher;
3231    use crate::operation::Op;
3232    use proptest::prelude::*;
3233
3234    /// Strategy for generating text that is multiple lines with a trailing newline.
3235    fn arb_multiline_text() -> impl Strategy<Value = String> {
3236        prop::collection::vec("[^\n\r]{0,80}", 1..10).prop_map(|lines| lines.join("\n") + "\n")
3237    }
3238
3239    /// Strategy for generating a non-empty find pattern (plain literal).
3240    fn arb_find_pattern() -> impl Strategy<Value = String> {
3241        "[a-zA-Z0-9]{1,8}"
3242    }
3243
3244    proptest! {
3245        /// EQUIVALENCE: the whole-buffer splice fast path must produce the
3246        /// same text AND the same line-shaped Change metadata as the
3247        /// per-line loop. A full-file numeric range forces the loop with
3248        /// identical semantics, so the two paths can be compared directly.
3249        #[test]
3250        fn prop_splice_equals_line_path(
3251            text in arb_multiline_text(),
3252            // {0,8}: the empty pattern is in scope — fuzz_engine found an
3253            // infinite loop the original {1,8} strategy could never reach.
3254            find in "[a-zA-Z0-9]{0,8}",
3255            replace in "[a-zA-Z0-9 ]{0,8}",
3256            case_insensitive in proptest::bool::ANY,
3257        ) {
3258            let op = Op::Replace {
3259                count: Default::default(),
3260                multiline: false,
3261                find,
3262                replace,
3263                regex: false,
3264                case_insensitive,
3265            };
3266            let matcher = Matcher::new(&op).unwrap();
3267            let spliced = apply(&text, &op, &matcher, None, 2).unwrap();
3268            let looped = apply(
3269                &text,
3270                &op,
3271                &matcher,
3272                Some(RangeSpec::Lines(LineRange { start: 1, end: None })),
3273                2,
3274            )
3275            .unwrap();
3276            prop_assert_eq!(spliced.text, looped.text);
3277            prop_assert_eq!(spliced.changes, looped.changes);
3278        }
3279
3280        /// Round-trip: applying a Replace then undoing (restoring original_text)
3281        /// should give back the original text.
3282        #[test]
3283        fn prop_roundtrip_undo(
3284            text in arb_multiline_text(),
3285            find in arb_find_pattern(),
3286            replace in "[a-zA-Z0-9]{0,8}",
3287        ) {
3288            let op = Op::Replace {
3289                count: Default::default(),
3290                multiline: false,
3291                find: find.clone(),
3292                replace: replace.clone(),
3293                regex: false,
3294                case_insensitive: false,
3295            };
3296            let matcher = Matcher::new(&op).unwrap();
3297            let result = apply(&text, &op, &matcher, None, 0).unwrap();
3298
3299            if let Some(undo) = &result.undo {
3300                // Undo should restore original text
3301                prop_assert_eq!(&undo.original_text, &text);
3302            }
3303            // If no changes, text should be None
3304            if result.text.is_none() {
3305                prop_assert!(result.changes.is_empty());
3306            }
3307        }
3308
3309        /// No-op: applying with a pattern that cannot match leaves text unchanged.
3310        #[test]
3311        fn prop_noop_nonmatching_pattern(text in arb_multiline_text()) {
3312            // Use a pattern with a NUL byte which will never appear in text generated
3313            // by arb_multiline_text
3314            let op = Op::Replace {
3315                count: Default::default(),
3316                multiline: false,
3317                find: "\x00\x00NOMATCH\x00\x00".to_string(),
3318                replace: "replacement".to_string(),
3319                regex: false,
3320                case_insensitive: false,
3321            };
3322            let matcher = Matcher::new(&op).unwrap();
3323            let result = apply(&text, &op, &matcher, None, 0).unwrap();
3324            prop_assert!(result.text.is_none(), "Non-matching pattern should not modify text");
3325            prop_assert!(result.changes.is_empty());
3326            prop_assert!(result.undo.is_none());
3327        }
3328
3329        /// Determinism: same input always produces same output.
3330        #[test]
3331        fn prop_deterministic(
3332            text in arb_multiline_text(),
3333            find in arb_find_pattern(),
3334            replace in "[a-zA-Z0-9]{0,8}",
3335        ) {
3336            let op = Op::Replace {
3337                count: Default::default(),
3338                multiline: false,
3339                find,
3340                replace,
3341                regex: false,
3342                case_insensitive: false,
3343            };
3344            let matcher = Matcher::new(&op).unwrap();
3345            let r1 = apply(&text, &op, &matcher, None, 0).unwrap();
3346            let r2 = apply(&text, &op, &matcher, None, 0).unwrap();
3347            prop_assert_eq!(&r1.text, &r2.text);
3348            prop_assert_eq!(r1.changes.len(), r2.changes.len());
3349        }
3350
3351        /// Line count: for Replace ops, output line count == input line count.
3352        #[test]
3353        fn prop_replace_preserves_line_count(
3354            text in arb_multiline_text(),
3355            find in arb_find_pattern(),
3356            replace in "[a-zA-Z0-9]{0,8}",
3357        ) {
3358            let op = Op::Replace {
3359                count: Default::default(),
3360                multiline: false,
3361                find,
3362                replace,
3363                regex: false,
3364                case_insensitive: false,
3365            };
3366            let matcher = Matcher::new(&op).unwrap();
3367            let result = apply(&text, &op, &matcher, None, 0).unwrap();
3368            if let Some(ref output) = result.text {
3369                let input_lines = text.lines().count();
3370                let output_lines = output.lines().count();
3371                prop_assert_eq!(
3372                    input_lines,
3373                    output_lines,
3374                    "Replace should preserve line count: input={} output={}",
3375                    input_lines,
3376                    output_lines
3377                );
3378            }
3379        }
3380
3381        /// Indent then Dedent by the same amount should restore the original text
3382        /// when every line contains the find pattern and starts with enough spaces.
3383        #[test]
3384        fn prop_indent_dedent_roundtrip(
3385            amount in 1usize..=16,
3386        ) {
3387            // Use a known find pattern that appears on every line
3388            let find = "marker".to_string();
3389            let text = "marker line one\nmarker line two\nmarker line three\n";
3390
3391            let indent_op = Op::Indent {
3392                find: find.clone(),
3393                amount,
3394                use_tabs: false,
3395                regex: false,
3396                case_insensitive: false,
3397            };
3398            let indent_matcher = Matcher::new(&indent_op).unwrap();
3399            let indented = apply(text, &indent_op, &indent_matcher, None, 0).unwrap();
3400            let indented_text = indented.text.unwrap();
3401
3402            // Every line should now start with `amount` spaces
3403            for line in indented_text.lines() {
3404                let leading = line.len() - line.trim_start_matches(' ').len();
3405                prop_assert!(leading >= amount, "Expected at least {} leading spaces, got {}", amount, leading);
3406            }
3407
3408            let dedent_op = Op::Dedent {
3409                find: find.clone(),
3410                amount,
3411                use_tabs: false,
3412                regex: false,
3413                case_insensitive: false,
3414            };
3415            let dedent_matcher = Matcher::new(&dedent_op).unwrap();
3416            let dedented = apply(&indented_text, &dedent_op, &dedent_matcher, None, 0).unwrap();
3417            prop_assert_eq!(dedented.text.unwrap(), text);
3418        }
3419
3420        /// Transform Upper then Lower should restore the original when
3421        /// the text is already all lowercase ASCII.
3422        #[test]
3423        fn prop_transform_upper_lower_roundtrip(
3424            find in "[a-z]{1,8}",
3425        ) {
3426            let text = format!("prefix {find} suffix\n");
3427
3428            let upper_op = Op::Transform {
3429                find: find.clone(),
3430                mode: crate::operation::TransformMode::Upper,
3431                regex: false,
3432                case_insensitive: false,
3433            };
3434            let upper_matcher = Matcher::new(&upper_op).unwrap();
3435            let uppered = apply(&text, &upper_op, &upper_matcher, None, 0).unwrap();
3436
3437            if let Some(ref upper_text) = uppered.text {
3438                let upper_find = find.to_uppercase();
3439                let lower_op = Op::Transform {
3440                    find: upper_find,
3441                    mode: crate::operation::TransformMode::Lower,
3442                    regex: false,
3443                    case_insensitive: false,
3444                };
3445                let lower_matcher = Matcher::new(&lower_op).unwrap();
3446                let lowered = apply(upper_text, &lower_op, &lower_matcher, None, 0).unwrap();
3447                prop_assert_eq!(lowered.text.unwrap(), text);
3448            }
3449        }
3450
3451        /// Surround preserves line count.
3452        #[test]
3453        fn prop_surround_preserves_line_count(
3454            text in arb_multiline_text(),
3455            find in arb_find_pattern(),
3456        ) {
3457            let op = Op::Surround {
3458                find,
3459                prefix: "<<".to_string(),
3460                suffix: ">>".to_string(),
3461                regex: false,
3462                case_insensitive: false,
3463            };
3464            let matcher = Matcher::new(&op).unwrap();
3465            let result = apply(&text, &op, &matcher, None, 0).unwrap();
3466            if let Some(ref output) = result.text {
3467                let input_lines = text.lines().count();
3468                let output_lines = output.lines().count();
3469                prop_assert_eq!(
3470                    input_lines,
3471                    output_lines,
3472                    "Surround should preserve line count: input={} output={}",
3473                    input_lines,
3474                    output_lines
3475                );
3476            }
3477        }
3478
3479        /// Transform preserves line count.
3480        #[test]
3481        fn prop_transform_preserves_line_count(
3482            text in arb_multiline_text(),
3483            find in arb_find_pattern(),
3484        ) {
3485            let op = Op::Transform {
3486                find,
3487                mode: crate::operation::TransformMode::Upper,
3488                regex: false,
3489                case_insensitive: false,
3490            };
3491            let matcher = Matcher::new(&op).unwrap();
3492            let result = apply(&text, &op, &matcher, None, 0).unwrap();
3493            if let Some(ref output) = result.text {
3494                let input_lines = text.lines().count();
3495                let output_lines = output.lines().count();
3496                prop_assert_eq!(
3497                    input_lines,
3498                    output_lines,
3499                    "Transform should preserve line count: input={} output={}",
3500                    input_lines,
3501                    output_lines
3502                );
3503            }
3504        }
3505
3506        /// Indent preserves line count.
3507        #[test]
3508        fn prop_indent_preserves_line_count(
3509            text in arb_multiline_text(),
3510            find in arb_find_pattern(),
3511            amount in 1usize..=16,
3512        ) {
3513            let op = Op::Indent {
3514                find,
3515                amount,
3516                use_tabs: false,
3517                regex: false,
3518                case_insensitive: false,
3519            };
3520            let matcher = Matcher::new(&op).unwrap();
3521            let result = apply(&text, &op, &matcher, None, 0).unwrap();
3522            if let Some(ref output) = result.text {
3523                let input_lines = text.lines().count();
3524                let output_lines = output.lines().count();
3525                prop_assert_eq!(
3526                    input_lines,
3527                    output_lines,
3528                    "Indent should preserve line count: input={} output={}",
3529                    input_lines,
3530                    output_lines
3531                );
3532            }
3533        }
3534
3535        /// **Adversarial**: Line numbers recorded in `changes` must be
3536        /// 1-indexed, strictly within the input's line bounds, and in
3537        /// ascending order. A violation would break agent tooling that
3538        /// depends on line numbers to navigate output.
3539        #[test]
3540        fn prop_change_lines_ascending_and_in_bounds(
3541            text in arb_multiline_text(),
3542            find in arb_find_pattern(),
3543            replace in "[a-zA-Z0-9]{0,8}",
3544        ) {
3545            let op = Op::Replace {
3546                count: Default::default(),
3547                multiline: false,
3548                find,
3549                replace,
3550                regex: false,
3551                case_insensitive: false,
3552            };
3553            let matcher = Matcher::new(&op).unwrap();
3554            let result = apply(&text, &op, &matcher, None, 0).unwrap();
3555            let max_line = text.lines().count();
3556            let mut prev: usize = 0;
3557            for change in &result.changes {
3558                prop_assert!(change.line >= 1, "Line numbers must be 1-indexed, got {}", change.line);
3559                prop_assert!(
3560                    change.line <= max_line,
3561                    "Line {} out of input range (max {})",
3562                    change.line,
3563                    max_line
3564                );
3565                prop_assert!(
3566                    change.line > prev,
3567                    "Changes must be strictly ascending by line: prev={} current={}",
3568                    prev,
3569                    change.line
3570                );
3571                prev = change.line;
3572            }
3573        }
3574
3575        /// **Adversarial**: Delete should reduce line count by exactly
3576        /// the number of matching lines. If the engine ever deletes one
3577        /// line too many or too few, this will catch it.
3578        #[test]
3579        fn prop_delete_exact_line_count(
3580            // Find must be alphanumeric; we use pure-punctuation filler lines
3581            // (guaranteed disjoint from the find character class) to avoid
3582            // accidental matches in "non-matching" lines.
3583            find in arb_find_pattern(),
3584            match_count in 0usize..=8,
3585            nonmatch_count in 0usize..=8,
3586        ) {
3587            let match_lines: Vec<String> = (0..match_count)
3588                .map(|_| format!("-- {} --", find))
3589                .collect();
3590            // Filler chars are punctuation only — cannot contain any char
3591            // from [a-zA-Z0-9], so they cannot contain the find pattern.
3592            let nonmatch_lines: Vec<String> = (0..nonmatch_count)
3593                .map(|_| "--- filler ---".to_string())
3594                .collect();
3595            // Assert our filler hypothesis before running the engine.
3596            for l in &nonmatch_lines {
3597                prop_assume!(!l.contains(&find));
3598            }
3599            // Interleave deterministically.
3600            let mut merged = Vec::with_capacity(match_count + nonmatch_count);
3601            let mut mi = match_lines.iter();
3602            let mut ni = nonmatch_lines.iter();
3603            loop {
3604                let m = mi.next();
3605                let n = ni.next();
3606                if m.is_none() && n.is_none() { break; }
3607                if let Some(m) = m { merged.push(m.clone()); }
3608                if let Some(n) = n { merged.push(n.clone()); }
3609            }
3610            if merged.is_empty() {
3611                // Degenerate: no lines at all — nothing interesting to test.
3612                return Ok(());
3613            }
3614            let text = merged.join("\n") + "\n";
3615
3616            let op = Op::Delete {
3617                multiline: false,
3618                find: find.clone(),
3619                regex: false,
3620                case_insensitive: false,
3621            };
3622            let matcher = Matcher::new(&op).unwrap();
3623            let result = apply(&text, &op, &matcher, None, 0).unwrap();
3624
3625            let expected_deletions: usize = text.lines().filter(|l| l.contains(&find)).count();
3626            prop_assert_eq!(
3627                expected_deletions,
3628                match_count,
3629                "test construction bug: matcher-count must equal match_count"
3630            );
3631
3632            if expected_deletions == 0 {
3633                prop_assert!(result.text.is_none());
3634                prop_assert!(result.changes.is_empty());
3635            } else {
3636                let input_lines = text.lines().count();
3637                let output_lines = result.text.as_ref().unwrap().lines().count();
3638                prop_assert_eq!(
3639                    input_lines - expected_deletions,
3640                    output_lines,
3641                    "Delete removed wrong number of lines: expected {} - {} = {}, got {}",
3642                    input_lines,
3643                    expected_deletions,
3644                    input_lines - expected_deletions,
3645                    output_lines
3646                );
3647                prop_assert_eq!(result.changes.len(), expected_deletions);
3648            }
3649        }
3650
3651        /// **Adversarial**: CRLF-majority input produces CRLF-majority output
3652        /// under any Replace that does not embed newlines. Regression guard
3653        /// for the uses_crlf majority-vote logic.
3654        #[test]
3655        fn prop_crlf_majority_preserved(
3656            find in "[a-zA-Z]{1,6}",
3657            replace in "[a-zA-Z]{0,6}",
3658        ) {
3659            // Build heavily-CRLF text with enough matches that the replace
3660            // doesn't turn into a no-op.
3661            let text = format!(
3662                "line {find} one\r\n{find} middle\r\nanother {find} here\r\nending\r\n"
3663            );
3664            let op = Op::Replace {
3665                count: Default::default(),
3666                multiline: false,
3667                find: find.clone(),
3668                replace,
3669                regex: false,
3670                case_insensitive: false,
3671            };
3672            let matcher = Matcher::new(&op).unwrap();
3673            let result = apply(&text, &op, &matcher, None, 0).unwrap();
3674            if let Some(ref output) = result.text {
3675                let crlf = output.matches("\r\n").count();
3676                let bare_lf = output.matches('\n').count() - crlf;
3677                prop_assert!(
3678                    crlf > bare_lf,
3679                    "CRLF majority lost: {crlf} CRLF vs {bare_lf} bare LF in {output:?}"
3680                );
3681            }
3682        }
3683
3684        /// **Adversarial**: The count of recorded changes under Replace must
3685        /// equal the number of input lines that contain the find pattern.
3686        /// (Replace changes at most one line per match-line, because replacements
3687        /// are intra-line.) This catches double-counting and missed matches.
3688        #[test]
3689        fn prop_replace_change_count_matches_containing_lines(
3690            find in arb_find_pattern(),
3691            replace in "[a-zA-Z]{0,8}",
3692        ) {
3693            // Build a text with a known, nonzero number of lines containing the pattern.
3694            let text = format!(
3695                "head\n{find} one\nmiddle\n{find} two {find}\ntail\n"
3696            );
3697            let op = Op::Replace {
3698                count: Default::default(),
3699                multiline: false,
3700                find: find.clone(),
3701                replace,
3702                regex: false,
3703                case_insensitive: false,
3704            };
3705            let matcher = Matcher::new(&op).unwrap();
3706            let result = apply(&text, &op, &matcher, None, 0).unwrap();
3707
3708            let expected: usize = text.lines().filter(|l| l.contains(&find)).count();
3709            prop_assert_eq!(result.changes.len(), expected);
3710        }
3711
3712        /// **Adversarial**: Text with no trailing newline must remain without
3713        /// a trailing newline after any Replace operation, even if the
3714        /// final line is modified. Regression guard for file-end handling.
3715        #[test]
3716        fn prop_no_trailing_newline_preserved(
3717            find in "[a-zA-Z]{1,6}",
3718            replace in "[a-zA-Z]{1,6}",
3719        ) {
3720            // Build text WITHOUT a trailing newline, with the pattern on the
3721            // last line so replacement happens there.
3722            let text = format!("first line\nlast line with {find}");
3723            prop_assume!(!text.ends_with('\n'));
3724
3725            let op = Op::Replace {
3726                count: Default::default(),
3727                multiline: false,
3728                find,
3729                replace,
3730                regex: false,
3731                case_insensitive: false,
3732            };
3733            let matcher = Matcher::new(&op).unwrap();
3734            let result = apply(&text, &op, &matcher, None, 0).unwrap();
3735            if let Some(ref output) = result.text {
3736                prop_assert!(
3737                    !output.ends_with('\n'),
3738                    "Spurious trailing newline added to {output:?} (input had none)"
3739                );
3740            }
3741        }
3742    }
3743}