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 } else if let Some(unknown) = unrecognised_directive(comment_body) {
205 log::warn!(
210 "Unrecognised directive '-- waypoint:{}' — this line is being treated as an \
211 ordinary comment and has no effect. Known directives: depends, env, require, \
212 ensure, safety-override, lint-ignore, lint-ignore-file.",
213 unknown
214 );
215 }
216 }
217
218 directives
219}
220
221fn unrecognised_directive(comment_body: &str) -> Option<&str> {
227 let name = comment_body.strip_prefix("waypoint:")?;
228 let head = name
229 .split_whitespace()
230 .next()
231 .unwrap_or(name)
232 .trim_end_matches(':');
233 if head.is_empty() || matches!(head, "lint-ignore" | "lint-ignore-file") {
234 return None;
235 }
236 Some(head)
237}
238
239#[cfg(test)]
240mod tests {
241 use super::*;
242
243 #[test]
244 fn test_parse_env_directive() {
245 let sql = "-- waypoint:env dev,staging\nCREATE TABLE foo();";
246 let d = parse_directives(sql);
247 assert_eq!(d.env, vec!["dev", "staging"]);
248 assert!(d.depends.is_empty());
249 }
250
251 #[test]
252 fn test_parse_depends_directive() {
253 let sql = "-- waypoint:depends V3,V5\nCREATE TABLE foo();";
254 let d = parse_directives(sql);
255 assert_eq!(d.depends, vec!["3", "5"]);
256 assert!(d.env.is_empty());
257 }
258
259 #[test]
260 fn test_parse_depends_without_v_prefix() {
261 let sql = "-- waypoint:depends 3,5\nCREATE TABLE foo();";
262 let d = parse_directives(sql);
263 assert_eq!(d.depends, vec!["3", "5"]);
264 }
265
266 #[test]
267 fn test_parse_multiple_directives() {
268 let sql = "-- waypoint:env dev\n-- waypoint:depends V1,V2\nCREATE TABLE foo();";
269 let d = parse_directives(sql);
270 assert_eq!(d.env, vec!["dev"]);
271 assert_eq!(d.depends, vec!["1", "2"]);
272 }
273
274 #[test]
275 fn test_stops_at_non_comment_line() {
276 let sql = "-- waypoint:env dev\nCREATE TABLE foo();\n-- waypoint:env prod\n";
277 let d = parse_directives(sql);
278 assert_eq!(d.env, vec!["dev"]);
279 }
280
281 #[test]
282 fn test_empty_sql() {
283 let d = parse_directives("");
284 assert!(d.env.is_empty());
285 assert!(d.depends.is_empty());
286 }
287
288 #[test]
289 fn test_no_directives() {
290 let sql = "-- Regular comment\nCREATE TABLE foo();";
291 let d = parse_directives(sql);
292 assert!(d.env.is_empty());
293 assert!(d.depends.is_empty());
294 }
295
296 #[test]
297 fn test_skips_leading_blank_lines() {
298 let sql = "\n\n-- waypoint:env prod\nCREATE TABLE foo();";
299 let d = parse_directives(sql);
300 assert_eq!(d.env, vec!["prod"]);
301 }
302
303 #[test]
304 fn test_whitespace_in_values() {
305 let sql = "-- waypoint:env dev , staging , prod \nCREATE TABLE foo();";
306 let d = parse_directives(sql);
307 assert_eq!(d.env, vec!["dev", "staging", "prod"]);
308 }
309
310 #[test]
311 fn test_no_env_runs_everywhere() {
312 let d = MigrationDirectives::default();
313 assert!(d.env.is_empty());
314 }
315
316 #[test]
317 fn test_parse_require_directive() {
318 let sql = "-- waypoint:require table_exists(\"users\")\nCREATE TABLE foo();";
319 let d = parse_directives(sql);
320 assert_eq!(d.require, vec!["table_exists(\"users\")"]);
321 }
322
323 #[test]
324 fn test_parse_ensure_directive() {
325 let sql = "-- waypoint:ensure column_exists(\"users\", \"email\")\nALTER TABLE users ADD COLUMN email TEXT;";
326 let d = parse_directives(sql);
327 assert_eq!(d.ensure, vec!["column_exists(\"users\", \"email\")"]);
328 }
329
330 #[test]
331 fn test_parse_multiple_guards() {
332 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;";
333 let d = parse_directives(sql);
334 assert_eq!(d.require.len(), 2);
335 assert_eq!(d.ensure.len(), 1);
336 }
337
338 #[test]
339 fn test_parse_lint_ignore_next_statement() {
340 let sql = "-- waypoint:lint-ignore E001 reason=\"empty table at deploy time\"\nALTER TABLE t ADD COLUMN a int NOT NULL;";
341 let d = parse_lint_ignores(sql);
342 assert_eq!(d.len(), 1);
343 assert_eq!(d[0].scope, LintIgnoreScope::NextStatement);
344 assert_eq!(d[0].rules, vec!["E001"]);
345 assert_eq!(d[0].reason.as_deref(), Some("empty table at deploy time"));
346 assert_eq!(d[0].line, 1);
347 assert_eq!(d[0].offset, 0);
348 }
349
350 #[test]
351 fn test_parse_lint_ignore_file_scope_and_multiple_rules() {
352 let sql = "-- header\n-- waypoint:lint-ignore-file E001,W004 reason=legacy migration\nDROP TABLE t;";
353 let d = parse_lint_ignores(sql);
354 assert_eq!(d.len(), 1);
355 assert_eq!(d[0].scope, LintIgnoreScope::File);
356 assert_eq!(d[0].rules, vec!["E001", "W004"]);
357 assert_eq!(d[0].reason.as_deref(), Some("legacy migration"));
358 assert_eq!(d[0].line, 2);
359 }
360
361 #[test]
362 fn test_parse_lint_ignore_without_reason() {
363 let d = parse_lint_ignores("-- waypoint:lint-ignore E001\nSELECT 1;");
364 assert_eq!(d.len(), 1);
365 assert!(d[0].reason.is_none());
366 assert_eq!(d[0].rules, vec!["E001"]);
367 }
368
369 #[test]
370 fn test_parse_lint_ignore_without_rules() {
371 let d = parse_lint_ignores("-- waypoint:lint-ignore reason=because\nSELECT 1;");
372 assert_eq!(d.len(), 1);
373 assert!(d[0].rules.is_empty());
374 assert_eq!(d[0].reason.as_deref(), Some("because"));
375 }
376
377 #[test]
378 fn test_parse_lint_ignore_offsets_are_exact() {
379 let sql = "SELECT 1;\n-- waypoint:lint-ignore E001 reason=x\nSELECT 2;";
380 let d = parse_lint_ignores(sql);
381 assert_eq!(d[0].line, 2);
382 assert_eq!(&sql[d[0].offset..d[0].offset + 2], "--");
383 }
384
385 #[test]
386 fn test_trailing_comment_is_not_a_directive() {
387 let d = parse_lint_ignores("SELECT 1; -- waypoint:lint-ignore E001 reason=x\n");
389 assert!(d.is_empty());
390 }
391
392 #[test]
393 fn test_lint_ignore_prefix_does_not_collide() {
394 let d = parse_lint_ignores("-- waypoint:lint-ignore-file E001 reason=x\n");
395 assert_eq!(d[0].scope, LintIgnoreScope::File);
396 assert_eq!(d[0].rules, vec!["E001"]);
397 }
398
399 #[test]
400 fn test_parse_safety_override() {
401 let sql = "-- waypoint:safety-override\nALTER TABLE large_table ADD COLUMN foo TEXT;";
402 let d = parse_directives(sql);
403 assert!(d.safety_override);
404 }
405
406 #[test]
407 fn test_safety_override_default_false() {
408 let sql = "CREATE TABLE foo();";
409 let d = parse_directives(sql);
410 assert!(!d.safety_override);
411 }
412
413 #[test]
414 fn test_env_prefix_does_not_match_ensure() {
415 let sql = "-- waypoint:ensure column_exists(\"users\", \"email\")\nALTER TABLE users ADD COLUMN email TEXT;";
416 let d = parse_directives(sql);
417 assert!(d.env.is_empty());
419 assert_eq!(d.ensure.len(), 1);
420 }
421
422 #[test]
423 fn test_directive_prefix_boundary() {
424 let sql = "-- waypoint:environment prod\nCREATE TABLE foo();";
426 let d = parse_directives(sql);
427 assert!(d.env.is_empty());
429 }
430
431 #[test]
432 fn test_parse_empty_depends() {
433 let sql = "-- waypoint:depends\nCREATE TABLE foo();";
434 let d = parse_directives(sql);
435 assert!(d.depends.is_empty());
436 }
437
438 #[test]
439 fn test_parse_empty_env() {
440 let sql = "-- waypoint:env\nCREATE TABLE foo();";
441 let d = parse_directives(sql);
442 assert!(d.env.is_empty());
443 }
444
445 #[test]
446 fn test_parse_require_with_special_chars() {
447 let sql = "-- waypoint:require table_exists(\"my-table\")\nCREATE TABLE foo();";
448 let d = parse_directives(sql);
449 assert_eq!(d.require, vec!["table_exists(\"my-table\")"]);
450 }
451
452 #[test]
453 fn test_unrecognised_directive_detects_typos_but_not_ordinary_comments() {
454 assert_eq!(
457 unrecognised_directive("waypoint:requires foo()"),
458 Some("requires")
459 );
460 assert_eq!(
461 unrecognised_directive("waypoint:saftey-override"),
462 Some("saftey-override")
463 );
464 assert_eq!(
465 unrecognised_directive("waypoint:ensures x"),
466 Some("ensures")
467 );
468
469 assert_eq!(
471 unrecognised_directive("waypoint:lint-ignore E001 reason=\"x\""),
472 None
473 );
474 assert_eq!(
475 unrecognised_directive("waypoint:lint-ignore-file E001 reason=\"x\""),
476 None
477 );
478 assert_eq!(unrecognised_directive("just a normal comment"), None);
479 assert_eq!(unrecognised_directive("waypoint is a tool"), None);
480 }
481
482 #[test]
483 fn test_known_directives_still_parse_and_are_not_warned_about() {
484 let sql = "-- waypoint:require table_exists(\"a\")\n\
485 -- waypoint:ensure table_exists(\"b\")\n\
486 -- waypoint:env prod\n\
487 -- waypoint:depends V1\n\
488 -- waypoint:safety-override\n\
489 SELECT 1;";
490 let d = parse_directives(sql);
491 assert_eq!(d.require.len(), 1);
492 assert_eq!(d.ensure.len(), 1);
493 assert_eq!(d.env, vec!["prod"]);
494 assert_eq!(d.depends, vec!["1"]);
495 assert!(d.safety_override);
496 for body in [
498 "waypoint:require x",
499 "waypoint:ensure x",
500 "waypoint:env prod",
501 "waypoint:depends V1",
502 "waypoint:safety-override",
503 ] {
504 let head = unrecognised_directive(body);
505 assert!(
506 matches!(
507 head,
508 Some("require" | "ensure" | "env" | "depends" | "safety-override")
509 ),
510 "known directive {body:?} classified as {head:?}"
511 );
512 }
513 }
514}