Skip to main content

ripsed_json/
request.rs

1use ripsed_core::error::RipsedError;
2use ripsed_core::operation::{Op, OpOptions};
3use serde::{Deserialize, Serialize};
4
5/// A structured JSON request from an agent.
6#[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    /// Undo request (mutually exclusive with operations).
15    #[serde(skip_serializing_if = "Option::is_none")]
16    pub undo: Option<UndoRequest>,
17    /// Forward-compatible: capture unknown top-level fields.
18    #[serde(flatten)]
19    pub extra: serde_json::Map<String, serde_json::Value>,
20}
21
22/// A single operation in a JSON request, with per-operation glob.
23#[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/// An undo request.
32#[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    /// Parse and validate a JSON request from a string.
43    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    /// Validate the request after parsing.
56    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        // Validate undo request
86        if let Some(undo) = &self.undo
87            && undo.last == 0
88        {
89            return Err(RipsedError::invalid_request(
90                "Undo 'last' must be at least 1.",
91                "Set \"last\" to the number of operations to undo (minimum 1).",
92            ));
93        }
94
95        // Validate each operation
96        for (i, json_op) in self.operations.iter().enumerate() {
97            validate_op(i, &json_op.op)?;
98
99            // Validate per-operation glob if present
100            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        // Validate global glob in options
111        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        // Validate ignore glob in options
121        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    /// Extract the list of operations with their effective globs.
134    /// Per-operation globs take precedence over the global options glob.
135    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
149/// Validate a single operation's fields.
150fn validate_op(index: usize, op: &Op) -> Result<(), RipsedError> {
151    match op {
152        // Note: an empty replacement is valid (it deletes the matched text)
153        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
305/// Validate that a string compiles as a valid regex.
306fn 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
312/// Validate a glob pattern for common malformations.
313fn 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    // Check for unmatched brackets
319    let mut in_bracket = false;
320    let mut chars = pattern.chars().peekable();
321    while let Some(ch) = chars.next() {
322        match ch {
323            '\\' => {
324                // Skip escaped character
325                let _ = chars.next();
326            }
327            '[' if !in_bracket => {
328                in_bracket = true;
329            }
330            ']' if in_bracket => {
331                in_bracket = false;
332            }
333            '{' => {
334                // Check for unmatched braces
335                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    // ── Basic parsing ──
376
377    #[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); // default
385    }
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    // ── Every operation type ──
409
410    #[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    // ── Validation: empty find ──
502
503    #[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    // ── Validation: empty content ──
539
540    #[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    // ── Replace with empty replacement is valid (acts as deletion) ──
562
563    #[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    // ── Regex validation ──
577
578    #[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    // ── Glob validation ──
600
601    #[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    // ── Per-operation glob extraction ──
676
677    #[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        // First op has per-op glob, should override global
689        assert_eq!(ops[0].1.as_deref(), Some("*.rs"));
690        // Second op has no per-op glob, should inherit global
691        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    // ── Undo requests ──
705
706    #[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    // ── Forward compatibility: extra fields preserved ──
732
733    #[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    // ── Unknown operation type ──
760
761    #[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    // ── Unicode patterns ──
837
838    #[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    // ── Options parsing ──
886
887    #[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    // ── Case insensitive flag ──
927
928    #[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    // ── Batch operations ──
945
946    #[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    // ── Nested validation errors ──
961
962    #[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    // ── Design doc example: full agent workflow request ──
988
989    #[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}