git_policy_cli/dependent_version_graph.rs
1//! What: Walk the workspace dependency graph from the packages whose version was raised and
2//! plan a patch bump for every publishable package that reaches one of them.
3//! Why: A dependent's published contents change when a dependency it ships with changes,
4//! so its version must move too. This is the incumbent's closure and filter
5//! (`dependent-version-bump.ts:183-350`): edges to names outside the workspace and
6//! self edges are ignored, the walk passes through unpublished packages and cycles,
7//! and only publishable, versioned, not-yet-raised packages are bumped.
8//!
9//! In TS you'd write (pseudocode):
10//! ```ts
11//! // planDependentBumps({ manifests, bumpedNames, publishableNames });
12//! ```
13
14/// What: `use` brings names from sibling files and the standard library into this file.
15/// `HashMap`/`HashSet` are hash-based map and set types.
16/// Why: The plan bumps release versions; edges and visits are looked up by name.
17///
18/// In TS you'd write (pseudocode):
19/// ```ts
20/// import { patchBumpVersion } from './dependent_version_release.ts';
21/// ```
22use super::dependent_version_release::{ReleaseBump, UnsupportedVersion, patch_bump_version};
23/// Hash-based maps and sets for edges and visits.
24use std::collections::{HashMap, HashSet};
25
26/// What: One workspace package as the walk sees it. `Option<Vec<u16>>` is the version as
27/// JavaScript code units, or nothing.
28/// Why: Edges are the names this package reaches through runtime fields or bundled
29/// development imports; which ones count is decided before the walk.
30///
31/// In TS you'd write (pseudocode):
32/// ```ts
33/// type WorkspaceManifest = { name: string; directory: string; version?: string; edgeNames: string[] };
34/// ```
35#[derive(Clone, Debug, Eq, PartialEq)]
36pub struct WorkspaceNode {
37 /// The package name.
38 pub name: String,
39 /// The package directory, repository-relative, without a trailing slash.
40 pub directory: Vec<u8>,
41 /// The current version.
42 pub version: Option<Vec<u16>>,
43 /// Names this package depends on.
44 pub edge_names: Vec<String>,
45}
46
47/// What: One planned dependent bump.
48/// Why: The plan turns each into a manifest text edit and a finding.
49///
50/// In TS you'd write (pseudocode):
51/// ```ts
52/// type PlannedBump = { name: string; directory: string; from: string; to: string };
53/// ```
54#[derive(Clone, Debug, Eq, PartialEq)]
55pub struct PlannedBump {
56 /// The dependent's package name.
57 pub name: String,
58 /// Its directory.
59 pub directory: Vec<u8>,
60 /// The version before and after.
61 pub bump: ReleaseBump,
62}
63
64/// What: Every package that transitively depends on one of `bumped_names`.
65/// `&[String]` borrows a list of names; the result owns its names.
66/// Why: The walk keeps a work stack of names whose dependents still need visiting, so it
67/// ends on cycles and never recurses. A raised package reached from another raised
68/// package is in the result; the caller decides what that means.
69///
70/// In TS you'd write (pseudocode):
71/// ```ts
72/// function transitiveDependentNames({ manifests, bumpedNames }): Set<string>;
73/// ```
74pub fn transitive_dependent_names(
75 nodes: &[WorkspaceNode],
76 bumped_names: &[String],
77) -> HashSet<String> {
78 let workspace: HashSet<&str> = nodes
79 .iter()
80 .map(|node: &WorkspaceNode| return node.name.as_str())
81 .collect();
82 let mut dependents: HashMap<&str, Vec<&str>> = HashMap::new();
83 for node in nodes {
84 for edge in &node.edge_names {
85 if workspace.contains(edge.as_str()) && *edge != node.name {
86 // `.entry(...).or_default()` finds the list for a key, creating an empty one.
87 dependents
88 .entry(edge.as_str())
89 .or_default()
90 .push(node.name.as_str());
91 }
92 }
93 }
94 let mut pending: Vec<&str> = bumped_names.iter().map(String::as_str).collect();
95 let mut reached: HashSet<String> = HashSet::new();
96 // `while let Some(...) = pending.pop()` takes names until the stack is empty.
97 while let Some(current) = pending.pop() {
98 for dependent in dependents.get(current).map_or(&[][..], Vec::as_slice) {
99 // `.insert` answers whether the name was new, so each name is expanded once.
100 if reached.insert(String::from(*dependent)) {
101 pending.push(dependent);
102 }
103 }
104 }
105 return reached;
106}
107
108/// What: Order names as JavaScript's `<` does, by UTF-16 code units.
109/// `std::cmp::Ordering` is less, equal or greater.
110/// Why: Rust compares `String`s by UTF-8 bytes, which orders characters above U+FFFF
111/// differently from characters between U+E000 and U+FFFF.
112///
113/// In TS you'd write (pseudocode):
114/// ```ts
115/// const byName = (left: string, right: string) => (left < right ? -1 : left > right ? 1 : 0);
116/// ```
117pub fn utf16_order(left: &str, right: &str) -> std::cmp::Ordering {
118 return left.encode_utf16().cmp(right.encode_utf16());
119}
120
121/// What: The patch bumps for publishable dependents of the raised packages, sorted by name.
122/// `Result<T, UnsupportedVersion>` is the plan, or the first dependent (in node order)
123/// whose version cannot be bumped automatically.
124/// Why: Raised packages already moved; a package without a version has nothing to bump;
125/// an unpublished package is never released, though the walk passes through it.
126///
127/// In TS you'd write (pseudocode):
128/// ```ts
129/// function planDependentBumps({ manifests, bumpedNames, publishableNames }): PlannedBump[];
130/// ```
131pub fn plan_dependent_bumps(
132 nodes: &[WorkspaceNode],
133 bumped_names: &[String],
134 publishable_names: &[String],
135) -> Result<Vec<PlannedBump>, UnsupportedVersion> {
136 let reached: HashSet<String> = transitive_dependent_names(nodes, bumped_names);
137 let mut bumps: Vec<PlannedBump> = Vec::new();
138 for node in nodes {
139 let needs_bump: bool = reached.contains(&node.name)
140 && !bumped_names.contains(&node.name)
141 && publishable_names.contains(&node.name);
142 // `if let (true, Some(...))` runs only for a node that needs a bump and has a version.
143 if let (true, Some(version)) = (needs_bump, &node.version) {
144 bumps.push(PlannedBump {
145 name: node.name.clone(),
146 directory: node.directory.clone(),
147 bump: patch_bump_version(&node.name, version)?,
148 });
149 }
150 }
151 // `.sort_by` is stable, as `toSorted` is.
152 bumps.sort_by(|left: &PlannedBump, right: &PlannedBump| {
153 return utf16_order(&left.name, &right.name);
154 });
155 return Ok(bumps);
156}
157
158/// The closure and plan, including every case of the incumbent's unit tests.
159#[cfg(test)]
160#[path = "dependent_version_graph_tests.rs"]
161mod tests;