Skip to main content

waypoint_core/
directive.rs

1//! Parse `-- waypoint:*` comment directives from SQL file headers.
2//!
3//! Directives appear as SQL comments at the top of migration files:
4//! ```sql
5//! -- waypoint:env dev,staging
6//! -- waypoint:depends V3,V5
7//! CREATE TABLE ...
8//! ```
9
10/// Parsed directives from a migration file header.
11#[derive(Debug, Clone, Default, PartialEq, Eq)]
12pub struct MigrationDirectives {
13    /// Dependencies: `-- waypoint:depends V3,V5` (V prefix is stripped)
14    pub depends: Vec<String>,
15    /// Environment tags: `-- waypoint:env dev,staging`
16    pub env: Vec<String>,
17    /// Preconditions: `-- waypoint:require table_exists("users")`
18    pub require: Vec<String>,
19    /// Postconditions: `-- waypoint:ensure column_exists("users", "email")`
20    pub ensure: Vec<String>,
21    /// Safety override: `-- waypoint:safety-override` bypasses DANGER blocks
22    pub safety_override: bool,
23}
24
25/// Scope of an inline lint suppression.
26#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
27pub enum LintIgnoreScope {
28    /// `-- waypoint:lint-ignore` — applies to the next statement only.
29    NextStatement,
30    /// `-- waypoint:lint-ignore-file` — applies to the whole file.
31    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/// An inline `-- waypoint:lint-ignore[-file]` directive.
44///
45/// ```sql
46/// -- waypoint:lint-ignore E001 reason="backfilled by the ceremony writer"
47/// ALTER TABLE t ADD COLUMN c int NOT NULL;
48/// ```
49#[derive(Debug, Clone, PartialEq, Eq)]
50pub struct LintIgnoreDirective {
51    /// Whether this suppresses the next statement or the whole file.
52    pub scope: LintIgnoreScope,
53    /// Rule IDs named by the directive, uppercased. Never empty for a valid
54    /// directive; an empty list means the directive named no rules.
55    pub rules: Vec<String>,
56    /// The mandatory `reason=...` value, if one was supplied.
57    pub reason: Option<String>,
58    /// 1-based line number of the directive.
59    pub line: usize,
60    /// Byte offset of the start of the directive's line.
61    pub offset: usize,
62}
63
64/// Parse every `-- waypoint:lint-ignore[-file]` directive in a migration file.
65///
66/// Unlike the header directives, these may appear anywhere in the file, but
67/// only on lines that contain nothing but the comment — a trailing comment on
68/// a line of SQL is ignored, because its scope would be ambiguous.
69pub fn parse_lint_ignores(sql: &str) -> Vec<LintIgnoreDirective> {
70    let mut out = Vec::new();
71    let mut offset = 0usize;
72
73    // split('\n') rather than lines() so byte offsets stay exact on CRLF input.
74    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
113/// Split a directive tail into its rule list and its `reason=` value.
114///
115/// The reason runs to the end of the line and may be quoted with `"` or `'`.
116fn 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    // Require `reason` to be a standalone word followed by `=` or `:`.
122    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
142/// Strip a directive prefix, ensuring the prefix is followed by whitespace or end of string.
143/// This prevents prefix collisions like "waypoint:env" matching "waypoint:environment".
144fn 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
156/// Parse `-- waypoint:*` directives from SQL content.
157///
158/// Only parses comment lines (`--`) at the top of the file.
159/// Stops at the first non-empty, non-comment line.
160pub 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        // Skip empty lines at the top
167        if trimmed.is_empty() {
168            continue;
169        }
170
171        // Only process SQL comment lines
172        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                    // Strip optional V prefix
183                    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        // Scope would be ambiguous, so only comment-only lines count.
359        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        // Should be parsed as ensure, not env
389        assert!(d.env.is_empty());
390        assert_eq!(d.ensure.len(), 1);
391    }
392
393    #[test]
394    fn test_directive_prefix_boundary() {
395        // "waypoint:environment" should NOT match "waypoint:env"
396        let sql = "-- waypoint:environment prod\nCREATE TABLE foo();";
397        let d = parse_directives(sql);
398        // Should NOT be parsed as env directive since "waypoint:environment" != "waypoint:env"
399        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}