1use serde::{Deserialize, Serialize};
2
3#[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#[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#[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#[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 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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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}