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, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
49#[serde(rename_all = "snake_case")]
50#[non_exhaustive]
51pub enum ReplaceCount {
52 #[default]
54 All,
55 FirstPerLine,
57 FirstInFile,
59 Max(usize),
61}
62
63#[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 #[serde(default)]
79 multiline: bool,
80 #[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 #[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#[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 #[serde(default, skip_serializing_if = "Option::is_none")]
184 pub range: Option<PatternRange>,
185 #[serde(default = "default_true")]
188 pub record_undo: bool,
189}
190
191impl OpOptions {
192 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#[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#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
244pub struct PatternRange {
245 pub start_pattern: String,
246 pub end_pattern: String,
247}
248
249#[derive(Debug, Clone, PartialEq, Eq)]
252pub enum RangeSpec {
253 Lines(LineRange),
254 Patterns(PatternRange),
255}
256
257use crate::default_true;
258
259impl Op {
260 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 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 #[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 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 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 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 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 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 #[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 #[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 #[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 #[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 #[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 #[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 #[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}