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        regex: bool,
122        #[serde(default)]
123        case_insensitive: bool,
124    },
125}
126
127fn default_indent_amount() -> usize {
128    4
129}
130
131/// Options that control how operations are applied.
132#[derive(Debug, Clone, Serialize, Deserialize)]
133pub struct OpOptions {
134    #[serde(default = "default_true")]
135    pub dry_run: bool,
136    pub root: Option<String>,
137    #[serde(default = "default_true")]
138    pub gitignore: bool,
139    #[serde(default)]
140    pub backup: bool,
141    #[serde(default)]
142    pub atomic: bool,
143    pub glob: Option<String>,
144    pub ignore: Option<String>,
145    #[serde(default)]
146    pub hidden: bool,
147    pub max_depth: Option<usize>,
148    pub line_range: Option<LineRange>,
149}
150
151impl Default for OpOptions {
152    fn default() -> Self {
153        Self {
154            dry_run: true,
155            root: None,
156            gitignore: true,
157            backup: false,
158            atomic: false,
159            glob: None,
160            ignore: None,
161            hidden: false,
162            max_depth: None,
163            line_range: None,
164        }
165    }
166}
167
168/// A range of lines to operate on (1-indexed, inclusive).
169#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
170pub struct LineRange {
171    pub start: usize,
172    pub end: Option<usize>,
173}
174
175impl LineRange {
176    pub fn contains(&self, line: usize) -> bool {
177        line >= self.start && self.end.is_none_or(|end| line <= end)
178    }
179}
180
181fn default_true() -> bool {
182    true
183}
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            regex: true,
636            case_insensitive: true,
637        };
638        let json = serde_json::to_string(&op).unwrap();
639        let deserialized: Op = serde_json::from_str(&json).unwrap();
640        assert_eq!(op, deserialized);
641    }
642
643    #[test]
644    fn dedent_serializes_with_op_tag() {
645        let op = Op::Dedent {
646            find: "line".into(),
647            amount: 4,
648            regex: false,
649            case_insensitive: false,
650        };
651        let json = serde_json::to_value(&op).unwrap();
652        assert_eq!(json["op"], "dedent");
653        assert_eq!(json["find"], "line");
654        assert_eq!(json["amount"], 4);
655    }
656
657    #[test]
658    fn dedent_deserialize_with_defaults() {
659        let json = r#"{"op": "dedent", "find": "x"}"#;
660        let op: Op = serde_json::from_str(json).unwrap();
661        assert!(!op.is_regex());
662        assert!(!op.is_case_insensitive());
663        // amount should default to 4
664        match op {
665            Op::Dedent { amount, .. } => {
666                assert_eq!(amount, 4);
667            }
668            _ => panic!("Expected Dedent variant"),
669        }
670    }
671
672    // ── Accessor methods for new variants ──
673
674    #[test]
675    fn find_pattern_returns_find_for_new_variants() {
676        let ops = [
677            Op::Transform {
678                find: "t".into(),
679                mode: TransformMode::Upper,
680                regex: false,
681                case_insensitive: false,
682            },
683            Op::Surround {
684                find: "s".into(),
685                prefix: "<".into(),
686                suffix: ">".into(),
687                regex: false,
688                case_insensitive: false,
689            },
690            Op::Indent {
691                find: "i".into(),
692                amount: 4,
693                use_tabs: false,
694                regex: false,
695                case_insensitive: false,
696            },
697            Op::Dedent {
698                find: "d".into(),
699                amount: 4,
700                regex: false,
701                case_insensitive: false,
702            },
703        ];
704        let expected = ["t", "s", "i", "d"];
705        for (op, exp) in ops.iter().zip(expected.iter()) {
706            assert_eq!(op.find_pattern(), *exp);
707        }
708    }
709
710    #[test]
711    fn is_regex_reflects_field_for_new_variants() {
712        let ops = [
713            Op::Transform {
714                find: "x".into(),
715                mode: TransformMode::Upper,
716                regex: true,
717                case_insensitive: false,
718            },
719            Op::Surround {
720                find: "x".into(),
721                prefix: "<".into(),
722                suffix: ">".into(),
723                regex: true,
724                case_insensitive: false,
725            },
726            Op::Indent {
727                find: "x".into(),
728                amount: 4,
729                use_tabs: false,
730                regex: true,
731                case_insensitive: false,
732            },
733            Op::Dedent {
734                find: "x".into(),
735                amount: 4,
736                regex: true,
737                case_insensitive: false,
738            },
739        ];
740        for op in &ops {
741            assert!(op.is_regex());
742        }
743    }
744
745    #[test]
746    fn is_case_insensitive_reflects_field_for_new_variants() {
747        let ops = [
748            Op::Transform {
749                find: "x".into(),
750                mode: TransformMode::Upper,
751                regex: false,
752                case_insensitive: true,
753            },
754            Op::Surround {
755                find: "x".into(),
756                prefix: "<".into(),
757                suffix: ">".into(),
758                regex: false,
759                case_insensitive: true,
760            },
761            Op::Indent {
762                find: "x".into(),
763                amount: 4,
764                use_tabs: false,
765                regex: false,
766                case_insensitive: true,
767            },
768            Op::Dedent {
769                find: "x".into(),
770                amount: 4,
771                regex: false,
772                case_insensitive: true,
773            },
774        ];
775        for op in &ops {
776            assert!(op.is_case_insensitive());
777        }
778    }
779
780    // ── TransformMode Display and FromStr ──
781
782    #[test]
783    fn transform_mode_display_roundtrip() {
784        let modes = [
785            TransformMode::Upper,
786            TransformMode::Lower,
787            TransformMode::Title,
788            TransformMode::SnakeCase,
789            TransformMode::CamelCase,
790        ];
791        for mode in modes {
792            let s = mode.to_string();
793            let parsed: TransformMode = s.parse().unwrap();
794            assert_eq!(mode, parsed);
795        }
796    }
797
798    #[test]
799    fn transform_mode_from_str_aliases() {
800        assert_eq!(
801            "snake".parse::<TransformMode>().unwrap(),
802            TransformMode::SnakeCase
803        );
804        assert_eq!(
805            "camel".parse::<TransformMode>().unwrap(),
806            TransformMode::CamelCase
807        );
808    }
809
810    #[test]
811    fn transform_mode_from_str_unknown_fails() {
812        assert!("unknown".parse::<TransformMode>().is_err());
813    }
814}