use std::collections::{BTreeMap, BTreeSet, VecDeque};
use std::sync::Arc;
use crate::ast_evaluator::{eval_node, EvalEnv, EvalValue};
use crate::ast_graph::AstGraph;
use crate::module_graph::{
ConfigSetter, EnvPrefixBinding, EnvPrefixKind, ModuleGraph, ModuleId, ModuleNode, SetterId,
};
pub type ConfigPath = Vec<String>;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub struct GlobalSetterId {
pub module: ModuleId,
pub setter: SetterId,
}
#[derive(Debug, thiserror::Error)]
pub enum SolverError {
#[error("dependency cycle detected involving setters {0:?}")]
Cycle(Vec<GlobalSetterId>),
#[error("body evaluator returned an error for setter {id:?}: {reason}")]
BodyEval {
id: GlobalSetterId,
reason: String,
},
}
pub trait BodyEvaluator {
fn evaluate(
&self,
gid: GlobalSetterId,
module: &ModuleNode,
setter: &ConfigSetter,
env_snapshot: &EnvSnapshot,
) -> Result<Vec<u8>, String>;
}
pub struct TreeWalkingEvaluator {
graph: Arc<AstGraph>,
}
impl TreeWalkingEvaluator {
#[must_use]
pub fn new(graph: Arc<AstGraph>) -> Self {
Self { graph }
}
}
impl BodyEvaluator for TreeWalkingEvaluator {
fn evaluate(
&self,
_gid: GlobalSetterId,
module: &ModuleNode,
setter: &ConfigSetter,
env_snapshot: &EnvSnapshot,
) -> Result<Vec<u8>, String> {
let env = build_eval_env_with_prefix(&self.graph, env_snapshot, &module.body_env_prefix)?;
let value = eval_node(&self.graph, setter.body_ast_root, &env)
.map_err(|e| format!("ast eval: {e}"))?;
serde_json::to_vec(&value)
.map_err(|e| format!("value→bytes: {e}"))
}
}
pub struct PerModuleEvaluator {
pub graphs: BTreeMap<ModuleId, Arc<AstGraph>>,
}
impl PerModuleEvaluator {
#[must_use]
pub fn from_pairs<I>(iter: I) -> Self
where
I: IntoIterator<Item = (ModuleId, Arc<AstGraph>)>,
{
Self {
graphs: iter.into_iter().collect(),
}
}
}
impl BodyEvaluator for PerModuleEvaluator {
fn evaluate(
&self,
gid: GlobalSetterId,
module: &ModuleNode,
setter: &ConfigSetter,
env_snapshot: &EnvSnapshot,
) -> Result<Vec<u8>, String> {
let graph = self.graphs.get(&gid.module).ok_or_else(|| {
format!(
"no AstGraph registered for module id {} (setter writing {:?})",
gid.module, setter.assigns_path
)
})?;
let env = build_eval_env_with_prefix(graph, env_snapshot, &module.body_env_prefix)?;
let value = eval_node(graph, setter.body_ast_root, &env)
.map_err(|e| format!("ast eval: {e}"))?;
serde_json::to_vec(&value).map_err(|e| format!("value→bytes: {e}"))
}
}
fn build_eval_env_with_prefix(
graph: &AstGraph,
snapshot: &EnvSnapshot,
prefix: &[EnvPrefixBinding],
) -> Result<EvalEnv, String> {
let mut env = env_snapshot_to_eval_env(snapshot);
for binding in prefix {
let value = eval_node(graph, binding.value_node_id, &env)
.map_err(|e| format!("evaluating env-prefix '{}': {e}", binding.name))?;
match binding.kind {
EnvPrefixKind::Let => {
env.bindings.insert(binding.name.clone(), value);
}
EnvPrefixKind::With => {
if let EvalValue::AttrSet(map) = value {
for (k, v) in map {
env.bindings.entry(k).or_insert(v);
}
}
}
}
}
Ok(env)
}
fn env_snapshot_to_eval_env(snapshot: &EnvSnapshot) -> EvalEnv {
let mut root = BTreeMap::<String, EvalValue>::new();
for (path, bytes) in &snapshot.config {
let value: EvalValue = match std::str::from_utf8(bytes)
.ok()
.and_then(|s| serde_json::from_str(s).ok())
{
Some(v) => v,
None => EvalValue::Str(
String::from_utf8(bytes.clone()).unwrap_or_default(),
),
};
insert_path(&mut root, path, value);
}
EvalEnv::new().with_binding("config", EvalValue::AttrSet(root))
}
fn insert_path(
out: &mut BTreeMap<String, EvalValue>,
path: &[String],
value: EvalValue,
) {
if path.is_empty() {
return;
}
if path.len() == 1 {
out.insert(path[0].clone(), value);
return;
}
let head = &path[0];
let tail = &path[1..];
let entry = out
.entry(head.clone())
.or_insert_with(|| EvalValue::AttrSet(BTreeMap::new()));
if let EvalValue::AttrSet(inner) = entry {
insert_path(inner, tail, value);
}
}
#[derive(Debug, Default, Clone)]
pub struct EnvSnapshot {
pub config: BTreeMap<ConfigPath, Vec<u8>>,
}
impl EnvSnapshot {
pub fn get(&self, path: &ConfigPath) -> Option<&Vec<u8>> {
self.config.get(path)
}
pub fn has_prefix(&self, prefix: &ConfigPath) -> bool {
self.config.keys().any(|k| k.starts_with(prefix))
}
}
pub struct SolverState<E: BodyEvaluator> {
graph: ModuleGraph,
writers_by_path: BTreeMap<ConfigPath, GlobalSetterId>,
readers_by_path: BTreeMap<ConfigPath, Vec<GlobalSetterId>>,
slice_by_setter: BTreeMap<GlobalSetterId, Vec<ConfigPath>>,
env: EnvSnapshot,
fired: BTreeSet<GlobalSetterId>,
evaluator: E,
}
impl<E: BodyEvaluator> SolverState<E> {
pub fn new(graph: ModuleGraph, evaluator: E) -> Self {
let mut writers_by_path: BTreeMap<ConfigPath, GlobalSetterId> = BTreeMap::new();
let mut readers_by_path: BTreeMap<ConfigPath, Vec<GlobalSetterId>> = BTreeMap::new();
let mut slice_by_setter: BTreeMap<GlobalSetterId, Vec<ConfigPath>> = BTreeMap::new();
for module in &graph.modules {
for setter in &module.setters {
let gid = GlobalSetterId {
module: module.id,
setter: setter.id,
};
writers_by_path.insert(setter.assigns_path.clone(), gid);
for slice_path in &setter.slice {
readers_by_path
.entry(slice_path.clone())
.or_default()
.push(gid);
}
slice_by_setter.insert(gid, setter.slice.clone());
}
}
Self {
graph,
writers_by_path,
readers_by_path,
slice_by_setter,
env: EnvSnapshot::default(),
fired: BTreeSet::new(),
evaluator,
}
}
pub fn env(&self) -> &EnvSnapshot {
&self.env
}
pub fn graph(&self) -> &ModuleGraph {
&self.graph
}
pub fn setter_count(&self) -> usize {
self.graph
.modules
.iter()
.map(|m| m.setters.len())
.sum()
}
pub fn schedule_for_dirty(
&self,
dirty_paths: &[ConfigPath],
) -> BTreeSet<GlobalSetterId> {
let mut scheduled: BTreeSet<GlobalSetterId> = BTreeSet::new();
for dirty in dirty_paths {
for (slice_path, readers) in &self.readers_by_path {
if slice_path_intersects(slice_path, dirty) {
for r in readers {
scheduled.insert(*r);
}
}
}
}
scheduled
}
pub fn topological_order(&self) -> Result<Vec<GlobalSetterId>, SolverError> {
let mut edges: BTreeMap<GlobalSetterId, BTreeSet<GlobalSetterId>> = BTreeMap::new();
let mut indegree: BTreeMap<GlobalSetterId, u32> = BTreeMap::new();
for module in &self.graph.modules {
for setter in &module.setters {
let gid = GlobalSetterId {
module: module.id,
setter: setter.id,
};
indegree.insert(gid, 0);
}
}
for (writer_path, &writer) in &self.writers_by_path {
for (gid, slice) in &self.slice_by_setter {
if *gid == writer {
continue;
}
if slice
.iter()
.any(|s| slice_path_intersects(s, writer_path))
{
edges.entry(writer).or_default().insert(*gid);
*indegree.entry(*gid).or_insert(0) += 1;
}
}
}
let mut queue: VecDeque<GlobalSetterId> = indegree
.iter()
.filter(|&(_, &d)| d == 0)
.map(|(k, _)| *k)
.collect();
let mut order: Vec<GlobalSetterId> = Vec::with_capacity(indegree.len());
while let Some(gid) = queue.pop_front() {
order.push(gid);
if let Some(neighbors) = edges.get(&gid) {
for &n in neighbors {
let d = indegree.get_mut(&n).expect("seeded above");
*d -= 1;
if *d == 0 {
queue.push_back(n);
}
}
}
}
if order.len() != indegree.len() {
let stuck: Vec<GlobalSetterId> = indegree
.iter()
.filter(|&(_, &d)| d > 0)
.map(|(k, _)| *k)
.collect();
return Err(SolverError::Cycle(stuck));
}
Ok(order)
}
pub fn fire(&mut self, gid: GlobalSetterId) -> Result<ConfigPath, SolverError> {
let module = self
.graph
.modules
.iter()
.find(|m| m.id == gid.module)
.expect("module id resolved")
.clone();
let setter = self.get_setter(gid).clone();
let bytes = self
.evaluator
.evaluate(gid, &module, &setter, &self.env)
.map_err(|reason| SolverError::BodyEval { id: gid, reason })?;
self.env.config.insert(setter.assigns_path.clone(), bytes);
self.fired.insert(gid);
Ok(setter.assigns_path)
}
pub fn run(&mut self, initial_dirty: &[ConfigPath]) -> Result<Vec<GlobalSetterId>, SolverError> {
let topo = self.topological_order()?;
let topo_index: BTreeMap<GlobalSetterId, usize> = topo
.iter()
.enumerate()
.map(|(i, gid)| (*gid, i))
.collect();
let mut to_fire: BTreeSet<GlobalSetterId> = if initial_dirty.is_empty() && self.env.config.is_empty() {
topo.iter().copied().collect()
} else {
self.schedule_for_dirty(initial_dirty)
};
let mut firing_order: Vec<GlobalSetterId> = Vec::new();
let mut iterations = 0u32;
let max_iterations = 64u32;
while !to_fire.is_empty() {
iterations += 1;
if iterations > max_iterations {
break;
}
let mut batch: Vec<GlobalSetterId> = to_fire.iter().copied().collect();
batch.sort_by_key(|gid| topo_index.get(gid).copied().unwrap_or(usize::MAX));
to_fire.clear();
let mut newly_dirty: Vec<ConfigPath> = Vec::new();
for gid in batch {
let before = self.env.config.get(&self.get_setter(gid).assigns_path).cloned();
let assigns = self.fire(gid)?;
firing_order.push(gid);
let after = self.env.config.get(&assigns).cloned();
if before != after {
newly_dirty.push(assigns);
}
}
to_fire = self.schedule_for_dirty(&newly_dirty);
}
Ok(firing_order)
}
fn get_setter(&self, gid: GlobalSetterId) -> &ConfigSetter {
let module = self
.graph
.modules
.iter()
.find(|m| m.id == gid.module)
.expect("module id resolved");
module
.setters
.iter()
.find(|s| s.id == gid.setter)
.expect("setter id resolved")
}
}
#[must_use]
pub fn slice_path_intersects(slice_path: &ConfigPath, dirty: &ConfigPath) -> bool {
slice_path.starts_with(dirty.as_slice()) || dirty.starts_with(slice_path.as_slice())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::ast_graph::AstGraph;
use crate::module_graph::ModuleGraph;
use pretty_assertions::assert_eq;
struct PathBytesEvaluator;
impl BodyEvaluator for PathBytesEvaluator {
fn evaluate(
&self,
_gid: GlobalSetterId,
_module: &ModuleNode,
setter: &ConfigSetter,
_env: &EnvSnapshot,
) -> Result<Vec<u8>, String> {
Ok(setter.assigns_path.join(".").into_bytes())
}
}
fn build_graph(modules: &[(&str, &str)]) -> ModuleGraph {
let pairs: Vec<(String, AstGraph)> = modules
.iter()
.map(|(label, src)| {
let ast = AstGraph::from_source(src).expect("parse");
((*label).to_string(), ast)
})
.collect();
ModuleGraph::from_ast_graphs(&pairs).expect("build")
}
#[test]
fn empty_graph_runs_to_quiescence_instantly() {
let g = ModuleGraph::new();
let mut solver = SolverState::new(g, PathBytesEvaluator);
let order = solver.run(&[]).unwrap();
assert!(order.is_empty());
}
#[test]
fn cold_start_fires_every_setter() {
let g = build_graph(&[(
"a.nix",
"{ config, ... }: { \
config.networking.hostName = \"rio\"; \
config.boot.kernelParams = [\"x\"]; \
}",
)]);
let mut solver = SolverState::new(g, PathBytesEvaluator);
assert_eq!(solver.setter_count(), 2);
let order = solver.run(&[]).unwrap();
assert_eq!(order.len(), 2);
}
#[test]
fn warm_run_with_no_dirty_paths_fires_nothing() {
let g = build_graph(&[(
"a.nix",
"{ config, ... }: { config.networking.hostName = \"rio\"; }",
)]);
let mut solver = SolverState::new(g, PathBytesEvaluator);
solver.env.config.insert(
vec!["networking".to_string(), "hostName".to_string()],
b"rio".to_vec(),
);
let order = solver.run(&[]).unwrap();
assert!(order.is_empty(), "warm + no-dirty should fire nothing");
}
#[test]
fn slice_keyed_re_firing_only_re_runs_matching_setters() {
let g = build_graph(&[(
"a.nix",
"{ config, ... }: { \
config.networking.hostName = \"rio\"; \
config.boot.kernelParams = mkIf (config.networking.hostName == \"rio\") [\"x\"]; \
}",
)]);
let mut solver = SolverState::new(g, PathBytesEvaluator);
solver.run(&[]).unwrap();
let order = solver
.run(&[vec!["networking".to_string(), "hostName".to_string()]])
.unwrap();
assert_eq!(
order.len(),
1,
"expected only the reader to re-fire on slice match (writer's output is current)"
);
let order = solver
.run(&[vec!["unrelated".to_string(), "path".to_string()]])
.unwrap();
assert!(
order.is_empty(),
"expected nothing to re-fire on unrelated dirty"
);
}
#[test]
fn topological_order_respects_writer_reader_edges() {
let g = build_graph(&[(
"ab.nix",
"{ config, ... }: { \
config.services.foo.enable = true; \
config.networking.hostName = mkIf config.services.foo.enable \"rio\"; \
}",
)]);
let solver = SolverState::new(g, PathBytesEvaluator);
let order = solver.topological_order().unwrap();
assert_eq!(order.len(), 2);
let pos_a = order
.iter()
.position(|gid| {
let m = &solver.graph.modules[gid.module as usize];
let s = &m.setters[gid.setter as usize];
s.assigns_path == vec!["services", "foo", "enable"]
})
.unwrap();
let pos_b = order
.iter()
.position(|gid| {
let m = &solver.graph.modules[gid.module as usize];
let s = &m.setters[gid.setter as usize];
s.assigns_path == vec!["networking", "hostName"]
})
.unwrap();
assert!(pos_a < pos_b, "writer must come before reader");
}
#[test]
fn slice_path_intersects_descendant() {
assert!(slice_path_intersects(
&vec!["services".to_string(), "atticd".to_string()],
&vec![
"services".to_string(),
"atticd".to_string(),
"enable".to_string()
]
));
assert!(slice_path_intersects(
&vec![
"services".to_string(),
"atticd".to_string(),
"enable".to_string()
],
&vec!["services".to_string(), "atticd".to_string()]
));
assert!(!slice_path_intersects(
&vec!["services".to_string()],
&vec!["boot".to_string()]
));
}
#[test]
fn fixed_point_terminates_within_budget() {
let g = build_graph(&[(
"chain.nix",
"{ config, ... }: { \
config.a = 1; \
config.b = if config.a == 1 then 2 else 0; \
config.c = if config.b == 2 then 3 else 0; \
}",
)]);
let mut solver = SolverState::new(g, PathBytesEvaluator);
let order = solver.run(&[]).unwrap();
assert!(!order.is_empty());
assert!(order.len() >= 3);
}
#[test]
fn schedule_for_dirty_returns_only_readers_not_writers() {
let g = build_graph(&[(
"writer_reader.nix",
"{ config, ... }: { \
config.x = 1; \
config.y = if config.x == 1 then 2 else 0; \
}",
)]);
let solver = SolverState::new(g, PathBytesEvaluator);
let dirty = vec![vec!["x".to_string()]];
let scheduled = solver.schedule_for_dirty(&dirty);
assert_eq!(scheduled.len(), 1);
}
#[test]
fn env_snapshot_get_and_has_prefix_work() {
let mut env = EnvSnapshot::default();
env.config.insert(
vec!["services".to_string(), "atticd".to_string(), "enable".to_string()],
b"true".to_vec(),
);
assert!(env
.get(&vec![
"services".to_string(),
"atticd".to_string(),
"enable".to_string()
])
.is_some());
assert!(env.has_prefix(&vec!["services".to_string()]));
assert!(env.has_prefix(&vec![
"services".to_string(),
"atticd".to_string()
]));
assert!(!env.has_prefix(&vec!["boot".to_string()]));
}
}