git_policy_cli/dependent_version_plan.rs
1//! What: Plan patch bumps for publishable dependents of packages whose version differs from
2//! the base, with the exact manifest bytes before and after each bump.
3//! Why: This is the one planner behind both entry points: the commit-time policy and the
4//! release workflow's ripple, which will call the native wrapper's direct fix. It is
5//! the incumbent's `planWorkspaceBumps` (`dependent-bump-workflow.ts:280-388`) over the
6//! content seam: it starts no Git process and reads no file.
7//!
8//! In TS you'd write (pseudocode):
9//! ```ts
10//! // const plan = await planWorkspaceBumps(reader);
11//! ```
12
13/// What: `use` brings names from sibling files into this file.
14/// Why: Each step of the plan has its own module.
15///
16/// In TS you'd write (pseudocode):
17/// ```ts
18/// import { readManifestStates, bumpedNames } from './dependent_version_states.ts';
19/// ```
20use super::dependent_version_bundled::bundled_development_edges;
21/// The content seam and its failures.
22use super::dependent_version_content::{PlanError, TrackedMode, TrackedPath, WorkspaceContent};
23/// The dependency walk and the bump plan.
24use super::dependent_version_graph::{PlannedBump, WorkspaceNode, plan_dependent_bumps};
25/// Strict decoding of the registry configuration.
26use super::dependent_version_manifest::strict_text;
27/// The registry configuration's path.
28use super::dependent_version_paths::PNPR_CONFIG_PATH;
29/// The publish set from the registry configuration.
30use super::dependent_version_publishable::read_publishable_names;
31/// A dependent whose version cannot be bumped automatically.
32use super::dependent_version_release::UnsupportedVersion;
33/// Manifest states, raised names and duplicate names.
34use super::dependent_version_states::{
35 ManifestState, bumped_names, duplicate_name, read_manifest_states,
36};
37/// The byte-preserving version rewrite.
38use super::dependent_version_text::replace_manifest_version;
39
40/// What: One dependent bump with the manifest change that performs it. `Vec<u8>` holds
41/// exact file bytes.
42/// Why: The policy turns it into a full-content patch of a tracked file; the release path
43/// writes `replacement` after checking the file still holds `original`.
44///
45/// In TS you'd write (pseudocode):
46/// ```ts
47/// type ManifestBump = PlannedBump & { path: string; text: string; replacement: string };
48/// ```
49#[derive(Clone, Debug, Eq, PartialEq)]
50pub struct ManifestBump {
51 /// The dependent, its directory and the version change.
52 pub planned: PlannedBump,
53 /// The manifest path.
54 pub path: Vec<u8>,
55 /// Its mode in the candidate state.
56 pub mode: TrackedMode,
57 /// Its exact current bytes.
58 pub original: Vec<u8>,
59 /// The same bytes with only the version value changed; a leading byte-order mark is
60 /// kept.
61 pub replacement: Vec<u8>,
62}
63
64/// What: A completed plan.
65/// Why: The finding message names the raised packages; the bumps are sorted by name.
66///
67/// In TS you'd write (pseudocode):
68/// ```ts
69/// type WorkspaceBumpPlan = { bumpedNames: string[]; bumps: ManifestBump[] };
70/// ```
71#[derive(Clone, Debug, Eq, PartialEq)]
72pub struct WorkspaceBumpPlan {
73 /// Packages whose version differs from the base, in manifest order.
74 pub bumped_names: Vec<String>,
75 /// Dependent bumps, sorted by name.
76 pub bumps: Vec<ManifestBump>,
77}
78
79/// What: How planning ended when nothing failed.
80/// Why: A dependent that needs a bump but holds no plain release stops the plan; the
81/// policy reports it for a bump by hand, the release path fails.
82///
83/// In TS you'd write (pseudocode):
84/// ```ts
85/// type PlanOutcome = { kind: 'planned'; plan: WorkspaceBumpPlan } | { kind: 'unsupported'; error: UnsupportedVersionError };
86/// ```
87#[derive(Clone, Debug, Eq, PartialEq)]
88pub enum PlanOutcome {
89 /// Every needed bump was planned; the list may be empty.
90 Planned(
91 /// The plan.
92 WorkspaceBumpPlan,
93 ),
94 /// The first dependent, in manifest order, whose version cannot be bumped.
95 Unsupported(
96 /// The dependent and its version.
97 UnsupportedVersion,
98 ),
99}
100
101/// What: The publishable names from the registry configuration, or nothing when it is not
102/// tracked.
103/// Why: Without the configuration nothing is publishable, so nothing is bumped.
104///
105/// In TS you'd write (pseudocode):
106/// ```ts
107/// const [configText] = await reader.pnprConfigText();
108/// ```
109fn publishable_names(
110 content: &mut dyn WorkspaceContent,
111 tracked: &[TrackedPath],
112) -> Result<Option<Vec<String>>, PlanError> {
113 let Some(config) = tracked
114 .iter()
115 .find(|entry: &&TrackedPath| return entry.path == PNPR_CONFIG_PATH)
116 else {
117 return Ok(None);
118 };
119 let bytes: Vec<u8> = content.candidate_bytes(&config.path)?;
120 return Ok(Some(read_publishable_names(strict_text(
121 &config.path,
122 &bytes,
123 )?)));
124}
125
126/// What: The manifest change for one planned bump.
127/// Why: The edit rewrites the parsed text; the original bytes before it (a byte-order
128/// mark) are kept.
129///
130/// In TS you'd write (pseudocode):
131/// ```ts
132/// const replacement = replaceManifestVersion({ path, text, from, to });
133/// ```
134fn manifest_bump(planned: PlannedBump, state: &ManifestState) -> Result<ManifestBump, PlanError> {
135 let edited: String = replace_manifest_version(
136 &state.path,
137 &state.text,
138 &planned.bump.from,
139 &planned.bump.to,
140 )?;
141 let prefix: &[u8] = &state.bytes[..state.bytes.len() - state.text.len()];
142 return Ok(ManifestBump {
143 planned,
144 path: state.path.clone(),
145 mode: state.mode,
146 original: state.bytes.clone(),
147 replacement: [prefix, edited.as_bytes()].concat(),
148 });
149}
150
151/// What: Plan patch bumps for publishable dependents of raised packages.
152/// Why: Reads every manifest at both states; the registry configuration only when a
153/// version was raised; source files only of dependents whose development edges could
154/// carry a raised version.
155///
156/// In TS you'd write (pseudocode):
157/// ```ts
158/// async function planWorkspaceBumps(reader: WorkspaceFileReader): Promise<WorkspaceBumpPlan>;
159/// ```
160pub fn plan_workspace_bumps(content: &mut dyn WorkspaceContent) -> Result<PlanOutcome, PlanError> {
161 let tracked: Vec<TrackedPath> = content.tracked_paths()?;
162 let states: Vec<ManifestState> = read_manifest_states(content, &tracked)?;
163 let bumped: Vec<String> = bumped_names(&states);
164 let empty = |bumped_names: Vec<String>| {
165 return Ok(PlanOutcome::Planned(WorkspaceBumpPlan {
166 bumped_names,
167 bumps: Vec::new(),
168 }));
169 };
170 if bumped.is_empty() {
171 return empty(bumped);
172 }
173 let Some(publishable) = publishable_names(content, &tracked)? else {
174 return empty(bumped);
175 };
176 // `if let Some(...)` returns the first duplicate as the failure.
177 if let Some(duplicate) = duplicate_name(&states) {
178 return Err(PlanError::PolicyIncomplete(duplicate));
179 }
180 let bundled: Vec<Vec<String>> = bundled_development_edges(content, &tracked, &states, &bumped)?;
181 // `.zip` pairs each state with its bundled edges.
182 let nodes: Vec<WorkspaceNode> = states
183 .iter()
184 .zip(bundled)
185 .map(|(state, edges): (&ManifestState, Vec<String>)| {
186 return WorkspaceNode {
187 name: state.facts.name.clone(),
188 directory: state.directory.clone(),
189 version: state.facts.version.clone(),
190 edge_names: [state.facts.runtime_dependency_names.clone(), edges].concat(),
191 };
192 })
193 .collect();
194 let planned: Vec<PlannedBump> = match plan_dependent_bumps(&nodes, &bumped, &publishable) {
195 Ok(found) => found,
196 Err(unsupported) => return Ok(PlanOutcome::Unsupported(unsupported)),
197 };
198 let mut bumps: Vec<ManifestBump> = Vec::new();
199 for bump in planned {
200 // Names are unique once the duplicate check passed, so this finds the one state.
201 if let Some(state) = states
202 .iter()
203 .find(|state: &&ManifestState| return state.facts.name == bump.name)
204 {
205 bumps.push(manifest_bump(bump, state)?);
206 }
207 }
208 return Ok(PlanOutcome::Planned(WorkspaceBumpPlan {
209 bumped_names: bumped,
210 bumps,
211 }));
212}
213
214/// The plan over scripted workspaces, including the incumbent's worktree ripple cases.
215#[cfg(test)]
216#[path = "dependent_version_plan_tests.rs"]
217mod tests;