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";
26
27/// Resolved schema source (single file or directory of modules).
28#[derive(Debug, Clone)]
29pub struct ResolvedSchemaSource {
30    /// Original path requested by caller.
31    pub requested: PathBuf,
32    /// Effective path used after fallback resolution.
33    pub root: PathBuf,
34    /// Ordered list of `.qail` files to merge.
35    pub files: Vec<PathBuf>,
36}
37
38impl ResolvedSchemaSource {
39    /// Returns `true` when source is a modular directory.
40    pub fn is_directory(&self) -> bool {
41        self.root.is_dir()
42    }
43
44    /// Paths useful for change-watching.
45    ///
46    /// Includes:
47    /// - root path
48    /// - all resolved module files
49    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    /// Read and merge source content into a single QAIL string.
67    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
96/// Resolve a schema source path into concrete module files.
97///
98/// Fallback behavior:
99/// - if requested path is missing and equals `schema.qail`,
100///   automatically use sibling `schema/` directory when present.
101pub 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
154/// Read schema source (file or directory modules) as merged QAIL text.
155pub 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    // Backward-compatible default:
165    // if "schema.qail" is missing, try sibling "schema/" directory.
166    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        // SAFETY: test mutates process env, keep scoped and restore after test.
719        unsafe { std::env::set_var(STRICT_ENV_VAR, "true") };
720        assert!(strict_manifest_default_enabled(&root));
721        // SAFETY: restore env for test isolation.
722        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}