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