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 regex: bool,
122 #[serde(default)]
123 case_insensitive: bool,
124 },
125}
126
127fn default_indent_amount() -> usize {
128 4
129}
130
131#[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#[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 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]
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 #[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 #[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 #[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 #[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 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 match op {
665 Op::Dedent { amount, .. } => {
666 assert_eq!(amount, 4);
667 }
668 _ => panic!("Expected Dedent variant"),
669 }
670 }
671
672 #[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 #[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}