Skip to main content

ripsed_core/
operation.rs

1use serde::{Deserialize, Serialize};
2
3/// Text transformation modes for the Transform operation.
4#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
5#[serde(rename_all = "snake_case")]
6#[non_exhaustive]
7pub enum TransformMode {
8    Upper,
9    Lower,
10    Title,
11    SnakeCase,
12    CamelCase,
13}
14
15impl std::fmt::Display for TransformMode {
16    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
17        match self {
18            TransformMode::Upper => write!(f, "upper"),
19            TransformMode::Lower => write!(f, "lower"),
20            TransformMode::Title => write!(f, "title"),
21            TransformMode::SnakeCase => write!(f, "snake_case"),
22            TransformMode::CamelCase => write!(f, "camel_case"),
23        }
24    }
25}
26
27impl std::str::FromStr for TransformMode {
28    type Err = String;
29    fn from_str(s: &str) -> Result<Self, Self::Err> {
30        match s {
31            "upper" => Ok(TransformMode::Upper),
32            "lower" => Ok(TransformMode::Lower),
33            "title" => Ok(TransformMode::Title),
34            "snake_case" | "snake" => Ok(TransformMode::SnakeCase),
35            "camel_case" | "camel" => Ok(TransformMode::CamelCase),
36            _ => Err(format!(
37                "unknown transform mode '{s}'. Valid modes: upper, lower, title, snake_case, camel_case"
38            )),
39        }
40    }
41}
42
43/// The intermediate representation for all ripsed operations.
44/// Both CLI args and JSON requests are normalized into this form.
45#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
46#[serde(tag = "op", rename_all = "snake_case")]
47#[non_exhaustive]
48pub enum Op {
49    Replace {
50        find: String,
51        replace: String,
52        #[serde(default)]
53        regex: bool,
54        #[serde(default)]
55        case_insensitive: bool,
56    },
57    Delete {
58        find: String,
59        #[serde(default)]
60        regex: bool,
61        #[serde(default)]
62        case_insensitive: bool,
63    },
64    InsertAfter {
65        find: String,
66        content: String,
67        #[serde(default)]
68        regex: bool,
69        #[serde(default)]
70        case_insensitive: bool,
71    },
72    InsertBefore {
73        find: String,
74        content: String,
75        #[serde(default)]
76        regex: bool,
77        #[serde(default)]
78        case_insensitive: bool,
79    },
80    ReplaceLine {
81        find: String,
82        content: String,
83        #[serde(default)]
84        regex: bool,
85        #[serde(default)]
86        case_insensitive: bool,
87    },
88    Transform {
89        find: String,
90        mode: TransformMode,
91        #[serde(default)]
92        regex: bool,
93        #[serde(default)]
94        case_insensitive: bool,
95    },
96    Surround {
97        find: String,
98        prefix: String,
99        suffix: String,
100        #[serde(default)]
101        regex: bool,
102        #[serde(default)]
103        case_insensitive: bool,
104    },
105    Indent {
106        find: String,
107        #[serde(default = "default_indent_amount")]
108        amount: usize,
109        #[serde(default)]
110        use_tabs: bool,
111        #[serde(default)]
112        regex: bool,
113        #[serde(default)]
114        case_insensitive: bool,
115    },
116    Dedent {
117        find: String,
118        #[serde(default = "default_indent_amount")]
119        amount: usize,
120        #[serde(default)]
121        use_tabs: bool,
122        #[serde(default)]
123        regex: bool,
124        #[serde(default)]
125        case_insensitive: bool,
126    },
127}
128
129fn default_indent_amount() -> usize {
130    4
131}
132
133/// Options that control how operations are applied.
134#[derive(Debug, Clone, Serialize, Deserialize)]
135pub struct OpOptions {
136    #[serde(default = "default_true")]
137    pub dry_run: bool,
138    pub root: Option<String>,
139    #[serde(default = "default_true")]
140    pub gitignore: bool,
141    #[serde(default)]
142    pub backup: bool,
143    #[serde(default)]
144    pub atomic: bool,
145    pub glob: Option<String>,
146    pub ignore: Option<String>,
147    #[serde(default)]
148    pub hidden: bool,
149    pub max_depth: Option<usize>,
150    pub line_range: Option<LineRange>,
151}
152
153impl Default for OpOptions {
154    fn default() -> Self {
155        Self {
156            dry_run: true,
157            root: None,
158            gitignore: true,
159            backup: false,
160            atomic: false,
161            glob: None,
162            ignore: None,
163            hidden: false,
164            max_depth: None,
165            line_range: None,
166        }
167    }
168}
169
170/// A range of lines to operate on (1-indexed, inclusive).
171#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
172pub struct LineRange {
173    pub start: usize,
174    pub end: Option<usize>,
175}
176
177impl LineRange {
178    pub fn contains(&self, line: usize) -> bool {
179        line >= self.start && self.end.is_none_or(|end| line <= end)
180    }
181}
182
183use crate::default_true;
184
185impl Op {
186    /// Extract the find pattern from the operation.
187    pub fn find_pattern(&self) -> &str {
188        match self {
189            Op::Replace { find, .. }
190            | Op::Delete { find, .. }
191            | Op::InsertAfter { find, .. }
192            | Op::InsertBefore { find, .. }
193            | Op::ReplaceLine { find, .. }
194            | Op::Transform { find, .. }
195            | Op::Surround { find, .. }
196            | Op::Indent { find, .. }
197            | Op::Dedent { find, .. } => find,
198        }
199    }
200
201    pub fn is_regex(&self) -> bool {
202        match self {
203            Op::Replace { regex, .. }
204            | Op::Delete { regex, .. }
205            | Op::InsertAfter { regex, .. }
206            | Op::InsertBefore { regex, .. }
207            | Op::ReplaceLine { regex, .. }
208            | Op::Transform { regex, .. }
209            | Op::Surround { regex, .. }
210            | Op::Indent { regex, .. }
211            | Op::Dedent { regex, .. } => *regex,
212        }
213    }
214
215    pub fn is_case_insensitive(&self) -> bool {
216        match self {
217            Op::Replace {
218                case_insensitive, ..
219            }
220            | Op::Delete {
221                case_insensitive, ..
222            }
223            | Op::InsertAfter {
224                case_insensitive, ..
225            }
226            | Op::InsertBefore {
227                case_insensitive, ..
228            }
229            | Op::ReplaceLine {
230                case_insensitive, ..
231            }
232            | Op::Transform {
233                case_insensitive, ..
234            }
235            | Op::Surround {
236                case_insensitive, ..
237            }
238            | Op::Indent {
239                case_insensitive, ..
240            }
241            | Op::Dedent {
242                case_insensitive, ..
243            } => *case_insensitive,
244        }
245    }
246}
247
248#[cfg(test)]
249mod tests {
250    use super::*;
251
252    // ── Op serde roundtrip ──
253
254    #[test]
255    fn replace_serializes_with_op_tag() {
256        let op = Op::Replace {
257            find: "foo".into(),
258            replace: "bar".into(),
259            regex: false,
260            case_insensitive: false,
261        };
262        let json = serde_json::to_value(&op).unwrap();
263        assert_eq!(json["op"], "replace");
264        assert_eq!(json["find"], "foo");
265        assert_eq!(json["replace"], "bar");
266    }
267
268    #[test]
269    fn delete_roundtrips_through_json() {
270        let op = Op::Delete {
271            find: "TODO".into(),
272            regex: true,
273            case_insensitive: false,
274        };
275        let json = serde_json::to_string(&op).unwrap();
276        let deserialized: Op = serde_json::from_str(&json).unwrap();
277        assert_eq!(op, deserialized);
278    }
279
280    #[test]
281    fn insert_after_roundtrips_through_json() {
282        let op = Op::InsertAfter {
283            find: "use serde;".into(),
284            content: "use serde_json;".into(),
285            regex: false,
286            case_insensitive: true,
287        };
288        let json = serde_json::to_string(&op).unwrap();
289        let deserialized: Op = serde_json::from_str(&json).unwrap();
290        assert_eq!(op, deserialized);
291    }
292
293    #[test]
294    fn insert_before_roundtrips_through_json() {
295        let op = Op::InsertBefore {
296            find: "fn main".into(),
297            content: "// entry".into(),
298            regex: false,
299            case_insensitive: false,
300        };
301        let json = serde_json::to_string(&op).unwrap();
302        let deserialized: Op = serde_json::from_str(&json).unwrap();
303        assert_eq!(op, deserialized);
304    }
305
306    #[test]
307    fn replace_line_roundtrips_through_json() {
308        let op = Op::ReplaceLine {
309            find: "old".into(),
310            content: "new".into(),
311            regex: true,
312            case_insensitive: true,
313        };
314        let json = serde_json::to_string(&op).unwrap();
315        let deserialized: Op = serde_json::from_str(&json).unwrap();
316        assert_eq!(op, deserialized);
317    }
318
319    #[test]
320    fn deserialize_with_default_booleans() {
321        let json = r#"{"op": "replace", "find": "a", "replace": "b"}"#;
322        let op: Op = serde_json::from_str(json).unwrap();
323        assert!(!op.is_regex());
324        assert!(!op.is_case_insensitive());
325    }
326
327    #[test]
328    fn unknown_op_tag_fails_deserialization() {
329        let json = r#"{"op": "transform", "find": "a"}"#;
330        let result = serde_json::from_str::<Op>(json);
331        assert!(result.is_err());
332    }
333
334    // ── Accessor methods ──
335
336    #[test]
337    fn find_pattern_returns_find_for_all_variants() {
338        let ops = [
339            Op::Replace {
340                find: "a".into(),
341                replace: "b".into(),
342                regex: false,
343                case_insensitive: false,
344            },
345            Op::Delete {
346                find: "c".into(),
347                regex: false,
348                case_insensitive: false,
349            },
350            Op::InsertAfter {
351                find: "d".into(),
352                content: "e".into(),
353                regex: false,
354                case_insensitive: false,
355            },
356            Op::InsertBefore {
357                find: "f".into(),
358                content: "g".into(),
359                regex: false,
360                case_insensitive: false,
361            },
362            Op::ReplaceLine {
363                find: "h".into(),
364                content: "i".into(),
365                regex: false,
366                case_insensitive: false,
367            },
368        ];
369        let expected = ["a", "c", "d", "f", "h"];
370        for (op, exp) in ops.iter().zip(expected.iter()) {
371            assert_eq!(op.find_pattern(), *exp);
372        }
373    }
374
375    #[test]
376    fn is_regex_reflects_field() {
377        let op = Op::Delete {
378            find: "x".into(),
379            regex: true,
380            case_insensitive: false,
381        };
382        assert!(op.is_regex());
383    }
384
385    #[test]
386    fn is_case_insensitive_reflects_field() {
387        let op = Op::Replace {
388            find: "x".into(),
389            replace: "y".into(),
390            regex: false,
391            case_insensitive: true,
392        };
393        assert!(op.is_case_insensitive());
394    }
395
396    // ── LineRange ──
397
398    #[test]
399    fn line_range_contains_bounded() {
400        let range = LineRange {
401            start: 5,
402            end: Some(10),
403        };
404        assert!(!range.contains(4));
405        assert!(range.contains(5));
406        assert!(range.contains(7));
407        assert!(range.contains(10));
408        assert!(!range.contains(11));
409    }
410
411    #[test]
412    fn line_range_contains_unbounded_end() {
413        let range = LineRange {
414            start: 3,
415            end: None,
416        };
417        assert!(!range.contains(2));
418        assert!(range.contains(3));
419        assert!(range.contains(1000));
420    }
421
422    #[test]
423    fn line_range_single_line() {
424        let range = LineRange {
425            start: 7,
426            end: Some(7),
427        };
428        assert!(!range.contains(6));
429        assert!(range.contains(7));
430        assert!(!range.contains(8));
431    }
432
433    #[test]
434    fn line_range_roundtrips_through_json() {
435        let range = LineRange {
436            start: 1,
437            end: Some(50),
438        };
439        let json = serde_json::to_string(&range).unwrap();
440        let deserialized: LineRange = serde_json::from_str(&json).unwrap();
441        assert_eq!(range, deserialized);
442    }
443
444    // ── OpOptions ──
445
446    #[test]
447    fn op_options_default_values() {
448        let opts = OpOptions::default();
449        assert!(opts.dry_run);
450        assert!(opts.gitignore);
451        assert!(!opts.backup);
452        assert!(!opts.atomic);
453        assert!(!opts.hidden);
454        assert!(opts.root.is_none());
455        assert!(opts.glob.is_none());
456        assert!(opts.ignore.is_none());
457        assert!(opts.max_depth.is_none());
458        assert!(opts.line_range.is_none());
459    }
460
461    #[test]
462    fn op_options_deserializes_with_defaults() {
463        let json = "{}";
464        let opts: OpOptions = serde_json::from_str(json).unwrap();
465        assert!(opts.dry_run);
466        assert!(opts.gitignore);
467    }
468
469    #[test]
470    fn op_options_overrides_defaults() {
471        let json = r#"{"dry_run": false, "gitignore": false, "backup": true}"#;
472        let opts: OpOptions = serde_json::from_str(json).unwrap();
473        assert!(!opts.dry_run);
474        assert!(!opts.gitignore);
475        assert!(opts.backup);
476    }
477
478    // ── New Op serde roundtrip tests ──
479
480    #[test]
481    fn transform_roundtrips_through_json() {
482        let op = Op::Transform {
483            find: "myVar".into(),
484            mode: TransformMode::SnakeCase,
485            regex: false,
486            case_insensitive: true,
487        };
488        let json = serde_json::to_string(&op).unwrap();
489        let deserialized: Op = serde_json::from_str(&json).unwrap();
490        assert_eq!(op, deserialized);
491    }
492
493    #[test]
494    fn transform_serializes_with_op_tag() {
495        let op = Op::Transform {
496            find: "hello".into(),
497            mode: TransformMode::Upper,
498            regex: true,
499            case_insensitive: false,
500        };
501        let json = serde_json::to_value(&op).unwrap();
502        assert_eq!(json["op"], "transform");
503        assert_eq!(json["find"], "hello");
504        assert_eq!(json["mode"], "upper");
505        assert_eq!(json["regex"], true);
506    }
507
508    #[test]
509    fn transform_all_modes_roundtrip() {
510        let modes = [
511            TransformMode::Upper,
512            TransformMode::Lower,
513            TransformMode::Title,
514            TransformMode::SnakeCase,
515            TransformMode::CamelCase,
516        ];
517        for mode in modes {
518            let op = Op::Transform {
519                find: "test".into(),
520                mode,
521                regex: false,
522                case_insensitive: false,
523            };
524            let json = serde_json::to_string(&op).unwrap();
525            let deserialized: Op = serde_json::from_str(&json).unwrap();
526            assert_eq!(op, deserialized, "Failed roundtrip for mode {:?}", mode);
527        }
528    }
529
530    #[test]
531    fn transform_deserialize_with_defaults() {
532        let json = r#"{"op": "transform", "find": "x", "mode": "upper"}"#;
533        let op: Op = serde_json::from_str(json).unwrap();
534        assert!(!op.is_regex());
535        assert!(!op.is_case_insensitive());
536    }
537
538    #[test]
539    fn transform_missing_mode_fails() {
540        let json = r#"{"op": "transform", "find": "a"}"#;
541        let result = serde_json::from_str::<Op>(json);
542        assert!(result.is_err());
543    }
544
545    #[test]
546    fn surround_roundtrips_through_json() {
547        let op = Op::Surround {
548            find: "TODO".into(),
549            prefix: "<<".into(),
550            suffix: ">>".into(),
551            regex: true,
552            case_insensitive: false,
553        };
554        let json = serde_json::to_string(&op).unwrap();
555        let deserialized: Op = serde_json::from_str(&json).unwrap();
556        assert_eq!(op, deserialized);
557    }
558
559    #[test]
560    fn surround_serializes_with_op_tag() {
561        let op = Op::Surround {
562            find: "word".into(),
563            prefix: "[".into(),
564            suffix: "]".into(),
565            regex: false,
566            case_insensitive: false,
567        };
568        let json = serde_json::to_value(&op).unwrap();
569        assert_eq!(json["op"], "surround");
570        assert_eq!(json["find"], "word");
571        assert_eq!(json["prefix"], "[");
572        assert_eq!(json["suffix"], "]");
573    }
574
575    #[test]
576    fn surround_deserialize_with_defaults() {
577        let json = r#"{"op": "surround", "find": "x", "prefix": "<", "suffix": ">"}"#;
578        let op: Op = serde_json::from_str(json).unwrap();
579        assert!(!op.is_regex());
580        assert!(!op.is_case_insensitive());
581    }
582
583    #[test]
584    fn indent_roundtrips_through_json() {
585        let op = Op::Indent {
586            find: "fn ".into(),
587            amount: 8,
588            use_tabs: true,
589            regex: false,
590            case_insensitive: false,
591        };
592        let json = serde_json::to_string(&op).unwrap();
593        let deserialized: Op = serde_json::from_str(&json).unwrap();
594        assert_eq!(op, deserialized);
595    }
596
597    #[test]
598    fn indent_serializes_with_op_tag() {
599        let op = Op::Indent {
600            find: "line".into(),
601            amount: 4,
602            use_tabs: false,
603            regex: false,
604            case_insensitive: false,
605        };
606        let json = serde_json::to_value(&op).unwrap();
607        assert_eq!(json["op"], "indent");
608        assert_eq!(json["find"], "line");
609        assert_eq!(json["amount"], 4);
610    }
611
612    #[test]
613    fn indent_deserialize_with_defaults() {
614        let json = r#"{"op": "indent", "find": "x"}"#;
615        let op: Op = serde_json::from_str(json).unwrap();
616        assert!(!op.is_regex());
617        assert!(!op.is_case_insensitive());
618        // amount should default to 4
619        match op {
620            Op::Indent {
621                amount, use_tabs, ..
622            } => {
623                assert_eq!(amount, 4);
624                assert!(!use_tabs);
625            }
626            _ => panic!("Expected Indent variant"),
627        }
628    }
629
630    #[test]
631    fn dedent_roundtrips_through_json() {
632        let op = Op::Dedent {
633            find: "code".into(),
634            amount: 2,
635            use_tabs: false,
636            regex: true,
637            case_insensitive: true,
638        };
639        let json = serde_json::to_string(&op).unwrap();
640        let deserialized: Op = serde_json::from_str(&json).unwrap();
641        assert_eq!(op, deserialized);
642    }
643
644    #[test]
645    fn dedent_serializes_with_op_tag() {
646        let op = Op::Dedent {
647            find: "line".into(),
648            amount: 4,
649            use_tabs: false,
650            regex: false,
651            case_insensitive: false,
652        };
653        let json = serde_json::to_value(&op).unwrap();
654        assert_eq!(json["op"], "dedent");
655        assert_eq!(json["find"], "line");
656        assert_eq!(json["amount"], 4);
657    }
658
659    #[test]
660    fn dedent_deserialize_with_defaults() {
661        let json = r#"{"op": "dedent", "find": "x"}"#;
662        let op: Op = serde_json::from_str(json).unwrap();
663        assert!(!op.is_regex());
664        assert!(!op.is_case_insensitive());
665        // amount should default to 4
666        match op {
667            Op::Dedent { amount, .. } => {
668                assert_eq!(amount, 4);
669            }
670            _ => panic!("Expected Dedent variant"),
671        }
672    }
673
674    // ── Accessor methods for new variants ──
675
676    #[test]
677    fn find_pattern_returns_find_for_new_variants() {
678        let ops = [
679            Op::Transform {
680                find: "t".into(),
681                mode: TransformMode::Upper,
682                regex: false,
683                case_insensitive: false,
684            },
685            Op::Surround {
686                find: "s".into(),
687                prefix: "<".into(),
688                suffix: ">".into(),
689                regex: false,
690                case_insensitive: false,
691            },
692            Op::Indent {
693                find: "i".into(),
694                amount: 4,
695                use_tabs: false,
696                regex: false,
697                case_insensitive: false,
698            },
699            Op::Dedent {
700                find: "d".into(),
701                amount: 4,
702                use_tabs: false,
703                regex: false,
704                case_insensitive: false,
705            },
706        ];
707        let expected = ["t", "s", "i", "d"];
708        for (op, exp) in ops.iter().zip(expected.iter()) {
709            assert_eq!(op.find_pattern(), *exp);
710        }
711    }
712
713    #[test]
714    fn is_regex_reflects_field_for_new_variants() {
715        let ops = [
716            Op::Transform {
717                find: "x".into(),
718                mode: TransformMode::Upper,
719                regex: true,
720                case_insensitive: false,
721            },
722            Op::Surround {
723                find: "x".into(),
724                prefix: "<".into(),
725                suffix: ">".into(),
726                regex: true,
727                case_insensitive: false,
728            },
729            Op::Indent {
730                find: "x".into(),
731                amount: 4,
732                use_tabs: false,
733                regex: true,
734                case_insensitive: false,
735            },
736            Op::Dedent {
737                find: "x".into(),
738                amount: 4,
739                use_tabs: false,
740                regex: true,
741                case_insensitive: false,
742            },
743        ];
744        for op in &ops {
745            assert!(op.is_regex());
746        }
747    }
748
749    #[test]
750    fn is_case_insensitive_reflects_field_for_new_variants() {
751        let ops = [
752            Op::Transform {
753                find: "x".into(),
754                mode: TransformMode::Upper,
755                regex: false,
756                case_insensitive: true,
757            },
758            Op::Surround {
759                find: "x".into(),
760                prefix: "<".into(),
761                suffix: ">".into(),
762                regex: false,
763                case_insensitive: true,
764            },
765            Op::Indent {
766                find: "x".into(),
767                amount: 4,
768                use_tabs: false,
769                regex: false,
770                case_insensitive: true,
771            },
772            Op::Dedent {
773                find: "x".into(),
774                amount: 4,
775                use_tabs: false,
776                regex: false,
777                case_insensitive: true,
778            },
779        ];
780        for op in &ops {
781            assert!(op.is_case_insensitive());
782        }
783    }
784
785    // ── TransformMode Display and FromStr ──
786
787    #[test]
788    fn transform_mode_display_roundtrip() {
789        let modes = [
790            TransformMode::Upper,
791            TransformMode::Lower,
792            TransformMode::Title,
793            TransformMode::SnakeCase,
794            TransformMode::CamelCase,
795        ];
796        for mode in modes {
797            let s = mode.to_string();
798            let parsed: TransformMode = s.parse().unwrap();
799            assert_eq!(mode, parsed);
800        }
801    }
802
803    #[test]
804    fn transform_mode_from_str_aliases() {
805        assert_eq!(
806            "snake".parse::<TransformMode>().unwrap(),
807            TransformMode::SnakeCase
808        );
809        assert_eq!(
810            "camel".parse::<TransformMode>().unwrap(),
811            TransformMode::CamelCase
812        );
813    }
814
815    #[test]
816    fn transform_mode_from_str_unknown_fails() {
817        assert!("unknown".parse::<TransformMode>().is_err());
818    }
819}