api_testing_core/graphql/
mutation.rs1use 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 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}