Skip to main content

xlog_logic/
resolver.rs

1//! Module resolution for XLOG programs.
2
3use crate::ast::{
4    ArithExpr, BodyLiteral, DomainDecl, FuncBody, PredDecl, Program, Rule, Term, TypeRef,
5};
6use crate::lower::Lowerer;
7use crate::meta_normalize::static_meta_predicate_dependency;
8use crate::module::{module_path_to_string, LoadedModule, ModuleError, ModulePath};
9use crate::parser::parse_program;
10use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet};
11use std::fs;
12use std::path::{Path, PathBuf};
13use xlog_core::{ScalarType, XlogError};
14
15fn collect_term_predicate_dependencies(term: &Term, predicates: &mut HashSet<String>) {
16    match term {
17        Term::List(items) => {
18            for item in items {
19                collect_term_predicate_dependencies(item, predicates);
20            }
21        }
22        Term::Cons { head, tail } => {
23            collect_term_predicate_dependencies(head, predicates);
24            collect_term_predicate_dependencies(tail, predicates);
25        }
26        Term::Compound { args, .. } => {
27            for argument in args {
28                collect_term_predicate_dependencies(argument, predicates);
29            }
30        }
31        Term::PredRef(name) => {
32            predicates.insert(name.clone());
33        }
34        Term::Variable(_)
35        | Term::Anonymous
36        | Term::Integer(_)
37        | Term::Float(_)
38        | Term::String(_)
39        | Term::Symbol(_)
40        | Term::Aggregate(_) => {}
41    }
42}
43
44fn collect_arithmetic_function_dependencies(
45    expression: &ArithExpr,
46    functions: &mut HashSet<String>,
47) {
48    match expression {
49        ArithExpr::Add(left, right)
50        | ArithExpr::Sub(left, right)
51        | ArithExpr::Mul(left, right)
52        | ArithExpr::Div(left, right)
53        | ArithExpr::Mod(left, right)
54        | ArithExpr::Min(left, right)
55        | ArithExpr::Max(left, right)
56        | ArithExpr::Pow(left, right) => {
57            collect_arithmetic_function_dependencies(left, functions);
58            collect_arithmetic_function_dependencies(right, functions);
59        }
60        ArithExpr::Abs(inner) | ArithExpr::Cast(inner, _) => {
61            collect_arithmetic_function_dependencies(inner, functions);
62        }
63        ArithExpr::FuncCall { name, args } => {
64            functions.insert(name.clone());
65            for argument in args {
66                collect_arithmetic_function_dependencies(argument, functions);
67            }
68        }
69        ArithExpr::Conditional {
70            cond_left,
71            cond_right,
72            then_expr,
73            else_expr,
74            ..
75        } => {
76            collect_arithmetic_function_dependencies(cond_left, functions);
77            collect_arithmetic_function_dependencies(cond_right, functions);
78            collect_arithmetic_function_dependencies(then_expr, functions);
79            collect_arithmetic_function_dependencies(else_expr, functions);
80        }
81        ArithExpr::Variable(_) | ArithExpr::Integer(_) | ArithExpr::Float(_) => {}
82    }
83}
84
85fn collect_body_dependencies(
86    body: &[BodyLiteral],
87    predicates: &mut HashSet<String>,
88    functions: &mut HashSet<String>,
89) {
90    for literal in body {
91        match literal {
92            BodyLiteral::Positive(atom) => {
93                predicates.insert(atom.predicate.clone());
94                if let Some(dependency) = static_meta_predicate_dependency(atom) {
95                    predicates.insert(dependency);
96                }
97                for term in &atom.terms {
98                    collect_term_predicate_dependencies(term, predicates);
99                }
100            }
101            BodyLiteral::Negated(atom) => {
102                predicates.insert(atom.predicate.clone());
103                for term in &atom.terms {
104                    collect_term_predicate_dependencies(term, predicates);
105                }
106            }
107            BodyLiteral::Epistemic(literal) => {
108                predicates.insert(literal.atom.predicate.clone());
109                for term in &literal.atom.terms {
110                    collect_term_predicate_dependencies(term, predicates);
111                }
112            }
113            BodyLiteral::Comparison(comparison) => {
114                collect_term_predicate_dependencies(&comparison.left, predicates);
115                collect_term_predicate_dependencies(&comparison.right, predicates);
116            }
117            BodyLiteral::IsExpr(expression) => {
118                collect_arithmetic_function_dependencies(&expression.expr, functions);
119            }
120            BodyLiteral::Univ(univ) => {
121                collect_term_predicate_dependencies(&univ.term, predicates);
122                collect_term_predicate_dependencies(&univ.parts, predicates);
123            }
124        }
125    }
126}
127
128fn collect_function_body_dependencies(
129    body: &FuncBody,
130    predicates: &mut HashSet<String>,
131    functions: &mut HashSet<String>,
132) {
133    match body {
134        FuncBody::Arithmetic(expression) => {
135            collect_arithmetic_function_dependencies(expression, functions);
136        }
137        FuncBody::Conditional(expression) => {
138            collect_arithmetic_function_dependencies(&expression.cond_left, functions);
139            collect_arithmetic_function_dependencies(&expression.cond_right, functions);
140            collect_function_body_dependencies(&expression.then_branch, predicates, functions);
141            collect_function_body_dependencies(&expression.else_branch, predicates, functions);
142        }
143        FuncBody::Predicate { body, .. } => {
144            collect_body_dependencies(body, predicates, functions);
145        }
146    }
147}
148
149/// Predicate and function names classified within one lexical import scope.
150#[derive(Clone, Default)]
151struct ModuleItems {
152    predicates: HashSet<String>,
153    functions: HashSet<String>,
154}
155
156/// Names supplied by at least one visible provider, and names filtered out by
157/// every provider in the same lexical import scope.
158#[derive(Default)]
159struct ImportScope {
160    visible: ModuleItems,
161    hidden: ModuleItems,
162}
163
164/// Function provider contributed by a resolved import branch.
165#[derive(Clone)]
166struct ImportProvider {
167    module: ModulePath,
168    source: PathBuf,
169}
170
171#[derive(Clone)]
172struct ImportedPredicateDeclaration {
173    module: ModulePath,
174    schema: PredicateDeclarationSchema,
175}
176
177#[derive(Clone, PartialEq, Eq)]
178struct PredicateDeclarationSchema {
179    columns: Vec<(Option<String>, TypeRef)>,
180}
181
182#[derive(Clone, PartialEq, Eq, PartialOrd, Ord)]
183struct PredicateKey {
184    name: String,
185    arity: usize,
186}
187
188#[derive(Clone)]
189struct InferredPredicateContribution {
190    provider: ImportProvider,
191    rule: Rule,
192}
193
194#[derive(Clone, Copy, PartialEq, Eq)]
195enum InferredColumn {
196    Unknown,
197    Known(ScalarType),
198    Conflicting,
199}
200
201#[derive(Clone)]
202struct ImportedDomainDeclaration {
203    module: ModulePath,
204    declaration: DomainDecl,
205}
206
207#[derive(Default)]
208struct ImportProviders {
209    functions: BTreeMap<String, ImportProvider>,
210    predicate_declarations: BTreeMap<String, ImportedPredicateDeclaration>,
211    inferred_predicate_contributions: BTreeMap<PredicateKey, Vec<InferredPredicateContribution>>,
212    domain_declarations: BTreeMap<String, ImportedDomainDeclaration>,
213}
214
215/// One import declaration resolved in the context of its owning source file.
216#[derive(Clone)]
217struct ResolvedImport {
218    source: PathBuf,
219    module_path: ModulePath,
220    imports: Option<Vec<String>>,
221}
222
223struct ResolvedImportGroup {
224    source: PathBuf,
225    module_path: ModulePath,
226    imported_items: Option<HashSet<String>>,
227}
228
229#[derive(Hash, PartialEq, Eq)]
230struct ImportMergeKey {
231    source: PathBuf,
232    imported_items: Option<Vec<String>>,
233}
234
235/// A `#pragma` directive declared in an imported module.
236///
237/// Pragmas are entry-file-scoped: a directive declared in an imported
238/// module never affects compilation. These records exist so callers can
239/// surface the dropped pragmas as warnings instead of ignoring them
240/// silently.
241#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
242pub struct IgnoredImportPragma {
243    /// Module path string (e.g. `rules/common/base`).
244    pub module: String,
245    /// Pragma key as written in source (e.g. `magic_sets`).
246    pub pragma: &'static str,
247}
248
249impl std::fmt::Display for IgnoredImportPragma {
250    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
251        writeln!(
252            f,
253            "warning[W0510]: `#pragma {}` in imported module `{}` is ignored",
254            self.pragma, self.module
255        )?;
256        write!(
257            f,
258            "  = note: pragmas apply only when declared in the entry file"
259        )
260    }
261}
262
263/// Resolves and loads modules
264pub struct ModuleResolver {
265    /// Directories to search for modules
266    search_paths: Vec<PathBuf>,
267    /// Loaded modules keyed by canonical source-file identity.
268    loaded: HashMap<PathBuf, LoadedModule>,
269    /// Logical path spellings mapped to their resolved source files. Bare
270    /// aliases retain first-load lookup behavior for public inspection APIs;
271    /// resolved programs use contextual paths that identify each import edge.
272    module_aliases: HashMap<String, Vec<PathBuf>>,
273    /// Import edges resolved separately for every loaded source file.
274    resolved_imports: HashMap<PathBuf, Vec<ResolvedImport>>,
275    /// Entry source loaded from its exact filesystem path. It is kept outside
276    /// the logical module map so its filename cannot collide with a `use` path.
277    entry: Option<LoadedModule>,
278    /// Canonical source identity and resolved import edges for the entry file.
279    entry_source: Option<PathBuf>,
280    entry_resolved_imports: Vec<ResolvedImport>,
281    /// Source identity of the most recent public `load_module` root.
282    root_module: Option<PathBuf>,
283    /// Source identities and display paths currently loading, for cycle detection.
284    loading: Vec<(PathBuf, ModulePath)>,
285    /// Path key of the entry module, when known. The entry module's own
286    /// pragmas are authoritative and excluded from the ignored-pragma
287    /// listing.
288    entry_module: Option<String>,
289}
290
291impl ModuleResolver {
292    /// Create a new resolver with given search paths
293    pub fn new(search_paths: Vec<PathBuf>) -> Self {
294        Self {
295            search_paths,
296            loaded: HashMap::new(),
297            module_aliases: HashMap::new(),
298            resolved_imports: HashMap::new(),
299            entry: None,
300            entry_source: None,
301            entry_resolved_imports: Vec::new(),
302            root_module: None,
303            loading: Vec::new(),
304            entry_module: None,
305        }
306    }
307
308    /// Record which loaded module is the compilation entry point.
309    ///
310    /// The entry module's pragmas are the ones the compiler honors;
311    /// [`Self::ignored_import_pragmas`] skips it.
312    pub fn mark_entry_module(&mut self, path_key: &str) {
313        self.entry_module = Some(path_key.to_string());
314    }
315
316    /// List `#pragma` directives declared in imported (non-entry) modules.
317    ///
318    /// Pragmas are entry-file-scoped, so anything an imported module
319    /// declares is dropped at merge time. Callers surface these records as
320    /// warnings so the scoping is never silent. The result is sorted by
321    /// module path, then pragma name, for deterministic output.
322    ///
323    /// Nested imports resolve relative to the importer's directory, so one
324    /// file can be loaded under several module-path spellings. Warnings are
325    /// deduplicated on the canonical source file (one warning per file per
326    /// pragma), keeping the alphabetically-first module label; the entry
327    /// file itself never warns under any spelling.
328    pub fn ignored_import_pragmas(&self) -> Vec<IgnoredImportPragma> {
329        let entry_source = self
330            .entry_module
331            .as_deref()
332            .and_then(|path| self.module_aliases.get(path))
333            .and_then(|sources| sources.first().cloned())
334            .or_else(|| self.entry_source.clone());
335
336        let mut candidates: Vec<(PathBuf, IgnoredImportPragma)> = Vec::new();
337        for (path_key, sources) in &self.module_aliases {
338            for source in sources {
339                if entry_source.as_ref() == Some(source) {
340                    continue;
341                }
342                let Some(module) = self.loaded.get(source) else {
343                    continue;
344                };
345                for pragma in module.program.directives.set_pragma_names() {
346                    candidates.push((
347                        source.clone(),
348                        IgnoredImportPragma {
349                            module: path_key.clone(),
350                            pragma,
351                        },
352                    ));
353                }
354            }
355        }
356
357        candidates.sort_by(|a, b| a.1.cmp(&b.1));
358        let mut seen: HashSet<(PathBuf, &'static str)> = HashSet::new();
359        let mut ignored = Vec::with_capacity(candidates.len());
360        for (source, warning) in candidates {
361            if seen.insert((source, warning.pragma)) {
362                ignored.push(warning);
363            }
364        }
365        ignored
366    }
367
368    fn source_identity(source_file: &Path) -> Result<PathBuf, ModuleError> {
369        fs::canonicalize(source_file).map_err(|error| ModuleError::ParseError {
370            path: source_file.to_path_buf(),
371            message: format!("failed to resolve source-file identity: {error}"),
372        })
373    }
374
375    fn resolve_module_file(
376        &self,
377        base_dir: &Path,
378        module_path: &[String],
379    ) -> Option<(PathBuf, bool)> {
380        let relative_path = format!("{}.xlog", module_path.join("/"));
381        let candidate = base_dir.join(&relative_path);
382        if candidate.exists() {
383            return Some((candidate, true));
384        }
385
386        for search_path in &self.search_paths {
387            let candidate = search_path.join(&relative_path);
388            if candidate.exists() {
389                return Some((candidate, false));
390            }
391        }
392
393        None
394    }
395
396    /// Find the file for a module path
397    pub fn find_module_file(&self, base_dir: &Path, module_path: &[String]) -> Option<PathBuf> {
398        self.resolve_module_file(base_dir, module_path)
399            .map(|(path, _)| path)
400    }
401
402    /// Get the list of searched paths for error reporting
403    fn searched_paths(&self, base_dir: &Path, module_path: &[String]) -> Vec<PathBuf> {
404        let relative_path = format!("{}.xlog", module_path.join("/"));
405        let mut searched = vec![base_dir.join(&relative_path)];
406        for sp in &self.search_paths {
407            searched.push(sp.join(&relative_path));
408        }
409        searched
410    }
411
412    /// Check if we're in a circular import
413    fn check_cycle(&self, source: &Path, module_path: &[String]) -> Option<Vec<ModulePath>> {
414        for (i, (loading_source, _)) in self.loading.iter().enumerate() {
415            if loading_source == source {
416                let mut cycle: Vec<ModulePath> = self.loading[i..]
417                    .iter()
418                    .map(|(_, path)| path.clone())
419                    .collect();
420                cycle.push(module_path.to_vec());
421                return Some(cycle);
422            }
423        }
424        None
425    }
426
427    /// Extract exports from a parsed program
428    /// Returns (predicate exports, function exports)
429    pub fn extract_exports(program: &Program) -> (HashSet<String>, HashSet<String>) {
430        let mut pred_exports = HashSet::new();
431        let mut func_exports = HashSet::new();
432
433        // Add declared predicates that aren't private
434        for pred in &program.predicates {
435            if !pred.is_private {
436                pred_exports.insert(pred.name.clone());
437            }
438        }
439
440        // Add rule heads (all rules define public predicates unless declared private)
441        for rule in &program.rules {
442            // Check if this predicate was declared as private
443            let is_private = program
444                .predicates
445                .iter()
446                .any(|p| p.name == rule.head.predicate && p.is_private);
447            if !is_private {
448                pred_exports.insert(rule.head.predicate.clone());
449            }
450        }
451
452        // Add functions that aren't private
453        for func in &program.functions {
454            if !func.is_private {
455                func_exports.insert(func.name.clone());
456            }
457        }
458
459        (pred_exports, func_exports)
460    }
461
462    /// Load a module from a logical path and make it the resolution root.
463    ///
464    /// A successful call replaces any exact-file entry anchor used by
465    /// [`Self::validate_imports`] and [`Self::merge_imports`].
466    pub fn load_module(
467        &mut self,
468        base_dir: &Path,
469        module_path: &[String],
470    ) -> Result<&LoadedModule, ModuleError> {
471        let (source, _) = self.load_module_resolved(base_dir, None, module_path)?;
472        self.entry = None;
473        self.entry_source = None;
474        self.entry_resolved_imports.clear();
475        self.root_module = Some(source.clone());
476        Ok(self.loaded.get(&source).expect("module was just resolved"))
477    }
478
479    /// Load the compilation entry from its exact filesystem path.
480    ///
481    /// Imported modules still use normal `.xlog` module lookup. The entry file
482    /// itself may use any extension accepted by the caller. It is tracked
483    /// separately from logical imports so a same-stem `.xlog` module remains
484    /// addressable through `use`. Nested imports in an imported module resolve
485    /// beside its canonical source target, making symbolic-link aliases
486    /// independent of load order.
487    pub fn load_entry_file(&mut self, entry_file: &Path) -> Result<&LoadedModule, ModuleError> {
488        let base_dir = entry_file.parent().unwrap_or(Path::new("."));
489        let module_name = entry_file
490            .file_stem()
491            .and_then(|stem| stem.to_str())
492            .unwrap_or("main")
493            .to_string();
494        let module_path = vec![module_name.clone()];
495
496        let module = Self::parse_module_file(&module_path, entry_file.to_path_buf())?;
497        let entry_source = Self::source_identity(entry_file)?;
498        let module_dir = module.source_file.parent().unwrap_or(base_dir);
499        self.loading
500            .push((entry_source.clone(), module_path.clone()));
501        let resolved_imports = (|| {
502            let mut resolved = Vec::with_capacity(module.program.imports.len());
503            for import in &module.program.imports {
504                let (source, resolved_path) =
505                    self.load_module_resolved(module_dir, Some(&module_path), &import.module_path)?;
506                resolved.push(ResolvedImport {
507                    source,
508                    module_path: resolved_path,
509                    imports: import.imports.clone(),
510                });
511            }
512            Ok(resolved)
513        })();
514        self.loading.pop();
515        let resolved_imports = resolved_imports?;
516        self.entry_module = None;
517        self.entry_source = Some(entry_source);
518        self.entry_resolved_imports = resolved_imports;
519        self.root_module = None;
520        self.entry = Some(module);
521        Ok(self.entry.as_ref().expect("entry module was just loaded"))
522    }
523
524    fn contextual_module_path(
525        parent_path: Option<&[String]>,
526        declared_path: &[String],
527        resolved_relative_to_parent: bool,
528    ) -> ModulePath {
529        if !resolved_relative_to_parent {
530            return declared_path.to_vec();
531        }
532
533        let mut resolved = parent_path
534            .and_then(|path| path.split_last().map(|(_, prefix)| prefix.to_vec()))
535            .unwrap_or_default();
536        resolved.extend_from_slice(declared_path);
537        resolved
538    }
539
540    fn record_module_alias(&mut self, module_path: &[String], source: &Path) {
541        let sources = self
542            .module_aliases
543            .entry(module_path_to_string(module_path))
544            .or_default();
545        if !sources.iter().any(|candidate| candidate == source) {
546            sources.push(source.to_path_buf());
547        }
548    }
549
550    fn load_module_resolved(
551        &mut self,
552        base_dir: &Path,
553        parent_path: Option<&[String]>,
554        declared_path: &[String],
555    ) -> Result<(PathBuf, ModulePath), ModuleError> {
556        let (source_file, resolved_relative_to_parent) = self
557            .resolve_module_file(base_dir, declared_path)
558            .ok_or_else(|| ModuleError::NotFound {
559                path: declared_path.to_vec(),
560                searched: self.searched_paths(base_dir, declared_path),
561            })?;
562        let source = Self::source_identity(&source_file)?;
563        let contextual_path =
564            Self::contextual_module_path(parent_path, declared_path, resolved_relative_to_parent);
565
566        if let Some(cycle) = self.check_cycle(&source, &contextual_path) {
567            return Err(ModuleError::CircularImport { cycle });
568        }
569
570        if self.loaded.contains_key(&source) {
571            self.record_module_alias(declared_path, &source);
572            self.record_module_alias(&contextual_path, &source);
573            return Ok((source, contextual_path));
574        }
575
576        self.loading.push((source.clone(), contextual_path.clone()));
577
578        let loaded = (|| {
579            let module = Self::parse_module_file(&contextual_path, source_file)?;
580            // Canonical aliases share one module identity and therefore one
581            // deterministic dependency closure. Resolve nested imports beside
582            // the canonical source instead of whichever alias loaded first.
583            let module_dir = source.parent().unwrap_or(base_dir).to_path_buf();
584            let mut resolved_imports = Vec::with_capacity(module.program.imports.len());
585            for import in &module.program.imports {
586                let (target_source, resolved_path) = self.load_module_resolved(
587                    &module_dir,
588                    Some(&contextual_path),
589                    &import.module_path,
590                )?;
591                resolved_imports.push(ResolvedImport {
592                    source: target_source,
593                    module_path: resolved_path,
594                    imports: import.imports.clone(),
595                });
596            }
597            Ok((module, resolved_imports))
598        })();
599
600        self.loading.pop();
601        let (module, resolved_imports) = loaded?;
602        let primary_path = module.path.clone();
603
604        self.record_module_alias(declared_path, &source);
605        self.record_module_alias(&contextual_path, &source);
606        self.resolved_imports
607            .insert(source.clone(), resolved_imports);
608        self.loaded.insert(source.clone(), module);
609        Ok((source, primary_path))
610    }
611
612    fn parse_module_file(
613        module_path: &[String],
614        source_file: PathBuf,
615    ) -> Result<LoadedModule, ModuleError> {
616        let source = fs::read_to_string(&source_file).map_err(|error| ModuleError::ParseError {
617            path: source_file.clone(),
618            message: error.to_string(),
619        })?;
620        let program = parse_program(&source).map_err(|error| ModuleError::ParseError {
621            path: source_file.clone(),
622            message: error.to_string(),
623        })?;
624        let (exports, function_exports) = Self::extract_exports(&program);
625
626        Ok(LoadedModule {
627            path: module_path.to_vec(),
628            source_file,
629            exports,
630            function_exports,
631            program,
632        })
633    }
634
635    /// Check if a predicate can be imported from a module
636    ///
637    /// This compatibility inspection uses the first source registered for the
638    /// logical path. Semantic validation follows importer-scoped resolved edges
639    /// and does not use this alias lookup.
640    pub fn check_import(&self, module_path: &[String], predicate: &str) -> Result<(), ModuleError> {
641        let (_, module) = self
642            .first_loaded_module_for_alias(module_path)
643            .ok_or_else(|| ModuleError::NotFound {
644                path: module_path.to_vec(),
645                searched: vec![],
646            })?;
647
648        Self::validate_consistent_predicate_visibility(&module.program, module_path)?;
649        if !module.exports.contains(predicate) {
650            return Err(ModuleError::PredicateNotFound {
651                name: predicate.to_string(),
652                module: module_path.to_vec(),
653            });
654        }
655
656        Ok(())
657    }
658
659    /// Validate all imports in a program.
660    ///
661    /// Import edges come from the most recently loaded entry file or root module
662    /// when its declarations match `program`. A context-free request is accepted
663    /// only when every logical path identifies one loaded source file.
664    ///
665    /// Returns predicate and function names with the first resolved source module
666    /// retained as a representative. Entry declarations and selected public
667    /// import declarations participate in declaration compatibility checks. For
668    /// signatures without a participating declaration, inferred head-column
669    /// types from entry and selected public import clauses are validated before
670    /// merging. Constants, head variables typed by ordinary body atoms or
671    /// built-in arithmetic bindings, and aggregate result types supply evidence;
672    /// unanchored variables do not.
673    #[allow(clippy::type_complexity)]
674    pub fn validate_imports(
675        &self,
676        program: &Program,
677    ) -> Result<(HashMap<String, ModulePath>, HashMap<String, ModulePath>), ModuleError> {
678        let imports = self.resolved_imports_for_program(program)?;
679        let validated = self.validate_resolved_imports(&imports)?;
680        self.validate_program_against_imports(program, &imports)?;
681        Ok(validated)
682    }
683
684    #[allow(clippy::type_complexity)]
685    fn validate_resolved_imports(
686        &self,
687        imports: &[ResolvedImport],
688    ) -> Result<(HashMap<String, ModulePath>, HashMap<String, ModulePath>), ModuleError> {
689        let mut imported_predicates: HashMap<String, ModulePath> = HashMap::new();
690        let mut imported_functions: HashMap<String, ModulePath> = HashMap::new();
691        let mut function_providers: HashMap<String, ImportProvider> = HashMap::new();
692
693        for resolved_import in imports {
694            let module =
695                self.loaded
696                    .get(&resolved_import.source)
697                    .ok_or_else(|| ModuleError::NotFound {
698                        path: resolved_import.module_path.clone(),
699                        searched: vec![],
700                    })?;
701
702            // Combine all available exports for wildcard imports
703            let all_exports: HashSet<String> = module
704                .exports
705                .iter()
706                .chain(module.function_exports.iter())
707                .cloned()
708                .collect();
709
710            let mut names_to_import: Vec<String> = match &resolved_import.imports {
711                Some(specific) => specific.clone(),
712                None => all_exports.iter().cloned().collect(),
713            };
714            names_to_import.sort();
715
716            for name in names_to_import {
717                // Check if name exists as predicate or function
718                let is_predicate = module.exports.contains(&name);
719                let is_function = module.function_exports.contains(&name);
720
721                if !is_predicate && !is_function {
722                    return Err(ModuleError::PredicateNotFound {
723                        name: name.clone(),
724                        module: resolved_import.module_path.clone(),
725                    });
726                }
727
728                if is_predicate {
729                    imported_predicates
730                        .entry(name.clone())
731                        .or_insert_with(|| resolved_import.module_path.clone());
732                }
733
734                if is_function {
735                    if let Some(previous) = function_providers.get(&name) {
736                        if previous.source != resolved_import.source {
737                            return Err(ModuleError::ImportConflict {
738                                name,
739                                module1: previous.module.clone(),
740                                module2: resolved_import.module_path.clone(),
741                            });
742                        }
743                    } else {
744                        function_providers.insert(
745                            name.clone(),
746                            ImportProvider {
747                                module: resolved_import.module_path.clone(),
748                                source: resolved_import.source.clone(),
749                            },
750                        );
751                    }
752                    imported_functions
753                        .entry(name.clone())
754                        .or_insert_with(|| resolved_import.module_path.clone());
755                }
756            }
757        }
758
759        Ok((imported_predicates, imported_functions))
760    }
761
762    fn resolved_imports_for_program(
763        &self,
764        program: &Program,
765    ) -> Result<Vec<ResolvedImport>, ModuleError> {
766        if self
767            .entry
768            .as_ref()
769            .is_some_and(|entry| entry.program.imports == program.imports)
770        {
771            return Ok(self.entry_resolved_imports.clone());
772        }
773
774        if let Some(root_source) = &self.root_module {
775            if let Some(root) = self.loaded.get(root_source) {
776                if root.program.imports == program.imports {
777                    return Ok(self
778                        .resolved_imports
779                        .get(root_source)
780                        .cloned()
781                        .unwrap_or_default());
782                }
783            }
784        }
785
786        program
787            .imports
788            .iter()
789            .map(|use_decl| {
790                let sources = self
791                    .module_aliases
792                    .get(&module_path_to_string(&use_decl.module_path))
793                    .ok_or_else(|| ModuleError::NotFound {
794                        path: use_decl.module_path.clone(),
795                        searched: vec![],
796                    })?;
797                if sources.len() > 1 {
798                    let mut candidates = sources.clone();
799                    candidates.sort();
800                    return Err(ModuleError::AmbiguousModulePath {
801                        path: use_decl.module_path.clone(),
802                        candidates,
803                    });
804                }
805                let source = sources
806                    .first()
807                    .filter(|source| self.loaded.contains_key(*source))
808                    .ok_or_else(|| ModuleError::NotFound {
809                        path: use_decl.module_path.clone(),
810                        searched: vec![],
811                    })?;
812                Ok(ResolvedImport {
813                    source: source.clone(),
814                    module_path: use_decl.module_path.clone(),
815                    imports: use_decl.imports.clone(),
816                })
817            })
818            .collect()
819    }
820
821    fn first_loaded_module_for_alias(
822        &self,
823        module_path: &[String],
824    ) -> Option<(&PathBuf, &LoadedModule)> {
825        let source = self
826            .module_aliases
827            .get(&module_path_to_string(module_path))?
828            .first()?;
829        self.loaded.get_key_value(source)
830    }
831
832    /// Get a loaded logical import by module path.
833    ///
834    /// If several importer contexts registered the same logical path for
835    /// different files, this compatibility view returns the first registered
836    /// source. Semantic validation and merging use resolved import edges.
837    pub fn get_module(&self, module_path: &[String]) -> Option<&LoadedModule> {
838        self.first_loaded_module_for_alias(module_path)
839            .map(|(_, module)| module)
840    }
841
842    /// Return the entry source loaded from its exact path, if present.
843    pub fn entry(&self) -> Option<&LoadedModule> {
844        self.entry.as_ref()
845    }
846
847    /// Check whether at least one source is registered for a logical import path.
848    pub fn is_loaded(&self, module_path: &str) -> bool {
849        self.module_aliases.contains_key(module_path)
850    }
851
852    /// Get all registered logical import aliases (for testing).
853    pub fn loaded_modules(&self) -> Vec<&str> {
854        self.module_aliases.keys().map(String::as_str).collect()
855    }
856
857    fn imported_item_set(imports: &Option<Vec<String>>) -> Option<HashSet<String>> {
858        match imports {
859            Some(items) if !items.is_empty() => Some(items.iter().cloned().collect()),
860            _ => None,
861        }
862    }
863
864    fn combined_import_selections(imports: &[ResolvedImport]) -> Vec<ResolvedImportGroup> {
865        // Repeated declarations in one source file form one selection. Keep
866        // this combination lexical: an unrelated importing module must not
867        // make an omitted dependency visible here.
868        let mut combined = Vec::<ResolvedImportGroup>::new();
869        let mut indexes = HashMap::<PathBuf, usize>::new();
870        for resolved_import in imports {
871            let selection = Self::imported_item_set(&resolved_import.imports);
872            if let Some(index) = indexes.get(&resolved_import.source).copied() {
873                let existing = &mut combined[index].imported_items;
874                match (existing.as_mut(), selection) {
875                    (Some(existing_names), Some(names)) => existing_names.extend(names),
876                    (_, None) => *existing = None,
877                    (None, Some(_)) => {}
878                }
879            } else {
880                indexes.insert(resolved_import.source.clone(), combined.len());
881                combined.push(ResolvedImportGroup {
882                    source: resolved_import.source.clone(),
883                    module_path: resolved_import.module_path.clone(),
884                    imported_items: selection,
885                });
886            }
887        }
888        combined
889    }
890
891    fn local_predicate_names(program: &Program) -> HashSet<String> {
892        program
893            .predicates
894            .iter()
895            .map(|predicate| predicate.name.clone())
896            .chain(program.rules.iter().map(|rule| rule.head.predicate.clone()))
897            .collect()
898    }
899
900    fn local_function_names(program: &Program) -> HashSet<String> {
901        program
902            .functions
903            .iter()
904            .map(|function| function.name.clone())
905            .collect()
906    }
907
908    fn hidden_local_function_names(
909        program: &Program,
910        imported_items: Option<&HashSet<String>>,
911    ) -> HashSet<String> {
912        let private_functions = program
913            .functions
914            .iter()
915            .filter(|function| function.is_private)
916            .map(|function| function.name.clone())
917            .collect::<HashSet<_>>();
918        Self::local_function_names(program)
919            .into_iter()
920            .filter(|name| {
921                private_functions.contains(name)
922                    || imported_items.is_some_and(|items| !items.contains(name))
923            })
924            .collect()
925    }
926
927    fn hidden_local_items(
928        program: &Program,
929        imported_items: Option<&HashSet<String>>,
930    ) -> ModuleItems {
931        let private_predicates = program
932            .predicates
933            .iter()
934            .filter(|predicate| predicate.is_private)
935            .map(|predicate| predicate.name.clone())
936            .collect::<HashSet<_>>();
937        let predicates = Self::local_predicate_names(program)
938            .into_iter()
939            .filter(|name| {
940                private_predicates.contains(name)
941                    || imported_items.is_some_and(|items| !items.contains(name))
942            })
943            .collect();
944
945        let functions = Self::hidden_local_function_names(program, imported_items);
946
947        ModuleItems {
948            predicates,
949            functions,
950        }
951    }
952
953    fn visible_local_functions(
954        program: &Program,
955        imported_items: Option<&HashSet<String>>,
956    ) -> HashSet<String> {
957        let hidden = Self::hidden_local_function_names(program, imported_items);
958        Self::local_function_names(program)
959            .difference(&hidden)
960            .cloned()
961            .collect()
962    }
963
964    fn local_inferred_predicate_contributions(
965        program: &Program,
966        excluded_predicates: &HashSet<String>,
967        provider: ImportProvider,
968    ) -> BTreeMap<PredicateKey, Vec<InferredPredicateContribution>> {
969        let mut contributions = BTreeMap::<PredicateKey, Vec<InferredPredicateContribution>>::new();
970
971        for rule in &program.rules {
972            let key = PredicateKey {
973                name: rule.head.predicate.clone(),
974                arity: rule.head.arity(),
975            };
976            if excluded_predicates.contains(&key.name)
977                || program.predicates.iter().any(|declaration| {
978                    declaration.name == key.name && declaration.arity() == key.arity
979                })
980            {
981                continue;
982            }
983
984            let contribution = InferredPredicateContribution {
985                provider: provider.clone(),
986                rule: rule.clone(),
987            };
988            let existing = contributions.entry(key).or_default();
989            if !existing.iter().any(|candidate| {
990                candidate.provider.source == contribution.provider.source
991                    && candidate.rule == contribution.rule
992            }) {
993                existing.push(contribution);
994            }
995        }
996
997        contributions
998    }
999
1000    fn merge_inferred_predicate_contributions(
1001        contributions: &mut BTreeMap<PredicateKey, Vec<InferredPredicateContribution>>,
1002        incoming: BTreeMap<PredicateKey, Vec<InferredPredicateContribution>>,
1003    ) {
1004        for (key, incoming_contributions) in incoming {
1005            let existing = contributions.entry(key).or_default();
1006            for contribution in incoming_contributions {
1007                if !existing.iter().any(|candidate| {
1008                    candidate.provider.source == contribution.provider.source
1009                        && candidate.rule == contribution.rule
1010                }) {
1011                    existing.push(contribution);
1012                }
1013            }
1014        }
1015    }
1016
1017    fn storage_scalar_type(typ: &TypeRef) -> Option<ScalarType> {
1018        match typ {
1019            TypeRef::Scalar(typ) => Some(*typ),
1020            TypeRef::List(_) | TypeRef::Term | TypeRef::Compound | TypeRef::PredRef => {
1021                Some(ScalarType::U64)
1022            }
1023            TypeRef::Domain(_) => None,
1024        }
1025    }
1026
1027    fn inferred_rule_columns(
1028        schema_inference: &Lowerer,
1029        rule: &Rule,
1030        schemas: &BTreeMap<PredicateKey, Vec<InferredColumn>>,
1031    ) -> xlog_core::Result<Vec<Option<ScalarType>>> {
1032        schema_inference.infer_rule_head_column_types_before_function_expansion(
1033            rule,
1034            |atom, index| {
1035                schemas
1036                    .get(&PredicateKey {
1037                        name: atom.predicate.clone(),
1038                        arity: atom.arity(),
1039                    })
1040                    .and_then(|columns| columns.get(index))
1041                    .and_then(|column| match column {
1042                        InferredColumn::Known(typ) => Some(*typ),
1043                        InferredColumn::Unknown | InferredColumn::Conflicting => None,
1044                    })
1045            },
1046        )
1047    }
1048
1049    fn predicate_schema_inference_error(
1050        key: &PredicateKey,
1051        contribution: &InferredPredicateContribution,
1052        error: XlogError,
1053    ) -> ModuleError {
1054        ModuleError::PredicateSchemaInferenceFailed {
1055            name: key.name.clone(),
1056            arity: key.arity,
1057            module: contribution.provider.module.clone(),
1058            source: Box::new(contribution.provider.source.clone()),
1059            message: error.to_string(),
1060        }
1061    }
1062
1063    fn inferred_predicate_schemas(
1064        providers: &ImportProviders,
1065    ) -> Result<BTreeMap<PredicateKey, Vec<InferredColumn>>, ModuleError> {
1066        let mut schemas = BTreeMap::<PredicateKey, Vec<InferredColumn>>::new();
1067        let mut declared = BTreeSet::<PredicateKey>::new();
1068
1069        for (name, declaration) in &providers.predicate_declarations {
1070            let key = PredicateKey {
1071                name: name.clone(),
1072                arity: declaration.schema.columns.len(),
1073            };
1074            let columns = declaration
1075                .schema
1076                .columns
1077                .iter()
1078                .map(|(_, typ)| {
1079                    Self::storage_scalar_type(typ)
1080                        .map(InferredColumn::Known)
1081                        .unwrap_or(InferredColumn::Unknown)
1082                })
1083                .collect();
1084            declared.insert(key.clone());
1085            schemas.insert(key, columns);
1086        }
1087
1088        let total_columns = providers
1089            .inferred_predicate_contributions
1090            .keys()
1091            .filter(|key| !declared.contains(*key))
1092            .map(|key| key.arity)
1093            .sum::<usize>();
1094        let max_iterations = total_columns.saturating_mul(2).saturating_add(1);
1095        let schema_inference = Lowerer::new();
1096        let mut converged = false;
1097        for _ in 0..max_iterations {
1098            let mut changed = false;
1099            for (key, contributions) in &providers.inferred_predicate_contributions {
1100                if declared.contains(key) {
1101                    continue;
1102                }
1103                for contribution in contributions {
1104                    let columns = Self::inferred_rule_columns(
1105                        &schema_inference,
1106                        &contribution.rule,
1107                        &schemas,
1108                    )
1109                    .map_err(|error| {
1110                        Self::predicate_schema_inference_error(key, contribution, error)
1111                    })?;
1112                    let schema = schemas
1113                        .entry(key.clone())
1114                        .or_insert_with(|| vec![InferredColumn::Unknown; key.arity]);
1115                    for (slot, inferred) in schema.iter_mut().zip(columns) {
1116                        let Some(inferred) = inferred else {
1117                            continue;
1118                        };
1119                        let updated = match *slot {
1120                            InferredColumn::Unknown => InferredColumn::Known(inferred),
1121                            InferredColumn::Known(existing) if existing != inferred => {
1122                                InferredColumn::Conflicting
1123                            }
1124                            InferredColumn::Known(_) | InferredColumn::Conflicting => continue,
1125                        };
1126                        *slot = updated;
1127                        changed = true;
1128                    }
1129                }
1130            }
1131            if !changed {
1132                converged = true;
1133                break;
1134            }
1135        }
1136        debug_assert!(
1137            converged,
1138            "predicate schema inference exceeded its monotonic transition bound"
1139        );
1140
1141        Ok(schemas)
1142    }
1143
1144    fn validate_inferred_predicate_contributions(
1145        providers: &ImportProviders,
1146    ) -> Result<(), ModuleError> {
1147        let schemas = Self::inferred_predicate_schemas(providers)?;
1148        let schema_inference = Lowerer::new();
1149        for (key, contributions) in &providers.inferred_predicate_contributions {
1150            if providers
1151                .predicate_declarations
1152                .get(&key.name)
1153                .is_some_and(|declaration| declaration.schema.columns.len() == key.arity)
1154            {
1155                continue;
1156            }
1157
1158            let inferred = contributions
1159                .iter()
1160                .map(|contribution| {
1161                    Self::inferred_rule_columns(&schema_inference, &contribution.rule, &schemas)
1162                        .map(|columns| (contribution, columns))
1163                        .map_err(|error| {
1164                            Self::predicate_schema_inference_error(key, contribution, error)
1165                        })
1166                })
1167                .collect::<Result<Vec<_>, _>>()?;
1168            for (left_index, (left, left_columns)) in inferred.iter().enumerate() {
1169                for (right, right_columns) in inferred.iter().skip(left_index + 1) {
1170                    if left.provider.source == right.provider.source {
1171                        continue;
1172                    }
1173                    for (column_index, (left_type, right_type)) in
1174                        left_columns.iter().zip(right_columns).enumerate()
1175                    {
1176                        if let (Some(type1), Some(type2)) = (left_type, right_type) {
1177                            if type1 != type2 {
1178                                return Err(ModuleError::IncompatibleInferredPredicateSchema {
1179                                    name: key.name.clone(),
1180                                    arity: key.arity,
1181                                    column: column_index + 1,
1182                                    type1: *type1,
1183                                    type2: *type2,
1184                                    module1: left.provider.module.clone(),
1185                                    module2: right.provider.module.clone(),
1186                                    source1: Box::new(left.provider.source.clone()),
1187                                    source2: Box::new(right.provider.source.clone()),
1188                                });
1189                            }
1190                        }
1191                    }
1192                }
1193            }
1194        }
1195
1196        Ok(())
1197    }
1198
1199    fn merge_function_providers(
1200        providers: &mut BTreeMap<String, ImportProvider>,
1201        incoming: BTreeMap<String, ImportProvider>,
1202    ) -> Result<(), ModuleError> {
1203        for (name, provider) in incoming {
1204            if let Some(existing) = providers.get(&name) {
1205                if existing.source != provider.source {
1206                    return Err(ModuleError::ImportConflict {
1207                        name,
1208                        module1: existing.module.clone(),
1209                        module2: provider.module,
1210                    });
1211                }
1212            } else {
1213                providers.insert(name, provider);
1214            }
1215        }
1216        Ok(())
1217    }
1218
1219    fn record_predicate_declaration(
1220        declarations: &mut BTreeMap<String, ImportedPredicateDeclaration>,
1221        name: String,
1222        incoming: ImportedPredicateDeclaration,
1223    ) -> Result<(), ModuleError> {
1224        if let Some(existing) = declarations.get(&name) {
1225            if existing.schema != incoming.schema {
1226                return Err(ModuleError::IncompatiblePredicateDeclaration {
1227                    name,
1228                    module1: existing.module.clone(),
1229                    module2: incoming.module,
1230                });
1231            }
1232        } else {
1233            declarations.insert(name, incoming);
1234        }
1235        Ok(())
1236    }
1237
1238    fn normalized_type_ref(
1239        domains: &BTreeMap<String, ImportedDomainDeclaration>,
1240        typ: &TypeRef,
1241    ) -> TypeRef {
1242        match typ {
1243            TypeRef::Domain(name) => domains
1244                .get(name)
1245                .map(|domain| TypeRef::Scalar(domain.declaration.typ))
1246                .unwrap_or_else(|| typ.clone()),
1247            TypeRef::List(element) => {
1248                TypeRef::List(Box::new(Self::normalized_type_ref(domains, element)))
1249            }
1250            _ => typ.clone(),
1251        }
1252    }
1253
1254    fn predicate_declaration_schema(
1255        domains: &BTreeMap<String, ImportedDomainDeclaration>,
1256        declaration: &PredDecl,
1257    ) -> PredicateDeclarationSchema {
1258        PredicateDeclarationSchema {
1259            columns: declaration
1260                .schema_columns()
1261                .iter()
1262                .map(|column| {
1263                    (
1264                        column.name.clone(),
1265                        Self::normalized_type_ref(domains, &column.typ),
1266                    )
1267                })
1268                .collect(),
1269        }
1270    }
1271
1272    fn merge_predicate_declaration_map(
1273        declarations: &mut BTreeMap<String, ImportedPredicateDeclaration>,
1274        incoming: BTreeMap<String, ImportedPredicateDeclaration>,
1275    ) -> Result<(), ModuleError> {
1276        for (name, declaration) in incoming {
1277            Self::record_predicate_declaration(declarations, name, declaration)?;
1278        }
1279        Ok(())
1280    }
1281
1282    fn record_domain_declaration(
1283        declarations: &mut BTreeMap<String, ImportedDomainDeclaration>,
1284        name: String,
1285        incoming: ImportedDomainDeclaration,
1286    ) -> Result<(), ModuleError> {
1287        if let Some(existing) = declarations.get(&name) {
1288            if existing.declaration.typ != incoming.declaration.typ {
1289                return Err(ModuleError::IncompatibleDomainDeclaration {
1290                    name,
1291                    module1: existing.module.clone(),
1292                    module2: incoming.module,
1293                });
1294            }
1295        } else {
1296            declarations.insert(name, incoming);
1297        }
1298        Ok(())
1299    }
1300
1301    fn merge_domain_declaration_map(
1302        declarations: &mut BTreeMap<String, ImportedDomainDeclaration>,
1303        incoming: BTreeMap<String, ImportedDomainDeclaration>,
1304    ) -> Result<(), ModuleError> {
1305        for (name, declaration) in incoming {
1306            Self::record_domain_declaration(declarations, name, declaration)?;
1307        }
1308        Ok(())
1309    }
1310
1311    fn validate_unique_imported_functions(
1312        program: &Program,
1313        module_path: &[String],
1314    ) -> Result<(), ModuleError> {
1315        let mut counts = HashMap::<&str, usize>::new();
1316        for function in &program.functions {
1317            *counts.entry(&function.name).or_default() += 1;
1318        }
1319        for function in program
1320            .functions
1321            .iter()
1322            .filter(|function| !function.is_private)
1323        {
1324            if counts.get(function.name.as_str()).copied().unwrap_or(0) > 1 {
1325                return Err(ModuleError::DuplicateImportedFunction {
1326                    name: function.name.clone(),
1327                    module: module_path.to_vec(),
1328                });
1329            }
1330        }
1331        Ok(())
1332    }
1333
1334    fn validate_consistent_predicate_visibility(
1335        program: &Program,
1336        module_path: &[String],
1337    ) -> Result<(), ModuleError> {
1338        let mut visibility = HashMap::<&str, bool>::new();
1339        for declaration in &program.predicates {
1340            match visibility.get(declaration.name.as_str()) {
1341                Some(is_private) if *is_private != declaration.is_private => {
1342                    return Err(ModuleError::ConflictingPredicateVisibility {
1343                        name: declaration.name.clone(),
1344                        module: module_path.to_vec(),
1345                    });
1346                }
1347                Some(_) => {}
1348                None => {
1349                    visibility.insert(declaration.name.as_str(), declaration.is_private);
1350                }
1351            }
1352        }
1353        Ok(())
1354    }
1355
1356    fn import_providers_for_module(
1357        &self,
1358        source: &Path,
1359        module_path: &[String],
1360        imported_items: Option<&HashSet<String>>,
1361    ) -> Result<ImportProviders, ModuleError> {
1362        let loaded_module = self
1363            .loaded
1364            .get(source)
1365            .ok_or_else(|| ModuleError::NotFound {
1366                path: module_path.to_vec(),
1367                searched: vec![],
1368            })?;
1369        let nested_imports = self.imports_for_source(source);
1370
1371        Self::validate_consistent_predicate_visibility(&loaded_module.program, module_path)?;
1372        Self::validate_unique_imported_functions(&loaded_module.program, module_path)?;
1373        self.validate_resolved_imports(nested_imports)?;
1374        let mut providers = self.import_providers_from_imports(nested_imports)?;
1375        for declaration in &loaded_module.program.domains {
1376            Self::record_domain_declaration(
1377                &mut providers.domain_declarations,
1378                declaration.name.clone(),
1379                ImportedDomainDeclaration {
1380                    module: module_path.to_vec(),
1381                    declaration: declaration.clone(),
1382                },
1383            )?;
1384        }
1385        for declaration in loaded_module
1386            .program
1387            .predicates
1388            .iter()
1389            .filter(|declaration| {
1390                !declaration.is_private
1391                    && imported_items.is_none_or(|items| items.contains(&declaration.name))
1392            })
1393        {
1394            let schema =
1395                Self::predicate_declaration_schema(&providers.domain_declarations, declaration);
1396            Self::record_predicate_declaration(
1397                &mut providers.predicate_declarations,
1398                declaration.name.clone(),
1399                ImportedPredicateDeclaration {
1400                    module: module_path.to_vec(),
1401                    schema,
1402                },
1403            )?;
1404        }
1405        let excluded_predicates =
1406            Self::hidden_local_items(&loaded_module.program, imported_items).predicates;
1407        let local_contributions = Self::local_inferred_predicate_contributions(
1408            &loaded_module.program,
1409            &excluded_predicates,
1410            ImportProvider {
1411                module: module_path.to_vec(),
1412                source: source.to_path_buf(),
1413            },
1414        );
1415        Self::merge_inferred_predicate_contributions(
1416            &mut providers.inferred_predicate_contributions,
1417            local_contributions,
1418        );
1419        let mut local_functions = BTreeMap::new();
1420        for name in Self::visible_local_functions(&loaded_module.program, imported_items) {
1421            local_functions.insert(
1422                name,
1423                ImportProvider {
1424                    module: module_path.to_vec(),
1425                    source: source.to_path_buf(),
1426                },
1427            );
1428        }
1429        // Functions have one body. Keeping the earlier merged definition
1430        // would silently discard this module's body, so distinct providers
1431        // are an import conflict even within one branch.
1432        Self::merge_function_providers(&mut providers.functions, local_functions)?;
1433        Ok(providers)
1434    }
1435
1436    fn import_providers_from_imports(
1437        &self,
1438        imports: &[ResolvedImport],
1439    ) -> Result<ImportProviders, ModuleError> {
1440        let mut providers = ImportProviders::default();
1441        for group in Self::combined_import_selections(imports) {
1442            let incoming = self.import_providers_for_module(
1443                &group.source,
1444                &group.module_path,
1445                group.imported_items.as_ref(),
1446            )?;
1447            Self::merge_domain_declaration_map(
1448                &mut providers.domain_declarations,
1449                incoming.domain_declarations,
1450            )?;
1451            Self::merge_predicate_declaration_map(
1452                &mut providers.predicate_declarations,
1453                incoming.predicate_declarations,
1454            )?;
1455            Self::merge_inferred_predicate_contributions(
1456                &mut providers.inferred_predicate_contributions,
1457                incoming.inferred_predicate_contributions,
1458            );
1459            Self::merge_function_providers(&mut providers.functions, incoming.functions)?;
1460        }
1461        Ok(providers)
1462    }
1463
1464    fn provider_for_program(&self, program: &Program) -> ImportProvider {
1465        if let Some(entry) = &self.entry {
1466            if entry.program.imports == program.imports {
1467                return ImportProvider {
1468                    module: entry.path.clone(),
1469                    source: self
1470                        .entry_source
1471                        .clone()
1472                        .unwrap_or_else(|| entry.source_file.clone()),
1473                };
1474            }
1475        }
1476        if let Some(root_source) = &self.root_module {
1477            if let Some(root) = self.loaded.get(root_source) {
1478                if root.program.imports == program.imports {
1479                    return ImportProvider {
1480                        module: root.path.clone(),
1481                        source: root_source.clone(),
1482                    };
1483                }
1484            }
1485        }
1486        ImportProvider {
1487            module: vec!["<program>".to_string()],
1488            source: PathBuf::from("<program>"),
1489        }
1490    }
1491
1492    fn validate_program_against_imports(
1493        &self,
1494        program: &Program,
1495        imports: &[ResolvedImport],
1496    ) -> Result<(), ModuleError> {
1497        let program_provider = self.provider_for_program(program);
1498        let module_path = program_provider.module.clone();
1499        let mut providers = self.import_providers_from_imports(imports)?;
1500
1501        for declaration in &program.domains {
1502            Self::record_domain_declaration(
1503                &mut providers.domain_declarations,
1504                declaration.name.clone(),
1505                ImportedDomainDeclaration {
1506                    module: module_path.clone(),
1507                    declaration: declaration.clone(),
1508                },
1509            )?;
1510        }
1511        for declaration in &program.predicates {
1512            let schema =
1513                Self::predicate_declaration_schema(&providers.domain_declarations, declaration);
1514            Self::record_predicate_declaration(
1515                &mut providers.predicate_declarations,
1516                declaration.name.clone(),
1517                ImportedPredicateDeclaration {
1518                    module: module_path.clone(),
1519                    schema,
1520                },
1521            )?;
1522        }
1523        let entry_contributions = Self::local_inferred_predicate_contributions(
1524            program,
1525            &HashSet::new(),
1526            program_provider,
1527        );
1528        Self::merge_inferred_predicate_contributions(
1529            &mut providers.inferred_predicate_contributions,
1530            entry_contributions,
1531        );
1532        for function in &program.functions {
1533            if let Some(imported) = providers.functions.get(&function.name) {
1534                return Err(ModuleError::ImportConflict {
1535                    name: function.name.clone(),
1536                    module1: imported.module.clone(),
1537                    module2: module_path,
1538                });
1539            }
1540        }
1541
1542        Self::validate_inferred_predicate_contributions(&providers)?;
1543
1544        Ok(())
1545    }
1546
1547    fn import_scope_for_module(
1548        &self,
1549        source: &Path,
1550        module_path: &[String],
1551        imported_items: Option<&HashSet<String>>,
1552    ) -> Result<ImportScope, ModuleError> {
1553        let loaded_module = self
1554            .loaded
1555            .get(source)
1556            .ok_or_else(|| ModuleError::NotFound {
1557                path: module_path.to_vec(),
1558                searched: vec![],
1559            })?;
1560        let nested_imports = self.imports_for_source(source);
1561        let local_predicates = Self::local_predicate_names(&loaded_module.program);
1562        let local_functions = Self::local_function_names(&loaded_module.program);
1563        let local_hidden = Self::hidden_local_items(&loaded_module.program, imported_items);
1564        let mut scope = self.import_scope_from_imports(nested_imports)?;
1565        scope.visible.predicates.extend(
1566            local_predicates
1567                .difference(&local_hidden.predicates)
1568                .cloned(),
1569        );
1570        scope
1571            .visible
1572            .functions
1573            .extend(local_functions.difference(&local_hidden.functions).cloned());
1574        scope.hidden.predicates.extend(local_hidden.predicates);
1575        scope.hidden.functions.extend(local_hidden.functions);
1576        // A visible provider in this module's import closure satisfies the
1577        // name even when another provider keeps an item with that name hidden.
1578        scope
1579            .hidden
1580            .predicates
1581            .retain(|name| !scope.visible.predicates.contains(name));
1582        scope
1583            .hidden
1584            .functions
1585            .retain(|name| !scope.visible.functions.contains(name));
1586        Ok(scope)
1587    }
1588
1589    fn import_scope_from_imports(
1590        &self,
1591        imports: &[ResolvedImport],
1592    ) -> Result<ImportScope, ModuleError> {
1593        let mut scope = ImportScope::default();
1594        for group in Self::combined_import_selections(imports) {
1595            let imported_scope = self.import_scope_for_module(
1596                &group.source,
1597                &group.module_path,
1598                group.imported_items.as_ref(),
1599            )?;
1600            scope
1601                .visible
1602                .predicates
1603                .extend(imported_scope.visible.predicates);
1604            scope
1605                .visible
1606                .functions
1607                .extend(imported_scope.visible.functions);
1608            scope
1609                .hidden
1610                .predicates
1611                .extend(imported_scope.hidden.predicates);
1612            scope
1613                .hidden
1614                .functions
1615                .extend(imported_scope.hidden.functions);
1616        }
1617        scope
1618            .hidden
1619            .predicates
1620            .retain(|name| !scope.visible.predicates.contains(name));
1621        scope
1622            .hidden
1623            .functions
1624            .retain(|name| !scope.visible.functions.contains(name));
1625        Ok(scope)
1626    }
1627
1628    fn imports_for_source(&self, source: &Path) -> &[ResolvedImport] {
1629        self.resolved_imports
1630            .get(source)
1631            .map(Vec::as_slice)
1632            .unwrap_or(&[])
1633    }
1634
1635    fn validate_visible_dependencies(
1636        program: &Program,
1637        imported_items: Option<&HashSet<String>>,
1638        imported_scope: &ImportScope,
1639        module_path: &[String],
1640    ) -> Result<(), ModuleError> {
1641        let local_predicates = Self::local_predicate_names(program);
1642        let local_functions = Self::local_function_names(program);
1643        let local_hidden = Self::hidden_local_items(program, imported_items);
1644        let mut hidden_predicates = local_hidden.predicates.clone();
1645        hidden_predicates.extend(
1646            imported_scope
1647                .hidden
1648                .predicates
1649                .difference(&local_predicates)
1650                .cloned(),
1651        );
1652        let mut hidden_functions = local_hidden.functions.clone();
1653        hidden_functions.extend(
1654            imported_scope
1655                .hidden
1656                .functions
1657                .difference(&local_functions)
1658                .cloned(),
1659        );
1660
1661        for rule in &program.rules {
1662            if local_hidden.predicates.contains(&rule.head.predicate) {
1663                continue;
1664            }
1665            let mut predicate_dependencies = HashSet::new();
1666            let mut function_dependencies = HashSet::new();
1667            collect_body_dependencies(
1668                &rule.body,
1669                &mut predicate_dependencies,
1670                &mut function_dependencies,
1671            );
1672            let mut hidden_dependencies = predicate_dependencies
1673                .intersection(&hidden_predicates)
1674                .chain(function_dependencies.intersection(&hidden_functions))
1675                .cloned()
1676                .collect::<Vec<_>>();
1677            hidden_dependencies.sort();
1678            if let Some(dependency) = hidden_dependencies.into_iter().next() {
1679                return Err(ModuleError::HiddenDependency {
1680                    module: module_path.to_vec(),
1681                    export: rule.head.predicate.clone(),
1682                    dependency,
1683                });
1684            }
1685        }
1686
1687        for function in &program.functions {
1688            if local_hidden.functions.contains(&function.name) {
1689                continue;
1690            }
1691            let mut predicate_dependencies = HashSet::new();
1692            let mut function_dependencies = HashSet::new();
1693            collect_function_body_dependencies(
1694                &function.body,
1695                &mut predicate_dependencies,
1696                &mut function_dependencies,
1697            );
1698            let mut hidden_dependencies = predicate_dependencies
1699                .intersection(&hidden_predicates)
1700                .chain(function_dependencies.intersection(&hidden_functions))
1701                .cloned()
1702                .collect::<Vec<_>>();
1703            hidden_dependencies.sort();
1704            if let Some(dependency) = hidden_dependencies.into_iter().next() {
1705                return Err(ModuleError::HiddenDependency {
1706                    module: module_path.to_vec(),
1707                    export: function.name.clone(),
1708                    dependency,
1709                });
1710            }
1711        }
1712
1713        Ok(())
1714    }
1715
1716    fn validate_supported_import_content(
1717        program: &Program,
1718        module_path: &[String],
1719    ) -> Result<(), ModuleError> {
1720        let Program {
1721            imports: _,
1722            functions: _,
1723            domains: _,
1724            predicates: _,
1725            rules: _,
1726            constraints,
1727            authored_constraint_source_bound: _,
1728            queries: _,
1729            prob_facts,
1730            annotated_disjunctions,
1731            evidence,
1732            prob_queries: _,
1733            neural_predicates,
1734            learnable_rules,
1735            directives: _,
1736        } = program;
1737
1738        let mut constructs = Vec::new();
1739        if !prob_facts.is_empty() {
1740            constructs.push("probabilistic facts".to_string());
1741        }
1742        if !annotated_disjunctions.is_empty() {
1743            constructs.push("annotated disjunctions".to_string());
1744        }
1745        if !evidence.is_empty() {
1746            constructs.push("evidence statements".to_string());
1747        }
1748        if !constraints.is_empty() {
1749            constructs.push("integrity constraints".to_string());
1750        }
1751        if !neural_predicates.is_empty() {
1752            constructs.push("neural predicate declarations".to_string());
1753        }
1754        if !learnable_rules.is_empty() {
1755            constructs.push("learnable rule templates".to_string());
1756        }
1757        constructs.sort();
1758
1759        if constructs.is_empty() {
1760            Ok(())
1761        } else {
1762            Err(ModuleError::UnsupportedImportedContent {
1763                module: module_path.to_vec(),
1764                constructs,
1765            })
1766        }
1767    }
1768
1769    fn import_merge_key(source: &Path, imported_items: Option<&HashSet<String>>) -> ImportMergeKey {
1770        let imported_items = imported_items.map(|items| {
1771            let mut sorted = items.iter().cloned().collect::<Vec<_>>();
1772            sorted.sort();
1773            sorted
1774        });
1775        ImportMergeKey {
1776            source: source.to_path_buf(),
1777            imported_items,
1778        }
1779    }
1780
1781    fn merge_import_group(
1782        &self,
1783        program: &mut Program,
1784        imports: &[ResolvedImport],
1785        merged_imports: &mut HashSet<ImportMergeKey>,
1786    ) -> Result<(), ModuleError> {
1787        for group in Self::combined_import_selections(imports) {
1788            let loaded_module =
1789                self.loaded
1790                    .get(&group.source)
1791                    .ok_or_else(|| ModuleError::NotFound {
1792                        path: group.module_path.clone(),
1793                        searched: vec![],
1794                    })?;
1795            let nested_imports = self.imports_for_source(&group.source);
1796
1797            self.merge_import_group(program, nested_imports, merged_imports)?;
1798
1799            let imported_scope = self.import_scope_from_imports(nested_imports)?;
1800            Self::validate_supported_import_content(&loaded_module.program, &group.module_path)?;
1801            Self::validate_visible_dependencies(
1802                &loaded_module.program,
1803                group.imported_items.as_ref(),
1804                &imported_scope,
1805                &group.module_path,
1806            )?;
1807            let merge_key = Self::import_merge_key(&group.source, group.imported_items.as_ref());
1808            if merged_imports.insert(merge_key) {
1809                program.merge_from(&loaded_module.program, group.imported_items.as_ref());
1810            }
1811        }
1812        Ok(())
1813    }
1814
1815    /// Merge supported deterministic content from every resolved import.
1816    ///
1817    /// Resolution follows the importer-scoped edges recorded by the matching
1818    /// entry file or root module. Without that anchor, every logical path must
1819    /// identify one loaded source. Imported entry-only content, incomplete
1820    /// exports, incompatible participating declarations, conflicting inferred
1821    /// head-column types for undeclared predicate signatures, and conflicting
1822    /// function definitions are rejected rather than silently omitted. Selected
1823    /// public predicate clauses from separate import branches are then merged
1824    /// into one relation.
1825    ///
1826    /// # Arguments
1827    /// * `program` - The main program with imports to resolve
1828    ///
1829    /// # Returns
1830    /// The program with all imports merged in
1831    pub fn merge_imports(&self, mut program: Program) -> Result<Program, ModuleError> {
1832        let imports = self.resolved_imports_for_program(&program)?;
1833        self.validate_resolved_imports(&imports)?;
1834        self.validate_program_against_imports(&program, &imports)?;
1835        let entry_rules = std::mem::take(&mut program.rules);
1836        let mut merged_imports = HashSet::new();
1837        self.merge_import_group(&mut program, &imports, &mut merged_imports)?;
1838        program.rules.extend(entry_rules);
1839
1840        Ok(program)
1841    }
1842}
1843
1844#[cfg(test)]
1845mod tests {
1846    use super::*;
1847    use std::io::Write;
1848    use tempfile::TempDir;
1849
1850    fn create_test_module(dir: &Path, name: &str, content: &str) -> PathBuf {
1851        let path = dir.join(format!("{}.xlog", name));
1852        let mut file = fs::File::create(&path).unwrap();
1853        file.write_all(content.as_bytes()).unwrap();
1854        path
1855    }
1856
1857    #[test]
1858    fn test_find_module_file() {
1859        let tmp = TempDir::new().unwrap();
1860        create_test_module(tmp.path(), "graph", "edge(1, 2).");
1861
1862        let resolver = ModuleResolver::new(vec![]);
1863        let found = resolver.find_module_file(tmp.path(), &["graph".into()]);
1864        assert!(found.is_some());
1865    }
1866
1867    #[test]
1868    fn test_load_entry_file_uses_supplied_path() {
1869        let tmp = TempDir::new().unwrap();
1870        create_test_module(tmp.path(), "helper", "helper_fact(1).");
1871        let entry = tmp.path().join("program.datalog");
1872        fs::write(&entry, "use helper.\nentry_fact(1).\n").unwrap();
1873
1874        let mut resolver = ModuleResolver::new(vec![]);
1875        let loaded = resolver.load_entry_file(&entry).unwrap();
1876
1877        assert_eq!(loaded.source_file, entry);
1878        assert_eq!(loaded.path, vec!["program"]);
1879        assert_eq!(resolver.entry().unwrap().source_file, entry);
1880        assert!(resolver.is_loaded("helper"));
1881    }
1882
1883    #[test]
1884    fn test_load_entry_file_distinguishes_same_stem_import() {
1885        let tmp = TempDir::new().unwrap();
1886        create_test_module(tmp.path(), "main", "imported_fact(1).");
1887        let entry = tmp.path().join("main.datalog");
1888        let entry_source = "use main.\nentry_fact(1).\n";
1889        fs::write(&entry, entry_source).unwrap();
1890
1891        let mut resolver = ModuleResolver::new(vec![]);
1892        let loaded = resolver.load_entry_file(&entry).unwrap();
1893
1894        assert_eq!(loaded.source_file, entry);
1895        assert_eq!(
1896            resolver
1897                .get_module(&["main".into()])
1898                .expect("same-stem import should be loaded")
1899                .source_file,
1900            tmp.path().join("main.xlog")
1901        );
1902        let merged = resolver
1903            .merge_imports(parse_program(entry_source).unwrap())
1904            .unwrap();
1905        assert!(merged
1906            .rules
1907            .iter()
1908            .any(|rule| rule.head.predicate == "imported_fact"));
1909    }
1910
1911    #[test]
1912    fn test_module_not_found() {
1913        let tmp = TempDir::new().unwrap();
1914        let mut resolver = ModuleResolver::new(vec![]);
1915
1916        let result = resolver.load_module(tmp.path(), &["nonexistent".into()]);
1917        assert!(matches!(result, Err(ModuleError::NotFound { .. })));
1918    }
1919
1920    #[test]
1921    fn merge_imports_returns_not_found_for_unloaded_module() {
1922        let resolver = ModuleResolver::new(vec![]);
1923        let program = parse_program("use nonexistent.").unwrap();
1924
1925        let result = resolver.merge_imports(program);
1926
1927        assert!(matches!(
1928            result,
1929            Err(ModuleError::NotFound { path, searched })
1930                if path == vec!["nonexistent"] && searched.is_empty()
1931        ));
1932    }
1933
1934    #[test]
1935    fn merge_imports_unions_compatible_predicates_from_separate_modules() {
1936        let tmp = TempDir::new().unwrap();
1937        create_test_module(tmp.path(), "first", "pred shared(u32). shared(1).");
1938        create_test_module(tmp.path(), "second", "pred shared(u32). shared(2).");
1939        let entry_source = "use first.\nuse second.\n";
1940        create_test_module(tmp.path(), "entry", entry_source);
1941
1942        let mut resolver = ModuleResolver::new(vec![]);
1943        resolver.load_module(tmp.path(), &["entry".into()]).unwrap();
1944
1945        let (predicate_imports, function_imports) = resolver
1946            .validate_imports(&parse_program(entry_source).unwrap())
1947            .unwrap();
1948        assert_eq!(predicate_imports.get("shared"), Some(&vec!["first".into()]));
1949        assert!(function_imports.is_empty());
1950
1951        let merged = resolver
1952            .merge_imports(parse_program(entry_source).unwrap())
1953            .unwrap();
1954        let shared_facts = merged
1955            .rules
1956            .iter()
1957            .filter(|rule| rule.head.predicate == "shared" && rule.is_fact())
1958            .collect::<Vec<_>>();
1959
1960        assert_eq!(shared_facts.len(), 2);
1961        assert!(shared_facts
1962            .iter()
1963            .any(|rule| rule.head.terms == vec![Term::Integer(1)]));
1964        assert!(shared_facts
1965            .iter()
1966            .any(|rule| rule.head.terms == vec![Term::Integer(2)]));
1967        assert_eq!(
1968            merged
1969                .predicates
1970                .iter()
1971                .filter(|declaration| declaration.name == "shared")
1972                .count(),
1973            1
1974        );
1975    }
1976
1977    #[test]
1978    fn merge_imports_unions_compatible_undeclared_predicates() {
1979        let tmp = TempDir::new().unwrap();
1980        create_test_module(tmp.path(), "first", "shared(from_first).");
1981        create_test_module(tmp.path(), "second", "shared(from_second).");
1982        let entry_source = "use first.\nuse second.\n";
1983        create_test_module(tmp.path(), "entry", entry_source);
1984
1985        let mut resolver = ModuleResolver::new(vec![]);
1986        resolver.load_module(tmp.path(), &["entry".into()]).unwrap();
1987
1988        let merged = resolver
1989            .merge_imports(parse_program(entry_source).unwrap())
1990            .unwrap();
1991        let shared_facts = merged
1992            .rules
1993            .iter()
1994            .filter(|rule| rule.head.predicate == "shared" && rule.is_fact())
1995            .collect::<Vec<_>>();
1996
1997        assert_eq!(shared_facts.len(), 2);
1998        assert!(shared_facts
1999            .iter()
2000            .any(|rule| rule.head.terms
2001                == vec![Term::Symbol(xlog_core::symbol::intern("from_first"))]));
2002        assert!(shared_facts
2003            .iter()
2004            .any(|rule| rule.head.terms
2005                == vec![Term::Symbol(xlog_core::symbol::intern("from_second"))]));
2006        assert!(!merged
2007            .predicates
2008            .iter()
2009            .any(|declaration| declaration.name == "shared"));
2010    }
2011
2012    #[test]
2013    fn merge_import_validation_keeps_undeclared_predicate_arities_distinct() {
2014        let tmp = TempDir::new().unwrap();
2015        create_test_module(tmp.path(), "unary", "shared(1).");
2016        create_test_module(tmp.path(), "binary", "shared(one, two).");
2017        let entry_source = "use unary.\nuse binary.\n";
2018        create_test_module(tmp.path(), "entry", entry_source);
2019
2020        let mut resolver = ModuleResolver::new(vec![]);
2021        resolver.load_module(tmp.path(), &["entry".into()]).unwrap();
2022
2023        let merged = resolver
2024            .merge_imports(parse_program(entry_source).unwrap())
2025            .unwrap();
2026        let arities = merged
2027            .rules
2028            .iter()
2029            .filter(|rule| rule.head.predicate == "shared")
2030            .map(|rule| rule.head.arity())
2031            .collect::<Vec<_>>();
2032
2033        assert_eq!(arities, vec![1, 2]);
2034    }
2035
2036    #[test]
2037    fn merge_imports_rejects_incompatible_undeclared_predicate_schemas() {
2038        let tmp = TempDir::new().unwrap();
2039        create_test_module(tmp.path(), "first", "shared(row, 1).");
2040        create_test_module(tmp.path(), "second", "shared(row, from_second).");
2041        let entry_source = "use first.\nuse second.\n";
2042        create_test_module(tmp.path(), "entry", entry_source);
2043
2044        let mut resolver = ModuleResolver::new(vec![]);
2045        resolver.load_module(tmp.path(), &["entry".into()]).unwrap();
2046
2047        let program = parse_program(entry_source).unwrap();
2048        let errors = [
2049            resolver
2050                .validate_imports(&program)
2051                .expect_err("validation must reject different inferred column types"),
2052            resolver
2053                .merge_imports(program)
2054                .expect_err("merge must reject different inferred column types"),
2055        ];
2056
2057        for error in errors {
2058            let message = error.to_string();
2059            assert!(message.contains("error[E0412]"), "{message}");
2060            assert!(message.contains("shared/2"), "{message}");
2061            assert!(message.contains("column 2"), "{message}");
2062            assert!(message.contains("first"), "{message}");
2063            assert!(message.contains("second"), "{message}");
2064            assert!(message.contains("u32"), "{message}");
2065            assert!(message.contains("symbol"), "{message}");
2066        }
2067    }
2068
2069    #[test]
2070    fn merge_imports_rejects_numeric_inference_conflicts_in_either_import_order() {
2071        let tmp = TempDir::new().unwrap();
2072        create_test_module(tmp.path(), "small", "shared(1).");
2073        create_test_module(tmp.path(), "wide", "shared(5000000000).");
2074
2075        for (entry_name, entry_source) in [
2076            ("small_first", "use small.\nuse wide.\n"),
2077            ("wide_first", "use wide.\nuse small.\n"),
2078        ] {
2079            create_test_module(tmp.path(), entry_name, entry_source);
2080            let mut resolver = ModuleResolver::new(vec![]);
2081            resolver
2082                .load_module(tmp.path(), &[entry_name.into()])
2083                .unwrap();
2084
2085            let error = resolver
2086                .merge_imports(parse_program(entry_source).unwrap())
2087                .expect_err("numeric inference must not depend on import order");
2088            let message = error.to_string();
2089
2090            assert!(message.contains("error[E0412]"), "{message}");
2091            assert!(message.contains("shared/1"), "{message}");
2092            assert!(message.contains("module `small`"), "{message}");
2093            assert!(message.contains("module `wide`"), "{message}");
2094            assert!(message.contains("u32"), "{message}");
2095            assert!(message.contains("i64"), "{message}");
2096        }
2097    }
2098
2099    #[test]
2100    fn merge_imports_uses_an_explicit_schema_for_undeclared_contributions() {
2101        let tmp = TempDir::new().unwrap();
2102        create_test_module(tmp.path(), "small", "shared(1).");
2103        create_test_module(tmp.path(), "wide", "shared(5000000000).");
2104        let entry_source = "use small.\nuse wide.\npred shared(i64).\n";
2105        create_test_module(tmp.path(), "entry", entry_source);
2106
2107        let mut resolver = ModuleResolver::new(vec![]);
2108        resolver.load_module(tmp.path(), &["entry".into()]).unwrap();
2109
2110        let merged = resolver
2111            .merge_imports(parse_program(entry_source).unwrap())
2112            .expect("the explicit schema controls both imported facts");
2113
2114        assert_eq!(
2115            merged
2116                .rules
2117                .iter()
2118                .filter(|rule| rule.head.predicate == "shared")
2119                .count(),
2120            2
2121        );
2122    }
2123
2124    #[test]
2125    fn merge_imports_rejects_transitive_inferred_schema_conflicts() {
2126        let tmp = TempDir::new().unwrap();
2127        create_test_module(tmp.path(), "left_provider", "shared(1).");
2128        create_test_module(tmp.path(), "left_wrapper", "use left_provider.\n");
2129        create_test_module(tmp.path(), "right_provider", "shared(from_right).");
2130        create_test_module(tmp.path(), "right_wrapper", "use right_provider.\n");
2131        let entry_source = "use left_wrapper.\nuse right_wrapper.\n";
2132        create_test_module(tmp.path(), "entry", entry_source);
2133
2134        let mut resolver = ModuleResolver::new(vec![]);
2135        resolver.load_module(tmp.path(), &["entry".into()]).unwrap();
2136
2137        let error = resolver
2138            .merge_imports(parse_program(entry_source).unwrap())
2139            .expect_err("transitive contributions must be schema-compatible");
2140        let message = error.to_string();
2141
2142        assert!(message.contains("error[E0412]"), "{message}");
2143        assert!(message.contains("shared/1"), "{message}");
2144        assert!(message.contains("left_provider"), "{message}");
2145        assert!(message.contains("right_provider"), "{message}");
2146    }
2147
2148    #[test]
2149    fn merge_imports_rejects_constant_head_rule_schema_conflicts() {
2150        let tmp = TempDir::new().unwrap();
2151        create_test_module(
2152            tmp.path(),
2153            "first",
2154            "first_source(ready).\nshared(1) :- first_source(ready).",
2155        );
2156        create_test_module(
2157            tmp.path(),
2158            "second",
2159            "second_source(ready).\nshared(from_second) :- second_source(ready).",
2160        );
2161        let entry_source = "use first.\nuse second.\n";
2162        create_test_module(tmp.path(), "entry", entry_source);
2163
2164        let mut resolver = ModuleResolver::new(vec![]);
2165        resolver.load_module(tmp.path(), &["entry".into()]).unwrap();
2166
2167        let error = resolver
2168            .merge_imports(parse_program(entry_source).unwrap())
2169            .expect_err("known constant head types must agree across modules");
2170
2171        assert!(error.to_string().contains("error[E0412]"));
2172    }
2173
2174    #[test]
2175    fn merge_imports_rejects_body_inferred_rule_head_conflicts() {
2176        let tmp = TempDir::new().unwrap();
2177        create_test_module(
2178            tmp.path(),
2179            "first",
2180            "first_source(1).\nshared(X) :- first_source(X).",
2181        );
2182        create_test_module(
2183            tmp.path(),
2184            "second",
2185            "second_source(from_second).\nshared(X) :- second_source(X).",
2186        );
2187        let entry_source = "use first.\nuse second.\n";
2188        create_test_module(tmp.path(), "entry", entry_source);
2189
2190        let mut resolver = ModuleResolver::new(vec![]);
2191        resolver.load_module(tmp.path(), &["entry".into()]).unwrap();
2192
2193        let error = resolver
2194            .merge_imports(parse_program(entry_source).unwrap())
2195            .expect_err("body-derived head types must agree across modules");
2196
2197        let message = error.to_string();
2198        assert!(message.contains("error[E0412]"), "{message}");
2199        assert!(message.contains("shared/1"), "{message}");
2200        assert!(message.contains("u32"), "{message}");
2201        assert!(message.contains("symbol"), "{message}");
2202    }
2203
2204    #[test]
2205    fn merge_imports_rejects_arithmetic_inferred_rule_head_conflicts() {
2206        let tmp = TempDir::new().unwrap();
2207        create_test_module(tmp.path(), "first", "shared(X) :- X is cast(1, u32).");
2208        create_test_module(tmp.path(), "second", "shared(X) :- X is cast(1, u64).");
2209        let entry_source = "use first.\nuse second.\n";
2210        create_test_module(tmp.path(), "entry", entry_source);
2211
2212        let mut resolver = ModuleResolver::new(vec![]);
2213        resolver.load_module(tmp.path(), &["entry".into()]).unwrap();
2214
2215        let error = resolver
2216            .merge_imports(parse_program(entry_source).unwrap())
2217            .expect_err("arithmetic-derived head types must agree across modules");
2218        let message = error.to_string();
2219
2220        assert!(message.contains("error[E0412]"), "{message}");
2221        assert!(message.contains("shared/1"), "{message}");
2222        assert!(message.contains("module `first`"), "{message}");
2223        assert!(message.contains("module `second`"), "{message}");
2224        assert!(message.contains("u32"), "{message}");
2225        assert!(message.contains("u64"), "{message}");
2226    }
2227
2228    #[test]
2229    fn merge_imports_rejects_aggregate_inferred_rule_head_conflicts() {
2230        let tmp = TempDir::new().unwrap();
2231        create_test_module(
2232            tmp.path(),
2233            "first",
2234            "first_source(1).\nshared(min(X)) :- first_source(X).",
2235        );
2236        create_test_module(
2237            tmp.path(),
2238            "second",
2239            "second_source(1.0).\nshared(logsumexp(X)) :- second_source(X).",
2240        );
2241        let entry_source = "use first.\nuse second.\n";
2242        create_test_module(tmp.path(), "entry", entry_source);
2243
2244        let mut resolver = ModuleResolver::new(vec![]);
2245        resolver.load_module(tmp.path(), &["entry".into()]).unwrap();
2246
2247        let error = resolver
2248            .merge_imports(parse_program(entry_source).unwrap())
2249            .expect_err("aggregate-derived head types must agree across modules");
2250        let message = error.to_string();
2251
2252        assert!(message.contains("error[E0412]"), "{message}");
2253        assert!(message.contains("shared/1"), "{message}");
2254        assert!(message.contains("module `first`"), "{message}");
2255        assert!(message.contains("module `second`"), "{message}");
2256        assert!(message.contains("u32"), "{message}");
2257        assert!(message.contains("f64"), "{message}");
2258    }
2259
2260    #[test]
2261    fn merge_imports_attributes_invalid_schema_inference_to_its_module() {
2262        let tmp = TempDir::new().unwrap();
2263        create_test_module(
2264            tmp.path(),
2265            "library",
2266            "shared(X) :- X is cast(1, u32) + cast(1, u64).",
2267        );
2268        let entry_source = "use library.\n";
2269        create_test_module(tmp.path(), "entry", entry_source);
2270
2271        let mut resolver = ModuleResolver::new(vec![]);
2272        resolver.load_module(tmp.path(), &["entry".into()]).unwrap();
2273
2274        let error = resolver
2275            .merge_imports(parse_program(entry_source).unwrap())
2276            .expect_err("invalid arithmetic evidence must fail during schema inference");
2277        let message = error.to_string();
2278
2279        assert!(message.contains("error[E0413]"), "{message}");
2280        assert!(message.contains("shared/1"), "{message}");
2281        assert!(message.contains("module `library`"), "{message}");
2282        assert!(message.contains("Type mismatch in arithmetic"), "{message}");
2283    }
2284
2285    #[test]
2286    fn merge_imports_defers_user_function_schema_evidence_until_expansion() {
2287        let tmp = TempDir::new().unwrap();
2288        create_test_module(
2289            tmp.path(),
2290            "first",
2291            "func first_value(X) = cast(X, u32).\nshared(X) :- X is first_value(1).",
2292        );
2293        create_test_module(
2294            tmp.path(),
2295            "second",
2296            "func second_value(X) = cast(X, u64).\nshared(X) :- X is second_value(1).",
2297        );
2298        let entry_source = "use first.\nuse second.\n";
2299        create_test_module(tmp.path(), "entry", entry_source);
2300
2301        let mut resolver = ModuleResolver::new(vec![]);
2302        resolver.load_module(tmp.path(), &["entry".into()]).unwrap();
2303
2304        let merged = resolver
2305            .merge_imports(parse_program(entry_source).unwrap())
2306            .expect("user-defined calls are expanded after module resolution");
2307
2308        assert_eq!(
2309            merged
2310                .rules
2311                .iter()
2312                .filter(|rule| rule.head.predicate == "shared")
2313                .count(),
2314            2
2315        );
2316    }
2317
2318    #[test]
2319    fn merge_imports_retains_independent_schema_evidence_beside_user_functions() {
2320        let tmp = TempDir::new().unwrap();
2321        create_test_module(
2322            tmp.path(),
2323            "first",
2324            "func first_value(X) = cast(X, u32).\nshared(1, X) :- X is first_value(1).",
2325        );
2326        create_test_module(
2327            tmp.path(),
2328            "second",
2329            "func second_value(X) = cast(X, u32).\nshared(from_second, X) :- X is second_value(1).",
2330        );
2331        let entry_source = "use first.\nuse second.\n";
2332        create_test_module(tmp.path(), "entry", entry_source);
2333
2334        let mut resolver = ModuleResolver::new(vec![]);
2335        resolver.load_module(tmp.path(), &["entry".into()]).unwrap();
2336
2337        let error = resolver
2338            .merge_imports(parse_program(entry_source).unwrap())
2339            .expect_err("a user function must not hide independent head-column evidence");
2340        let message = error.to_string();
2341
2342        assert!(message.contains("error[E0412]"), "{message}");
2343        assert!(message.contains("shared/2"), "{message}");
2344        assert!(message.contains("module `first`"), "{message}");
2345        assert!(message.contains("module `second`"), "{message}");
2346        assert!(message.contains("u32"), "{message}");
2347        assert!(message.contains("symbol"), "{message}");
2348    }
2349
2350    #[test]
2351    fn merge_imports_reports_invalid_builtin_evidence_beside_user_functions() {
2352        let tmp = TempDir::new().unwrap();
2353        create_test_module(
2354            tmp.path(),
2355            "library",
2356            "func value(X) = cast(X, u32).\nshared(X, Y) :- X is value(1), Y is cast(1, u32) + cast(1, u64).",
2357        );
2358        let entry_source = "use library.\n";
2359        create_test_module(tmp.path(), "entry", entry_source);
2360
2361        let mut resolver = ModuleResolver::new(vec![]);
2362        resolver.load_module(tmp.path(), &["entry".into()]).unwrap();
2363
2364        let error = resolver
2365            .merge_imports(parse_program(entry_source).unwrap())
2366            .expect_err("a user function must not hide invalid built-in arithmetic evidence");
2367        let message = error.to_string();
2368
2369        assert!(message.contains("error[E0413]"), "{message}");
2370        assert!(message.contains("shared/2"), "{message}");
2371        assert!(message.contains("module `library`"), "{message}");
2372        assert!(message.contains("Type mismatch in arithmetic"), "{message}");
2373    }
2374
2375    #[test]
2376    fn merge_imports_reports_invalid_builtin_subexpressions_beside_user_functions() {
2377        let tmp = TempDir::new().unwrap();
2378        create_test_module(
2379            tmp.path(),
2380            "library",
2381            "func value(X) = cast(X, u32).\nshared(Y) :- Y is value(1) + (cast(1, u32) + cast(1, u64)).",
2382        );
2383        let entry_source = "use library.\n";
2384        create_test_module(tmp.path(), "entry", entry_source);
2385
2386        let mut resolver = ModuleResolver::new(vec![]);
2387        resolver.load_module(tmp.path(), &["entry".into()]).unwrap();
2388
2389        let error = resolver
2390            .merge_imports(parse_program(entry_source).unwrap())
2391            .expect_err("an unknown user-function operand must not hide an invalid sibling");
2392        let message = error.to_string();
2393
2394        assert!(message.contains("error[E0413]"), "{message}");
2395        assert!(message.contains("shared/1"), "{message}");
2396        assert!(message.contains("module `library`"), "{message}");
2397        assert!(message.contains("Type mismatch in arithmetic"), "{message}");
2398    }
2399
2400    #[test]
2401    fn merge_imports_reports_invalid_user_function_argument_evidence() {
2402        let tmp = TempDir::new().unwrap();
2403        create_test_module(
2404            tmp.path(),
2405            "library",
2406            "func value(X) = cast(X, u32).\nshared(Y) :- Y is value(cast(1, u32) + cast(1, u64)).",
2407        );
2408        let entry_source = "use library.\n";
2409        create_test_module(tmp.path(), "entry", entry_source);
2410
2411        let mut resolver = ModuleResolver::new(vec![]);
2412        resolver.load_module(tmp.path(), &["entry".into()]).unwrap();
2413
2414        let error = resolver
2415            .merge_imports(parse_program(entry_source).unwrap())
2416            .expect_err("invalid built-in arithmetic inside a user-function argument must fail");
2417        let message = error.to_string();
2418
2419        assert!(message.contains("error[E0413]"), "{message}");
2420        assert!(message.contains("shared/1"), "{message}");
2421        assert!(message.contains("module `library`"), "{message}");
2422        assert!(message.contains("Type mismatch in arithmetic"), "{message}");
2423    }
2424
2425    #[test]
2426    fn merge_imports_reports_invalid_partial_arithmetic_evidence() {
2427        let cases = [
2428            (
2429                "symbols(foo).\nshared(Y) :- symbols(S), Y is value(1) + S.",
2430                "Arithmetic requires numeric type",
2431            ),
2432            (
2433                "floats(1.0).\nshared(Y) :- floats(F), Y is value(1) % F.",
2434                "Modulo (%) not supported for floating point",
2435            ),
2436            (
2437                "symbols(foo).\nshared(Y) :- symbols(S), Y is value(1) % S.",
2438                "Modulo (%) requires integer operands",
2439            ),
2440        ];
2441
2442        for (body, expected_detail) in cases {
2443            let tmp = TempDir::new().unwrap();
2444            create_test_module(
2445                tmp.path(),
2446                "library",
2447                &format!("func value(X) = cast(X, u32).\n{body}"),
2448            );
2449            let entry_source = "use library.\n";
2450            create_test_module(tmp.path(), "entry", entry_source);
2451
2452            let mut resolver = ModuleResolver::new(vec![]);
2453            resolver.load_module(tmp.path(), &["entry".into()]).unwrap();
2454
2455            let error = resolver
2456                .merge_imports(parse_program(entry_source).unwrap())
2457                .expect_err("a deferred operand must not hide an invalid known operand");
2458            let message = error.to_string();
2459
2460            assert!(message.contains("error[E0413]"), "{message}");
2461            assert!(message.contains("shared/1"), "{message}");
2462            assert!(message.contains("module `library`"), "{message}");
2463            assert!(message.contains(expected_detail), "{message}");
2464        }
2465    }
2466
2467    #[test]
2468    fn merge_imports_propagates_signature_types_through_rule_chains() {
2469        let tmp = TempDir::new().unwrap();
2470        create_test_module(
2471            tmp.path(),
2472            "first",
2473            "shared(X) :- first_mid(X).\nfirst_mid(X) :- z_typed(X).\nz_typed(1).",
2474        );
2475        create_test_module(
2476            tmp.path(),
2477            "second",
2478            "shared(X) :- second_mid(X).\nsecond_mid(X) :- z_typed(prefix, X).\nz_typed(prefix, from_second).",
2479        );
2480        let entry_source = "use first.\nuse second.\n";
2481        create_test_module(tmp.path(), "entry", entry_source);
2482
2483        let mut resolver = ModuleResolver::new(vec![]);
2484        resolver.load_module(tmp.path(), &["entry".into()]).unwrap();
2485
2486        let error = resolver
2487            .merge_imports(parse_program(entry_source).unwrap())
2488            .expect_err("transitive body-derived types must agree across modules");
2489        let message = error.to_string();
2490
2491        assert!(message.contains("error[E0412]"), "{message}");
2492        assert!(message.contains("shared/1"), "{message}");
2493        assert!(message.contains("u32"), "{message}");
2494        assert!(message.contains("symbol"), "{message}");
2495    }
2496
2497    #[test]
2498    fn merge_imports_leaves_unanchored_rule_head_variables_unconstrained() {
2499        let tmp = TempDir::new().unwrap();
2500        create_test_module(
2501            tmp.path(),
2502            "first",
2503            "first_cycle(X) :- first_cycle(X).\nshared(X) :- first_cycle(X).",
2504        );
2505        create_test_module(
2506            tmp.path(),
2507            "second",
2508            "second_cycle(X) :- second_cycle(X).\nshared(X) :- second_cycle(X).",
2509        );
2510        let entry_source = "use first.\nuse second.\n";
2511        create_test_module(tmp.path(), "entry", entry_source);
2512
2513        let mut resolver = ModuleResolver::new(vec![]);
2514        resolver.load_module(tmp.path(), &["entry".into()]).unwrap();
2515
2516        let merged = resolver
2517            .merge_imports(parse_program(entry_source).unwrap())
2518            .expect("unanchored variables do not supply concrete schema evidence");
2519
2520        assert_eq!(
2521            merged
2522                .rules
2523                .iter()
2524                .filter(|rule| rule.head.predicate == "shared")
2525                .count(),
2526            2
2527        );
2528    }
2529
2530    #[test]
2531    fn merge_imports_uses_column_schema_arity_for_programmatic_declarations() {
2532        let tmp = TempDir::new().unwrap();
2533        create_test_module(tmp.path(), "small", "shared(1).");
2534        create_test_module(tmp.path(), "wide", "shared(5000000000).");
2535        let entry_source = "use small.\nuse wide.\n";
2536        create_test_module(tmp.path(), "entry", entry_source);
2537
2538        let mut resolver = ModuleResolver::new(vec![]);
2539        resolver.load_module(tmp.path(), &["entry".into()]).unwrap();
2540        let mut program = parse_program(entry_source).unwrap();
2541        program.predicates.push(PredDecl {
2542            name: "shared".to_string(),
2543            types: Vec::new(),
2544            columns: vec![crate::ast::PredColumn {
2545                name: Some("value".to_string()),
2546                typ: TypeRef::Scalar(ScalarType::I64),
2547            }],
2548            is_private: false,
2549        });
2550
2551        let merged = resolver
2552            .merge_imports(program)
2553            .expect("the effective declaration schema must control imported facts");
2554
2555        assert_eq!(
2556            merged
2557                .rules
2558                .iter()
2559                .filter(|rule| rule.head.predicate == "shared")
2560                .count(),
2561            2
2562        );
2563    }
2564
2565    #[test]
2566    fn merge_imports_distinguishes_same_stem_entry_and_import_in_schema_errors() {
2567        let tmp = TempDir::new().unwrap();
2568        create_test_module(tmp.path(), "main", "shared(1).");
2569        let entry = tmp.path().join("main.datalog");
2570        fs::write(&entry, "use main.\nshared(from_entry).\n").unwrap();
2571
2572        let mut resolver = ModuleResolver::new(vec![]);
2573        let program = resolver.load_entry_file(&entry).unwrap().program.clone();
2574
2575        let error = resolver
2576            .merge_imports(program)
2577            .expect_err("same-stem sources with different schemas must be distinguishable");
2578        let message = error.to_string();
2579
2580        assert!(message.contains("error[E0412]"), "{message}");
2581        assert!(message.contains("main.datalog"), "{message}");
2582        assert!(message.contains("main.xlog"), "{message}");
2583    }
2584
2585    #[test]
2586    fn merge_imports_excludes_selectively_omitted_schema_conflicts() {
2587        let tmp = TempDir::new().unwrap();
2588        create_test_module(tmp.path(), "first", "shared(from_first).\nomitted(1).");
2589        create_test_module(
2590            tmp.path(),
2591            "second",
2592            "shared(from_second).\nomitted(from_second).",
2593        );
2594        let entry_source = "use first::{shared}.\nuse second::{shared}.\n";
2595        create_test_module(tmp.path(), "entry", entry_source);
2596
2597        let mut resolver = ModuleResolver::new(vec![]);
2598        resolver.load_module(tmp.path(), &["entry".into()]).unwrap();
2599
2600        let merged = resolver
2601            .merge_imports(parse_program(entry_source).unwrap())
2602            .expect("omitted predicates do not participate in schema validation");
2603
2604        assert_eq!(
2605            merged
2606                .rules
2607                .iter()
2608                .filter(|rule| rule.head.predicate == "shared")
2609                .count(),
2610            2
2611        );
2612        assert!(!merged
2613            .rules
2614            .iter()
2615            .any(|rule| rule.head.predicate == "omitted"));
2616    }
2617
2618    #[test]
2619    fn merge_imports_rejects_selected_inferred_schema_conflicts() {
2620        let tmp = TempDir::new().unwrap();
2621        create_test_module(tmp.path(), "first", "shared(1).\nfirst_only(ok).");
2622        create_test_module(
2623            tmp.path(),
2624            "second",
2625            "shared(from_second).\nsecond_only(ok).",
2626        );
2627        let entry_source = "use first::{shared}.\nuse second::{shared}.\n";
2628        create_test_module(tmp.path(), "entry", entry_source);
2629
2630        let mut resolver = ModuleResolver::new(vec![]);
2631        resolver.load_module(tmp.path(), &["entry".into()]).unwrap();
2632
2633        let error = resolver
2634            .merge_imports(parse_program(entry_source).unwrap())
2635            .expect_err("selected incompatible contributions must be rejected");
2636
2637        assert!(error.to_string().contains("error[E0412]"));
2638    }
2639
2640    #[test]
2641    fn merge_imports_excludes_private_schema_conflicts() {
2642        let tmp = TempDir::new().unwrap();
2643        create_test_module(
2644            tmp.path(),
2645            "first",
2646            "private pred hidden(u32).\nhidden(1).\nshared(from_first).",
2647        );
2648        create_test_module(
2649            tmp.path(),
2650            "second",
2651            "private pred hidden(symbol).\nhidden(from_second).\nshared(from_second).",
2652        );
2653        let entry_source = "use first.\nuse second.\n";
2654        create_test_module(tmp.path(), "entry", entry_source);
2655
2656        let mut resolver = ModuleResolver::new(vec![]);
2657        resolver.load_module(tmp.path(), &["entry".into()]).unwrap();
2658
2659        let merged = resolver
2660            .merge_imports(parse_program(entry_source).unwrap())
2661            .expect("private predicates do not participate in import validation");
2662
2663        assert_eq!(
2664            merged
2665                .rules
2666                .iter()
2667                .filter(|rule| rule.head.predicate == "shared")
2668                .count(),
2669            2
2670        );
2671        assert!(!merged
2672            .rules
2673            .iter()
2674            .any(|rule| rule.head.predicate == "hidden"));
2675    }
2676
2677    #[test]
2678    fn merge_imports_validates_entry_clause_schema_against_imports() {
2679        let tmp = TempDir::new().unwrap();
2680        create_test_module(tmp.path(), "library", "shared(1).");
2681        let entry_source = "use library.\nshared(from_entry).\n";
2682        create_test_module(tmp.path(), "entry", entry_source);
2683
2684        let mut resolver = ModuleResolver::new(vec![]);
2685        resolver.load_module(tmp.path(), &["entry".into()]).unwrap();
2686
2687        let error = resolver
2688            .merge_imports(parse_program(entry_source).unwrap())
2689            .expect_err("entry and imported contributions must agree");
2690        let message = error.to_string();
2691
2692        assert!(message.contains("error[E0412]"), "{message}");
2693        assert!(message.contains("library"), "{message}");
2694        assert!(message.contains("entry"), "{message}");
2695    }
2696
2697    #[test]
2698    fn merge_imports_unions_selectively_imported_compatible_predicates() {
2699        let tmp = TempDir::new().unwrap();
2700        create_test_module(
2701            tmp.path(),
2702            "first",
2703            "pred shared(u32). pred first_only(u32). shared(1). first_only(10).",
2704        );
2705        create_test_module(
2706            tmp.path(),
2707            "second",
2708            "pred shared(u32). pred second_only(u32). shared(2). second_only(20).",
2709        );
2710        let entry_source = "use first::{shared}.\nuse second::{shared}.\n";
2711        create_test_module(tmp.path(), "entry", entry_source);
2712
2713        let mut resolver = ModuleResolver::new(vec![]);
2714        resolver.load_module(tmp.path(), &["entry".into()]).unwrap();
2715
2716        let merged = resolver
2717            .merge_imports(parse_program(entry_source).unwrap())
2718            .unwrap();
2719        let shared_facts = merged
2720            .rules
2721            .iter()
2722            .filter(|rule| rule.head.predicate == "shared" && rule.is_fact())
2723            .count();
2724
2725        assert_eq!(shared_facts, 2);
2726        assert!(!merged.rules.iter().any(|rule| {
2727            rule.head.predicate == "first_only" || rule.head.predicate == "second_only"
2728        }));
2729        assert!(!merged.predicates.iter().any(|declaration| {
2730            declaration.name == "first_only" || declaration.name == "second_only"
2731        }));
2732    }
2733
2734    #[test]
2735    fn merge_imports_unions_compatible_predicates_across_wrapper_branches() {
2736        let tmp = TempDir::new().unwrap();
2737        create_test_module(
2738            tmp.path(),
2739            "left_provider",
2740            concat!(
2741                "pred left_source(symbol).\n",
2742                "pred shared(symbol).\n",
2743                "left_source(left).\n",
2744                "shared(X) :- left_source(X).",
2745            ),
2746        );
2747        create_test_module(
2748            tmp.path(),
2749            "left_wrapper",
2750            "use left_provider.\npred left_result(symbol).\nleft_result(X) :- shared(X).",
2751        );
2752        create_test_module(
2753            tmp.path(),
2754            "right_provider",
2755            concat!(
2756                "pred right_source(symbol).\n",
2757                "pred shared(symbol).\n",
2758                "right_source(right).\n",
2759                "shared(X) :- right_source(X).",
2760            ),
2761        );
2762        create_test_module(
2763            tmp.path(),
2764            "right_wrapper",
2765            "use right_provider.\npred right_result(symbol).\nright_result(X) :- shared(X).",
2766        );
2767        let entry_source = "use left_wrapper.\nuse right_wrapper.\n";
2768        create_test_module(tmp.path(), "entry", entry_source);
2769
2770        let mut resolver = ModuleResolver::new(vec![]);
2771        resolver.load_module(tmp.path(), &["entry".into()]).unwrap();
2772
2773        let merged = resolver
2774            .merge_imports(parse_program(entry_source).unwrap())
2775            .unwrap();
2776        let shared_rules = merged
2777            .rules
2778            .iter()
2779            .filter(|rule| rule.head.predicate == "shared" && !rule.is_fact())
2780            .collect::<Vec<_>>();
2781
2782        assert_eq!(shared_rules.len(), 2);
2783        assert!(merged
2784            .rules
2785            .iter()
2786            .any(|rule| rule.head.predicate == "left_result"));
2787        assert!(merged
2788            .rules
2789            .iter()
2790            .any(|rule| rule.head.predicate == "right_result"));
2791    }
2792
2793    #[test]
2794    fn merge_imports_allows_local_extension_of_one_imported_provider() {
2795        let tmp = TempDir::new().unwrap();
2796        create_test_module(
2797            tmp.path(),
2798            "provider",
2799            "pred shared(symbol). shared(provider_value).",
2800        );
2801        create_test_module(
2802            tmp.path(),
2803            "wrapper",
2804            "use provider.\npred shared(symbol).\nshared(wrapper_value).",
2805        );
2806        let entry_source = "use wrapper.\n";
2807        create_test_module(tmp.path(), "entry", entry_source);
2808
2809        let mut resolver = ModuleResolver::new(vec![]);
2810        resolver.load_module(tmp.path(), &["entry".into()]).unwrap();
2811
2812        let merged = resolver
2813            .merge_imports(parse_program(entry_source).unwrap())
2814            .unwrap();
2815        let shared_facts = merged
2816            .rules
2817            .iter()
2818            .filter(|rule| rule.head.predicate == "shared" && rule.is_fact())
2819            .count();
2820
2821        assert_eq!(shared_facts, 2);
2822    }
2823
2824    #[test]
2825    fn merge_imports_rejects_local_redefinition_of_an_imported_function() {
2826        let tmp = TempDir::new().unwrap();
2827        create_test_module(tmp.path(), "base", "func shared(X) = X + 1.");
2828        create_test_module(tmp.path(), "wrapper", "use base.\nfunc shared(X) = X + 2.");
2829        let entry_source = "use wrapper.\n";
2830        create_test_module(tmp.path(), "entry", entry_source);
2831
2832        let mut resolver = ModuleResolver::new(vec![]);
2833        resolver.load_module(tmp.path(), &["entry".into()]).unwrap();
2834
2835        let result = resolver.merge_imports(parse_program(entry_source).unwrap());
2836
2837        assert!(matches!(
2838            result,
2839            Err(ModuleError::ImportConflict {
2840                name,
2841                module1,
2842                module2,
2843            }) if name == "shared"
2844                && module1 == vec!["base"]
2845                && module2 == vec!["wrapper"]
2846        ));
2847    }
2848
2849    #[test]
2850    fn merge_imports_rejects_function_definitions_in_separate_branches() {
2851        let tmp = TempDir::new().unwrap();
2852        create_test_module(tmp.path(), "first", "func shared(X) = X + 1.");
2853        create_test_module(tmp.path(), "second", "func shared(X) = X + 2.");
2854        let entry_source = "use first.\nuse second.\n";
2855        create_test_module(tmp.path(), "entry", entry_source);
2856
2857        let mut resolver = ModuleResolver::new(vec![]);
2858        resolver.load_module(tmp.path(), &["entry".into()]).unwrap();
2859
2860        let result = resolver.merge_imports(parse_program(entry_source).unwrap());
2861
2862        assert!(matches!(
2863            result,
2864            Err(ModuleError::ImportConflict {
2865                name,
2866                module1,
2867                module2,
2868            }) if name == "shared"
2869                && module1 == vec!["first"]
2870                && module2 == vec!["second"]
2871        ));
2872    }
2873
2874    #[test]
2875    fn merge_imports_rejects_function_definitions_across_wrapper_branches() {
2876        let tmp = TempDir::new().unwrap();
2877        create_test_module(tmp.path(), "left_provider", "func shared(X) = X + 1.");
2878        create_test_module(tmp.path(), "left_wrapper", "use left_provider.\n");
2879        create_test_module(tmp.path(), "right_provider", "func shared(X) = X + 2.");
2880        create_test_module(tmp.path(), "right_wrapper", "use right_provider.\n");
2881        let entry_source = "use left_wrapper.\nuse right_wrapper.\n";
2882        create_test_module(tmp.path(), "entry", entry_source);
2883
2884        let mut resolver = ModuleResolver::new(vec![]);
2885        resolver.load_module(tmp.path(), &["entry".into()]).unwrap();
2886
2887        let result = resolver.merge_imports(parse_program(entry_source).unwrap());
2888
2889        assert!(matches!(
2890            result,
2891            Err(ModuleError::ImportConflict {
2892                name,
2893                module1,
2894                module2,
2895            }) if name == "shared"
2896                && module1 == vec!["left_provider"]
2897                && module2 == vec!["right_provider"]
2898        ));
2899    }
2900
2901    #[test]
2902    fn merge_imports_rejects_entry_redefinition_of_an_imported_function() {
2903        let tmp = TempDir::new().unwrap();
2904        create_test_module(tmp.path(), "library", "func shared(X) = X + 1.");
2905        let entry_source = "use library.\nfunc shared(X) = X + 2.\n";
2906        create_test_module(tmp.path(), "entry", entry_source);
2907
2908        let mut resolver = ModuleResolver::new(vec![]);
2909        resolver.load_module(tmp.path(), &["entry".into()]).unwrap();
2910
2911        let result = resolver.merge_imports(parse_program(entry_source).unwrap());
2912
2913        assert!(matches!(
2914            result,
2915            Err(ModuleError::ImportConflict {
2916                name,
2917                module1,
2918                module2,
2919            }) if name == "shared"
2920                && module1 == vec!["library"]
2921                && module2 == vec!["entry"]
2922        ));
2923    }
2924
2925    #[test]
2926    fn merge_imports_does_not_treat_declarations_as_providers() {
2927        let tmp = TempDir::new().unwrap();
2928        create_test_module(
2929            tmp.path(),
2930            "first",
2931            "pred external(symbol).\npred first_result(symbol).\nfirst_result(X) :- external(X).",
2932        );
2933        create_test_module(
2934            tmp.path(),
2935            "second",
2936            "pred external(symbol).\npred second_result(symbol).\nsecond_result(X) :- external(X).",
2937        );
2938        let entry_source = "use first.\nuse second.\n";
2939        create_test_module(tmp.path(), "entry", entry_source);
2940
2941        let mut resolver = ModuleResolver::new(vec![]);
2942        resolver.load_module(tmp.path(), &["entry".into()]).unwrap();
2943
2944        let merged = resolver
2945            .merge_imports(parse_program(entry_source).unwrap())
2946            .unwrap();
2947
2948        assert!(merged
2949            .rules
2950            .iter()
2951            .any(|rule| rule.head.predicate == "first_result"));
2952        assert!(merged
2953            .rules
2954            .iter()
2955            .any(|rule| rule.head.predicate == "second_result"));
2956    }
2957
2958    #[test]
2959    fn merge_imports_rejects_incompatible_predicate_declarations() {
2960        let tmp = TempDir::new().unwrap();
2961        create_test_module(tmp.path(), "first", "pred external(u32). external(1).");
2962        create_test_module(
2963            tmp.path(),
2964            "second",
2965            "pred external(symbol). external(value).",
2966        );
2967        let entry_source = "use first.\nuse second.\n";
2968        create_test_module(tmp.path(), "entry", entry_source);
2969
2970        let mut resolver = ModuleResolver::new(vec![]);
2971        resolver.load_module(tmp.path(), &["entry".into()]).unwrap();
2972
2973        let result = resolver.merge_imports(parse_program(entry_source).unwrap());
2974
2975        assert!(matches!(
2976            result,
2977            Err(ModuleError::IncompatiblePredicateDeclaration {
2978                name,
2979                module1,
2980                module2,
2981            }) if name == "external"
2982                && module1 == vec!["first"]
2983                && module2 == vec!["second"]
2984        ));
2985    }
2986
2987    #[test]
2988    fn merge_imports_rejects_incompatible_declaration_only_modules() {
2989        let tmp = TempDir::new().unwrap();
2990        create_test_module(tmp.path(), "first", "pred external(u32).");
2991        create_test_module(tmp.path(), "second", "pred external(symbol).");
2992        let entry_source = "use first.\nuse second.\n";
2993        create_test_module(tmp.path(), "entry", entry_source);
2994
2995        let mut resolver = ModuleResolver::new(vec![]);
2996        resolver.load_module(tmp.path(), &["entry".into()]).unwrap();
2997
2998        let result = resolver.merge_imports(parse_program(entry_source).unwrap());
2999
3000        assert!(matches!(
3001            result,
3002            Err(ModuleError::IncompatiblePredicateDeclaration {
3003                name,
3004                module1,
3005                module2,
3006            }) if name == "external"
3007                && module1 == vec!["first"]
3008                && module2 == vec!["second"]
3009        ));
3010    }
3011
3012    #[test]
3013    fn merge_imports_rejects_entry_declaration_incompatible_with_import() {
3014        let tmp = TempDir::new().unwrap();
3015        create_test_module(
3016            tmp.path(),
3017            "library",
3018            "pred external(symbol). external(value).",
3019        );
3020        let entry_source = "use library.\npred external(u32).\n";
3021        create_test_module(tmp.path(), "entry", entry_source);
3022
3023        let mut resolver = ModuleResolver::new(vec![]);
3024        resolver.load_module(tmp.path(), &["entry".into()]).unwrap();
3025
3026        let result = resolver.merge_imports(parse_program(entry_source).unwrap());
3027
3028        assert!(matches!(
3029            result,
3030            Err(ModuleError::IncompatiblePredicateDeclaration {
3031                name,
3032                module1,
3033                module2,
3034            }) if name == "external"
3035                && module1 == vec!["library"]
3036                && module2 == vec!["entry"]
3037        ));
3038    }
3039
3040    #[test]
3041    fn merge_imports_rejects_incompatible_domain_declarations() {
3042        let tmp = TempDir::new().unwrap();
3043        create_test_module(
3044            tmp.path(),
3045            "first",
3046            "domain key : u32.\npred external(key).",
3047        );
3048        create_test_module(
3049            tmp.path(),
3050            "second",
3051            "domain key : symbol.\npred external(key).",
3052        );
3053        let entry_source = "use first.\nuse second.\n";
3054        create_test_module(tmp.path(), "entry", entry_source);
3055
3056        let mut resolver = ModuleResolver::new(vec![]);
3057        resolver.load_module(tmp.path(), &["entry".into()]).unwrap();
3058
3059        let result = resolver.merge_imports(parse_program(entry_source).unwrap());
3060
3061        assert!(matches!(
3062            result,
3063            Err(ModuleError::IncompatibleDomainDeclaration {
3064                name,
3065                module1,
3066                module2,
3067            }) if name == "key"
3068                && module1 == vec!["first"]
3069                && module2 == vec!["second"]
3070        ));
3071    }
3072
3073    #[test]
3074    fn merge_imports_rejects_entry_domain_incompatible_with_import() {
3075        let tmp = TempDir::new().unwrap();
3076        create_test_module(tmp.path(), "library", "domain key : symbol.");
3077        let entry_source = "use library.\ndomain key : u32.\n";
3078        create_test_module(tmp.path(), "entry", entry_source);
3079
3080        let mut resolver = ModuleResolver::new(vec![]);
3081        resolver.load_module(tmp.path(), &["entry".into()]).unwrap();
3082
3083        let result = resolver.merge_imports(parse_program(entry_source).unwrap());
3084
3085        assert!(matches!(
3086            result,
3087            Err(ModuleError::IncompatibleDomainDeclaration {
3088                name,
3089                module1,
3090                module2,
3091            }) if name == "key"
3092                && module1 == vec!["library"]
3093                && module2 == vec!["entry"]
3094        ));
3095    }
3096
3097    #[test]
3098    fn merge_imports_rejects_duplicate_functions_within_an_imported_module() {
3099        let tmp = TempDir::new().unwrap();
3100        create_test_module(
3101            tmp.path(),
3102            "library",
3103            "func shared(X) = X + 1.\nfunc shared(X) = X + 2.",
3104        );
3105        let entry_source = "use library.\n";
3106        create_test_module(tmp.path(), "entry", entry_source);
3107
3108        let mut resolver = ModuleResolver::new(vec![]);
3109        resolver.load_module(tmp.path(), &["entry".into()]).unwrap();
3110
3111        let result = resolver.merge_imports(parse_program(entry_source).unwrap());
3112
3113        assert!(matches!(
3114            result,
3115            Err(ModuleError::DuplicateImportedFunction { name, module })
3116                if name == "shared" && module == vec!["library"]
3117        ));
3118    }
3119
3120    #[test]
3121    fn merge_imports_rejects_unselected_duplicate_public_functions() {
3122        let tmp = TempDir::new().unwrap();
3123        create_test_module(
3124            tmp.path(),
3125            "library",
3126            "func shared(X) = X + 1.\nfunc shared(X) = X + 2.\nfunc other(X) = X.",
3127        );
3128        let entry_source = "use library::{other}.\n";
3129        create_test_module(tmp.path(), "entry", entry_source);
3130
3131        let mut resolver = ModuleResolver::new(vec![]);
3132        resolver.load_module(tmp.path(), &["entry".into()]).unwrap();
3133
3134        let result = resolver.merge_imports(parse_program(entry_source).unwrap());
3135
3136        assert!(matches!(
3137            result,
3138            Err(ModuleError::DuplicateImportedFunction { name, module })
3139                if name == "shared" && module == vec!["library"]
3140        ));
3141    }
3142
3143    #[test]
3144    fn merge_imports_rejects_private_and_public_definitions_of_an_exported_function() {
3145        let tmp = TempDir::new().unwrap();
3146        create_test_module(
3147            tmp.path(),
3148            "library",
3149            "private func shared(X) = X + 1.\nfunc shared(X) = X + 2.",
3150        );
3151        let entry_source = "use library.\n";
3152        create_test_module(tmp.path(), "entry", entry_source);
3153
3154        let mut resolver = ModuleResolver::new(vec![]);
3155        resolver.load_module(tmp.path(), &["entry".into()]).unwrap();
3156
3157        let result = resolver.merge_imports(parse_program(entry_source).unwrap());
3158
3159        assert!(matches!(
3160            result,
3161            Err(ModuleError::DuplicateImportedFunction { name, module })
3162                if name == "shared" && module == vec!["library"]
3163        ));
3164    }
3165
3166    #[test]
3167    fn validation_rejects_mixed_visibility_for_one_imported_predicate() {
3168        let tmp = TempDir::new().unwrap();
3169        create_test_module(
3170            tmp.path(),
3171            "library",
3172            "private pred shared(u32).\npred shared(u32).\nshared(1).",
3173        );
3174        let entry_source = "use library.\n";
3175        create_test_module(tmp.path(), "entry", entry_source);
3176
3177        let mut resolver = ModuleResolver::new(vec![]);
3178        resolver.load_module(tmp.path(), &["entry".into()]).unwrap();
3179        let program = parse_program(entry_source).unwrap();
3180
3181        assert!(matches!(
3182            resolver.check_import(&["library".to_string()], "shared"),
3183            Err(ModuleError::ConflictingPredicateVisibility { name, module })
3184                if name == "shared" && module == vec!["library"]
3185        ));
3186        assert!(matches!(
3187            resolver.validate_imports(&program),
3188            Err(ModuleError::ConflictingPredicateVisibility { name, module })
3189                if name == "shared" && module == vec!["library"]
3190        ));
3191        assert!(matches!(
3192            resolver.merge_imports(program),
3193            Err(ModuleError::ConflictingPredicateVisibility { name, module })
3194                if name == "shared" && module == vec!["library"]
3195        ));
3196    }
3197
3198    #[test]
3199    fn merge_imports_accepts_equivalent_predicate_schemas_using_distinct_domains() {
3200        let tmp = TempDir::new().unwrap();
3201        create_test_module(
3202            tmp.path(),
3203            "first",
3204            "domain first_key : u32.\npred external(first_key).",
3205        );
3206        create_test_module(
3207            tmp.path(),
3208            "second",
3209            "domain second_key : u32.\npred external(second_key).",
3210        );
3211        let entry_source = "use first.\nuse second.\n";
3212        create_test_module(tmp.path(), "entry", entry_source);
3213
3214        let mut resolver = ModuleResolver::new(vec![]);
3215        resolver.load_module(tmp.path(), &["entry".into()]).unwrap();
3216
3217        let merged = resolver
3218            .merge_imports(parse_program(entry_source).unwrap())
3219            .unwrap();
3220
3221        assert!(merged
3222            .predicates
3223            .iter()
3224            .any(|declaration| declaration.name == "external"));
3225    }
3226
3227    #[test]
3228    fn merge_imports_normalizes_a_predicate_schema_with_an_imported_domain() {
3229        let tmp = TempDir::new().unwrap();
3230        create_test_module(tmp.path(), "types", "domain key : u32.");
3231        create_test_module(tmp.path(), "wrapper", "use types.\npred external(key).");
3232        create_test_module(tmp.path(), "second", "pred external(u32).");
3233        let entry_source = "use wrapper.\nuse second.\n";
3234        create_test_module(tmp.path(), "entry", entry_source);
3235
3236        let mut resolver = ModuleResolver::new(vec![]);
3237        resolver.load_module(tmp.path(), &["entry".into()]).unwrap();
3238
3239        let merged = resolver
3240            .merge_imports(parse_program(entry_source).unwrap())
3241            .unwrap();
3242
3243        assert!(merged
3244            .predicates
3245            .iter()
3246            .any(|declaration| declaration.name == "external"));
3247    }
3248
3249    #[test]
3250    fn merge_imports_normalizes_an_entry_schema_with_an_imported_domain() {
3251        let tmp = TempDir::new().unwrap();
3252        create_test_module(tmp.path(), "types", "domain key : u32.");
3253        create_test_module(tmp.path(), "provider", "pred external(u32).");
3254        let entry_source = "use types.\nuse provider.\npred external(key).\n";
3255        create_test_module(tmp.path(), "entry", entry_source);
3256
3257        let mut resolver = ModuleResolver::new(vec![]);
3258        resolver.load_module(tmp.path(), &["entry".into()]).unwrap();
3259
3260        let merged = resolver
3261            .merge_imports(parse_program(entry_source).unwrap())
3262            .unwrap();
3263
3264        assert!(merged
3265            .predicates
3266            .iter()
3267            .any(|declaration| declaration.name == "external"));
3268    }
3269
3270    #[test]
3271    fn merge_imports_allows_entry_to_extend_an_imported_predicate() {
3272        let tmp = TempDir::new().unwrap();
3273        create_test_module(tmp.path(), "library", "pred shared(u32). shared(1).");
3274        let entry_source = "use library.\npred shared(u32).\nshared(2).\n";
3275        create_test_module(tmp.path(), "entry", entry_source);
3276
3277        let mut resolver = ModuleResolver::new(vec![]);
3278        resolver.load_module(tmp.path(), &["entry".into()]).unwrap();
3279
3280        let merged = resolver
3281            .merge_imports(parse_program(entry_source).unwrap())
3282            .unwrap();
3283        let shared_facts = merged
3284            .rules
3285            .iter()
3286            .filter(|rule| rule.head.predicate == "shared" && rule.is_fact())
3287            .count();
3288
3289        assert_eq!(shared_facts, 2);
3290    }
3291
3292    #[test]
3293    fn merge_imports_resolves_same_relative_name_per_importer() {
3294        let tmp = TempDir::new().unwrap();
3295        let left = tmp.path().join("left");
3296        let right = tmp.path().join("right");
3297        fs::create_dir_all(&left).unwrap();
3298        fs::create_dir_all(&right).unwrap();
3299        create_test_module(
3300            &left,
3301            "support",
3302            "pred left_local(symbol). left_local(left).",
3303        );
3304        create_test_module(
3305            &left,
3306            "wrapper",
3307            "use support.\npred left_result(symbol).\nleft_result(X) :- left_local(X).",
3308        );
3309        create_test_module(
3310            &right,
3311            "support",
3312            "pred right_local(symbol). right_local(right).",
3313        );
3314        create_test_module(
3315            &right,
3316            "wrapper",
3317            "use support.\npred right_result(symbol).\nright_result(X) :- right_local(X).",
3318        );
3319        let entry_source = "use left/wrapper.\nuse right/wrapper.\n";
3320        let entry = create_test_module(tmp.path(), "entry", entry_source);
3321
3322        let mut resolver = ModuleResolver::new(vec![]);
3323        resolver.load_entry_file(&entry).unwrap();
3324
3325        let merged = resolver
3326            .merge_imports(parse_program(entry_source).unwrap())
3327            .unwrap();
3328
3329        assert!(merged
3330            .rules
3331            .iter()
3332            .any(|rule| rule.head.predicate == "left_local"));
3333        assert!(merged
3334            .rules
3335            .iter()
3336            .any(|rule| rule.head.predicate == "right_local"));
3337    }
3338
3339    #[test]
3340    fn merge_imports_deduplicates_aliases_of_one_source_file() {
3341        let tmp = TempDir::new().unwrap();
3342        let util = tmp.path().join("util");
3343        fs::create_dir_all(&util).unwrap();
3344        create_test_module(
3345            &util,
3346            "helpers",
3347            "pred shared(symbol). shared(helper_value).",
3348        );
3349        create_test_module(
3350            &util,
3351            "wrapper",
3352            "use helpers.\npred wrapped(symbol).\nwrapped(X) :- shared(X).",
3353        );
3354        let entry_source = "use util/wrapper.\nuse util/helpers.\n";
3355        let entry = create_test_module(tmp.path(), "entry", entry_source);
3356
3357        let mut resolver = ModuleResolver::new(vec![]);
3358        resolver.load_entry_file(&entry).unwrap();
3359
3360        let merged = resolver
3361            .merge_imports(parse_program(entry_source).unwrap())
3362            .unwrap();
3363        let shared_facts = merged
3364            .rules
3365            .iter()
3366            .filter(|rule| rule.head.predicate == "shared" && rule.is_fact())
3367            .count();
3368
3369        assert_eq!(shared_facts, 1);
3370        assert!(merged
3371            .rules
3372            .iter()
3373            .any(|rule| rule.head.predicate == "wrapped"));
3374    }
3375
3376    #[cfg(unix)]
3377    #[test]
3378    fn merge_imports_resolves_symlinked_module_dependencies_from_canonical_source() {
3379        let tmp = TempDir::new().unwrap();
3380        let real = tmp.path().join("real");
3381        let alias = tmp.path().join("alias");
3382        fs::create_dir_all(&real).unwrap();
3383        fs::create_dir_all(&alias).unwrap();
3384        create_test_module(
3385            &real,
3386            "support",
3387            "pred support_value(u32). support_value(1).",
3388        );
3389        create_test_module(
3390            &alias,
3391            "support",
3392            "pred support_value(u32). support_value(2).",
3393        );
3394        let shared = create_test_module(
3395            &real,
3396            "shared",
3397            "use support.\npred shared_value(symbol).\nshared_value(X) :- support_value(X).",
3398        );
3399        std::os::unix::fs::symlink(&shared, alias.join("shared.xlog")).unwrap();
3400        let entry_source = "use alias/shared.\n";
3401        let entry = create_test_module(tmp.path(), "entry", entry_source);
3402
3403        let mut resolver = ModuleResolver::new(vec![]);
3404        resolver.load_entry_file(&entry).unwrap();
3405
3406        let merged = resolver
3407            .merge_imports(parse_program(entry_source).unwrap())
3408            .unwrap();
3409        let support_values = merged
3410            .rules
3411            .iter()
3412            .filter(|rule| rule.head.predicate == "support_value" && rule.is_fact())
3413            .map(|rule| rule.head.terms.clone())
3414            .collect::<Vec<_>>();
3415
3416        assert_eq!(support_values, vec![vec![Term::Integer(1)]]);
3417    }
3418
3419    #[test]
3420    fn merge_imports_keeps_importer_local_resolution_across_search_paths() {
3421        let tmp = TempDir::new().unwrap();
3422        let entry_dir = tmp.path().join("entry");
3423        let module_dir = tmp.path().join("modules");
3424        fs::create_dir_all(&entry_dir).unwrap();
3425        fs::create_dir_all(&module_dir).unwrap();
3426        create_test_module(
3427            &entry_dir,
3428            "support",
3429            "pred entry_support(symbol). entry_support(local).",
3430        );
3431        create_test_module(
3432            &module_dir,
3433            "support",
3434            "pred wrapper_support(symbol). wrapper_support(search_path).",
3435        );
3436        create_test_module(
3437            &module_dir,
3438            "wrapper",
3439            "use support.\npred wrapped(symbol).\nwrapped(X) :- wrapper_support(X).",
3440        );
3441        let entry_source = "use support.\nuse wrapper.\n";
3442        let entry = create_test_module(&entry_dir, "main", entry_source);
3443
3444        let mut resolver = ModuleResolver::new(vec![module_dir]);
3445        resolver.load_entry_file(&entry).unwrap();
3446
3447        let merged = resolver
3448            .merge_imports(parse_program(entry_source).unwrap())
3449            .unwrap();
3450
3451        for predicate in ["entry_support", "wrapper_support", "wrapped"] {
3452            assert!(
3453                merged
3454                    .rules
3455                    .iter()
3456                    .any(|rule| rule.head.predicate == predicate),
3457                "missing rules for {predicate}"
3458            );
3459        }
3460    }
3461
3462    #[test]
3463    fn validate_imports_rejects_an_unanchored_ambiguous_module_path() {
3464        let tmp = TempDir::new().unwrap();
3465        let left = tmp.path().join("left");
3466        let right = tmp.path().join("right");
3467        fs::create_dir_all(&left).unwrap();
3468        fs::create_dir_all(&right).unwrap();
3469        create_test_module(&left, "support", "pred left(symbol). left(value).");
3470        create_test_module(&right, "support", "pred right(symbol). right(value).");
3471
3472        let mut resolver = ModuleResolver::new(vec![]);
3473        resolver.load_module(&left, &["support".into()]).unwrap();
3474        resolver.load_module(&right, &["support".into()]).unwrap();
3475
3476        let first = resolver
3477            .get_module(&["support".into()])
3478            .expect("first source remains available through the compatibility API");
3479        assert_eq!(first.source_file, left.join("support.xlog"));
3480        assert!(resolver.check_import(&["support".into()], "left").is_ok());
3481        assert!(resolver.check_import(&["support".into()], "right").is_err());
3482        assert_eq!(
3483            resolver
3484                .loaded_modules()
3485                .into_iter()
3486                .filter(|alias| *alias == "support")
3487                .count(),
3488            1
3489        );
3490
3491        let result = resolver.validate_imports(&parse_program("use support.").unwrap());
3492
3493        assert!(matches!(
3494            result,
3495            Err(ModuleError::AmbiguousModulePath { path, candidates })
3496                if path == vec!["support"] && candidates.len() == 2
3497        ));
3498    }
3499
3500    #[test]
3501    fn load_entry_file_detects_an_import_of_itself() {
3502        let tmp = TempDir::new().unwrap();
3503        let entry = create_test_module(tmp.path(), "entry", "use entry.");
3504        let mut resolver = ModuleResolver::new(vec![]);
3505
3506        let result = resolver.load_entry_file(&entry);
3507
3508        assert!(matches!(result, Err(ModuleError::CircularImport { .. })));
3509    }
3510
3511    #[cfg(unix)]
3512    #[test]
3513    fn load_entry_file_detects_a_symlink_import_of_itself() {
3514        let tmp = TempDir::new().unwrap();
3515        let entry = create_test_module(tmp.path(), "entry", "use alias.");
3516        std::os::unix::fs::symlink(&entry, tmp.path().join("alias.xlog")).unwrap();
3517        let mut resolver = ModuleResolver::new(vec![]);
3518
3519        let result = resolver.load_entry_file(&entry);
3520
3521        assert!(matches!(result, Err(ModuleError::CircularImport { .. })));
3522    }
3523
3524    #[test]
3525    fn merge_imports_deduplicates_function_aliases_of_one_source_file() {
3526        let tmp = TempDir::new().unwrap();
3527        let util = tmp.path().join("util");
3528        fs::create_dir_all(&util).unwrap();
3529        create_test_module(&util, "helpers", "func normalize(X) = X + 1.");
3530        create_test_module(&util, "wrapper", "use helpers.\n");
3531        let entry_source = "use util/wrapper.\nuse util/helpers.\n";
3532        let entry = create_test_module(tmp.path(), "entry", entry_source);
3533
3534        let mut resolver = ModuleResolver::new(vec![]);
3535        resolver.load_entry_file(&entry).unwrap();
3536
3537        let merged = resolver
3538            .merge_imports(parse_program(entry_source).unwrap())
3539            .unwrap();
3540
3541        assert_eq!(
3542            merged
3543                .functions
3544                .iter()
3545                .filter(|function| function.name == "normalize")
3546                .count(),
3547            1
3548        );
3549    }
3550
3551    #[test]
3552    fn test_circular_import() {
3553        let tmp = TempDir::new().unwrap();
3554        create_test_module(tmp.path(), "a", "use b.");
3555        create_test_module(tmp.path(), "b", "use a.");
3556
3557        let mut resolver = ModuleResolver::new(vec![]);
3558        let result = resolver.load_module(tmp.path(), &["a".into()]);
3559        assert!(matches!(result, Err(ModuleError::CircularImport { .. })));
3560    }
3561
3562    #[test]
3563    fn test_load_simple_module() {
3564        let tmp = TempDir::new().unwrap();
3565        create_test_module(
3566            tmp.path(),
3567            "math",
3568            r#"
3569            pred add(u32, u32, u32).
3570            add(1, 2, 3).
3571        "#,
3572        );
3573
3574        let mut resolver = ModuleResolver::new(vec![]);
3575        let result = resolver.load_module(tmp.path(), &["math".into()]);
3576        assert!(result.is_ok());
3577        let module = result.unwrap();
3578        assert!(module.exports.contains("add"));
3579    }
3580
3581    #[test]
3582    fn test_private_not_exported() {
3583        let tmp = TempDir::new().unwrap();
3584        create_test_module(
3585            tmp.path(),
3586            "graph",
3587            r#"
3588            pred edge(u32, u32).
3589            private pred helper(u32).
3590            edge(1, 2).
3591            helper(1).
3592        "#,
3593        );
3594
3595        let mut resolver = ModuleResolver::new(vec![]);
3596        let result = resolver.load_module(tmp.path(), &["graph".into()]);
3597        assert!(result.is_ok());
3598        let module = result.unwrap();
3599        assert!(module.exports.contains("edge"));
3600        assert!(!module.exports.contains("helper"));
3601    }
3602
3603    #[test]
3604    fn test_merge_rejects_export_with_private_support() {
3605        let tmp = TempDir::new().unwrap();
3606        create_test_module(
3607            tmp.path(),
3608            "library",
3609            r#"
3610            private pred hidden(u32).
3611            pred visible(u32).
3612            hidden(1).
3613            visible(X) :- hidden(X).
3614        "#,
3615        );
3616        let entry_source = "use library.\nquery(visible(1)).\n";
3617        create_test_module(tmp.path(), "entry", entry_source);
3618
3619        let mut resolver = ModuleResolver::new(vec![]);
3620        resolver.load_module(tmp.path(), &["entry".into()]).unwrap();
3621        let error = resolver
3622            .merge_imports(parse_program(entry_source).unwrap())
3623            .unwrap_err()
3624            .to_string();
3625
3626        assert!(error.contains("error[E0406]"), "{error}");
3627        assert!(error.contains("`visible`"), "{error}");
3628        assert!(error.contains("`hidden`"), "{error}");
3629        assert!(error.contains("`library`"), "{error}");
3630    }
3631
3632    #[test]
3633    fn test_merge_rejects_export_with_selectively_omitted_support() {
3634        let tmp = TempDir::new().unwrap();
3635        create_test_module(
3636            tmp.path(),
3637            "library",
3638            r#"
3639            pred support(u32).
3640            pred visible(u32).
3641            support(1).
3642            visible(X) :- support(X).
3643        "#,
3644        );
3645        let entry_source = "use library::{visible}.\nquery(visible(1)).\n";
3646        create_test_module(tmp.path(), "entry", entry_source);
3647
3648        let mut resolver = ModuleResolver::new(vec![]);
3649        resolver.load_module(tmp.path(), &["entry".into()]).unwrap();
3650        let error = resolver
3651            .merge_imports(parse_program(entry_source).unwrap())
3652            .unwrap_err()
3653            .to_string();
3654
3655        assert!(error.contains("error[E0406]"), "{error}");
3656        assert!(error.contains("`visible`"), "{error}");
3657        assert!(error.contains("`support`"), "{error}");
3658        assert!(error.contains("`library`"), "{error}");
3659    }
3660
3661    #[test]
3662    fn test_merge_combines_separate_selective_imports() {
3663        let tmp = TempDir::new().unwrap();
3664        create_test_module(
3665            tmp.path(),
3666            "library",
3667            r#"
3668            pred support(u32).
3669            pred visible(u32).
3670            support(1).
3671            visible(X) :- support(X).
3672        "#,
3673        );
3674        let entry_source = "use library::{visible}.\nuse library::{support}.\n";
3675        create_test_module(tmp.path(), "entry", entry_source);
3676
3677        let mut resolver = ModuleResolver::new(vec![]);
3678        resolver.load_module(tmp.path(), &["entry".into()]).unwrap();
3679        let merged = resolver
3680            .merge_imports(parse_program(entry_source).unwrap())
3681            .unwrap();
3682
3683        assert!(merged
3684            .rules
3685            .iter()
3686            .any(|rule| rule.head.predicate == "visible"));
3687        assert!(merged
3688            .rules
3689            .iter()
3690            .any(|rule| rule.head.predicate == "support"));
3691    }
3692
3693    #[test]
3694    fn test_merge_accepts_visible_provider_for_same_named_private_item() {
3695        let tmp = TempDir::new().unwrap();
3696        create_test_module(
3697            tmp.path(),
3698            "private_provider",
3699            "private pred hidden(u32).\nhidden(1).\n",
3700        );
3701        create_test_module(
3702            tmp.path(),
3703            "public_provider",
3704            "pred hidden(u32).\nhidden(2).\n",
3705        );
3706        create_test_module(
3707            tmp.path(),
3708            "wrapper",
3709            "use private_provider.\nuse public_provider.\npred visible(u32).\nvisible(X) :- hidden(X).\n",
3710        );
3711        let entry_source = "use wrapper.\n";
3712        create_test_module(tmp.path(), "entry", entry_source);
3713
3714        let mut resolver = ModuleResolver::new(vec![]);
3715        resolver.load_module(tmp.path(), &["entry".into()]).unwrap();
3716        let merged = resolver
3717            .merge_imports(parse_program(entry_source).unwrap())
3718            .unwrap();
3719
3720        assert!(merged
3721            .rules
3722            .iter()
3723            .any(|rule| rule.head.predicate == "visible"));
3724        assert!(merged.rules.iter().any(|rule| {
3725            rule.head.predicate == "hidden" && rule.head.terms == vec![Term::Integer(2)]
3726        }));
3727    }
3728
3729    #[test]
3730    fn test_unrelated_import_does_not_satisfy_selective_dependency() {
3731        let tmp = TempDir::new().unwrap();
3732        create_test_module(
3733            tmp.path(),
3734            "catalog",
3735            "pred support(u32).\npred marker(u32).\nsupport(1).\nmarker(1).\n",
3736        );
3737        create_test_module(
3738            tmp.path(),
3739            "wrapper_a",
3740            "use catalog::{marker}.\npred visible(u32).\nvisible(X) :- support(X).\n",
3741        );
3742        create_test_module(tmp.path(), "wrapper_b", "use catalog::{support}.\n");
3743        let entry_source = "use wrapper_a.\nuse wrapper_b.\n";
3744        create_test_module(tmp.path(), "entry", entry_source);
3745
3746        let mut resolver = ModuleResolver::new(vec![]);
3747        resolver.load_module(tmp.path(), &["entry".into()]).unwrap();
3748        let error = resolver
3749            .merge_imports(parse_program(entry_source).unwrap())
3750            .unwrap_err()
3751            .to_string();
3752
3753        assert!(error.contains("error[E0406]"), "{error}");
3754        assert!(error.contains("`visible`"), "{error}");
3755        assert!(error.contains("`support`"), "{error}");
3756        assert!(error.contains("`wrapper_a`"), "{error}");
3757    }
3758
3759    #[test]
3760    fn test_merge_rejects_unknown_selected_item() {
3761        let tmp = TempDir::new().unwrap();
3762        create_test_module(tmp.path(), "library", "known(1).\n");
3763        let entry_source = "use library::{missing}.\n";
3764        create_test_module(tmp.path(), "entry", entry_source);
3765
3766        let mut resolver = ModuleResolver::new(vec![]);
3767        resolver.load_module(tmp.path(), &["entry".into()]).unwrap();
3768        let error = resolver
3769            .merge_imports(parse_program(entry_source).unwrap())
3770            .unwrap_err()
3771            .to_string();
3772
3773        assert!(error.contains("error[E0404]"), "{error}");
3774        assert!(error.contains("`missing`"), "{error}");
3775        assert!(error.contains("module library"), "{error}");
3776    }
3777
3778    #[test]
3779    fn test_merge_rejects_transitive_private_support() {
3780        let tmp = TempDir::new().unwrap();
3781        create_test_module(
3782            tmp.path(),
3783            "base",
3784            "private pred hidden(u32).\nhidden(1).\n",
3785        );
3786        create_test_module(
3787            tmp.path(),
3788            "wrapper",
3789            "use base.\npred visible(u32).\nvisible(X) :- hidden(X).\n",
3790        );
3791        let entry_source = "use wrapper.\n";
3792        create_test_module(tmp.path(), "entry", entry_source);
3793
3794        let mut resolver = ModuleResolver::new(vec![]);
3795        resolver.load_module(tmp.path(), &["entry".into()]).unwrap();
3796        let error = resolver
3797            .merge_imports(parse_program(entry_source).unwrap())
3798            .unwrap_err()
3799            .to_string();
3800
3801        assert!(error.contains("error[E0406]"), "{error}");
3802        assert!(error.contains("`visible`"), "{error}");
3803        assert!(error.contains("`hidden`"), "{error}");
3804        assert!(error.contains("`wrapper`"), "{error}");
3805    }
3806
3807    #[test]
3808    fn test_merge_rejects_exported_function_with_private_support() {
3809        let tmp = TempDir::new().unwrap();
3810        create_test_module(
3811            tmp.path(),
3812            "library",
3813            r#"
3814            private func hidden(X) = X + 1.
3815            func visible(X) = hidden(X) * 2.
3816        "#,
3817        );
3818        let entry_source = "use library.\n";
3819        create_test_module(tmp.path(), "entry", entry_source);
3820
3821        let mut resolver = ModuleResolver::new(vec![]);
3822        resolver.load_module(tmp.path(), &["entry".into()]).unwrap();
3823        let error = resolver
3824            .merge_imports(parse_program(entry_source).unwrap())
3825            .unwrap_err()
3826            .to_string();
3827
3828        assert!(error.contains("error[E0406]"), "{error}");
3829        assert!(error.contains("`visible`"), "{error}");
3830        assert!(error.contains("`hidden`"), "{error}");
3831        assert!(error.contains("`library`"), "{error}");
3832    }
3833
3834    #[test]
3835    fn test_merge_rejects_private_safe_meta_dependencies() {
3836        let tmp = TempDir::new().unwrap();
3837        for (export, rule) in [
3838            ("all_hidden", "all_hidden() :- maplist(hidden, [1])."),
3839            (
3840                "collect_hidden",
3841                "collect_hidden(Values) :- findall(X, hidden(X), Values).",
3842            ),
3843        ] {
3844            create_test_module(
3845                tmp.path(),
3846                "library",
3847                &format!("private pred hidden(u32).\nhidden(1).\n{rule}\n"),
3848            );
3849            let entry_source = "use library.\n";
3850            create_test_module(tmp.path(), "entry", entry_source);
3851
3852            let mut resolver = ModuleResolver::new(vec![]);
3853            resolver.load_module(tmp.path(), &["entry".into()]).unwrap();
3854            let error = resolver
3855                .merge_imports(parse_program(entry_source).unwrap())
3856                .unwrap_err()
3857                .to_string();
3858
3859            assert!(error.contains("error[E0406]"), "{error}");
3860            assert!(error.contains(&format!("`{export}`")), "{error}");
3861            assert!(error.contains("`hidden`"), "{error}");
3862        }
3863    }
3864
3865    #[test]
3866    fn test_merge_rejects_imported_program_level_constructs() {
3867        let tmp = TempDir::new().unwrap();
3868        create_test_module(
3869            tmp.path(),
3870            "library",
3871            r#"
3872            pred coin(symbol).
3873            pred left(symbol).
3874            pred right(symbol).
3875            0.5::coin(heads).
3876            0.4::left(choice); 0.6::right(choice).
3877            evidence(coin(heads), true).
3878            :- coin(heads), not left(choice).
3879            nn(classifier, [X], Y, [yes, no]) :: neural_label(X, Y).
3880            learnable(W) :: learned(X) :- source(X).
3881        "#,
3882        );
3883        let entry_source = "use library.\n";
3884        create_test_module(tmp.path(), "entry", entry_source);
3885
3886        let mut resolver = ModuleResolver::new(vec![]);
3887        resolver.load_module(tmp.path(), &["entry".into()]).unwrap();
3888        let error = resolver
3889            .merge_imports(parse_program(entry_source).unwrap())
3890            .unwrap_err()
3891            .to_string();
3892
3893        assert!(error.contains("error[E0405]"), "{error}");
3894        assert!(error.contains("`library`"), "{error}");
3895        assert!(
3896            error.contains(
3897                "annotated disjunctions, evidence statements, integrity constraints, learnable rule templates, neural predicate declarations, probabilistic facts"
3898            ),
3899            "{error}"
3900        );
3901    }
3902
3903    #[test]
3904    fn test_search_paths() {
3905        let tmp = TempDir::new().unwrap();
3906        let lib_dir = tmp.path().join("lib");
3907        fs::create_dir(&lib_dir).unwrap();
3908        create_test_module(&lib_dir, "stdlib", "helper(1).");
3909
3910        let resolver = ModuleResolver::new(vec![lib_dir.clone()]);
3911        let found = resolver.find_module_file(tmp.path(), &["stdlib".into()]);
3912        assert!(found.is_some());
3913        assert!(found.unwrap().starts_with(&lib_dir));
3914    }
3915
3916    #[test]
3917    fn test_function_exports() {
3918        let tmp = TempDir::new().unwrap();
3919        create_test_module(
3920            tmp.path(),
3921            "mathfuncs",
3922            r#"
3923            func square(X) = X * X.
3924            func cube(X) = X * X * X.
3925            private func helper(X) = X.
3926        "#,
3927        );
3928
3929        let mut resolver = ModuleResolver::new(vec![]);
3930        let result = resolver.load_module(tmp.path(), &["mathfuncs".into()]);
3931        assert!(result.is_ok());
3932        let module = result.unwrap();
3933
3934        // Public functions should be exported
3935        assert!(module.function_exports.contains("square"));
3936        assert!(module.function_exports.contains("cube"));
3937
3938        // Private function should not be exported
3939        assert!(!module.function_exports.contains("helper"));
3940    }
3941
3942    #[test]
3943    fn test_mixed_exports() {
3944        let tmp = TempDir::new().unwrap();
3945        create_test_module(
3946            tmp.path(),
3947            "mixed",
3948            r#"
3949            pred value(i64).
3950            value(42).
3951            func double(X) = X * 2.
3952        "#,
3953        );
3954
3955        let mut resolver = ModuleResolver::new(vec![]);
3956        let result = resolver.load_module(tmp.path(), &["mixed".into()]);
3957        assert!(result.is_ok());
3958        let module = result.unwrap();
3959
3960        // Both predicate and function exports should be present
3961        assert!(module.exports.contains("value"));
3962        assert!(module.function_exports.contains("double"));
3963    }
3964
3965    #[test]
3966    fn test_ignored_import_pragmas_lists_imported_module_pragmas_only() {
3967        let tmp = TempDir::new().unwrap();
3968        create_test_module(
3969            tmp.path(),
3970            "entry",
3971            r#"
3972            #pragma magic_sets = auto
3973            use lib.
3974            result(1).
3975        "#,
3976        );
3977        create_test_module(
3978            tmp.path(),
3979            "lib",
3980            r#"
3981            #pragma magic_sets = auto
3982            #pragma prob_seed = 7
3983            helper(1).
3984        "#,
3985        );
3986
3987        let mut resolver = ModuleResolver::new(vec![]);
3988        resolver
3989            .load_module(tmp.path(), &["entry".into()])
3990            .expect("load entry");
3991        resolver.mark_entry_module("entry");
3992
3993        // The entry file's own pragma is authoritative and excluded; the
3994        // imported module's pragmas are listed sorted by module then name.
3995        let ignored = resolver.ignored_import_pragmas();
3996        assert_eq!(
3997            ignored,
3998            vec![
3999                IgnoredImportPragma {
4000                    module: "lib".to_string(),
4001                    pragma: "magic_sets",
4002                },
4003                IgnoredImportPragma {
4004                    module: "lib".to_string(),
4005                    pragma: "prob_seed",
4006                },
4007            ]
4008        );
4009
4010        // Verbatim: the rendered warning format is part of the contract.
4011        assert_eq!(
4012            ignored[0].to_string(),
4013            "warning[W0510]: `#pragma magic_sets` in imported module `lib` is ignored\n  \
4014             = note: pragmas apply only when declared in the entry file"
4015        );
4016    }
4017
4018    #[test]
4019    fn test_ignored_import_pragmas_empty_without_module_pragmas() {
4020        let tmp = TempDir::new().unwrap();
4021        create_test_module(
4022            tmp.path(),
4023            "entry",
4024            r#"
4025            #pragma magic_sets = on
4026            use quiet.
4027            result(1).
4028        "#,
4029        );
4030        create_test_module(tmp.path(), "quiet", "helper(1).");
4031
4032        let mut resolver = ModuleResolver::new(vec![]);
4033        resolver
4034            .load_module(tmp.path(), &["entry".into()])
4035            .expect("load entry");
4036        resolver.mark_entry_module("entry");
4037
4038        assert!(resolver.ignored_import_pragmas().is_empty());
4039    }
4040
4041    #[test]
4042    fn test_ignored_import_pragmas_sorted_across_modules() {
4043        let tmp = TempDir::new().unwrap();
4044        create_test_module(
4045            tmp.path(),
4046            "entry",
4047            r#"
4048            use zeta.
4049            use alpha.
4050            result(1).
4051        "#,
4052        );
4053        create_test_module(
4054            tmp.path(),
4055            "zeta",
4056            r#"
4057            #pragma prob_seed = 3
4058            z(1).
4059        "#,
4060        );
4061        create_test_module(
4062            tmp.path(),
4063            "alpha",
4064            r#"
4065            #pragma magic_sets = off
4066            a(1).
4067        "#,
4068        );
4069
4070        let mut resolver = ModuleResolver::new(vec![]);
4071        resolver
4072            .load_module(tmp.path(), &["entry".into()])
4073            .expect("load entry");
4074        resolver.mark_entry_module("entry");
4075
4076        // Deterministic cross-module order: sorted by module path first.
4077        assert_eq!(
4078            resolver.ignored_import_pragmas(),
4079            vec![
4080                IgnoredImportPragma {
4081                    module: "alpha".to_string(),
4082                    pragma: "magic_sets",
4083                },
4084                IgnoredImportPragma {
4085                    module: "zeta".to_string(),
4086                    pragma: "prob_seed",
4087                },
4088            ]
4089        );
4090    }
4091
4092    #[test]
4093    fn test_ignored_import_pragmas_dedups_two_spellings_of_one_file() {
4094        let tmp = TempDir::new().unwrap();
4095        let util = tmp.path().join("util");
4096        fs::create_dir_all(&util).unwrap();
4097        create_test_module(
4098            tmp.path(),
4099            "entry",
4100            r#"
4101            use util/b.
4102            use util/helpers.
4103            result(1).
4104        "#,
4105        );
4106        create_test_module(
4107            tmp.path(),
4108            "util/b",
4109            r#"
4110            use helpers.
4111            b_pred(1).
4112        "#,
4113        );
4114        create_test_module(
4115            tmp.path(),
4116            "util/helpers",
4117            r#"
4118            #pragma magic_sets = auto
4119            helper(1).
4120        "#,
4121        );
4122
4123        let mut resolver = ModuleResolver::new(vec![]);
4124        resolver
4125            .load_module(tmp.path(), &["entry".into()])
4126            .expect("load entry");
4127        resolver.mark_entry_module("entry");
4128
4129        // The nested `use helpers.` (resolved relative to util/) loads the
4130        // same file under a second path key; one file must warn once, under
4131        // the alphabetically-first label.
4132        assert_eq!(
4133            resolver.ignored_import_pragmas(),
4134            vec![IgnoredImportPragma {
4135                module: "helpers".to_string(),
4136                pragma: "magic_sets",
4137            }]
4138        );
4139    }
4140}