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
331        let mut joined = result_lines.join(line_sep);
332        if text.ends_with('\n') || text.ends_with("\r\n") {
333            joined.push_str(line_sep);
334        }
335        Some(joined)
336    };
337
338    let undo = if !changes.is_empty() {
339        Some(UndoEntry {
340            original_text: text.to_string(),
341        })
342    } else {
343        None
344    };
345
346    Ok(EngineOutput {
347        text: modified_text,
348        changes,
349        undo,
350    })
351}
352
353/// Apply a text transformation to matched portions of a line.
354fn apply_transform(line: &str, matcher: &Matcher, mode: TransformMode) -> String {
355    match matcher {
356        Matcher::Literal { pattern, .. } => {
357            line.replace(pattern.as_str(), &transform_text(pattern, mode))
358        }
359        Matcher::Regex(re) => {
360            let result = re.replace_all(line, |caps: &regex::Captures| {
361                transform_text(&caps[0], mode)
362            });
363            result.into_owned()
364        }
365    }
366}
367
368/// Transform a text string according to the given mode.
369fn transform_text(text: &str, mode: TransformMode) -> String {
370    match mode {
371        TransformMode::Upper => text.to_uppercase(),
372        TransformMode::Lower => text.to_lowercase(),
373        TransformMode::Title => {
374            let mut result = String::with_capacity(text.len());
375            let mut capitalize_next = true;
376            for ch in text.chars() {
377                if ch.is_whitespace() || ch == '_' || ch == '-' {
378                    result.push(ch);
379                    capitalize_next = true;
380                } else if capitalize_next {
381                    for upper in ch.to_uppercase() {
382                        result.push(upper);
383                    }
384                    capitalize_next = false;
385                } else {
386                    result.push(ch);
387                }
388            }
389            result
390        }
391        TransformMode::SnakeCase => {
392            let mut result = String::with_capacity(text.len() + 4);
393            let mut prev_was_lower = false;
394            for ch in text.chars() {
395                if ch.is_uppercase() {
396                    if prev_was_lower {
397                        result.push('_');
398                    }
399                    for lower in ch.to_lowercase() {
400                        result.push(lower);
401                    }
402                    prev_was_lower = false;
403                } else if ch == '-' || ch == ' ' {
404                    result.push('_');
405                    prev_was_lower = false;
406                } else {
407                    result.push(ch);
408                    prev_was_lower = ch.is_lowercase();
409                }
410            }
411            result
412        }
413        TransformMode::CamelCase => {
414            let mut result = String::with_capacity(text.len());
415            let mut capitalize_next = false;
416            let mut first = true;
417            for ch in text.chars() {
418                if ch == '_' || ch == '-' || ch == ' ' {
419                    capitalize_next = true;
420                } else if capitalize_next {
421                    for upper in ch.to_uppercase() {
422                        result.push(upper);
423                    }
424                    capitalize_next = false;
425                } else if first {
426                    for lower in ch.to_lowercase() {
427                        result.push(lower);
428                    }
429                    first = false;
430                } else {
431                    result.push(ch);
432                    first = false;
433                }
434            }
435            result
436        }
437    }
438}
439
440/// Remove up to `amount` leading whitespace characters from a line.
441/// When `use_tabs` is true, strips leading tabs; otherwise strips leading spaces.
442fn dedent_line(line: &str, amount: usize, use_tabs: bool) -> String {
443    let ch = if use_tabs { '\t' } else { ' ' };
444    let leading = line.len() - line.trim_start_matches(ch).len();
445    let remove = leading.min(amount);
446    line[remove..].to_string()
447}
448
449fn build_context(lines: &[&str], idx: usize, context_lines: usize) -> ChangeContext {
450    let start = idx.saturating_sub(context_lines);
451    let end = (idx + context_lines + 1).min(lines.len());
452
453    let before = lines[start..idx].iter().map(|s| s.to_string()).collect();
454    let after = if idx + 1 < end {
455        lines[idx + 1..end].iter().map(|s| s.to_string()).collect()
456    } else {
457        vec![]
458    };
459
460    ChangeContext { before, after }
461}
462
463/// Build an OpResult from file-level changes.
464pub fn build_op_result(operation_index: usize, path: &str, changes: Vec<Change>) -> OpResult {
465    OpResult {
466        operation_index,
467        files: if changes.is_empty() {
468            vec![]
469        } else {
470            vec![FileChanges {
471                path: path.to_string(),
472                changes,
473            }]
474        },
475    }
476}
477
478#[cfg(test)]
479mod tests {
480    use super::*;
481    use crate::matcher::Matcher;
482
483    #[test]
484    fn test_simple_replace() {
485        let text = "hello world\nfoo bar\nhello again\n";
486        let op = Op::Replace {
487            find: "hello".to_string(),
488            replace: "hi".to_string(),
489            regex: false,
490            case_insensitive: false,
491        };
492        let matcher = Matcher::new(&op).unwrap();
493        let result = apply(text, &op, &matcher, None, 2).unwrap();
494        assert_eq!(result.text.unwrap(), "hi world\nfoo bar\nhi again\n");
495        assert_eq!(result.changes.len(), 2);
496    }
497
498    #[test]
499    fn test_delete_lines() {
500        let text = "keep\ndelete me\nkeep too\n";
501        let op = Op::Delete {
502            find: "delete".to_string(),
503            regex: false,
504            case_insensitive: false,
505        };
506        let matcher = Matcher::new(&op).unwrap();
507        let result = apply(text, &op, &matcher, None, 0).unwrap();
508        assert_eq!(result.text.unwrap(), "keep\nkeep too\n");
509    }
510
511    #[test]
512    fn test_no_changes() {
513        let text = "nothing matches here\n";
514        let op = Op::Replace {
515            find: "zzz".to_string(),
516            replace: "aaa".to_string(),
517            regex: false,
518            case_insensitive: false,
519        };
520        let matcher = Matcher::new(&op).unwrap();
521        let result = apply(text, &op, &matcher, None, 0).unwrap();
522        assert!(result.text.is_none());
523        assert!(result.changes.is_empty());
524    }
525
526    #[test]
527    fn test_line_range() {
528        let text = "line1\nline2\nline3\nline4\n";
529        let op = Op::Replace {
530            find: "line".to_string(),
531            replace: "row".to_string(),
532            regex: false,
533            case_insensitive: false,
534        };
535        let range = Some(LineRange {
536            start: 2,
537            end: Some(3),
538        });
539        let matcher = Matcher::new(&op).unwrap();
540        let result = apply(text, &op, &matcher, range, 0).unwrap();
541        assert_eq!(result.text.unwrap(), "line1\nrow2\nrow3\nline4\n");
542    }
543
544    // ---------------------------------------------------------------
545    // CRLF handling tests
546    // ---------------------------------------------------------------
547
548    #[test]
549    fn test_crlf_replace_preserves_crlf() {
550        let text = "hello world\r\nfoo bar\r\nhello again\r\n";
551        let op = Op::Replace {
552            find: "hello".to_string(),
553            replace: "hi".to_string(),
554            regex: false,
555            case_insensitive: false,
556        };
557        let matcher = Matcher::new(&op).unwrap();
558        let result = apply(text, &op, &matcher, None, 0).unwrap();
559        assert_eq!(result.text.unwrap(), "hi world\r\nfoo bar\r\nhi again\r\n");
560    }
561
562    #[test]
563    fn test_crlf_delete_preserves_crlf() {
564        let text = "keep\r\ndelete me\r\nkeep too\r\n";
565        let op = Op::Delete {
566            find: "delete".to_string(),
567            regex: false,
568            case_insensitive: false,
569        };
570        let matcher = Matcher::new(&op).unwrap();
571        let result = apply(text, &op, &matcher, None, 0).unwrap();
572        assert_eq!(result.text.unwrap(), "keep\r\nkeep too\r\n");
573    }
574
575    #[test]
576    fn test_crlf_no_trailing_newline() {
577        let text = "hello world\r\nfoo bar";
578        let op = Op::Replace {
579            find: "hello".to_string(),
580            replace: "hi".to_string(),
581            regex: false,
582            case_insensitive: false,
583        };
584        let matcher = Matcher::new(&op).unwrap();
585        let result = apply(text, &op, &matcher, None, 0).unwrap();
586        let output = result.text.unwrap();
587        assert_eq!(output, "hi world\r\nfoo bar");
588        // No trailing CRLF since original didn't have one
589        assert!(!output.ends_with("\r\n"));
590    }
591
592    #[test]
593    fn test_uses_crlf_detection() {
594        assert!(uses_crlf("a\r\nb\r\n"));
595        assert!(uses_crlf("a\r\n"));
596        assert!(!uses_crlf("a\nb\n"));
597        assert!(!uses_crlf("no newline at all"));
598        assert!(!uses_crlf(""));
599    }
600
601    // ---------------------------------------------------------------
602    // Edge-case tests
603    // ---------------------------------------------------------------
604
605    #[test]
606    fn test_empty_input_text() {
607        let text = "";
608        let op = Op::Replace {
609            find: "anything".to_string(),
610            replace: "something".to_string(),
611            regex: false,
612            case_insensitive: false,
613        };
614        let matcher = Matcher::new(&op).unwrap();
615        let result = apply(text, &op, &matcher, None, 0).unwrap();
616        assert!(result.text.is_none());
617        assert!(result.changes.is_empty());
618    }
619
620    #[test]
621    fn test_single_line_no_trailing_newline() {
622        let text = "hello world";
623        let op = Op::Replace {
624            find: "hello".to_string(),
625            replace: "hi".to_string(),
626            regex: false,
627            case_insensitive: false,
628        };
629        let matcher = Matcher::new(&op).unwrap();
630        let result = apply(text, &op, &matcher, None, 0).unwrap();
631        let output = result.text.unwrap();
632        assert_eq!(output, "hi world");
633        // Should NOT add a trailing newline that wasn't there
634        assert!(!output.ends_with('\n'));
635    }
636
637    #[test]
638    fn test_whitespace_only_lines() {
639        let text = "  \n\t\n   \t  \n";
640        let op = Op::Replace {
641            find: "\t".to_string(),
642            replace: "TAB".to_string(),
643            regex: false,
644            case_insensitive: false,
645        };
646        let matcher = Matcher::new(&op).unwrap();
647        let result = apply(text, &op, &matcher, None, 0).unwrap();
648        let output = result.text.unwrap();
649        assert!(output.contains("TAB"));
650        assert_eq!(result.changes.len(), 2); // lines 2 and 3 have tabs
651    }
652
653    #[test]
654    fn test_very_long_line() {
655        let long_word = "x".repeat(100_000);
656        let text = format!("before\n{long_word}\nafter\n");
657        let op = Op::Replace {
658            find: "x".to_string(),
659            replace: "y".to_string(),
660            regex: false,
661            case_insensitive: false,
662        };
663        let matcher = Matcher::new(&op).unwrap();
664        let result = apply(&text, &op, &matcher, None, 0).unwrap();
665        let output = result.text.unwrap();
666        let expected_long = "y".repeat(100_000);
667        assert!(output.contains(&expected_long));
668    }
669
670    #[test]
671    fn test_unicode_emoji() {
672        let text = "hello world\n";
673        let op = Op::Replace {
674            find: "world".to_string(),
675            replace: "\u{1F30D}".to_string(), // earth globe emoji
676            regex: false,
677            case_insensitive: false,
678        };
679        let matcher = Matcher::new(&op).unwrap();
680        let result = apply(text, &op, &matcher, None, 0).unwrap();
681        assert_eq!(result.text.unwrap(), "hello \u{1F30D}\n");
682    }
683
684    #[test]
685    fn test_unicode_cjk() {
686        let text = "\u{4F60}\u{597D}\u{4E16}\u{754C}\n"; // "hello world" in Chinese
687        let op = Op::Replace {
688            find: "\u{4E16}\u{754C}".to_string(),    // "world"
689            replace: "\u{5730}\u{7403}".to_string(), // "earth"
690            regex: false,
691            case_insensitive: false,
692        };
693        let matcher = Matcher::new(&op).unwrap();
694        let result = apply(text, &op, &matcher, None, 0).unwrap();
695        assert_eq!(result.text.unwrap(), "\u{4F60}\u{597D}\u{5730}\u{7403}\n");
696    }
697
698    #[test]
699    fn test_unicode_combining_characters() {
700        // e + combining acute accent = e-acute
701        let text = "caf\u{0065}\u{0301}\n";
702        let op = Op::Replace {
703            find: "caf\u{0065}\u{0301}".to_string(),
704            replace: "coffee".to_string(),
705            regex: false,
706            case_insensitive: false,
707        };
708        let matcher = Matcher::new(&op).unwrap();
709        let result = apply(text, &op, &matcher, None, 0).unwrap();
710        assert_eq!(result.text.unwrap(), "coffee\n");
711    }
712
713    #[test]
714    fn test_regex_special_chars_in_literal_mode() {
715        // In literal mode, regex metacharacters should be treated as literals
716        let text = "price is $10.00 (USD)\n";
717        let op = Op::Replace {
718            find: "$10.00".to_string(),
719            replace: "$20.00".to_string(),
720            regex: false,
721            case_insensitive: false,
722        };
723        let matcher = Matcher::new(&op).unwrap();
724        let result = apply(text, &op, &matcher, None, 0).unwrap();
725        assert_eq!(result.text.unwrap(), "price is $20.00 (USD)\n");
726    }
727
728    #[test]
729    fn test_overlapping_matches_in_single_line() {
730        // "aaa" with pattern "aa" — standard str::replace does non-overlapping left-to-right
731        let text = "aaa\n";
732        let op = Op::Replace {
733            find: "aa".to_string(),
734            replace: "b".to_string(),
735            regex: false,
736            case_insensitive: false,
737        };
738        let matcher = Matcher::new(&op).unwrap();
739        let result = apply(text, &op, &matcher, None, 0).unwrap();
740        // Rust's str::replace: "aaa".replace("aa", "b") == "ba"
741        assert_eq!(result.text.unwrap(), "ba\n");
742    }
743
744    #[test]
745    fn test_replace_line_count_preserved() {
746        let text = "line1\nline2\nline3\nline4\nline5\n";
747        let input_line_count = text.lines().count();
748        let op = Op::Replace {
749            find: "line".to_string(),
750            replace: "row".to_string(),
751            regex: false,
752            case_insensitive: false,
753        };
754        let matcher = Matcher::new(&op).unwrap();
755        let result = apply(text, &op, &matcher, None, 0).unwrap();
756        let output = result.text.unwrap();
757        let output_line_count = output.lines().count();
758        assert_eq!(input_line_count, output_line_count);
759    }
760
761    #[test]
762    fn test_replace_preserves_empty_result_on_non_match() {
763        // Pattern that exists nowhere in text
764        let text = "alpha\nbeta\ngamma\n";
765        let op = Op::Replace {
766            find: "zzzzzz".to_string(),
767            replace: "y".to_string(),
768            regex: false,
769            case_insensitive: false,
770        };
771        let matcher = Matcher::new(&op).unwrap();
772        let result = apply(text, &op, &matcher, None, 0).unwrap();
773        assert!(result.text.is_none());
774        assert!(result.undo.is_none());
775    }
776
777    #[test]
778    fn test_undo_entry_stores_original() {
779        let text = "hello\nworld\n";
780        let op = Op::Replace {
781            find: "hello".to_string(),
782            replace: "hi".to_string(),
783            regex: false,
784            case_insensitive: false,
785        };
786        let matcher = Matcher::new(&op).unwrap();
787        let result = apply(text, &op, &matcher, None, 0).unwrap();
788        let undo = result.undo.unwrap();
789        assert_eq!(undo.original_text, text);
790    }
791
792    #[test]
793    fn test_determinism_same_input_same_output() {
794        let text = "foo bar baz\nhello world\nfoo again\n";
795        let op = Op::Replace {
796            find: "foo".to_string(),
797            replace: "qux".to_string(),
798            regex: false,
799            case_insensitive: false,
800        };
801        let matcher = Matcher::new(&op).unwrap();
802        let r1 = apply(text, &op, &matcher, None, 0).unwrap();
803        let r2 = apply(text, &op, &matcher, None, 0).unwrap();
804        assert_eq!(r1.text, r2.text);
805        assert_eq!(r1.changes.len(), r2.changes.len());
806        for (c1, c2) in r1.changes.iter().zip(r2.changes.iter()) {
807            assert_eq!(c1, c2);
808        }
809    }
810
811    // ---------------------------------------------------------------
812    // Transform operation tests
813    // ---------------------------------------------------------------
814
815    #[test]
816    fn test_transform_upper() {
817        let text = "hello world\nfoo bar\n";
818        let op = Op::Transform {
819            find: "hello".to_string(),
820            mode: TransformMode::Upper,
821            regex: false,
822            case_insensitive: false,
823        };
824        let matcher = Matcher::new(&op).unwrap();
825        let result = apply(text, &op, &matcher, None, 0).unwrap();
826        assert_eq!(result.text.unwrap(), "HELLO world\nfoo bar\n");
827        assert_eq!(result.changes.len(), 1);
828        assert_eq!(result.changes[0].line, 1);
829    }
830
831    #[test]
832    fn test_transform_lower() {
833        let text = "HELLO WORLD\nFOO BAR\n";
834        let op = Op::Transform {
835            find: "HELLO".to_string(),
836            mode: TransformMode::Lower,
837            regex: false,
838            case_insensitive: false,
839        };
840        let matcher = Matcher::new(&op).unwrap();
841        let result = apply(text, &op, &matcher, None, 0).unwrap();
842        assert_eq!(result.text.unwrap(), "hello WORLD\nFOO BAR\n");
843        assert_eq!(result.changes.len(), 1);
844    }
845
846    #[test]
847    fn test_transform_noop_when_already_target_case() {
848        // Transforming already-lowercase text to Lower should produce no changes
849        let text = "hello world\nfoo bar\n";
850        let op = Op::Transform {
851            find: "hello".to_string(),
852            mode: TransformMode::Lower,
853            regex: false,
854            case_insensitive: false,
855        };
856        let matcher = Matcher::new(&op).unwrap();
857        let result = apply(text, &op, &matcher, None, 0).unwrap();
858        assert!(result.text.is_none(), "No text modification expected");
859        assert!(result.changes.is_empty(), "No changes expected");
860    }
861
862    #[test]
863    fn test_transform_title() {
864        let text = "hello world\nfoo bar\n";
865        let op = Op::Transform {
866            find: "hello world".to_string(),
867            mode: TransformMode::Title,
868            regex: false,
869            case_insensitive: false,
870        };
871        let matcher = Matcher::new(&op).unwrap();
872        let result = apply(text, &op, &matcher, None, 0).unwrap();
873        assert_eq!(result.text.unwrap(), "Hello World\nfoo bar\n");
874        assert_eq!(result.changes.len(), 1);
875    }
876
877    #[test]
878    fn test_transform_snake_case() {
879        let text = "let myVariable = 1;\nother line\n";
880        let op = Op::Transform {
881            find: "myVariable".to_string(),
882            mode: TransformMode::SnakeCase,
883            regex: false,
884            case_insensitive: false,
885        };
886        let matcher = Matcher::new(&op).unwrap();
887        let result = apply(text, &op, &matcher, None, 0).unwrap();
888        assert_eq!(result.text.unwrap(), "let my_variable = 1;\nother line\n");
889        assert_eq!(result.changes.len(), 1);
890    }
891
892    #[test]
893    fn test_transform_camel_case() {
894        let text = "let my_variable = 1;\nother line\n";
895        let op = Op::Transform {
896            find: "my_variable".to_string(),
897            mode: TransformMode::CamelCase,
898            regex: false,
899            case_insensitive: false,
900        };
901        let matcher = Matcher::new(&op).unwrap();
902        let result = apply(text, &op, &matcher, None, 0).unwrap();
903        assert_eq!(result.text.unwrap(), "let myVariable = 1;\nother line\n");
904        assert_eq!(result.changes.len(), 1);
905    }
906
907    #[test]
908    fn test_transform_upper_multiple_matches_on_line() {
909        let text = "hello and hello again\n";
910        let op = Op::Transform {
911            find: "hello".to_string(),
912            mode: TransformMode::Upper,
913            regex: false,
914            case_insensitive: false,
915        };
916        let matcher = Matcher::new(&op).unwrap();
917        let result = apply(text, &op, &matcher, None, 0).unwrap();
918        assert_eq!(result.text.unwrap(), "HELLO and HELLO again\n");
919    }
920
921    #[test]
922    fn test_transform_no_match() {
923        let text = "hello world\n";
924        let op = Op::Transform {
925            find: "zzz".to_string(),
926            mode: TransformMode::Upper,
927            regex: false,
928            case_insensitive: false,
929        };
930        let matcher = Matcher::new(&op).unwrap();
931        let result = apply(text, &op, &matcher, None, 0).unwrap();
932        assert!(result.text.is_none());
933        assert!(result.changes.is_empty());
934    }
935
936    #[test]
937    fn test_transform_empty_text() {
938        let text = "";
939        let op = Op::Transform {
940            find: "anything".to_string(),
941            mode: TransformMode::Upper,
942            regex: false,
943            case_insensitive: false,
944        };
945        let matcher = Matcher::new(&op).unwrap();
946        let result = apply(text, &op, &matcher, None, 0).unwrap();
947        assert!(result.text.is_none());
948        assert!(result.changes.is_empty());
949    }
950
951    #[test]
952    fn test_transform_with_regex() {
953        let text = "let fooBar = 1;\nlet bazQux = 2;\n";
954        let op = Op::Transform {
955            find: r"[a-z]+[A-Z]\w*".to_string(),
956            mode: TransformMode::SnakeCase,
957            regex: true,
958            case_insensitive: false,
959        };
960        let matcher = Matcher::new(&op).unwrap();
961        let result = apply(text, &op, &matcher, None, 0).unwrap();
962        let output = result.text.unwrap();
963        assert!(output.contains("foo_bar"));
964        assert!(output.contains("baz_qux"));
965        assert_eq!(result.changes.len(), 2);
966    }
967
968    #[test]
969    fn test_transform_case_insensitive() {
970        let text = "Hello HELLO hello\n";
971        let op = Op::Transform {
972            find: "hello".to_string(),
973            mode: TransformMode::Upper,
974            regex: false,
975            case_insensitive: true,
976        };
977        let matcher = Matcher::new(&op).unwrap();
978        let result = apply(text, &op, &matcher, None, 0).unwrap();
979        assert_eq!(result.text.unwrap(), "HELLO HELLO HELLO\n");
980    }
981
982    #[test]
983    fn test_transform_crlf_preserved() {
984        let text = "hello world\r\nfoo bar\r\n";
985        let op = Op::Transform {
986            find: "hello".to_string(),
987            mode: TransformMode::Upper,
988            regex: false,
989            case_insensitive: false,
990        };
991        let matcher = Matcher::new(&op).unwrap();
992        let result = apply(text, &op, &matcher, None, 0).unwrap();
993        assert_eq!(result.text.unwrap(), "HELLO world\r\nfoo bar\r\n");
994    }
995
996    #[test]
997    fn test_transform_with_line_range() {
998        let text = "hello\nhello\nhello\nhello\n";
999        let op = Op::Transform {
1000            find: "hello".to_string(),
1001            mode: TransformMode::Upper,
1002            regex: false,
1003            case_insensitive: false,
1004        };
1005        let range = Some(LineRange {
1006            start: 2,
1007            end: Some(3),
1008        });
1009        let matcher = Matcher::new(&op).unwrap();
1010        let result = apply(text, &op, &matcher, range, 0).unwrap();
1011        assert_eq!(result.text.unwrap(), "hello\nHELLO\nHELLO\nhello\n");
1012        assert_eq!(result.changes.len(), 2);
1013    }
1014
1015    #[test]
1016    fn test_transform_title_with_underscores() {
1017        let text = "my_func_name\n";
1018        let op = Op::Transform {
1019            find: "my_func_name".to_string(),
1020            mode: TransformMode::Title,
1021            regex: false,
1022            case_insensitive: false,
1023        };
1024        let matcher = Matcher::new(&op).unwrap();
1025        let result = apply(text, &op, &matcher, None, 0).unwrap();
1026        // Title case capitalizes after underscores
1027        assert_eq!(result.text.unwrap(), "My_Func_Name\n");
1028    }
1029
1030    #[test]
1031    fn test_transform_snake_case_from_multi_word() {
1032        let text = "my-kebab-case\n";
1033        let op = Op::Transform {
1034            find: "my-kebab-case".to_string(),
1035            mode: TransformMode::SnakeCase,
1036            regex: false,
1037            case_insensitive: false,
1038        };
1039        let matcher = Matcher::new(&op).unwrap();
1040        let result = apply(text, &op, &matcher, None, 0).unwrap();
1041        assert_eq!(result.text.unwrap(), "my_kebab_case\n");
1042    }
1043
1044    #[test]
1045    fn test_transform_camel_case_from_snake() {
1046        let text = "my_var_name\n";
1047        let op = Op::Transform {
1048            find: "my_var_name".to_string(),
1049            mode: TransformMode::CamelCase,
1050            regex: false,
1051            case_insensitive: false,
1052        };
1053        let matcher = Matcher::new(&op).unwrap();
1054        let result = apply(text, &op, &matcher, None, 0).unwrap();
1055        assert_eq!(result.text.unwrap(), "myVarName\n");
1056    }
1057
1058    #[test]
1059    fn test_transform_camel_case_from_kebab() {
1060        let text = "my-var-name\n";
1061        let op = Op::Transform {
1062            find: "my-var-name".to_string(),
1063            mode: TransformMode::CamelCase,
1064            regex: false,
1065            case_insensitive: false,
1066        };
1067        let matcher = Matcher::new(&op).unwrap();
1068        let result = apply(text, &op, &matcher, None, 0).unwrap();
1069        assert_eq!(result.text.unwrap(), "myVarName\n");
1070    }
1071
1072    // ---------------------------------------------------------------
1073    // Surround operation tests
1074    // ---------------------------------------------------------------
1075
1076    #[test]
1077    fn test_surround_basic() {
1078        let text = "hello world\nfoo bar\n";
1079        let op = Op::Surround {
1080            find: "hello".to_string(),
1081            prefix: "<<".to_string(),
1082            suffix: ">>".to_string(),
1083            regex: false,
1084            case_insensitive: false,
1085        };
1086        let matcher = Matcher::new(&op).unwrap();
1087        let result = apply(text, &op, &matcher, None, 0).unwrap();
1088        assert_eq!(result.text.unwrap(), "<<hello world>>\nfoo bar\n");
1089        assert_eq!(result.changes.len(), 1);
1090        assert_eq!(result.changes[0].line, 1);
1091    }
1092
1093    #[test]
1094    fn test_surround_multiple_lines() {
1095        let text = "foo line 1\nbar line 2\nfoo line 3\n";
1096        let op = Op::Surround {
1097            find: "foo".to_string(),
1098            prefix: "[".to_string(),
1099            suffix: "]".to_string(),
1100            regex: false,
1101            case_insensitive: false,
1102        };
1103        let matcher = Matcher::new(&op).unwrap();
1104        let result = apply(text, &op, &matcher, None, 0).unwrap();
1105        assert_eq!(
1106            result.text.unwrap(),
1107            "[foo line 1]\nbar line 2\n[foo line 3]\n"
1108        );
1109        assert_eq!(result.changes.len(), 2);
1110    }
1111
1112    #[test]
1113    fn test_surround_no_match() {
1114        let text = "hello world\n";
1115        let op = Op::Surround {
1116            find: "zzz".to_string(),
1117            prefix: "<".to_string(),
1118            suffix: ">".to_string(),
1119            regex: false,
1120            case_insensitive: false,
1121        };
1122        let matcher = Matcher::new(&op).unwrap();
1123        let result = apply(text, &op, &matcher, None, 0).unwrap();
1124        assert!(result.text.is_none());
1125        assert!(result.changes.is_empty());
1126    }
1127
1128    #[test]
1129    fn test_surround_empty_text() {
1130        let text = "";
1131        let op = Op::Surround {
1132            find: "anything".to_string(),
1133            prefix: "<".to_string(),
1134            suffix: ">".to_string(),
1135            regex: false,
1136            case_insensitive: false,
1137        };
1138        let matcher = Matcher::new(&op).unwrap();
1139        let result = apply(text, &op, &matcher, None, 0).unwrap();
1140        assert!(result.text.is_none());
1141        assert!(result.changes.is_empty());
1142    }
1143
1144    #[test]
1145    fn test_surround_with_regex() {
1146        let text = "fn main() {\n    let x = 1;\n}\n";
1147        let op = Op::Surround {
1148            find: r"fn\s+\w+".to_string(),
1149            prefix: "/* ".to_string(),
1150            suffix: " */".to_string(),
1151            regex: true,
1152            case_insensitive: false,
1153        };
1154        let matcher = Matcher::new(&op).unwrap();
1155        let result = apply(text, &op, &matcher, None, 0).unwrap();
1156        assert_eq!(
1157            result.text.unwrap(),
1158            "/* fn main() { */\n    let x = 1;\n}\n"
1159        );
1160    }
1161
1162    #[test]
1163    fn test_surround_case_insensitive() {
1164        let text = "Hello world\nhello world\nHELLO world\n";
1165        let op = Op::Surround {
1166            find: "hello".to_string(),
1167            prefix: "(".to_string(),
1168            suffix: ")".to_string(),
1169            regex: false,
1170            case_insensitive: true,
1171        };
1172        let matcher = Matcher::new(&op).unwrap();
1173        let result = apply(text, &op, &matcher, None, 0).unwrap();
1174        let output = result.text.unwrap();
1175        assert_eq!(output, "(Hello world)\n(hello world)\n(HELLO world)\n");
1176        assert_eq!(result.changes.len(), 3);
1177    }
1178
1179    #[test]
1180    fn test_surround_crlf_preserved() {
1181        let text = "hello world\r\nfoo bar\r\n";
1182        let op = Op::Surround {
1183            find: "hello".to_string(),
1184            prefix: "[".to_string(),
1185            suffix: "]".to_string(),
1186            regex: false,
1187            case_insensitive: false,
1188        };
1189        let matcher = Matcher::new(&op).unwrap();
1190        let result = apply(text, &op, &matcher, None, 0).unwrap();
1191        assert_eq!(result.text.unwrap(), "[hello world]\r\nfoo bar\r\n");
1192    }
1193
1194    #[test]
1195    fn test_surround_with_line_range() {
1196        let text = "foo\nfoo\nfoo\nfoo\n";
1197        let op = Op::Surround {
1198            find: "foo".to_string(),
1199            prefix: "<".to_string(),
1200            suffix: ">".to_string(),
1201            regex: false,
1202            case_insensitive: false,
1203        };
1204        let range = Some(LineRange {
1205            start: 2,
1206            end: Some(3),
1207        });
1208        let matcher = Matcher::new(&op).unwrap();
1209        let result = apply(text, &op, &matcher, range, 0).unwrap();
1210        assert_eq!(result.text.unwrap(), "foo\n<foo>\n<foo>\nfoo\n");
1211        assert_eq!(result.changes.len(), 2);
1212    }
1213
1214    #[test]
1215    fn test_surround_with_empty_prefix_and_suffix() {
1216        let text = "hello world\n";
1217        let op = Op::Surround {
1218            find: "hello".to_string(),
1219            prefix: String::new(),
1220            suffix: String::new(),
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        // Surround with empty prefix and suffix is a no-op — no change recorded.
1227        assert!(result.text.is_none());
1228        assert!(result.changes.is_empty());
1229    }
1230
1231    // ---------------------------------------------------------------
1232    // Indent operation tests
1233    // ---------------------------------------------------------------
1234
1235    #[test]
1236    fn test_indent_basic() {
1237        let text = "hello\nworld\n";
1238        let op = Op::Indent {
1239            find: "hello".to_string(),
1240            amount: 4,
1241            use_tabs: false,
1242            regex: false,
1243            case_insensitive: false,
1244        };
1245        let matcher = Matcher::new(&op).unwrap();
1246        let result = apply(text, &op, &matcher, None, 0).unwrap();
1247        assert_eq!(result.text.unwrap(), "    hello\nworld\n");
1248        assert_eq!(result.changes.len(), 1);
1249    }
1250
1251    #[test]
1252    fn test_indent_multiple_lines() {
1253        let text = "foo line 1\nbar line 2\nfoo line 3\n";
1254        let op = Op::Indent {
1255            find: "foo".to_string(),
1256            amount: 2,
1257            use_tabs: false,
1258            regex: false,
1259            case_insensitive: false,
1260        };
1261        let matcher = Matcher::new(&op).unwrap();
1262        let result = apply(text, &op, &matcher, None, 0).unwrap();
1263        assert_eq!(
1264            result.text.unwrap(),
1265            "  foo line 1\nbar line 2\n  foo line 3\n"
1266        );
1267        assert_eq!(result.changes.len(), 2);
1268    }
1269
1270    #[test]
1271    fn test_indent_with_tabs() {
1272        let text = "hello\nworld\n";
1273        let op = Op::Indent {
1274            find: "hello".to_string(),
1275            amount: 2,
1276            use_tabs: true,
1277            regex: false,
1278            case_insensitive: false,
1279        };
1280        let matcher = Matcher::new(&op).unwrap();
1281        let result = apply(text, &op, &matcher, None, 0).unwrap();
1282        assert_eq!(result.text.unwrap(), "\t\thello\nworld\n");
1283    }
1284
1285    #[test]
1286    fn test_indent_no_match() {
1287        let text = "hello world\n";
1288        let op = Op::Indent {
1289            find: "zzz".to_string(),
1290            amount: 4,
1291            use_tabs: false,
1292            regex: false,
1293            case_insensitive: false,
1294        };
1295        let matcher = Matcher::new(&op).unwrap();
1296        let result = apply(text, &op, &matcher, None, 0).unwrap();
1297        assert!(result.text.is_none());
1298        assert!(result.changes.is_empty());
1299    }
1300
1301    #[test]
1302    fn test_indent_empty_text() {
1303        let text = "";
1304        let op = Op::Indent {
1305            find: "anything".to_string(),
1306            amount: 4,
1307            use_tabs: false,
1308            regex: false,
1309            case_insensitive: false,
1310        };
1311        let matcher = Matcher::new(&op).unwrap();
1312        let result = apply(text, &op, &matcher, None, 0).unwrap();
1313        assert!(result.text.is_none());
1314        assert!(result.changes.is_empty());
1315    }
1316
1317    #[test]
1318    fn test_indent_zero_amount() {
1319        let text = "hello\n";
1320        let op = Op::Indent {
1321            find: "hello".to_string(),
1322            amount: 0,
1323            use_tabs: false,
1324            regex: false,
1325            case_insensitive: false,
1326        };
1327        let matcher = Matcher::new(&op).unwrap();
1328        let result = apply(text, &op, &matcher, None, 0).unwrap();
1329        // Indent by 0 is a no-op — no change recorded.
1330        assert!(result.text.is_none());
1331        assert!(result.changes.is_empty());
1332    }
1333
1334    #[test]
1335    fn test_indent_with_regex() {
1336        let text = "fn main() {\nlet x = 1;\n}\n";
1337        let op = Op::Indent {
1338            find: r"let\s+".to_string(),
1339            amount: 4,
1340            use_tabs: false,
1341            regex: true,
1342            case_insensitive: false,
1343        };
1344        let matcher = Matcher::new(&op).unwrap();
1345        let result = apply(text, &op, &matcher, None, 0).unwrap();
1346        assert_eq!(result.text.unwrap(), "fn main() {\n    let x = 1;\n}\n");
1347        assert_eq!(result.changes.len(), 1);
1348    }
1349
1350    #[test]
1351    fn test_indent_case_insensitive() {
1352        let text = "Hello\nhello\nHELLO\n";
1353        let op = Op::Indent {
1354            find: "hello".to_string(),
1355            amount: 2,
1356            use_tabs: false,
1357            regex: false,
1358            case_insensitive: true,
1359        };
1360        let matcher = Matcher::new(&op).unwrap();
1361        let result = apply(text, &op, &matcher, None, 0).unwrap();
1362        assert_eq!(result.text.unwrap(), "  Hello\n  hello\n  HELLO\n");
1363        assert_eq!(result.changes.len(), 3);
1364    }
1365
1366    #[test]
1367    fn test_indent_crlf_preserved() {
1368        let text = "hello\r\nworld\r\n";
1369        let op = Op::Indent {
1370            find: "hello".to_string(),
1371            amount: 4,
1372            use_tabs: false,
1373            regex: false,
1374            case_insensitive: false,
1375        };
1376        let matcher = Matcher::new(&op).unwrap();
1377        let result = apply(text, &op, &matcher, None, 0).unwrap();
1378        assert_eq!(result.text.unwrap(), "    hello\r\nworld\r\n");
1379    }
1380
1381    #[test]
1382    fn test_indent_with_line_range() {
1383        let text = "foo\nfoo\nfoo\nfoo\n";
1384        let op = Op::Indent {
1385            find: "foo".to_string(),
1386            amount: 4,
1387            use_tabs: false,
1388            regex: false,
1389            case_insensitive: false,
1390        };
1391        let range = Some(LineRange {
1392            start: 2,
1393            end: Some(3),
1394        });
1395        let matcher = Matcher::new(&op).unwrap();
1396        let result = apply(text, &op, &matcher, range, 0).unwrap();
1397        assert_eq!(result.text.unwrap(), "foo\n    foo\n    foo\nfoo\n");
1398        assert_eq!(result.changes.len(), 2);
1399    }
1400
1401    // ---------------------------------------------------------------
1402    // Dedent operation tests
1403    // ---------------------------------------------------------------
1404
1405    #[test]
1406    fn test_dedent_basic() {
1407        let text = "    hello\nworld\n";
1408        let op = Op::Dedent {
1409            find: "hello".to_string(),
1410            amount: 4,
1411            use_tabs: false,
1412            regex: false,
1413            case_insensitive: false,
1414        };
1415        let matcher = Matcher::new(&op).unwrap();
1416        let result = apply(text, &op, &matcher, None, 0).unwrap();
1417        assert_eq!(result.text.unwrap(), "hello\nworld\n");
1418        assert_eq!(result.changes.len(), 1);
1419    }
1420
1421    #[test]
1422    fn test_dedent_partial() {
1423        // Only 2 spaces of leading whitespace, dedent by 4 should remove only 2
1424        let text = "  hello\n";
1425        let op = Op::Dedent {
1426            find: "hello".to_string(),
1427            amount: 4,
1428            use_tabs: false,
1429            regex: false,
1430            case_insensitive: false,
1431        };
1432        let matcher = Matcher::new(&op).unwrap();
1433        let result = apply(text, &op, &matcher, None, 0).unwrap();
1434        assert_eq!(result.text.unwrap(), "hello\n");
1435    }
1436
1437    #[test]
1438    fn test_dedent_no_leading_spaces() {
1439        // Line matches but has no leading spaces -- nothing to remove
1440        let text = "hello\n";
1441        let op = Op::Dedent {
1442            find: "hello".to_string(),
1443            amount: 4,
1444            use_tabs: false,
1445            regex: false,
1446            case_insensitive: false,
1447        };
1448        let matcher = Matcher::new(&op).unwrap();
1449        let result = apply(text, &op, &matcher, None, 0).unwrap();
1450        // No actual change because line has no leading spaces
1451        assert!(result.text.is_none());
1452        assert!(result.changes.is_empty());
1453    }
1454
1455    #[test]
1456    fn test_dedent_multiple_lines() {
1457        let text = "    foo line 1\n    bar line 2\n    foo line 3\n";
1458        let op = Op::Dedent {
1459            find: "foo".to_string(),
1460            amount: 4,
1461            use_tabs: false,
1462            regex: false,
1463            case_insensitive: false,
1464        };
1465        let matcher = Matcher::new(&op).unwrap();
1466        let result = apply(text, &op, &matcher, None, 0).unwrap();
1467        assert_eq!(
1468            result.text.unwrap(),
1469            "foo line 1\n    bar line 2\nfoo line 3\n"
1470        );
1471        assert_eq!(result.changes.len(), 2);
1472    }
1473
1474    #[test]
1475    fn test_dedent_no_match() {
1476        let text = "    hello world\n";
1477        let op = Op::Dedent {
1478            find: "zzz".to_string(),
1479            amount: 4,
1480            use_tabs: false,
1481            regex: false,
1482            case_insensitive: false,
1483        };
1484        let matcher = Matcher::new(&op).unwrap();
1485        let result = apply(text, &op, &matcher, None, 0).unwrap();
1486        assert!(result.text.is_none());
1487        assert!(result.changes.is_empty());
1488    }
1489
1490    #[test]
1491    fn test_dedent_empty_text() {
1492        let text = "";
1493        let op = Op::Dedent {
1494            find: "anything".to_string(),
1495            amount: 4,
1496            use_tabs: false,
1497            regex: false,
1498            case_insensitive: false,
1499        };
1500        let matcher = Matcher::new(&op).unwrap();
1501        let result = apply(text, &op, &matcher, None, 0).unwrap();
1502        assert!(result.text.is_none());
1503        assert!(result.changes.is_empty());
1504    }
1505
1506    #[test]
1507    fn test_dedent_with_regex() {
1508        let text = "    let x = 1;\n    fn main() {\n";
1509        let op = Op::Dedent {
1510            find: r"let\s+".to_string(),
1511            amount: 4,
1512            use_tabs: false,
1513            regex: true,
1514            case_insensitive: false,
1515        };
1516        let matcher = Matcher::new(&op).unwrap();
1517        let result = apply(text, &op, &matcher, None, 0).unwrap();
1518        assert_eq!(result.text.unwrap(), "let x = 1;\n    fn main() {\n");
1519        assert_eq!(result.changes.len(), 1);
1520    }
1521
1522    #[test]
1523    fn test_dedent_case_insensitive() {
1524        let text = "    Hello\n    hello\n    HELLO\n";
1525        let op = Op::Dedent {
1526            find: "hello".to_string(),
1527            amount: 2,
1528            use_tabs: false,
1529            regex: false,
1530            case_insensitive: true,
1531        };
1532        let matcher = Matcher::new(&op).unwrap();
1533        let result = apply(text, &op, &matcher, None, 0).unwrap();
1534        assert_eq!(result.text.unwrap(), "  Hello\n  hello\n  HELLO\n");
1535        assert_eq!(result.changes.len(), 3);
1536    }
1537
1538    #[test]
1539    fn test_dedent_crlf_preserved() {
1540        let text = "    hello\r\nworld\r\n";
1541        let op = Op::Dedent {
1542            find: "hello".to_string(),
1543            amount: 4,
1544            use_tabs: false,
1545            regex: false,
1546            case_insensitive: false,
1547        };
1548        let matcher = Matcher::new(&op).unwrap();
1549        let result = apply(text, &op, &matcher, None, 0).unwrap();
1550        assert_eq!(result.text.unwrap(), "hello\r\nworld\r\n");
1551    }
1552
1553    #[test]
1554    fn test_dedent_with_line_range() {
1555        let text = "    foo\n    foo\n    foo\n    foo\n";
1556        let op = Op::Dedent {
1557            find: "foo".to_string(),
1558            amount: 4,
1559            use_tabs: false,
1560            regex: false,
1561            case_insensitive: false,
1562        };
1563        let range = Some(LineRange {
1564            start: 2,
1565            end: Some(3),
1566        });
1567        let matcher = Matcher::new(&op).unwrap();
1568        let result = apply(text, &op, &matcher, range, 0).unwrap();
1569        assert_eq!(result.text.unwrap(), "    foo\nfoo\nfoo\n    foo\n");
1570        assert_eq!(result.changes.len(), 2);
1571    }
1572
1573    #[test]
1574    fn test_dedent_only_removes_spaces_not_tabs() {
1575        // Dedent only strips leading spaces, not tabs
1576        let text = "\t\thello\n";
1577        let op = Op::Dedent {
1578            find: "hello".to_string(),
1579            amount: 4,
1580            use_tabs: false,
1581            regex: false,
1582            case_insensitive: false,
1583        };
1584        let matcher = Matcher::new(&op).unwrap();
1585        let result = apply(text, &op, &matcher, None, 0).unwrap();
1586        // The dedent_line function only strips spaces (trim_start_matches(' ')),
1587        // tabs are not removed.
1588        assert!(result.text.is_none());
1589    }
1590
1591    // ---------------------------------------------------------------
1592    // Indent then Dedent roundtrip
1593    // ---------------------------------------------------------------
1594
1595    #[test]
1596    fn test_indent_then_dedent_roundtrip() {
1597        let original = "hello world\nfoo bar\n";
1598
1599        // Step 1: Indent by 4
1600        let indent_op = Op::Indent {
1601            find: "hello".to_string(),
1602            amount: 4,
1603            use_tabs: false,
1604            regex: false,
1605            case_insensitive: false,
1606        };
1607        let indent_matcher = Matcher::new(&indent_op).unwrap();
1608        let indented = apply(original, &indent_op, &indent_matcher, None, 0).unwrap();
1609        let indented_text = indented.text.unwrap();
1610        assert_eq!(indented_text, "    hello world\nfoo bar\n");
1611
1612        // Step 2: Dedent by 4 (the find still matches because "hello" is in the line)
1613        let dedent_op = Op::Dedent {
1614            find: "hello".to_string(),
1615            amount: 4,
1616            use_tabs: false,
1617            regex: false,
1618            case_insensitive: false,
1619        };
1620        let dedent_matcher = Matcher::new(&dedent_op).unwrap();
1621        let dedented = apply(&indented_text, &dedent_op, &dedent_matcher, None, 0).unwrap();
1622        assert_eq!(dedented.text.unwrap(), original);
1623    }
1624
1625    // ---------------------------------------------------------------
1626    // Undo entry tests for new ops
1627    // ---------------------------------------------------------------
1628
1629    #[test]
1630    fn test_transform_undo_stores_original() {
1631        let text = "hello world\n";
1632        let op = Op::Transform {
1633            find: "hello".to_string(),
1634            mode: TransformMode::Upper,
1635            regex: false,
1636            case_insensitive: false,
1637        };
1638        let matcher = Matcher::new(&op).unwrap();
1639        let result = apply(text, &op, &matcher, None, 0).unwrap();
1640        assert_eq!(result.undo.unwrap().original_text, text);
1641    }
1642
1643    #[test]
1644    fn test_surround_undo_stores_original() {
1645        let text = "hello world\n";
1646        let op = Op::Surround {
1647            find: "hello".to_string(),
1648            prefix: "<".to_string(),
1649            suffix: ">".to_string(),
1650            regex: false,
1651            case_insensitive: false,
1652        };
1653        let matcher = Matcher::new(&op).unwrap();
1654        let result = apply(text, &op, &matcher, None, 0).unwrap();
1655        assert_eq!(result.undo.unwrap().original_text, text);
1656    }
1657
1658    #[test]
1659    fn test_indent_undo_stores_original() {
1660        let text = "hello world\n";
1661        let op = Op::Indent {
1662            find: "hello".to_string(),
1663            amount: 4,
1664            use_tabs: false,
1665            regex: false,
1666            case_insensitive: false,
1667        };
1668        let matcher = Matcher::new(&op).unwrap();
1669        let result = apply(text, &op, &matcher, None, 0).unwrap();
1670        assert_eq!(result.undo.unwrap().original_text, text);
1671    }
1672
1673    #[test]
1674    fn test_dedent_undo_stores_original() {
1675        let text = "    hello world\n";
1676        let op = Op::Dedent {
1677            find: "hello".to_string(),
1678            amount: 4,
1679            use_tabs: false,
1680            regex: false,
1681            case_insensitive: false,
1682        };
1683        let matcher = Matcher::new(&op).unwrap();
1684        let result = apply(text, &op, &matcher, None, 0).unwrap();
1685        assert_eq!(result.undo.unwrap().original_text, text);
1686    }
1687
1688    // ---------------------------------------------------------------
1689    // Line preservation tests for new ops
1690    // ---------------------------------------------------------------
1691
1692    #[test]
1693    fn test_transform_preserves_line_count() {
1694        let text = "hello\nworld\nfoo\n";
1695        let op = Op::Transform {
1696            find: "hello".to_string(),
1697            mode: TransformMode::Upper,
1698            regex: false,
1699            case_insensitive: false,
1700        };
1701        let matcher = Matcher::new(&op).unwrap();
1702        let result = apply(text, &op, &matcher, None, 0).unwrap();
1703        let output = result.text.unwrap();
1704        assert_eq!(text.lines().count(), output.lines().count());
1705    }
1706
1707    #[test]
1708    fn test_surround_preserves_line_count() {
1709        let text = "hello\nworld\nfoo\n";
1710        let op = Op::Surround {
1711            find: "hello".to_string(),
1712            prefix: "<".to_string(),
1713            suffix: ">".to_string(),
1714            regex: false,
1715            case_insensitive: false,
1716        };
1717        let matcher = Matcher::new(&op).unwrap();
1718        let result = apply(text, &op, &matcher, None, 0).unwrap();
1719        let output = result.text.unwrap();
1720        assert_eq!(text.lines().count(), output.lines().count());
1721    }
1722
1723    #[test]
1724    fn test_indent_preserves_line_count() {
1725        let text = "hello\nworld\nfoo\n";
1726        let op = Op::Indent {
1727            find: "hello".to_string(),
1728            amount: 4,
1729            use_tabs: false,
1730            regex: false,
1731            case_insensitive: false,
1732        };
1733        let matcher = Matcher::new(&op).unwrap();
1734        let result = apply(text, &op, &matcher, None, 0).unwrap();
1735        let output = result.text.unwrap();
1736        assert_eq!(text.lines().count(), output.lines().count());
1737    }
1738
1739    #[test]
1740    fn test_dedent_preserves_line_count() {
1741        let text = "    hello\n    world\n    foo\n";
1742        let op = Op::Dedent {
1743            find: "hello".to_string(),
1744            amount: 4,
1745            use_tabs: false,
1746            regex: false,
1747            case_insensitive: false,
1748        };
1749        let matcher = Matcher::new(&op).unwrap();
1750        let result = apply(text, &op, &matcher, None, 0).unwrap();
1751        let output = result.text.unwrap();
1752        assert_eq!(text.lines().count(), output.lines().count());
1753    }
1754}
1755
1756// ---------------------------------------------------------------
1757// Property-based tests (proptest)
1758// ---------------------------------------------------------------
1759#[cfg(test)]
1760mod proptests {
1761    use super::*;
1762    use crate::matcher::Matcher;
1763    use crate::operation::Op;
1764    use proptest::prelude::*;
1765
1766    /// Strategy for generating text that is multiple lines with a trailing newline.
1767    fn arb_multiline_text() -> impl Strategy<Value = String> {
1768        prop::collection::vec("[^\n\r]{0,80}", 1..10).prop_map(|lines| lines.join("\n") + "\n")
1769    }
1770
1771    /// Strategy for generating a non-empty find pattern (plain literal).
1772    fn arb_find_pattern() -> impl Strategy<Value = String> {
1773        "[a-zA-Z0-9]{1,8}"
1774    }
1775
1776    proptest! {
1777        /// Round-trip: applying a Replace then undoing (restoring original_text)
1778        /// should give back the original text.
1779        #[test]
1780        fn prop_roundtrip_undo(
1781            text in arb_multiline_text(),
1782            find in arb_find_pattern(),
1783            replace in "[a-zA-Z0-9]{0,8}",
1784        ) {
1785            let op = Op::Replace {
1786                find: find.clone(),
1787                replace: replace.clone(),
1788                regex: false,
1789                case_insensitive: false,
1790            };
1791            let matcher = Matcher::new(&op).unwrap();
1792            let result = apply(&text, &op, &matcher, None, 0).unwrap();
1793
1794            if let Some(undo) = &result.undo {
1795                // Undo should restore original text
1796                prop_assert_eq!(&undo.original_text, &text);
1797            }
1798            // If no changes, text should be None
1799            if result.text.is_none() {
1800                prop_assert!(result.changes.is_empty());
1801            }
1802        }
1803
1804        /// No-op: applying with a pattern that cannot match leaves text unchanged.
1805        #[test]
1806        fn prop_noop_nonmatching_pattern(text in arb_multiline_text()) {
1807            // Use a pattern with a NUL byte which will never appear in text generated
1808            // by arb_multiline_text
1809            let op = Op::Replace {
1810                find: "\x00\x00NOMATCH\x00\x00".to_string(),
1811                replace: "replacement".to_string(),
1812                regex: false,
1813                case_insensitive: false,
1814            };
1815            let matcher = Matcher::new(&op).unwrap();
1816            let result = apply(&text, &op, &matcher, None, 0).unwrap();
1817            prop_assert!(result.text.is_none(), "Non-matching pattern should not modify text");
1818            prop_assert!(result.changes.is_empty());
1819            prop_assert!(result.undo.is_none());
1820        }
1821
1822        /// Determinism: same input always produces same output.
1823        #[test]
1824        fn prop_deterministic(
1825            text in arb_multiline_text(),
1826            find in arb_find_pattern(),
1827            replace in "[a-zA-Z0-9]{0,8}",
1828        ) {
1829            let op = Op::Replace {
1830                find,
1831                replace,
1832                regex: false,
1833                case_insensitive: false,
1834            };
1835            let matcher = Matcher::new(&op).unwrap();
1836            let r1 = apply(&text, &op, &matcher, None, 0).unwrap();
1837            let r2 = apply(&text, &op, &matcher, None, 0).unwrap();
1838            prop_assert_eq!(&r1.text, &r2.text);
1839            prop_assert_eq!(r1.changes.len(), r2.changes.len());
1840        }
1841
1842        /// Line count: for Replace ops, output line count == input line count.
1843        #[test]
1844        fn prop_replace_preserves_line_count(
1845            text in arb_multiline_text(),
1846            find in arb_find_pattern(),
1847            replace in "[a-zA-Z0-9]{0,8}",
1848        ) {
1849            let op = Op::Replace {
1850                find,
1851                replace,
1852                regex: false,
1853                case_insensitive: false,
1854            };
1855            let matcher = Matcher::new(&op).unwrap();
1856            let result = apply(&text, &op, &matcher, None, 0).unwrap();
1857            if let Some(ref output) = result.text {
1858                let input_lines = text.lines().count();
1859                let output_lines = output.lines().count();
1860                prop_assert_eq!(
1861                    input_lines,
1862                    output_lines,
1863                    "Replace should preserve line count: input={} output={}",
1864                    input_lines,
1865                    output_lines
1866                );
1867            }
1868        }
1869
1870        /// Indent then Dedent by the same amount should restore the original text
1871        /// when every line contains the find pattern and starts with enough spaces.
1872        #[test]
1873        fn prop_indent_dedent_roundtrip(
1874            amount in 1usize..=16,
1875        ) {
1876            // Use a known find pattern that appears on every line
1877            let find = "marker".to_string();
1878            let text = "marker line one\nmarker line two\nmarker line three\n";
1879
1880            let indent_op = Op::Indent {
1881                find: find.clone(),
1882                amount,
1883                use_tabs: false,
1884                regex: false,
1885                case_insensitive: false,
1886            };
1887            let indent_matcher = Matcher::new(&indent_op).unwrap();
1888            let indented = apply(text, &indent_op, &indent_matcher, None, 0).unwrap();
1889            let indented_text = indented.text.unwrap();
1890
1891            // Every line should now start with `amount` spaces
1892            for line in indented_text.lines() {
1893                let leading = line.len() - line.trim_start_matches(' ').len();
1894                prop_assert!(leading >= amount, "Expected at least {} leading spaces, got {}", amount, leading);
1895            }
1896
1897            let dedent_op = Op::Dedent {
1898                find: find.clone(),
1899                amount,
1900                use_tabs: false,
1901                regex: false,
1902                case_insensitive: false,
1903            };
1904            let dedent_matcher = Matcher::new(&dedent_op).unwrap();
1905            let dedented = apply(&indented_text, &dedent_op, &dedent_matcher, None, 0).unwrap();
1906            prop_assert_eq!(dedented.text.unwrap(), text);
1907        }
1908
1909        /// Transform Upper then Lower should restore the original when
1910        /// the text is already all lowercase ASCII.
1911        #[test]
1912        fn prop_transform_upper_lower_roundtrip(
1913            find in "[a-z]{1,8}",
1914        ) {
1915            let text = format!("prefix {find} suffix\n");
1916
1917            let upper_op = Op::Transform {
1918                find: find.clone(),
1919                mode: crate::operation::TransformMode::Upper,
1920                regex: false,
1921                case_insensitive: false,
1922            };
1923            let upper_matcher = Matcher::new(&upper_op).unwrap();
1924            let uppered = apply(&text, &upper_op, &upper_matcher, None, 0).unwrap();
1925
1926            if let Some(ref upper_text) = uppered.text {
1927                let upper_find = find.to_uppercase();
1928                let lower_op = Op::Transform {
1929                    find: upper_find,
1930                    mode: crate::operation::TransformMode::Lower,
1931                    regex: false,
1932                    case_insensitive: false,
1933                };
1934                let lower_matcher = Matcher::new(&lower_op).unwrap();
1935                let lowered = apply(upper_text, &lower_op, &lower_matcher, None, 0).unwrap();
1936                prop_assert_eq!(lowered.text.unwrap(), text);
1937            }
1938        }
1939
1940        /// Surround preserves line count.
1941        #[test]
1942        fn prop_surround_preserves_line_count(
1943            text in arb_multiline_text(),
1944            find in arb_find_pattern(),
1945        ) {
1946            let op = Op::Surround {
1947                find,
1948                prefix: "<<".to_string(),
1949                suffix: ">>".to_string(),
1950                regex: false,
1951                case_insensitive: false,
1952            };
1953            let matcher = Matcher::new(&op).unwrap();
1954            let result = apply(&text, &op, &matcher, None, 0).unwrap();
1955            if let Some(ref output) = result.text {
1956                let input_lines = text.lines().count();
1957                let output_lines = output.lines().count();
1958                prop_assert_eq!(
1959                    input_lines,
1960                    output_lines,
1961                    "Surround should preserve line count: input={} output={}",
1962                    input_lines,
1963                    output_lines
1964                );
1965            }
1966        }
1967
1968        /// Transform preserves line count.
1969        #[test]
1970        fn prop_transform_preserves_line_count(
1971            text in arb_multiline_text(),
1972            find in arb_find_pattern(),
1973        ) {
1974            let op = Op::Transform {
1975                find,
1976                mode: crate::operation::TransformMode::Upper,
1977                regex: false,
1978                case_insensitive: false,
1979            };
1980            let matcher = Matcher::new(&op).unwrap();
1981            let result = apply(&text, &op, &matcher, None, 0).unwrap();
1982            if let Some(ref output) = result.text {
1983                let input_lines = text.lines().count();
1984                let output_lines = output.lines().count();
1985                prop_assert_eq!(
1986                    input_lines,
1987                    output_lines,
1988                    "Transform should preserve line count: input={} output={}",
1989                    input_lines,
1990                    output_lines
1991                );
1992            }
1993        }
1994
1995        /// Indent preserves line count.
1996        #[test]
1997        fn prop_indent_preserves_line_count(
1998            text in arb_multiline_text(),
1999            find in arb_find_pattern(),
2000            amount in 1usize..=16,
2001        ) {
2002            let op = Op::Indent {
2003                find,
2004                amount,
2005                use_tabs: false,
2006                regex: false,
2007                case_insensitive: false,
2008            };
2009            let matcher = Matcher::new(&op).unwrap();
2010            let result = apply(&text, &op, &matcher, None, 0).unwrap();
2011            if let Some(ref output) = result.text {
2012                let input_lines = text.lines().count();
2013                let output_lines = output.lines().count();
2014                prop_assert_eq!(
2015                    input_lines,
2016                    output_lines,
2017                    "Indent should preserve line count: input={} output={}",
2018                    input_lines,
2019                    output_lines
2020                );
2021            }
2022        }
2023    }
2024}