Skip to main content

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;