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