1use ripsed_core::error::RipsedError;
2use ripsed_core::operation::{Op, OpOptions};
3use serde::{Deserialize, Serialize};
4
5#[derive(Debug, Clone, Serialize, Deserialize)]
7pub struct JsonRequest {
8 #[serde(default = "default_version")]
9 pub version: String,
10 #[serde(default)]
11 pub operations: Vec<JsonOp>,
12 #[serde(default)]
13 pub options: OpOptions,
14 #[serde(skip_serializing_if = "Option::is_none")]
16 pub undo: Option<UndoRequest>,
17 #[serde(flatten)]
19 pub extra: serde_json::Map<String, serde_json::Value>,
20}
21
22#[derive(Debug, Clone, Serialize, Deserialize)]
24pub struct JsonOp {
25 #[serde(flatten)]
26 pub op: Op,
27 #[serde(skip_serializing_if = "Option::is_none")]
28 pub glob: Option<String>,
29}
30
31#[derive(Debug, Clone, Serialize, Deserialize)]
33pub struct UndoRequest {
34 pub last: usize,
35}
36
37fn default_version() -> String {
38 "1".to_string()
39}
40
41impl JsonRequest {
42 pub fn parse(input: &str) -> Result<Self, RipsedError> {
44 let request: JsonRequest = serde_json::from_str(input).map_err(|e| {
45 RipsedError::invalid_request(
46 format!("Failed to parse JSON request: {e}"),
47 "Check that the JSON is well-formed and matches the ripsed request schema.",
48 )
49 })?;
50
51 request.validate()?;
52 Ok(request)
53 }
54
55 fn validate(&self) -> Result<(), RipsedError> {
57 if self.version != "1" {
58 return Err(RipsedError::invalid_request(
59 format!("Unknown version '{}'. Supported versions: 1", self.version),
60 "Set \"version\": \"1\" in your request.",
61 ));
62 }
63
64 if self.undo.is_some() && !self.operations.is_empty() {
65 return Err(RipsedError::invalid_request(
66 "Request cannot contain both 'operations' and 'undo'.",
67 "Send undo and operations as separate requests.",
68 ));
69 }
70
71 if self.undo.is_none() && self.operations.is_empty() {
72 return Err(RipsedError::invalid_request(
73 "Request must contain 'operations' or 'undo'.",
74 "Add at least one operation or an undo request.",
75 ));
76 }
77
78 if let Some(undo) = &self.undo {
80 if undo.last == 0 {
81 return Err(RipsedError::invalid_request(
82 "Undo 'last' must be at least 1.",
83 "Set \"last\" to the number of operations to undo (minimum 1).",
84 ));
85 }
86 }
87
88 for (i, json_op) in self.operations.iter().enumerate() {
90 validate_op(i, &json_op.op)?;
91
92 if let Some(glob) = &json_op.glob {
94 validate_glob_pattern(glob).map_err(|msg| {
95 RipsedError::invalid_request(
96 format!("Invalid glob in operation {i}: {msg}"),
97 format!("Fix the glob pattern '{}' in operation {i}. {}", glob, msg),
98 )
99 })?;
100 }
101 }
102
103 if let Some(glob) = &self.options.glob {
105 validate_glob_pattern(glob).map_err(|msg| {
106 RipsedError::invalid_request(
107 format!("Invalid glob in options: {msg}"),
108 format!("Fix the glob pattern '{}' in options. {}", glob, msg),
109 )
110 })?;
111 }
112
113 if let Some(ignore) = &self.options.ignore {
115 validate_glob_pattern(ignore).map_err(|msg| {
116 RipsedError::invalid_request(
117 format!("Invalid ignore glob in options: {msg}"),
118 format!("Fix the ignore pattern '{}' in options. {}", ignore, msg),
119 )
120 })?;
121 }
122
123 Ok(())
124 }
125
126 pub fn into_ops(self) -> (Vec<(Op, Option<String>)>, OpOptions) {
129 let global_glob = self.options.glob.clone();
130 let ops = self
131 .operations
132 .into_iter()
133 .map(|json_op| {
134 let glob = json_op.glob.or_else(|| global_glob.clone());
135 (json_op.op, glob)
136 })
137 .collect();
138 (ops, self.options)
139 }
140}
141
142fn validate_op(index: usize, op: &Op) -> Result<(), RipsedError> {
144 match op {
145 Op::Replace {
146 find,
147 replace,
148 regex,
149 ..
150 } => {
151 if find.is_empty() {
152 return Err(RipsedError::invalid_request(
153 format!("Operation {index}: 'find' must not be empty for replace."),
154 format!("Set a non-empty 'find' pattern in operation {index}."),
155 ));
156 }
157 let _ = replace;
159 if *regex {
160 validate_regex(index, find)?;
161 }
162 }
163 Op::Delete { find, regex, .. } => {
164 if find.is_empty() {
165 return Err(RipsedError::invalid_request(
166 format!("Operation {index}: 'find' must not be empty for delete."),
167 format!("Set a non-empty 'find' pattern in operation {index}."),
168 ));
169 }
170 if *regex {
171 validate_regex(index, find)?;
172 }
173 }
174 Op::InsertAfter {
175 find,
176 content,
177 regex,
178 ..
179 } => {
180 if find.is_empty() {
181 return Err(RipsedError::invalid_request(
182 format!("Operation {index}: 'find' must not be empty for insert_after."),
183 format!("Set a non-empty 'find' pattern in operation {index}."),
184 ));
185 }
186 if content.is_empty() {
187 return Err(RipsedError::invalid_request(
188 format!("Operation {index}: 'content' must not be empty for insert_after."),
189 format!("Set a non-empty 'content' in operation {index}."),
190 ));
191 }
192 if *regex {
193 validate_regex(index, find)?;
194 }
195 }
196 Op::InsertBefore {
197 find,
198 content,
199 regex,
200 ..
201 } => {
202 if find.is_empty() {
203 return Err(RipsedError::invalid_request(
204 format!("Operation {index}: 'find' must not be empty for insert_before."),
205 format!("Set a non-empty 'find' pattern in operation {index}."),
206 ));
207 }
208 if content.is_empty() {
209 return Err(RipsedError::invalid_request(
210 format!("Operation {index}: 'content' must not be empty for insert_before."),
211 format!("Set a non-empty 'content' in operation {index}."),
212 ));
213 }
214 if *regex {
215 validate_regex(index, find)?;
216 }
217 }
218 Op::ReplaceLine {
219 find,
220 content,
221 regex,
222 ..
223 } => {
224 if find.is_empty() {
225 return Err(RipsedError::invalid_request(
226 format!("Operation {index}: 'find' must not be empty for replace_line."),
227 format!("Set a non-empty 'find' pattern in operation {index}."),
228 ));
229 }
230 if content.is_empty() {
231 return Err(RipsedError::invalid_request(
232 format!("Operation {index}: 'content' must not be empty for replace_line."),
233 format!("Set a non-empty 'content' in operation {index}."),
234 ));
235 }
236 if *regex {
237 validate_regex(index, find)?;
238 }
239 }
240 Op::Transform { find, regex, .. } => {
241 if find.is_empty() {
242 return Err(RipsedError::invalid_request(
243 format!("Operation {index}: 'find' must not be empty for transform."),
244 format!("Set a non-empty 'find' pattern in operation {index}."),
245 ));
246 }
247 if *regex {
248 validate_regex(index, find)?;
249 }
250 }
251 Op::Surround {
252 find,
253 prefix,
254 suffix,
255 regex,
256 ..
257 } => {
258 if find.is_empty() {
259 return Err(RipsedError::invalid_request(
260 format!("Operation {index}: 'find' must not be empty for surround."),
261 format!("Set a non-empty 'find' pattern in operation {index}."),
262 ));
263 }
264 if prefix.is_empty() && suffix.is_empty() {
265 return Err(RipsedError::invalid_request(
266 format!(
267 "Operation {index}: 'prefix' or 'suffix' must not both be empty for surround."
268 ),
269 format!("Set a non-empty 'prefix' or 'suffix' in operation {index}."),
270 ));
271 }
272 if *regex {
273 validate_regex(index, find)?;
274 }
275 }
276 Op::Indent { find, regex, .. } => {
277 if find.is_empty() {
278 return Err(RipsedError::invalid_request(
279 format!("Operation {index}: 'find' must not be empty for indent."),
280 format!("Set a non-empty 'find' pattern in operation {index}."),
281 ));
282 }
283 if *regex {
284 validate_regex(index, find)?;
285 }
286 }
287 Op::Dedent { find, regex, .. } => {
288 if find.is_empty() {
289 return Err(RipsedError::invalid_request(
290 format!("Operation {index}: 'find' must not be empty for dedent."),
291 format!("Set a non-empty 'find' pattern in operation {index}."),
292 ));
293 }
294 if *regex {
295 validate_regex(index, find)?;
296 }
297 }
298 _ => {}
299 }
300
301 Ok(())
302}
303
304fn validate_regex(index: usize, pattern: &str) -> Result<(), RipsedError> {
306 regex::Regex::new(pattern)
307 .map_err(|e| RipsedError::invalid_regex(index, pattern, &e.to_string()))?;
308 Ok(())
309}
310
311fn validate_glob_pattern(pattern: &str) -> Result<(), String> {
313 if pattern.is_empty() {
314 return Err("Glob pattern must not be empty.".to_string());
315 }
316
317 let mut in_bracket = false;
319 let mut chars = pattern.chars().peekable();
320 while let Some(ch) = chars.next() {
321 match ch {
322 '\\' => {
323 let _ = chars.next();
325 }
326 '[' if !in_bracket => {
327 in_bracket = true;
328 }
329 ']' if in_bracket => {
330 in_bracket = false;
331 }
332 '{' => {
333 let mut brace_depth = 1;
335 let mut found_close = false;
336 for next_ch in chars.by_ref() {
337 match next_ch {
338 '{' => brace_depth += 1,
339 '}' => {
340 brace_depth -= 1;
341 if brace_depth == 0 {
342 found_close = true;
343 break;
344 }
345 }
346 _ => {}
347 }
348 }
349 if !found_close {
350 return Err("Unmatched '{' in glob pattern. Add a closing '}'.".to_string());
351 }
352 }
353 '}' => {
354 return Err(
355 "Unmatched '}' in glob pattern. Remove the extra '}' or add an opening '{'."
356 .to_string(),
357 );
358 }
359 _ => {}
360 }
361 }
362
363 if in_bracket {
364 return Err("Unmatched '[' in glob pattern. Add a closing ']'.".to_string());
365 }
366
367 Ok(())
368}
369
370#[cfg(test)]
371mod tests {
372 use super::*;
373
374 #[test]
377 fn test_parse_simple_replace() {
378 let input = r#"{
379 "operations": [{"op": "replace", "find": "foo", "replace": "bar"}]
380 }"#;
381 let req = JsonRequest::parse(input).unwrap();
382 assert_eq!(req.operations.len(), 1);
383 assert!(req.options.dry_run); }
385
386 #[test]
387 fn test_parse_invalid_json() {
388 let result = JsonRequest::parse("not json");
389 assert!(result.is_err());
390 }
391
392 #[test]
393 fn test_parse_empty_operations() {
394 let input = r#"{"operations": []}"#;
395 let result = JsonRequest::parse(input);
396 assert!(result.is_err());
397 }
398
399 #[test]
400 fn test_parse_unknown_version() {
401 let input =
402 r#"{"version": "99", "operations": [{"op": "replace", "find": "a", "replace": "b"}]}"#;
403 let result = JsonRequest::parse(input);
404 assert!(result.is_err());
405 }
406
407 #[test]
410 fn test_parse_delete() {
411 let input = r#"{
412 "operations": [{"op": "delete", "find": "TODO", "regex": false}]
413 }"#;
414 let req = JsonRequest::parse(input).unwrap();
415 assert_eq!(req.operations.len(), 1);
416 match &req.operations[0].op {
417 Op::Delete { find, regex, .. } => {
418 assert_eq!(find, "TODO");
419 assert!(!regex);
420 }
421 _ => panic!("Expected Delete operation"),
422 }
423 }
424
425 #[test]
426 fn test_parse_delete_with_regex() {
427 let input = r#"{
428 "operations": [{"op": "delete", "find": "^\\s*//\\s*TODO:.*$", "regex": true}]
429 }"#;
430 let req = JsonRequest::parse(input).unwrap();
431 match &req.operations[0].op {
432 Op::Delete { find, regex, .. } => {
433 assert_eq!(find, r"^\s*//\s*TODO:.*$");
434 assert!(regex);
435 }
436 _ => panic!("Expected Delete operation"),
437 }
438 }
439
440 #[test]
441 fn test_parse_insert_after() {
442 let input = r#"{
443 "operations": [{
444 "op": "insert_after",
445 "find": "use serde::Deserialize;",
446 "content": "use serde::Serialize;",
447 "glob": "src/models/*.rs"
448 }]
449 }"#;
450 let req = JsonRequest::parse(input).unwrap();
451 assert_eq!(req.operations.len(), 1);
452 match &req.operations[0].op {
453 Op::InsertAfter { find, content, .. } => {
454 assert_eq!(find, "use serde::Deserialize;");
455 assert_eq!(content, "use serde::Serialize;");
456 }
457 _ => panic!("Expected InsertAfter operation"),
458 }
459 assert_eq!(req.operations[0].glob.as_deref(), Some("src/models/*.rs"));
460 }
461
462 #[test]
463 fn test_parse_insert_before() {
464 let input = r#"{
465 "operations": [{
466 "op": "insert_before",
467 "find": "fn main()",
468 "content": "// Entry point"
469 }]
470 }"#;
471 let req = JsonRequest::parse(input).unwrap();
472 match &req.operations[0].op {
473 Op::InsertBefore { find, content, .. } => {
474 assert_eq!(find, "fn main()");
475 assert_eq!(content, "// Entry point");
476 }
477 _ => panic!("Expected InsertBefore operation"),
478 }
479 }
480
481 #[test]
482 fn test_parse_replace_line() {
483 let input = r#"{
484 "operations": [{
485 "op": "replace_line",
486 "find": "old_version = 1",
487 "content": "new_version = 2"
488 }]
489 }"#;
490 let req = JsonRequest::parse(input).unwrap();
491 match &req.operations[0].op {
492 Op::ReplaceLine { find, content, .. } => {
493 assert_eq!(find, "old_version = 1");
494 assert_eq!(content, "new_version = 2");
495 }
496 _ => panic!("Expected ReplaceLine operation"),
497 }
498 }
499
500 #[test]
503 fn test_reject_empty_find_replace() {
504 let input = r#"{"operations": [{"op": "replace", "find": "", "replace": "bar"}]}"#;
505 let err = JsonRequest::parse(input).unwrap_err();
506 assert!(err.message.contains("'find' must not be empty"));
507 }
508
509 #[test]
510 fn test_reject_empty_find_delete() {
511 let input = r#"{"operations": [{"op": "delete", "find": ""}]}"#;
512 let err = JsonRequest::parse(input).unwrap_err();
513 assert!(err.message.contains("'find' must not be empty"));
514 }
515
516 #[test]
517 fn test_reject_empty_find_insert_after() {
518 let input = r#"{"operations": [{"op": "insert_after", "find": "", "content": "x"}]}"#;
519 let err = JsonRequest::parse(input).unwrap_err();
520 assert!(err.message.contains("'find' must not be empty"));
521 }
522
523 #[test]
524 fn test_reject_empty_find_insert_before() {
525 let input = r#"{"operations": [{"op": "insert_before", "find": "", "content": "x"}]}"#;
526 let err = JsonRequest::parse(input).unwrap_err();
527 assert!(err.message.contains("'find' must not be empty"));
528 }
529
530 #[test]
531 fn test_reject_empty_find_replace_line() {
532 let input = r#"{"operations": [{"op": "replace_line", "find": "", "content": "x"}]}"#;
533 let err = JsonRequest::parse(input).unwrap_err();
534 assert!(err.message.contains("'find' must not be empty"));
535 }
536
537 #[test]
540 fn test_reject_empty_content_insert_after() {
541 let input = r#"{"operations": [{"op": "insert_after", "find": "x", "content": ""}]}"#;
542 let err = JsonRequest::parse(input).unwrap_err();
543 assert!(err.message.contains("'content' must not be empty"));
544 }
545
546 #[test]
547 fn test_reject_empty_content_insert_before() {
548 let input = r#"{"operations": [{"op": "insert_before", "find": "x", "content": ""}]}"#;
549 let err = JsonRequest::parse(input).unwrap_err();
550 assert!(err.message.contains("'content' must not be empty"));
551 }
552
553 #[test]
554 fn test_reject_empty_content_replace_line() {
555 let input = r#"{"operations": [{"op": "replace_line", "find": "x", "content": ""}]}"#;
556 let err = JsonRequest::parse(input).unwrap_err();
557 assert!(err.message.contains("'content' must not be empty"));
558 }
559
560 #[test]
563 fn test_allow_empty_replacement_in_replace() {
564 let input = r#"{"operations": [{"op": "replace", "find": "remove_me", "replace": ""}]}"#;
565 let req = JsonRequest::parse(input).unwrap();
566 match &req.operations[0].op {
567 Op::Replace { find, replace, .. } => {
568 assert_eq!(find, "remove_me");
569 assert_eq!(replace, "");
570 }
571 _ => panic!("Expected Replace operation"),
572 }
573 }
574
575 #[test]
578 fn test_reject_invalid_regex_in_replace() {
579 let input = r#"{"operations": [{"op": "replace", "find": "fn (foo", "replace": "bar", "regex": true}]}"#;
580 let err = JsonRequest::parse(input).unwrap_err();
581 assert_eq!(err.code, ripsed_core::error::ErrorCode::InvalidRegex);
582 }
583
584 #[test]
585 fn test_reject_invalid_regex_in_delete() {
586 let input = r#"{"operations": [{"op": "delete", "find": "[unclosed", "regex": true}]}"#;
587 let err = JsonRequest::parse(input).unwrap_err();
588 assert_eq!(err.code, ripsed_core::error::ErrorCode::InvalidRegex);
589 }
590
591 #[test]
592 fn test_accept_valid_regex_in_delete() {
593 let input = r#"{"operations": [{"op": "delete", "find": "^\\s*//.*$", "regex": true}]}"#;
594 let req = JsonRequest::parse(input).unwrap();
595 assert_eq!(req.operations.len(), 1);
596 }
597
598 #[test]
601 fn test_accept_valid_glob() {
602 let input = r#"{
603 "operations": [{"op": "replace", "find": "a", "replace": "b", "glob": "**/*.rs"}]
604 }"#;
605 let req = JsonRequest::parse(input).unwrap();
606 assert_eq!(req.operations[0].glob.as_deref(), Some("**/*.rs"));
607 }
608
609 #[test]
610 fn test_reject_empty_glob() {
611 let input = r#"{
612 "operations": [{"op": "replace", "find": "a", "replace": "b", "glob": ""}]
613 }"#;
614 let err = JsonRequest::parse(input).unwrap_err();
615 assert!(err.message.contains("Invalid glob"));
616 }
617
618 #[test]
619 fn test_reject_unmatched_open_bracket() {
620 let input = r#"{
621 "operations": [{"op": "replace", "find": "a", "replace": "b", "glob": "[unclosed"}]
622 }"#;
623 let err = JsonRequest::parse(input).unwrap_err();
624 assert!(err.message.contains("Unmatched '['"));
625 }
626
627 #[test]
628 fn test_reject_unmatched_open_brace() {
629 let input = r#"{
630 "operations": [{"op": "replace", "find": "a", "replace": "b", "glob": "{a,b"}]
631 }"#;
632 let err = JsonRequest::parse(input).unwrap_err();
633 assert!(err.message.contains("Unmatched '{'"));
634 }
635
636 #[test]
637 fn test_reject_unmatched_close_brace() {
638 let input = r#"{
639 "operations": [{"op": "replace", "find": "a", "replace": "b", "glob": "a,b}"}]
640 }"#;
641 let err = JsonRequest::parse(input).unwrap_err();
642 assert!(err.message.contains("Unmatched '}'"));
643 }
644
645 #[test]
646 fn test_accept_valid_alternation_glob() {
647 let input = r#"{
648 "operations": [{"op": "replace", "find": "a", "replace": "b", "glob": "*.{rs,toml}"}]
649 }"#;
650 let req = JsonRequest::parse(input).unwrap();
651 assert_eq!(req.operations[0].glob.as_deref(), Some("*.{rs,toml}"));
652 }
653
654 #[test]
655 fn test_reject_empty_options_glob() {
656 let input = r#"{
657 "operations": [{"op": "replace", "find": "a", "replace": "b"}],
658 "options": {"glob": ""}
659 }"#;
660 let err = JsonRequest::parse(input).unwrap_err();
661 assert!(err.message.contains("Invalid glob in options"));
662 }
663
664 #[test]
665 fn test_reject_malformed_options_ignore() {
666 let input = r#"{
667 "operations": [{"op": "replace", "find": "a", "replace": "b"}],
668 "options": {"ignore": "[bad"}
669 }"#;
670 let err = JsonRequest::parse(input).unwrap_err();
671 assert!(err.message.contains("Invalid ignore glob"));
672 }
673
674 #[test]
677 fn test_per_op_glob_overrides_global() {
678 let input = r#"{
679 "operations": [
680 {"op": "replace", "find": "a", "replace": "b", "glob": "*.rs"},
681 {"op": "delete", "find": "c"}
682 ],
683 "options": {"glob": "*.py"}
684 }"#;
685 let req = JsonRequest::parse(input).unwrap();
686 let (ops, _options) = req.into_ops();
687 assert_eq!(ops[0].1.as_deref(), Some("*.rs"));
689 assert_eq!(ops[1].1.as_deref(), Some("*.py"));
691 }
692
693 #[test]
694 fn test_no_glob_yields_none() {
695 let input = r#"{
696 "operations": [{"op": "replace", "find": "a", "replace": "b"}]
697 }"#;
698 let req = JsonRequest::parse(input).unwrap();
699 let (ops, _) = req.into_ops();
700 assert_eq!(ops[0].1, None);
701 }
702
703 #[test]
706 fn test_parse_undo_request() {
707 let input = r#"{"undo": {"last": 3}}"#;
708 let req = JsonRequest::parse(input).unwrap();
709 assert!(req.operations.is_empty());
710 assert_eq!(req.undo.as_ref().unwrap().last, 3);
711 }
712
713 #[test]
714 fn test_reject_undo_with_operations() {
715 let input = r#"{
716 "operations": [{"op": "replace", "find": "a", "replace": "b"}],
717 "undo": {"last": 1}
718 }"#;
719 let err = JsonRequest::parse(input).unwrap_err();
720 assert!(err.message.contains("both 'operations' and 'undo'"));
721 }
722
723 #[test]
724 fn test_reject_undo_zero() {
725 let input = r#"{"undo": {"last": 0}}"#;
726 let err = JsonRequest::parse(input).unwrap_err();
727 assert!(err.message.contains("'last' must be at least 1"));
728 }
729
730 #[test]
733 fn test_extra_top_level_fields_preserved() {
734 let input = r#"{
735 "operations": [{"op": "replace", "find": "a", "replace": "b"}],
736 "metadata": {"agent": "test-agent", "request_id": "abc123"}
737 }"#;
738 let req = JsonRequest::parse(input).unwrap();
739 assert!(req.extra.contains_key("metadata"));
740 let metadata = req.extra.get("metadata").unwrap();
741 assert_eq!(
742 metadata.get("agent").and_then(|v| v.as_str()),
743 Some("test-agent")
744 );
745 }
746
747 #[test]
748 fn test_unknown_top_level_fields_do_not_cause_error() {
749 let input = r#"{
750 "operations": [{"op": "replace", "find": "a", "replace": "b"}],
751 "future_field": true,
752 "another_thing": [1, 2, 3]
753 }"#;
754 let req = JsonRequest::parse(input).unwrap();
755 assert_eq!(req.extra.len(), 2);
756 }
757
758 #[test]
761 fn test_unknown_op_type_rejected() {
762 let input = r#"{
763 "operations": [{"op": "explode", "find": "a"}]
764 }"#;
765 let err = JsonRequest::parse(input);
766 assert!(err.is_err());
767 }
768
769 #[test]
770 fn test_parse_transform() {
771 let input = r#"{
772 "operations": [{"op": "transform", "find": "hello", "mode": "upper"}]
773 }"#;
774 let req = JsonRequest::parse(input).unwrap();
775 match &req.operations[0].op {
776 Op::Transform { find, mode, .. } => {
777 assert_eq!(find, "hello");
778 assert_eq!(*mode, ripsed_core::operation::TransformMode::Upper);
779 }
780 _ => panic!("Expected Transform operation"),
781 }
782 }
783
784 #[test]
785 fn test_parse_surround() {
786 let input = r#"{
787 "operations": [{"op": "surround", "find": "word", "prefix": "(", "suffix": ")"}]
788 }"#;
789 let req = JsonRequest::parse(input).unwrap();
790 match &req.operations[0].op {
791 Op::Surround {
792 find,
793 prefix,
794 suffix,
795 ..
796 } => {
797 assert_eq!(find, "word");
798 assert_eq!(prefix, "(");
799 assert_eq!(suffix, ")");
800 }
801 _ => panic!("Expected Surround operation"),
802 }
803 }
804
805 #[test]
806 fn test_parse_indent() {
807 let input = r#"{
808 "operations": [{"op": "indent", "find": "fn main", "amount": 2}]
809 }"#;
810 let req = JsonRequest::parse(input).unwrap();
811 match &req.operations[0].op {
812 Op::Indent { find, amount, .. } => {
813 assert_eq!(find, "fn main");
814 assert_eq!(*amount, 2);
815 }
816 _ => panic!("Expected Indent operation"),
817 }
818 }
819
820 #[test]
821 fn test_parse_dedent() {
822 let input = r#"{
823 "operations": [{"op": "dedent", "find": "nested", "amount": 4}]
824 }"#;
825 let req = JsonRequest::parse(input).unwrap();
826 match &req.operations[0].op {
827 Op::Dedent { find, amount, .. } => {
828 assert_eq!(find, "nested");
829 assert_eq!(*amount, 4);
830 }
831 _ => panic!("Expected Dedent operation"),
832 }
833 }
834
835 #[test]
838 fn test_unicode_find_replace() {
839 let input = r#"{
840 "operations": [{"op": "replace", "find": "\u00e9l\u00e8ve", "replace": "\u00e9tudiant"}]
841 }"#;
842 let req = JsonRequest::parse(input).unwrap();
843 match &req.operations[0].op {
844 Op::Replace { find, replace, .. } => {
845 assert_eq!(find, "\u{00e9}l\u{00e8}ve");
846 assert_eq!(replace, "\u{00e9}tudiant");
847 }
848 _ => panic!("Expected Replace"),
849 }
850 }
851
852 #[test]
853 fn test_cjk_find_pattern() {
854 let input = r#"{
855 "operations": [{"op": "replace", "find": "\u4f60\u597d", "replace": "\u5168\u7403"}]
856 }"#;
857 let req = JsonRequest::parse(input).unwrap();
858 match &req.operations[0].op {
859 Op::Replace { find, .. } => {
860 assert_eq!(find, "\u{4f60}\u{597d}");
861 }
862 _ => panic!("Expected Replace"),
863 }
864 }
865
866 #[test]
867 fn test_emoji_in_content() {
868 let input = r#"{
869 "operations": [{
870 "op": "insert_after",
871 "find": "// header",
872 "content": "// \u2764\ufe0f love this code"
873 }]
874 }"#;
875 let req = JsonRequest::parse(input).unwrap();
876 match &req.operations[0].op {
877 Op::InsertAfter { content, .. } => {
878 assert!(content.contains('\u{2764}'));
879 }
880 _ => panic!("Expected InsertAfter"),
881 }
882 }
883
884 #[test]
887 fn test_parse_options() {
888 let input = r#"{
889 "operations": [{"op": "replace", "find": "a", "replace": "b"}],
890 "options": {
891 "dry_run": false,
892 "root": "./my-project",
893 "gitignore": true,
894 "backup": true,
895 "atomic": true,
896 "glob": "**/*.rs",
897 "hidden": true,
898 "max_depth": 5
899 }
900 }"#;
901 let req = JsonRequest::parse(input).unwrap();
902 assert!(!req.options.dry_run);
903 assert_eq!(req.options.root.as_deref(), Some("./my-project"));
904 assert!(req.options.gitignore);
905 assert!(req.options.backup);
906 assert!(req.options.atomic);
907 assert_eq!(req.options.glob.as_deref(), Some("**/*.rs"));
908 assert!(req.options.hidden);
909 assert_eq!(req.options.max_depth, Some(5));
910 }
911
912 #[test]
913 fn test_default_options() {
914 let input = r#"{"operations": [{"op": "replace", "find": "a", "replace": "b"}]}"#;
915 let req = JsonRequest::parse(input).unwrap();
916 assert!(req.options.dry_run);
917 assert!(req.options.gitignore);
918 assert!(!req.options.backup);
919 assert!(!req.options.atomic);
920 assert!(!req.options.hidden);
921 assert!(req.options.glob.is_none());
922 assert!(req.options.root.is_none());
923 }
924
925 #[test]
928 fn test_case_insensitive_flag() {
929 let input = r#"{
930 "operations": [{"op": "replace", "find": "hello", "replace": "world", "case_insensitive": true}]
931 }"#;
932 let req = JsonRequest::parse(input).unwrap();
933 match &req.operations[0].op {
934 Op::Replace {
935 case_insensitive, ..
936 } => {
937 assert!(case_insensitive);
938 }
939 _ => panic!("Expected Replace"),
940 }
941 }
942
943 #[test]
946 fn test_multiple_operations() {
947 let input = r#"{
948 "operations": [
949 {"op": "replace", "find": "old_fn", "replace": "new_fn", "glob": "src/**/*.rs"},
950 {"op": "delete", "find": "^\\s*//\\s*TODO:.*$", "regex": true, "glob": "**/*.rs"},
951 {"op": "insert_after", "find": "use serde::Deserialize;", "content": "use serde::Serialize;", "glob": "src/models/*.rs"}
952 ],
953 "options": {"dry_run": true}
954 }"#;
955 let req = JsonRequest::parse(input).unwrap();
956 assert_eq!(req.operations.len(), 3);
957 }
958
959 #[test]
962 fn test_first_bad_op_reports_index() {
963 let input = r#"{
964 "operations": [
965 {"op": "replace", "find": "good", "replace": "fine"},
966 {"op": "replace", "find": "", "replace": "bad"}
967 ]
968 }"#;
969 let err = JsonRequest::parse(input).unwrap_err();
970 assert!(err.message.contains("Operation 1"));
971 }
972
973 #[test]
974 fn test_bad_regex_reports_index() {
975 let input = r#"{
976 "operations": [
977 {"op": "replace", "find": "ok", "replace": "fine"},
978 {"op": "delete", "find": "[bad", "regex": true}
979 ]
980 }"#;
981 let err = JsonRequest::parse(input).unwrap_err();
982 assert_eq!(err.code, ripsed_core::error::ErrorCode::InvalidRegex);
983 assert_eq!(err.operation_index, Some(1));
984 }
985
986 #[test]
989 fn test_design_doc_rename_struct_request() {
990 let input = r#"{
991 "operations": [
992 {
993 "op": "replace",
994 "find": "UserConfig",
995 "replace": "AppConfig",
996 "glob": "**/*.rs"
997 }
998 ],
999 "options": { "dry_run": true, "root": "/home/dev/my-project" }
1000 }"#;
1001 let req = JsonRequest::parse(input).unwrap();
1002 assert_eq!(req.operations.len(), 1);
1003 assert!(req.options.dry_run);
1004 assert_eq!(req.options.root.as_deref(), Some("/home/dev/my-project"));
1005 let (ops, _) = req.into_ops();
1006 assert_eq!(ops[0].1.as_deref(), Some("**/*.rs"));
1007 }
1008
1009 #[test]
1010 fn test_design_doc_full_request_example() {
1011 let input = r#"{
1012 "version": "1",
1013 "operations": [
1014 {
1015 "op": "replace",
1016 "find": "old_function_name",
1017 "replace": "new_function_name",
1018 "regex": false,
1019 "glob": "src/**/*.rs",
1020 "case_insensitive": false
1021 },
1022 {
1023 "op": "delete",
1024 "find": "^\\s*//\\s*TODO:.*$",
1025 "regex": true,
1026 "glob": "**/*.rs"
1027 },
1028 {
1029 "op": "insert_after",
1030 "find": "use serde::Deserialize;",
1031 "content": "use serde::Serialize;",
1032 "glob": "src/models/*.rs"
1033 }
1034 ],
1035 "options": {
1036 "dry_run": true,
1037 "root": "./my-project",
1038 "gitignore": true,
1039 "backup": false,
1040 "atomic": true
1041 }
1042 }"#;
1043 let req = JsonRequest::parse(input).unwrap();
1044 assert_eq!(req.version, "1");
1045 assert_eq!(req.operations.len(), 3);
1046 assert!(req.options.dry_run);
1047 assert!(req.options.atomic);
1048 assert!(!req.options.backup);
1049 }
1050
1051 #[test]
1052 fn test_design_doc_undo_request() {
1053 let input = r#"{"undo": {"last": 1}}"#;
1054 let req = JsonRequest::parse(input).unwrap();
1055 assert_eq!(req.undo.unwrap().last, 1);
1056 }
1057
1058 #[test]
1061 fn test_serialize_then_parse_roundtrip() {
1062 let request = JsonRequest {
1063 version: "1".to_string(),
1064 operations: vec![JsonOp {
1065 op: Op::Replace {
1066 find: "foo".to_string(),
1067 replace: "bar".to_string(),
1068 regex: false,
1069 case_insensitive: false,
1070 },
1071 glob: Some("**/*.rs".to_string()),
1072 }],
1073 options: OpOptions::default(),
1074 undo: None,
1075 extra: serde_json::Map::new(),
1076 };
1077 let json = serde_json::to_string(&request).unwrap();
1078 let parsed = JsonRequest::parse(&json).unwrap();
1079 assert_eq!(parsed.operations.len(), 1);
1080 assert_eq!(parsed.operations[0].glob.as_deref(), Some("**/*.rs"));
1081 }
1082}