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    /// Every op field as raw JSON (serde's flatten buffering copies ALL
30    /// keys here, including ones `Op` consumed — not just leftovers).
31    /// Used by validation to detect fields that an op variant silently
32    /// dropped, e.g. `multiline` on a line-scoped operation.
33    #[serde(flatten, skip_serializing_if = "serde_json::Map::is_empty")]
34    pub extra: serde_json::Map<String, serde_json::Value>,
35}
36
37/// An undo request.
38#[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    /// Parse and validate a JSON request from a string.
49    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    /// Validate the request after parsing.
62    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        // Validate undo request
92        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        // line_range and pattern range are mutually exclusive
102        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        // Pattern-range regexes must compile — fail the request up front
110        // rather than per file at apply time.
111        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        // Validate each operation
126        for (i, json_op) in self.operations.iter().enumerate() {
127            validate_op(i, &json_op.op)?;
128
129            // `multiline` only exists on replace/delete. Serde's flatten
130            // buffering copies every key into `extra` (even ones `Op`
131            // consumed), so the field's presence there is only meaningful
132            // for ops that can't consume it — reject those rather than
133            // silently ignore, since an agent that sets it expects it to
134            // work. An explicit `false` is a harmless no-op and allowed.
135            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            // Same detection pattern for 'count', which only replace consumes.
148            // An explicit "all" is the default and tolerated as a no-op.
149            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            // Validate per-operation glob if present
162            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        // Validate global glob in options
173        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        // Validate ignore glob in options
183        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    /// Extract the list of operations with their effective globs.
196    /// Per-operation globs take precedence over the global options glob.
197    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
211/// Validate a single operation's fields.
212fn validate_op(index: usize, op: &Op) -> Result<(), RipsedError> {
213    match op {
214        // Note: an empty replacement is valid (it deletes the matched text)
215        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
387/// Validate that a string compiles as a valid regex.
388fn 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
394/// Validate a glob pattern for common malformations.
395fn 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    // Check for unmatched brackets
401    let mut in_bracket = false;
402    let mut chars = pattern.chars().peekable();
403    while let Some(ch) = chars.next() {
404        match ch {
405            '\\' => {
406                // Skip escaped character
407                let _ = chars.next();
408            }
409            '[' if !in_bracket => {
410                in_bracket = true;
411            }
412            ']' if in_bracket => {
413                in_bracket = false;
414            }
415            '{' => {
416                // Check for unmatched braces
417                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    // ── Basic parsing ──
458
459    #[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); // default
467    }
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    // ── Every operation type ──
491
492    #[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    // ── Validation: empty find ──
584
585    #[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    // ── Validation: empty content ──
621
622    #[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    // ── Replace with empty replacement is valid (acts as deletion) ──
644
645    #[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    // ── Regex validation ──
659
660    #[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    // ── Glob validation ──
682
683    #[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    // ── Per-operation glob extraction ──
758
759    #[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        // First op has per-op glob, should override global
771        assert_eq!(ops[0].1.as_deref(), Some("*.rs"));
772        // Second op has no per-op glob, should inherit global
773        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    // ── Undo requests ──
787
788    #[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    // ── Forward compatibility: extra fields preserved ──
814
815    #[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    // ── Unknown operation type ──
842
843    #[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    // ── Unicode patterns ──
919
920    #[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    // ── Options parsing ──
968
969    #[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    // ── Multiline flag ──
1009
1010    #[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        // Explicit `"multiline": false` is a no-op everywhere — rejecting it
1026        // would break agents that emit defaults for every field.
1027        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    // ── Replacement count ──
1062
1063    #[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    // ── Case insensitive flag ──
1114
1115    #[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    // ── Batch operations ──
1132
1133    #[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    // ── Nested validation errors ──
1148
1149    #[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    // ── Design doc example: full agent workflow request ──
1175
1176    #[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}