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}