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