Skip to main content

semifold_core/
workspace.rs

1use std::{
2    collections::{BTreeMap, BTreeSet},
3    fmt,
4};
5
6use crate::{PackageId, PackageSnapshot};
7
8/// Validated package graph for one multi-ecosystem workspace.
9#[derive(Clone, Debug, Eq, PartialEq)]
10pub struct WorkspaceGraph {
11    packages: BTreeMap<PackageId, PackageSnapshot>,
12    dependencies: BTreeMap<PackageId, BTreeSet<PackageId>>,
13}
14
15impl WorkspaceGraph {
16    /// Builds a graph from discovered packages and their internal dependencies.
17    pub fn new(packages: Vec<PackageSnapshot>) -> Result<Self, WorkspaceGraphError> {
18        let mut package_map = BTreeMap::new();
19
20        for package in packages {
21            let id = package.id.clone();
22            if package_map.insert(id.clone(), package).is_some() {
23                return Err(WorkspaceGraphError::DuplicatePackageId { package: id });
24            }
25        }
26
27        let mut dependencies = BTreeMap::new();
28        for (id, package) in &package_map {
29            let mut package_dependencies = BTreeSet::new();
30            for dependency in &package.dependencies {
31                if !package_map.contains_key(&dependency.package) {
32                    return Err(WorkspaceGraphError::UnknownDependency {
33                        package: id.clone(),
34                        dependency: dependency.package.clone(),
35                    });
36                }
37                package_dependencies.insert(dependency.package.clone());
38            }
39            dependencies.insert(id.clone(), package_dependencies);
40        }
41
42        Ok(Self {
43            packages: package_map,
44            dependencies,
45        })
46    }
47
48    #[must_use]
49    pub fn package(&self, id: &PackageId) -> Option<&PackageSnapshot> {
50        self.packages.get(id)
51    }
52
53    pub fn packages(&self) -> impl Iterator<Item = &PackageSnapshot> {
54        self.packages.values()
55    }
56
57    /// Returns a stable order where every dependency precedes its dependents.
58    pub fn topological_order(&self) -> Result<Vec<PackageId>, WorkspaceGraphError> {
59        let mut remaining_dependencies = self.dependencies.clone();
60        let mut order = Vec::with_capacity(self.packages.len());
61
62        while let Some(id) = remaining_dependencies
63            .iter()
64            .find_map(|(id, dependencies)| dependencies.is_empty().then(|| id.clone()))
65        {
66            remaining_dependencies.remove(&id);
67            for dependencies in remaining_dependencies.values_mut() {
68                dependencies.remove(&id);
69            }
70            order.push(id);
71        }
72
73        if remaining_dependencies.is_empty() {
74            Ok(order)
75        } else {
76            Err(WorkspaceGraphError::DependencyCycle {
77                cycle: self.find_cycle(&remaining_dependencies)?,
78            })
79        }
80    }
81
82    fn find_cycle(
83        &self,
84        remaining_dependencies: &BTreeMap<PackageId, BTreeSet<PackageId>>,
85    ) -> Result<Vec<PackageId>, WorkspaceGraphError> {
86        let mut visited = BTreeSet::new();
87        let mut stack = Vec::new();
88        let mut visiting = BTreeSet::new();
89
90        for id in remaining_dependencies.keys() {
91            if let Some(cycle) = Self::visit(
92                id,
93                remaining_dependencies,
94                &mut visited,
95                &mut visiting,
96                &mut stack,
97            ) {
98                return Ok(cycle);
99            }
100        }
101
102        Err(WorkspaceGraphError::CycleDetectionFailed)
103    }
104
105    fn visit(
106        id: &PackageId,
107        remaining_dependencies: &BTreeMap<PackageId, BTreeSet<PackageId>>,
108        visited: &mut BTreeSet<PackageId>,
109        visiting: &mut BTreeSet<PackageId>,
110        stack: &mut Vec<PackageId>,
111    ) -> Option<Vec<PackageId>> {
112        if let Some(cycle_start) = stack.iter().position(|package| package == id) {
113            let mut cycle = stack.iter().skip(cycle_start).cloned().collect::<Vec<_>>();
114            cycle.push(id.clone());
115            return Some(cycle);
116        }
117        if !visited.insert(id.clone()) {
118            return None;
119        }
120
121        visiting.insert(id.clone());
122        stack.push(id.clone());
123        let dependencies = remaining_dependencies.get(id)?;
124        for dependency in dependencies {
125            if let Some(cycle) =
126                Self::visit(dependency, remaining_dependencies, visited, visiting, stack)
127            {
128                return Some(cycle);
129            }
130        }
131        stack.pop();
132        visiting.remove(id);
133        None
134    }
135}
136
137/// Validation and ordering failures produced by [`WorkspaceGraph`].
138#[derive(Clone, Debug, Eq, PartialEq, thiserror::Error)]
139pub enum WorkspaceGraphError {
140    #[error("duplicate package id: {package}")]
141    DuplicatePackageId { package: PackageId },
142    #[error("package {package} depends on unknown package {dependency}")]
143    UnknownDependency {
144        package: PackageId,
145        dependency: PackageId,
146    },
147    #[error("dependency cycle: {}", display_cycle(.cycle))]
148    DependencyCycle { cycle: Vec<PackageId> },
149    #[error("failed to identify a dependency cycle in the remaining graph")]
150    CycleDetectionFailed,
151}
152
153fn display_cycle(cycle: &[PackageId]) -> DependencyCycleDisplay<'_> {
154    DependencyCycleDisplay(cycle)
155}
156
157struct DependencyCycleDisplay<'a>(&'a [PackageId]);
158
159impl fmt::Display for DependencyCycleDisplay<'_> {
160    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
161        for (index, package) in self.0.iter().enumerate() {
162            if index > 0 {
163                formatter.write_str(" -> ")?;
164            }
165            package.fmt(formatter)?;
166        }
167        Ok(())
168    }
169}
170
171#[cfg(test)]
172mod tests {
173    use camino::Utf8PathBuf;
174    use semver::Version;
175
176    use super::*;
177    use crate::{Dependency, DependencyKind, DependencySource, EcosystemId, VersionSource};
178
179    fn package(id: &str, dependencies: &[&str]) -> PackageSnapshot {
180        PackageSnapshot {
181            id: PackageId::new(id),
182            manifest_name: id.to_owned(),
183            version: Version::new(1, 0, 0),
184            version_source: VersionSource::PackageManifest,
185            ecosystem: EcosystemId::RUST,
186            path: Utf8PathBuf::from(format!("crates/{id}")),
187            publishable: true,
188            dependencies: dependencies
189                .iter()
190                .map(|dependency| Dependency {
191                    package: PackageId::new(*dependency),
192                    kind: DependencyKind::Runtime,
193                    requirement: None,
194                    source: DependencySource::Manifest,
195                })
196                .collect(),
197        }
198    }
199
200    fn ids(order: Vec<PackageId>) -> Vec<String> {
201        order.into_iter().map(|id| id.to_string()).collect()
202    }
203
204    #[test]
205    fn orders_multi_level_dependencies() {
206        let graph = WorkspaceGraph::new(vec![
207            package("app", &["api"]),
208            package("api", &["core"]),
209            package("core", &[]),
210        ])
211        .unwrap();
212
213        assert_eq!(
214            ids(graph.topological_order().unwrap()),
215            ["core", "api", "app"]
216        );
217    }
218
219    #[test]
220    fn orders_diamond_dependencies_once() {
221        let graph = WorkspaceGraph::new(vec![
222            package("app", &["left", "right"]),
223            package("left", &["core"]),
224            package("right", &["core"]),
225            package("core", &[]),
226        ])
227        .unwrap();
228
229        assert_eq!(
230            ids(graph.topological_order().unwrap()),
231            ["core", "left", "right", "app"]
232        );
233    }
234
235    #[test]
236    fn orders_every_manifest_dependency_kind_before_the_dependent() {
237        let mut app = package(
238            "app",
239            &["runtime", "development", "build", "optional", "peer"],
240        );
241        for (dependency, kind) in app.dependencies.iter_mut().zip([
242            DependencyKind::Runtime,
243            DependencyKind::Development,
244            DependencyKind::Build,
245            DependencyKind::Optional,
246            DependencyKind::Peer,
247        ]) {
248            dependency.kind = kind;
249        }
250        let graph = WorkspaceGraph::new(vec![
251            app,
252            package("runtime", &[]),
253            package("development", &[]),
254            package("build", &[]),
255            package("optional", &[]),
256            package("peer", &[]),
257        ])
258        .unwrap();
259
260        assert_eq!(
261            ids(graph.topological_order().unwrap()),
262            ["build", "development", "optional", "peer", "runtime", "app"]
263        );
264    }
265
266    #[test]
267    fn orders_unrelated_packages_by_package_id() {
268        let graph =
269            WorkspaceGraph::new(vec![package("zebra", &[]), package("alpha", &[])]).unwrap();
270
271        assert_eq!(ids(graph.topological_order().unwrap()), ["alpha", "zebra"]);
272    }
273
274    #[test]
275    fn preserves_dynamic_ecosystem_identity_in_the_workspace_graph() {
276        let ecosystem = EcosystemId::new("com.example.engine").unwrap();
277        let mut plugin_package = package("game", &[]);
278        plugin_package.ecosystem = ecosystem.clone();
279
280        let graph = WorkspaceGraph::new(vec![plugin_package]).unwrap();
281
282        assert_eq!(
283            graph.package(&PackageId::new("game")).unwrap().ecosystem,
284            ecosystem
285        );
286    }
287
288    #[test]
289    fn reports_complete_dependency_cycle() {
290        let graph = WorkspaceGraph::new(vec![
291            package("a", &["b"]),
292            package("b", &["c"]),
293            package("c", &["a"]),
294        ])
295        .unwrap();
296
297        assert_eq!(
298            graph.topological_order(),
299            Err(WorkspaceGraphError::DependencyCycle {
300                cycle: vec![
301                    PackageId::new("a"),
302                    PackageId::new("b"),
303                    PackageId::new("c"),
304                    PackageId::new("a"),
305                ],
306            })
307        );
308        assert_eq!(
309            graph.topological_order().unwrap_err().to_string(),
310            "dependency cycle: a -> b -> c -> a"
311        );
312    }
313
314    #[test]
315    fn rejects_duplicate_package_ids() {
316        assert_eq!(
317            WorkspaceGraph::new(vec![package("core", &[]), package("core", &[])]),
318            Err(WorkspaceGraphError::DuplicatePackageId {
319                package: PackageId::new("core"),
320            })
321        );
322    }
323
324    #[test]
325    fn rejects_unknown_internal_dependencies() {
326        assert_eq!(
327            WorkspaceGraph::new(vec![package("app", &["missing"])]),
328            Err(WorkspaceGraphError::UnknownDependency {
329                package: PackageId::new("app"),
330                dependency: PackageId::new("missing"),
331            })
332        );
333    }
334}