1use std::path::Path;
23
24use crate::identifier::Identifier;
25use crate::parse::error::{ParseError, SourceLocation};
26
27#[derive(Debug, Clone, PartialEq, Eq)]
33pub struct DepDirective {
34 pub target: String,
36}
37
38#[derive(Debug, Clone, Default)]
40pub struct FileDirectives {
41 pub schema: Option<Identifier>,
43 pub deps: Vec<DepDirective>,
45}
46
47const IGNORED_PLAN_KEYS: &[&str] = &[
52 "plan",
53 "step",
54 "group",
55 "kind",
56 "destructive",
57 "intent_id",
58 "version",
59 "created",
60 "source_rev",
61 "target",
62 "intents_required",
63 "transactional",
64 "targets",
65];
66
67pub fn extract_file_directives(sql: &str, file: &Path) -> Result<FileDirectives, ParseError> {
72 let mut out = FileDirectives::default();
73 for (line_no, raw_line) in sql.lines().enumerate() {
74 let trimmed = raw_line.trim();
75 if trimmed.is_empty() {
76 continue;
77 }
78 let Some(rest) = trimmed.strip_prefix("--") else {
79 break;
80 };
81 let rest = rest.trim();
82 let Some(payload) = rest.strip_prefix("@pgevolve") else {
83 continue;
84 };
85 let payload = payload.trim();
86 if let Some(target_raw) = payload.strip_prefix("dep:") {
89 let target = target_raw.trim().to_string();
90 if target.is_empty() {
91 return Err(ParseError::InvalidDirective {
92 location: SourceLocation::new(file.into(), line_no + 1, 1),
93 message: "dep: requires a non-empty target".into(),
94 });
95 }
96 out.deps.push(DepDirective { target });
97 continue;
98 }
99 for kv in payload.split_whitespace() {
100 let Some((k, v)) = kv.split_once('=') else {
101 return Err(ParseError::InvalidDirective {
102 location: SourceLocation::new(file.into(), line_no + 1, 1),
103 message: format!("expected `key=value`, got {kv:?}"),
104 });
105 };
106 apply_kv(&mut out, k, v, file, line_no)?;
107 }
108 }
109 Ok(out)
110}
111
112fn apply_kv(
113 out: &mut FileDirectives,
114 key: &str,
115 value: &str,
116 file: &Path,
117 line_no: usize,
118) -> Result<(), ParseError> {
119 match key {
120 "schema" => {
121 if value.is_empty() {
122 return Err(ParseError::InvalidDirective {
123 location: SourceLocation::new(file.into(), line_no + 1, 1),
124 message: "schema= requires a non-empty identifier".into(),
125 });
126 }
127 let id =
128 Identifier::from_unquoted(value).map_err(|e| ParseError::InvalidDirective {
129 location: SourceLocation::new(file.into(), line_no + 1, 1),
130 message: format!("invalid schema identifier: {e}"),
131 })?;
132 out.schema = Some(id);
133 Ok(())
134 }
135 k if IGNORED_PLAN_KEYS.contains(&k) => Ok(()),
136 other => Err(ParseError::InvalidDirective {
137 location: SourceLocation::new(file.into(), line_no + 1, 1),
138 message: format!("unknown directive key: {other}"),
139 }),
140 }
141}
142
143#[cfg(test)]
144mod tests {
145 use super::*;
146 use std::path::PathBuf;
147
148 fn parse(sql: &str) -> Result<FileDirectives, ParseError> {
149 extract_file_directives(sql, &PathBuf::from("test.sql"))
150 }
151
152 #[test]
153 fn schema_directive_recognized() {
154 let d = parse("-- @pgevolve schema=app\nCREATE TABLE x (id int);").unwrap();
155 assert_eq!(
156 d.schema.as_ref().map(crate::identifier::Identifier::as_str),
157 Some("app")
158 );
159 }
160
161 #[test]
162 fn schema_directive_lowercases() {
163 let d = parse("-- @pgevolve schema=Foo\n").unwrap();
164 assert_eq!(
165 d.schema.as_ref().map(crate::identifier::Identifier::as_str),
166 Some("foo")
167 );
168 }
169
170 #[test]
171 fn directive_in_multi_line_header() {
172 let sql = "\
173 -- header comment\n\
174 -- another comment\n\
175 -- @pgevolve schema=billing\n\
176 CREATE TABLE x (id int);\n";
177 let d = parse(sql).unwrap();
178 assert_eq!(
179 d.schema.as_ref().map(crate::identifier::Identifier::as_str),
180 Some("billing")
181 );
182 }
183
184 #[test]
185 fn directive_after_first_sql_is_ignored() {
186 let sql = "\
187 CREATE TABLE x (id int);\n\
188 -- @pgevolve schema=app\n";
189 let d = parse(sql).unwrap();
190 assert!(d.schema.is_none());
191 }
192
193 #[test]
194 fn empty_value_rejected() {
195 let err = parse("-- @pgevolve schema=\n").unwrap_err();
196 assert!(matches!(err, ParseError::InvalidDirective { .. }));
197 }
198
199 #[test]
200 fn missing_equals_rejected() {
201 let err = parse("-- @pgevolve schema\n").unwrap_err();
202 match err {
203 ParseError::InvalidDirective { message, .. } => {
204 assert!(message.contains("key=value"), "got: {message}");
205 }
206 other => panic!("expected InvalidDirective, got {other:?}"),
207 }
208 }
209
210 #[test]
211 fn unknown_key_rejected() {
212 let err = parse("-- @pgevolve unknown=x\n").unwrap_err();
213 match err {
214 ParseError::InvalidDirective { message, .. } => {
215 assert!(message.contains("unknown"), "got: {message}");
216 }
217 other => panic!("expected InvalidDirective, got {other:?}"),
218 }
219 }
220
221 #[test]
222 fn plan_format_keys_silently_ignored() {
223 let d = parse("-- @pgevolve schema=app step=1 group=migrations kind=create\n").unwrap();
224 assert_eq!(
225 d.schema.as_ref().map(crate::identifier::Identifier::as_str),
226 Some("app")
227 );
228 }
229
230 #[test]
231 fn empty_lines_in_header_are_skipped() {
232 let sql = "\n\n-- @pgevolve schema=app\nCREATE TABLE x (id int);\n";
233 let d = parse(sql).unwrap();
234 assert_eq!(
235 d.schema.as_ref().map(crate::identifier::Identifier::as_str),
236 Some("app")
237 );
238 }
239
240 #[test]
241 fn no_directive_returns_default() {
242 let d = parse("-- just a comment\nCREATE TABLE x (id int);\n").unwrap();
243 assert!(d.schema.is_none());
244 }
245
246 #[test]
249 fn dep_directive_parses() {
250 let d = parse("-- @pgevolve dep: app.audit_log\n").unwrap();
251 assert_eq!(d.deps.len(), 1);
252 assert_eq!(d.deps[0].target, "app.audit_log");
253 }
254
255 #[test]
256 fn dep_directive_trims_whitespace() {
257 let d = parse("-- @pgevolve dep: app.users.email \n").unwrap();
258 assert_eq!(d.deps.len(), 1);
259 assert_eq!(d.deps[0].target, "app.users.email");
260 }
261
262 #[test]
263 fn multiple_dep_directives_collected() {
264 let sql = "-- @pgevolve dep: app.foo\n-- @pgevolve dep: app.bar\nSELECT 1;\n";
265 let d = parse(sql).unwrap();
266 let targets: Vec<&str> = d.deps.iter().map(|dep| dep.target.as_str()).collect();
267 assert!(
268 targets.contains(&"app.foo"),
269 "expected app.foo in {targets:?}"
270 );
271 assert!(
272 targets.contains(&"app.bar"),
273 "expected app.bar in {targets:?}"
274 );
275 }
276
277 #[test]
278 fn dep_directive_coexists_with_schema_directive() {
279 let sql = "-- @pgevolve schema=app\n-- @pgevolve dep: app.audit_log\n";
280 let d = parse(sql).unwrap();
281 assert_eq!(d.schema.as_ref().map(Identifier::as_str), Some("app"));
282 assert_eq!(d.deps.len(), 1);
283 assert_eq!(d.deps[0].target, "app.audit_log");
284 }
285
286 #[test]
287 fn dep_after_first_sql_is_ignored() {
288 let sql = "SELECT 1;\n-- @pgevolve dep: app.foo\n";
290 let d = parse(sql).unwrap();
291 assert!(d.deps.is_empty());
292 }
293
294 #[test]
295 fn dep_directive_with_empty_target_is_rejected() {
296 let err = parse("-- @pgevolve dep:\n").unwrap_err();
297 match err {
298 ParseError::InvalidDirective { message, .. } => {
299 assert!(message.contains("non-empty target"), "got: {message}");
300 }
301 other => panic!("expected InvalidDirective, got {other:?}"),
302 }
303 }
304}