Skip to main content

api_testing_core/graphql/
mutation.rs

1use std::path::Path;
2
3use anyhow::Context;
4
5use crate::Result;
6
7fn strip_block_comments(input: &str) -> String {
8    let bytes = input.as_bytes();
9    let mut out: Vec<u8> = Vec::with_capacity(bytes.len());
10    let mut i = 0usize;
11    let mut in_comment = false;
12
13    while i < bytes.len() {
14        if !in_comment {
15            if bytes[i] == b'/' && i + 1 < bytes.len() && bytes[i + 1] == b'*' {
16                in_comment = true;
17                out.push(b' ');
18                out.push(b' ');
19                i += 2;
20                continue;
21            }
22            out.push(bytes[i]);
23            i += 1;
24            continue;
25        }
26
27        if bytes[i] == b'*' && i + 1 < bytes.len() && bytes[i + 1] == b'/' {
28            in_comment = false;
29            out.push(b' ');
30            out.push(b' ');
31            i += 2;
32            continue;
33        }
34
35        let b = bytes[i];
36        if b == b'\n' || b == b'\r' {
37            out.push(b);
38        } else {
39            out.push(b' ');
40        }
41        i += 1;
42    }
43
44    String::from_utf8(out).unwrap_or_default()
45}
46
47fn strip_strings(input: &str) -> String {
48    let bytes = input.as_bytes();
49    let mut out: Vec<u8> = Vec::with_capacity(bytes.len());
50    let mut i = 0usize;
51
52    #[derive(Debug, Clone, Copy, PartialEq, Eq)]
53    enum State {
54        Normal,
55        Triple,
56        Double { escape: bool },
57    }
58
59    let mut state = State::Normal;
60
61    while i < bytes.len() {
62        match state {
63            State::Normal => {
64                if i + 2 < bytes.len()
65                    && bytes[i] == b'"'
66                    && bytes[i + 1] == b'"'
67                    && bytes[i + 2] == b'"'
68                {
69                    state = State::Triple;
70                    out.extend_from_slice(b"   ");
71                    i += 3;
72                    continue;
73                }
74                if bytes[i] == b'"' {
75                    state = State::Double { escape: false };
76                    out.push(b' ');
77                    i += 1;
78                    continue;
79                }
80                out.push(bytes[i]);
81                i += 1;
82            }
83            State::Triple => {
84                if i + 2 < bytes.len()
85                    && bytes[i] == b'"'
86                    && bytes[i + 1] == b'"'
87                    && bytes[i + 2] == b'"'
88                {
89                    state = State::Normal;
90                    out.extend_from_slice(b"   ");
91                    i += 3;
92                    continue;
93                }
94                let b = bytes[i];
95                if b == b'\n' || b == b'\r' {
96                    out.push(b);
97                } else {
98                    out.push(b' ');
99                }
100                i += 1;
101            }
102            State::Double { escape } => {
103                let b = bytes[i];
104                if escape {
105                    if b == b'\n' || b == b'\r' {
106                        out.push(b);
107                    } else {
108                        out.push(b' ');
109                    }
110                    state = State::Double { escape: false };
111                    i += 1;
112                    continue;
113                }
114
115                if b == b'\\' {
116                    out.push(b' ');
117                    state = State::Double { escape: true };
118                    i += 1;
119                    continue;
120                }
121
122                if b == b'"' {
123                    out.push(b' ');
124                    state = State::Normal;
125                    i += 1;
126                    continue;
127                }
128
129                if b == b'\n' || b == b'\r' {
130                    out.push(b);
131                } else {
132                    out.push(b' ');
133                }
134                i += 1;
135            }
136        }
137    }
138
139    String::from_utf8(out).unwrap_or_default()
140}
141
142fn strip_line_comments(input: &str) -> String {
143    let bytes = input.as_bytes();
144    let mut out: Vec<u8> = Vec::with_capacity(bytes.len());
145    let mut i = 0usize;
146    let mut in_comment = false;
147
148    while i < bytes.len() {
149        if !in_comment {
150            if bytes[i] == b'#' {
151                in_comment = true;
152                out.push(b' ');
153                i += 1;
154                continue;
155            }
156            if bytes[i] == b'/' && i + 1 < bytes.len() && bytes[i + 1] == b'/' {
157                in_comment = true;
158                out.push(b' ');
159                out.push(b' ');
160                i += 2;
161                continue;
162            }
163            out.push(bytes[i]);
164            i += 1;
165            continue;
166        }
167
168        let b = bytes[i];
169        if b == b'\n' || b == b'\r' {
170            in_comment = false;
171            out.push(b);
172        } else {
173            out.push(b' ');
174        }
175        i += 1;
176    }
177
178    String::from_utf8(out).unwrap_or_default()
179}
180
181pub fn operation_text_is_mutation(text: &str) -> bool {
182    // Keep newlines, replace stripped ranges with spaces to prevent creating accidental tokens
183    // (parity with legacy `re.sub(..., " ", ...)` behavior).
184    let cleaned = strip_line_comments(&strip_strings(&strip_block_comments(text)));
185
186    for raw_line in cleaned.lines() {
187        let line = raw_line.trim_start();
188        if !line
189            .get(..8)
190            .is_some_and(|p| p.eq_ignore_ascii_case("mutation"))
191        {
192            continue;
193        }
194
195        let after = &line[8..];
196        let boundary_ok = after
197            .chars()
198            .next()
199            .is_none_or(|c| !(c.is_ascii_alphanumeric() || c == '_'));
200        if !boundary_ok {
201            continue;
202        }
203
204        let after_ws = after.trim_start();
205        let Some(next) = after_ws.chars().next() else {
206            continue;
207        };
208
209        let ok =
210            next == '(' || next == '@' || next == '{' || next == '_' || next.is_ascii_alphabetic();
211        if ok {
212            return true;
213        }
214    }
215
216    false
217}
218
219pub fn operation_file_is_mutation(path: &Path) -> Result<bool> {
220    let text = std::fs::read_to_string(path)
221        .with_context(|| format!("read GraphQL operation file: {}", path.display()))?;
222    Ok(operation_text_is_mutation(&text))
223}
224
225#[cfg(test)]
226mod tests {
227    use super::*;
228
229    #[test]
230    fn mutation_detection_matches_expected_intent() {
231        assert!(operation_text_is_mutation(
232            r#"
233query Q { ok }
234mutation CreateUser($x: Int) { createUser(x: $x) { id } }
235"#
236        ));
237
238        assert!(!operation_text_is_mutation("mutation: Mutation"));
239        assert!(!operation_text_is_mutation(r#"# mutation { no }"#));
240        assert!(!operation_text_is_mutation(r#""mutation { no }""#));
241        assert!(!operation_text_is_mutation(
242            r#"
243query Example {
244  foo(text: """
245mutation { no }
246""")
247}
248"#
249        ));
250        assert!(!operation_text_is_mutation(r#"// mutation { no }"#));
251    }
252
253    #[test]
254    fn does_not_create_false_positive_when_comment_splits_keyword() {
255        assert!(!operation_text_is_mutation("mu/*x*/tation { no }"));
256    }
257}