Skip to main content

xlog_logic/
resolver.rs

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