1use std::collections::{HashMap, HashSet};
18use std::ffi::OsStr;
19use std::fs;
20use std::path::{Path, PathBuf};
21
22const MODULE_ORDER_FILE: &str = "_order.qail";
23const ORDER_STRICT_DIRECTIVE: &str = "qail: strict-manifest";
24const ORDER_STRICT_SHORTHAND: &str = "!strict";
25const STRICT_ENV_VAR: &str = "QAIL_SCHEMA_STRICT_MANIFEST";
26
27#[derive(Debug, Clone)]
29pub struct ResolvedSchemaSource {
30 pub requested: PathBuf,
32 pub root: PathBuf,
34 pub files: Vec<PathBuf>,
36}
37
38impl ResolvedSchemaSource {
39 pub fn is_directory(&self) -> bool {
41 self.root.is_dir()
42 }
43
44 pub fn watch_paths(&self) -> Vec<PathBuf> {
50 let mut out = Vec::with_capacity(1 + self.files.len());
51 out.push(self.root.clone());
52 if self.root.is_dir() {
53 let order_file = self.root.join(MODULE_ORDER_FILE);
54 if order_file.exists() {
55 out.push(order_file);
56 }
57 }
58 for p in &self.files {
59 if !out.contains(p) {
60 out.push(p.clone());
61 }
62 }
63 out
64 }
65
66 pub fn read_merged(&self) -> Result<String, String> {
68 if self.files.len() == 1 && self.root.is_file() {
69 return fs::read_to_string(&self.files[0]).map_err(|e| {
70 format!(
71 "Failed to read schema file '{}': {}",
72 self.files[0].display(),
73 e
74 )
75 });
76 }
77
78 let mut merged = String::new();
79 for file in &self.files {
80 let content = fs::read_to_string(file)
81 .map_err(|e| format!("Failed to read schema module '{}': {}", file.display(), e))?;
82
83 let rel = file.strip_prefix(&self.root).ok().unwrap_or(file);
84 merged.push_str(&format!("-- qail: module={}\n", rel.display()));
85 merged.push_str(&content);
86 if !content.ends_with('\n') {
87 merged.push('\n');
88 }
89 merged.push('\n');
90 }
91
92 Ok(merged)
93 }
94}
95
96pub fn resolve_schema_source(path: impl AsRef<Path>) -> Result<ResolvedSchemaSource, String> {
102 let requested = path.as_ref();
103 let root = resolve_root_path(requested)?;
104
105 if root.is_file() {
106 return Ok(ResolvedSchemaSource {
107 requested: requested.to_path_buf(),
108 root: root.clone(),
109 files: vec![root],
110 });
111 }
112
113 if root.is_dir() {
114 let mut discovered_files = Vec::new();
115 let root_canonical = root.canonicalize().map_err(|e| {
116 format!(
117 "Failed to canonicalize schema root '{}': {}",
118 root.display(),
119 e
120 )
121 })?;
122 let mut visited_dirs = HashSet::new();
123 visited_dirs.insert(root_canonical.clone());
124 collect_qail_files(
125 &root,
126 &root_canonical,
127 &mut visited_dirs,
128 &mut discovered_files,
129 )?;
130 sort_paths_by_relative_path(&root, &mut discovered_files);
131
132 if discovered_files.is_empty() {
133 return Err(format!(
134 "Schema directory '{}' contains no .qail files",
135 root.display()
136 ));
137 }
138
139 let files = apply_module_order(&root, discovered_files)?;
140
141 return Ok(ResolvedSchemaSource {
142 requested: requested.to_path_buf(),
143 root,
144 files,
145 });
146 }
147
148 Err(format!(
149 "Schema path '{}' is neither a file nor a directory",
150 root.display()
151 ))
152}
153
154pub fn read_qail_schema_source(path: impl AsRef<Path>) -> Result<String, String> {
156 resolve_schema_source(path)?.read_merged()
157}
158
159fn resolve_root_path(requested: &Path) -> Result<PathBuf, String> {
160 if requested.exists() {
161 return Ok(requested.to_path_buf());
162 }
163
164 if requested.file_name() == Some(OsStr::new("schema.qail")) {
167 let parent = requested.parent().unwrap_or_else(|| Path::new("."));
168 let modular_dir = parent.join("schema");
169 if modular_dir.is_dir() {
170 return Ok(modular_dir);
171 }
172 }
173
174 Err(format!(
175 "Schema source '{}' not found (expected file or directory)",
176 requested.display()
177 ))
178}
179
180fn collect_qail_files(
181 dir: &Path,
182 root_canonical: &Path,
183 visited_dirs: &mut HashSet<PathBuf>,
184 out: &mut Vec<PathBuf>,
185) -> Result<(), String> {
186 let entries = fs::read_dir(dir)
187 .map_err(|e| format!("Failed to read schema directory '{}': {}", dir.display(), e))?;
188
189 for entry in entries {
190 let entry = entry.map_err(|e| {
191 format!(
192 "Failed to read entry in schema directory '{}': {}",
193 dir.display(),
194 e
195 )
196 })?;
197 let path = entry.path();
198 let file_type = entry.file_type().map_err(|e| {
199 format!(
200 "Failed to read file type in schema directory '{}': {}",
201 dir.display(),
202 e
203 )
204 })?;
205
206 let hidden = path
207 .file_name()
208 .and_then(|n| n.to_str())
209 .is_some_and(|n| n.starts_with('.'));
210 if hidden {
211 continue;
212 }
213
214 if file_type.is_dir() {
215 let canonical = path.canonicalize().map_err(|e| {
216 format!(
217 "Failed to canonicalize schema directory '{}': {}",
218 path.display(),
219 e
220 )
221 })?;
222 if !canonical.starts_with(root_canonical) {
223 continue;
224 }
225 if !visited_dirs.insert(canonical) {
226 continue;
227 }
228 collect_qail_files(&path, root_canonical, visited_dirs, out)?;
229 } else if path
230 .extension()
231 .and_then(|e| e.to_str())
232 .is_some_and(|e| e.eq_ignore_ascii_case("qail"))
233 && path.file_name() != Some(OsStr::new(MODULE_ORDER_FILE))
234 {
235 let canonical = path.canonicalize().map_err(|e| {
236 format!(
237 "Failed to canonicalize schema module '{}': {}",
238 path.display(),
239 e
240 )
241 })?;
242 if !canonical.starts_with(root_canonical) {
243 continue;
244 }
245 out.push(path);
246 }
247 }
248
249 Ok(())
250}
251
252fn sort_paths_by_relative_path(root: &Path, files: &mut [PathBuf]) {
253 files.sort_by(|a, b| {
254 let ar = a.strip_prefix(root).ok().unwrap_or(a);
255 let br = b.strip_prefix(root).ok().unwrap_or(b);
256 ar.to_string_lossy().cmp(&br.to_string_lossy())
257 });
258}
259
260fn apply_module_order(root: &Path, all_files: Vec<PathBuf>) -> Result<Vec<PathBuf>, String> {
261 let order_path = root.join(MODULE_ORDER_FILE);
262 if !order_path.exists() {
263 return Ok(all_files);
264 }
265
266 let order_text = fs::read_to_string(&order_path).map_err(|e| {
267 format!(
268 "Failed to read schema module order file '{}': {}",
269 order_path.display(),
270 e
271 )
272 })?;
273
274 let root_canonical = root.canonicalize().map_err(|e| {
275 format!(
276 "Failed to canonicalize schema root '{}': {}",
277 root.display(),
278 e
279 )
280 })?;
281
282 let mut known_modules: HashMap<PathBuf, PathBuf> = HashMap::new();
283 for module in &all_files {
284 let canonical = module.canonicalize().map_err(|e| {
285 format!(
286 "Failed to canonicalize schema module '{}': {}",
287 module.display(),
288 e
289 )
290 })?;
291 known_modules.insert(canonical, module.clone());
292 }
293
294 let mut ordered = Vec::new();
295 let mut seen = HashSet::new();
296 let mut strict_manifest = strict_manifest_default_enabled(root);
297
298 let mut push_module = |canonical: PathBuf, source_entry: &str| -> Result<(), String> {
299 if let Some(original) = known_modules.get(&canonical) {
300 if seen.insert(canonical) {
301 ordered.push(original.clone());
302 }
303 Ok(())
304 } else {
305 Err(format!(
306 "Order file '{}' references '{}' but it is not a loadable .qail module",
307 order_path.display(),
308 source_entry
309 ))
310 }
311 };
312
313 for (line_no, raw) in order_text.lines().enumerate() {
314 let line = raw.trim();
315 if line.is_empty() || line.starts_with('#') {
316 continue;
317 }
318
319 if let Some(comment) = line.strip_prefix("--") {
320 let comment = comment.trim();
321 if comment.eq_ignore_ascii_case(ORDER_STRICT_DIRECTIVE) {
322 strict_manifest = true;
323 }
324 continue;
325 }
326
327 if line.eq_ignore_ascii_case(ORDER_STRICT_SHORTHAND) {
328 strict_manifest = true;
329 continue;
330 }
331
332 let requested = root.join(line);
333 let canonical = requested.canonicalize().map_err(|e| {
334 format!(
335 "Order file '{}': line {} references '{}' which cannot be resolved: {}",
336 order_path.display(),
337 line_no + 1,
338 line,
339 e
340 )
341 })?;
342
343 if !canonical.starts_with(&root_canonical) {
344 return Err(format!(
345 "Order file '{}': line {} escapes schema root with '{}'",
346 order_path.display(),
347 line_no + 1,
348 line
349 ));
350 }
351
352 if canonical.is_dir() {
353 let mut nested = Vec::new();
354 let mut nested_visited = HashSet::new();
355 nested_visited.insert(canonical.clone());
356 collect_qail_files(
357 &requested,
358 &root_canonical,
359 &mut nested_visited,
360 &mut nested,
361 )?;
362 sort_paths_by_relative_path(root, &mut nested);
363
364 if nested.is_empty() {
365 return Err(format!(
366 "Order file '{}': line {} directory '{}' has no .qail modules",
367 order_path.display(),
368 line_no + 1,
369 line
370 ));
371 }
372
373 for module in nested {
374 let module_canonical = module.canonicalize().map_err(|e| {
375 format!(
376 "Order file '{}': failed to canonicalize module '{}': {}",
377 order_path.display(),
378 module.display(),
379 e
380 )
381 })?;
382 push_module(module_canonical, line)?;
383 }
384 continue;
385 }
386
387 if canonical.file_name() == Some(OsStr::new(MODULE_ORDER_FILE)) {
388 return Err(format!(
389 "Order file '{}': line {} cannot include '{}' recursively",
390 order_path.display(),
391 line_no + 1,
392 MODULE_ORDER_FILE
393 ));
394 }
395
396 if canonical
397 .extension()
398 .and_then(|e| e.to_str())
399 .is_none_or(|e| !e.eq_ignore_ascii_case("qail"))
400 {
401 return Err(format!(
402 "Order file '{}': line {} must reference .qail files or directories (got '{}')",
403 order_path.display(),
404 line_no + 1,
405 line
406 ));
407 }
408
409 push_module(canonical, line)?;
410 }
411
412 let mut unlisted = Vec::new();
413 for module in all_files {
414 let canonical = module.canonicalize().map_err(|e| {
415 format!(
416 "Failed to canonicalize schema module '{}': {}",
417 module.display(),
418 e
419 )
420 })?;
421 if seen.insert(canonical) {
422 if strict_manifest {
423 unlisted.push(module);
424 } else {
425 ordered.push(module);
426 }
427 }
428 }
429
430 if strict_manifest && !unlisted.is_empty() {
431 let preview: Vec<String> = unlisted
432 .iter()
433 .take(10)
434 .map(|p| {
435 p.strip_prefix(root)
436 .ok()
437 .unwrap_or(p)
438 .to_string_lossy()
439 .to_string()
440 })
441 .collect();
442 let suffix = if unlisted.len() > preview.len() {
443 format!(" (+{} more)", unlisted.len() - preview.len())
444 } else {
445 String::new()
446 };
447 return Err(format!(
448 "Order file '{}' has strict manifest enabled, but {} module(s) are unlisted: {}{}",
449 order_path.display(),
450 unlisted.len(),
451 preview.join(", "),
452 suffix
453 ));
454 }
455
456 Ok(ordered)
457}
458
459fn strict_manifest_default_enabled(schema_root: &Path) -> bool {
460 if let Ok(raw) = std::env::var(STRICT_ENV_VAR) {
461 let normalized = raw.trim().to_ascii_lowercase();
462 return matches!(normalized.as_str(), "1" | "true" | "yes" | "on");
463 }
464
465 for dir in schema_root.ancestors() {
466 let candidate = dir.join("qail.toml");
467 if !candidate.is_file() {
468 continue;
469 }
470 if let Ok(cfg) = crate::config::QailConfig::load_from(&candidate) {
471 return cfg.project.schema_strict_manifest.unwrap_or(false);
472 }
473 }
474
475 false
476}
477
478#[cfg(test)]
479mod tests {
480 use super::*;
481
482 fn tmp_dir(name: &str) -> PathBuf {
483 let base = std::env::temp_dir();
484 let nanos = std::time::SystemTime::now()
485 .duration_since(std::time::UNIX_EPOCH)
486 .expect("clock ok")
487 .as_nanos();
488 base.join(format!("qail_schema_source_{name}_{nanos}"))
489 }
490
491 #[test]
492 fn resolve_schema_qail_falls_back_to_schema_dir() {
493 let root = tmp_dir("fallback");
494 fs::create_dir_all(root.join("schema")).expect("mkdir schema");
495 fs::write(
496 root.join("schema").join("auth.qail"),
497 "table auth_users {\n id uuid primary_key\n}\n",
498 )
499 .expect("write auth");
500 fs::write(
501 root.join("schema").join("user.qail"),
502 "table users {\n id uuid primary_key\n}\n",
503 )
504 .expect("write user");
505
506 let requested = root.join("schema.qail");
507 let resolved = resolve_schema_source(&requested).expect("resolved");
508 assert!(resolved.is_directory());
509 assert_eq!(resolved.files.len(), 2);
510
511 let merged = resolved.read_merged().expect("merged");
512 assert!(merged.contains("table auth_users"));
513 assert!(merged.contains("table users"));
514
515 let _ = fs::remove_dir_all(root);
516 }
517
518 #[test]
519 fn resolve_single_file() {
520 let root = tmp_dir("single");
521 fs::create_dir_all(&root).expect("mkdir");
522 let schema_file = root.join("schema.qail");
523 fs::write(&schema_file, "table users {\n id uuid primary_key\n}\n").expect("write file");
524
525 let resolved = resolve_schema_source(&schema_file).expect("resolved");
526 assert!(!resolved.is_directory());
527 assert_eq!(resolved.files, vec![schema_file.clone()]);
528 assert!(
529 resolved
530 .read_merged()
531 .expect("read")
532 .contains("table users")
533 );
534
535 let _ = fs::remove_dir_all(root);
536 }
537
538 #[test]
539 fn order_file_reorders_modules_and_appends_unlisted() {
540 let root = tmp_dir("order");
541 let schema_dir = root.join("schema");
542 fs::create_dir_all(&schema_dir).expect("mkdir schema");
543 fs::write(
544 schema_dir.join("auth.qail"),
545 "table auth_users {\n id uuid primary_key\n}\n",
546 )
547 .expect("write auth");
548 fs::write(
549 schema_dir.join("user.qail"),
550 "table users {\n id uuid primary_key\n}\n",
551 )
552 .expect("write user");
553 fs::write(
554 schema_dir.join("billing.qail"),
555 "table invoices {\n id uuid primary_key\n}\n",
556 )
557 .expect("write billing");
558 fs::write(schema_dir.join(MODULE_ORDER_FILE), "user.qail\nauth.qail\n")
559 .expect("write order");
560
561 let resolved = resolve_schema_source(root.join("schema.qail")).expect("resolved");
562 assert_eq!(resolved.files.len(), 3);
563 assert_eq!(
564 resolved.files[0].file_name().and_then(|n| n.to_str()),
565 Some("user.qail")
566 );
567 assert_eq!(
568 resolved.files[1].file_name().and_then(|n| n.to_str()),
569 Some("auth.qail")
570 );
571 assert_eq!(
572 resolved.files[2].file_name().and_then(|n| n.to_str()),
573 Some("billing.qail")
574 );
575
576 let _ = fs::remove_dir_all(root);
577 }
578
579 #[test]
580 fn order_file_strict_manifest_requires_full_listing() {
581 let root = tmp_dir("order_strict_missing");
582 let schema_dir = root.join("schema");
583 fs::create_dir_all(&schema_dir).expect("mkdir schema");
584 fs::write(
585 schema_dir.join("auth.qail"),
586 "table auth_users {\n id uuid primary_key\n}\n",
587 )
588 .expect("write auth");
589 fs::write(
590 schema_dir.join("user.qail"),
591 "table users {\n id uuid primary_key\n}\n",
592 )
593 .expect("write user");
594 fs::write(
595 schema_dir.join("billing.qail"),
596 "table invoices {\n id uuid primary_key\n}\n",
597 )
598 .expect("write billing");
599 fs::write(
600 schema_dir.join(MODULE_ORDER_FILE),
601 "-- qail: strict-manifest\nuser.qail\nauth.qail\n",
602 )
603 .expect("write order");
604
605 let err = resolve_schema_source(root.join("schema.qail")).expect_err("should error");
606 assert!(err.contains("strict manifest enabled"));
607 assert!(err.contains("billing.qail"));
608
609 let _ = fs::remove_dir_all(root);
610 }
611
612 #[test]
613 fn order_file_strict_manifest_allows_complete_listing() {
614 let root = tmp_dir("order_strict_ok");
615 let schema_dir = root.join("schema");
616 fs::create_dir_all(&schema_dir).expect("mkdir schema");
617 fs::write(
618 schema_dir.join("auth.qail"),
619 "table auth_users {\n id uuid primary_key\n}\n",
620 )
621 .expect("write auth");
622 fs::write(
623 schema_dir.join("user.qail"),
624 "table users {\n id uuid primary_key\n}\n",
625 )
626 .expect("write user");
627 fs::write(
628 schema_dir.join("billing.qail"),
629 "table invoices {\n id uuid primary_key\n}\n",
630 )
631 .expect("write billing");
632 fs::write(
633 schema_dir.join(MODULE_ORDER_FILE),
634 "-- qail: strict-manifest\nuser.qail\nauth.qail\nbilling.qail\n",
635 )
636 .expect("write order");
637
638 let resolved = resolve_schema_source(root.join("schema.qail")).expect("resolved");
639 assert_eq!(resolved.files.len(), 3);
640 assert_eq!(
641 resolved.files[0].file_name().and_then(|n| n.to_str()),
642 Some("user.qail")
643 );
644 assert_eq!(
645 resolved.files[1].file_name().and_then(|n| n.to_str()),
646 Some("auth.qail")
647 );
648 assert_eq!(
649 resolved.files[2].file_name().and_then(|n| n.to_str()),
650 Some("billing.qail")
651 );
652
653 let _ = fs::remove_dir_all(root);
654 }
655
656 #[test]
657 fn order_file_missing_module_errors() {
658 let root = tmp_dir("order_missing");
659 let schema_dir = root.join("schema");
660 fs::create_dir_all(&schema_dir).expect("mkdir schema");
661 fs::write(
662 schema_dir.join("user.qail"),
663 "table users {\n id uuid primary_key\n}\n",
664 )
665 .expect("write user");
666 fs::write(schema_dir.join(MODULE_ORDER_FILE), "missing.qail\n").expect("write order");
667
668 let err = resolve_schema_source(root.join("schema.qail")).expect_err("should error");
669 assert!(err.contains("cannot be resolved") || err.contains("not a loadable"));
670
671 let _ = fs::remove_dir_all(root);
672 }
673
674 #[test]
675 fn order_file_rejects_path_escape() {
676 let root = tmp_dir("order_escape");
677 let schema_dir = root.join("schema");
678 fs::create_dir_all(&schema_dir).expect("mkdir schema");
679 fs::write(
680 schema_dir.join("user.qail"),
681 "table users {\n id uuid primary_key\n}\n",
682 )
683 .expect("write user");
684
685 let outside = root.join("outside.qail");
686 fs::write(&outside, "table outside { id uuid primary_key }\n").expect("write outside");
687 fs::write(schema_dir.join(MODULE_ORDER_FILE), "../outside.qail\n").expect("write order");
688
689 let err = resolve_schema_source(root.join("schema.qail")).expect_err("should error");
690 assert!(err.contains("escapes schema root"));
691
692 let _ = fs::remove_dir_all(root);
693 }
694
695 #[test]
696 fn watch_paths_include_order_file() {
697 let root = tmp_dir("order_watch");
698 let schema_dir = root.join("schema");
699 fs::create_dir_all(&schema_dir).expect("mkdir schema");
700 fs::write(
701 schema_dir.join("user.qail"),
702 "table users {\n id uuid primary_key\n}\n",
703 )
704 .expect("write user");
705 fs::write(schema_dir.join(MODULE_ORDER_FILE), "user.qail\n").expect("write order");
706
707 let resolved = resolve_schema_source(root.join("schema.qail")).expect("resolved");
708 let watch_paths = resolved.watch_paths();
709 assert!(watch_paths.iter().any(|p| p.ends_with(MODULE_ORDER_FILE)));
710
711 let _ = fs::remove_dir_all(root);
712 }
713
714 #[test]
715 fn strict_manifest_default_from_env() {
716 let root = tmp_dir("strict_env");
717 fs::create_dir_all(&root).expect("mkdir");
718 unsafe { std::env::set_var(STRICT_ENV_VAR, "true") };
720 assert!(strict_manifest_default_enabled(&root));
721 unsafe { std::env::remove_var(STRICT_ENV_VAR) };
723 let _ = fs::remove_dir_all(root);
724 }
725
726 #[test]
727 fn strict_manifest_default_from_ancestor_qail_toml() {
728 let root = tmp_dir("strict_cfg");
729 let schema_dir = root.join("schema");
730 fs::create_dir_all(&schema_dir).expect("mkdir schema");
731 fs::write(
732 root.join("qail.toml"),
733 "[project]\nname = \"strict-cfg\"\nschema_strict_manifest = true\n",
734 )
735 .expect("write config");
736 fs::write(
737 schema_dir.join("users.qail"),
738 "table users {\n id uuid primary_key\n}\n",
739 )
740 .expect("write users");
741 fs::write(
742 schema_dir.join("billing.qail"),
743 "table invoices {\n id uuid primary_key\n}\n",
744 )
745 .expect("write billing");
746 fs::write(schema_dir.join(MODULE_ORDER_FILE), "users.qail\n").expect("write order");
747
748 let err = resolve_schema_source(root.join("schema.qail")).expect_err("should error");
749 assert!(err.contains("strict manifest enabled"));
750 assert!(err.contains("billing.qail"));
751
752 let _ = fs::remove_dir_all(root);
753 }
754
755 #[cfg(unix)]
756 #[test]
757 fn resolve_ignores_symlinked_outside_modules() {
758 use std::os::unix::fs::symlink;
759
760 let root = tmp_dir("symlink_outside");
761 let schema_dir = root.join("schema");
762 let outside_dir = root.join("outside");
763 fs::create_dir_all(&schema_dir).expect("mkdir schema");
764 fs::create_dir_all(&outside_dir).expect("mkdir outside");
765 fs::write(
766 schema_dir.join("users.qail"),
767 "table users {\n id uuid primary_key\n}\n",
768 )
769 .expect("write users");
770 fs::write(
771 outside_dir.join("leak.qail"),
772 "table leaked {\n id uuid primary_key\n}\n",
773 )
774 .expect("write leak");
775 symlink(&outside_dir, schema_dir.join("ext")).expect("symlink outside");
776
777 let resolved = resolve_schema_source(root.join("schema.qail")).expect("resolved");
778 assert_eq!(resolved.files.len(), 1);
779 assert!(resolved.files[0].ends_with("users.qail"));
780
781 let _ = fs::remove_dir_all(root);
782 }
783
784 #[cfg(unix)]
785 #[test]
786 fn resolve_ignores_symlink_directory_loops() {
787 use std::os::unix::fs::symlink;
788
789 let root = tmp_dir("symlink_loop");
790 let schema_dir = root.join("schema");
791 fs::create_dir_all(&schema_dir).expect("mkdir schema");
792 fs::write(
793 schema_dir.join("users.qail"),
794 "table users {\n id uuid primary_key\n}\n",
795 )
796 .expect("write users");
797 symlink(&schema_dir, schema_dir.join("loop")).expect("symlink loop");
798
799 let resolved = resolve_schema_source(root.join("schema.qail")).expect("resolved");
800 assert_eq!(resolved.files.len(), 1);
801 assert!(resolved.files[0].ends_with("users.qail"));
802
803 let _ = fs::remove_dir_all(root);
804 }
805}