#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct MigrationDirectives {
pub depends: Vec<String>,
pub env: Vec<String>,
pub require: Vec<String>,
pub ensure: Vec<String>,
pub safety_override: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
pub enum LintIgnoreScope {
NextStatement,
File,
}
impl std::fmt::Display for LintIgnoreScope {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
LintIgnoreScope::NextStatement => write!(f, "statement"),
LintIgnoreScope::File => write!(f, "file"),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LintIgnoreDirective {
pub scope: LintIgnoreScope,
pub rules: Vec<String>,
pub reason: Option<String>,
pub line: usize,
pub offset: usize,
}
pub fn parse_lint_ignores(sql: &str) -> Vec<LintIgnoreDirective> {
let mut out = Vec::new();
let mut offset = 0usize;
for (idx, line) in sql.split('\n').enumerate() {
let line_offset = offset;
offset += line.len() + 1;
let trimmed = line.trim();
let Some(body) = trimmed.strip_prefix("--") else {
continue;
};
let body = body.trim();
let (scope, rest) =
if let Some(rest) = strip_directive_prefix(body, "waypoint:lint-ignore-file") {
(LintIgnoreScope::File, rest)
} else if let Some(rest) = strip_directive_prefix(body, "waypoint:lint-ignore") {
(LintIgnoreScope::NextStatement, rest)
} else {
continue;
};
let (rules_part, reason) = split_reason(rest);
let rules = rules_part
.split([',', ' ', '\t'])
.map(|r| r.trim())
.filter(|r| !r.is_empty())
.map(|r| r.to_uppercase())
.collect();
out.push(LintIgnoreDirective {
scope,
rules,
reason,
line: idx + 1,
offset: line_offset,
});
}
out
}
fn split_reason(rest: &str) -> (&str, Option<String>) {
let lower = rest.to_lowercase();
let Some(pos) = lower.find("reason") else {
return (rest, None);
};
let after = rest[pos + "reason".len()..].trim_start();
let Some(value) = after.strip_prefix('=').or_else(|| after.strip_prefix(':')) else {
return (rest, None);
};
let value = value.trim();
let value = value
.strip_prefix('"')
.and_then(|v| v.strip_suffix('"'))
.or_else(|| value.strip_prefix('\'').and_then(|v| v.strip_suffix('\'')))
.unwrap_or(value)
.trim();
let reason = if value.is_empty() {
None
} else {
Some(value.to_string())
};
(&rest[..pos], reason)
}
fn strip_directive_prefix<'a>(line: &'a str, prefix: &str) -> Option<&'a str> {
if let Some(rest) = line.strip_prefix(prefix) {
if rest.is_empty() || rest.starts_with(char::is_whitespace) {
Some(rest.trim())
} else {
None
}
} else {
None
}
}
pub fn parse_directives(sql: &str) -> MigrationDirectives {
let mut directives = MigrationDirectives::default();
for line in sql.lines() {
let trimmed = line.trim();
if trimmed.is_empty() {
continue;
}
if !trimmed.starts_with("--") {
break;
}
let comment_body = trimmed.strip_prefix("--").unwrap().trim();
if let Some(value) = strip_directive_prefix(comment_body, "waypoint:depends") {
for item in value.split(',') {
let item = item.trim();
if !item.is_empty() {
let version = item.strip_prefix('V').unwrap_or(item);
directives.depends.push(version.to_string());
}
}
} else if let Some(value) = strip_directive_prefix(comment_body, "waypoint:env") {
for item in value.split(',') {
let item = item.trim();
if !item.is_empty() {
directives.env.push(item.to_string());
}
}
} else if let Some(value) = strip_directive_prefix(comment_body, "waypoint:require") {
if !value.is_empty() {
directives.require.push(value.to_string());
}
} else if let Some(value) = strip_directive_prefix(comment_body, "waypoint:ensure") {
if !value.is_empty() {
directives.ensure.push(value.to_string());
}
} else if comment_body.trim() == "waypoint:safety-override" {
directives.safety_override = true;
}
}
directives
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_env_directive() {
let sql = "-- waypoint:env dev,staging\nCREATE TABLE foo();";
let d = parse_directives(sql);
assert_eq!(d.env, vec!["dev", "staging"]);
assert!(d.depends.is_empty());
}
#[test]
fn test_parse_depends_directive() {
let sql = "-- waypoint:depends V3,V5\nCREATE TABLE foo();";
let d = parse_directives(sql);
assert_eq!(d.depends, vec!["3", "5"]);
assert!(d.env.is_empty());
}
#[test]
fn test_parse_depends_without_v_prefix() {
let sql = "-- waypoint:depends 3,5\nCREATE TABLE foo();";
let d = parse_directives(sql);
assert_eq!(d.depends, vec!["3", "5"]);
}
#[test]
fn test_parse_multiple_directives() {
let sql = "-- waypoint:env dev\n-- waypoint:depends V1,V2\nCREATE TABLE foo();";
let d = parse_directives(sql);
assert_eq!(d.env, vec!["dev"]);
assert_eq!(d.depends, vec!["1", "2"]);
}
#[test]
fn test_stops_at_non_comment_line() {
let sql = "-- waypoint:env dev\nCREATE TABLE foo();\n-- waypoint:env prod\n";
let d = parse_directives(sql);
assert_eq!(d.env, vec!["dev"]);
}
#[test]
fn test_empty_sql() {
let d = parse_directives("");
assert!(d.env.is_empty());
assert!(d.depends.is_empty());
}
#[test]
fn test_no_directives() {
let sql = "-- Regular comment\nCREATE TABLE foo();";
let d = parse_directives(sql);
assert!(d.env.is_empty());
assert!(d.depends.is_empty());
}
#[test]
fn test_skips_leading_blank_lines() {
let sql = "\n\n-- waypoint:env prod\nCREATE TABLE foo();";
let d = parse_directives(sql);
assert_eq!(d.env, vec!["prod"]);
}
#[test]
fn test_whitespace_in_values() {
let sql = "-- waypoint:env dev , staging , prod \nCREATE TABLE foo();";
let d = parse_directives(sql);
assert_eq!(d.env, vec!["dev", "staging", "prod"]);
}
#[test]
fn test_no_env_runs_everywhere() {
let d = MigrationDirectives::default();
assert!(d.env.is_empty());
}
#[test]
fn test_parse_require_directive() {
let sql = "-- waypoint:require table_exists(\"users\")\nCREATE TABLE foo();";
let d = parse_directives(sql);
assert_eq!(d.require, vec!["table_exists(\"users\")"]);
}
#[test]
fn test_parse_ensure_directive() {
let sql = "-- waypoint:ensure column_exists(\"users\", \"email\")\nALTER TABLE users ADD COLUMN email TEXT;";
let d = parse_directives(sql);
assert_eq!(d.ensure, vec!["column_exists(\"users\", \"email\")"]);
}
#[test]
fn test_parse_multiple_guards() {
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;";
let d = parse_directives(sql);
assert_eq!(d.require.len(), 2);
assert_eq!(d.ensure.len(), 1);
}
#[test]
fn test_parse_lint_ignore_next_statement() {
let sql = "-- waypoint:lint-ignore E001 reason=\"empty table at deploy time\"\nALTER TABLE t ADD COLUMN a int NOT NULL;";
let d = parse_lint_ignores(sql);
assert_eq!(d.len(), 1);
assert_eq!(d[0].scope, LintIgnoreScope::NextStatement);
assert_eq!(d[0].rules, vec!["E001"]);
assert_eq!(d[0].reason.as_deref(), Some("empty table at deploy time"));
assert_eq!(d[0].line, 1);
assert_eq!(d[0].offset, 0);
}
#[test]
fn test_parse_lint_ignore_file_scope_and_multiple_rules() {
let sql = "-- header\n-- waypoint:lint-ignore-file E001,W004 reason=legacy migration\nDROP TABLE t;";
let d = parse_lint_ignores(sql);
assert_eq!(d.len(), 1);
assert_eq!(d[0].scope, LintIgnoreScope::File);
assert_eq!(d[0].rules, vec!["E001", "W004"]);
assert_eq!(d[0].reason.as_deref(), Some("legacy migration"));
assert_eq!(d[0].line, 2);
}
#[test]
fn test_parse_lint_ignore_without_reason() {
let d = parse_lint_ignores("-- waypoint:lint-ignore E001\nSELECT 1;");
assert_eq!(d.len(), 1);
assert!(d[0].reason.is_none());
assert_eq!(d[0].rules, vec!["E001"]);
}
#[test]
fn test_parse_lint_ignore_without_rules() {
let d = parse_lint_ignores("-- waypoint:lint-ignore reason=because\nSELECT 1;");
assert_eq!(d.len(), 1);
assert!(d[0].rules.is_empty());
assert_eq!(d[0].reason.as_deref(), Some("because"));
}
#[test]
fn test_parse_lint_ignore_offsets_are_exact() {
let sql = "SELECT 1;\n-- waypoint:lint-ignore E001 reason=x\nSELECT 2;";
let d = parse_lint_ignores(sql);
assert_eq!(d[0].line, 2);
assert_eq!(&sql[d[0].offset..d[0].offset + 2], "--");
}
#[test]
fn test_trailing_comment_is_not_a_directive() {
let d = parse_lint_ignores("SELECT 1; -- waypoint:lint-ignore E001 reason=x\n");
assert!(d.is_empty());
}
#[test]
fn test_lint_ignore_prefix_does_not_collide() {
let d = parse_lint_ignores("-- waypoint:lint-ignore-file E001 reason=x\n");
assert_eq!(d[0].scope, LintIgnoreScope::File);
assert_eq!(d[0].rules, vec!["E001"]);
}
#[test]
fn test_parse_safety_override() {
let sql = "-- waypoint:safety-override\nALTER TABLE large_table ADD COLUMN foo TEXT;";
let d = parse_directives(sql);
assert!(d.safety_override);
}
#[test]
fn test_safety_override_default_false() {
let sql = "CREATE TABLE foo();";
let d = parse_directives(sql);
assert!(!d.safety_override);
}
#[test]
fn test_env_prefix_does_not_match_ensure() {
let sql = "-- waypoint:ensure column_exists(\"users\", \"email\")\nALTER TABLE users ADD COLUMN email TEXT;";
let d = parse_directives(sql);
assert!(d.env.is_empty());
assert_eq!(d.ensure.len(), 1);
}
#[test]
fn test_directive_prefix_boundary() {
let sql = "-- waypoint:environment prod\nCREATE TABLE foo();";
let d = parse_directives(sql);
assert!(d.env.is_empty());
}
#[test]
fn test_parse_empty_depends() {
let sql = "-- waypoint:depends\nCREATE TABLE foo();";
let d = parse_directives(sql);
assert!(d.depends.is_empty());
}
#[test]
fn test_parse_empty_env() {
let sql = "-- waypoint:env\nCREATE TABLE foo();";
let d = parse_directives(sql);
assert!(d.env.is_empty());
}
#[test]
fn test_parse_require_with_special_chars() {
let sql = "-- waypoint:require table_exists(\"my-table\")\nCREATE TABLE foo();";
let d = parse_directives(sql);
assert_eq!(d.require, vec!["table_exists(\"my-table\")"]);
}
}