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/// Detect whether the text uses CRLF line endings.
19///
20/// Returns true if CRLF (`\r\n`) is found before any bare LF (`\n`).
21fn uses_crlf(text: &str) -> bool {
22    text.contains("\r\n")
23}
24
25/// Apply a single operation to a text buffer.
26///
27/// Returns the modified text and a structured diff.
28/// If `dry_run` is true, the text is computed but flagged as preview-only.
29pub fn apply(
30    text: &str,
31    op: &Op,
32    matcher: &Matcher,
33    line_range: Option<LineRange>,
34    context_lines: usize,
35) -> Result<EngineOutput, RipsedError> {
36    let crlf = uses_crlf(text);
37    let lines: Vec<&str> = text.lines().collect();
38    let mut result_lines: Vec<String> = Vec::with_capacity(lines.len());
39    let mut changes: Vec<Change> = Vec::new();
40
41    for (idx, &line) in lines.iter().enumerate() {
42        let line_num = idx + 1; // 1-indexed
43
44        // Skip lines outside the line range
45        if let Some(range) = line_range {
46            if !range.contains(line_num) {
47                result_lines.push(line.to_string());
48                continue;
49            }
50        }
51
52        match op {
53            Op::Replace { replace, .. } => {
54                if let Some(replaced) = matcher.replace(line, replace) {
55                    let ctx = build_context(&lines, idx, context_lines);
56                    changes.push(Change {
57                        line: line_num,
58                        before: line.to_string(),
59                        after: Some(replaced.clone()),
60                        context: Some(ctx),
61                    });
62                    result_lines.push(replaced);
63                } else {
64                    result_lines.push(line.to_string());
65                }
66            }
67            Op::Delete { .. } => {
68                if matcher.is_match(line) {
69                    let ctx = build_context(&lines, idx, context_lines);
70                    changes.push(Change {
71                        line: line_num,
72                        before: line.to_string(),
73                        after: None,
74                        context: Some(ctx),
75                    });
76                    // Don't push — line is deleted
77                } else {
78                    result_lines.push(line.to_string());
79                }
80            }
81            Op::InsertAfter { content, .. } => {
82                result_lines.push(line.to_string());
83                if matcher.is_match(line) {
84                    let ctx = build_context(&lines, idx, context_lines);
85                    changes.push(Change {
86                        line: line_num,
87                        before: line.to_string(),
88                        after: Some(format!("{line}\n{content}")),
89                        context: Some(ctx),
90                    });
91                    result_lines.push(content.clone());
92                }
93            }
94            Op::InsertBefore { content, .. } => {
95                if matcher.is_match(line) {
96                    let ctx = build_context(&lines, idx, context_lines);
97                    changes.push(Change {
98                        line: line_num,
99                        before: line.to_string(),
100                        after: Some(format!("{content}\n{line}")),
101                        context: Some(ctx),
102                    });
103                    result_lines.push(content.clone());
104                }
105                result_lines.push(line.to_string());
106            }
107            Op::ReplaceLine { content, .. } => {
108                if matcher.is_match(line) {
109                    let ctx = build_context(&lines, idx, context_lines);
110                    changes.push(Change {
111                        line: line_num,
112                        before: line.to_string(),
113                        after: Some(content.clone()),
114                        context: Some(ctx),
115                    });
116                    result_lines.push(content.clone());
117                } else {
118                    result_lines.push(line.to_string());
119                }
120            }
121            Op::Transform { mode, .. } => {
122                if let Some(transformed) = matcher.replace(line, "") {
123                    // Replace matched text with transformed version
124                    let _ = transformed;
125                    let new_line = apply_transform(line, matcher, *mode);
126                    if new_line != line {
127                        let ctx = build_context(&lines, idx, context_lines);
128                        changes.push(Change {
129                            line: line_num,
130                            before: line.to_string(),
131                            after: Some(new_line.clone()),
132                            context: Some(ctx),
133                        });
134                        result_lines.push(new_line);
135                    } else {
136                        result_lines.push(line.to_string());
137                    }
138                } else {
139                    result_lines.push(line.to_string());
140                }
141            }
142            Op::Surround { prefix, suffix, .. } => {
143                if matcher.is_match(line) {
144                    let new_line = format!("{prefix}{line}{suffix}");
145                    let ctx = build_context(&lines, idx, context_lines);
146                    changes.push(Change {
147                        line: line_num,
148                        before: line.to_string(),
149                        after: Some(new_line.clone()),
150                        context: Some(ctx),
151                    });
152                    result_lines.push(new_line);
153                } else {
154                    result_lines.push(line.to_string());
155                }
156            }
157            Op::Indent {
158                amount, use_tabs, ..
159            } => {
160                if matcher.is_match(line) {
161                    let indent = if *use_tabs {
162                        "\t".repeat(*amount)
163                    } else {
164                        " ".repeat(*amount)
165                    };
166                    let new_line = format!("{indent}{line}");
167                    let ctx = build_context(&lines, idx, context_lines);
168                    changes.push(Change {
169                        line: line_num,
170                        before: line.to_string(),
171                        after: Some(new_line.clone()),
172                        context: Some(ctx),
173                    });
174                    result_lines.push(new_line);
175                } else {
176                    result_lines.push(line.to_string());
177                }
178            }
179            Op::Dedent { amount, .. } => {
180                if matcher.is_match(line) {
181                    let new_line = dedent_line(line, *amount);
182                    if new_line != line {
183                        let ctx = build_context(&lines, idx, context_lines);
184                        changes.push(Change {
185                            line: line_num,
186                            before: line.to_string(),
187                            after: Some(new_line.clone()),
188                            context: Some(ctx),
189                        });
190                        result_lines.push(new_line);
191                    } else {
192                        result_lines.push(line.to_string());
193                    }
194                } else {
195                    result_lines.push(line.to_string());
196                }
197            }
198        }
199    }
200
201    let line_sep = if crlf { "\r\n" } else { "\n" };
202    let modified_text = if changes.is_empty() {
203        None
204    } else {
205        // Preserve line ending style and trailing newline
206        let mut joined = result_lines.join(line_sep);
207        if text.ends_with('\n') || text.ends_with("\r\n") {
208            joined.push_str(line_sep);
209        }
210        Some(joined)
211    };
212
213    let undo = if !changes.is_empty() {
214        Some(UndoEntry {
215            original_text: text.to_string(),
216        })
217    } else {
218        None
219    };
220
221    Ok(EngineOutput {
222        text: modified_text,
223        changes,
224        undo,
225    })
226}
227
228/// Apply a text transformation to matched portions of a line.
229fn apply_transform(line: &str, matcher: &Matcher, mode: TransformMode) -> String {
230    match matcher {
231        Matcher::Literal {
232            pattern,
233            case_insensitive,
234        } => {
235            if *case_insensitive {
236                let lower_line = line.to_lowercase();
237                let lower_pat = pattern.to_lowercase();
238                let mut result = String::with_capacity(line.len());
239                let mut search_start = 0;
240                while let Some(pos) = lower_line[search_start..].find(&lower_pat) {
241                    let abs_pos = search_start + pos;
242                    result.push_str(&line[search_start..abs_pos]);
243                    let matched = &line[abs_pos..abs_pos + pattern.len()];
244                    result.push_str(&transform_text(matched, mode));
245                    search_start = abs_pos + pattern.len();
246                }
247                result.push_str(&line[search_start..]);
248                result
249            } else {
250                line.replace(pattern.as_str(), &transform_text(pattern, mode))
251            }
252        }
253        Matcher::Regex(re) => {
254            let result = re.replace_all(line, |caps: &regex::Captures| {
255                transform_text(&caps[0], mode)
256            });
257            result.into_owned()
258        }
259    }
260}
261
262/// Transform a text string according to the given mode.
263fn transform_text(text: &str, mode: TransformMode) -> String {
264    match mode {
265        TransformMode::Upper => text.to_uppercase(),
266        TransformMode::Lower => text.to_lowercase(),
267        TransformMode::Title => {
268            let mut result = String::with_capacity(text.len());
269            let mut capitalize_next = true;
270            for ch in text.chars() {
271                if ch.is_whitespace() || ch == '_' || ch == '-' {
272                    result.push(ch);
273                    capitalize_next = true;
274                } else if capitalize_next {
275                    for upper in ch.to_uppercase() {
276                        result.push(upper);
277                    }
278                    capitalize_next = false;
279                } else {
280                    result.push(ch);
281                }
282            }
283            result
284        }
285        TransformMode::SnakeCase => {
286            let mut result = String::with_capacity(text.len() + 4);
287            let mut prev_was_lower = false;
288            for ch in text.chars() {
289                if ch.is_uppercase() {
290                    if prev_was_lower {
291                        result.push('_');
292                    }
293                    for lower in ch.to_lowercase() {
294                        result.push(lower);
295                    }
296                    prev_was_lower = false;
297                } else if ch == '-' || ch == ' ' {
298                    result.push('_');
299                    prev_was_lower = false;
300                } else {
301                    result.push(ch);
302                    prev_was_lower = ch.is_lowercase();
303                }
304            }
305            result
306        }
307        TransformMode::CamelCase => {
308            let mut result = String::with_capacity(text.len());
309            let mut capitalize_next = false;
310            let mut first = true;
311            for ch in text.chars() {
312                if ch == '_' || ch == '-' || ch == ' ' {
313                    capitalize_next = true;
314                } else if capitalize_next {
315                    for upper in ch.to_uppercase() {
316                        result.push(upper);
317                    }
318                    capitalize_next = false;
319                } else if first {
320                    for lower in ch.to_lowercase() {
321                        result.push(lower);
322                    }
323                    first = false;
324                } else {
325                    result.push(ch);
326                    first = false;
327                }
328            }
329            result
330        }
331    }
332}
333
334/// Remove up to `amount` leading spaces from a line.
335fn dedent_line(line: &str, amount: usize) -> String {
336    let leading_spaces = line.len() - line.trim_start_matches(' ').len();
337    let remove = leading_spaces.min(amount);
338    line[remove..].to_string()
339}
340
341fn build_context(lines: &[&str], idx: usize, context_lines: usize) -> ChangeContext {
342    let start = idx.saturating_sub(context_lines);
343    let end = (idx + context_lines + 1).min(lines.len());
344
345    let before = lines[start..idx].iter().map(|s| s.to_string()).collect();
346    let after = if idx + 1 < end {
347        lines[idx + 1..end].iter().map(|s| s.to_string()).collect()
348    } else {
349        vec![]
350    };
351
352    ChangeContext { before, after }
353}
354
355/// Build an OpResult from file-level changes.
356pub fn build_op_result(operation_index: usize, path: &str, changes: Vec<Change>) -> OpResult {
357    OpResult {
358        operation_index,
359        files: if changes.is_empty() {
360            vec![]
361        } else {
362            vec![FileChanges {
363                path: path.to_string(),
364                changes,
365            }]
366        },
367    }
368}
369
370#[cfg(test)]
371mod tests {
372    use super::*;
373    use crate::matcher::Matcher;
374
375    #[test]
376    fn test_simple_replace() {
377        let text = "hello world\nfoo bar\nhello again\n";
378        let op = Op::Replace {
379            find: "hello".to_string(),
380            replace: "hi".to_string(),
381            regex: false,
382            case_insensitive: false,
383        };
384        let matcher = Matcher::new(&op).unwrap();
385        let result = apply(text, &op, &matcher, None, 2).unwrap();
386        assert_eq!(result.text.unwrap(), "hi world\nfoo bar\nhi again\n");
387        assert_eq!(result.changes.len(), 2);
388    }
389
390    #[test]
391    fn test_delete_lines() {
392        let text = "keep\ndelete me\nkeep too\n";
393        let op = Op::Delete {
394            find: "delete".to_string(),
395            regex: false,
396            case_insensitive: false,
397        };
398        let matcher = Matcher::new(&op).unwrap();
399        let result = apply(text, &op, &matcher, None, 0).unwrap();
400        assert_eq!(result.text.unwrap(), "keep\nkeep too\n");
401    }
402
403    #[test]
404    fn test_no_changes() {
405        let text = "nothing matches here\n";
406        let op = Op::Replace {
407            find: "zzz".to_string(),
408            replace: "aaa".to_string(),
409            regex: false,
410            case_insensitive: false,
411        };
412        let matcher = Matcher::new(&op).unwrap();
413        let result = apply(text, &op, &matcher, None, 0).unwrap();
414        assert!(result.text.is_none());
415        assert!(result.changes.is_empty());
416    }
417
418    #[test]
419    fn test_line_range() {
420        let text = "line1\nline2\nline3\nline4\n";
421        let op = Op::Replace {
422            find: "line".to_string(),
423            replace: "row".to_string(),
424            regex: false,
425            case_insensitive: false,
426        };
427        let range = Some(LineRange {
428            start: 2,
429            end: Some(3),
430        });
431        let matcher = Matcher::new(&op).unwrap();
432        let result = apply(text, &op, &matcher, range, 0).unwrap();
433        assert_eq!(result.text.unwrap(), "line1\nrow2\nrow3\nline4\n");
434    }
435
436    // ---------------------------------------------------------------
437    // CRLF handling tests
438    // ---------------------------------------------------------------
439
440    #[test]
441    fn test_crlf_replace_preserves_crlf() {
442        let text = "hello world\r\nfoo bar\r\nhello again\r\n";
443        let op = Op::Replace {
444            find: "hello".to_string(),
445            replace: "hi".to_string(),
446            regex: false,
447            case_insensitive: false,
448        };
449        let matcher = Matcher::new(&op).unwrap();
450        let result = apply(text, &op, &matcher, None, 0).unwrap();
451        assert_eq!(result.text.unwrap(), "hi world\r\nfoo bar\r\nhi again\r\n");
452    }
453
454    #[test]
455    fn test_crlf_delete_preserves_crlf() {
456        let text = "keep\r\ndelete me\r\nkeep too\r\n";
457        let op = Op::Delete {
458            find: "delete".to_string(),
459            regex: false,
460            case_insensitive: false,
461        };
462        let matcher = Matcher::new(&op).unwrap();
463        let result = apply(text, &op, &matcher, None, 0).unwrap();
464        assert_eq!(result.text.unwrap(), "keep\r\nkeep too\r\n");
465    }
466
467    #[test]
468    fn test_crlf_no_trailing_newline() {
469        let text = "hello world\r\nfoo bar";
470        let op = Op::Replace {
471            find: "hello".to_string(),
472            replace: "hi".to_string(),
473            regex: false,
474            case_insensitive: false,
475        };
476        let matcher = Matcher::new(&op).unwrap();
477        let result = apply(text, &op, &matcher, None, 0).unwrap();
478        let output = result.text.unwrap();
479        assert_eq!(output, "hi world\r\nfoo bar");
480        // No trailing CRLF since original didn't have one
481        assert!(!output.ends_with("\r\n"));
482    }
483
484    #[test]
485    fn test_uses_crlf_detection() {
486        assert!(uses_crlf("a\r\nb\r\n"));
487        assert!(uses_crlf("a\r\n"));
488        assert!(!uses_crlf("a\nb\n"));
489        assert!(!uses_crlf("no newline at all"));
490        assert!(!uses_crlf(""));
491    }
492
493    // ---------------------------------------------------------------
494    // Edge-case tests
495    // ---------------------------------------------------------------
496
497    #[test]
498    fn test_empty_input_text() {
499        let text = "";
500        let op = Op::Replace {
501            find: "anything".to_string(),
502            replace: "something".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!(result.text.is_none());
509        assert!(result.changes.is_empty());
510    }
511
512    #[test]
513    fn test_single_line_no_trailing_newline() {
514        let text = "hello world";
515        let op = Op::Replace {
516            find: "hello".to_string(),
517            replace: "hi".to_string(),
518            regex: false,
519            case_insensitive: false,
520        };
521        let matcher = Matcher::new(&op).unwrap();
522        let result = apply(text, &op, &matcher, None, 0).unwrap();
523        let output = result.text.unwrap();
524        assert_eq!(output, "hi world");
525        // Should NOT add a trailing newline that wasn't there
526        assert!(!output.ends_with('\n'));
527    }
528
529    #[test]
530    fn test_whitespace_only_lines() {
531        let text = "  \n\t\n   \t  \n";
532        let op = Op::Replace {
533            find: "\t".to_string(),
534            replace: "TAB".to_string(),
535            regex: false,
536            case_insensitive: false,
537        };
538        let matcher = Matcher::new(&op).unwrap();
539        let result = apply(text, &op, &matcher, None, 0).unwrap();
540        let output = result.text.unwrap();
541        assert!(output.contains("TAB"));
542        assert_eq!(result.changes.len(), 2); // lines 2 and 3 have tabs
543    }
544
545    #[test]
546    fn test_very_long_line() {
547        let long_word = "x".repeat(100_000);
548        let text = format!("before\n{long_word}\nafter\n");
549        let op = Op::Replace {
550            find: "x".to_string(),
551            replace: "y".to_string(),
552            regex: false,
553            case_insensitive: false,
554        };
555        let matcher = Matcher::new(&op).unwrap();
556        let result = apply(&text, &op, &matcher, None, 0).unwrap();
557        let output = result.text.unwrap();
558        let expected_long = "y".repeat(100_000);
559        assert!(output.contains(&expected_long));
560    }
561
562    #[test]
563    fn test_unicode_emoji() {
564        let text = "hello world\n";
565        let op = Op::Replace {
566            find: "world".to_string(),
567            replace: "\u{1F30D}".to_string(), // earth globe emoji
568            regex: false,
569            case_insensitive: false,
570        };
571        let matcher = Matcher::new(&op).unwrap();
572        let result = apply(text, &op, &matcher, None, 0).unwrap();
573        assert_eq!(result.text.unwrap(), "hello \u{1F30D}\n");
574    }
575
576    #[test]
577    fn test_unicode_cjk() {
578        let text = "\u{4F60}\u{597D}\u{4E16}\u{754C}\n"; // "hello world" in Chinese
579        let op = Op::Replace {
580            find: "\u{4E16}\u{754C}".to_string(),    // "world"
581            replace: "\u{5730}\u{7403}".to_string(), // "earth"
582            regex: false,
583            case_insensitive: false,
584        };
585        let matcher = Matcher::new(&op).unwrap();
586        let result = apply(text, &op, &matcher, None, 0).unwrap();
587        assert_eq!(result.text.unwrap(), "\u{4F60}\u{597D}\u{5730}\u{7403}\n");
588    }
589
590    #[test]
591    fn test_unicode_combining_characters() {
592        // e + combining acute accent = e-acute
593        let text = "caf\u{0065}\u{0301}\n";
594        let op = Op::Replace {
595            find: "caf\u{0065}\u{0301}".to_string(),
596            replace: "coffee".to_string(),
597            regex: false,
598            case_insensitive: false,
599        };
600        let matcher = Matcher::new(&op).unwrap();
601        let result = apply(text, &op, &matcher, None, 0).unwrap();
602        assert_eq!(result.text.unwrap(), "coffee\n");
603    }
604
605    #[test]
606    fn test_regex_special_chars_in_literal_mode() {
607        // In literal mode, regex metacharacters should be treated as literals
608        let text = "price is $10.00 (USD)\n";
609        let op = Op::Replace {
610            find: "$10.00".to_string(),
611            replace: "$20.00".to_string(),
612            regex: false,
613            case_insensitive: false,
614        };
615        let matcher = Matcher::new(&op).unwrap();
616        let result = apply(text, &op, &matcher, None, 0).unwrap();
617        assert_eq!(result.text.unwrap(), "price is $20.00 (USD)\n");
618    }
619
620    #[test]
621    fn test_overlapping_matches_in_single_line() {
622        // "aaa" with pattern "aa" — standard str::replace does non-overlapping left-to-right
623        let text = "aaa\n";
624        let op = Op::Replace {
625            find: "aa".to_string(),
626            replace: "b".to_string(),
627            regex: false,
628            case_insensitive: false,
629        };
630        let matcher = Matcher::new(&op).unwrap();
631        let result = apply(text, &op, &matcher, None, 0).unwrap();
632        // Rust's str::replace: "aaa".replace("aa", "b") == "ba"
633        assert_eq!(result.text.unwrap(), "ba\n");
634    }
635
636    #[test]
637    fn test_replace_line_count_preserved() {
638        let text = "line1\nline2\nline3\nline4\nline5\n";
639        let input_line_count = text.lines().count();
640        let op = Op::Replace {
641            find: "line".to_string(),
642            replace: "row".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        let output_line_count = output.lines().count();
650        assert_eq!(input_line_count, output_line_count);
651    }
652
653    #[test]
654    fn test_replace_preserves_empty_result_on_non_match() {
655        // Pattern that exists nowhere in text
656        let text = "alpha\nbeta\ngamma\n";
657        let op = Op::Replace {
658            find: "zzzzzz".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        assert!(result.text.is_none());
666        assert!(result.undo.is_none());
667    }
668
669    #[test]
670    fn test_undo_entry_stores_original() {
671        let text = "hello\nworld\n";
672        let op = Op::Replace {
673            find: "hello".to_string(),
674            replace: "hi".to_string(),
675            regex: false,
676            case_insensitive: false,
677        };
678        let matcher = Matcher::new(&op).unwrap();
679        let result = apply(text, &op, &matcher, None, 0).unwrap();
680        let undo = result.undo.unwrap();
681        assert_eq!(undo.original_text, text);
682    }
683
684    #[test]
685    fn test_determinism_same_input_same_output() {
686        let text = "foo bar baz\nhello world\nfoo again\n";
687        let op = Op::Replace {
688            find: "foo".to_string(),
689            replace: "qux".to_string(),
690            regex: false,
691            case_insensitive: false,
692        };
693        let matcher = Matcher::new(&op).unwrap();
694        let r1 = apply(text, &op, &matcher, None, 0).unwrap();
695        let r2 = apply(text, &op, &matcher, None, 0).unwrap();
696        assert_eq!(r1.text, r2.text);
697        assert_eq!(r1.changes.len(), r2.changes.len());
698        for (c1, c2) in r1.changes.iter().zip(r2.changes.iter()) {
699            assert_eq!(c1, c2);
700        }
701    }
702
703    // ---------------------------------------------------------------
704    // Transform operation tests
705    // ---------------------------------------------------------------
706
707    #[test]
708    fn test_transform_upper() {
709        let text = "hello world\nfoo bar\n";
710        let op = Op::Transform {
711            find: "hello".to_string(),
712            mode: TransformMode::Upper,
713            regex: false,
714            case_insensitive: false,
715        };
716        let matcher = Matcher::new(&op).unwrap();
717        let result = apply(text, &op, &matcher, None, 0).unwrap();
718        assert_eq!(result.text.unwrap(), "HELLO world\nfoo bar\n");
719        assert_eq!(result.changes.len(), 1);
720        assert_eq!(result.changes[0].line, 1);
721    }
722
723    #[test]
724    fn test_transform_lower() {
725        let text = "HELLO WORLD\nFOO BAR\n";
726        let op = Op::Transform {
727            find: "HELLO".to_string(),
728            mode: TransformMode::Lower,
729            regex: false,
730            case_insensitive: false,
731        };
732        let matcher = Matcher::new(&op).unwrap();
733        let result = apply(text, &op, &matcher, None, 0).unwrap();
734        assert_eq!(result.text.unwrap(), "hello WORLD\nFOO BAR\n");
735        assert_eq!(result.changes.len(), 1);
736    }
737
738    #[test]
739    fn test_transform_title() {
740        let text = "hello world\nfoo bar\n";
741        let op = Op::Transform {
742            find: "hello world".to_string(),
743            mode: TransformMode::Title,
744            regex: false,
745            case_insensitive: false,
746        };
747        let matcher = Matcher::new(&op).unwrap();
748        let result = apply(text, &op, &matcher, None, 0).unwrap();
749        assert_eq!(result.text.unwrap(), "Hello World\nfoo bar\n");
750        assert_eq!(result.changes.len(), 1);
751    }
752
753    #[test]
754    fn test_transform_snake_case() {
755        let text = "let myVariable = 1;\nother line\n";
756        let op = Op::Transform {
757            find: "myVariable".to_string(),
758            mode: TransformMode::SnakeCase,
759            regex: false,
760            case_insensitive: false,
761        };
762        let matcher = Matcher::new(&op).unwrap();
763        let result = apply(text, &op, &matcher, None, 0).unwrap();
764        assert_eq!(result.text.unwrap(), "let my_variable = 1;\nother line\n");
765        assert_eq!(result.changes.len(), 1);
766    }
767
768    #[test]
769    fn test_transform_camel_case() {
770        let text = "let my_variable = 1;\nother line\n";
771        let op = Op::Transform {
772            find: "my_variable".to_string(),
773            mode: TransformMode::CamelCase,
774            regex: false,
775            case_insensitive: false,
776        };
777        let matcher = Matcher::new(&op).unwrap();
778        let result = apply(text, &op, &matcher, None, 0).unwrap();
779        assert_eq!(result.text.unwrap(), "let myVariable = 1;\nother line\n");
780        assert_eq!(result.changes.len(), 1);
781    }
782
783    #[test]
784    fn test_transform_upper_multiple_matches_on_line() {
785        let text = "hello and hello again\n";
786        let op = Op::Transform {
787            find: "hello".to_string(),
788            mode: TransformMode::Upper,
789            regex: false,
790            case_insensitive: false,
791        };
792        let matcher = Matcher::new(&op).unwrap();
793        let result = apply(text, &op, &matcher, None, 0).unwrap();
794        assert_eq!(result.text.unwrap(), "HELLO and HELLO again\n");
795    }
796
797    #[test]
798    fn test_transform_no_match() {
799        let text = "hello world\n";
800        let op = Op::Transform {
801            find: "zzz".to_string(),
802            mode: TransformMode::Upper,
803            regex: false,
804            case_insensitive: false,
805        };
806        let matcher = Matcher::new(&op).unwrap();
807        let result = apply(text, &op, &matcher, None, 0).unwrap();
808        assert!(result.text.is_none());
809        assert!(result.changes.is_empty());
810    }
811
812    #[test]
813    fn test_transform_empty_text() {
814        let text = "";
815        let op = Op::Transform {
816            find: "anything".to_string(),
817            mode: TransformMode::Upper,
818            regex: false,
819            case_insensitive: false,
820        };
821        let matcher = Matcher::new(&op).unwrap();
822        let result = apply(text, &op, &matcher, None, 0).unwrap();
823        assert!(result.text.is_none());
824        assert!(result.changes.is_empty());
825    }
826
827    #[test]
828    fn test_transform_with_regex() {
829        let text = "let fooBar = 1;\nlet bazQux = 2;\n";
830        let op = Op::Transform {
831            find: r"[a-z]+[A-Z]\w*".to_string(),
832            mode: TransformMode::SnakeCase,
833            regex: true,
834            case_insensitive: false,
835        };
836        let matcher = Matcher::new(&op).unwrap();
837        let result = apply(text, &op, &matcher, None, 0).unwrap();
838        let output = result.text.unwrap();
839        assert!(output.contains("foo_bar"));
840        assert!(output.contains("baz_qux"));
841        assert_eq!(result.changes.len(), 2);
842    }
843
844    #[test]
845    fn test_transform_case_insensitive() {
846        let text = "Hello HELLO hello\n";
847        let op = Op::Transform {
848            find: "hello".to_string(),
849            mode: TransformMode::Upper,
850            regex: false,
851            case_insensitive: true,
852        };
853        let matcher = Matcher::new(&op).unwrap();
854        let result = apply(text, &op, &matcher, None, 0).unwrap();
855        assert_eq!(result.text.unwrap(), "HELLO HELLO HELLO\n");
856    }
857
858    #[test]
859    fn test_transform_crlf_preserved() {
860        let text = "hello world\r\nfoo bar\r\n";
861        let op = Op::Transform {
862            find: "hello".to_string(),
863            mode: TransformMode::Upper,
864            regex: false,
865            case_insensitive: false,
866        };
867        let matcher = Matcher::new(&op).unwrap();
868        let result = apply(text, &op, &matcher, None, 0).unwrap();
869        assert_eq!(result.text.unwrap(), "HELLO world\r\nfoo bar\r\n");
870    }
871
872    #[test]
873    fn test_transform_with_line_range() {
874        let text = "hello\nhello\nhello\nhello\n";
875        let op = Op::Transform {
876            find: "hello".to_string(),
877            mode: TransformMode::Upper,
878            regex: false,
879            case_insensitive: false,
880        };
881        let range = Some(LineRange {
882            start: 2,
883            end: Some(3),
884        });
885        let matcher = Matcher::new(&op).unwrap();
886        let result = apply(text, &op, &matcher, range, 0).unwrap();
887        assert_eq!(result.text.unwrap(), "hello\nHELLO\nHELLO\nhello\n");
888        assert_eq!(result.changes.len(), 2);
889    }
890
891    #[test]
892    fn test_transform_title_with_underscores() {
893        let text = "my_func_name\n";
894        let op = Op::Transform {
895            find: "my_func_name".to_string(),
896            mode: TransformMode::Title,
897            regex: false,
898            case_insensitive: false,
899        };
900        let matcher = Matcher::new(&op).unwrap();
901        let result = apply(text, &op, &matcher, None, 0).unwrap();
902        // Title case capitalizes after underscores
903        assert_eq!(result.text.unwrap(), "My_Func_Name\n");
904    }
905
906    #[test]
907    fn test_transform_snake_case_from_multi_word() {
908        let text = "my-kebab-case\n";
909        let op = Op::Transform {
910            find: "my-kebab-case".to_string(),
911            mode: TransformMode::SnakeCase,
912            regex: false,
913            case_insensitive: false,
914        };
915        let matcher = Matcher::new(&op).unwrap();
916        let result = apply(text, &op, &matcher, None, 0).unwrap();
917        assert_eq!(result.text.unwrap(), "my_kebab_case\n");
918    }
919
920    #[test]
921    fn test_transform_camel_case_from_snake() {
922        let text = "my_var_name\n";
923        let op = Op::Transform {
924            find: "my_var_name".to_string(),
925            mode: TransformMode::CamelCase,
926            regex: false,
927            case_insensitive: false,
928        };
929        let matcher = Matcher::new(&op).unwrap();
930        let result = apply(text, &op, &matcher, None, 0).unwrap();
931        assert_eq!(result.text.unwrap(), "myVarName\n");
932    }
933
934    #[test]
935    fn test_transform_camel_case_from_kebab() {
936        let text = "my-var-name\n";
937        let op = Op::Transform {
938            find: "my-var-name".to_string(),
939            mode: TransformMode::CamelCase,
940            regex: false,
941            case_insensitive: false,
942        };
943        let matcher = Matcher::new(&op).unwrap();
944        let result = apply(text, &op, &matcher, None, 0).unwrap();
945        assert_eq!(result.text.unwrap(), "myVarName\n");
946    }
947
948    // ---------------------------------------------------------------
949    // Surround operation tests
950    // ---------------------------------------------------------------
951
952    #[test]
953    fn test_surround_basic() {
954        let text = "hello world\nfoo bar\n";
955        let op = Op::Surround {
956            find: "hello".to_string(),
957            prefix: "<<".to_string(),
958            suffix: ">>".to_string(),
959            regex: false,
960            case_insensitive: false,
961        };
962        let matcher = Matcher::new(&op).unwrap();
963        let result = apply(text, &op, &matcher, None, 0).unwrap();
964        assert_eq!(result.text.unwrap(), "<<hello world>>\nfoo bar\n");
965        assert_eq!(result.changes.len(), 1);
966        assert_eq!(result.changes[0].line, 1);
967    }
968
969    #[test]
970    fn test_surround_multiple_lines() {
971        let text = "foo line 1\nbar line 2\nfoo line 3\n";
972        let op = Op::Surround {
973            find: "foo".to_string(),
974            prefix: "[".to_string(),
975            suffix: "]".to_string(),
976            regex: false,
977            case_insensitive: false,
978        };
979        let matcher = Matcher::new(&op).unwrap();
980        let result = apply(text, &op, &matcher, None, 0).unwrap();
981        assert_eq!(
982            result.text.unwrap(),
983            "[foo line 1]\nbar line 2\n[foo line 3]\n"
984        );
985        assert_eq!(result.changes.len(), 2);
986    }
987
988    #[test]
989    fn test_surround_no_match() {
990        let text = "hello world\n";
991        let op = Op::Surround {
992            find: "zzz".to_string(),
993            prefix: "<".to_string(),
994            suffix: ">".to_string(),
995            regex: false,
996            case_insensitive: false,
997        };
998        let matcher = Matcher::new(&op).unwrap();
999        let result = apply(text, &op, &matcher, None, 0).unwrap();
1000        assert!(result.text.is_none());
1001        assert!(result.changes.is_empty());
1002    }
1003
1004    #[test]
1005    fn test_surround_empty_text() {
1006        let text = "";
1007        let op = Op::Surround {
1008            find: "anything".to_string(),
1009            prefix: "<".to_string(),
1010            suffix: ">".to_string(),
1011            regex: false,
1012            case_insensitive: false,
1013        };
1014        let matcher = Matcher::new(&op).unwrap();
1015        let result = apply(text, &op, &matcher, None, 0).unwrap();
1016        assert!(result.text.is_none());
1017        assert!(result.changes.is_empty());
1018    }
1019
1020    #[test]
1021    fn test_surround_with_regex() {
1022        let text = "fn main() {\n    let x = 1;\n}\n";
1023        let op = Op::Surround {
1024            find: r"fn\s+\w+".to_string(),
1025            prefix: "/* ".to_string(),
1026            suffix: " */".to_string(),
1027            regex: true,
1028            case_insensitive: false,
1029        };
1030        let matcher = Matcher::new(&op).unwrap();
1031        let result = apply(text, &op, &matcher, None, 0).unwrap();
1032        assert_eq!(
1033            result.text.unwrap(),
1034            "/* fn main() { */\n    let x = 1;\n}\n"
1035        );
1036    }
1037
1038    #[test]
1039    fn test_surround_case_insensitive() {
1040        let text = "Hello world\nhello world\nHELLO world\n";
1041        let op = Op::Surround {
1042            find: "hello".to_string(),
1043            prefix: "(".to_string(),
1044            suffix: ")".to_string(),
1045            regex: false,
1046            case_insensitive: true,
1047        };
1048        let matcher = Matcher::new(&op).unwrap();
1049        let result = apply(text, &op, &matcher, None, 0).unwrap();
1050        let output = result.text.unwrap();
1051        assert_eq!(output, "(Hello world)\n(hello world)\n(HELLO world)\n");
1052        assert_eq!(result.changes.len(), 3);
1053    }
1054
1055    #[test]
1056    fn test_surround_crlf_preserved() {
1057        let text = "hello world\r\nfoo bar\r\n";
1058        let op = Op::Surround {
1059            find: "hello".to_string(),
1060            prefix: "[".to_string(),
1061            suffix: "]".to_string(),
1062            regex: false,
1063            case_insensitive: false,
1064        };
1065        let matcher = Matcher::new(&op).unwrap();
1066        let result = apply(text, &op, &matcher, None, 0).unwrap();
1067        assert_eq!(result.text.unwrap(), "[hello world]\r\nfoo bar\r\n");
1068    }
1069
1070    #[test]
1071    fn test_surround_with_line_range() {
1072        let text = "foo\nfoo\nfoo\nfoo\n";
1073        let op = Op::Surround {
1074            find: "foo".to_string(),
1075            prefix: "<".to_string(),
1076            suffix: ">".to_string(),
1077            regex: false,
1078            case_insensitive: false,
1079        };
1080        let range = Some(LineRange {
1081            start: 2,
1082            end: Some(3),
1083        });
1084        let matcher = Matcher::new(&op).unwrap();
1085        let result = apply(text, &op, &matcher, range, 0).unwrap();
1086        assert_eq!(result.text.unwrap(), "foo\n<foo>\n<foo>\nfoo\n");
1087        assert_eq!(result.changes.len(), 2);
1088    }
1089
1090    #[test]
1091    fn test_surround_with_empty_prefix_and_suffix() {
1092        let text = "hello world\n";
1093        let op = Op::Surround {
1094            find: "hello".to_string(),
1095            prefix: String::new(),
1096            suffix: String::new(),
1097            regex: false,
1098            case_insensitive: false,
1099        };
1100        let matcher = Matcher::new(&op).unwrap();
1101        let result = apply(text, &op, &matcher, None, 0).unwrap();
1102        // Surround always records a change when is_match is true, even if
1103        // prefix and suffix are empty (the new_line equals the original).
1104        assert!(result.text.is_some());
1105        let output = result.text.unwrap();
1106        assert_eq!(output, "hello world\n");
1107    }
1108
1109    // ---------------------------------------------------------------
1110    // Indent operation tests
1111    // ---------------------------------------------------------------
1112
1113    #[test]
1114    fn test_indent_basic() {
1115        let text = "hello\nworld\n";
1116        let op = Op::Indent {
1117            find: "hello".to_string(),
1118            amount: 4,
1119            use_tabs: false,
1120            regex: false,
1121            case_insensitive: false,
1122        };
1123        let matcher = Matcher::new(&op).unwrap();
1124        let result = apply(text, &op, &matcher, None, 0).unwrap();
1125        assert_eq!(result.text.unwrap(), "    hello\nworld\n");
1126        assert_eq!(result.changes.len(), 1);
1127    }
1128
1129    #[test]
1130    fn test_indent_multiple_lines() {
1131        let text = "foo line 1\nbar line 2\nfoo line 3\n";
1132        let op = Op::Indent {
1133            find: "foo".to_string(),
1134            amount: 2,
1135            use_tabs: false,
1136            regex: false,
1137            case_insensitive: false,
1138        };
1139        let matcher = Matcher::new(&op).unwrap();
1140        let result = apply(text, &op, &matcher, None, 0).unwrap();
1141        assert_eq!(
1142            result.text.unwrap(),
1143            "  foo line 1\nbar line 2\n  foo line 3\n"
1144        );
1145        assert_eq!(result.changes.len(), 2);
1146    }
1147
1148    #[test]
1149    fn test_indent_with_tabs() {
1150        let text = "hello\nworld\n";
1151        let op = Op::Indent {
1152            find: "hello".to_string(),
1153            amount: 2,
1154            use_tabs: true,
1155            regex: false,
1156            case_insensitive: false,
1157        };
1158        let matcher = Matcher::new(&op).unwrap();
1159        let result = apply(text, &op, &matcher, None, 0).unwrap();
1160        assert_eq!(result.text.unwrap(), "\t\thello\nworld\n");
1161    }
1162
1163    #[test]
1164    fn test_indent_no_match() {
1165        let text = "hello world\n";
1166        let op = Op::Indent {
1167            find: "zzz".to_string(),
1168            amount: 4,
1169            use_tabs: false,
1170            regex: false,
1171            case_insensitive: false,
1172        };
1173        let matcher = Matcher::new(&op).unwrap();
1174        let result = apply(text, &op, &matcher, None, 0).unwrap();
1175        assert!(result.text.is_none());
1176        assert!(result.changes.is_empty());
1177    }
1178
1179    #[test]
1180    fn test_indent_empty_text() {
1181        let text = "";
1182        let op = Op::Indent {
1183            find: "anything".to_string(),
1184            amount: 4,
1185            use_tabs: false,
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!(result.text.is_none());
1192        assert!(result.changes.is_empty());
1193    }
1194
1195    #[test]
1196    fn test_indent_zero_amount() {
1197        let text = "hello\n";
1198        let op = Op::Indent {
1199            find: "hello".to_string(),
1200            amount: 0,
1201            use_tabs: false,
1202            regex: false,
1203            case_insensitive: false,
1204        };
1205        let matcher = Matcher::new(&op).unwrap();
1206        let result = apply(text, &op, &matcher, None, 0).unwrap();
1207        // Indent by 0 means prepend "" which produces the same line,
1208        // but the engine still records it as a change because it always
1209        // pushes when is_match is true for Indent.
1210        assert!(result.text.is_some());
1211        assert_eq!(result.text.unwrap(), "hello\n");
1212    }
1213
1214    #[test]
1215    fn test_indent_with_regex() {
1216        let text = "fn main() {\nlet x = 1;\n}\n";
1217        let op = Op::Indent {
1218            find: r"let\s+".to_string(),
1219            amount: 4,
1220            use_tabs: false,
1221            regex: true,
1222            case_insensitive: false,
1223        };
1224        let matcher = Matcher::new(&op).unwrap();
1225        let result = apply(text, &op, &matcher, None, 0).unwrap();
1226        assert_eq!(result.text.unwrap(), "fn main() {\n    let x = 1;\n}\n");
1227        assert_eq!(result.changes.len(), 1);
1228    }
1229
1230    #[test]
1231    fn test_indent_case_insensitive() {
1232        let text = "Hello\nhello\nHELLO\n";
1233        let op = Op::Indent {
1234            find: "hello".to_string(),
1235            amount: 2,
1236            use_tabs: false,
1237            regex: false,
1238            case_insensitive: true,
1239        };
1240        let matcher = Matcher::new(&op).unwrap();
1241        let result = apply(text, &op, &matcher, None, 0).unwrap();
1242        assert_eq!(result.text.unwrap(), "  Hello\n  hello\n  HELLO\n");
1243        assert_eq!(result.changes.len(), 3);
1244    }
1245
1246    #[test]
1247    fn test_indent_crlf_preserved() {
1248        let text = "hello\r\nworld\r\n";
1249        let op = Op::Indent {
1250            find: "hello".to_string(),
1251            amount: 4,
1252            use_tabs: false,
1253            regex: false,
1254            case_insensitive: false,
1255        };
1256        let matcher = Matcher::new(&op).unwrap();
1257        let result = apply(text, &op, &matcher, None, 0).unwrap();
1258        assert_eq!(result.text.unwrap(), "    hello\r\nworld\r\n");
1259    }
1260
1261    #[test]
1262    fn test_indent_with_line_range() {
1263        let text = "foo\nfoo\nfoo\nfoo\n";
1264        let op = Op::Indent {
1265            find: "foo".to_string(),
1266            amount: 4,
1267            use_tabs: false,
1268            regex: false,
1269            case_insensitive: false,
1270        };
1271        let range = Some(LineRange {
1272            start: 2,
1273            end: Some(3),
1274        });
1275        let matcher = Matcher::new(&op).unwrap();
1276        let result = apply(text, &op, &matcher, range, 0).unwrap();
1277        assert_eq!(result.text.unwrap(), "foo\n    foo\n    foo\nfoo\n");
1278        assert_eq!(result.changes.len(), 2);
1279    }
1280
1281    // ---------------------------------------------------------------
1282    // Dedent operation tests
1283    // ---------------------------------------------------------------
1284
1285    #[test]
1286    fn test_dedent_basic() {
1287        let text = "    hello\nworld\n";
1288        let op = Op::Dedent {
1289            find: "hello".to_string(),
1290            amount: 4,
1291            regex: false,
1292            case_insensitive: false,
1293        };
1294        let matcher = Matcher::new(&op).unwrap();
1295        let result = apply(text, &op, &matcher, None, 0).unwrap();
1296        assert_eq!(result.text.unwrap(), "hello\nworld\n");
1297        assert_eq!(result.changes.len(), 1);
1298    }
1299
1300    #[test]
1301    fn test_dedent_partial() {
1302        // Only 2 spaces of leading whitespace, dedent by 4 should remove only 2
1303        let text = "  hello\n";
1304        let op = Op::Dedent {
1305            find: "hello".to_string(),
1306            amount: 4,
1307            regex: false,
1308            case_insensitive: false,
1309        };
1310        let matcher = Matcher::new(&op).unwrap();
1311        let result = apply(text, &op, &matcher, None, 0).unwrap();
1312        assert_eq!(result.text.unwrap(), "hello\n");
1313    }
1314
1315    #[test]
1316    fn test_dedent_no_leading_spaces() {
1317        // Line matches but has no leading spaces -- nothing to remove
1318        let text = "hello\n";
1319        let op = Op::Dedent {
1320            find: "hello".to_string(),
1321            amount: 4,
1322            regex: false,
1323            case_insensitive: false,
1324        };
1325        let matcher = Matcher::new(&op).unwrap();
1326        let result = apply(text, &op, &matcher, None, 0).unwrap();
1327        // No actual change because line has no leading spaces
1328        assert!(result.text.is_none());
1329        assert!(result.changes.is_empty());
1330    }
1331
1332    #[test]
1333    fn test_dedent_multiple_lines() {
1334        let text = "    foo line 1\n    bar line 2\n    foo line 3\n";
1335        let op = Op::Dedent {
1336            find: "foo".to_string(),
1337            amount: 4,
1338            regex: false,
1339            case_insensitive: false,
1340        };
1341        let matcher = Matcher::new(&op).unwrap();
1342        let result = apply(text, &op, &matcher, None, 0).unwrap();
1343        assert_eq!(
1344            result.text.unwrap(),
1345            "foo line 1\n    bar line 2\nfoo line 3\n"
1346        );
1347        assert_eq!(result.changes.len(), 2);
1348    }
1349
1350    #[test]
1351    fn test_dedent_no_match() {
1352        let text = "    hello world\n";
1353        let op = Op::Dedent {
1354            find: "zzz".to_string(),
1355            amount: 4,
1356            regex: false,
1357            case_insensitive: false,
1358        };
1359        let matcher = Matcher::new(&op).unwrap();
1360        let result = apply(text, &op, &matcher, None, 0).unwrap();
1361        assert!(result.text.is_none());
1362        assert!(result.changes.is_empty());
1363    }
1364
1365    #[test]
1366    fn test_dedent_empty_text() {
1367        let text = "";
1368        let op = Op::Dedent {
1369            find: "anything".to_string(),
1370            amount: 4,
1371            regex: false,
1372            case_insensitive: false,
1373        };
1374        let matcher = Matcher::new(&op).unwrap();
1375        let result = apply(text, &op, &matcher, None, 0).unwrap();
1376        assert!(result.text.is_none());
1377        assert!(result.changes.is_empty());
1378    }
1379
1380    #[test]
1381    fn test_dedent_with_regex() {
1382        let text = "    let x = 1;\n    fn main() {\n";
1383        let op = Op::Dedent {
1384            find: r"let\s+".to_string(),
1385            amount: 4,
1386            regex: true,
1387            case_insensitive: false,
1388        };
1389        let matcher = Matcher::new(&op).unwrap();
1390        let result = apply(text, &op, &matcher, None, 0).unwrap();
1391        assert_eq!(result.text.unwrap(), "let x = 1;\n    fn main() {\n");
1392        assert_eq!(result.changes.len(), 1);
1393    }
1394
1395    #[test]
1396    fn test_dedent_case_insensitive() {
1397        let text = "    Hello\n    hello\n    HELLO\n";
1398        let op = Op::Dedent {
1399            find: "hello".to_string(),
1400            amount: 2,
1401            regex: false,
1402            case_insensitive: true,
1403        };
1404        let matcher = Matcher::new(&op).unwrap();
1405        let result = apply(text, &op, &matcher, None, 0).unwrap();
1406        assert_eq!(result.text.unwrap(), "  Hello\n  hello\n  HELLO\n");
1407        assert_eq!(result.changes.len(), 3);
1408    }
1409
1410    #[test]
1411    fn test_dedent_crlf_preserved() {
1412        let text = "    hello\r\nworld\r\n";
1413        let op = Op::Dedent {
1414            find: "hello".to_string(),
1415            amount: 4,
1416            regex: false,
1417            case_insensitive: false,
1418        };
1419        let matcher = Matcher::new(&op).unwrap();
1420        let result = apply(text, &op, &matcher, None, 0).unwrap();
1421        assert_eq!(result.text.unwrap(), "hello\r\nworld\r\n");
1422    }
1423
1424    #[test]
1425    fn test_dedent_with_line_range() {
1426        let text = "    foo\n    foo\n    foo\n    foo\n";
1427        let op = Op::Dedent {
1428            find: "foo".to_string(),
1429            amount: 4,
1430            regex: false,
1431            case_insensitive: false,
1432        };
1433        let range = Some(LineRange {
1434            start: 2,
1435            end: Some(3),
1436        });
1437        let matcher = Matcher::new(&op).unwrap();
1438        let result = apply(text, &op, &matcher, range, 0).unwrap();
1439        assert_eq!(result.text.unwrap(), "    foo\nfoo\nfoo\n    foo\n");
1440        assert_eq!(result.changes.len(), 2);
1441    }
1442
1443    #[test]
1444    fn test_dedent_only_removes_spaces_not_tabs() {
1445        // Dedent only strips leading spaces, not tabs
1446        let text = "\t\thello\n";
1447        let op = Op::Dedent {
1448            find: "hello".to_string(),
1449            amount: 4,
1450            regex: false,
1451            case_insensitive: false,
1452        };
1453        let matcher = Matcher::new(&op).unwrap();
1454        let result = apply(text, &op, &matcher, None, 0).unwrap();
1455        // The dedent_line function only strips spaces (trim_start_matches(' ')),
1456        // tabs are not removed.
1457        assert!(result.text.is_none());
1458    }
1459
1460    // ---------------------------------------------------------------
1461    // Indent then Dedent roundtrip
1462    // ---------------------------------------------------------------
1463
1464    #[test]
1465    fn test_indent_then_dedent_roundtrip() {
1466        let original = "hello world\nfoo bar\n";
1467
1468        // Step 1: Indent by 4
1469        let indent_op = Op::Indent {
1470            find: "hello".to_string(),
1471            amount: 4,
1472            use_tabs: false,
1473            regex: false,
1474            case_insensitive: false,
1475        };
1476        let indent_matcher = Matcher::new(&indent_op).unwrap();
1477        let indented = apply(original, &indent_op, &indent_matcher, None, 0).unwrap();
1478        let indented_text = indented.text.unwrap();
1479        assert_eq!(indented_text, "    hello world\nfoo bar\n");
1480
1481        // Step 2: Dedent by 4 (the find still matches because "hello" is in the line)
1482        let dedent_op = Op::Dedent {
1483            find: "hello".to_string(),
1484            amount: 4,
1485            regex: false,
1486            case_insensitive: false,
1487        };
1488        let dedent_matcher = Matcher::new(&dedent_op).unwrap();
1489        let dedented = apply(&indented_text, &dedent_op, &dedent_matcher, None, 0).unwrap();
1490        assert_eq!(dedented.text.unwrap(), original);
1491    }
1492
1493    // ---------------------------------------------------------------
1494    // Undo entry tests for new ops
1495    // ---------------------------------------------------------------
1496
1497    #[test]
1498    fn test_transform_undo_stores_original() {
1499        let text = "hello world\n";
1500        let op = Op::Transform {
1501            find: "hello".to_string(),
1502            mode: TransformMode::Upper,
1503            regex: false,
1504            case_insensitive: false,
1505        };
1506        let matcher = Matcher::new(&op).unwrap();
1507        let result = apply(text, &op, &matcher, None, 0).unwrap();
1508        assert_eq!(result.undo.unwrap().original_text, text);
1509    }
1510
1511    #[test]
1512    fn test_surround_undo_stores_original() {
1513        let text = "hello world\n";
1514        let op = Op::Surround {
1515            find: "hello".to_string(),
1516            prefix: "<".to_string(),
1517            suffix: ">".to_string(),
1518            regex: false,
1519            case_insensitive: false,
1520        };
1521        let matcher = Matcher::new(&op).unwrap();
1522        let result = apply(text, &op, &matcher, None, 0).unwrap();
1523        assert_eq!(result.undo.unwrap().original_text, text);
1524    }
1525
1526    #[test]
1527    fn test_indent_undo_stores_original() {
1528        let text = "hello world\n";
1529        let op = Op::Indent {
1530            find: "hello".to_string(),
1531            amount: 4,
1532            use_tabs: false,
1533            regex: false,
1534            case_insensitive: false,
1535        };
1536        let matcher = Matcher::new(&op).unwrap();
1537        let result = apply(text, &op, &matcher, None, 0).unwrap();
1538        assert_eq!(result.undo.unwrap().original_text, text);
1539    }
1540
1541    #[test]
1542    fn test_dedent_undo_stores_original() {
1543        let text = "    hello world\n";
1544        let op = Op::Dedent {
1545            find: "hello".to_string(),
1546            amount: 4,
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.undo.unwrap().original_text, text);
1553    }
1554
1555    // ---------------------------------------------------------------
1556    // Line preservation tests for new ops
1557    // ---------------------------------------------------------------
1558
1559    #[test]
1560    fn test_transform_preserves_line_count() {
1561        let text = "hello\nworld\nfoo\n";
1562        let op = Op::Transform {
1563            find: "hello".to_string(),
1564            mode: TransformMode::Upper,
1565            regex: false,
1566            case_insensitive: false,
1567        };
1568        let matcher = Matcher::new(&op).unwrap();
1569        let result = apply(text, &op, &matcher, None, 0).unwrap();
1570        let output = result.text.unwrap();
1571        assert_eq!(text.lines().count(), output.lines().count());
1572    }
1573
1574    #[test]
1575    fn test_surround_preserves_line_count() {
1576        let text = "hello\nworld\nfoo\n";
1577        let op = Op::Surround {
1578            find: "hello".to_string(),
1579            prefix: "<".to_string(),
1580            suffix: ">".to_string(),
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        let output = result.text.unwrap();
1587        assert_eq!(text.lines().count(), output.lines().count());
1588    }
1589
1590    #[test]
1591    fn test_indent_preserves_line_count() {
1592        let text = "hello\nworld\nfoo\n";
1593        let op = Op::Indent {
1594            find: "hello".to_string(),
1595            amount: 4,
1596            use_tabs: false,
1597            regex: false,
1598            case_insensitive: false,
1599        };
1600        let matcher = Matcher::new(&op).unwrap();
1601        let result = apply(text, &op, &matcher, None, 0).unwrap();
1602        let output = result.text.unwrap();
1603        assert_eq!(text.lines().count(), output.lines().count());
1604    }
1605
1606    #[test]
1607    fn test_dedent_preserves_line_count() {
1608        let text = "    hello\n    world\n    foo\n";
1609        let op = Op::Dedent {
1610            find: "hello".to_string(),
1611            amount: 4,
1612            regex: false,
1613            case_insensitive: false,
1614        };
1615        let matcher = Matcher::new(&op).unwrap();
1616        let result = apply(text, &op, &matcher, None, 0).unwrap();
1617        let output = result.text.unwrap();
1618        assert_eq!(text.lines().count(), output.lines().count());
1619    }
1620}
1621
1622// ---------------------------------------------------------------
1623// Property-based tests (proptest)
1624// ---------------------------------------------------------------
1625#[cfg(test)]
1626mod proptests {
1627    use super::*;
1628    use crate::matcher::Matcher;
1629    use crate::operation::Op;
1630    use proptest::prelude::*;
1631
1632    /// Strategy for generating text that is multiple lines with a trailing newline.
1633    fn arb_multiline_text() -> impl Strategy<Value = String> {
1634        prop::collection::vec("[^\n\r]{0,80}", 1..10).prop_map(|lines| lines.join("\n") + "\n")
1635    }
1636
1637    /// Strategy for generating a non-empty find pattern (plain literal).
1638    fn arb_find_pattern() -> impl Strategy<Value = String> {
1639        "[a-zA-Z0-9]{1,8}"
1640    }
1641
1642    proptest! {
1643        /// Round-trip: applying a Replace then undoing (restoring original_text)
1644        /// should give back the original text.
1645        #[test]
1646        fn prop_roundtrip_undo(
1647            text in arb_multiline_text(),
1648            find in arb_find_pattern(),
1649            replace in "[a-zA-Z0-9]{0,8}",
1650        ) {
1651            let op = Op::Replace {
1652                find: find.clone(),
1653                replace: replace.clone(),
1654                regex: false,
1655                case_insensitive: false,
1656            };
1657            let matcher = Matcher::new(&op).unwrap();
1658            let result = apply(&text, &op, &matcher, None, 0).unwrap();
1659
1660            if let Some(undo) = &result.undo {
1661                // Undo should restore original text
1662                prop_assert_eq!(&undo.original_text, &text);
1663            }
1664            // If no changes, text should be None
1665            if result.text.is_none() {
1666                prop_assert!(result.changes.is_empty());
1667            }
1668        }
1669
1670        /// No-op: applying with a pattern that cannot match leaves text unchanged.
1671        #[test]
1672        fn prop_noop_nonmatching_pattern(text in arb_multiline_text()) {
1673            // Use a pattern with a NUL byte which will never appear in text generated
1674            // by arb_multiline_text
1675            let op = Op::Replace {
1676                find: "\x00\x00NOMATCH\x00\x00".to_string(),
1677                replace: "replacement".to_string(),
1678                regex: false,
1679                case_insensitive: false,
1680            };
1681            let matcher = Matcher::new(&op).unwrap();
1682            let result = apply(&text, &op, &matcher, None, 0).unwrap();
1683            prop_assert!(result.text.is_none(), "Non-matching pattern should not modify text");
1684            prop_assert!(result.changes.is_empty());
1685            prop_assert!(result.undo.is_none());
1686        }
1687
1688        /// Determinism: same input always produces same output.
1689        #[test]
1690        fn prop_deterministic(
1691            text in arb_multiline_text(),
1692            find in arb_find_pattern(),
1693            replace in "[a-zA-Z0-9]{0,8}",
1694        ) {
1695            let op = Op::Replace {
1696                find,
1697                replace,
1698                regex: false,
1699                case_insensitive: false,
1700            };
1701            let matcher = Matcher::new(&op).unwrap();
1702            let r1 = apply(&text, &op, &matcher, None, 0).unwrap();
1703            let r2 = apply(&text, &op, &matcher, None, 0).unwrap();
1704            prop_assert_eq!(&r1.text, &r2.text);
1705            prop_assert_eq!(r1.changes.len(), r2.changes.len());
1706        }
1707
1708        /// Line count: for Replace ops, output line count == input line count.
1709        #[test]
1710        fn prop_replace_preserves_line_count(
1711            text in arb_multiline_text(),
1712            find in arb_find_pattern(),
1713            replace in "[a-zA-Z0-9]{0,8}",
1714        ) {
1715            let op = Op::Replace {
1716                find,
1717                replace,
1718                regex: false,
1719                case_insensitive: false,
1720            };
1721            let matcher = Matcher::new(&op).unwrap();
1722            let result = apply(&text, &op, &matcher, None, 0).unwrap();
1723            if let Some(ref output) = result.text {
1724                let input_lines = text.lines().count();
1725                let output_lines = output.lines().count();
1726                prop_assert_eq!(
1727                    input_lines,
1728                    output_lines,
1729                    "Replace should preserve line count: input={} output={}",
1730                    input_lines,
1731                    output_lines
1732                );
1733            }
1734        }
1735
1736        /// Indent then Dedent by the same amount should restore the original text
1737        /// when every line contains the find pattern and starts with enough spaces.
1738        #[test]
1739        fn prop_indent_dedent_roundtrip(
1740            amount in 1usize..=16,
1741        ) {
1742            // Use a known find pattern that appears on every line
1743            let find = "marker".to_string();
1744            let text = "marker line one\nmarker line two\nmarker line three\n";
1745
1746            let indent_op = Op::Indent {
1747                find: find.clone(),
1748                amount,
1749                use_tabs: false,
1750                regex: false,
1751                case_insensitive: false,
1752            };
1753            let indent_matcher = Matcher::new(&indent_op).unwrap();
1754            let indented = apply(text, &indent_op, &indent_matcher, None, 0).unwrap();
1755            let indented_text = indented.text.unwrap();
1756
1757            // Every line should now start with `amount` spaces
1758            for line in indented_text.lines() {
1759                let leading = line.len() - line.trim_start_matches(' ').len();
1760                prop_assert!(leading >= amount, "Expected at least {} leading spaces, got {}", amount, leading);
1761            }
1762
1763            let dedent_op = Op::Dedent {
1764                find: find.clone(),
1765                amount,
1766                regex: false,
1767                case_insensitive: false,
1768            };
1769            let dedent_matcher = Matcher::new(&dedent_op).unwrap();
1770            let dedented = apply(&indented_text, &dedent_op, &dedent_matcher, None, 0).unwrap();
1771            prop_assert_eq!(dedented.text.unwrap(), text);
1772        }
1773
1774        /// Transform Upper then Lower should restore the original when
1775        /// the text is already all lowercase ASCII.
1776        #[test]
1777        fn prop_transform_upper_lower_roundtrip(
1778            find in "[a-z]{1,8}",
1779        ) {
1780            let text = format!("prefix {find} suffix\n");
1781
1782            let upper_op = Op::Transform {
1783                find: find.clone(),
1784                mode: crate::operation::TransformMode::Upper,
1785                regex: false,
1786                case_insensitive: false,
1787            };
1788            let upper_matcher = Matcher::new(&upper_op).unwrap();
1789            let uppered = apply(&text, &upper_op, &upper_matcher, None, 0).unwrap();
1790
1791            if let Some(ref upper_text) = uppered.text {
1792                let upper_find = find.to_uppercase();
1793                let lower_op = Op::Transform {
1794                    find: upper_find,
1795                    mode: crate::operation::TransformMode::Lower,
1796                    regex: false,
1797                    case_insensitive: false,
1798                };
1799                let lower_matcher = Matcher::new(&lower_op).unwrap();
1800                let lowered = apply(upper_text, &lower_op, &lower_matcher, None, 0).unwrap();
1801                prop_assert_eq!(lowered.text.unwrap(), text);
1802            }
1803        }
1804
1805        /// Surround preserves line count.
1806        #[test]
1807        fn prop_surround_preserves_line_count(
1808            text in arb_multiline_text(),
1809            find in arb_find_pattern(),
1810        ) {
1811            let op = Op::Surround {
1812                find,
1813                prefix: "<<".to_string(),
1814                suffix: ">>".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            if let Some(ref output) = result.text {
1821                let input_lines = text.lines().count();
1822                let output_lines = output.lines().count();
1823                prop_assert_eq!(
1824                    input_lines,
1825                    output_lines,
1826                    "Surround should preserve line count: input={} output={}",
1827                    input_lines,
1828                    output_lines
1829                );
1830            }
1831        }
1832
1833        /// Transform preserves line count.
1834        #[test]
1835        fn prop_transform_preserves_line_count(
1836            text in arb_multiline_text(),
1837            find in arb_find_pattern(),
1838        ) {
1839            let op = Op::Transform {
1840                find,
1841                mode: crate::operation::TransformMode::Upper,
1842                regex: false,
1843                case_insensitive: false,
1844            };
1845            let matcher = Matcher::new(&op).unwrap();
1846            let result = apply(&text, &op, &matcher, None, 0).unwrap();
1847            if let Some(ref output) = result.text {
1848                let input_lines = text.lines().count();
1849                let output_lines = output.lines().count();
1850                prop_assert_eq!(
1851                    input_lines,
1852                    output_lines,
1853                    "Transform should preserve line count: input={} output={}",
1854                    input_lines,
1855                    output_lines
1856                );
1857            }
1858        }
1859
1860        /// Indent preserves line count.
1861        #[test]
1862        fn prop_indent_preserves_line_count(
1863            text in arb_multiline_text(),
1864            find in arb_find_pattern(),
1865            amount in 1usize..=16,
1866        ) {
1867            let op = Op::Indent {
1868                find,
1869                amount,
1870                use_tabs: false,
1871                regex: false,
1872                case_insensitive: false,
1873            };
1874            let matcher = Matcher::new(&op).unwrap();
1875            let result = apply(&text, &op, &matcher, None, 0).unwrap();
1876            if let Some(ref output) = result.text {
1877                let input_lines = text.lines().count();
1878                let output_lines = output.lines().count();
1879                prop_assert_eq!(
1880                    input_lines,
1881                    output_lines,
1882                    "Indent should preserve line count: input={} output={}",
1883                    input_lines,
1884                    output_lines
1885                );
1886            }
1887        }
1888    }
1889}