Skip to main content

sui_spec/
module_system.rs

1//! The Nix module system — typed border for the option-merge lattice.
2//!
3//! This module names the load-bearing gap that blocks sui from
4//! evaluating NixOS, nix-darwin, and home-manager configurations.
5//! cppnix's `lib.evalModules` runs a fixed-point over a set of
6//! modules, each declaring `options` (typed slots) and `config`
7//! (definitions for those slots) plus optional `imports` (recursive
8//! module inclusion).  The result is a merged config attrset that
9//! `system.build.toplevel` (and its kin) consume.
10//!
11//! Per the constructive-substrate-engineering pattern, the algorithm
12//! lives here as a typed Rust border + a Lisp spec — both engines
13//! will eventually drive the same `(defmodule-eval-algorithm …)`,
14//! so they cannot drift.  The implementation in `sui-eval` is M2
15//! work; today this module pins down the typed contract every M2
16//! implementation must satisfy.
17//!
18//! ## Authoring surface
19//!
20//! Three keyword forms compose into the full algorithm:
21//!
22//! - `(defoption-type :name "..." :merge-strategy ... :check-kind ...)`
23//!   declares a type in the option-type registry (`bool`, `int`,
24//!   `str`, `path`, `listOf<T>`, `attrsOf<T>`, `submodule`,
25//!   `oneOf [...]`, `nullOr T`, `attrs`, `any`).  The
26//!   `merge-strategy` determines how multiple definitions of the
27//!   same option combine; the `check-kind` determines acceptance.
28//!
29//! - `(defpriority :name "..." :level N :origin ...)` declares one
30//!   priority rank in the priority lattice (`mkDefault 1000`,
31//!   `mkOverride 0`, `mkForce 50`, normal=100 by default).  Higher
32//!   `level` = lower priority (matches cppnix).
33//!
34//! - `(defmodule-eval-algorithm cppnix-module-eval :name ... :phases (...))`
35//!   declares the fixed-point pipeline: collect-modules,
36//!   resolve-imports, evaluate-options, group-definitions,
37//!   resolve-priorities, merge-per-type, type-check, emit-config.
38//!
39//! Future M2 implementation: replace the `apply()` stub with a real
40//! interpreter that walks the phases against an `evalModulesArgs`.
41//! Both `sui-eval` and `sui-bytecode` will call exactly that
42//! function, with exactly the same spec.
43
44use std::collections::HashMap;
45
46use serde::{Deserialize, Serialize};
47use tatara_lisp::DeriveTataraDomain;
48
49use crate::SpecError;
50
51// ── Typed border — option types ────────────────────────────────────
52
53/// One entry in the option-type registry.  Each cppnix type
54/// (`lib.types.<X>`) becomes one `(defoption-type …)` form.
55#[derive(DeriveTataraDomain, Serialize, Deserialize, Debug, Clone)]
56#[tatara(keyword = "defoption-type")]
57pub struct OptionTypeSpec {
58    /// Canonical name (`"bool"`, `"int"`, `"listOf"`, ...).
59    pub name: String,
60    /// How definitions of this type combine when multiple values
61    /// exist at the same priority.
62    #[serde(rename = "mergeStrategy")]
63    pub merge_strategy: MergeStrategy,
64    /// Acceptance check applied per individual definition.
65    #[serde(rename = "checkKind")]
66    pub check_kind: TypeCheckKind,
67    /// For parametric types (`listOf T`, `attrsOf T`, `nullOr T`):
68    /// the name of the element type's `OptionTypeSpec`.
69    #[serde(default, rename = "elementType")]
70    pub element_type: Option<String>,
71    /// For `oneOf [a b c]`: the named candidate types.
72    #[serde(default, rename = "memberTypes")]
73    pub member_types: Vec<String>,
74}
75
76/// How multiple definitions of one option fold into one value.
77///
78/// The cppnix lattice is small: most types are last-wins under
79/// priority resolution; `listOf` concatenates after priority; the
80/// submodule + attrsOf variants recurse.  Custom merges plug in via
81/// a named hook the interpreter resolves.
82#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
83pub enum MergeStrategy {
84    /// One definition wins by priority order (`bool`, `int`, `str`,
85    /// `path`, `package`, `null`, `enum`).  Tie at top priority is
86    /// a typeError.
87    LastWins,
88    /// Concatenate lists in priority order (`listOf T`).  Elements
89    /// from higher priorities come first.
90    Concatenate,
91    /// Recursive submodule eval — apply the module algorithm to the
92    /// definitions of this option (`submodule`).
93    SubmoduleMerge,
94    /// Deep-merge attrsets — recurse on overlapping keys, set-union
95    /// on disjoint (`attrsOf T`, plain `attrs`).
96    AttrsetMerge,
97    /// At most one definition allowed; multiple = typeError
98    /// (`oneOf` after dispatch).
99    Disjoint,
100    /// Plug-in merge function named by string; the interpreter
101    /// resolves the name to a builtin or a user-supplied function.
102    /// Used for `lib.types.either`, `lib.types.functionTo`, etc.
103    Custom,
104    /// `any`-typed: accept any value, last-wins.  Unsafe; only
105    /// permitted for the `any` option type explicitly.
106    AnyLastWins,
107}
108
109/// Per-definition acceptance check.  Run before merging.
110#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
111pub enum TypeCheckKind {
112    Bool,
113    Int,
114    Str,
115    Path,
116    Null,
117    /// Numeric range; details supplied by element_type rendering.
118    IntBetween,
119    /// Anything; no check.
120    Any,
121    /// One-of-string-enum; member_types holds the literals.
122    Enum,
123    /// `listOf T` — every element checks against element_type.
124    ListOf,
125    /// `attrsOf T` — every value checks against element_type.
126    AttrsOf,
127    /// Submodule — invoke recursive module eval.
128    Submodule,
129    /// `oneOf [t1 t2 ...]` — one of member_types succeeds.
130    OneOf,
131    /// `nullOr T` — null OR element_type.
132    NullOr,
133    /// Plain `attrs` — must be an attrset (values unchecked).
134    Attrs,
135    /// `package` — must be a derivation (or path to one).
136    Package,
137    /// `functionTo T` — function whose return type checks.
138    FunctionTo,
139}
140
141// ── Typed border — priority lattice ────────────────────────────────
142
143/// One rank in the priority lattice.  cppnix uses `mkDefault 1000`
144/// (lowest), `default 100`, `mkOverride 50`, `mkForce 50` — lower
145/// `level` value = higher priority.  This typed border lets the
146/// authored spec declare the canonical ranks once.
147#[derive(DeriveTataraDomain, Serialize, Deserialize, Debug, Clone)]
148#[tatara(keyword = "defpriority")]
149pub struct PriorityRank {
150    pub name: String,
151    pub level: u32,
152    pub origin: PriorityOrigin,
153}
154
155/// Where a definition's priority comes from.
156#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
157pub enum PriorityOrigin {
158    /// Set by `mkDefault`.  Level conventionally 1000.
159    MkDefault,
160    /// Plain definition without wrapper.  Level conventionally 100.
161    Normal,
162    /// Set by `mkOverride N`.  Level is the wrapper's argument.
163    MkOverride,
164    /// Set by `mkForce`.  Level conventionally 50.
165    MkForce,
166    /// Set by `mkOptionDefault`.  Level conventionally 1500
167    /// (lower than any user-visible priority).
168    MkOptionDefault,
169}
170
171// ── Typed border — algorithm pipeline ─────────────────────────────
172
173/// The module-evaluation algorithm authored as
174/// `(defmodule-eval-algorithm …)`.  Phases compose left-to-right
175/// over an `EvalModulesArgs` scratchpad; later phases consume
176/// earlier-bound slots.
177#[derive(DeriveTataraDomain, Serialize, Deserialize, Debug, Clone)]
178#[tatara(keyword = "defmodule-eval-algorithm")]
179pub struct ModuleEvalAlgorithm {
180    pub name: String,
181    pub phases: Vec<ModulePhase>,
182}
183
184/// One phase of the module-evaluation pipeline.  Mirrors the shape
185/// of [`crate::derivation::Phase`] for visual + cognitive
186/// consistency across spec domains.
187#[derive(Serialize, Deserialize, Debug, Clone)]
188pub struct ModulePhase {
189    pub kind: ModulePhaseKind,
190    #[serde(default)]
191    pub bind: Option<String>,
192    #[serde(default)]
193    pub from: Option<String>,
194}
195
196/// The closed set of operations any module-eval algorithm composes.
197/// Adding a new variant IS adding a new primitive to the spec
198/// language.
199#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
200pub enum ModulePhaseKind {
201    /// Resolve `imports` transitively, deduplicating by content
202    /// hash.  Each module is just an attrset / function; the
203    /// fixpoint walks until no new imports appear.
204    CollectModules,
205    /// Distinguish `options` from `config` in every module.  Some
206    /// modules are pure-config; others declare options.
207    PartitionOptionsAndConfig,
208    /// Walk option declarations, build a typed option tree.  The
209    /// tree is path-indexed (`services.foo.enable`).
210    BuildOptionTree,
211    /// Walk config definitions, attach each to its option path.
212    /// Wrappers (`mkDefault`, `mkForce`, `mkIf cond`, `mkMerge`)
213    /// are unwrapped into typed [`PriorityRank`] + value pairs.
214    GroupDefinitions,
215    /// Filter out definitions whose `mkIf` predicate is `false`.
216    ResolveConditionals,
217    /// Sort definitions per option by priority (lowest level wins).
218    ResolvePriorities,
219    /// For each option, run the type's [`MergeStrategy`] over the
220    /// surviving definitions.  Submodule strategies recurse into
221    /// this same algorithm.
222    MergePerOption,
223    /// Validate every merged value against its option's
224    /// [`TypeCheckKind`].  Failure produces a typed error.
225    TypeCheck,
226    /// Walk recursive references between options (cppnix's
227    /// `config.foo = config.bar`) until fixpoint.
228    EvaluateRecursive,
229    /// Produce the final `config` attrset.
230    EmitConfig,
231}
232
233// ── Spec interpreter inputs/outputs ──────────────────────────────
234
235/// The substrate's value type for module-system computation.  M2.0
236/// uses `serde_json::Value` because it covers Nix's literal types
237/// (bool, int/float, string, list, attrset, null) with no extra
238/// machinery.  When sui-eval consumes this layer (M2.1), it'll
239/// swap to its lazy `Value` type via an adapter trait.
240pub type NixValue = serde_json::Value;
241
242/// A single module — the cppnix `{ options, config, imports }`
243/// authoring shape, typed.
244#[derive(Debug, Clone, Default)]
245pub struct Module {
246    /// Other modules this one pulls in (by name).  M2.0 doesn't
247    /// resolve these — caller passes pre-flattened module list.
248    pub imports: Vec<String>,
249    /// Option declarations this module contributes.  Keyed by
250    /// option path (`"services.foo.enable"`).
251    pub options: HashMap<String, OptionDecl>,
252    /// Config definitions this module contributes.  Keyed by
253    /// option path.
254    pub config: Vec<Definition>,
255}
256
257/// One option declaration — what cppnix's `mkOption` returns.
258#[derive(Debug, Clone)]
259pub struct OptionDecl {
260    /// Name of the `OptionTypeSpec` in the canonical registry
261    /// (`"bool"`, `"int"`, `"str"`, `"path"`, `"listOf"`, ...).
262    pub type_name: String,
263    /// Default value when no module defines this option.
264    pub default: Option<NixValue>,
265    /// Human-readable description.
266    pub description: String,
267    /// For `type_name == "submodule"`: the submodule's nested
268    /// option declarations + own definitions.  The M2.1
269    /// SubmoduleMerge implementation recurses into
270    /// `eval_modules` against this spec.
271    pub submodule: Option<Box<Module>>,
272}
273
274impl Default for OptionDecl {
275    fn default() -> Self {
276        Self {
277            type_name: "any".into(),
278            default: None,
279            description: String::new(),
280            submodule: None,
281        }
282    }
283}
284
285/// One config definition — a value assigned to an option path, with
286/// a priority rank.  Wrappers like `mkDefault` / `mkForce` /
287/// `mkOverride N` produce these directly with adjusted `priority`.
288#[derive(Debug, Clone)]
289pub struct Definition {
290    /// Option path this defines (`"services.foo.enable"`).
291    pub path: String,
292    /// Value assigned at this path.
293    pub value: NixValue,
294    /// Priority level (lower = wins).  See [`PriorityRank`].
295    pub priority: u32,
296    /// `mkIf cond ...` translates here.  `None` = unconditional
297    /// (the default and most common shape).  `Some(false)` drops
298    /// the definition before priority resolution.  `Some(true)`
299    /// keeps it (equivalent to `None`).
300    ///
301    /// Nested `mkIf` wrappers collapse to a single AND-combined
302    /// bool at translation time — this surface stays Boolean.
303    pub cond: Option<bool>,
304}
305
306/// The output of `eval_modules` — a path-keyed config attrset.
307pub type Config = HashMap<String, NixValue>;
308
309// ── Spec interpreter — M2.0 minimal implementation ───────────────
310
311/// Evaluate a list of modules + their option-type registry, and
312/// return the merged config.  M2.0 implementation: covers LastWins
313/// (bool, int, str, path, package, null), Concatenate (listOf),
314/// AttrsetMerge (attrsOf, attrs), and priority resolution
315/// (mkForce < normal < mkDefault).  Submodules + recursion +
316/// transitive imports are M2.1 work.
317///
318/// # Errors
319///
320/// - `SpecError::Interp { phase: "type-check" }` if a definition's
321///   value doesn't satisfy its option's declared type.
322/// - `SpecError::Interp { phase: "unknown-type" }` if a module
323///   declares an option whose `type_name` isn't in the registry.
324/// - `SpecError::Interp { phase: "unknown-option" }` if a config
325///   definition references an option path that wasn't declared by
326///   any module.
327/// - `SpecError::Interp { phase: "merge-conflict" }` if a non-
328///   mergeable type sees multiple definitions at the same priority.
329pub fn eval_modules(
330    modules: &[Module],
331    option_types: &[OptionTypeSpec],
332) -> Result<Config, SpecError> {
333    // Phase: BuildOptionTree — collect every option declaration into
334    // a path-indexed tree.  When two modules declare the same option,
335    // the first declaration's type wins; cppnix's typeMerge is more
336    // sophisticated (it intersects the types) but for M2.0 we
337    // accept the first declaration.
338    let mut option_tree: HashMap<String, OptionDecl> = HashMap::new();
339    for module in modules {
340        for (path, decl) in &module.options {
341            option_tree.entry(path.clone()).or_insert_with(|| decl.clone());
342        }
343    }
344
345    // Phase: GroupDefinitions + ResolveConditionals — collect every
346    // config definition into per-option lists, filtering out
347    // mkIf-false definitions early.  ResolveConditionals comes
348    // before priority resolution in the cppnix algorithm spec.
349    let mut by_path: HashMap<String, Vec<Definition>> = HashMap::new();
350    for module in modules {
351        for def in &module.config {
352            if def.cond == Some(false) {
353                continue;
354            }
355            by_path.entry(def.path.clone()).or_default().push(def.clone());
356        }
357    }
358
359    // Phase: TypeCheck (early) — every defined option path must
360    // be declared somewhere.  This catches typos at the substrate
361    // level where cppnix would catch them via the option system.
362    for path in by_path.keys() {
363        if !option_tree.contains_key(path) {
364            return Err(SpecError::Interp {
365                phase: "unknown-option".into(),
366                message: format!(
367                    "definition for `{path}` but no module declares this option",
368                ),
369            });
370        }
371    }
372
373    let type_by_name: HashMap<&str, &OptionTypeSpec> =
374        option_types.iter().map(|t| (t.name.as_str(), t)).collect();
375
376    let mut config: Config = HashMap::new();
377
378    for (path, decl) in &option_tree {
379        // Look up the option's declared type.
380        let type_spec = type_by_name.get(decl.type_name.as_str()).ok_or_else(|| {
381            SpecError::Interp {
382                phase: "unknown-type".into(),
383                message: format!(
384                    "option `{path}` declares type `{}` but no \
385                     OptionTypeSpec with that name is in the registry",
386                    decl.type_name,
387                ),
388            }
389        })?;
390
391        // Phase: ResolveConditionals — M2.0 doesn't have mkIf yet;
392        // every definition is treated as active.
393
394        // Phase: ResolvePriorities — sort by priority ascending,
395        // partition by winning priority.
396        let mut defs = by_path.remove(path).unwrap_or_default();
397        if defs.is_empty() {
398            // No definitions; use the default if any.
399            if let Some(default) = &decl.default {
400                config.insert(path.clone(), default.clone());
401            }
402            continue;
403        }
404        defs.sort_by_key(|d| d.priority);
405        let top_priority = defs[0].priority;
406        let winners: Vec<&Definition> =
407            defs.iter().filter(|d| d.priority == top_priority).collect();
408
409        // Phase: TypeCheck (per definition).
410        for d in &winners {
411            check_value(type_spec, &d.value, &d.path)?;
412        }
413
414        // Phase: MergePerOption — dispatch on the type's strategy.
415        // SubmoduleMerge routes through merge_submodule because it
416        // needs the option's nested template + the type registry
417        // for recursion.
418        let merged = if type_spec.merge_strategy == MergeStrategy::SubmoduleMerge {
419            merge_submodule(decl, &winners, path, option_types)?
420        } else {
421            merge_definitions(type_spec, &winners, path)?
422        };
423
424        // Phase: EmitConfig (incremental).
425        config.insert(path.clone(), merged);
426    }
427
428    Ok(config)
429}
430
431/// Per-definition type check.  Tests the value against the option's
432/// declared `check_kind`.
433fn check_value(
434    type_spec: &OptionTypeSpec,
435    value: &NixValue,
436    path: &str,
437) -> Result<(), SpecError> {
438    let ok = match type_spec.check_kind {
439        TypeCheckKind::Bool   => value.is_boolean(),
440        TypeCheckKind::Int    => value.is_i64() || value.is_u64(),
441        TypeCheckKind::Str    => value.is_string(),
442        TypeCheckKind::Path   => value.is_string(),
443        TypeCheckKind::Null   => value.is_null(),
444        TypeCheckKind::Any    => true,
445        TypeCheckKind::Attrs  => value.is_object(),
446        TypeCheckKind::ListOf => value.is_array(),
447        TypeCheckKind::AttrsOf => value.is_object(),
448        TypeCheckKind::NullOr => true, // null OR element-type; M2.0 accepts any
449        TypeCheckKind::Submodule => value.is_object(),
450        // M2.0 doesn't enforce the deeper constraints; M2.1 will.
451        TypeCheckKind::IntBetween | TypeCheckKind::Enum
452        | TypeCheckKind::OneOf | TypeCheckKind::Package
453        | TypeCheckKind::FunctionTo => true,
454    };
455    if !ok {
456        return Err(SpecError::Interp {
457            phase: "type-check".into(),
458            message: format!(
459                "option `{path}` declared `{}` but value is `{}`: {}",
460                type_spec.name,
461                value_type(value),
462                truncate_value(value),
463            ),
464        });
465    }
466    Ok(())
467}
468
469fn value_type(v: &NixValue) -> &'static str {
470    if v.is_boolean() { "bool" }
471    else if v.is_i64() || v.is_u64() { "int" }
472    else if v.is_f64() { "float" }
473    else if v.is_string() { "str" }
474    else if v.is_array() { "list" }
475    else if v.is_object() { "attrs" }
476    else if v.is_null() { "null" }
477    else { "unknown" }
478}
479
480fn truncate_value(v: &NixValue) -> String {
481    let s = v.to_string();
482    if s.len() <= 60 { s } else { format!("{}…", &s[..60]) }
483}
484
485/// Apply the type's merge strategy to a slice of definitions at the
486/// winning priority.
487fn merge_definitions(
488    type_spec: &OptionTypeSpec,
489    winners: &[&Definition],
490    path: &str,
491) -> Result<NixValue, SpecError> {
492    match type_spec.merge_strategy {
493        MergeStrategy::LastWins | MergeStrategy::AnyLastWins => {
494            // Strict cppnix: tie at top priority is an error.  But
495            // many real configs have multiple `mkDefault` definitions
496            // that happen to be identical — those are fine.
497            if winners.len() > 1 {
498                let first = &winners[0].value;
499                let all_equal = winners.iter().all(|d| &d.value == first);
500                if !all_equal {
501                    return Err(SpecError::Interp {
502                        phase: "merge-conflict".into(),
503                        message: format!(
504                            "option `{path}` has {} distinct top-priority \
505                             definitions; LastWins requires either one \
506                             definition at the top or all equal",
507                            winners.len(),
508                        ),
509                    });
510                }
511            }
512            Ok(winners[0].value.clone())
513        }
514        MergeStrategy::Concatenate => {
515            // Lists: concatenate in priority-then-author order.
516            // M2.0: winners are at the same priority; concatenate
517            // in document order.
518            let mut acc: Vec<NixValue> = Vec::new();
519            for w in winners {
520                let Some(arr) = w.value.as_array() else {
521                    return Err(SpecError::Interp {
522                        phase: "type-check".into(),
523                        message: format!(
524                            "option `{path}` is Concatenate (listOf) \
525                             but a definition value is not a list",
526                        ),
527                    });
528                };
529                for item in arr {
530                    acc.push(item.clone());
531                }
532            }
533            Ok(NixValue::Array(acc))
534        }
535        MergeStrategy::AttrsetMerge => {
536            // attrsOf / attrs: deep-merge keys.
537            let mut acc = serde_json::Map::new();
538            for w in winners {
539                let Some(obj) = w.value.as_object() else {
540                    return Err(SpecError::Interp {
541                        phase: "type-check".into(),
542                        message: format!(
543                            "option `{path}` is AttrsetMerge but a \
544                             definition value is not an attrset",
545                        ),
546                    });
547                };
548                for (k, v) in obj {
549                    acc.insert(k.clone(), v.clone());
550                }
551            }
552            Ok(NixValue::Object(acc))
553        }
554        MergeStrategy::Disjoint => {
555            if winners.len() > 1 {
556                return Err(SpecError::Interp {
557                    phase: "merge-conflict".into(),
558                    message: format!(
559                        "option `{path}` is Disjoint (oneOf) but has \
560                         {} top-priority definitions",
561                        winners.len(),
562                    ),
563                });
564            }
565            Ok(winners[0].value.clone())
566        }
567        MergeStrategy::SubmoduleMerge => {
568            // M2.1: SubmoduleMerge recurses into eval_modules with
569            // the option's submodule spec as the option tree and
570            // each winning definition's attrset values as
571            // definitions.  Caller-provided OptionDecl::submodule
572            // is the schema; the values come from `winners`.
573            //
574            // The merge is left-to-right: later winners override
575            // earlier ones per-key, but identical priorities at
576            // sub-options still merge per their own type strategy.
577            // (Achieved by passing all winning attrsets as
578            // multiple module-shaped contributions.)
579            let mut synth_options: HashMap<String, OptionDecl> = HashMap::new();
580            // The submodule template must be known — caller looks
581            // it up on the OptionDecl, which we don't have direct
582            // access to here.  Hence the caller passes it via the
583            // `submodule_for` lookup wired in eval_modules.
584            // For M2.1 the merge_definitions surface is extended
585            // through a sibling helper in eval_modules; see
586            // `eval_modules` for the dispatch on submodule.
587            //
588            // This arm is reached when the caller failed to wire
589            // the submodule properly — surface the gap clearly.
590            let _ = (&mut synth_options, winners, path);
591            Err(SpecError::Interp {
592                phase: "submodule-misuse".into(),
593                message: format!(
594                    "option `{path}` is submodule-typed but \
595                     merge_definitions was called without the \
596                     submodule context — eval_modules must dispatch \
597                     submodule merges directly via merge_submodule",
598                ),
599            })
600        }
601        MergeStrategy::Custom => {
602            Err(SpecError::Interp {
603                phase: "merge-unimplemented".into(),
604                message: format!(
605                    "option `{path}` uses Custom merge strategy — \
606                     M2.x will land the named-merge-function registry",
607                ),
608            })
609        }
610    }
611}
612
613/// SubmoduleMerge dispatcher — recurses into `eval_modules` against
614/// the option's submodule spec.  Called directly by `eval_modules`
615/// when it sees an option with `MergeStrategy::SubmoduleMerge`,
616/// because only `eval_modules` has the `option_types` registry +
617/// the OptionDecl::submodule context.
618fn merge_submodule(
619    decl: &OptionDecl,
620    winners: &[&Definition],
621    path: &str,
622    option_types: &[OptionTypeSpec],
623) -> Result<NixValue, SpecError> {
624    let Some(sub_template) = decl.submodule.as_deref() else {
625        return Err(SpecError::Interp {
626            phase: "submodule-missing-spec".into(),
627            message: format!(
628                "option `{path}` is submodule-typed but its OptionDecl \
629                 has no `submodule` field set — declare the nested \
630                 Module schema",
631            ),
632        });
633    };
634    // Each winning definition's value must be an attrset
635    // representing a partial submodule.  Build one synthetic Module
636    // per winner, all carrying the submodule's own options.
637    let mut synthetic: Vec<Module> = Vec::new();
638    for w in winners {
639        let Some(obj) = w.value.as_object() else {
640            return Err(SpecError::Interp {
641                phase: "type-check".into(),
642                message: format!(
643                    "submodule option `{path}` definition must be an \
644                     attrset, got `{}`",
645                    value_type(&w.value),
646                ),
647            });
648        };
649        let mut sub_defs: Vec<Definition> = Vec::new();
650        for (key, value) in obj {
651            sub_defs.push(Definition {
652                path: key.clone(),
653                value: value.clone(),
654                priority: w.priority,
655                cond: None,
656            });
657        }
658        synthetic.push(Module {
659            imports: Vec::new(),
660            options: sub_template.options.clone(),
661            config: sub_defs,
662        });
663    }
664    // Submodules carry their OWN config definitions from the
665    // template too — surface those.
666    if !sub_template.config.is_empty() {
667        synthetic.push((**decl.submodule.as_ref().unwrap()).clone());
668    }
669    let sub_config = eval_modules(&synthetic, option_types)?;
670    // Serialise the sub-config map back to a JSON object.
671    let mut obj = serde_json::Map::new();
672    for (k, v) in sub_config {
673        obj.insert(k, v);
674    }
675    Ok(NixValue::Object(obj))
676}
677
678/// Flatten a tree of modules by resolving `imports` transitively.
679/// The `resolver` closure receives an import name and must return
680/// the corresponding Module; returning `None` errors.  Imports are
681/// deduplicated by name — the same name imported from multiple
682/// modules contributes its options + config exactly once.
683///
684/// Caller invokes `flatten_imports` then passes the result to
685/// `eval_modules`.  The split keeps `eval_modules` pure-data and
686/// easy to test in isolation.
687///
688/// # Errors
689///
690/// Returns `SpecError::Load` if `resolver` returns `None` for an
691/// import name; returns `SpecError::Interp` with phase
692/// `imports-cycle` if the import graph contains a cycle.
693pub fn flatten_imports<F>(
694    roots: &[Module],
695    mut resolver: F,
696) -> Result<Vec<Module>, SpecError>
697where
698    F: FnMut(&str) -> Option<Module>,
699{
700    let mut seen: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
701    let mut in_path: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
702    let mut out: Vec<Module> = Vec::new();
703
704    fn visit<F>(
705        module: &Module,
706        name: Option<&str>,
707        resolver: &mut F,
708        seen: &mut std::collections::BTreeSet<String>,
709        in_path: &mut std::collections::BTreeSet<String>,
710        out: &mut Vec<Module>,
711    ) -> Result<(), SpecError>
712    where F: FnMut(&str) -> Option<Module>,
713    {
714        // Recurse into imports first (depth-first).
715        // Standard white/gray/black DFS cycle detection:
716        //  - in_path tracks the currently-active visit stack.
717        //  - seen tracks completed traversals (for dedup).
718        // Cycle is detected when an import refers to a name
719        // already in in_path.
720        for import_name in &module.imports {
721            if in_path.contains(import_name) {
722                return Err(SpecError::Interp {
723                    phase: "imports-cycle".into(),
724                    message: format!(
725                        "import cycle detected involving `{import_name}`",
726                    ),
727                });
728            }
729            if !seen.insert(import_name.clone()) {
730                continue;
731            }
732            in_path.insert(import_name.clone());
733            let imported = resolver(import_name).ok_or_else(|| {
734                SpecError::Load(format!("import `{import_name}` not found"))
735            })?;
736            visit(&imported, Some(import_name), resolver, seen, in_path, out)?;
737            in_path.remove(import_name);
738        }
739        // Then push the module itself (so leaves come before
740        // dependents — same ordering convention as the substrate
741        // dependency graph).
742        let _ = name;
743        out.push(module.clone());
744        Ok(())
745    }
746
747    for root in roots {
748        visit(root, None, &mut resolver, &mut seen, &mut in_path, &mut out)?;
749    }
750    Ok(out)
751}
752
753/// Legacy `apply()` — kept for backwards compatibility with the
754/// previous typed-NotYet API surface.  Returns a typed not-yet
755/// error since the typed pipeline driven by `algo.phases` requires
756/// the sui-eval Value type that lands at M2.1.  New code should
757/// use [`eval_modules`] directly.
758///
759/// # Errors
760///
761/// Always returns `SpecError::Interp { phase: "module-eval" }` —
762/// the pipeline-driven interpretation is M2.1 work.  Use
763/// [`eval_modules`] for the M2.0 direct implementation.
764pub fn apply(
765    _algo: &ModuleEvalAlgorithm,
766    _args: EvalModulesArgs,
767) -> Result<String, SpecError> {
768    Err(SpecError::Interp {
769        phase: "module-eval".into(),
770        message: "pipeline-driven apply() awaits the sui-eval Value \
771                  bridge (M2.1).  The M2.0 minimal interpreter is in \
772                  eval_modules() — call that directly with typed \
773                  Module values".into(),
774    })
775}
776
777/// Legacy `EvalModulesArgs` — kept for backwards compatibility with
778/// the `apply()` surface.  New code uses the [`Module`] +
779/// [`eval_modules`] API directly.
780pub struct EvalModulesArgs {
781    pub initial_modules: Vec<String>,
782    pub scratchpad: HashMap<String, String>,
783}
784
785impl EvalModulesArgs {
786    #[must_use]
787    pub fn new(initial_modules: Vec<String>) -> Self {
788        Self { initial_modules, scratchpad: HashMap::new() }
789    }
790}
791
792// ── Canonical specs, compiled in ───────────────────────────────────
793
794pub const CANONICAL_MODULE_SYSTEM_LISP: &str =
795    include_str!("../specs/module_system.lisp");
796
797/// Compile the canonical module-system specs.  Returns
798/// `(algorithms, option_types, priorities)`.
799///
800/// # Errors
801///
802/// Returns an error if the Lisp source fails to parse.  Empty
803/// algorithms vector is fine — the file may grow incrementally as
804/// the M2 work scopes additional algorithms.
805pub fn load_canonical() -> Result<CanonicalSpecs, SpecError> {
806    let algos = crate::loader::load_all::<ModuleEvalAlgorithm>(
807        CANONICAL_MODULE_SYSTEM_LISP,
808    )?;
809    let types = crate::loader::load_all::<OptionTypeSpec>(
810        CANONICAL_MODULE_SYSTEM_LISP,
811    )?;
812    let priorities = crate::loader::load_all::<PriorityRank>(
813        CANONICAL_MODULE_SYSTEM_LISP,
814    )?;
815    Ok(CanonicalSpecs { algos, types, priorities })
816}
817
818/// The three typed surfaces, loaded from one Lisp file.
819pub struct CanonicalSpecs {
820    pub algos: Vec<ModuleEvalAlgorithm>,
821    pub types: Vec<OptionTypeSpec>,
822    pub priorities: Vec<PriorityRank>,
823}
824
825#[cfg(test)]
826mod tests {
827    use super::*;
828
829    #[test]
830    fn canonical_specs_parse() {
831        let specs = load_canonical().expect("canonical specs must compile");
832        // The corpus must contain at least the cppnix-baseline
833        // algorithm.  Empty vectors are not a regression today
834        // (incremental authoring) but the named algorithm must
835        // exist.
836        assert!(
837            specs.algos.iter().any(|a| a.name == "cppnix-module-eval"),
838            "canonical specs must contain `cppnix-module-eval` algorithm",
839        );
840    }
841
842    #[test]
843    fn algorithm_has_expected_phases() {
844        let specs = load_canonical().unwrap();
845        let cppnix = specs
846            .algos
847            .iter()
848            .find(|a| a.name == "cppnix-module-eval")
849            .expect("cppnix algorithm must exist");
850        let kinds: Vec<ModulePhaseKind> =
851            cppnix.phases.iter().map(|p| p.kind).collect();
852        for required in [
853            ModulePhaseKind::CollectModules,
854            ModulePhaseKind::ResolvePriorities,
855            ModulePhaseKind::MergePerOption,
856            ModulePhaseKind::TypeCheck,
857            ModulePhaseKind::EmitConfig,
858        ] {
859            assert!(
860                kinds.contains(&required),
861                "cppnix-module-eval missing required phase: {required:?}",
862            );
863        }
864    }
865
866    #[test]
867    fn canonical_option_types_cover_core() {
868        let specs = load_canonical().unwrap();
869        let names: std::collections::HashSet<&str> =
870            specs.types.iter().map(|t| t.name.as_str()).collect();
871        // The seven types every NixOS config touches must be in
872        // the registry from day one.  If any of these disappear,
873        // the option-merge surface is incomplete.
874        for required in ["bool", "int", "str", "path", "listOf", "attrsOf", "submodule"] {
875            assert!(
876                names.contains(required),
877                "canonical option-type registry missing `{required}`",
878            );
879        }
880    }
881
882    #[test]
883    fn canonical_priorities_cover_core() {
884        let specs = load_canonical().unwrap();
885        let names: std::collections::HashSet<&str> =
886            specs.priorities.iter().map(|p| p.name.as_str()).collect();
887        for required in ["mkDefault", "normal", "mkForce", "mkOptionDefault"] {
888            assert!(
889                names.contains(required),
890                "canonical priority lattice missing `{required}`",
891            );
892        }
893    }
894
895    #[test]
896    fn pipeline_apply_still_typed_not_yet() {
897        // The pipeline-driven apply() awaits the sui-eval Value
898        // bridge (M2.1).  Until then, calling it surfaces a typed
899        // error so consumers know which API to use.
900        let algo = ModuleEvalAlgorithm {
901            name: "test".into(),
902            phases: vec![],
903        };
904        let err = apply(&algo, EvalModulesArgs::new(vec![]))
905            .expect_err("pipeline apply must return error until M2.1");
906        match err {
907            SpecError::Interp { phase, message } => {
908                assert_eq!(phase, "module-eval");
909                assert!(message.contains("M2.1") || message.contains("eval_modules"));
910            }
911            _ => panic!("expected SpecError::Interp, got {err:?}"),
912        }
913    }
914
915    // ── M2.0 minimal interpreter tests ─────────────────────────
916
917    fn registry() -> Vec<OptionTypeSpec> {
918        load_canonical().unwrap().types
919    }
920
921    fn opt(type_name: &str) -> OptionDecl {
922        OptionDecl {
923            type_name: type_name.into(),
924            ..Default::default()
925        }
926    }
927
928    fn opt_with_default(type_name: &str, default: NixValue) -> OptionDecl {
929        OptionDecl {
930            type_name: type_name.into(),
931            default: Some(default),
932            ..Default::default()
933        }
934    }
935
936    fn opt_submodule(template: Module) -> OptionDecl {
937        OptionDecl {
938            type_name: "submodule".into(),
939            submodule: Some(Box::new(template)),
940            ..Default::default()
941        }
942    }
943
944    fn def(path: &str, value: NixValue) -> Definition {
945        Definition { path: path.into(), value, priority: 100, cond: None }
946    }
947
948    fn def_if(path: &str, value: NixValue, cond: bool) -> Definition {
949        Definition { path: path.into(), value, priority: 100, cond: Some(cond) }
950    }
951
952    #[test]
953    fn eval_modules_trivial_bool_passes_through() {
954        let mut module = Module::default();
955        module.options.insert("foo".into(), opt("bool"));
956        module.config.push(def("foo", NixValue::Bool(true)));
957        let config = eval_modules(&[module], &registry()).unwrap();
958        assert_eq!(config.get("foo"), Some(&NixValue::Bool(true)));
959    }
960
961    #[test]
962    fn eval_modules_default_when_no_definition() {
963        let mut module = Module::default();
964        module.options.insert(
965            "foo".into(),
966            opt_with_default("bool", NixValue::Bool(false)),
967        );
968        let config = eval_modules(&[module], &registry()).unwrap();
969        assert_eq!(config.get("foo"), Some(&NixValue::Bool(false)));
970    }
971
972    #[test]
973    fn eval_modules_listof_concatenates() {
974        let mut m1 = Module::default();
975        m1.options.insert("xs".into(), opt("listOf"));
976        m1.config.push(def("xs", serde_json::json!([1, 2])));
977        let mut m2 = Module::default();
978        m2.config.push(def("xs", serde_json::json!([3, 4])));
979        let config = eval_modules(&[m1, m2], &registry()).unwrap();
980        assert_eq!(config.get("xs"), Some(&serde_json::json!([1, 2, 3, 4])));
981    }
982
983    #[test]
984    fn eval_modules_attrsof_deep_merges() {
985        let mut m1 = Module::default();
986        m1.options.insert("attrs".into(), opt("attrsOf"));
987        m1.config.push(def("attrs", serde_json::json!({"a": 1})));
988        let mut m2 = Module::default();
989        m2.config.push(def("attrs", serde_json::json!({"b": 2})));
990        let config = eval_modules(&[m1, m2], &registry()).unwrap();
991        assert_eq!(config.get("attrs"), Some(&serde_json::json!({"a": 1, "b": 2})));
992    }
993
994    #[test]
995    fn eval_modules_higher_priority_wins() {
996        let mut module = Module::default();
997        module.options.insert("foo".into(), opt("int"));
998        module.config.push(Definition {
999            path: "foo".into(),
1000            value: NixValue::from(7),
1001            priority: 1000, // mkDefault
1002            cond: None,
1003        });
1004        module.config.push(Definition {
1005            path: "foo".into(),
1006            value: NixValue::from(99),
1007            priority: 50,   // mkForce
1008            cond: None,
1009        });
1010        let config = eval_modules(&[module], &registry()).unwrap();
1011        assert_eq!(config.get("foo"), Some(&NixValue::from(99)));
1012    }
1013
1014    #[test]
1015    fn eval_modules_type_check_rejects_wrong_type() {
1016        let mut module = Module::default();
1017        module.options.insert("foo".into(), opt("bool"));
1018        module.config.push(def("foo", NixValue::from(42))); // int!
1019        let err = eval_modules(&[module], &registry()).unwrap_err();
1020        match err {
1021            SpecError::Interp { phase, message } => {
1022                assert_eq!(phase, "type-check");
1023                assert!(message.contains("foo"));
1024                assert!(message.contains("bool"));
1025            }
1026            _ => panic!("expected type-check error"),
1027        }
1028    }
1029
1030    #[test]
1031    fn eval_modules_rejects_unknown_option() {
1032        let module = Module {
1033            imports: vec![],
1034            options: HashMap::new(),
1035            config: vec![def("undeclared.path", NixValue::Bool(true))],
1036        };
1037        let err = eval_modules(&[module], &registry()).unwrap_err();
1038        match err {
1039            SpecError::Interp { phase, message } => {
1040                assert_eq!(phase, "unknown-option");
1041                assert!(message.contains("undeclared.path"));
1042            }
1043            _ => panic!("expected unknown-option error"),
1044        }
1045    }
1046
1047    #[test]
1048    fn eval_modules_rejects_unknown_type_name() {
1049        let mut module = Module::default();
1050        module.options.insert("foo".into(), opt("nonexistent-type"));
1051        let err = eval_modules(&[module], &registry()).unwrap_err();
1052        match err {
1053            SpecError::Interp { phase, message } => {
1054                assert_eq!(phase, "unknown-type");
1055                assert!(message.contains("nonexistent-type"));
1056            }
1057            _ => panic!("expected unknown-type error"),
1058        }
1059    }
1060
1061    #[test]
1062    fn eval_modules_lastwins_tie_at_top_priority_with_identical_value_passes() {
1063        let mut m1 = Module::default();
1064        m1.options.insert("foo".into(), opt("str"));
1065        m1.config.push(def("foo", NixValue::from("hello")));
1066        let mut m2 = Module::default();
1067        m2.config.push(def("foo", NixValue::from("hello")));
1068        let config = eval_modules(&[m1, m2], &registry()).unwrap();
1069        assert_eq!(config.get("foo"), Some(&NixValue::from("hello")));
1070    }
1071
1072    #[test]
1073    fn eval_modules_lastwins_tie_at_top_priority_distinct_errors() {
1074        let mut m1 = Module::default();
1075        m1.options.insert("foo".into(), opt("str"));
1076        m1.config.push(def("foo", NixValue::from("a")));
1077        let mut m2 = Module::default();
1078        m2.config.push(def("foo", NixValue::from("b")));
1079        let err = eval_modules(&[m1, m2], &registry()).unwrap_err();
1080        match err {
1081            SpecError::Interp { phase, .. } => assert_eq!(phase, "merge-conflict"),
1082            _ => panic!("expected merge-conflict"),
1083        }
1084    }
1085
1086    // ── M2.1 tests: mkIf, SubmoduleMerge, flatten_imports ──────
1087
1088    #[test]
1089    fn mkif_false_drops_definition() {
1090        let mut module = Module::default();
1091        module.options.insert(
1092            "foo".into(),
1093            opt_with_default("bool", NixValue::Bool(false)),
1094        );
1095        module.config.push(def_if("foo", NixValue::Bool(true), false));
1096        let config = eval_modules(&[module], &registry()).unwrap();
1097        // mkIf false dropped the def; default kicks in.
1098        assert_eq!(config.get("foo"), Some(&NixValue::Bool(false)));
1099    }
1100
1101    #[test]
1102    fn mkif_true_keeps_definition() {
1103        let mut module = Module::default();
1104        module.options.insert(
1105            "foo".into(),
1106            opt_with_default("bool", NixValue::Bool(false)),
1107        );
1108        module.config.push(def_if("foo", NixValue::Bool(true), true));
1109        let config = eval_modules(&[module], &registry()).unwrap();
1110        assert_eq!(config.get("foo"), Some(&NixValue::Bool(true)));
1111    }
1112
1113    #[test]
1114    fn mkif_filters_one_def_in_a_list_of_definitions() {
1115        // Two definitions for a listOf; one is mkIf-false and
1116        // dropped, the other survives.
1117        let mut module = Module::default();
1118        module.options.insert("xs".into(), opt("listOf"));
1119        module.config.push(def("xs", serde_json::json!([1, 2])));
1120        module.config.push(def_if("xs", serde_json::json!([99]), false));
1121        let config = eval_modules(&[module], &registry()).unwrap();
1122        // [99] was dropped; only [1, 2] survives.
1123        assert_eq!(config.get("xs"), Some(&serde_json::json!([1, 2])));
1124    }
1125
1126    #[test]
1127    fn submodule_evaluates_nested_options() {
1128        // The inner submodule template:
1129        //   options.enable = mkOption { type = bool; default = false; };
1130        //   options.port   = mkOption { type = int; default = 80; };
1131        let mut sub_template = Module::default();
1132        sub_template.options.insert(
1133            "enable".into(),
1134            opt_with_default("bool", NixValue::Bool(false)),
1135        );
1136        sub_template.options.insert(
1137            "port".into(),
1138            opt_with_default("int", NixValue::from(80)),
1139        );
1140
1141        // The outer module wraps the submodule as an option.
1142        let mut outer = Module::default();
1143        outer.options.insert("foo".into(), opt_submodule(sub_template));
1144        outer.config.push(def(
1145            "foo",
1146            serde_json::json!({"enable": true, "port": 8080}),
1147        ));
1148
1149        let config = eval_modules(&[outer], &registry()).unwrap();
1150        let foo = config.get("foo").expect("foo must resolve");
1151        assert_eq!(foo.get("enable"), Some(&NixValue::Bool(true)));
1152        assert_eq!(foo.get("port"), Some(&NixValue::from(8080)));
1153    }
1154
1155    #[test]
1156    fn submodule_picks_up_inner_defaults_when_unset() {
1157        let mut sub_template = Module::default();
1158        sub_template.options.insert(
1159            "enable".into(),
1160            opt_with_default("bool", NixValue::Bool(false)),
1161        );
1162        sub_template.options.insert(
1163            "port".into(),
1164            opt_with_default("int", NixValue::from(80)),
1165        );
1166
1167        let mut outer = Module::default();
1168        outer.options.insert("foo".into(), opt_submodule(sub_template));
1169        // Only `enable` defined; port should fall back to inner default.
1170        outer.config.push(def("foo", serde_json::json!({"enable": true})));
1171
1172        let config = eval_modules(&[outer], &registry()).unwrap();
1173        let foo = config.get("foo").unwrap();
1174        assert_eq!(foo.get("enable"), Some(&NixValue::Bool(true)));
1175        assert_eq!(foo.get("port"), Some(&NixValue::from(80)));
1176    }
1177
1178    #[test]
1179    fn submodule_type_checks_inner_values() {
1180        let mut sub_template = Module::default();
1181        sub_template.options.insert("enable".into(), opt("bool"));
1182
1183        let mut outer = Module::default();
1184        outer.options.insert("foo".into(), opt_submodule(sub_template));
1185        // `enable` declared as bool but config gives int — should
1186        // surface as type-check error during recursion.
1187        outer.config.push(def(
1188            "foo",
1189            serde_json::json!({"enable": 42}),
1190        ));
1191
1192        let err = eval_modules(&[outer], &registry()).unwrap_err();
1193        match err {
1194            SpecError::Interp { phase, .. } => assert_eq!(phase, "type-check"),
1195            _ => panic!("expected type-check error"),
1196        }
1197    }
1198
1199    #[test]
1200    fn flatten_imports_resolves_one_level() {
1201        let inner = Module {
1202            imports: vec![],
1203            options: {
1204                let mut h = HashMap::new();
1205                h.insert("nested".into(), opt("str"));
1206                h
1207            },
1208            config: vec![],
1209        };
1210        let root = Module {
1211            imports: vec!["inner".into()],
1212            options: HashMap::new(),
1213            config: vec![def("nested", NixValue::from("from-root"))],
1214        };
1215        let inner_for_resolve = inner.clone();
1216        let flat = flatten_imports(&[root], move |name| {
1217            if name == "inner" { Some(inner_for_resolve.clone()) } else { None }
1218        })
1219        .unwrap();
1220        // Flatten must include `inner` before `root` (leaves first).
1221        assert_eq!(flat.len(), 2);
1222        assert!(flat[0].options.contains_key("nested"));
1223        assert!(!flat[0].config.iter().any(|d| d.path == "nested"));
1224        assert!(flat[1].config.iter().any(|d| d.path == "nested"));
1225        // Now evaluate the flat list.
1226        let config = eval_modules(&flat, &registry()).unwrap();
1227        assert_eq!(config.get("nested"), Some(&NixValue::from("from-root")));
1228    }
1229
1230    #[test]
1231    fn flatten_imports_dedups_repeated() {
1232        // A diamond import — two roots both importing `common`.
1233        let common = Module::default();
1234        let root_a = Module {
1235            imports: vec!["common".into()],
1236            options: HashMap::new(),
1237            config: vec![],
1238        };
1239        let root_b = Module {
1240            imports: vec!["common".into()],
1241            options: HashMap::new(),
1242            config: vec![],
1243        };
1244        let common_for_resolve = common.clone();
1245        let flat = flatten_imports(&[root_a, root_b], move |name| {
1246            if name == "common" { Some(common_for_resolve.clone()) } else { None }
1247        })
1248        .unwrap();
1249        // `common` appears exactly once + the two roots = 3 modules
1250        // total, NOT 4 (no double-import).
1251        assert_eq!(flat.len(), 3);
1252    }
1253
1254    #[test]
1255    fn flatten_imports_detects_cycle() {
1256        // A → B → A
1257        let a = Module { imports: vec!["b".into()], ..Default::default() };
1258        let b = Module { imports: vec!["a".into()], ..Default::default() };
1259        let a_for_resolve = a.clone();
1260        let b_for_resolve = b.clone();
1261        let err = flatten_imports(&[a.clone()], move |name| match name {
1262            "a" => Some(a_for_resolve.clone()),
1263            "b" => Some(b_for_resolve.clone()),
1264            _ => None,
1265        })
1266        .unwrap_err();
1267        match err {
1268            SpecError::Interp { phase, .. } => assert_eq!(phase, "imports-cycle"),
1269            _ => panic!("expected imports-cycle"),
1270        }
1271    }
1272
1273    #[test]
1274    fn flatten_imports_unknown_name_errors() {
1275        let root = Module {
1276            imports: vec!["nope".into()],
1277            options: HashMap::new(),
1278            config: vec![],
1279        };
1280        let err = flatten_imports(&[root], |_| None).unwrap_err();
1281        match err {
1282            SpecError::Load(msg) => assert!(msg.contains("nope")),
1283            _ => panic!("expected Load error for missing import"),
1284        }
1285    }
1286
1287    #[test]
1288    fn priority_ordering_matches_cppnix_convention() {
1289        let specs = load_canonical().unwrap();
1290        let level = |n: &str| -> u32 {
1291            specs.priorities.iter()
1292                .find(|p| p.name == n)
1293                .expect(n)
1294                .level
1295        };
1296        // mkForce < normal < mkDefault < mkOptionDefault (lower = wins)
1297        assert!(level("mkForce") < level("normal"));
1298        assert!(level("normal") < level("mkDefault"));
1299        assert!(level("mkDefault") < level("mkOptionDefault"));
1300    }
1301}