Skip to main content

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;