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]
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 use_tabs: false,
636 regex: true,
637 case_insensitive: true,
638 };
639 let json = serde_json::to_string(&op).unwrap();
640 let deserialized: Op = serde_json::from_str(&json).unwrap();
641 assert_eq!(op, deserialized);
642 }
643
644 #[test]
645 fn dedent_serializes_with_op_tag() {
646 let op = Op::Dedent {
647 find: "line".into(),
648 amount: 4,
649 use_tabs: false,
650 regex: false,
651 case_insensitive: false,
652 };
653 let json = serde_json::to_value(&op).unwrap();
654 assert_eq!(json["op"], "dedent");
655 assert_eq!(json["find"], "line");
656 assert_eq!(json["amount"], 4);
657 }
658
659 #[test]
660 fn dedent_deserialize_with_defaults() {
661 let json = r#"{"op": "dedent", "find": "x"}"#;
662 let op: Op = serde_json::from_str(json).unwrap();
663 assert!(!op.is_regex());
664 assert!(!op.is_case_insensitive());
665 match op {
667 Op::Dedent { amount, .. } => {
668 assert_eq!(amount, 4);
669 }
670 _ => panic!("Expected Dedent variant"),
671 }
672 }
673
674 #[test]
677 fn find_pattern_returns_find_for_new_variants() {
678 let ops = [
679 Op::Transform {
680 find: "t".into(),
681 mode: TransformMode::Upper,
682 regex: false,
683 case_insensitive: false,
684 },
685 Op::Surround {
686 find: "s".into(),
687 prefix: "<".into(),
688 suffix: ">".into(),
689 regex: false,
690 case_insensitive: false,
691 },
692 Op::Indent {
693 find: "i".into(),
694 amount: 4,
695 use_tabs: false,
696 regex: false,
697 case_insensitive: false,
698 },
699 Op::Dedent {
700 find: "d".into(),
701 amount: 4,
702 use_tabs: false,
703 regex: false,
704 case_insensitive: false,
705 },
706 ];
707 let expected = ["t", "s", "i", "d"];
708 for (op, exp) in ops.iter().zip(expected.iter()) {
709 assert_eq!(op.find_pattern(), *exp);
710 }
711 }
712
713 #[test]
714 fn is_regex_reflects_field_for_new_variants() {
715 let ops = [
716 Op::Transform {
717 find: "x".into(),
718 mode: TransformMode::Upper,
719 regex: true,
720 case_insensitive: false,
721 },
722 Op::Surround {
723 find: "x".into(),
724 prefix: "<".into(),
725 suffix: ">".into(),
726 regex: true,
727 case_insensitive: false,
728 },
729 Op::Indent {
730 find: "x".into(),
731 amount: 4,
732 use_tabs: false,
733 regex: true,
734 case_insensitive: false,
735 },
736 Op::Dedent {
737 find: "x".into(),
738 amount: 4,
739 use_tabs: false,
740 regex: true,
741 case_insensitive: false,
742 },
743 ];
744 for op in &ops {
745 assert!(op.is_regex());
746 }
747 }
748
749 #[test]
750 fn is_case_insensitive_reflects_field_for_new_variants() {
751 let ops = [
752 Op::Transform {
753 find: "x".into(),
754 mode: TransformMode::Upper,
755 regex: false,
756 case_insensitive: true,
757 },
758 Op::Surround {
759 find: "x".into(),
760 prefix: "<".into(),
761 suffix: ">".into(),
762 regex: false,
763 case_insensitive: true,
764 },
765 Op::Indent {
766 find: "x".into(),
767 amount: 4,
768 use_tabs: false,
769 regex: false,
770 case_insensitive: true,
771 },
772 Op::Dedent {
773 find: "x".into(),
774 amount: 4,
775 use_tabs: false,
776 regex: false,
777 case_insensitive: true,
778 },
779 ];
780 for op in &ops {
781 assert!(op.is_case_insensitive());
782 }
783 }
784
785 #[test]
788 fn transform_mode_display_roundtrip() {
789 let modes = [
790 TransformMode::Upper,
791 TransformMode::Lower,
792 TransformMode::Title,
793 TransformMode::SnakeCase,
794 TransformMode::CamelCase,
795 ];
796 for mode in modes {
797 let s = mode.to_string();
798 let parsed: TransformMode = s.parse().unwrap();
799 assert_eq!(mode, parsed);
800 }
801 }
802
803 #[test]
804 fn transform_mode_from_str_aliases() {
805 assert_eq!(
806 "snake".parse::<TransformMode>().unwrap(),
807 TransformMode::SnakeCase
808 );
809 assert_eq!(
810 "camel".parse::<TransformMode>().unwrap(),
811 TransformMode::CamelCase
812 );
813 }
814
815 #[test]
816 fn transform_mode_from_str_unknown_fails() {
817 assert!("unknown".parse::<TransformMode>().is_err());
818 }
819}