Skip to main content

sui_spec/
module_solver.rs

1//! L4 module-system solver — slice-keyed re-firing over a compiled
2//! [`ModuleGraph`].
3//!
4//! ## What this does today
5//!
6//! The dependency-tracking core of the fixed-point solver. Given a
7//! compiled [`ModuleGraph`] (option declarations + config setters with
8//! `slice` + `assigns_path` metadata, all populated by
9//! `sui-spec::module_compiler`):
10//!
11//! 1. **Topologically order** the setters by the writes/reads
12//!    dependency: setter A writes `services.atticd.enable`, setter B
13//!    reads it via `mkIf config.services.atticd.enable …` → A fires
14//!    before B.
15//! 2. **Track dirty paths** — the set of `config.*` paths that have
16//!    changed since the last cycle.
17//! 3. **Schedule setters whose slice intersects the dirty set** —
18//!    everything else stays untouched. A leaf setter (empty slice)
19//!    fires exactly once unless its assigns_path itself becomes
20//!    dirty (e.g. via mkForce in a downstream module).
21//! 4. **Run to quiescence** — re-iterate until no more setters need
22//!    to fire.
23//!
24//! ## What this does NOT do today (queued)
25//!
26//! * **Body evaluation** — setter bodies are AST node ids; actually
27//!   running them through the bytecode VM and producing typed values
28//!   requires the sui-eval integration. Today the solver carries a
29//!   [`BodyEvaluator`] trait the eval crate will implement;
30//!   [`StubEvaluator`] in the test module returns a deterministic
31//!   placeholder so the solver math is provable in isolation.
32//! * **Defunctionalization** — higher-order setters stay AST-pointer-
33//!   only. The transform that lowers them to first-order tagged
34//!   closures lands next.
35//! * **NbE structural-equality caching** — cache key for the compiled
36//!   closure uses [`ModuleGraph::canonical_hash`]; NbE-driven
37//!   canonicalization (equal-up-to-alpha for setter bodies) is the
38//!   next optimization layer.
39//! * **Rayon-per-SCC parallelism** — today's solver is single-threaded
40//!   (Kahn's queue order). The opportunistic-parallelism work cited in
41//!   the eval-engine research (arxiv:2405.11361) lands when bodies
42//!   evaluate for real.
43//!
44//! ## Why this matters
45//!
46//! The 24-second `nixosConfigurations.rio.config.system.build.toplevel`
47//! cost is the cppnix module-system fixed point re-evaluating every
48//! setter on every rebuild. With slice-keyed re-firing, a rebuild
49//! where (say) only `services.atticd.enable` changed re-fires ONLY the
50//! setters whose slice contains that path — for the rio fleet that
51//! drops from ~2000 setters to a small handful. The math is here
52//! today; the eval integration in the next ship makes the perf
53//! visible to operators.
54
55use std::collections::{BTreeMap, BTreeSet, VecDeque};
56use std::sync::Arc;
57
58use crate::ast_evaluator::{eval_node, EvalEnv, EvalValue};
59use crate::ast_graph::AstGraph;
60use crate::module_graph::{
61    ConfigSetter, EnvPrefixBinding, EnvPrefixKind, ModuleGraph, ModuleId, ModuleNode, SetterId,
62};
63
64/// A canonical config path, e.g. `["services", "atticd", "enable"]`.
65pub type ConfigPath = Vec<String>;
66
67/// A setter's identity inside a [`ModuleGraph`]: `(module_id,
68/// setter_id_within_module)`. Each setter is unique by this pair —
69/// `module_id` disambiguates because the same `setter_id_within_module`
70/// can collide across modules.
71#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
72pub struct GlobalSetterId {
73    pub module: ModuleId,
74    pub setter: SetterId,
75}
76
77/// Errors from the solver.
78#[derive(Debug, thiserror::Error)]
79pub enum SolverError {
80    #[error("dependency cycle detected involving setters {0:?}")]
81    Cycle(Vec<GlobalSetterId>),
82
83    #[error("body evaluator returned an error for setter {id:?}: {reason}")]
84    BodyEval {
85        id: GlobalSetterId,
86        reason: String,
87    },
88}
89
90/// Trait the eval engine will implement to actually evaluate setter
91/// bodies. Carried in the solver state so the dependency-tracking core
92/// can be exercised under stub evaluators in tests without dragging
93/// sui-eval into sui-spec.
94///
95/// **Today**: [`TreeWalkingEvaluator`] is the production implementation
96/// — a minimum-viable AST tree-walker over the setter's `body_ast_root`
97/// in its containing module's [`AstGraph`]. Handles literals,
98/// arithmetic, comparisons, if-then-else, attrset construction +
99/// select, list concat, and Select chains rooted at `config`. Returns
100/// [`EvalValue::Opaque`] for the long tail (Apply / Lambda / LetIn /
101/// With) — those are picked up by the future sui-eval bytecode VM
102/// integration that replaces this minimum-viable engine.
103pub trait BodyEvaluator {
104    /// Evaluate the setter's body in the given environment and return
105    /// the resulting value as canonical bytes (JSON today; rkyv when
106    /// the typed value lattice is finalized).
107    ///
108    /// `gid` identifies the setter's containing module so multi-module
109    /// evaluators (`PerModuleEvaluator`) route the body to the right
110    /// `AstGraph`. Single-module evaluators ignore it.
111    ///
112    /// `module` carries the [`ModuleNode::body_env_prefix`] the
113    /// evaluator must apply BEFORE the per-setter body — that's how
114    /// `let cfg = config.foo; in BODY` bindings flow into setter
115    /// evaluation. Modules with no prefix have an empty list.
116    ///
117    /// # Errors
118    ///
119    /// Returns a free-form reason string when the body can't be
120    /// evaluated; the solver surfaces it as
121    /// [`SolverError::BodyEval`].
122    fn evaluate(
123        &self,
124        gid: GlobalSetterId,
125        module: &ModuleNode,
126        setter: &ConfigSetter,
127        env_snapshot: &EnvSnapshot,
128    ) -> Result<Vec<u8>, String>;
129}
130
131/// Production [`BodyEvaluator`] backed by [`crate::ast_evaluator`] —
132/// the minimum-viable tree-walker over the typed [`AstGraph`].
133///
134/// One evaluator per module (the AST graph owns its node table, so the
135/// evaluator needs the matching graph to look up `body_ast_root`).
136/// The solver's [`SolverState::new`] takes a single evaluator; for
137/// multi-module setups (the common case), wrap a slice of per-module
138/// evaluators in a [`PerModuleEvaluator`] (helper below).
139///
140/// Setter bodies that touch unsupported AST kinds (function calls,
141/// closures) bubble up as `Opaque` from the tree walker, which the
142/// evaluator surfaces as the literal JSON string `"<opaque:Apply>"` —
143/// the eventual sui-eval integration recognizes the sentinel and
144/// recomputes the body through the real VM.
145pub struct TreeWalkingEvaluator {
146    graph: Arc<AstGraph>,
147}
148
149impl TreeWalkingEvaluator {
150    /// Build an evaluator that resolves every setter body via `graph`.
151    /// One evaluator per module's AST.
152    #[must_use]
153    pub fn new(graph: Arc<AstGraph>) -> Self {
154        Self { graph }
155    }
156}
157
158impl BodyEvaluator for TreeWalkingEvaluator {
159    fn evaluate(
160        &self,
161        _gid: GlobalSetterId,
162        module: &ModuleNode,
163        setter: &ConfigSetter,
164        env_snapshot: &EnvSnapshot,
165    ) -> Result<Vec<u8>, String> {
166        let env = build_eval_env_with_prefix(&self.graph, env_snapshot, &module.body_env_prefix)?;
167        let value = eval_node(&self.graph, setter.body_ast_root, &env)
168            .map_err(|e| format!("ast eval: {e}"))?;
169        serde_json::to_vec(&value)
170            .map_err(|e| format!("value→bytes: {e}"))
171    }
172}
173
174/// Multi-module evaluator that routes each setter to the AstGraph of
175/// its containing module. Used in tests + by callers that build a
176/// ModuleGraph from multiple AstGraphs.
177pub struct PerModuleEvaluator {
178    /// Module-id → AstGraph for that module. The solver passes setters
179    /// with `body_ast_root` indexing into the correct one via the
180    /// setter's containing module id.
181    pub graphs: BTreeMap<ModuleId, Arc<AstGraph>>,
182}
183
184impl PerModuleEvaluator {
185    /// Build from a `(module_id, graph)` iterator.
186    #[must_use]
187    pub fn from_pairs<I>(iter: I) -> Self
188    where
189        I: IntoIterator<Item = (ModuleId, Arc<AstGraph>)>,
190    {
191        Self {
192            graphs: iter.into_iter().collect(),
193        }
194    }
195}
196
197impl BodyEvaluator for PerModuleEvaluator {
198    fn evaluate(
199        &self,
200        gid: GlobalSetterId,
201        module: &ModuleNode,
202        setter: &ConfigSetter,
203        env_snapshot: &EnvSnapshot,
204    ) -> Result<Vec<u8>, String> {
205        let graph = self.graphs.get(&gid.module).ok_or_else(|| {
206            format!(
207                "no AstGraph registered for module id {} (setter writing {:?})",
208                gid.module, setter.assigns_path
209            )
210        })?;
211        let env = build_eval_env_with_prefix(graph, env_snapshot, &module.body_env_prefix)?;
212        let value = eval_node(graph, setter.body_ast_root, &env)
213            .map_err(|e| format!("ast eval: {e}"))?;
214        serde_json::to_vec(&value).map_err(|e| format!("value→bytes: {e}"))
215    }
216}
217
218/// Build the full evaluation env for a setter: start from
219/// [`env_snapshot_to_eval_env`] (which seeds `config`), then layer in
220/// every binding from `prefix` so let-bound names + with-scope attrs
221/// from the module's outer wrapping resolve correctly.
222///
223/// Each prefix entry's `value_node_id` is evaluated against the env
224/// built so far (so later prefix entries can reference earlier ones).
225/// With-kind entries' values are expected to evaluate to an
226/// AttrSet — its attrs unpack as top-level bindings (existing names
227/// shadow; matches cppnix's `with` precedence).
228fn build_eval_env_with_prefix(
229    graph: &AstGraph,
230    snapshot: &EnvSnapshot,
231    prefix: &[EnvPrefixBinding],
232) -> Result<EvalEnv, String> {
233    let mut env = env_snapshot_to_eval_env(snapshot);
234    for binding in prefix {
235        let value = eval_node(graph, binding.value_node_id, &env)
236            .map_err(|e| format!("evaluating env-prefix '{}': {e}", binding.name))?;
237        match binding.kind {
238            EnvPrefixKind::Let => {
239                env.bindings.insert(binding.name.clone(), value);
240            }
241            EnvPrefixKind::With => {
242                if let EvalValue::AttrSet(map) = value {
243                    for (k, v) in map {
244                        env.bindings.entry(k).or_insert(v);
245                    }
246                }
247                // If the with-scope didn't evaluate to an AttrSet,
248                // silently skip — matches cppnix's "with non-attrset
249                // is a no-op" tolerance.
250            }
251        }
252    }
253    Ok(env)
254}
255
256/// Project [`EnvSnapshot`] into an [`EvalEnv`] with a single
257/// `config` binding (an AttrSet built from every path-bytes entry).
258/// The tree walker then resolves `config.x.y.z` selects via this
259/// AttrSet.
260fn env_snapshot_to_eval_env(snapshot: &EnvSnapshot) -> EvalEnv {
261    let mut root = BTreeMap::<String, EvalValue>::new();
262    for (path, bytes) in &snapshot.config {
263        // Try to deserialize the stored bytes back to an EvalValue.
264        // Bytes were written via `serde_json::to_vec(&EvalValue)` by
265        // a prior `TreeWalkingEvaluator::evaluate` call. Going through
266        // `from_str` over an owned String forces full-ownership on
267        // the deserialized value (no borrowed lifetimes from `bytes`).
268        let value: EvalValue = match std::str::from_utf8(bytes)
269            .ok()
270            .and_then(|s| serde_json::from_str(s).ok())
271        {
272            Some(v) => v,
273            None => EvalValue::Str(
274                String::from_utf8(bytes.clone()).unwrap_or_default(),
275            ),
276        };
277        insert_path(&mut root, path, value);
278    }
279    EvalEnv::new().with_binding("config", EvalValue::AttrSet(root))
280}
281
282fn insert_path(
283    out: &mut BTreeMap<String, EvalValue>,
284    path: &[String],
285    value: EvalValue,
286) {
287    if path.is_empty() {
288        return;
289    }
290    if path.len() == 1 {
291        out.insert(path[0].clone(), value);
292        return;
293    }
294    let head = &path[0];
295    let tail = &path[1..];
296    let entry = out
297        .entry(head.clone())
298        .or_insert_with(|| EvalValue::AttrSet(BTreeMap::new()));
299    if let EvalValue::AttrSet(inner) = entry {
300        insert_path(inner, tail, value);
301    }
302}
303
304/// A read-only snapshot of the current config attrset, projected as
305/// `path → bytes`. Reflects every setter that has fired in prior
306/// iterations of the fixed point. Newer mkForce'd values override
307/// older default-priority ones.
308#[derive(Debug, Default, Clone)]
309pub struct EnvSnapshot {
310    pub config: BTreeMap<ConfigPath, Vec<u8>>,
311}
312
313impl EnvSnapshot {
314    /// Read one path. Returns `None` if no setter has produced it yet.
315    pub fn get(&self, path: &ConfigPath) -> Option<&Vec<u8>> {
316        self.config.get(path)
317    }
318
319    /// Whether the snapshot contains a value for any prefix of `path`
320    /// — useful for the "is this slice satisfied yet?" check.
321    pub fn has_prefix(&self, prefix: &ConfigPath) -> bool {
322        self.config.keys().any(|k| k.starts_with(prefix))
323    }
324}
325
326/// Live state of the solver.
327pub struct SolverState<E: BodyEvaluator> {
328    /// The compiled module graph the solver is operating on.
329    graph: ModuleGraph,
330    /// Index from each setter's `assigns_path` back to the setter that
331    /// writes it. Built once on construction.
332    writers_by_path: BTreeMap<ConfigPath, GlobalSetterId>,
333    /// Reverse-dependency index: for every `path`, which setters DEPEND
334    /// on it (i.e. their slice contains a prefix of `path`)?
335    readers_by_path: BTreeMap<ConfigPath, Vec<GlobalSetterId>>,
336    /// Per-setter slice cache (deduplicates the slice lookup we hit on
337    /// every iteration).
338    slice_by_setter: BTreeMap<GlobalSetterId, Vec<ConfigPath>>,
339    /// Current config attrset projection.
340    env: EnvSnapshot,
341    /// Setters fired so far in this run.
342    fired: BTreeSet<GlobalSetterId>,
343    /// Body evaluator (the eval engine — or a stub in tests).
344    evaluator: E,
345}
346
347impl<E: BodyEvaluator> SolverState<E> {
348    /// Construct a fresh solver for `graph`. Builds the reverse-
349    /// dependency index up-front so per-iteration scheduling is O(1)
350    /// per dirty path.
351    pub fn new(graph: ModuleGraph, evaluator: E) -> Self {
352        let mut writers_by_path: BTreeMap<ConfigPath, GlobalSetterId> = BTreeMap::new();
353        let mut readers_by_path: BTreeMap<ConfigPath, Vec<GlobalSetterId>> = BTreeMap::new();
354        let mut slice_by_setter: BTreeMap<GlobalSetterId, Vec<ConfigPath>> = BTreeMap::new();
355
356        for module in &graph.modules {
357            for setter in &module.setters {
358                let gid = GlobalSetterId {
359                    module: module.id,
360                    setter: setter.id,
361                };
362                writers_by_path.insert(setter.assigns_path.clone(), gid);
363                for slice_path in &setter.slice {
364                    readers_by_path
365                        .entry(slice_path.clone())
366                        .or_default()
367                        .push(gid);
368                }
369                slice_by_setter.insert(gid, setter.slice.clone());
370            }
371        }
372
373        Self {
374            graph,
375            writers_by_path,
376            readers_by_path,
377            slice_by_setter,
378            env: EnvSnapshot::default(),
379            fired: BTreeSet::new(),
380            evaluator,
381        }
382    }
383
384    /// Borrow the current env snapshot.
385    pub fn env(&self) -> &EnvSnapshot {
386        &self.env
387    }
388
389    /// Borrow the compiled graph back out.
390    pub fn graph(&self) -> &ModuleGraph {
391        &self.graph
392    }
393
394    /// Total setter count across all modules.
395    pub fn setter_count(&self) -> usize {
396        self.graph
397            .modules
398            .iter()
399            .map(|m| m.setters.len())
400            .sum()
401    }
402
403    /// Find every CONSUMER setter that needs to fire because at least
404    /// one of `dirty_paths` matches a prefix of its slice.
405    ///
406    /// "Matches a prefix" means: if a setter's slice is
407    /// `[["services", "atticd"]]` and `dirty_paths` contains
408    /// `["services", "atticd", "enable"]`, the setter is scheduled —
409    /// a slice over a subtree fires on any descendant change.
410    ///
411    /// Writers are deliberately **not** included here. A writer's own
412    /// re-firing happens only when its OWN slice changes (covered by
413    /// the readers lookup that finds it as a consumer of its
414    /// upstream's outputs). Multi-writer conflict resolution happens
415    /// at the evaluator layer via [`ConfigSetter::priority`] —
416    /// firing each writer once per topo pass is enough.
417    pub fn schedule_for_dirty(
418        &self,
419        dirty_paths: &[ConfigPath],
420    ) -> BTreeSet<GlobalSetterId> {
421        let mut scheduled: BTreeSet<GlobalSetterId> = BTreeSet::new();
422        for dirty in dirty_paths {
423            for (slice_path, readers) in &self.readers_by_path {
424                if slice_path_intersects(slice_path, dirty) {
425                    for r in readers {
426                        scheduled.insert(*r);
427                    }
428                }
429            }
430        }
431        scheduled
432    }
433
434    /// Topological order of all setters by writes→reads dependency.
435    /// Kahn's algorithm.
436    ///
437    /// # Errors
438    ///
439    /// [`SolverError::Cycle`] if the writes→reads graph has a cycle
440    /// (which is a module-author bug — cppnix would also fail here).
441    pub fn topological_order(&self) -> Result<Vec<GlobalSetterId>, SolverError> {
442        // Build adjacency: writer → readers
443        let mut edges: BTreeMap<GlobalSetterId, BTreeSet<GlobalSetterId>> = BTreeMap::new();
444        let mut indegree: BTreeMap<GlobalSetterId, u32> = BTreeMap::new();
445
446        // Pre-seed every setter with indegree 0
447        for module in &self.graph.modules {
448            for setter in &module.setters {
449                let gid = GlobalSetterId {
450                    module: module.id,
451                    setter: setter.id,
452                };
453                indegree.insert(gid, 0);
454            }
455        }
456
457        // For each setter, find which other setters it depends on
458        // (i.e. which writers feed its slice).
459        for (writer_path, &writer) in &self.writers_by_path {
460            // Anyone whose slice contains this path or a prefix.
461            for (gid, slice) in &self.slice_by_setter {
462                if *gid == writer {
463                    continue;
464                }
465                if slice
466                    .iter()
467                    .any(|s| slice_path_intersects(s, writer_path))
468                {
469                    edges.entry(writer).or_default().insert(*gid);
470                    *indegree.entry(*gid).or_insert(0) += 1;
471                }
472            }
473        }
474
475        let mut queue: VecDeque<GlobalSetterId> = indegree
476            .iter()
477            .filter(|&(_, &d)| d == 0)
478            .map(|(k, _)| *k)
479            .collect();
480        let mut order: Vec<GlobalSetterId> = Vec::with_capacity(indegree.len());
481
482        while let Some(gid) = queue.pop_front() {
483            order.push(gid);
484            if let Some(neighbors) = edges.get(&gid) {
485                for &n in neighbors {
486                    let d = indegree.get_mut(&n).expect("seeded above");
487                    *d -= 1;
488                    if *d == 0 {
489                        queue.push_back(n);
490                    }
491                }
492            }
493        }
494
495        if order.len() != indegree.len() {
496            // Find the surviving nodes (those still with indegree > 0).
497            let stuck: Vec<GlobalSetterId> = indegree
498                .iter()
499                .filter(|&(_, &d)| d > 0)
500                .map(|(k, _)| *k)
501                .collect();
502            return Err(SolverError::Cycle(stuck));
503        }
504
505        Ok(order)
506    }
507
508    /// Fire one setter, write its result into the env, mark it fired.
509    /// Returns the setter's own `assigns_path` so callers can register
510    /// it as newly-dirty.
511    ///
512    /// # Errors
513    ///
514    /// [`SolverError::BodyEval`] when the body evaluator rejects.
515    pub fn fire(&mut self, gid: GlobalSetterId) -> Result<ConfigPath, SolverError> {
516        let module = self
517            .graph
518            .modules
519            .iter()
520            .find(|m| m.id == gid.module)
521            .expect("module id resolved")
522            .clone();
523        let setter = self.get_setter(gid).clone();
524        let bytes = self
525            .evaluator
526            .evaluate(gid, &module, &setter, &self.env)
527            .map_err(|reason| SolverError::BodyEval { id: gid, reason })?;
528        self.env.config.insert(setter.assigns_path.clone(), bytes);
529        self.fired.insert(gid);
530        Ok(setter.assigns_path)
531    }
532
533    /// Run the solver to quiescence starting from `initial_dirty`.
534    /// Returns the firing order in temporal sequence. Caller can
535    /// inspect [`Self::env`] afterward for the final config attrset.
536    ///
537    /// On a cold rebuild (no env, no prior dirty), pass an empty
538    /// `initial_dirty` and the solver runs every setter in topological
539    /// order. On a warm rebuild where only `services.atticd.enable`
540    /// changed, pass `[vec!["services", "atticd", "enable"]]` and only
541    /// the setters depending on that slice fire.
542    ///
543    /// # Errors
544    ///
545    /// Cycles or body-eval failures surface as [`SolverError`].
546    pub fn run(&mut self, initial_dirty: &[ConfigPath]) -> Result<Vec<GlobalSetterId>, SolverError> {
547        // Topological sort once — relative order is stable across
548        // iterations.
549        let topo = self.topological_order()?;
550        let topo_index: BTreeMap<GlobalSetterId, usize> = topo
551            .iter()
552            .enumerate()
553            .map(|(i, gid)| (*gid, i))
554            .collect();
555
556        // Initial schedule: setters intersecting initial_dirty.
557        // Plus on a cold start (initial_dirty empty AND env empty),
558        // schedule every setter.
559        let mut to_fire: BTreeSet<GlobalSetterId> = if initial_dirty.is_empty() && self.env.config.is_empty() {
560            topo.iter().copied().collect()
561        } else {
562            self.schedule_for_dirty(initial_dirty)
563        };
564
565        let mut firing_order: Vec<GlobalSetterId> = Vec::new();
566        let mut iterations = 0u32;
567        let max_iterations = 64u32; // belt-and-suspenders against pathological feedback loops
568
569        while !to_fire.is_empty() {
570            iterations += 1;
571            if iterations > max_iterations {
572                break;
573            }
574
575            // Sort the to-fire set into topological order to maximize
576            // single-pass progress.
577            let mut batch: Vec<GlobalSetterId> = to_fire.iter().copied().collect();
578            batch.sort_by_key(|gid| topo_index.get(gid).copied().unwrap_or(usize::MAX));
579            to_fire.clear();
580
581            let mut newly_dirty: Vec<ConfigPath> = Vec::new();
582            for gid in batch {
583                let before = self.env.config.get(&self.get_setter(gid).assigns_path).cloned();
584                let assigns = self.fire(gid)?;
585                firing_order.push(gid);
586                let after = self.env.config.get(&assigns).cloned();
587                if before != after {
588                    newly_dirty.push(assigns);
589                }
590            }
591
592            // Schedule downstream consumers of the newly-dirty paths.
593            to_fire = self.schedule_for_dirty(&newly_dirty);
594            // Don't re-fire setters we already fired this run unless
595            // their dependency actually changed and they're slice-keyed
596            // to react — schedule_for_dirty already filters by slice
597            // intersection, so we let the loop run.
598        }
599
600        Ok(firing_order)
601    }
602
603    fn get_setter(&self, gid: GlobalSetterId) -> &ConfigSetter {
604        let module = self
605            .graph
606            .modules
607            .iter()
608            .find(|m| m.id == gid.module)
609            .expect("module id resolved");
610        module
611            .setters
612            .iter()
613            .find(|s| s.id == gid.setter)
614            .expect("setter id resolved")
615    }
616}
617
618/// Slice-intersection predicate: does `dirty_path` lie within the
619/// subtree rooted at `slice_path`? Examples:
620///
621/// * slice `["services"]` + dirty `["services", "atticd", "enable"]`
622///   → true (slice is a prefix of dirty)
623/// * slice `["services", "atticd"]` + dirty `["services"]` → true
624///   (dirty is a prefix of slice — a coarser change covers it)
625/// * slice `["boot"]` + dirty `["services"]` → false
626#[must_use]
627pub fn slice_path_intersects(slice_path: &ConfigPath, dirty: &ConfigPath) -> bool {
628    slice_path.starts_with(dirty.as_slice()) || dirty.starts_with(slice_path.as_slice())
629}
630
631#[cfg(test)]
632mod tests {
633    use super::*;
634    use crate::ast_graph::AstGraph;
635    use crate::module_graph::ModuleGraph;
636    use pretty_assertions::assert_eq;
637
638    /// Stub evaluator that returns the setter's assigns_path as bytes.
639    /// Useful for proving the solver math without dragging in the eval
640    /// engine.
641    struct PathBytesEvaluator;
642    impl BodyEvaluator for PathBytesEvaluator {
643        fn evaluate(
644            &self,
645            _gid: GlobalSetterId,
646            _module: &ModuleNode,
647            setter: &ConfigSetter,
648            _env: &EnvSnapshot,
649        ) -> Result<Vec<u8>, String> {
650            Ok(setter.assigns_path.join(".").into_bytes())
651        }
652    }
653
654    fn build_graph(modules: &[(&str, &str)]) -> ModuleGraph {
655        let pairs: Vec<(String, AstGraph)> = modules
656            .iter()
657            .map(|(label, src)| {
658                let ast = AstGraph::from_source(src).expect("parse");
659                ((*label).to_string(), ast)
660            })
661            .collect();
662        ModuleGraph::from_ast_graphs(&pairs).expect("build")
663    }
664
665    #[test]
666    fn empty_graph_runs_to_quiescence_instantly() {
667        let g = ModuleGraph::new();
668        let mut solver = SolverState::new(g, PathBytesEvaluator);
669        let order = solver.run(&[]).unwrap();
670        assert!(order.is_empty());
671    }
672
673    #[test]
674    fn cold_start_fires_every_setter() {
675        let g = build_graph(&[(
676            "a.nix",
677            "{ config, ... }: { \
678             config.networking.hostName = \"rio\"; \
679             config.boot.kernelParams = [\"x\"]; \
680             }",
681        )]);
682        let mut solver = SolverState::new(g, PathBytesEvaluator);
683        assert_eq!(solver.setter_count(), 2);
684        let order = solver.run(&[]).unwrap();
685        // Both setters fire on cold start.
686        assert_eq!(order.len(), 2);
687    }
688
689    #[test]
690    fn warm_run_with_no_dirty_paths_fires_nothing() {
691        let g = build_graph(&[(
692            "a.nix",
693            "{ config, ... }: { config.networking.hostName = \"rio\"; }",
694        )]);
695        let mut solver = SolverState::new(g, PathBytesEvaluator);
696        // Prime the env so it's not "cold".
697        solver.env.config.insert(
698            vec!["networking".to_string(), "hostName".to_string()],
699            b"rio".to_vec(),
700        );
701        let order = solver.run(&[]).unwrap();
702        assert!(order.is_empty(), "warm + no-dirty should fire nothing");
703    }
704
705    #[test]
706    fn slice_keyed_re_firing_only_re_runs_matching_setters() {
707        // Two setters:
708        //   A: config.networking.hostName = "rio"; (no reads → empty slice)
709        //   B: config.boot.kernelParams = mkIf config.networking.hostName == "rio" [...]
710        //      (slice reads networking.hostName)
711        // Mark networking.hostName dirty → A re-fires (writer), B re-fires (reader).
712        // Mark boot.unrelatedPath dirty → nothing.
713        let g = build_graph(&[(
714            "a.nix",
715            "{ config, ... }: { \
716             config.networking.hostName = \"rio\"; \
717             config.boot.kernelParams = mkIf (config.networking.hostName == \"rio\") [\"x\"]; \
718             }",
719        )]);
720        let mut solver = SolverState::new(g, PathBytesEvaluator);
721        // Cold start to populate.
722        solver.run(&[]).unwrap();
723
724        // Now re-fire with networking.hostName dirty.
725        let order = solver
726            .run(&[vec!["networking".to_string(), "hostName".to_string()]])
727            .unwrap();
728        // Only the READER (B, which uses networking.hostName in its
729        // mkIf condition) re-fires. The writer A doesn't re-fire
730        // because nothing in its (empty) slice changed — its output is
731        // already in env from the cold start.
732        assert_eq!(
733            order.len(),
734            1,
735            "expected only the reader to re-fire on slice match (writer's output is current)"
736        );
737
738        // Re-fire with a totally unrelated path dirty.
739        let order = solver
740            .run(&[vec!["unrelated".to_string(), "path".to_string()]])
741            .unwrap();
742        assert!(
743            order.is_empty(),
744            "expected nothing to re-fire on unrelated dirty"
745        );
746    }
747
748    #[test]
749    fn topological_order_respects_writer_reader_edges() {
750        // A writes services.foo.enable.
751        // B reads services.foo.enable via mkIf.
752        // Topological order: A before B.
753        let g = build_graph(&[(
754            "ab.nix",
755            "{ config, ... }: { \
756             config.services.foo.enable = true; \
757             config.networking.hostName = mkIf config.services.foo.enable \"rio\"; \
758             }",
759        )]);
760        let solver = SolverState::new(g, PathBytesEvaluator);
761        let order = solver.topological_order().unwrap();
762        assert_eq!(order.len(), 2);
763        // A (writer of services.foo.enable) must come before B (reader).
764        let pos_a = order
765            .iter()
766            .position(|gid| {
767                let m = &solver.graph.modules[gid.module as usize];
768                let s = &m.setters[gid.setter as usize];
769                s.assigns_path == vec!["services", "foo", "enable"]
770            })
771            .unwrap();
772        let pos_b = order
773            .iter()
774            .position(|gid| {
775                let m = &solver.graph.modules[gid.module as usize];
776                let s = &m.setters[gid.setter as usize];
777                s.assigns_path == vec!["networking", "hostName"]
778            })
779            .unwrap();
780        assert!(pos_a < pos_b, "writer must come before reader");
781    }
782
783    #[test]
784    fn slice_path_intersects_descendant() {
785        // slice: services.atticd
786        // dirty: services.atticd.enable  → descendant → intersects
787        assert!(slice_path_intersects(
788            &vec!["services".to_string(), "atticd".to_string()],
789            &vec![
790                "services".to_string(),
791                "atticd".to_string(),
792                "enable".to_string()
793            ]
794        ));
795        // slice: services.atticd.enable
796        // dirty: services.atticd  → ancestor → also intersects
797        assert!(slice_path_intersects(
798            &vec![
799                "services".to_string(),
800                "atticd".to_string(),
801                "enable".to_string()
802            ],
803            &vec!["services".to_string(), "atticd".to_string()]
804        ));
805        // Disjoint → no
806        assert!(!slice_path_intersects(
807            &vec!["services".to_string()],
808            &vec!["boot".to_string()]
809        ));
810    }
811
812    #[test]
813    fn fixed_point_terminates_within_budget() {
814        // Three setters: A → B → C chain
815        // (A.assigns is in B.slice; B.assigns is in C.slice)
816        let g = build_graph(&[(
817            "chain.nix",
818            "{ config, ... }: { \
819             config.a = 1; \
820             config.b = if config.a == 1 then 2 else 0; \
821             config.c = if config.b == 2 then 3 else 0; \
822             }",
823        )]);
824        let mut solver = SolverState::new(g, PathBytesEvaluator);
825        let order = solver.run(&[]).unwrap();
826        assert!(!order.is_empty());
827        // The chain has to fire each setter at least once.
828        assert!(order.len() >= 3);
829    }
830
831    #[test]
832    fn schedule_for_dirty_returns_only_readers_not_writers() {
833        let g = build_graph(&[(
834            "writer_reader.nix",
835            "{ config, ... }: { \
836             config.x = 1; \
837             config.y = if config.x == 1 then 2 else 0; \
838             }",
839        )]);
840        let solver = SolverState::new(g, PathBytesEvaluator);
841        let dirty = vec![vec!["x".to_string()]];
842        let scheduled = solver.schedule_for_dirty(&dirty);
843        // Only the READER (config.y, which reads config.x via mkIf
844        // condition) is scheduled. Writers re-fire only when their
845        // OWN slice changes — covered by the topo-order initial pass.
846        assert_eq!(scheduled.len(), 1);
847    }
848
849    #[test]
850    fn env_snapshot_get_and_has_prefix_work() {
851        let mut env = EnvSnapshot::default();
852        env.config.insert(
853            vec!["services".to_string(), "atticd".to_string(), "enable".to_string()],
854            b"true".to_vec(),
855        );
856        assert!(env
857            .get(&vec![
858                "services".to_string(),
859                "atticd".to_string(),
860                "enable".to_string()
861            ])
862            .is_some());
863        assert!(env.has_prefix(&vec!["services".to_string()]));
864        assert!(env.has_prefix(&vec![
865            "services".to_string(),
866            "atticd".to_string()
867        ]));
868        assert!(!env.has_prefix(&vec!["boot".to_string()]));
869    }
870}