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/// How many occurrences a Replace operation substitutes.
44///
45/// Wire format (externally tagged): unit variants serialize as plain
46/// strings (`"all"`, `"first_per_line"`, `"first_in_file"`), the capped
47/// variant as an object (`{"max": 3}`, counting occurrences per file).
48#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
49#[serde(rename_all = "snake_case")]
50#[non_exhaustive]
51pub enum ReplaceCount {
52    /// Replace every occurrence (sed `s///g`, the default).
53    #[default]
54    All,
55    /// Replace only the first occurrence on each matching line (sed `s///`).
56    FirstPerLine,
57    /// Replace only the first occurrence in the file, then stop.
58    FirstInFile,
59    /// Replace at most N occurrences in the file, then stop.
60    Max(usize),
61}
62
63/// The intermediate representation for all ripsed operations.
64/// Both CLI args and JSON requests are normalized into this form.
65#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
66#[serde(tag = "op", rename_all = "snake_case")]
67#[non_exhaustive]
68pub enum Op {
69    Replace {
70        find: String,
71        replace: String,
72        #[serde(default)]
73        regex: bool,
74        #[serde(default)]
75        case_insensitive: bool,
76        /// Match against the whole buffer instead of line-by-line, allowing
77        /// patterns to span line boundaries (like ripgrep's `-U`).
78        #[serde(default)]
79        multiline: bool,
80        /// How many occurrences to replace (default: all).
81        #[serde(default)]
82        count: ReplaceCount,
83    },
84    Delete {
85        find: String,
86        #[serde(default)]
87        regex: bool,
88        #[serde(default)]
89        case_insensitive: bool,
90        /// Match against the whole buffer; deletes the matched span rather
91        /// than whole lines (like ripgrep's `-U`).
92        #[serde(default)]
93        multiline: bool,
94    },
95    InsertAfter {
96        find: String,
97        content: String,
98        #[serde(default)]
99        regex: bool,
100        #[serde(default)]
101        case_insensitive: bool,
102    },
103    InsertBefore {
104        find: String,
105        content: String,
106        #[serde(default)]
107        regex: bool,
108        #[serde(default)]
109        case_insensitive: bool,
110    },
111    ReplaceLine {
112        find: String,
113        content: String,
114        #[serde(default)]
115        regex: bool,
116        #[serde(default)]
117        case_insensitive: bool,
118    },
119    Transform {
120        find: String,
121        mode: TransformMode,
122        #[serde(default)]
123        regex: bool,
124        #[serde(default)]
125        case_insensitive: bool,
126    },
127    Surround {
128        find: String,
129        prefix: String,
130        suffix: String,
131        #[serde(default)]
132        regex: bool,
133        #[serde(default)]
134        case_insensitive: bool,
135    },
136    Indent {
137        find: String,
138        #[serde(default = "default_indent_amount")]
139        amount: usize,
140        #[serde(default)]
141        use_tabs: bool,
142        #[serde(default)]
143        regex: bool,
144        #[serde(default)]
145        case_insensitive: bool,
146    },
147    Dedent {
148        find: String,
149        #[serde(default = "default_indent_amount")]
150        amount: usize,
151        #[serde(default)]
152        use_tabs: bool,
153        #[serde(default)]
154        regex: bool,
155        #[serde(default)]
156        case_insensitive: bool,
157    },
158}
159
160fn default_indent_amount() -> usize {
161    4
162}
163
164/// Options that control how operations are applied.
165#[derive(Debug, Clone, Serialize, Deserialize)]
166pub struct OpOptions {
167    #[serde(default = "default_true")]
168    pub dry_run: bool,
169    pub root: Option<String>,
170    #[serde(default = "default_true")]
171    pub gitignore: bool,
172    #[serde(default)]
173    pub backup: bool,
174    #[serde(default)]
175    pub atomic: bool,
176    pub glob: Option<String>,
177    pub ignore: Option<String>,
178    #[serde(default)]
179    pub hidden: bool,
180    pub max_depth: Option<usize>,
181    pub line_range: Option<LineRange>,
182    /// Pattern-addressed regions (mutually exclusive with `line_range`).
183    #[serde(default, skip_serializing_if = "Option::is_none")]
184    pub range: Option<PatternRange>,
185    /// Record undo entries for modified files (CLI: disable with
186    /// `--no-undo`). Per-file size caps still apply when enabled.
187    #[serde(default = "default_true")]
188    pub record_undo: bool,
189}
190
191impl OpOptions {
192    /// The effective range filter: pattern regions if set, else the
193    /// numeric line range. (Validation rejects requests setting both.)
194    pub fn range_spec(&self) -> Option<RangeSpec> {
195        if let Some(patterns) = &self.range {
196            Some(RangeSpec::Patterns(patterns.clone()))
197        } else {
198            self.line_range.map(RangeSpec::Lines)
199        }
200    }
201}
202
203impl Default for OpOptions {
204    fn default() -> Self {
205        Self {
206            record_undo: true,
207            dry_run: true,
208            root: None,
209            gitignore: true,
210            backup: false,
211            atomic: false,
212            glob: None,
213            ignore: None,
214            hidden: false,
215            max_depth: None,
216            line_range: None,
217            range: None,
218        }
219    }
220}
221
222/// A range of lines to operate on (1-indexed, inclusive).
223#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
224pub struct LineRange {
225    pub start: usize,
226    pub end: Option<usize>,
227}
228
229impl LineRange {
230    pub fn contains(&self, line: usize) -> bool {
231        line >= self.start && self.end.is_none_or(|end| line <= end)
232    }
233}
234
235/// A pattern-addressed region, like sed's `/start/,/end/`.
236///
237/// Both patterns are regexes. A region opens on a line matching
238/// `start_pattern` and closes on the next subsequent line matching
239/// `end_pattern` (both boundary lines are inside the region; the end
240/// pattern is never tested against the line that opened the region, so
241/// `/a/,/a/` spans from one `a` to the *next* one — sed semantics).
242/// Multiple regions are supported; an unclosed region extends to EOF.
243#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
244pub struct PatternRange {
245    pub start_pattern: String,
246    pub end_pattern: String,
247}
248
249/// Which lines an operation applies to: a numeric line range or
250/// pattern-addressed regions.
251#[derive(Debug, Clone, PartialEq, Eq)]
252pub enum RangeSpec {
253    Lines(LineRange),
254    Patterns(PatternRange),
255}
256
257use crate::default_true;
258
259impl Op {
260    /// Extract the find pattern from the operation.
261    pub fn find_pattern(&self) -> &str {
262        match self {
263            Op::Replace { find, .. }
264            | Op::Delete { find, .. }
265            | Op::InsertAfter { find, .. }
266            | Op::InsertBefore { find, .. }
267            | Op::ReplaceLine { find, .. }
268            | Op::Transform { find, .. }
269            | Op::Surround { find, .. }
270            | Op::Indent { find, .. }
271            | Op::Dedent { find, .. } => find,
272        }
273    }
274
275    /// Whether this operation matches against the whole buffer (allowing
276    /// patterns to span line boundaries) instead of line-by-line.
277    ///
278    /// Only `Replace` and `Delete` support multiline matching; every other
279    /// operation is inherently line-scoped and always returns `false`.
280    pub fn is_multiline(&self) -> bool {
281        match self {
282            Op::Replace { multiline, .. } | Op::Delete { multiline, .. } => *multiline,
283            _ => false,
284        }
285    }
286
287    pub fn is_regex(&self) -> bool {
288        match self {
289            Op::Replace { regex, .. }
290            | Op::Delete { regex, .. }
291            | Op::InsertAfter { regex, .. }
292            | Op::InsertBefore { regex, .. }
293            | Op::ReplaceLine { regex, .. }
294            | Op::Transform { regex, .. }
295            | Op::Surround { regex, .. }
296            | Op::Indent { regex, .. }
297            | Op::Dedent { regex, .. } => *regex,
298        }
299    }
300
301    pub fn is_case_insensitive(&self) -> bool {
302        match self {
303            Op::Replace {
304                case_insensitive, ..
305            }
306            | Op::Delete {
307                case_insensitive, ..
308            }
309            | Op::InsertAfter {
310                case_insensitive, ..
311            }
312            | Op::InsertBefore {
313                case_insensitive, ..
314            }
315            | Op::ReplaceLine {
316                case_insensitive, ..
317            }
318            | Op::Transform {
319                case_insensitive, ..
320            }
321            | Op::Surround {
322                case_insensitive, ..
323            }
324            | Op::Indent {
325                case_insensitive, ..
326            }
327            | Op::Dedent {
328                case_insensitive, ..
329            } => *case_insensitive,
330        }
331    }
332}
333
334#[cfg(test)]
335mod tests {
336    use super::*;
337
338    // ── Op serde roundtrip ──
339
340    /// Locks in the protocol wire-format: the `op` tag MUST serialize as
341    /// `"replace"` (snake_case), not `"Replace"`. Agents that depend on
342    /// this wire format would silently break if the tag rename were
343    /// removed. Only one such test per variant class — the rest are
344    /// pure serde-framework roundtrips with no added value.
345    #[test]
346    fn replace_op_tag_wire_format() {
347        let op = Op::Replace {
348            count: Default::default(),
349            multiline: false,
350            find: "foo".into(),
351            replace: "bar".into(),
352            regex: false,
353            case_insensitive: false,
354        };
355        let json = serde_json::to_value(&op).unwrap();
356        assert_eq!(json["op"], "replace");
357        assert_eq!(json["find"], "foo");
358        assert_eq!(json["replace"], "bar");
359    }
360
361    #[test]
362    fn multiline_field_defaults_to_false_and_roundtrips() {
363        // Wire format: omitted -> false (back-compat with pre-multiline requests).
364        let op: Op =
365            serde_json::from_str(r#"{"op": "replace", "find": "a", "replace": "b"}"#).unwrap();
366        assert!(!op.is_multiline());
367
368        let op: Op = serde_json::from_str(
369            r#"{"op": "replace", "find": "a", "replace": "b", "multiline": true}"#,
370        )
371        .unwrap();
372        assert!(op.is_multiline());
373
374        let op: Op =
375            serde_json::from_str(r#"{"op": "delete", "find": "a", "multiline": true}"#).unwrap();
376        assert!(op.is_multiline());
377    }
378
379    #[test]
380    fn replace_count_wire_format() {
381        // Unit variants are plain strings, Max is an object — agents
382        // depend on these exact spellings.
383        let op: Op = serde_json::from_str(
384            r#"{"op": "replace", "find": "a", "replace": "b", "count": "first_per_line"}"#,
385        )
386        .unwrap();
387        assert!(matches!(
388            op,
389            Op::Replace {
390                count: ReplaceCount::FirstPerLine,
391                ..
392            }
393        ));
394
395        let op: Op = serde_json::from_str(
396            r#"{"op": "replace", "find": "a", "replace": "b", "count": {"max": 3}}"#,
397        )
398        .unwrap();
399        assert!(matches!(
400            op,
401            Op::Replace {
402                count: ReplaceCount::Max(3),
403                ..
404            }
405        ));
406
407        // Omitted -> All (back-compat).
408        let op: Op =
409            serde_json::from_str(r#"{"op": "replace", "find": "a", "replace": "b"}"#).unwrap();
410        assert!(matches!(
411            op,
412            Op::Replace {
413                count: ReplaceCount::All,
414                ..
415            }
416        ));
417
418        // Serialization side of the contract.
419        let json = serde_json::to_value(&Op::Replace {
420            find: "a".into(),
421            replace: "b".into(),
422            regex: false,
423            case_insensitive: false,
424            multiline: false,
425            count: ReplaceCount::FirstInFile,
426        })
427        .unwrap();
428        assert_eq!(json["count"], "first_in_file");
429    }
430
431    #[test]
432    fn is_multiline_false_for_line_scoped_ops() {
433        // Line-scoped ops can't express multiline at the type level.
434        let op = Op::InsertAfter {
435            find: "a".into(),
436            content: "b".into(),
437            regex: false,
438            case_insensitive: false,
439        };
440        assert!(!op.is_multiline());
441    }
442
443    #[test]
444    fn deserialize_with_default_booleans() {
445        let json = r#"{"op": "replace", "find": "a", "replace": "b"}"#;
446        let op: Op = serde_json::from_str(json).unwrap();
447        assert!(!op.is_regex());
448        assert!(!op.is_case_insensitive());
449    }
450
451    #[test]
452    fn unknown_op_tag_fails_deserialization() {
453        let json = r#"{"op": "transform", "find": "a"}"#;
454        let result = serde_json::from_str::<Op>(json);
455        assert!(result.is_err());
456    }
457
458    // ── LineRange ──
459
460    #[test]
461    fn line_range_contains_bounded() {
462        let range = LineRange {
463            start: 5,
464            end: Some(10),
465        };
466        assert!(!range.contains(4));
467        assert!(range.contains(5));
468        assert!(range.contains(7));
469        assert!(range.contains(10));
470        assert!(!range.contains(11));
471    }
472
473    #[test]
474    fn line_range_contains_unbounded_end() {
475        let range = LineRange {
476            start: 3,
477            end: None,
478        };
479        assert!(!range.contains(2));
480        assert!(range.contains(3));
481        assert!(range.contains(1000));
482    }
483
484    #[test]
485    fn line_range_single_line() {
486        let range = LineRange {
487            start: 7,
488            end: Some(7),
489        };
490        assert!(!range.contains(6));
491        assert!(range.contains(7));
492        assert!(!range.contains(8));
493    }
494
495    // ── OpOptions ──
496
497    #[test]
498    fn op_options_default_values() {
499        let opts = OpOptions::default();
500        assert!(opts.dry_run);
501        assert!(opts.gitignore);
502        assert!(!opts.backup);
503        assert!(!opts.atomic);
504        assert!(!opts.hidden);
505        assert!(opts.root.is_none());
506        assert!(opts.glob.is_none());
507        assert!(opts.ignore.is_none());
508        assert!(opts.max_depth.is_none());
509        assert!(opts.line_range.is_none());
510    }
511
512    #[test]
513    fn op_options_deserializes_with_defaults() {
514        let json = "{}";
515        let opts: OpOptions = serde_json::from_str(json).unwrap();
516        assert!(opts.dry_run);
517        assert!(opts.gitignore);
518    }
519
520    #[test]
521    fn op_options_overrides_defaults() {
522        let json = r#"{"dry_run": false, "gitignore": false, "backup": true}"#;
523        let opts: OpOptions = serde_json::from_str(json).unwrap();
524        assert!(!opts.dry_run);
525        assert!(!opts.gitignore);
526        assert!(opts.backup);
527    }
528
529    // ── Serde defaults and unknown-variant behavior ──
530
531    /// Protocol: `transform` with no `mode` must be rejected, not defaulted.
532    #[test]
533    fn transform_missing_mode_fails() {
534        let json = r#"{"op": "transform", "find": "a"}"#;
535        let result = serde_json::from_str::<Op>(json);
536        assert!(result.is_err());
537    }
538
539    /// Protocol: `indent` amount defaults to 4 when omitted.
540    #[test]
541    fn indent_amount_defaults_to_four() {
542        let json = r#"{"op": "indent", "find": "x"}"#;
543        let op: Op = serde_json::from_str(json).unwrap();
544        match op {
545            Op::Indent {
546                amount, use_tabs, ..
547            } => {
548                assert_eq!(amount, 4);
549                assert!(!use_tabs);
550            }
551            _ => panic!("Expected Indent variant"),
552        }
553    }
554
555    /// Protocol: `dedent` amount defaults to 4 when omitted.
556    #[test]
557    fn dedent_amount_defaults_to_four() {
558        let json = r#"{"op": "dedent", "find": "x"}"#;
559        let op: Op = serde_json::from_str(json).unwrap();
560        match op {
561            Op::Dedent { amount, .. } => {
562                assert_eq!(amount, 4);
563            }
564            _ => panic!("Expected Dedent variant"),
565        }
566    }
567
568    /// Protocol wire format: transform mode names serialize to the
569    /// snake_case forms agents expect. Locks in the API contract.
570    #[test]
571    fn transform_mode_wire_names() {
572        let op = Op::Transform {
573            find: "hello".into(),
574            mode: TransformMode::SnakeCase,
575            regex: true,
576            case_insensitive: false,
577        };
578        let json = serde_json::to_value(&op).unwrap();
579        assert_eq!(json["op"], "transform");
580        assert_eq!(json["mode"], "snake_case");
581    }
582
583    // ── TransformMode Display and FromStr ──
584
585    #[test]
586    fn transform_mode_display_roundtrip() {
587        let modes = [
588            TransformMode::Upper,
589            TransformMode::Lower,
590            TransformMode::Title,
591            TransformMode::SnakeCase,
592            TransformMode::CamelCase,
593        ];
594        for mode in modes {
595            let s = mode.to_string();
596            let parsed: TransformMode = s.parse().unwrap();
597            assert_eq!(mode, parsed);
598        }
599    }
600
601    #[test]
602    fn transform_mode_from_str_aliases() {
603        assert_eq!(
604            "snake".parse::<TransformMode>().unwrap(),
605            TransformMode::SnakeCase
606        );
607        assert_eq!(
608            "camel".parse::<TransformMode>().unwrap(),
609            TransformMode::CamelCase
610        );
611    }
612
613    #[test]
614    fn transform_mode_from_str_unknown_fails() {
615        assert!("unknown".parse::<TransformMode>().is_err());
616    }
617}