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/// Strip a directive prefix, ensuring the prefix is followed by whitespace or end of string.
26/// This prevents prefix collisions like "waypoint:env" matching "waypoint:environment".
27fn strip_directive_prefix<'a>(line: &'a str, prefix: &str) -> Option<&'a str> {
28    if let Some(rest) = line.strip_prefix(prefix) {
29        if rest.is_empty() || rest.starts_with(char::is_whitespace) {
30            Some(rest.trim())
31        } else {
32            None
33        }
34    } else {
35        None
36    }
37}
38
39/// Parse `-- waypoint:*` directives from SQL content.
40///
41/// Only parses comment lines (`--`) at the top of the file.
42/// Stops at the first non-empty, non-comment line.
43pub fn parse_directives(sql: &str) -> MigrationDirectives {
44    let mut directives = MigrationDirectives::default();
45
46    for line in sql.lines() {
47        let trimmed = line.trim();
48
49        // Skip empty lines at the top
50        if trimmed.is_empty() {
51            continue;
52        }
53
54        // Only process SQL comment lines
55        if !trimmed.starts_with("--") {
56            break;
57        }
58
59        let comment_body = trimmed.strip_prefix("--").unwrap().trim();
60
61        if let Some(value) = strip_directive_prefix(comment_body, "waypoint:depends") {
62            for item in value.split(',') {
63                let item = item.trim();
64                if !item.is_empty() {
65                    // Strip optional V prefix
66                    let version = item.strip_prefix('V').unwrap_or(item);
67                    directives.depends.push(version.to_string());
68                }
69            }
70        } else if let Some(value) = strip_directive_prefix(comment_body, "waypoint:env") {
71            for item in value.split(',') {
72                let item = item.trim();
73                if !item.is_empty() {
74                    directives.env.push(item.to_string());
75                }
76            }
77        } else if let Some(value) = strip_directive_prefix(comment_body, "waypoint:require") {
78            if !value.is_empty() {
79                directives.require.push(value.to_string());
80            }
81        } else if let Some(value) = strip_directive_prefix(comment_body, "waypoint:ensure") {
82            if !value.is_empty() {
83                directives.ensure.push(value.to_string());
84            }
85        } else if comment_body.trim() == "waypoint:safety-override" {
86            directives.safety_override = true;
87        }
88    }
89
90    directives
91}
92
93#[cfg(test)]
94mod tests {
95    use super::*;
96
97    #[test]
98    fn test_parse_env_directive() {
99        let sql = "-- waypoint:env dev,staging\nCREATE TABLE foo();";
100        let d = parse_directives(sql);
101        assert_eq!(d.env, vec!["dev", "staging"]);
102        assert!(d.depends.is_empty());
103    }
104
105    #[test]
106    fn test_parse_depends_directive() {
107        let sql = "-- waypoint:depends V3,V5\nCREATE TABLE foo();";
108        let d = parse_directives(sql);
109        assert_eq!(d.depends, vec!["3", "5"]);
110        assert!(d.env.is_empty());
111    }
112
113    #[test]
114    fn test_parse_depends_without_v_prefix() {
115        let sql = "-- waypoint:depends 3,5\nCREATE TABLE foo();";
116        let d = parse_directives(sql);
117        assert_eq!(d.depends, vec!["3", "5"]);
118    }
119
120    #[test]
121    fn test_parse_multiple_directives() {
122        let sql = "-- waypoint:env dev\n-- waypoint:depends V1,V2\nCREATE TABLE foo();";
123        let d = parse_directives(sql);
124        assert_eq!(d.env, vec!["dev"]);
125        assert_eq!(d.depends, vec!["1", "2"]);
126    }
127
128    #[test]
129    fn test_stops_at_non_comment_line() {
130        let sql = "-- waypoint:env dev\nCREATE TABLE foo();\n-- waypoint:env prod\n";
131        let d = parse_directives(sql);
132        assert_eq!(d.env, vec!["dev"]);
133    }
134
135    #[test]
136    fn test_empty_sql() {
137        let d = parse_directives("");
138        assert!(d.env.is_empty());
139        assert!(d.depends.is_empty());
140    }
141
142    #[test]
143    fn test_no_directives() {
144        let sql = "-- Regular comment\nCREATE TABLE foo();";
145        let d = parse_directives(sql);
146        assert!(d.env.is_empty());
147        assert!(d.depends.is_empty());
148    }
149
150    #[test]
151    fn test_skips_leading_blank_lines() {
152        let sql = "\n\n-- waypoint:env prod\nCREATE TABLE foo();";
153        let d = parse_directives(sql);
154        assert_eq!(d.env, vec!["prod"]);
155    }
156
157    #[test]
158    fn test_whitespace_in_values() {
159        let sql = "-- waypoint:env  dev , staging , prod \nCREATE TABLE foo();";
160        let d = parse_directives(sql);
161        assert_eq!(d.env, vec!["dev", "staging", "prod"]);
162    }
163
164    #[test]
165    fn test_no_env_runs_everywhere() {
166        let d = MigrationDirectives::default();
167        assert!(d.env.is_empty());
168    }
169
170    #[test]
171    fn test_parse_require_directive() {
172        let sql = "-- waypoint:require table_exists(\"users\")\nCREATE TABLE foo();";
173        let d = parse_directives(sql);
174        assert_eq!(d.require, vec!["table_exists(\"users\")"]);
175    }
176
177    #[test]
178    fn test_parse_ensure_directive() {
179        let sql = "-- waypoint:ensure column_exists(\"users\", \"email\")\nALTER TABLE users ADD COLUMN email TEXT;";
180        let d = parse_directives(sql);
181        assert_eq!(d.ensure, vec!["column_exists(\"users\", \"email\")"]);
182    }
183
184    #[test]
185    fn test_parse_multiple_guards() {
186        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;";
187        let d = parse_directives(sql);
188        assert_eq!(d.require.len(), 2);
189        assert_eq!(d.ensure.len(), 1);
190    }
191
192    #[test]
193    fn test_parse_safety_override() {
194        let sql = "-- waypoint:safety-override\nALTER TABLE large_table ADD COLUMN foo TEXT;";
195        let d = parse_directives(sql);
196        assert!(d.safety_override);
197    }
198
199    #[test]
200    fn test_safety_override_default_false() {
201        let sql = "CREATE TABLE foo();";
202        let d = parse_directives(sql);
203        assert!(!d.safety_override);
204    }
205
206    #[test]
207    fn test_env_prefix_does_not_match_ensure() {
208        let sql = "-- waypoint:ensure column_exists(\"users\", \"email\")\nALTER TABLE users ADD COLUMN email TEXT;";
209        let d = parse_directives(sql);
210        // Should be parsed as ensure, not env
211        assert!(d.env.is_empty());
212        assert_eq!(d.ensure.len(), 1);
213    }
214
215    #[test]
216    fn test_directive_prefix_boundary() {
217        // "waypoint:environment" should NOT match "waypoint:env"
218        let sql = "-- waypoint:environment prod\nCREATE TABLE foo();";
219        let d = parse_directives(sql);
220        // Should NOT be parsed as env directive since "waypoint:environment" != "waypoint:env"
221        assert!(d.env.is_empty());
222    }
223
224    #[test]
225    fn test_parse_empty_depends() {
226        let sql = "-- waypoint:depends\nCREATE TABLE foo();";
227        let d = parse_directives(sql);
228        assert!(d.depends.is_empty());
229    }
230
231    #[test]
232    fn test_parse_empty_env() {
233        let sql = "-- waypoint:env\nCREATE TABLE foo();";
234        let d = parse_directives(sql);
235        assert!(d.env.is_empty());
236    }
237
238    #[test]
239    fn test_parse_require_with_special_chars() {
240        let sql = "-- waypoint:require table_exists(\"my-table\")\nCREATE TABLE foo();";
241        let d = parse_directives(sql);
242        assert_eq!(d.require, vec!["table_exists(\"my-table\")"]);
243    }
244}