1#[derive(Debug, Clone, Default, PartialEq, Eq)]
12pub struct MigrationDirectives {
13 pub depends: Vec<String>,
15 pub env: Vec<String>,
17 pub require: Vec<String>,
19 pub ensure: Vec<String>,
21 pub safety_override: bool,
23}
24
25#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
27pub enum LintIgnoreScope {
28 NextStatement,
30 File,
32}
33
34impl std::fmt::Display for LintIgnoreScope {
35 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
36 match self {
37 LintIgnoreScope::NextStatement => write!(f, "statement"),
38 LintIgnoreScope::File => write!(f, "file"),
39 }
40 }
41}
42
43#[derive(Debug, Clone, PartialEq, Eq)]
50pub struct LintIgnoreDirective {
51 pub scope: LintIgnoreScope,
53 pub rules: Vec<String>,
56 pub reason: Option<String>,
58 pub line: usize,
60 pub offset: usize,
62}
63
64pub fn parse_lint_ignores(sql: &str) -> Vec<LintIgnoreDirective> {
70 let mut out = Vec::new();
71 let mut offset = 0usize;
72
73 for (idx, line) in sql.split('\n').enumerate() {
75 let line_offset = offset;
76 offset += line.len() + 1;
77
78 let trimmed = line.trim();
79 let Some(body) = trimmed.strip_prefix("--") else {
80 continue;
81 };
82 let body = body.trim();
83
84 let (scope, rest) =
85 if let Some(rest) = strip_directive_prefix(body, "waypoint:lint-ignore-file") {
86 (LintIgnoreScope::File, rest)
87 } else if let Some(rest) = strip_directive_prefix(body, "waypoint:lint-ignore") {
88 (LintIgnoreScope::NextStatement, rest)
89 } else {
90 continue;
91 };
92
93 let (rules_part, reason) = split_reason(rest);
94 let rules = rules_part
95 .split([',', ' ', '\t'])
96 .map(|r| r.trim())
97 .filter(|r| !r.is_empty())
98 .map(|r| r.to_uppercase())
99 .collect();
100
101 out.push(LintIgnoreDirective {
102 scope,
103 rules,
104 reason,
105 line: idx + 1,
106 offset: line_offset,
107 });
108 }
109
110 out
111}
112
113fn split_reason(rest: &str) -> (&str, Option<String>) {
117 let lower = rest.to_lowercase();
118 let Some(pos) = lower.find("reason") else {
119 return (rest, None);
120 };
121 let after = rest[pos + "reason".len()..].trim_start();
123 let Some(value) = after.strip_prefix('=').or_else(|| after.strip_prefix(':')) else {
124 return (rest, None);
125 };
126 let value = value.trim();
127 let value = value
128 .strip_prefix('"')
129 .and_then(|v| v.strip_suffix('"'))
130 .or_else(|| value.strip_prefix('\'').and_then(|v| v.strip_suffix('\'')))
131 .unwrap_or(value)
132 .trim();
133
134 let reason = if value.is_empty() {
135 None
136 } else {
137 Some(value.to_string())
138 };
139 (&rest[..pos], reason)
140}
141
142fn strip_directive_prefix<'a>(line: &'a str, prefix: &str) -> Option<&'a str> {
145 if let Some(rest) = line.strip_prefix(prefix) {
146 if rest.is_empty() || rest.starts_with(char::is_whitespace) {
147 Some(rest.trim())
148 } else {
149 None
150 }
151 } else {
152 None
153 }
154}
155
156pub fn parse_directives(sql: &str) -> MigrationDirectives {
161 let mut directives = MigrationDirectives::default();
162
163 for line in sql.lines() {
164 let trimmed = line.trim();
165
166 if trimmed.is_empty() {
168 continue;
169 }
170
171 if !trimmed.starts_with("--") {
173 break;
174 }
175
176 let comment_body = trimmed.strip_prefix("--").unwrap().trim();
177
178 if let Some(value) = strip_directive_prefix(comment_body, "waypoint:depends") {
179 for item in value.split(',') {
180 let item = item.trim();
181 if !item.is_empty() {
182 let version = item.strip_prefix('V').unwrap_or(item);
184 directives.depends.push(version.to_string());
185 }
186 }
187 } else if let Some(value) = strip_directive_prefix(comment_body, "waypoint:env") {
188 for item in value.split(',') {
189 let item = item.trim();
190 if !item.is_empty() {
191 directives.env.push(item.to_string());
192 }
193 }
194 } else if let Some(value) = strip_directive_prefix(comment_body, "waypoint:require") {
195 if !value.is_empty() {
196 directives.require.push(value.to_string());
197 }
198 } else if let Some(value) = strip_directive_prefix(comment_body, "waypoint:ensure") {
199 if !value.is_empty() {
200 directives.ensure.push(value.to_string());
201 }
202 } else if comment_body.trim() == "waypoint:safety-override" {
203 directives.safety_override = true;
204 }
205 }
206
207 directives
208}
209
210#[cfg(test)]
211mod tests {
212 use super::*;
213
214 #[test]
215 fn test_parse_env_directive() {
216 let sql = "-- waypoint:env dev,staging\nCREATE TABLE foo();";
217 let d = parse_directives(sql);
218 assert_eq!(d.env, vec!["dev", "staging"]);
219 assert!(d.depends.is_empty());
220 }
221
222 #[test]
223 fn test_parse_depends_directive() {
224 let sql = "-- waypoint:depends V3,V5\nCREATE TABLE foo();";
225 let d = parse_directives(sql);
226 assert_eq!(d.depends, vec!["3", "5"]);
227 assert!(d.env.is_empty());
228 }
229
230 #[test]
231 fn test_parse_depends_without_v_prefix() {
232 let sql = "-- waypoint:depends 3,5\nCREATE TABLE foo();";
233 let d = parse_directives(sql);
234 assert_eq!(d.depends, vec!["3", "5"]);
235 }
236
237 #[test]
238 fn test_parse_multiple_directives() {
239 let sql = "-- waypoint:env dev\n-- waypoint:depends V1,V2\nCREATE TABLE foo();";
240 let d = parse_directives(sql);
241 assert_eq!(d.env, vec!["dev"]);
242 assert_eq!(d.depends, vec!["1", "2"]);
243 }
244
245 #[test]
246 fn test_stops_at_non_comment_line() {
247 let sql = "-- waypoint:env dev\nCREATE TABLE foo();\n-- waypoint:env prod\n";
248 let d = parse_directives(sql);
249 assert_eq!(d.env, vec!["dev"]);
250 }
251
252 #[test]
253 fn test_empty_sql() {
254 let d = parse_directives("");
255 assert!(d.env.is_empty());
256 assert!(d.depends.is_empty());
257 }
258
259 #[test]
260 fn test_no_directives() {
261 let sql = "-- Regular comment\nCREATE TABLE foo();";
262 let d = parse_directives(sql);
263 assert!(d.env.is_empty());
264 assert!(d.depends.is_empty());
265 }
266
267 #[test]
268 fn test_skips_leading_blank_lines() {
269 let sql = "\n\n-- waypoint:env prod\nCREATE TABLE foo();";
270 let d = parse_directives(sql);
271 assert_eq!(d.env, vec!["prod"]);
272 }
273
274 #[test]
275 fn test_whitespace_in_values() {
276 let sql = "-- waypoint:env dev , staging , prod \nCREATE TABLE foo();";
277 let d = parse_directives(sql);
278 assert_eq!(d.env, vec!["dev", "staging", "prod"]);
279 }
280
281 #[test]
282 fn test_no_env_runs_everywhere() {
283 let d = MigrationDirectives::default();
284 assert!(d.env.is_empty());
285 }
286
287 #[test]
288 fn test_parse_require_directive() {
289 let sql = "-- waypoint:require table_exists(\"users\")\nCREATE TABLE foo();";
290 let d = parse_directives(sql);
291 assert_eq!(d.require, vec!["table_exists(\"users\")"]);
292 }
293
294 #[test]
295 fn test_parse_ensure_directive() {
296 let sql = "-- waypoint:ensure column_exists(\"users\", \"email\")\nALTER TABLE users ADD COLUMN email TEXT;";
297 let d = parse_directives(sql);
298 assert_eq!(d.ensure, vec!["column_exists(\"users\", \"email\")"]);
299 }
300
301 #[test]
302 fn test_parse_multiple_guards() {
303 let sql = "-- waypoint:require table_exists(\"users\")\n-- waypoint:require NOT column_exists(\"users\", \"email\")\n-- waypoint:ensure column_exists(\"users\", \"email\")\nALTER TABLE users ADD COLUMN email TEXT;";
304 let d = parse_directives(sql);
305 assert_eq!(d.require.len(), 2);
306 assert_eq!(d.ensure.len(), 1);
307 }
308
309 #[test]
310 fn test_parse_lint_ignore_next_statement() {
311 let sql = "-- waypoint:lint-ignore E001 reason=\"empty table at deploy time\"\nALTER TABLE t ADD COLUMN a int NOT NULL;";
312 let d = parse_lint_ignores(sql);
313 assert_eq!(d.len(), 1);
314 assert_eq!(d[0].scope, LintIgnoreScope::NextStatement);
315 assert_eq!(d[0].rules, vec!["E001"]);
316 assert_eq!(d[0].reason.as_deref(), Some("empty table at deploy time"));
317 assert_eq!(d[0].line, 1);
318 assert_eq!(d[0].offset, 0);
319 }
320
321 #[test]
322 fn test_parse_lint_ignore_file_scope_and_multiple_rules() {
323 let sql = "-- header\n-- waypoint:lint-ignore-file E001,W004 reason=legacy migration\nDROP TABLE t;";
324 let d = parse_lint_ignores(sql);
325 assert_eq!(d.len(), 1);
326 assert_eq!(d[0].scope, LintIgnoreScope::File);
327 assert_eq!(d[0].rules, vec!["E001", "W004"]);
328 assert_eq!(d[0].reason.as_deref(), Some("legacy migration"));
329 assert_eq!(d[0].line, 2);
330 }
331
332 #[test]
333 fn test_parse_lint_ignore_without_reason() {
334 let d = parse_lint_ignores("-- waypoint:lint-ignore E001\nSELECT 1;");
335 assert_eq!(d.len(), 1);
336 assert!(d[0].reason.is_none());
337 assert_eq!(d[0].rules, vec!["E001"]);
338 }
339
340 #[test]
341 fn test_parse_lint_ignore_without_rules() {
342 let d = parse_lint_ignores("-- waypoint:lint-ignore reason=because\nSELECT 1;");
343 assert_eq!(d.len(), 1);
344 assert!(d[0].rules.is_empty());
345 assert_eq!(d[0].reason.as_deref(), Some("because"));
346 }
347
348 #[test]
349 fn test_parse_lint_ignore_offsets_are_exact() {
350 let sql = "SELECT 1;\n-- waypoint:lint-ignore E001 reason=x\nSELECT 2;";
351 let d = parse_lint_ignores(sql);
352 assert_eq!(d[0].line, 2);
353 assert_eq!(&sql[d[0].offset..d[0].offset + 2], "--");
354 }
355
356 #[test]
357 fn test_trailing_comment_is_not_a_directive() {
358 let d = parse_lint_ignores("SELECT 1; -- waypoint:lint-ignore E001 reason=x\n");
360 assert!(d.is_empty());
361 }
362
363 #[test]
364 fn test_lint_ignore_prefix_does_not_collide() {
365 let d = parse_lint_ignores("-- waypoint:lint-ignore-file E001 reason=x\n");
366 assert_eq!(d[0].scope, LintIgnoreScope::File);
367 assert_eq!(d[0].rules, vec!["E001"]);
368 }
369
370 #[test]
371 fn test_parse_safety_override() {
372 let sql = "-- waypoint:safety-override\nALTER TABLE large_table ADD COLUMN foo TEXT;";
373 let d = parse_directives(sql);
374 assert!(d.safety_override);
375 }
376
377 #[test]
378 fn test_safety_override_default_false() {
379 let sql = "CREATE TABLE foo();";
380 let d = parse_directives(sql);
381 assert!(!d.safety_override);
382 }
383
384 #[test]
385 fn test_env_prefix_does_not_match_ensure() {
386 let sql = "-- waypoint:ensure column_exists(\"users\", \"email\")\nALTER TABLE users ADD COLUMN email TEXT;";
387 let d = parse_directives(sql);
388 assert!(d.env.is_empty());
390 assert_eq!(d.ensure.len(), 1);
391 }
392
393 #[test]
394 fn test_directive_prefix_boundary() {
395 let sql = "-- waypoint:environment prod\nCREATE TABLE foo();";
397 let d = parse_directives(sql);
398 assert!(d.env.is_empty());
400 }
401
402 #[test]
403 fn test_parse_empty_depends() {
404 let sql = "-- waypoint:depends\nCREATE TABLE foo();";
405 let d = parse_directives(sql);
406 assert!(d.depends.is_empty());
407 }
408
409 #[test]
410 fn test_parse_empty_env() {
411 let sql = "-- waypoint:env\nCREATE TABLE foo();";
412 let d = parse_directives(sql);
413 assert!(d.env.is_empty());
414 }
415
416 #[test]
417 fn test_parse_require_with_special_chars() {
418 let sql = "-- waypoint:require table_exists(\"my-table\")\nCREATE TABLE foo();";
419 let d = parse_directives(sql);
420 assert_eq!(d.require, vec!["table_exists(\"my-table\")"]);
421 }
422}