diff --git a/src/sls/state/generic_implementations.rs b/src/sls/state/generic_implementations.rs new file mode 100644 index 0000000..869b238 --- /dev/null +++ b/src/sls/state/generic_implementations.rs @@ -0,0 +1,494 @@ +//! Generic, state-agnostic helpers that operate on [`State`] values. +//! +//! These are deliberately independent of any particular state function +//! (file, service, package, ...) so they apply to every state the same way. +//! They provide: +//! * topological ordering of states for application, and +//! * conditional links deciding whether a state should run based on the +//! outcome of the states it depends on. + +use super::{State, StateDirective}; +use std::collections::{HashMap, HashSet, VecDeque}; + +/// The outcome of applying a single state. Used to evaluate conditional gates: +/// whether the state succeeded and whether it reported changes. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct StateOutcome { + pub success: bool, + pub changed: bool, +} + +impl StateOutcome { + pub const fn success_unchanged() -> Self { + Self { + success: true, + changed: false, + } + } + + pub const fn success_changed() -> Self { + Self { + success: true, + changed: true, + } + } + + pub const fn failure_unchanged() -> Self { + Self { + success: false, + changed: false, + } + } + + pub const fn failure_changed() -> Self { + Self { + success: false, + changed: true, + } + } +} + +/// How a set of target states gates the state that declares the link. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum GateRule { + /// Run only if *every* target succeeded (`require`, `require_in`). + AllSucceeded, + /// Run only if *at least one* target succeeded (`require_any`). + AnySucceeded, + /// Run only if *at least one* target failed (`onfail`, `onfail_in`). + AnyFailed, + /// Run only if *at least one* target failed **and** reported changes + /// (`onfailchanges`, `onfailchanges_in`). + AnyFailedChanged, + /// Run only if *at least one* target reported changes (`watch`, `onchanges` + /// and their `_in` / `_any` forms). + AnyChanged, +} + +/// A conditional link: this state should only be applied when the rule holds +/// against the recorded outcomes of its target states. +#[derive(Debug, Clone, PartialEq)] +pub struct Gate { + pub rule: GateRule, + pub targets: Vec, +} + +impl Gate { + /// Returns `true` when the rule is satisfied by the given outcomes. + /// + /// A target with no recorded outcome never satisfies the rule. + pub fn is_satisfied(&self, outcomes: &HashMap) -> bool { + match self.rule { + GateRule::AllSucceeded => self + .targets + .iter() + .all(|t| outcomes.get(t).is_some_and(|o| o.success)), + GateRule::AnySucceeded => self + .targets + .iter() + .any(|t| outcomes.get(t).is_some_and(|o| o.success)), + GateRule::AnyFailed => self + .targets + .iter() + .any(|t| outcomes.get(t).is_some_and(|o| !o.success)), + GateRule::AnyFailedChanged => self + .targets + .iter() + .any(|t| outcomes.get(t).is_some_and(|o| !o.success && o.changed)), + GateRule::AnyChanged => self + .targets + .iter() + .any(|t| outcomes.get(t).is_some_and(|o| o.changed)), + } + } +} + +/// A state in its scheduled position, together with the conditional links that +/// decide whether it should actually run once its predecessors have applied. +#[derive(Debug, Clone, PartialEq)] +pub struct PlannedState { + pub id: String, + /// Ids of states that must be applied before this one (ordering only). + pub predecessors: Vec, + /// Conditional links; all must be satisfied for the state to run. + pub gates: Vec, +} + +impl PlannedState { + /// Returns `true` when every gate is satisfied by the given outcomes. + pub fn should_apply(&self, outcomes: &HashMap) -> bool { + self.gates.iter().all(|g| g.is_satisfied(outcomes)) + } +} + +/// Errors that can occur while planning state application. +#[derive(Debug, Clone, PartialEq)] +pub enum StatePlanError { + /// A set of states that form a dependency cycle and cannot be ordered. + Cycle(Vec), + /// A dependency references a state id that is not part of the input. + UnknownState(String), +} + +impl std::fmt::Display for StatePlanError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + StatePlanError::Cycle(states) => { + write!(f, "dependency cycle among states: {}", states.join(", ")) + } + StatePlanError::UnknownState(id) => write!(f, "dependency on unknown state id: {id}"), + } + } +} + +impl std::error::Error for StatePlanError {} + +/// Topologically sorts the states, returning their ids in the order they +/// should be applied. +/// +/// Fails if the dependencies form a cycle or reference a state id that is not +/// present in the input. +pub fn topological_sort(states: &[State]) -> Result, StatePlanError> { + build_graph(states).map(|(order, _)| order) +} + +/// Topologically sorts the states and attaches the conditional links to each +/// one, returning the full application plan in scheduling order. +pub fn plan(states: &[State]) -> Result, StatePlanError> { + let (order, predecessors) = build_graph(states)?; + let gates = gates_by_state(states); + + Ok(order + .into_iter() + .map(|id| PlannedState { + id: id.clone(), + predecessors: predecessors.get(&id).cloned().unwrap_or_default(), + gates: gates.get(&id).cloned().unwrap_or_default(), + }) + .collect()) +} + +/// Collects the conditional gate for each state. +/// +/// A forward directive on a state gates that state itself on its targets; a +/// reverse (`*_in`) directive on a state gates each of its targets on that +/// state instead. `use`-style directives produce no gate. +fn gates_by_state(states: &[State]) -> HashMap> { + let mut gates: HashMap> = + states.iter().map(|s| (s.id.clone(), Vec::new())).collect(); + + for state in states { + for dep in &state.dependency { + let rule = match rule_for(&dep.directive) { + Some(rule) => rule, + None => continue, + }; + if is_reverse(&dep.directive) { + for target in &dep.target { + if let Some(target_gates) = gates.get_mut(target) { + target_gates.push(Gate { + rule, + targets: vec![state.id.clone()], + }); + } + } + } else if let Some(self_gates) = gates.get_mut(&state.id) { + self_gates.push(Gate { + rule, + targets: dep.target.clone(), + }); + } + } + } + + gates +} + +impl State { + /// Every state id referenced by this state's dependencies, regardless of + /// the directive used. + pub fn referenced_states(&self) -> Vec { + self.dependency + .iter() + .flat_map(|d| d.target.iter().cloned()) + .collect() + } +} + +/// A topological ordering of state ids paired with the immediate predecessors +/// of each state. +type OrderAndPredecessors = (Vec, HashMap>); + +/// Builds the dependency graph and returns a topological ordering together +/// with the immediate predecessors of each state. +fn build_graph(states: &[State]) -> Result { + let ids: HashSet = states.iter().map(|s| s.id.clone()).collect(); + + let mut in_degree: HashMap = + states.iter().map(|s| (s.id.clone(), 0usize)).collect(); + let mut adjacency: HashMap> = + states.iter().map(|s| (s.id.clone(), Vec::new())).collect(); + let mut predecessors: HashMap> = + states.iter().map(|s| (s.id.clone(), Vec::new())).collect(); + + for state in states { + for dep in &state.dependency { + if !orders(&dep.directive) { + continue; // `use`-style directives do not impose an ordering + } + let reverse = is_reverse(&dep.directive); + for target in &dep.target { + if !ids.contains(target) { + return Err(StatePlanError::UnknownState(target.clone())); + } + let (from, to) = if reverse { + (state.id.clone(), target.clone()) + } else { + (target.clone(), state.id.clone()) + }; + adjacency + .get_mut(&from) + .expect("id must be part of the input") + .push(to.clone()); + *in_degree.get_mut(&to).expect("id must be part of the input") += 1; + predecessors + .get_mut(&to) + .expect("id must be part of the input") + .push(from.clone()); + } + } + } + + // Deterministic start: sort the initially ready states. + let mut ready: Vec = in_degree + .iter() + .filter(|(_, deg)| **deg == 0) + .map(|(id, _)| id.clone()) + .collect(); + ready.sort(); + let mut queue: VecDeque = ready.into_iter().collect(); + + let mut order: Vec = Vec::with_capacity(states.len()); + while let Some(id) = queue.pop_front() { + order.push(id.clone()); + for next in adjacency.get(&id).into_iter().flatten().cloned() { + let deg = in_degree.get_mut(&next).expect("id must be part of the input"); + *deg -= 1; + if *deg == 0 { + queue.push_back(next); + } + } + } + + if order.len() != states.len() { + let remaining: Vec = in_degree + .iter() + .filter(|(_, deg)| **deg > 0) + .map(|(id, _)| id.clone()) + .collect(); + return Err(StatePlanError::Cycle(remaining)); + } + + Ok((order, predecessors)) +} + +/// Returns `true` for the reverse (`*_in`) ordering directives, which declare +/// that the current state is a prerequisite of the target rather than the +/// other way around. +fn is_reverse(d: &StateDirective) -> bool { + matches!( + d, + StateDirective::RequireIn + | StateDirective::WatchIn + | StateDirective::OnChangesIn + | StateDirective::OnFailIn + | StateDirective::OnFailChangesIn + ) +} + +/// Returns `true` when the directive imposes an ordering on the states. +fn orders(d: &StateDirective) -> bool { + rule_for(d).is_some() +} + +/// Maps a directive to the conditional gate it produces. +/// +/// Returns `None` for `use`-style directives, which only share data and +/// neither order the states nor condition their execution. +fn rule_for(d: &StateDirective) -> Option { + match d { + StateDirective::Require | StateDirective::RequireIn => Some(GateRule::AllSucceeded), + StateDirective::RequireAny => Some(GateRule::AnySucceeded), + StateDirective::Watch + | StateDirective::WatchIn + | StateDirective::OnChanges + | StateDirective::OnChangesIn + | StateDirective::WatchAny + | StateDirective::OnChangesAny => Some(GateRule::AnyChanged), + StateDirective::OnFail | StateDirective::OnFailIn | StateDirective::OnFailAny => { + Some(GateRule::AnyFailed) + } + StateDirective::OnFailChanges | StateDirective::OnFailChangesIn => { + Some(GateRule::AnyFailedChanged) + } + StateDirective::Use | StateDirective::UseIn | StateDirective::UseAny => None, + } +} + +#[cfg(test)] +mod tests { + use super::{plan, topological_sort, GateRule, StateOutcome, StatePlanError}; + use crate::sls::state::package::PackageState; + use crate::sls::state::{State, StateDependency, StateDirective, StateFunction}; + use std::collections::HashMap; + + fn dep(directive: StateDirective, targets: &[&str]) -> StateDependency { + StateDependency { + directive, + target: targets.iter().map(|t| t.to_string()).collect(), + } + } + + fn state(id: &str, dependency: Vec) -> State { + State { + id: String::from(id), + function: StateFunction::Package(PackageState::Installed { + name: String::from(id), + }), + dependency, + } + } + + #[test] + fn require_orders_target_before_dependent() { + let states = vec![ + state("a", vec![dep(StateDirective::Require, &["b"])]), + state("b", vec![]), + ]; + assert_eq!( + topological_sort(&states).unwrap(), + vec!["b".to_string(), "a".to_string()] + ); + } + + #[test] + fn require_in_orders_reverse() { + let states = vec![ + state("a", vec![dep(StateDirective::RequireIn, &["b"])]), + state("b", vec![]), + ]; + assert_eq!( + topological_sort(&states).unwrap(), + vec!["a".to_string(), "b".to_string()] + ); + + let planned = plan(&states).unwrap(); + let b = planned.iter().find(|p| p.id == "b").unwrap(); + assert_eq!(b.predecessors, vec!["a".to_string()]); + assert_eq!(b.gates[0].rule, GateRule::AllSucceeded); + assert_eq!(b.gates[0].targets, vec!["a".to_string()]); + + let a = planned.iter().find(|p| p.id == "a").unwrap(); + assert!(a.gates.is_empty()); + } + + #[test] + fn use_is_neither_ordering_nor_gate() { + let states = vec![ + state("a", vec![dep(StateDirective::Use, &["b"])]), + state("b", vec![]), + ]; + let a = plan(&states) + .unwrap() + .into_iter() + .find(|p| p.id == "a") + .unwrap(); + assert!(a.predecessors.is_empty()); + assert!(a.gates.is_empty()); + } + + #[test] + fn onfail_gate_depends_on_outcome() { + let states = vec![ + state("a", vec![dep(StateDirective::OnFail, &["b"])]), + state("b", vec![]), + ]; + let a = plan(&states) + .unwrap() + .into_iter() + .find(|p| p.id == "a") + .unwrap(); + + assert!(!a.should_apply(&HashMap::new())); + + let mut o = HashMap::new(); + o.insert("b".to_string(), StateOutcome::failure_unchanged()); + assert!(a.should_apply(&o)); + + o.insert("b".to_string(), StateOutcome::success_changed()); + assert!(!a.should_apply(&o)); + } + + #[test] + fn onfailchanges_requires_changed_failure() { + let states = vec![ + state("a", vec![dep(StateDirective::OnFailChanges, &["b"])]), + state("b", vec![]), + ]; + let a = plan(&states) + .unwrap() + .into_iter() + .find(|p| p.id == "a") + .unwrap(); + + let mut o = HashMap::new(); + o.insert("b".to_string(), StateOutcome::failure_changed()); + assert!(a.should_apply(&o)); + + o.insert("b".to_string(), StateOutcome::failure_unchanged()); + assert!(!a.should_apply(&o)); + } + + #[test] + fn require_any_runs_when_one_target_succeeds() { + let states = vec![ + state("a", vec![dep(StateDirective::RequireAny, &["b", "c"])]), + state("b", vec![]), + state("c", vec![]), + ]; + let a = plan(&states) + .unwrap() + .into_iter() + .find(|p| p.id == "a") + .unwrap(); + + let mut o = HashMap::new(); + o.insert("b".to_string(), StateOutcome::failure_unchanged()); + o.insert("c".to_string(), StateOutcome::success_unchanged()); + assert!(a.should_apply(&o)); + + o.insert("c".to_string(), StateOutcome::failure_unchanged()); + assert!(!a.should_apply(&o)); + } + + #[test] + fn detects_cycle() { + let states = vec![ + state("a", vec![dep(StateDirective::Require, &["b"])]), + state("b", vec![dep(StateDirective::Require, &["a"])]), + ]; + assert!(matches!( + topological_sort(&states), + Err(StatePlanError::Cycle(_)) + )); + } + + #[test] + fn detects_unknown_target() { + let states = vec![state("a", vec![dep(StateDirective::Require, &["missing"])])]; + assert!(matches!( + topological_sort(&states), + Err(StatePlanError::UnknownState(id)) if id == "missing" + )); + } +} diff --git a/src/sls/state/mod.rs b/src/sls/state/mod.rs index 6cd785a..7cc8f60 100644 --- a/src/sls/state/mod.rs +++ b/src/sls/state/mod.rs @@ -11,8 +11,10 @@ pub mod package; pub mod command; pub mod user; pub mod group; +pub mod generic_implementations; pub struct State { + pub id: String, pub function: StateFunction, pub dependency: Vec, } @@ -26,6 +28,27 @@ pub enum StateFunction { Group (GroupState), } -pub enum StateDependency { +pub struct StateDependency { + pub directive: StateDirective, + pub target: Vec, +} +pub enum StateDirective { + Require, + Watch, + OnChanges, + OnFail, + OnFailChanges, + Use, + RequireIn, + WatchIn, + OnChangesIn, + OnFailIn, + OnFailChangesIn, + UseIn, + RequireAny, + WatchAny, + OnChangesAny, + OnFailAny, + UseAny, } \ No newline at end of file