Skip to main content

qail_core/
schema_source.rs

1//! Filesystem loader for QAIL schema sources.
2//!
3//! Supports:
4//! - single file (`schema.qail`)
5//! - modular directory (`schema/*.qail`, recursive)
6//! - optional module-order manifest (`schema/_order.qail`)
7//!
8//! Directory modules are merged in deterministic lexical path order.
9//! If `_order.qail` exists, listed modules are loaded first in listed
10//! order; unlisted modules are appended in lexical order.
11//!
12//! Strict manifest mode (optional):
13//! - add `-- qail: strict-manifest` or `!strict` in `_order.qail`
14//! - then every discovered module must be listed (directly or via listed directories)
15//! - unlisted modules cause an error
16
17use 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/// Resolved schema source (single file or directory of modules).
29#[derive(Debug, Clone)]
30pub struct ResolvedSchemaSource {
31    /// Original path requested by caller.
32    pub requested: PathBuf,
33    /// Effective source path (file or directory).
34    pub root: PathBuf,
35    /// Ordered list of `.qail` files to merge.
36    pub files: Vec<PathBuf>,
37}
38
39impl ResolvedSchemaSource {
40    /// Returns `true` when source is a modular directory.
41    pub fn is_directory(&self) -> bool {
42        self.root.is_dir()
43    }
44
45    /// Paths useful for change-watching.
46    ///
47    /// Includes:
48    /// - root path
49    /// - all resolved module files
50    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    /// Read and merge source content into a single QAIL string.
68    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
97/// Resolve a schema source path into concrete module files.
98pub 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
151/// Read schema source (file or directory modules) as merged QAIL text.
152pub 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                    // SAFETY: test-only restoration while holding the env lock.
510                    unsafe { std::env::set_var(STRICT_ENV_VAR, value) };
511                }
512                None => {
513                    // SAFETY: test-only restoration while holding the env lock.
514                    unsafe { std::env::remove_var(STRICT_ENV_VAR) };
515                }
516            }
517
518            match &self.strict_manifest_config {
519                Some(value) => {
520                    // SAFETY: test-only restoration while holding the env lock.
521                    unsafe { std::env::set_var(STRICT_CONFIG_ERROR_ENV_VAR, value) };
522                }
523                None => {
524                    // SAFETY: test-only restoration while holding the env lock.
525                    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        // SAFETY: test-only env isolation while holding the env lock.
537        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        // SAFETY: test mutates process env, keep scoped and restore after test.
782        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        // SAFETY: test mutates process env, keep scoped and restore after test.
873        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}