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 #[serde(flatten, skip_serializing_if = "serde_json::Map::is_empty")]
34 pub extra: serde_json::Map<String, serde_json::Value>,
35}
36
37#[derive(Debug, Clone, Serialize, Deserialize)]
39pub struct UndoRequest {
40 pub last: usize,
41}
42
43fn default_version() -> String {
44 crate::schema::CURRENT_VERSION.to_string()
45}
46
47impl JsonRequest {
48 pub fn parse(input: &str) -> Result<Self, RipsedError> {
50 let request: JsonRequest = serde_json::from_str(input).map_err(|e| {
51 RipsedError::invalid_request(
52 format!("Failed to parse JSON request: {e}"),
53 "Check that the JSON is well-formed and matches the ripsed request schema.",
54 )
55 })?;
56
57 request.validate()?;
58 Ok(request)
59 }
60
61 fn validate(&self) -> Result<(), RipsedError> {
63 if !crate::schema::is_supported_version(&self.version) {
64 return Err(RipsedError::invalid_request(
65 format!(
66 "Unknown version '{}'. Supported versions: {}",
67 self.version,
68 crate::schema::SUPPORTED_VERSIONS.join(", ")
69 ),
70 format!(
71 "Set \"version\": \"{}\" in your request.",
72 crate::schema::CURRENT_VERSION
73 ),
74 ));
75 }
76
77 if self.undo.is_some() && !self.operations.is_empty() {
78 return Err(RipsedError::invalid_request(
79 "Request cannot contain both 'operations' and 'undo'.",
80 "Send undo and operations as separate requests.",
81 ));
82 }
83
84 if self.undo.is_none() && self.operations.is_empty() {
85 return Err(RipsedError::invalid_request(
86 "Request must contain 'operations' or 'undo'.",
87 "Add at least one operation or an undo request.",
88 ));
89 }
90
91 if let Some(undo) = &self.undo
93 && undo.last == 0
94 {
95 return Err(RipsedError::invalid_request(
96 "Undo 'last' must be at least 1.",
97 "Set \"last\" to the number of operations to undo (minimum 1).",
98 ));
99 }
100
101 if self.options.line_range.is_some() && self.options.range.is_some() {
103 return Err(RipsedError::invalid_request(
104 "Options cannot contain both 'line_range' and 'range'.",
105 "Use a numeric 'line_range' or a pattern-addressed 'range', not both.",
106 ));
107 }
108
109 if let Some(patterns) = &self.options.range {
112 for (which, pattern) in [
113 ("start_pattern", &patterns.start_pattern),
114 ("end_pattern", &patterns.end_pattern),
115 ] {
116 if let Err(e) = regex::Regex::new(pattern) {
117 return Err(RipsedError::invalid_request(
118 format!("Range {which} failed to compile: {e}."),
119 format!("Fix the regex in options.range.{which}: '{pattern}'."),
120 ));
121 }
122 }
123 }
124
125 for (i, json_op) in self.operations.iter().enumerate() {
127 validate_op(i, &json_op.op)?;
128
129 if !matches!(json_op.op, Op::Replace { .. } | Op::Delete { .. })
136 && let Some(value) = json_op.extra.get("multiline")
137 && value.as_bool() != Some(false)
138 {
139 let mut err = RipsedError::invalid_request(
140 format!("Operation {i}: 'multiline' is not supported for this operation type."),
141 "Multiline matching is only available for 'replace' and 'delete' operations.",
142 );
143 err.operation_index = Some(i);
144 return Err(err);
145 }
146
147 if !matches!(json_op.op, Op::Replace { .. })
150 && let Some(value) = json_op.extra.get("count")
151 && value.as_str() != Some("all")
152 {
153 let mut err = RipsedError::invalid_request(
154 format!("Operation {i}: 'count' is not supported for this operation type."),
155 "Replacement counts are only available for 'replace' operations.",
156 );
157 err.operation_index = Some(i);
158 return Err(err);
159 }
160
161 if let Some(glob) = &json_op.glob {
163 validate_glob_pattern(glob).map_err(|msg| {
164 RipsedError::invalid_request(
165 format!("Invalid glob in operation {i}: {msg}"),
166 format!("Fix the glob pattern '{}' in operation {i}. {}", glob, msg),
167 )
168 })?;
169 }
170 }
171
172 if let Some(glob) = &self.options.glob {
174 validate_glob_pattern(glob).map_err(|msg| {
175 RipsedError::invalid_request(
176 format!("Invalid glob in options: {msg}"),
177 format!("Fix the glob pattern '{}' in options. {}", glob, msg),
178 )
179 })?;
180 }
181
182 if let Some(ignore) = &self.options.ignore {
184 validate_glob_pattern(ignore).map_err(|msg| {
185 RipsedError::invalid_request(
186 format!("Invalid ignore glob in options: {msg}"),
187 format!("Fix the ignore pattern '{}' in options. {}", ignore, msg),
188 )
189 })?;
190 }
191
192 Ok(())
193 }
194
195 pub fn into_ops(self) -> (Vec<(Op, Option<String>)>, OpOptions) {
198 let global_glob = self.options.glob.clone();
199 let ops = self
200 .operations
201 .into_iter()
202 .map(|json_op| {
203 let glob = json_op.glob.or_else(|| global_glob.clone());
204 (json_op.op, glob)
205 })
206 .collect();
207 (ops, self.options)
208 }
209}
210
211fn validate_op(index: usize, op: &Op) -> Result<(), RipsedError> {
213 match op {
214 Op::Replace {
216 find,
217 regex,
218 multiline,
219 count,
220 ..
221 } => {
222 if find.is_empty() {
223 return Err(RipsedError::invalid_request(
224 format!("Operation {index}: 'find' must not be empty for replace."),
225 format!("Set a non-empty 'find' pattern in operation {index}."),
226 ));
227 }
228 if *regex {
229 validate_regex(index, find)?;
230 }
231 if let ripsed_core::operation::ReplaceCount::Max(0) = count {
232 return Err(RipsedError::invalid_request(
233 format!("Operation {index}: 'count' max must be at least 1."),
234 format!("Set {{\"max\": n}} with n >= 1 in operation {index}."),
235 ));
236 }
237 if *multiline && matches!(count, ripsed_core::operation::ReplaceCount::FirstPerLine) {
238 return Err(RipsedError::invalid_request(
239 format!(
240 "Operation {index}: 'first_per_line' count is not supported with multiline."
241 ),
242 "Per-line counting has no meaning when matching the whole buffer; use 'first_in_file' or {\"max\": n}.",
243 ));
244 }
245 }
246 Op::Delete { find, regex, .. } => {
247 if find.is_empty() {
248 return Err(RipsedError::invalid_request(
249 format!("Operation {index}: 'find' must not be empty for delete."),
250 format!("Set a non-empty 'find' pattern in operation {index}."),
251 ));
252 }
253 if *regex {
254 validate_regex(index, find)?;
255 }
256 }
257 Op::InsertAfter {
258 find,
259 content,
260 regex,
261 ..
262 } => {
263 if find.is_empty() {
264 return Err(RipsedError::invalid_request(
265 format!("Operation {index}: 'find' must not be empty for insert_after."),
266 format!("Set a non-empty 'find' pattern in operation {index}."),
267 ));
268 }
269 if content.is_empty() {
270 return Err(RipsedError::invalid_request(
271 format!("Operation {index}: 'content' must not be empty for insert_after."),
272 format!("Set a non-empty 'content' in operation {index}."),
273 ));
274 }
275 if *regex {
276 validate_regex(index, find)?;
277 }
278 }
279 Op::InsertBefore {
280 find,
281 content,
282 regex,
283 ..
284 } => {
285 if find.is_empty() {
286 return Err(RipsedError::invalid_request(
287 format!("Operation {index}: 'find' must not be empty for insert_before."),
288 format!("Set a non-empty 'find' pattern in operation {index}."),
289 ));
290 }
291 if content.is_empty() {
292 return Err(RipsedError::invalid_request(
293 format!("Operation {index}: 'content' must not be empty for insert_before."),
294 format!("Set a non-empty 'content' in operation {index}."),
295 ));
296 }
297 if *regex {
298 validate_regex(index, find)?;
299 }
300 }
301 Op::ReplaceLine {
302 find,
303 content,
304 regex,
305 ..
306 } => {
307 if find.is_empty() {
308 return Err(RipsedError::invalid_request(
309 format!("Operation {index}: 'find' must not be empty for replace_line."),
310 format!("Set a non-empty 'find' pattern in operation {index}."),
311 ));
312 }
313 if content.is_empty() {
314 return Err(RipsedError::invalid_request(
315 format!("Operation {index}: 'content' must not be empty for replace_line."),
316 format!("Set a non-empty 'content' in operation {index}."),
317 ));
318 }
319 if *regex {
320 validate_regex(index, find)?;
321 }
322 }
323 Op::Transform { find, regex, .. } => {
324 if find.is_empty() {
325 return Err(RipsedError::invalid_request(
326 format!("Operation {index}: 'find' must not be empty for transform."),
327 format!("Set a non-empty 'find' pattern in operation {index}."),
328 ));
329 }
330 if *regex {
331 validate_regex(index, find)?;
332 }
333 }
334 Op::Surround {
335 find,
336 prefix,
337 suffix,
338 regex,
339 ..
340 } => {
341 if find.is_empty() {
342 return Err(RipsedError::invalid_request(
343 format!("Operation {index}: 'find' must not be empty for surround."),
344 format!("Set a non-empty 'find' pattern in operation {index}."),
345 ));
346 }
347 if prefix.is_empty() && suffix.is_empty() {
348 return Err(RipsedError::invalid_request(
349 format!(
350 "Operation {index}: 'prefix' or 'suffix' must not both be empty for surround."
351 ),
352 format!("Set a non-empty 'prefix' or 'suffix' in operation {index}."),
353 ));
354 }
355 if *regex {
356 validate_regex(index, find)?;
357 }
358 }
359 Op::Indent { find, regex, .. } => {
360 if find.is_empty() {
361 return Err(RipsedError::invalid_request(
362 format!("Operation {index}: 'find' must not be empty for indent."),
363 format!("Set a non-empty 'find' pattern in operation {index}."),
364 ));
365 }
366 if *regex {
367 validate_regex(index, find)?;
368 }
369 }
370 Op::Dedent { find, regex, .. } => {
371 if find.is_empty() {
372 return Err(RipsedError::invalid_request(
373 format!("Operation {index}: 'find' must not be empty for dedent."),
374 format!("Set a non-empty 'find' pattern in operation {index}."),
375 ));
376 }
377 if *regex {
378 validate_regex(index, find)?;
379 }
380 }
381 _ => {}
382 }
383
384 Ok(())
385}
386
387fn validate_regex(index: usize, pattern: &str) -> Result<(), RipsedError> {
389 regex::Regex::new(pattern)
390 .map_err(|e| RipsedError::invalid_regex(index, pattern, &e.to_string()))?;
391 Ok(())
392}
393
394fn validate_glob_pattern(pattern: &str) -> Result<(), String> {
396 if pattern.is_empty() {
397 return Err("Glob pattern must not be empty.".to_string());
398 }
399
400 let mut in_bracket = false;
402 let mut chars = pattern.chars().peekable();
403 while let Some(ch) = chars.next() {
404 match ch {
405 '\\' => {
406 let _ = chars.next();
408 }
409 '[' if !in_bracket => {
410 in_bracket = true;
411 }
412 ']' if in_bracket => {
413 in_bracket = false;
414 }
415 '{' => {
416 let mut brace_depth = 1;
418 let mut found_close = false;
419 for next_ch in chars.by_ref() {
420 match next_ch {
421 '{' => brace_depth += 1,
422 '}' => {
423 brace_depth -= 1;
424 if brace_depth == 0 {
425 found_close = true;
426 break;
427 }
428 }
429 _ => {}
430 }
431 }
432 if !found_close {
433 return Err("Unmatched '{' in glob pattern. Add a closing '}'.".to_string());
434 }
435 }
436 '}' => {
437 return Err(
438 "Unmatched '}' in glob pattern. Remove the extra '}' or add an opening '{'."
439 .to_string(),
440 );
441 }
442 _ => {}
443 }
444 }
445
446 if in_bracket {
447 return Err("Unmatched '[' in glob pattern. Add a closing ']'.".to_string());
448 }
449
450 Ok(())
451}
452
453#[cfg(test)]
454mod tests {
455 use super::*;
456
457 #[test]
460 fn test_parse_simple_replace() {
461 let input = r#"{
462 "operations": [{"op": "replace", "find": "foo", "replace": "bar"}]
463 }"#;
464 let req = JsonRequest::parse(input).unwrap();
465 assert_eq!(req.operations.len(), 1);
466 assert!(req.options.dry_run); }
468
469 #[test]
470 fn test_parse_invalid_json() {
471 let result = JsonRequest::parse("not json");
472 assert!(result.is_err());
473 }
474
475 #[test]
476 fn test_parse_empty_operations() {
477 let input = r#"{"operations": []}"#;
478 let result = JsonRequest::parse(input);
479 assert!(result.is_err());
480 }
481
482 #[test]
483 fn test_parse_unknown_version() {
484 let input =
485 r#"{"version": "99", "operations": [{"op": "replace", "find": "a", "replace": "b"}]}"#;
486 let result = JsonRequest::parse(input);
487 assert!(result.is_err());
488 }
489
490 #[test]
493 fn test_parse_delete() {
494 let input = r#"{
495 "operations": [{"op": "delete", "find": "TODO", "regex": false}]
496 }"#;
497 let req = JsonRequest::parse(input).unwrap();
498 assert_eq!(req.operations.len(), 1);
499 match &req.operations[0].op {
500 Op::Delete { find, regex, .. } => {
501 assert_eq!(find, "TODO");
502 assert!(!regex);
503 }
504 _ => panic!("Expected Delete operation"),
505 }
506 }
507
508 #[test]
509 fn test_parse_delete_with_regex() {
510 let input = r#"{
511 "operations": [{"op": "delete", "find": "^\\s*//\\s*TODO:.*$", "regex": true}]
512 }"#;
513 let req = JsonRequest::parse(input).unwrap();
514 match &req.operations[0].op {
515 Op::Delete { find, regex, .. } => {
516 assert_eq!(find, r"^\s*//\s*TODO:.*$");
517 assert!(regex);
518 }
519 _ => panic!("Expected Delete operation"),
520 }
521 }
522
523 #[test]
524 fn test_parse_insert_after() {
525 let input = r#"{
526 "operations": [{
527 "op": "insert_after",
528 "find": "use serde::Deserialize;",
529 "content": "use serde::Serialize;",
530 "glob": "src/models/*.rs"
531 }]
532 }"#;
533 let req = JsonRequest::parse(input).unwrap();
534 assert_eq!(req.operations.len(), 1);
535 match &req.operations[0].op {
536 Op::InsertAfter { find, content, .. } => {
537 assert_eq!(find, "use serde::Deserialize;");
538 assert_eq!(content, "use serde::Serialize;");
539 }
540 _ => panic!("Expected InsertAfter operation"),
541 }
542 assert_eq!(req.operations[0].glob.as_deref(), Some("src/models/*.rs"));
543 }
544
545 #[test]
546 fn test_parse_insert_before() {
547 let input = r#"{
548 "operations": [{
549 "op": "insert_before",
550 "find": "fn main()",
551 "content": "// Entry point"
552 }]
553 }"#;
554 let req = JsonRequest::parse(input).unwrap();
555 match &req.operations[0].op {
556 Op::InsertBefore { find, content, .. } => {
557 assert_eq!(find, "fn main()");
558 assert_eq!(content, "// Entry point");
559 }
560 _ => panic!("Expected InsertBefore operation"),
561 }
562 }
563
564 #[test]
565 fn test_parse_replace_line() {
566 let input = r#"{
567 "operations": [{
568 "op": "replace_line",
569 "find": "old_version = 1",
570 "content": "new_version = 2"
571 }]
572 }"#;
573 let req = JsonRequest::parse(input).unwrap();
574 match &req.operations[0].op {
575 Op::ReplaceLine { find, content, .. } => {
576 assert_eq!(find, "old_version = 1");
577 assert_eq!(content, "new_version = 2");
578 }
579 _ => panic!("Expected ReplaceLine operation"),
580 }
581 }
582
583 #[test]
586 fn test_reject_empty_find_replace() {
587 let input = r#"{"operations": [{"op": "replace", "find": "", "replace": "bar"}]}"#;
588 let err = JsonRequest::parse(input).unwrap_err();
589 assert!(err.message.contains("'find' must not be empty"));
590 }
591
592 #[test]
593 fn test_reject_empty_find_delete() {
594 let input = r#"{"operations": [{"op": "delete", "find": ""}]}"#;
595 let err = JsonRequest::parse(input).unwrap_err();
596 assert!(err.message.contains("'find' must not be empty"));
597 }
598
599 #[test]
600 fn test_reject_empty_find_insert_after() {
601 let input = r#"{"operations": [{"op": "insert_after", "find": "", "content": "x"}]}"#;
602 let err = JsonRequest::parse(input).unwrap_err();
603 assert!(err.message.contains("'find' must not be empty"));
604 }
605
606 #[test]
607 fn test_reject_empty_find_insert_before() {
608 let input = r#"{"operations": [{"op": "insert_before", "find": "", "content": "x"}]}"#;
609 let err = JsonRequest::parse(input).unwrap_err();
610 assert!(err.message.contains("'find' must not be empty"));
611 }
612
613 #[test]
614 fn test_reject_empty_find_replace_line() {
615 let input = r#"{"operations": [{"op": "replace_line", "find": "", "content": "x"}]}"#;
616 let err = JsonRequest::parse(input).unwrap_err();
617 assert!(err.message.contains("'find' must not be empty"));
618 }
619
620 #[test]
623 fn test_reject_empty_content_insert_after() {
624 let input = r#"{"operations": [{"op": "insert_after", "find": "x", "content": ""}]}"#;
625 let err = JsonRequest::parse(input).unwrap_err();
626 assert!(err.message.contains("'content' must not be empty"));
627 }
628
629 #[test]
630 fn test_reject_empty_content_insert_before() {
631 let input = r#"{"operations": [{"op": "insert_before", "find": "x", "content": ""}]}"#;
632 let err = JsonRequest::parse(input).unwrap_err();
633 assert!(err.message.contains("'content' must not be empty"));
634 }
635
636 #[test]
637 fn test_reject_empty_content_replace_line() {
638 let input = r#"{"operations": [{"op": "replace_line", "find": "x", "content": ""}]}"#;
639 let err = JsonRequest::parse(input).unwrap_err();
640 assert!(err.message.contains("'content' must not be empty"));
641 }
642
643 #[test]
646 fn test_allow_empty_replacement_in_replace() {
647 let input = r#"{"operations": [{"op": "replace", "find": "remove_me", "replace": ""}]}"#;
648 let req = JsonRequest::parse(input).unwrap();
649 match &req.operations[0].op {
650 Op::Replace { find, replace, .. } => {
651 assert_eq!(find, "remove_me");
652 assert_eq!(replace, "");
653 }
654 _ => panic!("Expected Replace operation"),
655 }
656 }
657
658 #[test]
661 fn test_reject_invalid_regex_in_replace() {
662 let input = r#"{"operations": [{"op": "replace", "find": "fn (foo", "replace": "bar", "regex": true}]}"#;
663 let err = JsonRequest::parse(input).unwrap_err();
664 assert_eq!(err.code, ripsed_core::error::ErrorCode::InvalidRegex);
665 }
666
667 #[test]
668 fn test_reject_invalid_regex_in_delete() {
669 let input = r#"{"operations": [{"op": "delete", "find": "[unclosed", "regex": true}]}"#;
670 let err = JsonRequest::parse(input).unwrap_err();
671 assert_eq!(err.code, ripsed_core::error::ErrorCode::InvalidRegex);
672 }
673
674 #[test]
675 fn test_accept_valid_regex_in_delete() {
676 let input = r#"{"operations": [{"op": "delete", "find": "^\\s*//.*$", "regex": true}]}"#;
677 let req = JsonRequest::parse(input).unwrap();
678 assert_eq!(req.operations.len(), 1);
679 }
680
681 #[test]
684 fn test_accept_valid_glob() {
685 let input = r#"{
686 "operations": [{"op": "replace", "find": "a", "replace": "b", "glob": "**/*.rs"}]
687 }"#;
688 let req = JsonRequest::parse(input).unwrap();
689 assert_eq!(req.operations[0].glob.as_deref(), Some("**/*.rs"));
690 }
691
692 #[test]
693 fn test_reject_empty_glob() {
694 let input = r#"{
695 "operations": [{"op": "replace", "find": "a", "replace": "b", "glob": ""}]
696 }"#;
697 let err = JsonRequest::parse(input).unwrap_err();
698 assert!(err.message.contains("Invalid glob"));
699 }
700
701 #[test]
702 fn test_reject_unmatched_open_bracket() {
703 let input = r#"{
704 "operations": [{"op": "replace", "find": "a", "replace": "b", "glob": "[unclosed"}]
705 }"#;
706 let err = JsonRequest::parse(input).unwrap_err();
707 assert!(err.message.contains("Unmatched '['"));
708 }
709
710 #[test]
711 fn test_reject_unmatched_open_brace() {
712 let input = r#"{
713 "operations": [{"op": "replace", "find": "a", "replace": "b", "glob": "{a,b"}]
714 }"#;
715 let err = JsonRequest::parse(input).unwrap_err();
716 assert!(err.message.contains("Unmatched '{'"));
717 }
718
719 #[test]
720 fn test_reject_unmatched_close_brace() {
721 let input = r#"{
722 "operations": [{"op": "replace", "find": "a", "replace": "b", "glob": "a,b}"}]
723 }"#;
724 let err = JsonRequest::parse(input).unwrap_err();
725 assert!(err.message.contains("Unmatched '}'"));
726 }
727
728 #[test]
729 fn test_accept_valid_alternation_glob() {
730 let input = r#"{
731 "operations": [{"op": "replace", "find": "a", "replace": "b", "glob": "*.{rs,toml}"}]
732 }"#;
733 let req = JsonRequest::parse(input).unwrap();
734 assert_eq!(req.operations[0].glob.as_deref(), Some("*.{rs,toml}"));
735 }
736
737 #[test]
738 fn test_reject_empty_options_glob() {
739 let input = r#"{
740 "operations": [{"op": "replace", "find": "a", "replace": "b"}],
741 "options": {"glob": ""}
742 }"#;
743 let err = JsonRequest::parse(input).unwrap_err();
744 assert!(err.message.contains("Invalid glob in options"));
745 }
746
747 #[test]
748 fn test_reject_malformed_options_ignore() {
749 let input = r#"{
750 "operations": [{"op": "replace", "find": "a", "replace": "b"}],
751 "options": {"ignore": "[bad"}
752 }"#;
753 let err = JsonRequest::parse(input).unwrap_err();
754 assert!(err.message.contains("Invalid ignore glob"));
755 }
756
757 #[test]
760 fn test_per_op_glob_overrides_global() {
761 let input = r#"{
762 "operations": [
763 {"op": "replace", "find": "a", "replace": "b", "glob": "*.rs"},
764 {"op": "delete", "find": "c"}
765 ],
766 "options": {"glob": "*.py"}
767 }"#;
768 let req = JsonRequest::parse(input).unwrap();
769 let (ops, _options) = req.into_ops();
770 assert_eq!(ops[0].1.as_deref(), Some("*.rs"));
772 assert_eq!(ops[1].1.as_deref(), Some("*.py"));
774 }
775
776 #[test]
777 fn test_no_glob_yields_none() {
778 let input = r#"{
779 "operations": [{"op": "replace", "find": "a", "replace": "b"}]
780 }"#;
781 let req = JsonRequest::parse(input).unwrap();
782 let (ops, _) = req.into_ops();
783 assert_eq!(ops[0].1, None);
784 }
785
786 #[test]
789 fn test_parse_undo_request() {
790 let input = r#"{"undo": {"last": 3}}"#;
791 let req = JsonRequest::parse(input).unwrap();
792 assert!(req.operations.is_empty());
793 assert_eq!(req.undo.as_ref().unwrap().last, 3);
794 }
795
796 #[test]
797 fn test_reject_undo_with_operations() {
798 let input = r#"{
799 "operations": [{"op": "replace", "find": "a", "replace": "b"}],
800 "undo": {"last": 1}
801 }"#;
802 let err = JsonRequest::parse(input).unwrap_err();
803 assert!(err.message.contains("both 'operations' and 'undo'"));
804 }
805
806 #[test]
807 fn test_reject_undo_zero() {
808 let input = r#"{"undo": {"last": 0}}"#;
809 let err = JsonRequest::parse(input).unwrap_err();
810 assert!(err.message.contains("'last' must be at least 1"));
811 }
812
813 #[test]
816 fn test_extra_top_level_fields_preserved() {
817 let input = r#"{
818 "operations": [{"op": "replace", "find": "a", "replace": "b"}],
819 "metadata": {"agent": "test-agent", "request_id": "abc123"}
820 }"#;
821 let req = JsonRequest::parse(input).unwrap();
822 assert!(req.extra.contains_key("metadata"));
823 let metadata = req.extra.get("metadata").unwrap();
824 assert_eq!(
825 metadata.get("agent").and_then(|v| v.as_str()),
826 Some("test-agent")
827 );
828 }
829
830 #[test]
831 fn test_unknown_top_level_fields_do_not_cause_error() {
832 let input = r#"{
833 "operations": [{"op": "replace", "find": "a", "replace": "b"}],
834 "future_field": true,
835 "another_thing": [1, 2, 3]
836 }"#;
837 let req = JsonRequest::parse(input).unwrap();
838 assert_eq!(req.extra.len(), 2);
839 }
840
841 #[test]
844 fn test_unknown_op_type_rejected() {
845 let input = r#"{
846 "operations": [{"op": "explode", "find": "a"}]
847 }"#;
848 let err = JsonRequest::parse(input);
849 assert!(err.is_err());
850 }
851
852 #[test]
853 fn test_parse_transform() {
854 let input = r#"{
855 "operations": [{"op": "transform", "find": "hello", "mode": "upper"}]
856 }"#;
857 let req = JsonRequest::parse(input).unwrap();
858 match &req.operations[0].op {
859 Op::Transform { find, mode, .. } => {
860 assert_eq!(find, "hello");
861 assert_eq!(*mode, ripsed_core::operation::TransformMode::Upper);
862 }
863 _ => panic!("Expected Transform operation"),
864 }
865 }
866
867 #[test]
868 fn test_parse_surround() {
869 let input = r#"{
870 "operations": [{"op": "surround", "find": "word", "prefix": "(", "suffix": ")"}]
871 }"#;
872 let req = JsonRequest::parse(input).unwrap();
873 match &req.operations[0].op {
874 Op::Surround {
875 find,
876 prefix,
877 suffix,
878 ..
879 } => {
880 assert_eq!(find, "word");
881 assert_eq!(prefix, "(");
882 assert_eq!(suffix, ")");
883 }
884 _ => panic!("Expected Surround operation"),
885 }
886 }
887
888 #[test]
889 fn test_parse_indent() {
890 let input = r#"{
891 "operations": [{"op": "indent", "find": "fn main", "amount": 2}]
892 }"#;
893 let req = JsonRequest::parse(input).unwrap();
894 match &req.operations[0].op {
895 Op::Indent { find, amount, .. } => {
896 assert_eq!(find, "fn main");
897 assert_eq!(*amount, 2);
898 }
899 _ => panic!("Expected Indent operation"),
900 }
901 }
902
903 #[test]
904 fn test_parse_dedent() {
905 let input = r#"{
906 "operations": [{"op": "dedent", "find": "nested", "amount": 4}]
907 }"#;
908 let req = JsonRequest::parse(input).unwrap();
909 match &req.operations[0].op {
910 Op::Dedent { find, amount, .. } => {
911 assert_eq!(find, "nested");
912 assert_eq!(*amount, 4);
913 }
914 _ => panic!("Expected Dedent operation"),
915 }
916 }
917
918 #[test]
921 fn test_unicode_find_replace() {
922 let input = r#"{
923 "operations": [{"op": "replace", "find": "\u00e9l\u00e8ve", "replace": "\u00e9tudiant"}]
924 }"#;
925 let req = JsonRequest::parse(input).unwrap();
926 match &req.operations[0].op {
927 Op::Replace { find, replace, .. } => {
928 assert_eq!(find, "\u{00e9}l\u{00e8}ve");
929 assert_eq!(replace, "\u{00e9}tudiant");
930 }
931 _ => panic!("Expected Replace"),
932 }
933 }
934
935 #[test]
936 fn test_cjk_find_pattern() {
937 let input = r#"{
938 "operations": [{"op": "replace", "find": "\u4f60\u597d", "replace": "\u5168\u7403"}]
939 }"#;
940 let req = JsonRequest::parse(input).unwrap();
941 match &req.operations[0].op {
942 Op::Replace { find, .. } => {
943 assert_eq!(find, "\u{4f60}\u{597d}");
944 }
945 _ => panic!("Expected Replace"),
946 }
947 }
948
949 #[test]
950 fn test_emoji_in_content() {
951 let input = r#"{
952 "operations": [{
953 "op": "insert_after",
954 "find": "// header",
955 "content": "// \u2764\ufe0f love this code"
956 }]
957 }"#;
958 let req = JsonRequest::parse(input).unwrap();
959 match &req.operations[0].op {
960 Op::InsertAfter { content, .. } => {
961 assert!(content.contains('\u{2764}'));
962 }
963 _ => panic!("Expected InsertAfter"),
964 }
965 }
966
967 #[test]
970 fn test_parse_options() {
971 let input = r#"{
972 "operations": [{"op": "replace", "find": "a", "replace": "b"}],
973 "options": {
974 "dry_run": false,
975 "root": "./my-project",
976 "gitignore": true,
977 "backup": true,
978 "atomic": true,
979 "glob": "**/*.rs",
980 "hidden": true,
981 "max_depth": 5
982 }
983 }"#;
984 let req = JsonRequest::parse(input).unwrap();
985 assert!(!req.options.dry_run);
986 assert_eq!(req.options.root.as_deref(), Some("./my-project"));
987 assert!(req.options.gitignore);
988 assert!(req.options.backup);
989 assert!(req.options.atomic);
990 assert_eq!(req.options.glob.as_deref(), Some("**/*.rs"));
991 assert!(req.options.hidden);
992 assert_eq!(req.options.max_depth, Some(5));
993 }
994
995 #[test]
996 fn test_default_options() {
997 let input = r#"{"operations": [{"op": "replace", "find": "a", "replace": "b"}]}"#;
998 let req = JsonRequest::parse(input).unwrap();
999 assert!(req.options.dry_run);
1000 assert!(req.options.gitignore);
1001 assert!(!req.options.backup);
1002 assert!(!req.options.atomic);
1003 assert!(!req.options.hidden);
1004 assert!(req.options.glob.is_none());
1005 assert!(req.options.root.is_none());
1006 }
1007
1008 #[test]
1011 fn test_multiline_flag_on_replace_and_delete() {
1012 let input = r#"{
1013 "operations": [
1014 {"op": "replace", "find": "a\nb", "replace": "ab", "multiline": true},
1015 {"op": "delete", "find": "x\ny", "multiline": true}
1016 ]
1017 }"#;
1018 let req = JsonRequest::parse(input).unwrap();
1019 assert!(req.operations[0].op.is_multiline());
1020 assert!(req.operations[1].op.is_multiline());
1021 }
1022
1023 #[test]
1024 fn test_multiline_false_on_line_scoped_op_is_tolerated() {
1025 let input = r#"{
1028 "operations": [{"op": "insert_after", "find": "a", "content": "b", "multiline": false}]
1029 }"#;
1030 assert!(JsonRequest::parse(input).is_ok());
1031 }
1032
1033 #[test]
1034 fn test_multiline_defaults_to_false_when_omitted() {
1035 let input = r#"{
1036 "operations": [{"op": "replace", "find": "a", "replace": "b"}]
1037 }"#;
1038 let req = JsonRequest::parse(input).unwrap();
1039 assert!(!req.operations[0].op.is_multiline());
1040 }
1041
1042 #[test]
1043 fn test_multiline_rejected_on_line_scoped_ops() {
1044 for op_json in [
1045 r#"{"op": "insert_after", "find": "a", "content": "b", "multiline": true}"#,
1046 r#"{"op": "transform", "find": "a", "mode": "upper", "multiline": true}"#,
1047 r#"{"op": "indent", "find": "a", "amount": 2, "multiline": true}"#,
1048 ] {
1049 let input = format!(r#"{{"operations": [{op_json}]}}"#);
1050 let err = JsonRequest::parse(&input).unwrap_err();
1051 assert_eq!(
1052 err.code,
1053 ripsed_core::error::ErrorCode::InvalidRequest,
1054 "expected rejection for {op_json}"
1055 );
1056 assert_eq!(err.operation_index, Some(0));
1057 assert!(err.message.contains("multiline"));
1058 }
1059 }
1060
1061 #[test]
1064 fn test_count_accepted_on_replace() {
1065 let input = r#"{
1066 "operations": [
1067 {"op": "replace", "find": "a", "replace": "b", "count": "first_per_line"},
1068 {"op": "replace", "find": "a", "replace": "b", "count": {"max": 2}}
1069 ]
1070 }"#;
1071 assert!(JsonRequest::parse(input).is_ok());
1072 }
1073
1074 #[test]
1075 fn test_count_max_zero_rejected() {
1076 let input = r#"{
1077 "operations": [{"op": "replace", "find": "a", "replace": "b", "count": {"max": 0}}]
1078 }"#;
1079 let err = JsonRequest::parse(input).unwrap_err();
1080 assert_eq!(err.code, ripsed_core::error::ErrorCode::InvalidRequest);
1081 assert!(err.message.contains("max"));
1082 }
1083
1084 #[test]
1085 fn test_count_rejected_on_non_replace_ops() {
1086 let input = r#"{
1087 "operations": [{"op": "delete", "find": "a", "count": "first_per_line"}]
1088 }"#;
1089 let err = JsonRequest::parse(input).unwrap_err();
1090 assert_eq!(err.code, ripsed_core::error::ErrorCode::InvalidRequest);
1091 assert_eq!(err.operation_index, Some(0));
1092 assert!(err.message.contains("count"));
1093 }
1094
1095 #[test]
1096 fn test_count_all_tolerated_on_non_replace_ops() {
1097 let input = r#"{
1098 "operations": [{"op": "delete", "find": "a", "count": "all"}]
1099 }"#;
1100 assert!(JsonRequest::parse(input).is_ok());
1101 }
1102
1103 #[test]
1104 fn test_count_first_per_line_with_multiline_rejected() {
1105 let input = r#"{
1106 "operations": [{"op": "replace", "find": "a", "replace": "b", "multiline": true, "count": "first_per_line"}]
1107 }"#;
1108 let err = JsonRequest::parse(input).unwrap_err();
1109 assert_eq!(err.code, ripsed_core::error::ErrorCode::InvalidRequest);
1110 assert!(err.message.contains("first_per_line"));
1111 }
1112
1113 #[test]
1116 fn test_case_insensitive_flag() {
1117 let input = r#"{
1118 "operations": [{"op": "replace", "find": "hello", "replace": "world", "case_insensitive": true}]
1119 }"#;
1120 let req = JsonRequest::parse(input).unwrap();
1121 match &req.operations[0].op {
1122 Op::Replace {
1123 case_insensitive, ..
1124 } => {
1125 assert!(case_insensitive);
1126 }
1127 _ => panic!("Expected Replace"),
1128 }
1129 }
1130
1131 #[test]
1134 fn test_multiple_operations() {
1135 let input = r#"{
1136 "operations": [
1137 {"op": "replace", "find": "old_fn", "replace": "new_fn", "glob": "src/**/*.rs"},
1138 {"op": "delete", "find": "^\\s*//\\s*TODO:.*$", "regex": true, "glob": "**/*.rs"},
1139 {"op": "insert_after", "find": "use serde::Deserialize;", "content": "use serde::Serialize;", "glob": "src/models/*.rs"}
1140 ],
1141 "options": {"dry_run": true}
1142 }"#;
1143 let req = JsonRequest::parse(input).unwrap();
1144 assert_eq!(req.operations.len(), 3);
1145 }
1146
1147 #[test]
1150 fn test_first_bad_op_reports_index() {
1151 let input = r#"{
1152 "operations": [
1153 {"op": "replace", "find": "good", "replace": "fine"},
1154 {"op": "replace", "find": "", "replace": "bad"}
1155 ]
1156 }"#;
1157 let err = JsonRequest::parse(input).unwrap_err();
1158 assert!(err.message.contains("Operation 1"));
1159 }
1160
1161 #[test]
1162 fn test_bad_regex_reports_index() {
1163 let input = r#"{
1164 "operations": [
1165 {"op": "replace", "find": "ok", "replace": "fine"},
1166 {"op": "delete", "find": "[bad", "regex": true}
1167 ]
1168 }"#;
1169 let err = JsonRequest::parse(input).unwrap_err();
1170 assert_eq!(err.code, ripsed_core::error::ErrorCode::InvalidRegex);
1171 assert_eq!(err.operation_index, Some(1));
1172 }
1173
1174 #[test]
1177 fn test_design_doc_rename_struct_request() {
1178 let input = r#"{
1179 "operations": [
1180 {
1181 "op": "replace",
1182 "find": "UserConfig",
1183 "replace": "AppConfig",
1184 "glob": "**/*.rs"
1185 }
1186 ],
1187 "options": { "dry_run": true, "root": "/home/dev/my-project" }
1188 }"#;
1189 let req = JsonRequest::parse(input).unwrap();
1190 assert_eq!(req.operations.len(), 1);
1191 assert!(req.options.dry_run);
1192 assert_eq!(req.options.root.as_deref(), Some("/home/dev/my-project"));
1193 let (ops, _) = req.into_ops();
1194 assert_eq!(ops[0].1.as_deref(), Some("**/*.rs"));
1195 }
1196
1197 #[test]
1198 fn test_design_doc_full_request_example() {
1199 let input = r#"{
1200 "version": "1",
1201 "operations": [
1202 {
1203 "op": "replace",
1204 "find": "old_function_name",
1205 "replace": "new_function_name",
1206 "regex": false,
1207 "glob": "src/**/*.rs",
1208 "case_insensitive": false
1209 },
1210 {
1211 "op": "delete",
1212 "find": "^\\s*//\\s*TODO:.*$",
1213 "regex": true,
1214 "glob": "**/*.rs"
1215 },
1216 {
1217 "op": "insert_after",
1218 "find": "use serde::Deserialize;",
1219 "content": "use serde::Serialize;",
1220 "glob": "src/models/*.rs"
1221 }
1222 ],
1223 "options": {
1224 "dry_run": true,
1225 "root": "./my-project",
1226 "gitignore": true,
1227 "backup": false,
1228 "atomic": true
1229 }
1230 }"#;
1231 let req = JsonRequest::parse(input).unwrap();
1232 assert_eq!(req.version, "1");
1233 assert_eq!(req.operations.len(), 3);
1234 assert!(req.options.dry_run);
1235 assert!(req.options.atomic);
1236 assert!(!req.options.backup);
1237 }
1238
1239 #[test]
1240 fn test_design_doc_undo_request() {
1241 let input = r#"{"undo": {"last": 1}}"#;
1242 let req = JsonRequest::parse(input).unwrap();
1243 assert_eq!(req.undo.unwrap().last, 1);
1244 }
1245}