Skip to main content

lightshuttle_runtime/lifecycle/
plan.rs

1//! Resolved execution plan: topologically sorted nodes plus the
2//! dependency graph.
3
4use std::collections::{HashMap, HashSet};
5
6use lightshuttle_manifest::Manifest;
7
8use crate::lifecycle::error::LifecycleError;
9use crate::spec::{ContainerSpec, ResolvedResource, ResourceOutputs, from_resource};
10
11/// A single resource to manage, with its resolved [`ContainerSpec`],
12/// its exposed outputs and its explicit dependencies.
13#[derive(Debug, Clone)]
14pub struct PlanNode {
15    /// Resource name as declared in the manifest.
16    pub name: String,
17    /// Resource kind discriminant (`postgres`, `redis`, `container`,
18    /// `dockerfile`), mirrored from the manifest.
19    pub kind: String,
20    /// Container specification derived from the manifest.
21    pub spec: ContainerSpec,
22    /// Outputs the resource exposes to its dependents (host, port,
23    /// password, url, ...).
24    pub outputs: ResourceOutputs,
25    /// Names of resources this one depends on.
26    pub depends_on: Vec<String>,
27}
28
29/// Topologically sorted execution plan.
30#[derive(Debug, Clone)]
31pub struct LifecyclePlan {
32    nodes: Vec<PlanNode>,
33    edges: HashMap<String, Vec<String>>,
34}
35
36impl LifecyclePlan {
37    /// Build a plan from a parsed manifest.
38    ///
39    /// Resolves the dependency graph, performs a topological sort and
40    /// converts every resource to a [`ContainerSpec`].
41    pub fn from_manifest(manifest: &Manifest) -> Result<Self, LifecycleError> {
42        let project = manifest.project.name.as_str();
43
44        // Build edges and collect spec + outputs for every resource.
45        let mut resolved: HashMap<String, ResolvedResource> = HashMap::new();
46        let mut deps: HashMap<String, Vec<String>> = HashMap::new();
47        let mut kinds: HashMap<String, &'static str> = HashMap::new();
48        for (name, kind) in &manifest.resources {
49            let r =
50                from_resource(project, name, kind).map_err(|source| LifecycleError::SpecBuild {
51                    resource: name.clone(),
52                    source,
53                })?;
54            resolved.insert(name.clone(), r);
55            deps.insert(name.clone(), kind.depends_on().to_vec());
56            kinds.insert(name.clone(), kind.kind_name());
57        }
58
59        // Verify every dependency points to an existing resource.
60        for (name, dependencies) in &deps {
61            for dependency in dependencies {
62                if !resolved.contains_key(dependency) {
63                    return Err(LifecycleError::ResourceNotFound(format!(
64                        "`{dependency}` (depended on by `{name}`)"
65                    )));
66                }
67            }
68        }
69
70        // Kahn's algorithm for topological sort.
71        let mut in_degree: HashMap<String, usize> = resolved
72            .keys()
73            .map(|name| (name.clone(), 0_usize))
74            .collect();
75        for dependencies in deps.values() {
76            for dependency in dependencies {
77                *in_degree.entry(dependency.clone()).or_insert(0) += 1;
78            }
79        }
80
81        // Build reverse adjacency: dependency → resources that depend on it.
82        let mut reverse: HashMap<String, Vec<String>> = HashMap::new();
83        for (name, dependencies) in &deps {
84            for dependency in dependencies {
85                reverse
86                    .entry(dependency.clone())
87                    .or_default()
88                    .push(name.clone());
89            }
90        }
91
92        // Start with nodes that no one depends on (in_degree == 0 in the
93        // reverse graph means "no dependent waits on this one"). For a
94        // dependency edge dep → res, res is added to nodes that depend
95        // on dep; topo order should yield deps first.
96        //
97        // We invert: deps come before their dependents.
98        // in_degree counts how many incoming dependency edges (= how
99        // many of my own dependencies) each node has.
100        let mut in_count: HashMap<String, usize> = resolved
101            .keys()
102            .map(|name| (name.clone(), deps.get(name).map_or(0, Vec::len)))
103            .collect();
104
105        let mut ready: Vec<String> = in_count
106            .iter()
107            .filter(|(_, count)| **count == 0)
108            .map(|(name, _)| name.clone())
109            .collect();
110        // Deterministic order: sort by name.
111        ready.sort();
112
113        let mut sorted: Vec<String> = Vec::with_capacity(resolved.len());
114        while let Some(node) = ready.pop() {
115            sorted.push(node.clone());
116            if let Some(dependents) = reverse.get(&node) {
117                let mut newly_ready: Vec<String> = Vec::new();
118                for dependent in dependents {
119                    let count = in_count.get_mut(dependent).expect("dependent indexed");
120                    *count -= 1;
121                    if *count == 0 {
122                        newly_ready.push(dependent.clone());
123                    }
124                }
125                newly_ready.sort();
126                ready.extend(newly_ready);
127            }
128        }
129
130        if sorted.len() != resolved.len() {
131            let unresolved: Vec<&String> = resolved
132                .keys()
133                .filter(|name| !sorted.contains(name))
134                .collect();
135            return Err(LifecycleError::Cycle(format!(
136                "{unresolved:?} involved in a cycle"
137            )));
138        }
139
140        let _ = in_degree; // silence unused warning for the alternate counter
141        let _ = HashSet::<&str>::new();
142
143        // Snapshot the edges before draining deps into the nodes.
144        let edges = deps.clone();
145
146        let nodes: Vec<PlanNode> = sorted
147            .into_iter()
148            .map(|name| {
149                let ResolvedResource { spec, outputs } =
150                    resolved.remove(&name).expect("spec indexed by name");
151                let dependencies = deps.remove(&name).unwrap_or_default();
152                let kind = kinds
153                    .remove(&name)
154                    .expect("kind indexed by name")
155                    .to_owned();
156                PlanNode {
157                    name,
158                    kind,
159                    spec,
160                    outputs,
161                    depends_on: dependencies,
162                }
163            })
164            .collect();
165
166        Ok(Self { nodes, edges })
167    }
168
169    /// The list of nodes in topological order.
170    #[must_use]
171    pub fn nodes(&self) -> &[PlanNode] {
172        &self.nodes
173    }
174
175    /// Names of resources that depend on `name`.
176    #[must_use]
177    pub fn dependents_of(&self, name: &str) -> Vec<&str> {
178        let mut out: Vec<&str> = Vec::new();
179        for (resource, deps) in &self.edges {
180            if deps.iter().any(|d| d == name) {
181                out.push(resource.as_str());
182            }
183        }
184        out.sort_unstable();
185        out
186    }
187}