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