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    /// Locks in the protocol wire-format: the `op` tag MUST serialize as
255    /// `"replace"` (snake_case), not `"Replace"`. Agents that depend on
256    /// this wire format would silently break if the tag rename were
257    /// removed. Only one such test per variant class — the rest are
258    /// pure serde-framework roundtrips with no added value.
259    #[test]
260    fn replace_op_tag_wire_format() {
261        let op = Op::Replace {
262            find: "foo".into(),
263            replace: "bar".into(),
264            regex: false,
265            case_insensitive: false,
266        };
267        let json = serde_json::to_value(&op).unwrap();
268        assert_eq!(json["op"], "replace");
269        assert_eq!(json["find"], "foo");
270        assert_eq!(json["replace"], "bar");
271    }
272
273    #[test]
274    fn deserialize_with_default_booleans() {
275        let json = r#"{"op": "replace", "find": "a", "replace": "b"}"#;
276        let op: Op = serde_json::from_str(json).unwrap();
277        assert!(!op.is_regex());
278        assert!(!op.is_case_insensitive());
279    }
280
281    #[test]
282    fn unknown_op_tag_fails_deserialization() {
283        let json = r#"{"op": "transform", "find": "a"}"#;
284        let result = serde_json::from_str::<Op>(json);
285        assert!(result.is_err());
286    }
287
288    // ── LineRange ──
289
290    #[test]
291    fn line_range_contains_bounded() {
292        let range = LineRange {
293            start: 5,
294            end: Some(10),
295        };
296        assert!(!range.contains(4));
297        assert!(range.contains(5));
298        assert!(range.contains(7));
299        assert!(range.contains(10));
300        assert!(!range.contains(11));
301    }
302
303    #[test]
304    fn line_range_contains_unbounded_end() {
305        let range = LineRange {
306            start: 3,
307            end: None,
308        };
309        assert!(!range.contains(2));
310        assert!(range.contains(3));
311        assert!(range.contains(1000));
312    }
313
314    #[test]
315    fn line_range_single_line() {
316        let range = LineRange {
317            start: 7,
318            end: Some(7),
319        };
320        assert!(!range.contains(6));
321        assert!(range.contains(7));
322        assert!(!range.contains(8));
323    }
324
325    // ── OpOptions ──
326
327    #[test]
328    fn op_options_default_values() {
329        let opts = OpOptions::default();
330        assert!(opts.dry_run);
331        assert!(opts.gitignore);
332        assert!(!opts.backup);
333        assert!(!opts.atomic);
334        assert!(!opts.hidden);
335        assert!(opts.root.is_none());
336        assert!(opts.glob.is_none());
337        assert!(opts.ignore.is_none());
338        assert!(opts.max_depth.is_none());
339        assert!(opts.line_range.is_none());
340    }
341
342    #[test]
343    fn op_options_deserializes_with_defaults() {
344        let json = "{}";
345        let opts: OpOptions = serde_json::from_str(json).unwrap();
346        assert!(opts.dry_run);
347        assert!(opts.gitignore);
348    }
349
350    #[test]
351    fn op_options_overrides_defaults() {
352        let json = r#"{"dry_run": false, "gitignore": false, "backup": true}"#;
353        let opts: OpOptions = serde_json::from_str(json).unwrap();
354        assert!(!opts.dry_run);
355        assert!(!opts.gitignore);
356        assert!(opts.backup);
357    }
358
359    // ── Serde defaults and unknown-variant behavior ──
360
361    /// Protocol: `transform` with no `mode` must be rejected, not defaulted.
362    #[test]
363    fn transform_missing_mode_fails() {
364        let json = r#"{"op": "transform", "find": "a"}"#;
365        let result = serde_json::from_str::<Op>(json);
366        assert!(result.is_err());
367    }
368
369    /// Protocol: `indent` amount defaults to 4 when omitted.
370    #[test]
371    fn indent_amount_defaults_to_four() {
372        let json = r#"{"op": "indent", "find": "x"}"#;
373        let op: Op = serde_json::from_str(json).unwrap();
374        match op {
375            Op::Indent {
376                amount, use_tabs, ..
377            } => {
378                assert_eq!(amount, 4);
379                assert!(!use_tabs);
380            }
381            _ => panic!("Expected Indent variant"),
382        }
383    }
384
385    /// Protocol: `dedent` amount defaults to 4 when omitted.
386    #[test]
387    fn dedent_amount_defaults_to_four() {
388        let json = r#"{"op": "dedent", "find": "x"}"#;
389        let op: Op = serde_json::from_str(json).unwrap();
390        match op {
391            Op::Dedent { amount, .. } => {
392                assert_eq!(amount, 4);
393            }
394            _ => panic!("Expected Dedent variant"),
395        }
396    }
397
398    /// Protocol wire format: transform mode names serialize to the
399    /// snake_case forms agents expect. Locks in the API contract.
400    #[test]
401    fn transform_mode_wire_names() {
402        let op = Op::Transform {
403            find: "hello".into(),
404            mode: TransformMode::SnakeCase,
405            regex: true,
406            case_insensitive: false,
407        };
408        let json = serde_json::to_value(&op).unwrap();
409        assert_eq!(json["op"], "transform");
410        assert_eq!(json["mode"], "snake_case");
411    }
412
413    // ── TransformMode Display and FromStr ──
414
415    #[test]
416    fn transform_mode_display_roundtrip() {
417        let modes = [
418            TransformMode::Upper,
419            TransformMode::Lower,
420            TransformMode::Title,
421            TransformMode::SnakeCase,
422            TransformMode::CamelCase,
423        ];
424        for mode in modes {
425            let s = mode.to_string();
426            let parsed: TransformMode = s.parse().unwrap();
427            assert_eq!(mode, parsed);
428        }
429    }
430
431    #[test]
432    fn transform_mode_from_str_aliases() {
433        assert_eq!(
434            "snake".parse::<TransformMode>().unwrap(),
435            TransformMode::SnakeCase
436        );
437        assert_eq!(
438            "camel".parse::<TransformMode>().unwrap(),
439            TransformMode::CamelCase
440        );
441    }
442
443    #[test]
444    fn transform_mode_from_str_unknown_fails() {
445        assert!("unknown".parse::<TransformMode>().is_err());
446    }
447}