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