Skip to main content

waypoint_core/
dependency.rs

1//! Migration dependency graph with topological sort.
2//!
3//! Supports `-- waypoint:depends V3,V5` directives for non-linear
4//! migration ordering using Kahn's algorithm.
5
6use std::collections::{HashMap, HashSet, VecDeque};
7
8use crate::error::{Result, WaypointError};
9use crate::migration::ResolvedMigration;
10
11/// Whether `from` depends on `target`, directly or through other versions.
12///
13/// Plain DFS over the `depends-on` edges; migration counts are small enough
14/// that the repeated traversal during graph construction is not worth caching.
15fn depends_transitively(
16    edges: &HashMap<String, HashSet<String>>,
17    from: &str,
18    target: &str,
19) -> bool {
20    let mut stack = vec![from];
21    let mut seen: HashSet<&str> = HashSet::new();
22    while let Some(node) = stack.pop() {
23        if node == target {
24            return true;
25        }
26        if !seen.insert(node) {
27            continue;
28        }
29        if let Some(deps) = edges.get(node) {
30            stack.extend(deps.iter().map(|s| s.as_str()));
31        }
32    }
33    false
34}
35
36/// A directed acyclic graph of migration dependencies.
37pub struct DependencyGraph {
38    /// version -> set of versions it depends on
39    edges: HashMap<String, HashSet<String>>,
40    /// version -> set of versions that depend on it
41    reverse_edges: HashMap<String, HashSet<String>>,
42    /// All known versions
43    all_versions: Vec<String>,
44}
45
46impl DependencyGraph {
47    /// Build a dependency graph from resolved migrations.
48    ///
49    /// If `implicit_chain` is true, each versioned migration implicitly depends
50    /// on the previous version in sort order (backward-compatible default).
51    pub fn build(migrations: &[&ResolvedMigration], implicit_chain: bool) -> Result<Self> {
52        let mut edges: HashMap<String, HashSet<String>> = HashMap::new();
53        let mut reverse_edges: HashMap<String, HashSet<String>> = HashMap::new();
54        let mut all_versions: Vec<String> = Vec::new();
55
56        // Collect all versioned migrations sorted by version
57        let mut versioned: Vec<&ResolvedMigration> = migrations
58            .iter()
59            .filter(|m| m.is_versioned())
60            .copied()
61            .collect();
62        versioned.sort_by(|a, b| a.version().unwrap().cmp(b.version().unwrap()));
63
64        for m in &versioned {
65            let version = m.version().unwrap().raw.clone();
66            edges.entry(version.clone()).or_default();
67            reverse_edges.entry(version.clone()).or_default();
68            all_versions.push(version);
69        }
70
71        // Add explicit dependencies from directives
72        for m in &versioned {
73            let version = &m.version().unwrap().raw;
74            for dep in &m.directives.depends {
75                if !edges.contains_key(dep) {
76                    return Err(WaypointError::MissingDependency {
77                        version: version.clone(),
78                        dependency: dep.clone(),
79                    });
80                }
81                edges.get_mut(version.as_str()).unwrap().insert(dep.clone());
82                reverse_edges
83                    .get_mut(dep.as_str())
84                    .unwrap()
85                    .insert(version.clone());
86            }
87        }
88
89        // Add implicit chain dependencies (each version depends on previous).
90        //
91        // An implicit edge must never contradict an explicit one. If `previous`
92        // already depends — directly or transitively — on `current`, then
93        // adding `current -> previous` closes a cycle, and the graph would be
94        // rejected even though the migrations are perfectly orderable. That
95        // happens whenever a lower version declares `depends` on a higher one:
96        //
97        //   V2 `-- waypoint:depends 3`   explicit: 2 -> 3
98        //   V3 has no directives         implicit: 3 -> 2   ← false cycle
99        //
100        // Skipping the implicit edge in that case leaves the explicit
101        // dependency to do the ordering, which is what the author asked for.
102        if implicit_chain {
103            for i in 1..all_versions.len() {
104                let current = all_versions[i].clone();
105                let previous = all_versions[i - 1].clone();
106
107                let has_explicit_deps = edges.get(&current).is_some_and(|deps| !deps.is_empty());
108                if has_explicit_deps {
109                    continue;
110                }
111                if depends_transitively(&edges, &previous, &current) {
112                    log::debug!(
113                        "Skipping implicit dependency {} -> {}: {} already depends on {}",
114                        current,
115                        previous,
116                        previous,
117                        current
118                    );
119                    continue;
120                }
121
122                edges
123                    .entry(current.clone())
124                    .or_default()
125                    .insert(previous.clone());
126                reverse_edges.entry(previous).or_default().insert(current);
127            }
128        }
129
130        Ok(DependencyGraph {
131            edges,
132            reverse_edges,
133            all_versions,
134        })
135    }
136
137    /// Produce a topologically sorted order of versions using Kahn's algorithm.
138    ///
139    /// Uses borrowed `&str` references internally to avoid cloning during
140    /// the sort; only clones into owned `String`s for the output.
141    pub fn topological_sort(&self) -> Result<Vec<String>> {
142        // Compute in-degree for each node using borrowed keys
143        let mut in_degree: HashMap<&str, usize> = HashMap::new();
144        for v in &self.all_versions {
145            in_degree.insert(v, self.edges.get(v).map_or(0, |deps| deps.len()));
146        }
147
148        // Start with nodes that have no dependencies
149        let mut queue: VecDeque<&str> = VecDeque::new();
150        for v in &self.all_versions {
151            if *in_degree.get(v.as_str()).unwrap_or(&0) == 0 {
152                queue.push_back(v);
153            }
154        }
155
156        let mut sorted = Vec::new();
157
158        while let Some(node) = queue.pop_front() {
159            sorted.push(node.to_string());
160
161            // For each node that depends on this one, decrement in-degree
162            if let Some(dependents) = self.reverse_edges.get(node) {
163                for dep in dependents {
164                    let deg = in_degree.get_mut(dep.as_str()).unwrap();
165                    *deg -= 1;
166                    if *deg == 0 {
167                        queue.push_back(dep);
168                    }
169                }
170            }
171        }
172
173        if sorted.len() != self.all_versions.len() {
174            // Trace an actual cycle path — convert in_degree to owned keys for trace_cycle
175            let owned_in_degree: HashMap<String, usize> = in_degree
176                .iter()
177                .map(|(&k, &v)| (k.to_string(), v))
178                .collect();
179            let cycle_path = self.trace_cycle(&owned_in_degree);
180            return Err(WaypointError::DependencyCycle { path: cycle_path });
181        }
182
183        Ok(sorted)
184    }
185
186    /// Trace an actual cycle path for error reporting.
187    fn trace_cycle(&self, in_degree: &HashMap<String, usize>) -> String {
188        // Start from any node still in the cycle
189        let start = self
190            .all_versions
191            .iter()
192            .find(|v| *in_degree.get(*v).unwrap_or(&0) > 0);
193
194        let Some(start) = start else {
195            return "unknown cycle".to_string();
196        };
197
198        // Follow dependency edges to trace the cycle
199        let mut path = vec![start.clone()];
200        let mut current = start.clone();
201        let mut visited = std::collections::HashSet::new();
202        visited.insert(current.clone());
203
204        loop {
205            // Find a dependency of `current` that is also in the cycle
206            let next = self
207                .edges
208                .get(&current)
209                .and_then(|deps| deps.iter().find(|d| *in_degree.get(*d).unwrap_or(&0) > 0));
210
211            match next {
212                Some(n) => {
213                    if !visited.insert(n.clone()) {
214                        // We've come back to a visited node — complete the cycle
215                        path.push(n.clone());
216                        // Trim path to start from the cycle entry point
217                        if let Some(pos) = path.iter().position(|v| v == n) {
218                            let cycle: Vec<String> = path[pos..].to_vec();
219                            return cycle.join(" -> ");
220                        }
221                        return path.join(" -> ");
222                    }
223                    path.push(n.clone());
224                    current = n.clone();
225                }
226                None => {
227                    // Fallback: list all nodes in cycle
228                    let in_cycle: Vec<String> = self
229                        .all_versions
230                        .iter()
231                        .filter(|v| *in_degree.get(*v).unwrap_or(&0) > 0)
232                        .cloned()
233                        .collect();
234                    return format!("cycle involving: {}", in_cycle.join(", "));
235                }
236            }
237        }
238    }
239}
240
241#[cfg(test)]
242mod tests {
243    use super::*;
244    use crate::directive::MigrationDirectives;
245    use crate::migration::{MigrationKind, MigrationVersion, ResolvedMigration};
246
247    fn make_migration(version: &str, depends: Vec<&str>) -> ResolvedMigration {
248        ResolvedMigration {
249            kind: MigrationKind::Versioned(MigrationVersion::parse(version).unwrap()),
250            description: format!("V{}", version),
251            script: format!("V{}__test.sql", version),
252            checksum: 0,
253            sql: String::new(),
254            directives: MigrationDirectives {
255                depends: depends.into_iter().map(String::from).collect(),
256                env: vec![],
257                ..Default::default()
258            },
259        }
260    }
261
262    #[test]
263    fn test_simple_chain() {
264        let m1 = make_migration("1", vec![]);
265        let m2 = make_migration("2", vec![]);
266        let m3 = make_migration("3", vec![]);
267        let migrations: Vec<&ResolvedMigration> = vec![&m1, &m2, &m3];
268
269        let graph = DependencyGraph::build(&migrations, true).unwrap();
270        let order = graph.topological_sort().unwrap();
271        assert_eq!(order, vec!["1", "2", "3"]);
272    }
273
274    #[test]
275    fn test_explicit_dependency() {
276        let m1 = make_migration("1", vec![]);
277        let m2 = make_migration("2", vec![]);
278        let m3 = make_migration("3", vec!["1"]); // V3 depends on V1, skipping V2
279        let migrations: Vec<&ResolvedMigration> = vec![&m1, &m2, &m3];
280
281        let graph = DependencyGraph::build(&migrations, false).unwrap();
282        let order = graph.topological_sort().unwrap();
283        // V1 must come before V3, V2 has no deps so can be anywhere
284        let pos1 = order.iter().position(|v| v == "1").unwrap();
285        let pos3 = order.iter().position(|v| v == "3").unwrap();
286        assert!(pos1 < pos3);
287    }
288
289    #[test]
290    fn test_cycle_detection() {
291        let m1 = make_migration("1", vec!["2"]);
292        let m2 = make_migration("2", vec!["1"]);
293        let migrations: Vec<&ResolvedMigration> = vec![&m1, &m2];
294
295        let graph = DependencyGraph::build(&migrations, false).unwrap();
296        assert!(graph.topological_sort().is_err());
297    }
298
299    #[test]
300    fn test_missing_dependency() {
301        let m1 = make_migration("1", vec!["99"]);
302        let migrations: Vec<&ResolvedMigration> = vec![&m1];
303
304        assert!(DependencyGraph::build(&migrations, false).is_err());
305    }
306
307    #[test]
308    fn test_cycle_error_shows_path() {
309        let m1 = make_migration("1", vec!["3"]);
310        let m2 = make_migration("2", vec!["1"]);
311        let m3 = make_migration("3", vec!["2"]);
312        let migrations: Vec<&ResolvedMigration> = vec![&m1, &m2, &m3];
313
314        let graph = DependencyGraph::build(&migrations, false).unwrap();
315        let err = graph.topological_sort().unwrap_err();
316        let msg = err.to_string();
317        // The error should contain cycle path information
318        assert!(msg.contains("->"), "Cycle error should show path: {}", msg);
319    }
320
321    #[test]
322    fn test_empty_migrations() {
323        let migrations: Vec<&ResolvedMigration> = vec![];
324        let graph = DependencyGraph::build(&migrations, true).unwrap();
325        let order = graph.topological_sort().unwrap();
326        assert!(order.is_empty());
327    }
328
329    #[test]
330    fn test_single_migration() {
331        let m1 = make_migration("1", vec![]);
332        let migrations: Vec<&ResolvedMigration> = vec![&m1];
333        let graph = DependencyGraph::build(&migrations, true).unwrap();
334        let order = graph.topological_sort().unwrap();
335        assert_eq!(order, vec!["1"]);
336    }
337
338    #[test]
339    fn test_diamond_dependency() {
340        let m1 = make_migration("1", vec![]);
341        let m2 = make_migration("2", vec!["1"]);
342        let m3 = make_migration("3", vec!["1"]);
343        let m4 = make_migration("4", vec!["2", "3"]);
344        let migrations: Vec<&ResolvedMigration> = vec![&m1, &m2, &m3, &m4];
345
346        let graph = DependencyGraph::build(&migrations, false).unwrap();
347        let order = graph.topological_sort().unwrap();
348
349        // V1 must be first, V4 must be last
350        assert_eq!(order[0], "1");
351        assert_eq!(order[3], "4");
352    }
353
354    #[test]
355    fn test_self_referencing_cycle() {
356        let m1 = make_migration("1", vec!["1"]);
357        let migrations: Vec<&ResolvedMigration> = vec![&m1];
358
359        let graph = DependencyGraph::build(&migrations, false).unwrap();
360        assert!(graph.topological_sort().is_err());
361    }
362}