Skip to main content

cuenv_release/
publish.rs

1//! Publishing workflow and topological ordering.
2//!
3//! This module provides utilities for publishing packages in the correct
4//! dependency order, ensuring that dependencies are published before
5//! the packages that depend on them.
6
7use crate::error::{Error, Result};
8use crate::version::Version;
9use petgraph::graph::{DiGraph, NodeIndex};
10use petgraph::visit::Topo;
11use std::collections::HashMap;
12use std::path::PathBuf;
13
14/// Represents a package to be published.
15#[derive(Debug, Clone)]
16pub struct PublishPackage {
17    /// Package name.
18    pub name: String,
19    /// Path to the package root.
20    pub path: PathBuf,
21    /// New version to publish.
22    pub version: Version,
23    /// Names of packages this depends on.
24    pub dependencies: Vec<String>,
25}
26
27/// A plan for publishing packages in the correct order.
28#[derive(Debug, Clone)]
29pub struct PublishPlan {
30    /// Packages in topological order (dependencies first).
31    pub packages: Vec<PublishPackage>,
32}
33
34impl PublishPlan {
35    /// Create a publish plan from a list of packages.
36    ///
37    /// # Errors
38    ///
39    /// Returns an error if there is a dependency cycle or missing dependency.
40    pub fn from_packages(packages: Vec<PublishPackage>) -> Result<Self> {
41        if packages.is_empty() {
42            return Ok(Self { packages: vec![] });
43        }
44
45        // Build a dependency graph
46        let mut graph: DiGraph<String, ()> = DiGraph::new();
47        let mut node_map: HashMap<String, NodeIndex> = HashMap::new();
48
49        // Add all nodes
50        for pkg in &packages {
51            let idx = graph.add_node(pkg.name.clone());
52            node_map.insert(pkg.name.clone(), idx);
53        }
54
55        // Add edges (dependency -> dependent)
56        for pkg in &packages {
57            let dependent_idx = node_map[&pkg.name];
58            for dep in &pkg.dependencies {
59                // Only add edge if the dependency is also being published
60                if let Some(&dep_idx) = node_map.get(dep) {
61                    graph.add_edge(dep_idx, dependent_idx, ());
62                }
63            }
64        }
65
66        // Topological sort
67        let mut topo = Topo::new(&graph);
68        let mut ordered_names = Vec::new();
69
70        while let Some(idx) = topo.next(&graph) {
71            ordered_names.push(graph[idx].clone());
72        }
73
74        // Check for cycles: petgraph's Topo iterator stops early when it cannot find
75        // a node with no unvisited predecessors (i.e., when there's a cycle)
76        if ordered_names.len() != packages.len() {
77            return Err(Error::config(
78                "Dependency cycle detected in packages to publish",
79                "Check package dependencies for circular references",
80            ));
81        }
82
83        // Build ordered package list
84        let pkg_map: HashMap<String, PublishPackage> =
85            packages.into_iter().map(|p| (p.name.clone(), p)).collect();
86
87        let ordered_packages: Vec<PublishPackage> = ordered_names
88            .into_iter()
89            .filter_map(|name| pkg_map.get(&name).cloned())
90            .collect();
91
92        Ok(Self {
93            packages: ordered_packages,
94        })
95    }
96
97    /// Get the number of packages to publish.
98    #[must_use]
99    pub const fn len(&self) -> usize {
100        self.packages.len()
101    }
102
103    /// Check if the plan is empty.
104    #[must_use]
105    pub const fn is_empty(&self) -> bool {
106        self.packages.is_empty()
107    }
108
109    /// Iterate over packages in publish order.
110    pub fn iter(&self) -> impl Iterator<Item = &PublishPackage> {
111        self.packages.iter()
112    }
113}
114
115#[cfg(test)]
116mod tests {
117    use super::*;
118
119    fn make_package(name: &str, deps: Vec<&str>) -> PublishPackage {
120        PublishPackage {
121            name: name.to_string(),
122            path: PathBuf::from(format!("packages/{name}")),
123            version: Version::new(1, 0, 0),
124            dependencies: deps.into_iter().map(String::from).collect(),
125        }
126    }
127
128    #[test]
129    fn test_publish_plan_empty() {
130        let plan = PublishPlan::from_packages(vec![]).unwrap();
131        assert!(plan.is_empty());
132        assert_eq!(plan.len(), 0);
133    }
134
135    #[test]
136    fn test_publish_plan_single_package() {
137        let packages = vec![make_package("pkg-a", vec![])];
138        let plan = PublishPlan::from_packages(packages).unwrap();
139
140        assert_eq!(plan.len(), 1);
141        assert_eq!(plan.packages[0].name, "pkg-a");
142    }
143
144    #[test]
145    fn test_publish_plan_linear_deps() {
146        // pkg-c depends on pkg-b depends on pkg-a
147        let packages = vec![
148            make_package("pkg-c", vec!["pkg-b"]),
149            make_package("pkg-b", vec!["pkg-a"]),
150            make_package("pkg-a", vec![]),
151        ];
152
153        let plan = PublishPlan::from_packages(packages).unwrap();
154
155        assert_eq!(plan.len(), 3);
156        // pkg-a should come first, then pkg-b, then pkg-c
157        assert_eq!(plan.packages[0].name, "pkg-a");
158        assert_eq!(plan.packages[1].name, "pkg-b");
159        assert_eq!(plan.packages[2].name, "pkg-c");
160    }
161
162    #[test]
163    fn test_publish_plan_diamond_deps() {
164        // pkg-d depends on pkg-b and pkg-c
165        // pkg-b depends on pkg-a
166        // pkg-c depends on pkg-a
167        let packages = vec![
168            make_package("pkg-d", vec!["pkg-b", "pkg-c"]),
169            make_package("pkg-b", vec!["pkg-a"]),
170            make_package("pkg-c", vec!["pkg-a"]),
171            make_package("pkg-a", vec![]),
172        ];
173
174        let plan = PublishPlan::from_packages(packages).unwrap();
175
176        assert_eq!(plan.len(), 4);
177        // pkg-a must come first
178        assert_eq!(plan.packages[0].name, "pkg-a");
179        // pkg-d must come last
180        assert_eq!(plan.packages[3].name, "pkg-d");
181        // pkg-b and pkg-c can be in any order in the middle
182        let middle: Vec<&str> = plan.packages[1..3]
183            .iter()
184            .map(|p| p.name.as_str())
185            .collect();
186        assert!(middle.contains(&"pkg-b"));
187        assert!(middle.contains(&"pkg-c"));
188    }
189
190    #[test]
191    fn test_publish_plan_external_deps() {
192        // Dependencies on packages not in the publish list are ignored
193        let packages = vec![
194            make_package("pkg-a", vec!["external-dep"]),
195            make_package("pkg-b", vec!["pkg-a"]),
196        ];
197
198        let plan = PublishPlan::from_packages(packages).unwrap();
199
200        assert_eq!(plan.len(), 2);
201        assert_eq!(plan.packages[0].name, "pkg-a");
202        assert_eq!(plan.packages[1].name, "pkg-b");
203    }
204
205    #[test]
206    fn test_publish_plan_iteration() {
207        let packages = vec![
208            make_package("pkg-b", vec!["pkg-a"]),
209            make_package("pkg-a", vec![]),
210        ];
211
212        let plan = PublishPlan::from_packages(packages).unwrap();
213
214        let names: Vec<&str> = plan.iter().map(|p| p.name.as_str()).collect();
215        assert_eq!(names, vec!["pkg-a", "pkg-b"]);
216    }
217}