Skip to main content

ripsed_core/
engine.rs

1use crate::diff::{Change, ChangeContext, FileChanges, OpResult};
2use crate::error::RipsedError;
3use crate::matcher::Matcher;
4use crate::operation::{LineRange, Op, 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/// Detect whether the text predominantly uses CRLF line endings.
36///
37/// Uses a majority-vote heuristic: counts CRLF (`\r\n`) vs bare LF (`\n`)
38/// occurrences and returns `true` only when CRLF is strictly more common.
39/// When counts are equal (including zero), prefers LF as the more portable
40/// default.  This prevents a file with mixed line endings from being
41/// silently normalized to all-CRLF.
42fn uses_crlf(text: &str) -> bool {
43    let crlf_count = text.matches("\r\n").count();
44    let lf_count = text.matches('\n').count() - crlf_count;
45    crlf_count > lf_count
46}
47
48// ---------------------------------------------------------------------------
49// Per-operation helper functions
50//
51// Each helper inspects one line and returns a `LineAction` indicating what the
52// main `apply()` loop should do.  Keeping the logic in dedicated functions
53// makes it easy to add new `Op` variants without bloating `apply()`.
54// ---------------------------------------------------------------------------
55
56/// Shared context passed to each per-line operation helper.
57struct LineCtx<'a> {
58    line: &'a str,
59    line_num: usize,
60    matcher: &'a Matcher,
61    lines: &'a [&'a str],
62    idx: usize,
63    context_lines: usize,
64}
65
66impl LineCtx<'_> {
67    fn build_context(&self) -> ChangeContext {
68        build_context(self.lines, self.idx, self.context_lines)
69    }
70}
71
72/// Handle `Op::Replace` — substitute matched text within the line.
73fn apply_replace(cx: &LineCtx, replace: &str) -> LineAction {
74    if let Some(replaced) = cx.matcher.replace(cx.line, replace) {
75        LineAction::Replaced {
76            new_line: replaced.clone(),
77            change: Change {
78                line: cx.line_num,
79                before: cx.line.to_string(),
80                after: Some(replaced),
81                context: Some(cx.build_context()),
82            },
83        }
84    } else {
85        LineAction::Unchanged
86    }
87}
88
89/// Handle `Op::Delete` — remove the line entirely if matched.
90fn apply_delete(cx: &LineCtx) -> LineAction {
91    if cx.matcher.is_match(cx.line) {
92        LineAction::Deleted {
93            change: Change {
94                line: cx.line_num,
95                before: cx.line.to_string(),
96                after: None,
97                context: Some(cx.build_context()),
98            },
99        }
100    } else {
101        LineAction::Unchanged
102    }
103}
104
105/// Handle `Op::InsertAfter` — insert new content after a matched line.
106fn apply_insert_after(cx: &LineCtx, content: &str) -> LineAction {
107    if cx.matcher.is_match(cx.line) {
108        LineAction::InsertedAfter {
109            content: content.to_string(),
110            change: Change {
111                line: cx.line_num,
112                before: cx.line.to_string(),
113                after: Some(format!("{}\n{content}", cx.line)),
114                context: Some(cx.build_context()),
115            },
116        }
117    } else {
118        LineAction::Unchanged
119    }
120}
121
122/// Handle `Op::InsertBefore` — insert new content before a matched line.
123fn apply_insert_before(cx: &LineCtx, content: &str) -> LineAction {
124    if cx.matcher.is_match(cx.line) {
125        LineAction::InsertedBefore {
126            content: content.to_string(),
127            change: Change {
128                line: cx.line_num,
129                before: cx.line.to_string(),
130                after: Some(format!("{content}\n{}", cx.line)),
131                context: Some(cx.build_context()),
132            },
133        }
134    } else {
135        LineAction::Unchanged
136    }
137}
138
139/// Handle `Op::ReplaceLine` — replace the entire line with new content.
140fn apply_replace_line(cx: &LineCtx, content: &str) -> LineAction {
141    if cx.matcher.is_match(cx.line) {
142        LineAction::Replaced {
143            new_line: content.to_string(),
144            change: Change {
145                line: cx.line_num,
146                before: cx.line.to_string(),
147                after: Some(content.to_string()),
148                context: Some(cx.build_context()),
149            },
150        }
151    } else {
152        LineAction::Unchanged
153    }
154}
155
156/// Handle `Op::Transform` — apply a case/naming transformation to matched text.
157fn apply_transform_op(cx: &LineCtx, mode: TransformMode) -> LineAction {
158    if cx.matcher.is_match(cx.line) {
159        let new_line = apply_transform(cx.line, cx.matcher, mode);
160        if new_line != cx.line {
161            LineAction::Replaced {
162                new_line: new_line.clone(),
163                change: Change {
164                    line: cx.line_num,
165                    before: cx.line.to_string(),
166                    after: Some(new_line),
167                    context: Some(cx.build_context()),
168                },
169            }
170        } else {
171            LineAction::Unchanged
172        }
173    } else {
174        LineAction::Unchanged
175    }
176}
177
178/// Handle `Op::Surround` — wrap matched lines with a prefix and suffix.
179fn apply_surround(cx: &LineCtx, prefix: &str, suffix: &str) -> LineAction {
180    if cx.matcher.is_match(cx.line) {
181        let new_line = format!("{prefix}{}{suffix}", cx.line);
182        if new_line != cx.line {
183            LineAction::Replaced {
184                new_line: new_line.clone(),
185                change: Change {
186                    line: cx.line_num,
187                    before: cx.line.to_string(),
188                    after: Some(new_line),
189                    context: Some(cx.build_context()),
190                },
191            }
192        } else {
193            LineAction::Unchanged
194        }
195    } else {
196        LineAction::Unchanged
197    }
198}
199
200/// Handle `Op::Indent` — prepend whitespace to matched lines.
201fn apply_indent(cx: &LineCtx, amount: usize, use_tabs: bool) -> LineAction {
202    if cx.matcher.is_match(cx.line) {
203        let indent = if use_tabs {
204            "\t".repeat(amount)
205        } else {
206            " ".repeat(amount)
207        };
208        let new_line = format!("{indent}{}", cx.line);
209        if new_line != cx.line {
210            LineAction::Replaced {
211                new_line: new_line.clone(),
212                change: Change {
213                    line: cx.line_num,
214                    before: cx.line.to_string(),
215                    after: Some(new_line),
216                    context: Some(cx.build_context()),
217                },
218            }
219        } else {
220            LineAction::Unchanged
221        }
222    } else {
223        LineAction::Unchanged
224    }
225}
226
227/// Handle `Op::Dedent` — remove leading whitespace from matched lines.
228fn apply_dedent(cx: &LineCtx, amount: usize, use_tabs: bool) -> LineAction {
229    if cx.matcher.is_match(cx.line) {
230        let new_line = dedent_line(cx.line, amount, use_tabs);
231        if new_line != cx.line {
232            LineAction::Replaced {
233                new_line: new_line.clone(),
234                change: Change {
235                    line: cx.line_num,
236                    before: cx.line.to_string(),
237                    after: Some(new_line),
238                    context: Some(cx.build_context()),
239                },
240            }
241        } else {
242            LineAction::Unchanged
243        }
244    } else {
245        LineAction::Unchanged
246    }
247}
248
249/// Apply a single operation to a text buffer.
250///
251/// Returns the modified text and a structured diff.
252/// If `dry_run` is true, the text is computed but flagged as preview-only.
253pub fn apply(
254    text: &str,
255    op: &Op,
256    matcher: &Matcher,
257    line_range: Option<LineRange>,
258    context_lines: usize,
259) -> Result<EngineOutput, RipsedError> {
260    let crlf = uses_crlf(text);
261    let lines: Vec<&str> = text.lines().collect();
262    let mut result_lines: Vec<String> = Vec::with_capacity(lines.len());
263    let mut changes: Vec<Change> = Vec::new();
264
265    for (idx, &line) in lines.iter().enumerate() {
266        let line_num = idx + 1; // 1-indexed
267
268        // Skip lines outside the line range
269        if let Some(range) = line_range {
270            if !range.contains(line_num) {
271                result_lines.push(line.to_string());
272                continue;
273            }
274        }
275
276        let cx = LineCtx {
277            line,
278            line_num,
279            matcher,
280            lines: &lines,
281            idx,
282            context_lines,
283        };
284
285        let action = match op {
286            Op::Replace { replace, .. } => apply_replace(&cx, replace),
287            Op::Delete { .. } => apply_delete(&cx),
288            Op::InsertAfter { content, .. } => apply_insert_after(&cx, content),
289            Op::InsertBefore { content, .. } => apply_insert_before(&cx, content),
290            Op::ReplaceLine { content, .. } => apply_replace_line(&cx, content),
291            Op::Transform { mode, .. } => apply_transform_op(&cx, *mode),
292            Op::Surround { prefix, suffix, .. } => apply_surround(&cx, prefix, suffix),
293            Op::Indent {
294                amount, use_tabs, ..
295            } => apply_indent(&cx, *amount, *use_tabs),
296            Op::Dedent {
297                amount, use_tabs, ..
298            } => apply_dedent(&cx, *amount, *use_tabs),
299        };
300
301        match action {
302            LineAction::Unchanged => {
303                result_lines.push(line.to_string());
304            }
305            LineAction::Replaced { new_line, change } => {
306                changes.push(change);
307                result_lines.push(new_line);
308            }
309            LineAction::Deleted { change } => {
310                changes.push(change);
311                // Don't push — line is deleted
312            }
313            LineAction::InsertedAfter { content, change } => {
314                result_lines.push(line.to_string());
315                changes.push(change);
316                result_lines.push(content);
317            }
318            LineAction::InsertedBefore { content, change } => {
319                changes.push(change);
320                result_lines.push(content);
321                result_lines.push(line.to_string());
322            }
323        }
324    }
325
326    let line_sep = if crlf { "\r\n" } else { "\n" };
327    let modified_text = if changes.is_empty() {
328        None
329    } else {
330        // Preserve line ending style and trailing newline — but only when
331        // the result has content. If every line was deleted, the output is
332        // a genuinely empty file, not a file containing a single empty line.
333        let mut joined = result_lines.join(line_sep);
334        if !result_lines.is_empty() && (text.ends_with('\n') || text.ends_with("\r\n")) {
335            joined.push_str(line_sep);
336        }
337        Some(joined)
338    };
339
340    let undo = if !changes.is_empty() {
341        Some(UndoEntry {
342            original_text: text.to_string(),
343        })
344    } else {
345        None
346    };
347
348    Ok(EngineOutput {
349        text: modified_text,
350        changes,
351        undo,
352    })
353}
354
355/// Apply a text transformation to matched portions of a line.
356fn apply_transform(line: &str, matcher: &Matcher, mode: TransformMode) -> String {
357    match matcher {
358        Matcher::Literal { pattern, .. } => {
359            line.replace(pattern.as_str(), &transform_text(pattern, mode))
360        }
361        Matcher::Regex(re) => {
362            let result = re.replace_all(line, |caps: &regex::Captures| {
363                transform_text(&caps[0], mode)
364            });
365            result.into_owned()
366        }
367    }
368}
369
370/// Transform a text string according to the given mode.
371fn transform_text(text: &str, mode: TransformMode) -> String {
372    match mode {
373        TransformMode::Upper => text.to_uppercase(),
374        TransformMode::Lower => text.to_lowercase(),
375        TransformMode::Title => {
376            let mut result = String::with_capacity(text.len());
377            let mut capitalize_next = true;
378            for ch in text.chars() {
379                if ch.is_whitespace() || ch == '_' || ch == '-' {
380                    result.push(ch);
381                    capitalize_next = true;
382                } else if capitalize_next {
383                    for upper in ch.to_uppercase() {
384                        result.push(upper);
385                    }
386                    capitalize_next = false;
387                } else {
388                    result.push(ch);
389                }
390            }
391            result
392        }
393        TransformMode::SnakeCase => {
394            let mut result = String::with_capacity(text.len() + 4);
395            let mut prev_was_lower = false;
396            for ch in text.chars() {
397                if ch.is_uppercase() {
398                    if prev_was_lower {
399                        result.push('_');
400                    }
401                    for lower in ch.to_lowercase() {
402                        result.push(lower);
403                    }
404                    prev_was_lower = false;
405                } else if ch == '-' || ch == ' ' {
406                    result.push('_');
407                    prev_was_lower = false;
408                } else {
409                    result.push(ch);
410                    prev_was_lower = ch.is_lowercase();
411                }
412            }
413            result
414        }
415        TransformMode::CamelCase => {
416            let mut result = String::with_capacity(text.len());
417            let mut capitalize_next = false;
418            let mut first = true;
419            for ch in text.chars() {
420                if ch == '_' || ch == '-' || ch == ' ' {
421                    capitalize_next = true;
422                } else if capitalize_next {
423                    for upper in ch.to_uppercase() {
424                        result.push(upper);
425                    }
426                    capitalize_next = false;
427                } else if first {
428                    for lower in ch.to_lowercase() {
429                        result.push(lower);
430                    }
431                    first = false;
432                } else {
433                    result.push(ch);
434                    first = false;
435                }
436            }
437            result
438        }
439    }
440}
441
442/// Remove up to `amount` leading whitespace characters from a line.
443/// When `use_tabs` is true, strips leading tabs; otherwise strips leading spaces.
444fn dedent_line(line: &str, amount: usize, use_tabs: bool) -> String {
445    let ch = if use_tabs { '\t' } else { ' ' };
446    let leading = line.len() - line.trim_start_matches(ch).len();
447    let remove = leading.min(amount);
448    line[remove..].to_string()
449}
450
451fn build_context(lines: &[&str], idx: usize, context_lines: usize) -> ChangeContext {
452    let start = idx.saturating_sub(context_lines);
453    let end = (idx + context_lines + 1).min(lines.len());
454
455    let before = lines[start..idx].iter().map(|s| s.to_string()).collect();
456    let after = if idx + 1 < end {
457        lines[idx + 1..end].iter().map(|s| s.to_string()).collect()
458    } else {
459        vec![]
460    };
461
462    ChangeContext { before, after }
463}
464
465/// Build an OpResult from file-level changes.
466pub fn build_op_result(operation_index: usize, path: &str, changes: Vec<Change>) -> OpResult {
467    OpResult {
468        operation_index,
469        files: if changes.is_empty() {
470            vec![]
471        } else {
472            vec![FileChanges {
473                path: path.to_string(),
474                changes,
475            }]
476        },
477    }
478}
479
480#[cfg(test)]
481mod tests {
482    use super::*;
483    use crate::matcher::Matcher;
484
485    #[test]
486    fn test_simple_replace() {
487        let text = "hello world\nfoo bar\nhello again\n";
488        let op = Op::Replace {
489            find: "hello".to_string(),
490            replace: "hi".to_string(),
491            regex: false,
492            case_insensitive: false,
493        };
494        let matcher = Matcher::new(&op).unwrap();
495        let result = apply(text, &op, &matcher, None, 2).unwrap();
496        assert_eq!(result.text.unwrap(), "hi world\nfoo bar\nhi again\n");
497        assert_eq!(result.changes.len(), 2);
498    }
499
500    #[test]
501    fn test_delete_lines() {
502        let text = "keep\ndelete me\nkeep too\n";
503        let op = Op::Delete {
504            find: "delete".to_string(),
505            regex: false,
506            case_insensitive: false,
507        };
508        let matcher = Matcher::new(&op).unwrap();
509        let result = apply(text, &op, &matcher, None, 0).unwrap();
510        assert_eq!(result.text.unwrap(), "keep\nkeep too\n");
511    }
512
513    #[test]
514    fn test_no_changes() {
515        let text = "nothing matches here\n";
516        let op = Op::Replace {
517            find: "zzz".to_string(),
518            replace: "aaa".to_string(),
519            regex: false,
520            case_insensitive: false,
521        };
522        let matcher = Matcher::new(&op).unwrap();
523        let result = apply(text, &op, &matcher, None, 0).unwrap();
524        assert!(result.text.is_none());
525        assert!(result.changes.is_empty());
526    }
527
528    #[test]
529    fn test_line_range() {
530        let text = "line1\nline2\nline3\nline4\n";
531        let op = Op::Replace {
532            find: "line".to_string(),
533            replace: "row".to_string(),
534            regex: false,
535            case_insensitive: false,
536        };
537        let range = Some(LineRange {
538            start: 2,
539            end: Some(3),
540        });
541        let matcher = Matcher::new(&op).unwrap();
542        let result = apply(text, &op, &matcher, range, 0).unwrap();
543        assert_eq!(result.text.unwrap(), "line1\nrow2\nrow3\nline4\n");
544    }
545
546    // ---------------------------------------------------------------
547    // CRLF handling tests
548    // ---------------------------------------------------------------
549
550    #[test]
551    fn test_crlf_replace_preserves_crlf() {
552        let text = "hello world\r\nfoo bar\r\nhello again\r\n";
553        let op = Op::Replace {
554            find: "hello".to_string(),
555            replace: "hi".to_string(),
556            regex: false,
557            case_insensitive: false,
558        };
559        let matcher = Matcher::new(&op).unwrap();
560        let result = apply(text, &op, &matcher, None, 0).unwrap();
561        assert_eq!(result.text.unwrap(), "hi world\r\nfoo bar\r\nhi again\r\n");
562    }
563
564    #[test]
565    fn test_crlf_delete_preserves_crlf() {
566        let text = "keep\r\ndelete me\r\nkeep too\r\n";
567        let op = Op::Delete {
568            find: "delete".to_string(),
569            regex: false,
570            case_insensitive: false,
571        };
572        let matcher = Matcher::new(&op).unwrap();
573        let result = apply(text, &op, &matcher, None, 0).unwrap();
574        assert_eq!(result.text.unwrap(), "keep\r\nkeep too\r\n");
575    }
576
577    #[test]
578    fn test_crlf_no_trailing_newline() {
579        let text = "hello world\r\nfoo bar";
580        let op = Op::Replace {
581            find: "hello".to_string(),
582            replace: "hi".to_string(),
583            regex: false,
584            case_insensitive: false,
585        };
586        let matcher = Matcher::new(&op).unwrap();
587        let result = apply(text, &op, &matcher, None, 0).unwrap();
588        let output = result.text.unwrap();
589        assert_eq!(output, "hi world\r\nfoo bar");
590        // No trailing CRLF since original didn't have one
591        assert!(!output.ends_with("\r\n"));
592    }
593
594    #[test]
595    fn test_uses_crlf_detection() {
596        assert!(uses_crlf("a\r\nb\r\n"));
597        assert!(uses_crlf("a\r\n"));
598        assert!(!uses_crlf("a\nb\n"));
599        assert!(!uses_crlf("no newline at all"));
600        assert!(!uses_crlf(""));
601    }
602
603    // ---------------------------------------------------------------
604    // Edge-case tests
605    // ---------------------------------------------------------------
606
607    #[test]
608    fn test_empty_input_text() {
609        let text = "";
610        let op = Op::Replace {
611            find: "anything".to_string(),
612            replace: "something".to_string(),
613            regex: false,
614            case_insensitive: false,
615        };
616        let matcher = Matcher::new(&op).unwrap();
617        let result = apply(text, &op, &matcher, None, 0).unwrap();
618        assert!(result.text.is_none());
619        assert!(result.changes.is_empty());
620    }
621
622    #[test]
623    fn test_single_line_no_trailing_newline() {
624        let text = "hello world";
625        let op = Op::Replace {
626            find: "hello".to_string(),
627            replace: "hi".to_string(),
628            regex: false,
629            case_insensitive: false,
630        };
631        let matcher = Matcher::new(&op).unwrap();
632        let result = apply(text, &op, &matcher, None, 0).unwrap();
633        let output = result.text.unwrap();
634        assert_eq!(output, "hi world");
635        // Should NOT add a trailing newline that wasn't there
636        assert!(!output.ends_with('\n'));
637    }
638
639    #[test]
640    fn test_whitespace_only_lines() {
641        let text = "  \n\t\n   \t  \n";
642        let op = Op::Replace {
643            find: "\t".to_string(),
644            replace: "TAB".to_string(),
645            regex: false,
646            case_insensitive: false,
647        };
648        let matcher = Matcher::new(&op).unwrap();
649        let result = apply(text, &op, &matcher, None, 0).unwrap();
650        let output = result.text.unwrap();
651        assert!(output.contains("TAB"));
652        assert_eq!(result.changes.len(), 2); // lines 2 and 3 have tabs
653    }
654
655    #[test]
656    fn test_very_long_line() {
657        let long_word = "x".repeat(100_000);
658        let text = format!("before\n{long_word}\nafter\n");
659        let op = Op::Replace {
660            find: "x".to_string(),
661            replace: "y".to_string(),
662            regex: false,
663            case_insensitive: false,
664        };
665        let matcher = Matcher::new(&op).unwrap();
666        let result = apply(&text, &op, &matcher, None, 0).unwrap();
667        let output = result.text.unwrap();
668        let expected_long = "y".repeat(100_000);
669        assert!(output.contains(&expected_long));
670    }
671
672    #[test]
673    fn test_unicode_emoji() {
674        let text = "hello world\n";
675        let op = Op::Replace {
676            find: "world".to_string(),
677            replace: "\u{1F30D}".to_string(), // earth globe emoji
678            regex: false,
679            case_insensitive: false,
680        };
681        let matcher = Matcher::new(&op).unwrap();
682        let result = apply(text, &op, &matcher, None, 0).unwrap();
683        assert_eq!(result.text.unwrap(), "hello \u{1F30D}\n");
684    }
685
686    #[test]
687    fn test_unicode_cjk() {
688        let text = "\u{4F60}\u{597D}\u{4E16}\u{754C}\n"; // "hello world" in Chinese
689        let op = Op::Replace {
690            find: "\u{4E16}\u{754C}".to_string(),    // "world"
691            replace: "\u{5730}\u{7403}".to_string(), // "earth"
692            regex: false,
693            case_insensitive: false,
694        };
695        let matcher = Matcher::new(&op).unwrap();
696        let result = apply(text, &op, &matcher, None, 0).unwrap();
697        assert_eq!(result.text.unwrap(), "\u{4F60}\u{597D}\u{5730}\u{7403}\n");
698    }
699
700    #[test]
701    fn test_unicode_combining_characters() {
702        // e + combining acute accent = e-acute
703        let text = "caf\u{0065}\u{0301}\n";
704        let op = Op::Replace {
705            find: "caf\u{0065}\u{0301}".to_string(),
706            replace: "coffee".to_string(),
707            regex: false,
708            case_insensitive: false,
709        };
710        let matcher = Matcher::new(&op).unwrap();
711        let result = apply(text, &op, &matcher, None, 0).unwrap();
712        assert_eq!(result.text.unwrap(), "coffee\n");
713    }
714
715    #[test]
716    fn test_regex_special_chars_in_literal_mode() {
717        // In literal mode, regex metacharacters should be treated as literals
718        let text = "price is $10.00 (USD)\n";
719        let op = Op::Replace {
720            find: "$10.00".to_string(),
721            replace: "$20.00".to_string(),
722            regex: false,
723            case_insensitive: false,
724        };
725        let matcher = Matcher::new(&op).unwrap();
726        let result = apply(text, &op, &matcher, None, 0).unwrap();
727        assert_eq!(result.text.unwrap(), "price is $20.00 (USD)\n");
728    }
729
730    #[test]
731    fn test_overlapping_matches_in_single_line() {
732        // "aaa" with pattern "aa" — standard str::replace does non-overlapping left-to-right
733        let text = "aaa\n";
734        let op = Op::Replace {
735            find: "aa".to_string(),
736            replace: "b".to_string(),
737            regex: false,
738            case_insensitive: false,
739        };
740        let matcher = Matcher::new(&op).unwrap();
741        let result = apply(text, &op, &matcher, None, 0).unwrap();
742        // Rust's str::replace: "aaa".replace("aa", "b") == "ba"
743        assert_eq!(result.text.unwrap(), "ba\n");
744    }
745
746    #[test]
747    fn test_replace_line_count_preserved() {
748        let text = "line1\nline2\nline3\nline4\nline5\n";
749        let input_line_count = text.lines().count();
750        let op = Op::Replace {
751            find: "line".to_string(),
752            replace: "row".to_string(),
753            regex: false,
754            case_insensitive: false,
755        };
756        let matcher = Matcher::new(&op).unwrap();
757        let result = apply(text, &op, &matcher, None, 0).unwrap();
758        let output = result.text.unwrap();
759        let output_line_count = output.lines().count();
760        assert_eq!(input_line_count, output_line_count);
761    }
762
763    #[test]
764    fn test_replace_preserves_empty_result_on_non_match() {
765        // Pattern that exists nowhere in text
766        let text = "alpha\nbeta\ngamma\n";
767        let op = Op::Replace {
768            find: "zzzzzz".to_string(),
769            replace: "y".to_string(),
770            regex: false,
771            case_insensitive: false,
772        };
773        let matcher = Matcher::new(&op).unwrap();
774        let result = apply(text, &op, &matcher, None, 0).unwrap();
775        assert!(result.text.is_none());
776        assert!(result.undo.is_none());
777    }
778
779    #[test]
780    fn test_undo_entry_stores_original() {
781        let text = "hello\nworld\n";
782        let op = Op::Replace {
783            find: "hello".to_string(),
784            replace: "hi".to_string(),
785            regex: false,
786            case_insensitive: false,
787        };
788        let matcher = Matcher::new(&op).unwrap();
789        let result = apply(text, &op, &matcher, None, 0).unwrap();
790        let undo = result.undo.unwrap();
791        assert_eq!(undo.original_text, text);
792    }
793
794    #[test]
795    fn test_determinism_same_input_same_output() {
796        let text = "foo bar baz\nhello world\nfoo again\n";
797        let op = Op::Replace {
798            find: "foo".to_string(),
799            replace: "qux".to_string(),
800            regex: false,
801            case_insensitive: false,
802        };
803        let matcher = Matcher::new(&op).unwrap();
804        let r1 = apply(text, &op, &matcher, None, 0).unwrap();
805        let r2 = apply(text, &op, &matcher, None, 0).unwrap();
806        assert_eq!(r1.text, r2.text);
807        assert_eq!(r1.changes.len(), r2.changes.len());
808        for (c1, c2) in r1.changes.iter().zip(r2.changes.iter()) {
809            assert_eq!(c1, c2);
810        }
811    }
812
813    // ---------------------------------------------------------------
814    // Transform operation tests
815    // ---------------------------------------------------------------
816
817    #[test]
818    fn test_transform_upper() {
819        let text = "hello world\nfoo bar\n";
820        let op = Op::Transform {
821            find: "hello".to_string(),
822            mode: TransformMode::Upper,
823            regex: false,
824            case_insensitive: false,
825        };
826        let matcher = Matcher::new(&op).unwrap();
827        let result = apply(text, &op, &matcher, None, 0).unwrap();
828        assert_eq!(result.text.unwrap(), "HELLO world\nfoo bar\n");
829        assert_eq!(result.changes.len(), 1);
830        assert_eq!(result.changes[0].line, 1);
831    }
832
833    #[test]
834    fn test_transform_lower() {
835        let text = "HELLO WORLD\nFOO BAR\n";
836        let op = Op::Transform {
837            find: "HELLO".to_string(),
838            mode: TransformMode::Lower,
839            regex: false,
840            case_insensitive: false,
841        };
842        let matcher = Matcher::new(&op).unwrap();
843        let result = apply(text, &op, &matcher, None, 0).unwrap();
844        assert_eq!(result.text.unwrap(), "hello WORLD\nFOO BAR\n");
845        assert_eq!(result.changes.len(), 1);
846    }
847
848    #[test]
849    fn test_transform_noop_when_already_target_case() {
850        // Transforming already-lowercase text to Lower should produce no changes
851        let text = "hello world\nfoo bar\n";
852        let op = Op::Transform {
853            find: "hello".to_string(),
854            mode: TransformMode::Lower,
855            regex: false,
856            case_insensitive: false,
857        };
858        let matcher = Matcher::new(&op).unwrap();
859        let result = apply(text, &op, &matcher, None, 0).unwrap();
860        assert!(result.text.is_none(), "No text modification expected");
861        assert!(result.changes.is_empty(), "No changes expected");
862    }
863
864    #[test]
865    fn test_transform_title() {
866        let text = "hello world\nfoo bar\n";
867        let op = Op::Transform {
868            find: "hello world".to_string(),
869            mode: TransformMode::Title,
870            regex: false,
871            case_insensitive: false,
872        };
873        let matcher = Matcher::new(&op).unwrap();
874        let result = apply(text, &op, &matcher, None, 0).unwrap();
875        assert_eq!(result.text.unwrap(), "Hello World\nfoo bar\n");
876        assert_eq!(result.changes.len(), 1);
877    }
878
879    #[test]
880    fn test_transform_snake_case() {
881        let text = "let myVariable = 1;\nother line\n";
882        let op = Op::Transform {
883            find: "myVariable".to_string(),
884            mode: TransformMode::SnakeCase,
885            regex: false,
886            case_insensitive: false,
887        };
888        let matcher = Matcher::new(&op).unwrap();
889        let result = apply(text, &op, &matcher, None, 0).unwrap();
890        assert_eq!(result.text.unwrap(), "let my_variable = 1;\nother line\n");
891        assert_eq!(result.changes.len(), 1);
892    }
893
894    #[test]
895    fn test_transform_camel_case() {
896        let text = "let my_variable = 1;\nother line\n";
897        let op = Op::Transform {
898            find: "my_variable".to_string(),
899            mode: TransformMode::CamelCase,
900            regex: false,
901            case_insensitive: false,
902        };
903        let matcher = Matcher::new(&op).unwrap();
904        let result = apply(text, &op, &matcher, None, 0).unwrap();
905        assert_eq!(result.text.unwrap(), "let myVariable = 1;\nother line\n");
906        assert_eq!(result.changes.len(), 1);
907    }
908
909    #[test]
910    fn test_transform_upper_multiple_matches_on_line() {
911        let text = "hello and hello again\n";
912        let op = Op::Transform {
913            find: "hello".to_string(),
914            mode: TransformMode::Upper,
915            regex: false,
916            case_insensitive: false,
917        };
918        let matcher = Matcher::new(&op).unwrap();
919        let result = apply(text, &op, &matcher, None, 0).unwrap();
920        assert_eq!(result.text.unwrap(), "HELLO and HELLO again\n");
921    }
922
923    #[test]
924    fn test_transform_no_match() {
925        let text = "hello world\n";
926        let op = Op::Transform {
927            find: "zzz".to_string(),
928            mode: TransformMode::Upper,
929            regex: false,
930            case_insensitive: false,
931        };
932        let matcher = Matcher::new(&op).unwrap();
933        let result = apply(text, &op, &matcher, None, 0).unwrap();
934        assert!(result.text.is_none());
935        assert!(result.changes.is_empty());
936    }
937
938    #[test]
939    fn test_transform_empty_text() {
940        let text = "";
941        let op = Op::Transform {
942            find: "anything".to_string(),
943            mode: TransformMode::Upper,
944            regex: false,
945            case_insensitive: false,
946        };
947        let matcher = Matcher::new(&op).unwrap();
948        let result = apply(text, &op, &matcher, None, 0).unwrap();
949        assert!(result.text.is_none());
950        assert!(result.changes.is_empty());
951    }
952
953    #[test]
954    fn test_transform_with_regex() {
955        let text = "let fooBar = 1;\nlet bazQux = 2;\n";
956        let op = Op::Transform {
957            find: r"[a-z]+[A-Z]\w*".to_string(),
958            mode: TransformMode::SnakeCase,
959            regex: true,
960            case_insensitive: false,
961        };
962        let matcher = Matcher::new(&op).unwrap();
963        let result = apply(text, &op, &matcher, None, 0).unwrap();
964        let output = result.text.unwrap();
965        assert!(output.contains("foo_bar"));
966        assert!(output.contains("baz_qux"));
967        assert_eq!(result.changes.len(), 2);
968    }
969
970    #[test]
971    fn test_transform_case_insensitive() {
972        let text = "Hello HELLO hello\n";
973        let op = Op::Transform {
974            find: "hello".to_string(),
975            mode: TransformMode::Upper,
976            regex: false,
977            case_insensitive: true,
978        };
979        let matcher = Matcher::new(&op).unwrap();
980        let result = apply(text, &op, &matcher, None, 0).unwrap();
981        assert_eq!(result.text.unwrap(), "HELLO HELLO HELLO\n");
982    }
983
984    #[test]
985    fn test_transform_crlf_preserved() {
986        let text = "hello world\r\nfoo bar\r\n";
987        let op = Op::Transform {
988            find: "hello".to_string(),
989            mode: TransformMode::Upper,
990            regex: false,
991            case_insensitive: false,
992        };
993        let matcher = Matcher::new(&op).unwrap();
994        let result = apply(text, &op, &matcher, None, 0).unwrap();
995        assert_eq!(result.text.unwrap(), "HELLO world\r\nfoo bar\r\n");
996    }
997
998    #[test]
999    fn test_transform_with_line_range() {
1000        let text = "hello\nhello\nhello\nhello\n";
1001        let op = Op::Transform {
1002            find: "hello".to_string(),
1003            mode: TransformMode::Upper,
1004            regex: false,
1005            case_insensitive: false,
1006        };
1007        let range = Some(LineRange {
1008            start: 2,
1009            end: Some(3),
1010        });
1011        let matcher = Matcher::new(&op).unwrap();
1012        let result = apply(text, &op, &matcher, range, 0).unwrap();
1013        assert_eq!(result.text.unwrap(), "hello\nHELLO\nHELLO\nhello\n");
1014        assert_eq!(result.changes.len(), 2);
1015    }
1016
1017    #[test]
1018    fn test_transform_title_with_underscores() {
1019        let text = "my_func_name\n";
1020        let op = Op::Transform {
1021            find: "my_func_name".to_string(),
1022            mode: TransformMode::Title,
1023            regex: false,
1024            case_insensitive: false,
1025        };
1026        let matcher = Matcher::new(&op).unwrap();
1027        let result = apply(text, &op, &matcher, None, 0).unwrap();
1028        // Title case capitalizes after underscores
1029        assert_eq!(result.text.unwrap(), "My_Func_Name\n");
1030    }
1031
1032    #[test]
1033    fn test_transform_snake_case_from_multi_word() {
1034        let text = "my-kebab-case\n";
1035        let op = Op::Transform {
1036            find: "my-kebab-case".to_string(),
1037            mode: TransformMode::SnakeCase,
1038            regex: false,
1039            case_insensitive: false,
1040        };
1041        let matcher = Matcher::new(&op).unwrap();
1042        let result = apply(text, &op, &matcher, None, 0).unwrap();
1043        assert_eq!(result.text.unwrap(), "my_kebab_case\n");
1044    }
1045
1046    #[test]
1047    fn test_transform_camel_case_from_snake() {
1048        let text = "my_var_name\n";
1049        let op = Op::Transform {
1050            find: "my_var_name".to_string(),
1051            mode: TransformMode::CamelCase,
1052            regex: false,
1053            case_insensitive: false,
1054        };
1055        let matcher = Matcher::new(&op).unwrap();
1056        let result = apply(text, &op, &matcher, None, 0).unwrap();
1057        assert_eq!(result.text.unwrap(), "myVarName\n");
1058    }
1059
1060    #[test]
1061    fn test_transform_camel_case_from_kebab() {
1062        let text = "my-var-name\n";
1063        let op = Op::Transform {
1064            find: "my-var-name".to_string(),
1065            mode: TransformMode::CamelCase,
1066            regex: false,
1067            case_insensitive: false,
1068        };
1069        let matcher = Matcher::new(&op).unwrap();
1070        let result = apply(text, &op, &matcher, None, 0).unwrap();
1071        assert_eq!(result.text.unwrap(), "myVarName\n");
1072    }
1073
1074    // ---------------------------------------------------------------
1075    // Surround operation tests
1076    // ---------------------------------------------------------------
1077
1078    #[test]
1079    fn test_surround_basic() {
1080        let text = "hello world\nfoo bar\n";
1081        let op = Op::Surround {
1082            find: "hello".to_string(),
1083            prefix: "<<".to_string(),
1084            suffix: ">>".to_string(),
1085            regex: false,
1086            case_insensitive: false,
1087        };
1088        let matcher = Matcher::new(&op).unwrap();
1089        let result = apply(text, &op, &matcher, None, 0).unwrap();
1090        assert_eq!(result.text.unwrap(), "<<hello world>>\nfoo bar\n");
1091        assert_eq!(result.changes.len(), 1);
1092        assert_eq!(result.changes[0].line, 1);
1093    }
1094
1095    #[test]
1096    fn test_surround_multiple_lines() {
1097        let text = "foo line 1\nbar line 2\nfoo line 3\n";
1098        let op = Op::Surround {
1099            find: "foo".to_string(),
1100            prefix: "[".to_string(),
1101            suffix: "]".to_string(),
1102            regex: false,
1103            case_insensitive: false,
1104        };
1105        let matcher = Matcher::new(&op).unwrap();
1106        let result = apply(text, &op, &matcher, None, 0).unwrap();
1107        assert_eq!(
1108            result.text.unwrap(),
1109            "[foo line 1]\nbar line 2\n[foo line 3]\n"
1110        );
1111        assert_eq!(result.changes.len(), 2);
1112    }
1113
1114    #[test]
1115    fn test_surround_no_match() {
1116        let text = "hello world\n";
1117        let op = Op::Surround {
1118            find: "zzz".to_string(),
1119            prefix: "<".to_string(),
1120            suffix: ">".to_string(),
1121            regex: false,
1122            case_insensitive: false,
1123        };
1124        let matcher = Matcher::new(&op).unwrap();
1125        let result = apply(text, &op, &matcher, None, 0).unwrap();
1126        assert!(result.text.is_none());
1127        assert!(result.changes.is_empty());
1128    }
1129
1130    #[test]
1131    fn test_surround_empty_text() {
1132        let text = "";
1133        let op = Op::Surround {
1134            find: "anything".to_string(),
1135            prefix: "<".to_string(),
1136            suffix: ">".to_string(),
1137            regex: false,
1138            case_insensitive: false,
1139        };
1140        let matcher = Matcher::new(&op).unwrap();
1141        let result = apply(text, &op, &matcher, None, 0).unwrap();
1142        assert!(result.text.is_none());
1143        assert!(result.changes.is_empty());
1144    }
1145
1146    #[test]
1147    fn test_surround_with_regex() {
1148        let text = "fn main() {\n    let x = 1;\n}\n";
1149        let op = Op::Surround {
1150            find: r"fn\s+\w+".to_string(),
1151            prefix: "/* ".to_string(),
1152            suffix: " */".to_string(),
1153            regex: true,
1154            case_insensitive: false,
1155        };
1156        let matcher = Matcher::new(&op).unwrap();
1157        let result = apply(text, &op, &matcher, None, 0).unwrap();
1158        assert_eq!(
1159            result.text.unwrap(),
1160            "/* fn main() { */\n    let x = 1;\n}\n"
1161        );
1162    }
1163
1164    #[test]
1165    fn test_surround_case_insensitive() {
1166        let text = "Hello world\nhello world\nHELLO world\n";
1167        let op = Op::Surround {
1168            find: "hello".to_string(),
1169            prefix: "(".to_string(),
1170            suffix: ")".to_string(),
1171            regex: false,
1172            case_insensitive: true,
1173        };
1174        let matcher = Matcher::new(&op).unwrap();
1175        let result = apply(text, &op, &matcher, None, 0).unwrap();
1176        let output = result.text.unwrap();
1177        assert_eq!(output, "(Hello world)\n(hello world)\n(HELLO world)\n");
1178        assert_eq!(result.changes.len(), 3);
1179    }
1180
1181    #[test]
1182    fn test_surround_crlf_preserved() {
1183        let text = "hello world\r\nfoo bar\r\n";
1184        let op = Op::Surround {
1185            find: "hello".to_string(),
1186            prefix: "[".to_string(),
1187            suffix: "]".to_string(),
1188            regex: false,
1189            case_insensitive: false,
1190        };
1191        let matcher = Matcher::new(&op).unwrap();
1192        let result = apply(text, &op, &matcher, None, 0).unwrap();
1193        assert_eq!(result.text.unwrap(), "[hello world]\r\nfoo bar\r\n");
1194    }
1195
1196    #[test]
1197    fn test_surround_with_line_range() {
1198        let text = "foo\nfoo\nfoo\nfoo\n";
1199        let op = Op::Surround {
1200            find: "foo".to_string(),
1201            prefix: "<".to_string(),
1202            suffix: ">".to_string(),
1203            regex: false,
1204            case_insensitive: false,
1205        };
1206        let range = Some(LineRange {
1207            start: 2,
1208            end: Some(3),
1209        });
1210        let matcher = Matcher::new(&op).unwrap();
1211        let result = apply(text, &op, &matcher, range, 0).unwrap();
1212        assert_eq!(result.text.unwrap(), "foo\n<foo>\n<foo>\nfoo\n");
1213        assert_eq!(result.changes.len(), 2);
1214    }
1215
1216    #[test]
1217    fn test_surround_with_empty_prefix_and_suffix() {
1218        let text = "hello world\n";
1219        let op = Op::Surround {
1220            find: "hello".to_string(),
1221            prefix: String::new(),
1222            suffix: String::new(),
1223            regex: false,
1224            case_insensitive: false,
1225        };
1226        let matcher = Matcher::new(&op).unwrap();
1227        let result = apply(text, &op, &matcher, None, 0).unwrap();
1228        // Surround with empty prefix and suffix is a no-op — no change recorded.
1229        assert!(result.text.is_none());
1230        assert!(result.changes.is_empty());
1231    }
1232
1233    // ---------------------------------------------------------------
1234    // Indent operation tests
1235    // ---------------------------------------------------------------
1236
1237    #[test]
1238    fn test_indent_basic() {
1239        let text = "hello\nworld\n";
1240        let op = Op::Indent {
1241            find: "hello".to_string(),
1242            amount: 4,
1243            use_tabs: false,
1244            regex: false,
1245            case_insensitive: false,
1246        };
1247        let matcher = Matcher::new(&op).unwrap();
1248        let result = apply(text, &op, &matcher, None, 0).unwrap();
1249        assert_eq!(result.text.unwrap(), "    hello\nworld\n");
1250        assert_eq!(result.changes.len(), 1);
1251    }
1252
1253    #[test]
1254    fn test_indent_multiple_lines() {
1255        let text = "foo line 1\nbar line 2\nfoo line 3\n";
1256        let op = Op::Indent {
1257            find: "foo".to_string(),
1258            amount: 2,
1259            use_tabs: false,
1260            regex: false,
1261            case_insensitive: false,
1262        };
1263        let matcher = Matcher::new(&op).unwrap();
1264        let result = apply(text, &op, &matcher, None, 0).unwrap();
1265        assert_eq!(
1266            result.text.unwrap(),
1267            "  foo line 1\nbar line 2\n  foo line 3\n"
1268        );
1269        assert_eq!(result.changes.len(), 2);
1270    }
1271
1272    #[test]
1273    fn test_indent_with_tabs() {
1274        let text = "hello\nworld\n";
1275        let op = Op::Indent {
1276            find: "hello".to_string(),
1277            amount: 2,
1278            use_tabs: true,
1279            regex: false,
1280            case_insensitive: false,
1281        };
1282        let matcher = Matcher::new(&op).unwrap();
1283        let result = apply(text, &op, &matcher, None, 0).unwrap();
1284        assert_eq!(result.text.unwrap(), "\t\thello\nworld\n");
1285    }
1286
1287    #[test]
1288    fn test_indent_no_match() {
1289        let text = "hello world\n";
1290        let op = Op::Indent {
1291            find: "zzz".to_string(),
1292            amount: 4,
1293            use_tabs: false,
1294            regex: false,
1295            case_insensitive: false,
1296        };
1297        let matcher = Matcher::new(&op).unwrap();
1298        let result = apply(text, &op, &matcher, None, 0).unwrap();
1299        assert!(result.text.is_none());
1300        assert!(result.changes.is_empty());
1301    }
1302
1303    #[test]
1304    fn test_indent_empty_text() {
1305        let text = "";
1306        let op = Op::Indent {
1307            find: "anything".to_string(),
1308            amount: 4,
1309            use_tabs: false,
1310            regex: false,
1311            case_insensitive: false,
1312        };
1313        let matcher = Matcher::new(&op).unwrap();
1314        let result = apply(text, &op, &matcher, None, 0).unwrap();
1315        assert!(result.text.is_none());
1316        assert!(result.changes.is_empty());
1317    }
1318
1319    #[test]
1320    fn test_indent_zero_amount() {
1321        let text = "hello\n";
1322        let op = Op::Indent {
1323            find: "hello".to_string(),
1324            amount: 0,
1325            use_tabs: false,
1326            regex: false,
1327            case_insensitive: false,
1328        };
1329        let matcher = Matcher::new(&op).unwrap();
1330        let result = apply(text, &op, &matcher, None, 0).unwrap();
1331        // Indent by 0 is a no-op — no change recorded.
1332        assert!(result.text.is_none());
1333        assert!(result.changes.is_empty());
1334    }
1335
1336    #[test]
1337    fn test_indent_with_regex() {
1338        let text = "fn main() {\nlet x = 1;\n}\n";
1339        let op = Op::Indent {
1340            find: r"let\s+".to_string(),
1341            amount: 4,
1342            use_tabs: false,
1343            regex: true,
1344            case_insensitive: false,
1345        };
1346        let matcher = Matcher::new(&op).unwrap();
1347        let result = apply(text, &op, &matcher, None, 0).unwrap();
1348        assert_eq!(result.text.unwrap(), "fn main() {\n    let x = 1;\n}\n");
1349        assert_eq!(result.changes.len(), 1);
1350    }
1351
1352    #[test]
1353    fn test_indent_case_insensitive() {
1354        let text = "Hello\nhello\nHELLO\n";
1355        let op = Op::Indent {
1356            find: "hello".to_string(),
1357            amount: 2,
1358            use_tabs: false,
1359            regex: false,
1360            case_insensitive: true,
1361        };
1362        let matcher = Matcher::new(&op).unwrap();
1363        let result = apply(text, &op, &matcher, None, 0).unwrap();
1364        assert_eq!(result.text.unwrap(), "  Hello\n  hello\n  HELLO\n");
1365        assert_eq!(result.changes.len(), 3);
1366    }
1367
1368    #[test]
1369    fn test_indent_crlf_preserved() {
1370        let text = "hello\r\nworld\r\n";
1371        let op = Op::Indent {
1372            find: "hello".to_string(),
1373            amount: 4,
1374            use_tabs: false,
1375            regex: false,
1376            case_insensitive: false,
1377        };
1378        let matcher = Matcher::new(&op).unwrap();
1379        let result = apply(text, &op, &matcher, None, 0).unwrap();
1380        assert_eq!(result.text.unwrap(), "    hello\r\nworld\r\n");
1381    }
1382
1383    #[test]
1384    fn test_indent_with_line_range() {
1385        let text = "foo\nfoo\nfoo\nfoo\n";
1386        let op = Op::Indent {
1387            find: "foo".to_string(),
1388            amount: 4,
1389            use_tabs: false,
1390            regex: false,
1391            case_insensitive: false,
1392        };
1393        let range = Some(LineRange {
1394            start: 2,
1395            end: Some(3),
1396        });
1397        let matcher = Matcher::new(&op).unwrap();
1398        let result = apply(text, &op, &matcher, range, 0).unwrap();
1399        assert_eq!(result.text.unwrap(), "foo\n    foo\n    foo\nfoo\n");
1400        assert_eq!(result.changes.len(), 2);
1401    }
1402
1403    // ---------------------------------------------------------------
1404    // Dedent operation tests
1405    // ---------------------------------------------------------------
1406
1407    #[test]
1408    fn test_dedent_basic() {
1409        let text = "    hello\nworld\n";
1410        let op = Op::Dedent {
1411            find: "hello".to_string(),
1412            amount: 4,
1413            use_tabs: false,
1414            regex: false,
1415            case_insensitive: false,
1416        };
1417        let matcher = Matcher::new(&op).unwrap();
1418        let result = apply(text, &op, &matcher, None, 0).unwrap();
1419        assert_eq!(result.text.unwrap(), "hello\nworld\n");
1420        assert_eq!(result.changes.len(), 1);
1421    }
1422
1423    #[test]
1424    fn test_dedent_partial() {
1425        // Only 2 spaces of leading whitespace, dedent by 4 should remove only 2
1426        let text = "  hello\n";
1427        let op = Op::Dedent {
1428            find: "hello".to_string(),
1429            amount: 4,
1430            use_tabs: false,
1431            regex: false,
1432            case_insensitive: false,
1433        };
1434        let matcher = Matcher::new(&op).unwrap();
1435        let result = apply(text, &op, &matcher, None, 0).unwrap();
1436        assert_eq!(result.text.unwrap(), "hello\n");
1437    }
1438
1439    #[test]
1440    fn test_dedent_no_leading_spaces() {
1441        // Line matches but has no leading spaces -- nothing to remove
1442        let text = "hello\n";
1443        let op = Op::Dedent {
1444            find: "hello".to_string(),
1445            amount: 4,
1446            use_tabs: false,
1447            regex: false,
1448            case_insensitive: false,
1449        };
1450        let matcher = Matcher::new(&op).unwrap();
1451        let result = apply(text, &op, &matcher, None, 0).unwrap();
1452        // No actual change because line has no leading spaces
1453        assert!(result.text.is_none());
1454        assert!(result.changes.is_empty());
1455    }
1456
1457    #[test]
1458    fn test_dedent_multiple_lines() {
1459        let text = "    foo line 1\n    bar line 2\n    foo line 3\n";
1460        let op = Op::Dedent {
1461            find: "foo".to_string(),
1462            amount: 4,
1463            use_tabs: false,
1464            regex: false,
1465            case_insensitive: false,
1466        };
1467        let matcher = Matcher::new(&op).unwrap();
1468        let result = apply(text, &op, &matcher, None, 0).unwrap();
1469        assert_eq!(
1470            result.text.unwrap(),
1471            "foo line 1\n    bar line 2\nfoo line 3\n"
1472        );
1473        assert_eq!(result.changes.len(), 2);
1474    }
1475
1476    #[test]
1477    fn test_dedent_no_match() {
1478        let text = "    hello world\n";
1479        let op = Op::Dedent {
1480            find: "zzz".to_string(),
1481            amount: 4,
1482            use_tabs: false,
1483            regex: false,
1484            case_insensitive: false,
1485        };
1486        let matcher = Matcher::new(&op).unwrap();
1487        let result = apply(text, &op, &matcher, None, 0).unwrap();
1488        assert!(result.text.is_none());
1489        assert!(result.changes.is_empty());
1490    }
1491
1492    #[test]
1493    fn test_dedent_empty_text() {
1494        let text = "";
1495        let op = Op::Dedent {
1496            find: "anything".to_string(),
1497            amount: 4,
1498            use_tabs: false,
1499            regex: false,
1500            case_insensitive: false,
1501        };
1502        let matcher = Matcher::new(&op).unwrap();
1503        let result = apply(text, &op, &matcher, None, 0).unwrap();
1504        assert!(result.text.is_none());
1505        assert!(result.changes.is_empty());
1506    }
1507
1508    #[test]
1509    fn test_dedent_with_regex() {
1510        let text = "    let x = 1;\n    fn main() {\n";
1511        let op = Op::Dedent {
1512            find: r"let\s+".to_string(),
1513            amount: 4,
1514            use_tabs: false,
1515            regex: true,
1516            case_insensitive: false,
1517        };
1518        let matcher = Matcher::new(&op).unwrap();
1519        let result = apply(text, &op, &matcher, None, 0).unwrap();
1520        assert_eq!(result.text.unwrap(), "let x = 1;\n    fn main() {\n");
1521        assert_eq!(result.changes.len(), 1);
1522    }
1523
1524    #[test]
1525    fn test_dedent_case_insensitive() {
1526        let text = "    Hello\n    hello\n    HELLO\n";
1527        let op = Op::Dedent {
1528            find: "hello".to_string(),
1529            amount: 2,
1530            use_tabs: false,
1531            regex: false,
1532            case_insensitive: true,
1533        };
1534        let matcher = Matcher::new(&op).unwrap();
1535        let result = apply(text, &op, &matcher, None, 0).unwrap();
1536        assert_eq!(result.text.unwrap(), "  Hello\n  hello\n  HELLO\n");
1537        assert_eq!(result.changes.len(), 3);
1538    }
1539
1540    #[test]
1541    fn test_dedent_crlf_preserved() {
1542        let text = "    hello\r\nworld\r\n";
1543        let op = Op::Dedent {
1544            find: "hello".to_string(),
1545            amount: 4,
1546            use_tabs: false,
1547            regex: false,
1548            case_insensitive: false,
1549        };
1550        let matcher = Matcher::new(&op).unwrap();
1551        let result = apply(text, &op, &matcher, None, 0).unwrap();
1552        assert_eq!(result.text.unwrap(), "hello\r\nworld\r\n");
1553    }
1554
1555    #[test]
1556    fn test_dedent_with_line_range() {
1557        let text = "    foo\n    foo\n    foo\n    foo\n";
1558        let op = Op::Dedent {
1559            find: "foo".to_string(),
1560            amount: 4,
1561            use_tabs: false,
1562            regex: false,
1563            case_insensitive: false,
1564        };
1565        let range = Some(LineRange {
1566            start: 2,
1567            end: Some(3),
1568        });
1569        let matcher = Matcher::new(&op).unwrap();
1570        let result = apply(text, &op, &matcher, range, 0).unwrap();
1571        assert_eq!(result.text.unwrap(), "    foo\nfoo\nfoo\n    foo\n");
1572        assert_eq!(result.changes.len(), 2);
1573    }
1574
1575    #[test]
1576    fn test_dedent_only_removes_spaces_not_tabs() {
1577        // Dedent only strips leading spaces, not tabs
1578        let text = "\t\thello\n";
1579        let op = Op::Dedent {
1580            find: "hello".to_string(),
1581            amount: 4,
1582            use_tabs: false,
1583            regex: false,
1584            case_insensitive: false,
1585        };
1586        let matcher = Matcher::new(&op).unwrap();
1587        let result = apply(text, &op, &matcher, None, 0).unwrap();
1588        // The dedent_line function only strips spaces (trim_start_matches(' ')),
1589        // tabs are not removed.
1590        assert!(result.text.is_none());
1591    }
1592
1593    // ---------------------------------------------------------------
1594    // Indent then Dedent roundtrip
1595    // ---------------------------------------------------------------
1596
1597    #[test]
1598    fn test_indent_then_dedent_roundtrip() {
1599        let original = "hello world\nfoo bar\n";
1600
1601        // Step 1: Indent by 4
1602        let indent_op = Op::Indent {
1603            find: "hello".to_string(),
1604            amount: 4,
1605            use_tabs: false,
1606            regex: false,
1607            case_insensitive: false,
1608        };
1609        let indent_matcher = Matcher::new(&indent_op).unwrap();
1610        let indented = apply(original, &indent_op, &indent_matcher, None, 0).unwrap();
1611        let indented_text = indented.text.unwrap();
1612        assert_eq!(indented_text, "    hello world\nfoo bar\n");
1613
1614        // Step 2: Dedent by 4 (the find still matches because "hello" is in the line)
1615        let dedent_op = Op::Dedent {
1616            find: "hello".to_string(),
1617            amount: 4,
1618            use_tabs: false,
1619            regex: false,
1620            case_insensitive: false,
1621        };
1622        let dedent_matcher = Matcher::new(&dedent_op).unwrap();
1623        let dedented = apply(&indented_text, &dedent_op, &dedent_matcher, None, 0).unwrap();
1624        assert_eq!(dedented.text.unwrap(), original);
1625    }
1626
1627    // ---------------------------------------------------------------
1628    // Undo entry tests for new ops
1629    // ---------------------------------------------------------------
1630
1631    #[test]
1632    fn test_transform_undo_stores_original() {
1633        let text = "hello world\n";
1634        let op = Op::Transform {
1635            find: "hello".to_string(),
1636            mode: TransformMode::Upper,
1637            regex: false,
1638            case_insensitive: false,
1639        };
1640        let matcher = Matcher::new(&op).unwrap();
1641        let result = apply(text, &op, &matcher, None, 0).unwrap();
1642        assert_eq!(result.undo.unwrap().original_text, text);
1643    }
1644
1645    #[test]
1646    fn test_surround_undo_stores_original() {
1647        let text = "hello world\n";
1648        let op = Op::Surround {
1649            find: "hello".to_string(),
1650            prefix: "<".to_string(),
1651            suffix: ">".to_string(),
1652            regex: false,
1653            case_insensitive: false,
1654        };
1655        let matcher = Matcher::new(&op).unwrap();
1656        let result = apply(text, &op, &matcher, None, 0).unwrap();
1657        assert_eq!(result.undo.unwrap().original_text, text);
1658    }
1659
1660    #[test]
1661    fn test_indent_undo_stores_original() {
1662        let text = "hello world\n";
1663        let op = Op::Indent {
1664            find: "hello".to_string(),
1665            amount: 4,
1666            use_tabs: false,
1667            regex: false,
1668            case_insensitive: false,
1669        };
1670        let matcher = Matcher::new(&op).unwrap();
1671        let result = apply(text, &op, &matcher, None, 0).unwrap();
1672        assert_eq!(result.undo.unwrap().original_text, text);
1673    }
1674
1675    #[test]
1676    fn test_dedent_undo_stores_original() {
1677        let text = "    hello world\n";
1678        let op = Op::Dedent {
1679            find: "hello".to_string(),
1680            amount: 4,
1681            use_tabs: false,
1682            regex: false,
1683            case_insensitive: false,
1684        };
1685        let matcher = Matcher::new(&op).unwrap();
1686        let result = apply(text, &op, &matcher, None, 0).unwrap();
1687        assert_eq!(result.undo.unwrap().original_text, text);
1688    }
1689
1690    // ---------------------------------------------------------------
1691    // Line preservation tests for new ops
1692    // ---------------------------------------------------------------
1693
1694    #[test]
1695    fn test_transform_preserves_line_count() {
1696        let text = "hello\nworld\nfoo\n";
1697        let op = Op::Transform {
1698            find: "hello".to_string(),
1699            mode: TransformMode::Upper,
1700            regex: false,
1701            case_insensitive: false,
1702        };
1703        let matcher = Matcher::new(&op).unwrap();
1704        let result = apply(text, &op, &matcher, None, 0).unwrap();
1705        let output = result.text.unwrap();
1706        assert_eq!(text.lines().count(), output.lines().count());
1707    }
1708
1709    #[test]
1710    fn test_surround_preserves_line_count() {
1711        let text = "hello\nworld\nfoo\n";
1712        let op = Op::Surround {
1713            find: "hello".to_string(),
1714            prefix: "<".to_string(),
1715            suffix: ">".to_string(),
1716            regex: false,
1717            case_insensitive: false,
1718        };
1719        let matcher = Matcher::new(&op).unwrap();
1720        let result = apply(text, &op, &matcher, None, 0).unwrap();
1721        let output = result.text.unwrap();
1722        assert_eq!(text.lines().count(), output.lines().count());
1723    }
1724
1725    #[test]
1726    fn test_indent_preserves_line_count() {
1727        let text = "hello\nworld\nfoo\n";
1728        let op = Op::Indent {
1729            find: "hello".to_string(),
1730            amount: 4,
1731            use_tabs: false,
1732            regex: false,
1733            case_insensitive: false,
1734        };
1735        let matcher = Matcher::new(&op).unwrap();
1736        let result = apply(text, &op, &matcher, None, 0).unwrap();
1737        let output = result.text.unwrap();
1738        assert_eq!(text.lines().count(), output.lines().count());
1739    }
1740
1741    #[test]
1742    fn test_dedent_preserves_line_count() {
1743        let text = "    hello\n    world\n    foo\n";
1744        let op = Op::Dedent {
1745            find: "hello".to_string(),
1746            amount: 4,
1747            use_tabs: false,
1748            regex: false,
1749            case_insensitive: false,
1750        };
1751        let matcher = Matcher::new(&op).unwrap();
1752        let result = apply(text, &op, &matcher, None, 0).unwrap();
1753        let output = result.text.unwrap();
1754        assert_eq!(text.lines().count(), output.lines().count());
1755    }
1756
1757    // ---------------------------------------------------------------
1758    // Adversarial: line number correctness under odd line endings
1759    // ---------------------------------------------------------------
1760
1761    /// **Adversarial**: in a file with mixed line endings, the change
1762    /// reported for line 3 should correspond to the THIRD *logical line*
1763    /// (1-indexed), not a byte offset or a line under some alternative
1764    /// counting scheme. Agents use this line number to navigate.
1765    #[test]
1766    fn test_line_numbers_with_mixed_line_endings() {
1767        // Line 1 = "alpha" (LF)
1768        // Line 2 = "beta" (CRLF)
1769        // Line 3 = "gamma match" (LF) — this is where the change should be reported
1770        // Line 4 = "delta" (CRLF)
1771        let text = "alpha\nbeta\r\ngamma match\ndelta\r\n";
1772        let op = Op::Replace {
1773            find: "match".to_string(),
1774            replace: "HIT".to_string(),
1775            regex: false,
1776            case_insensitive: false,
1777        };
1778        let matcher = Matcher::new(&op).unwrap();
1779        let result = apply(text, &op, &matcher, None, 0).unwrap();
1780        assert_eq!(result.changes.len(), 1);
1781        assert_eq!(
1782            result.changes[0].line, 3,
1783            "Line number must be 3 regardless of the mixed CR/LF / CRLF endings above it; got {}",
1784            result.changes[0].line
1785        );
1786        assert_eq!(result.changes[0].before, "gamma match");
1787    }
1788
1789    /// **Adversarial**: a file consisting entirely of a single line without
1790    /// any trailing newline still has "line 1" — if that line matches, the
1791    /// reported change must be on line 1.
1792    #[test]
1793    fn test_line_number_for_single_line_no_newline() {
1794        let text = "only line matches here";
1795        let op = Op::Replace {
1796            find: "matches".to_string(),
1797            replace: "OK".to_string(),
1798            regex: false,
1799            case_insensitive: false,
1800        };
1801        let matcher = Matcher::new(&op).unwrap();
1802        let result = apply(text, &op, &matcher, None, 0).unwrap();
1803        assert_eq!(result.changes.len(), 1);
1804        assert_eq!(result.changes[0].line, 1);
1805    }
1806
1807    /// **Adversarial**: the first line of a file, when matched, must be
1808    /// reported as line 1, not line 0 (off-by-one regression guard).
1809    #[test]
1810    fn test_first_line_is_one_not_zero() {
1811        let text = "match first\nother\nother\n";
1812        let op = Op::Replace {
1813            find: "match".to_string(),
1814            replace: "X".to_string(),
1815            regex: false,
1816            case_insensitive: false,
1817        };
1818        let matcher = Matcher::new(&op).unwrap();
1819        let result = apply(text, &op, &matcher, None, 0).unwrap();
1820        assert_eq!(
1821            result.changes[0].line, 1,
1822            "First-line change must be reported as line 1, not 0"
1823        );
1824    }
1825
1826    /// **Adversarial**: when a Delete operation removes the middle line of
1827    /// a three-line file, the single reported change must reference the
1828    /// DELETED line's original line number (2), not the new position of
1829    /// the following line. Undo needs this invariant to restore correctly.
1830    #[test]
1831    fn test_delete_reports_original_line_number() {
1832        let text = "keep1\ndelete_me\nkeep2\n";
1833        let op = Op::Delete {
1834            find: "delete_me".to_string(),
1835            regex: false,
1836            case_insensitive: false,
1837        };
1838        let matcher = Matcher::new(&op).unwrap();
1839        let result = apply(text, &op, &matcher, None, 0).unwrap();
1840        assert_eq!(result.changes.len(), 1);
1841        assert_eq!(
1842            result.changes[0].line, 2,
1843            "Deleted line's reported line must be its original position (2)"
1844        );
1845        assert_eq!(result.changes[0].before, "delete_me");
1846        assert_eq!(result.changes[0].after, None);
1847    }
1848
1849    /// **Adversarial regression**: deleting the single line of a one-line
1850    /// file must produce an empty file (""), not a file containing one
1851    /// empty line ("\n"). The old engine preserved the trailing newline
1852    /// unconditionally, which inverted the POSIX "non-existent file vs.
1853    /// file with empty line" distinction — `wc -l` would say "1" for a
1854    /// file the user thought they had emptied.
1855    #[test]
1856    fn test_delete_all_lines_produces_empty_file() {
1857        let text = "only line\n";
1858        let op = Op::Delete {
1859            find: "only".to_string(),
1860            regex: false,
1861            case_insensitive: false,
1862        };
1863        let matcher = Matcher::new(&op).unwrap();
1864        let result = apply(text, &op, &matcher, None, 0).unwrap();
1865        let output = result.text.unwrap();
1866        assert_eq!(
1867            output, "",
1868            "Deleting every line must yield an empty file, got {output:?}"
1869        );
1870    }
1871
1872    /// Same invariant for CRLF input.
1873    #[test]
1874    fn test_delete_all_lines_crlf_produces_empty_file() {
1875        let text = "only line\r\n";
1876        let op = Op::Delete {
1877            find: "only".to_string(),
1878            regex: false,
1879            case_insensitive: false,
1880        };
1881        let matcher = Matcher::new(&op).unwrap();
1882        let result = apply(text, &op, &matcher, None, 0).unwrap();
1883        let output = result.text.unwrap();
1884        assert_eq!(
1885            output, "",
1886            "Deleting every CRLF line must yield an empty file"
1887        );
1888    }
1889}
1890
1891// ---------------------------------------------------------------
1892// Property-based tests (proptest)
1893// ---------------------------------------------------------------
1894#[cfg(test)]
1895mod proptests {
1896    use super::*;
1897    use crate::matcher::Matcher;
1898    use crate::operation::Op;
1899    use proptest::prelude::*;
1900
1901    /// Strategy for generating text that is multiple lines with a trailing newline.
1902    fn arb_multiline_text() -> impl Strategy<Value = String> {
1903        prop::collection::vec("[^\n\r]{0,80}", 1..10).prop_map(|lines| lines.join("\n") + "\n")
1904    }
1905
1906    /// Strategy for generating a non-empty find pattern (plain literal).
1907    fn arb_find_pattern() -> impl Strategy<Value = String> {
1908        "[a-zA-Z0-9]{1,8}"
1909    }
1910
1911    proptest! {
1912        /// Round-trip: applying a Replace then undoing (restoring original_text)
1913        /// should give back the original text.
1914        #[test]
1915        fn prop_roundtrip_undo(
1916            text in arb_multiline_text(),
1917            find in arb_find_pattern(),
1918            replace in "[a-zA-Z0-9]{0,8}",
1919        ) {
1920            let op = Op::Replace {
1921                find: find.clone(),
1922                replace: replace.clone(),
1923                regex: false,
1924                case_insensitive: false,
1925            };
1926            let matcher = Matcher::new(&op).unwrap();
1927            let result = apply(&text, &op, &matcher, None, 0).unwrap();
1928
1929            if let Some(undo) = &result.undo {
1930                // Undo should restore original text
1931                prop_assert_eq!(&undo.original_text, &text);
1932            }
1933            // If no changes, text should be None
1934            if result.text.is_none() {
1935                prop_assert!(result.changes.is_empty());
1936            }
1937        }
1938
1939        /// No-op: applying with a pattern that cannot match leaves text unchanged.
1940        #[test]
1941        fn prop_noop_nonmatching_pattern(text in arb_multiline_text()) {
1942            // Use a pattern with a NUL byte which will never appear in text generated
1943            // by arb_multiline_text
1944            let op = Op::Replace {
1945                find: "\x00\x00NOMATCH\x00\x00".to_string(),
1946                replace: "replacement".to_string(),
1947                regex: false,
1948                case_insensitive: false,
1949            };
1950            let matcher = Matcher::new(&op).unwrap();
1951            let result = apply(&text, &op, &matcher, None, 0).unwrap();
1952            prop_assert!(result.text.is_none(), "Non-matching pattern should not modify text");
1953            prop_assert!(result.changes.is_empty());
1954            prop_assert!(result.undo.is_none());
1955        }
1956
1957        /// Determinism: same input always produces same output.
1958        #[test]
1959        fn prop_deterministic(
1960            text in arb_multiline_text(),
1961            find in arb_find_pattern(),
1962            replace in "[a-zA-Z0-9]{0,8}",
1963        ) {
1964            let op = Op::Replace {
1965                find,
1966                replace,
1967                regex: false,
1968                case_insensitive: false,
1969            };
1970            let matcher = Matcher::new(&op).unwrap();
1971            let r1 = apply(&text, &op, &matcher, None, 0).unwrap();
1972            let r2 = apply(&text, &op, &matcher, None, 0).unwrap();
1973            prop_assert_eq!(&r1.text, &r2.text);
1974            prop_assert_eq!(r1.changes.len(), r2.changes.len());
1975        }
1976
1977        /// Line count: for Replace ops, output line count == input line count.
1978        #[test]
1979        fn prop_replace_preserves_line_count(
1980            text in arb_multiline_text(),
1981            find in arb_find_pattern(),
1982            replace in "[a-zA-Z0-9]{0,8}",
1983        ) {
1984            let op = Op::Replace {
1985                find,
1986                replace,
1987                regex: false,
1988                case_insensitive: false,
1989            };
1990            let matcher = Matcher::new(&op).unwrap();
1991            let result = apply(&text, &op, &matcher, None, 0).unwrap();
1992            if let Some(ref output) = result.text {
1993                let input_lines = text.lines().count();
1994                let output_lines = output.lines().count();
1995                prop_assert_eq!(
1996                    input_lines,
1997                    output_lines,
1998                    "Replace should preserve line count: input={} output={}",
1999                    input_lines,
2000                    output_lines
2001                );
2002            }
2003        }
2004
2005        /// Indent then Dedent by the same amount should restore the original text
2006        /// when every line contains the find pattern and starts with enough spaces.
2007        #[test]
2008        fn prop_indent_dedent_roundtrip(
2009            amount in 1usize..=16,
2010        ) {
2011            // Use a known find pattern that appears on every line
2012            let find = "marker".to_string();
2013            let text = "marker line one\nmarker line two\nmarker line three\n";
2014
2015            let indent_op = Op::Indent {
2016                find: find.clone(),
2017                amount,
2018                use_tabs: false,
2019                regex: false,
2020                case_insensitive: false,
2021            };
2022            let indent_matcher = Matcher::new(&indent_op).unwrap();
2023            let indented = apply(text, &indent_op, &indent_matcher, None, 0).unwrap();
2024            let indented_text = indented.text.unwrap();
2025
2026            // Every line should now start with `amount` spaces
2027            for line in indented_text.lines() {
2028                let leading = line.len() - line.trim_start_matches(' ').len();
2029                prop_assert!(leading >= amount, "Expected at least {} leading spaces, got {}", amount, leading);
2030            }
2031
2032            let dedent_op = Op::Dedent {
2033                find: find.clone(),
2034                amount,
2035                use_tabs: false,
2036                regex: false,
2037                case_insensitive: false,
2038            };
2039            let dedent_matcher = Matcher::new(&dedent_op).unwrap();
2040            let dedented = apply(&indented_text, &dedent_op, &dedent_matcher, None, 0).unwrap();
2041            prop_assert_eq!(dedented.text.unwrap(), text);
2042        }
2043
2044        /// Transform Upper then Lower should restore the original when
2045        /// the text is already all lowercase ASCII.
2046        #[test]
2047        fn prop_transform_upper_lower_roundtrip(
2048            find in "[a-z]{1,8}",
2049        ) {
2050            let text = format!("prefix {find} suffix\n");
2051
2052            let upper_op = Op::Transform {
2053                find: find.clone(),
2054                mode: crate::operation::TransformMode::Upper,
2055                regex: false,
2056                case_insensitive: false,
2057            };
2058            let upper_matcher = Matcher::new(&upper_op).unwrap();
2059            let uppered = apply(&text, &upper_op, &upper_matcher, None, 0).unwrap();
2060
2061            if let Some(ref upper_text) = uppered.text {
2062                let upper_find = find.to_uppercase();
2063                let lower_op = Op::Transform {
2064                    find: upper_find,
2065                    mode: crate::operation::TransformMode::Lower,
2066                    regex: false,
2067                    case_insensitive: false,
2068                };
2069                let lower_matcher = Matcher::new(&lower_op).unwrap();
2070                let lowered = apply(upper_text, &lower_op, &lower_matcher, None, 0).unwrap();
2071                prop_assert_eq!(lowered.text.unwrap(), text);
2072            }
2073        }
2074
2075        /// Surround preserves line count.
2076        #[test]
2077        fn prop_surround_preserves_line_count(
2078            text in arb_multiline_text(),
2079            find in arb_find_pattern(),
2080        ) {
2081            let op = Op::Surround {
2082                find,
2083                prefix: "<<".to_string(),
2084                suffix: ">>".to_string(),
2085                regex: false,
2086                case_insensitive: false,
2087            };
2088            let matcher = Matcher::new(&op).unwrap();
2089            let result = apply(&text, &op, &matcher, None, 0).unwrap();
2090            if let Some(ref output) = result.text {
2091                let input_lines = text.lines().count();
2092                let output_lines = output.lines().count();
2093                prop_assert_eq!(
2094                    input_lines,
2095                    output_lines,
2096                    "Surround should preserve line count: input={} output={}",
2097                    input_lines,
2098                    output_lines
2099                );
2100            }
2101        }
2102
2103        /// Transform preserves line count.
2104        #[test]
2105        fn prop_transform_preserves_line_count(
2106            text in arb_multiline_text(),
2107            find in arb_find_pattern(),
2108        ) {
2109            let op = Op::Transform {
2110                find,
2111                mode: crate::operation::TransformMode::Upper,
2112                regex: false,
2113                case_insensitive: false,
2114            };
2115            let matcher = Matcher::new(&op).unwrap();
2116            let result = apply(&text, &op, &matcher, None, 0).unwrap();
2117            if let Some(ref output) = result.text {
2118                let input_lines = text.lines().count();
2119                let output_lines = output.lines().count();
2120                prop_assert_eq!(
2121                    input_lines,
2122                    output_lines,
2123                    "Transform should preserve line count: input={} output={}",
2124                    input_lines,
2125                    output_lines
2126                );
2127            }
2128        }
2129
2130        /// Indent preserves line count.
2131        #[test]
2132        fn prop_indent_preserves_line_count(
2133            text in arb_multiline_text(),
2134            find in arb_find_pattern(),
2135            amount in 1usize..=16,
2136        ) {
2137            let op = Op::Indent {
2138                find,
2139                amount,
2140                use_tabs: false,
2141                regex: false,
2142                case_insensitive: false,
2143            };
2144            let matcher = Matcher::new(&op).unwrap();
2145            let result = apply(&text, &op, &matcher, None, 0).unwrap();
2146            if let Some(ref output) = result.text {
2147                let input_lines = text.lines().count();
2148                let output_lines = output.lines().count();
2149                prop_assert_eq!(
2150                    input_lines,
2151                    output_lines,
2152                    "Indent should preserve line count: input={} output={}",
2153                    input_lines,
2154                    output_lines
2155                );
2156            }
2157        }
2158
2159        /// **Adversarial**: Line numbers recorded in `changes` must be
2160        /// 1-indexed, strictly within the input's line bounds, and in
2161        /// ascending order. A violation would break agent tooling that
2162        /// depends on line numbers to navigate output.
2163        #[test]
2164        fn prop_change_lines_ascending_and_in_bounds(
2165            text in arb_multiline_text(),
2166            find in arb_find_pattern(),
2167            replace in "[a-zA-Z0-9]{0,8}",
2168        ) {
2169            let op = Op::Replace {
2170                find,
2171                replace,
2172                regex: false,
2173                case_insensitive: false,
2174            };
2175            let matcher = Matcher::new(&op).unwrap();
2176            let result = apply(&text, &op, &matcher, None, 0).unwrap();
2177            let max_line = text.lines().count();
2178            let mut prev: usize = 0;
2179            for change in &result.changes {
2180                prop_assert!(change.line >= 1, "Line numbers must be 1-indexed, got {}", change.line);
2181                prop_assert!(
2182                    change.line <= max_line,
2183                    "Line {} out of input range (max {})",
2184                    change.line,
2185                    max_line
2186                );
2187                prop_assert!(
2188                    change.line > prev,
2189                    "Changes must be strictly ascending by line: prev={} current={}",
2190                    prev,
2191                    change.line
2192                );
2193                prev = change.line;
2194            }
2195        }
2196
2197        /// **Adversarial**: Delete should reduce line count by exactly
2198        /// the number of matching lines. If the engine ever deletes one
2199        /// line too many or too few, this will catch it.
2200        #[test]
2201        fn prop_delete_exact_line_count(
2202            // Find must be alphanumeric; we use pure-punctuation filler lines
2203            // (guaranteed disjoint from the find character class) to avoid
2204            // accidental matches in "non-matching" lines.
2205            find in arb_find_pattern(),
2206            match_count in 0usize..=8,
2207            nonmatch_count in 0usize..=8,
2208        ) {
2209            let match_lines: Vec<String> = (0..match_count)
2210                .map(|_| format!("-- {} --", find))
2211                .collect();
2212            // Filler chars are punctuation only — cannot contain any char
2213            // from [a-zA-Z0-9], so they cannot contain the find pattern.
2214            let nonmatch_lines: Vec<String> = (0..nonmatch_count)
2215                .map(|_| "--- filler ---".to_string())
2216                .collect();
2217            // Assert our filler hypothesis before running the engine.
2218            for l in &nonmatch_lines {
2219                prop_assume!(!l.contains(&find));
2220            }
2221            // Interleave deterministically.
2222            let mut merged = Vec::with_capacity(match_count + nonmatch_count);
2223            let mut mi = match_lines.iter();
2224            let mut ni = nonmatch_lines.iter();
2225            loop {
2226                let m = mi.next();
2227                let n = ni.next();
2228                if m.is_none() && n.is_none() { break; }
2229                if let Some(m) = m { merged.push(m.clone()); }
2230                if let Some(n) = n { merged.push(n.clone()); }
2231            }
2232            if merged.is_empty() {
2233                // Degenerate: no lines at all — nothing interesting to test.
2234                return Ok(());
2235            }
2236            let text = merged.join("\n") + "\n";
2237
2238            let op = Op::Delete {
2239                find: find.clone(),
2240                regex: false,
2241                case_insensitive: false,
2242            };
2243            let matcher = Matcher::new(&op).unwrap();
2244            let result = apply(&text, &op, &matcher, None, 0).unwrap();
2245
2246            let expected_deletions: usize = text.lines().filter(|l| l.contains(&find)).count();
2247            prop_assert_eq!(
2248                expected_deletions,
2249                match_count,
2250                "test construction bug: matcher-count must equal match_count"
2251            );
2252
2253            if expected_deletions == 0 {
2254                prop_assert!(result.text.is_none());
2255                prop_assert!(result.changes.is_empty());
2256            } else {
2257                let input_lines = text.lines().count();
2258                let output_lines = result.text.as_ref().unwrap().lines().count();
2259                prop_assert_eq!(
2260                    input_lines - expected_deletions,
2261                    output_lines,
2262                    "Delete removed wrong number of lines: expected {} - {} = {}, got {}",
2263                    input_lines,
2264                    expected_deletions,
2265                    input_lines - expected_deletions,
2266                    output_lines
2267                );
2268                prop_assert_eq!(result.changes.len(), expected_deletions);
2269            }
2270        }
2271
2272        /// **Adversarial**: CRLF-majority input produces CRLF-majority output
2273        /// under any Replace that does not embed newlines. Regression guard
2274        /// for the uses_crlf majority-vote logic.
2275        #[test]
2276        fn prop_crlf_majority_preserved(
2277            find in "[a-zA-Z]{1,6}",
2278            replace in "[a-zA-Z]{0,6}",
2279        ) {
2280            // Build heavily-CRLF text with enough matches that the replace
2281            // doesn't turn into a no-op.
2282            let text = format!(
2283                "line {find} one\r\n{find} middle\r\nanother {find} here\r\nending\r\n"
2284            );
2285            let op = Op::Replace {
2286                find: find.clone(),
2287                replace,
2288                regex: false,
2289                case_insensitive: false,
2290            };
2291            let matcher = Matcher::new(&op).unwrap();
2292            let result = apply(&text, &op, &matcher, None, 0).unwrap();
2293            if let Some(ref output) = result.text {
2294                let crlf = output.matches("\r\n").count();
2295                let bare_lf = output.matches('\n').count() - crlf;
2296                prop_assert!(
2297                    crlf > bare_lf,
2298                    "CRLF majority lost: {crlf} CRLF vs {bare_lf} bare LF in {output:?}"
2299                );
2300            }
2301        }
2302
2303        /// **Adversarial**: The count of recorded changes under Replace must
2304        /// equal the number of input lines that contain the find pattern.
2305        /// (Replace changes at most one line per match-line, because replacements
2306        /// are intra-line.) This catches double-counting and missed matches.
2307        #[test]
2308        fn prop_replace_change_count_matches_containing_lines(
2309            find in arb_find_pattern(),
2310            replace in "[a-zA-Z]{0,8}",
2311        ) {
2312            // Build a text with a known, nonzero number of lines containing the pattern.
2313            let text = format!(
2314                "head\n{find} one\nmiddle\n{find} two {find}\ntail\n"
2315            );
2316            let op = Op::Replace {
2317                find: find.clone(),
2318                replace,
2319                regex: false,
2320                case_insensitive: false,
2321            };
2322            let matcher = Matcher::new(&op).unwrap();
2323            let result = apply(&text, &op, &matcher, None, 0).unwrap();
2324
2325            let expected: usize = text.lines().filter(|l| l.contains(&find)).count();
2326            prop_assert_eq!(result.changes.len(), expected);
2327        }
2328
2329        /// **Adversarial**: Text with no trailing newline must remain without
2330        /// a trailing newline after any Replace operation, even if the
2331        /// final line is modified. Regression guard for file-end handling.
2332        #[test]
2333        fn prop_no_trailing_newline_preserved(
2334            find in "[a-zA-Z]{1,6}",
2335            replace in "[a-zA-Z]{1,6}",
2336        ) {
2337            // Build text WITHOUT a trailing newline, with the pattern on the
2338            // last line so replacement happens there.
2339            let text = format!("first line\nlast line with {find}");
2340            prop_assume!(!text.ends_with('\n'));
2341
2342            let op = Op::Replace {
2343                find,
2344                replace,
2345                regex: false,
2346                case_insensitive: false,
2347            };
2348            let matcher = Matcher::new(&op).unwrap();
2349            let result = apply(&text, &op, &matcher, None, 0).unwrap();
2350            if let Some(ref output) = result.text {
2351                prop_assert!(
2352                    !output.ends_with('\n'),
2353                    "Spurious trailing newline added to {output:?} (input had none)"
2354                );
2355            }
2356        }
2357    }
2358}