Skip to main content

github_actions_maintainer/
versioning.rs

1//! Release version rewriting across Cargo manifests and lockfiles.
2//!
3//! Ports the semantics of `scripts/release_version.py` onto `toml_edit`:
4//! read the current version from `[package]` (falling back to
5//! `[workspace.package]`), then rewrite the version in every workspace member
6//! manifest — including internal dependency entries that pin a member's
7//! version — plus the workspace `Cargo.lock`, without shelling out to cargo.
8
9use std::{
10    collections::BTreeSet,
11    fs,
12    path::{Path, PathBuf},
13};
14
15use anyhow::{Context, Result, bail};
16use toml_edit::{DocumentMut, Item, Value, value};
17
18use crate::{cargo, model::FileUpdate};
19
20const DEPENDENCY_TABLES: [&str; 3] = ["dependencies", "dev-dependencies", "build-dependencies"];
21
22#[derive(Debug, Clone, Eq, PartialEq)]
23pub struct VersionRewritePlan {
24    pub current_version: String,
25    pub internal_packages: Vec<String>,
26    pub file_updates: Vec<FileUpdate>,
27}
28
29/// Read the release version from the repository root manifest.
30pub fn current_version(repo_root: &Path) -> Result<String> {
31    let manifest = repo_root.join("Cargo.toml");
32    let document = parse_manifest(&manifest)?;
33    version_from_document(&document).ok_or_else(|| {
34        anyhow::anyhow!("unable to determine current version from '{}'", manifest.display())
35    })
36}
37
38/// Plan the rewrite of every member manifest and the lockfile to
39/// `new_version`, without touching the filesystem.
40pub fn plan_version_rewrite(repo_root: &Path, new_version: &str) -> Result<VersionRewritePlan> {
41    let repo_root = repo_root
42        .canonicalize()
43        .with_context(|| format!("failed to resolve repository root '{}'", repo_root.display()))?;
44    let root_manifest = repo_root.join("Cargo.toml");
45    let root_document = parse_manifest(&root_manifest)?;
46    let current_version = version_from_document(&root_document).ok_or_else(|| {
47        anyhow::anyhow!("unable to determine current version from '{}'", root_manifest.display())
48    })?;
49
50    let manifests = member_manifests(&repo_root, &root_document);
51    let internal_packages = internal_package_names(&manifests)?;
52    let mut file_updates = plan_manifest_updates(&manifests, &internal_packages, new_version)?;
53
54    if let Some(lockfile_update) =
55        plan_lockfile_update(&repo_root, &internal_packages, new_version)?
56    {
57        file_updates.push(lockfile_update);
58    }
59
60    if file_updates.is_empty() {
61        bail!("no Cargo.toml files were updated");
62    }
63
64    Ok(VersionRewritePlan {
65        current_version,
66        internal_packages: internal_packages.into_iter().collect(),
67        file_updates,
68    })
69}
70
71fn plan_manifest_updates(
72    manifests: &[PathBuf],
73    internal_packages: &BTreeSet<String>,
74    new_version: &str,
75) -> Result<Vec<FileUpdate>> {
76    let mut file_updates = Vec::new();
77    for manifest in manifests {
78        let original = fs::read_to_string(manifest)
79            .with_context(|| format!("failed to read Cargo manifest '{}'", manifest.display()))?;
80        let mut document = original
81            .parse::<DocumentMut>()
82            .with_context(|| format!("failed to parse Cargo manifest '{}'", manifest.display()))?;
83        if rewrite_document(&mut document, internal_packages, new_version) {
84            let updated_content = document.to_string();
85            if updated_content != original {
86                file_updates.push(FileUpdate { file: manifest.clone(), updated_content });
87            }
88        }
89    }
90    Ok(file_updates)
91}
92
93fn parse_manifest(manifest: &Path) -> Result<DocumentMut> {
94    let content = fs::read_to_string(manifest)
95        .with_context(|| format!("failed to read Cargo manifest '{}'", manifest.display()))?;
96    content
97        .parse::<DocumentMut>()
98        .with_context(|| format!("failed to parse Cargo manifest '{}'", manifest.display()))
99}
100
101fn version_from_document(document: &DocumentMut) -> Option<String> {
102    let package_version = document
103        .get("package")
104        .and_then(Item::as_table_like)
105        .and_then(|package| package.get("version"))
106        .and_then(Item::as_str);
107    if let Some(version) = package_version {
108        return Some(version.to_owned());
109    }
110
111    document
112        .get("workspace")
113        .and_then(Item::as_table_like)
114        .and_then(|workspace| workspace.get("package"))
115        .and_then(Item::as_table_like)
116        .and_then(|package| package.get("version"))
117        .and_then(Item::as_str)
118        .map(ToOwned::to_owned)
119}
120
121fn member_manifests(repo_root: &Path, root_document: &DocumentMut) -> Vec<PathBuf> {
122    let root_manifest = repo_root.join("Cargo.toml");
123    let mut manifests = vec![root_manifest.clone()];
124
125    let patterns = workspace_path_list(root_document, "members");
126    if patterns.is_empty() {
127        return manifests;
128    }
129    let excludes = workspace_path_list(root_document, "exclude");
130
131    for manifest in cargo::discover_manifest_files(repo_root) {
132        if manifest == root_manifest {
133            continue;
134        }
135        let Some(parent) = manifest.parent() else {
136            continue;
137        };
138        let Ok(relative) = parent.strip_prefix(repo_root) else {
139            continue;
140        };
141        let relative = relative.to_string_lossy().replace('\\', "/");
142        if patterns.iter().any(|pattern| matches_member_pattern(pattern, &relative))
143            && !excludes.iter().any(|pattern| matches_member_pattern(pattern, &relative))
144        {
145            manifests.push(manifest);
146        }
147    }
148
149    manifests
150}
151
152fn workspace_path_list(root_document: &DocumentMut, key: &str) -> Vec<String> {
153    root_document
154        .get("workspace")
155        .and_then(Item::as_table_like)
156        .and_then(|workspace| workspace.get(key))
157        .and_then(Item::as_array)
158        .map(|entries| entries.iter().filter_map(Value::as_str).map(ToOwned::to_owned).collect())
159        .unwrap_or_default()
160}
161
162fn internal_package_names(manifests: &[PathBuf]) -> Result<BTreeSet<String>> {
163    let mut names = BTreeSet::new();
164    for manifest in manifests {
165        let document = parse_manifest(manifest)?;
166        if let Some(name) = document
167            .get("package")
168            .and_then(Item::as_table_like)
169            .and_then(|package| package.get("name"))
170            .and_then(Item::as_str)
171        {
172            names.insert(name.to_owned());
173        }
174    }
175    Ok(names)
176}
177
178fn rewrite_document(
179    document: &mut DocumentMut,
180    internal_packages: &BTreeSet<String>,
181    new_version: &str,
182) -> bool {
183    let mut changed = set_version_string(document, &["package", "version"], new_version);
184    changed |= set_version_string(document, &["workspace", "package", "version"], new_version);
185
186    for table_path in dependency_table_paths(document) {
187        for name in internal_packages {
188            let mut item_path = table_path.clone();
189            item_path.push(name.clone());
190            if let Some(item) = cargo::get_item_mut(document.as_item_mut(), &item_path) {
191                changed |= set_dependency_version(item, new_version);
192            }
193        }
194    }
195
196    changed
197}
198
199fn dependency_table_paths(document: &DocumentMut) -> Vec<Vec<String>> {
200    let mut paths: Vec<Vec<String>> =
201        DEPENDENCY_TABLES.iter().map(|table| vec![(*table).to_owned()]).collect();
202    paths.push(vec![String::from("workspace"), String::from("dependencies")]);
203
204    if let Some(target_table) = document.get("target").and_then(Item::as_table) {
205        for (target_name, _) in target_table {
206            for table in DEPENDENCY_TABLES {
207                paths.push(vec![String::from("target"), target_name.to_owned(), table.to_owned()]);
208            }
209        }
210    }
211
212    paths
213}
214
215fn set_version_string(document: &mut DocumentMut, path: &[&str], new_version: &str) -> bool {
216    let item_path: Vec<String> = path.iter().map(|segment| (*segment).to_owned()).collect();
217    if let Some(item) = cargo::get_item_mut(document.as_item_mut(), &item_path)
218        && item.as_str().is_some()
219    {
220        *item = value(new_version);
221        return true;
222    }
223    false
224}
225
226fn set_dependency_version(item: &mut Item, new_version: &str) -> bool {
227    if let Some(inline_table) = item.as_inline_table_mut() {
228        if inline_table.get("version").is_some_and(|version| version.as_str().is_some()) {
229            inline_table.insert("version", Value::from(new_version));
230            return true;
231        }
232        return false;
233    }
234
235    if let Some(table) = item.as_table_mut() {
236        if let Some(version_item) = table.get_mut("version")
237            && version_item.as_str().is_some()
238        {
239            *version_item = value(new_version);
240            return true;
241        }
242        return false;
243    }
244
245    if item.as_str().is_some() {
246        *item = value(new_version);
247        return true;
248    }
249
250    false
251}
252
253fn plan_lockfile_update(
254    repo_root: &Path,
255    internal_packages: &BTreeSet<String>,
256    new_version: &str,
257) -> Result<Option<FileUpdate>> {
258    let lockfile = repo_root.join("Cargo.lock");
259    if !lockfile.is_file() {
260        return Ok(None);
261    }
262
263    let original = fs::read_to_string(&lockfile)
264        .with_context(|| format!("failed to read lockfile '{}'", lockfile.display()))?;
265    let mut document = original
266        .parse::<DocumentMut>()
267        .with_context(|| format!("failed to parse lockfile '{}'", lockfile.display()))?;
268    let mut changed = false;
269
270    if let Some(packages) = document.get_mut("package").and_then(Item::as_array_of_tables_mut) {
271        for package in packages.iter_mut() {
272            let is_internal = package
273                .get("name")
274                .and_then(Item::as_str)
275                .is_some_and(|name| internal_packages.contains(name));
276            // A `source` marks a registry/git entry, which can share a
277            // workspace member's name (for example a dev-dependency on an
278            // older published version of the same crate).
279            if !is_internal || package.get("source").is_some() {
280                continue;
281            }
282            if let Some(version_item) = package.get_mut("version")
283                && version_item.as_str().is_some()
284            {
285                *version_item = value(new_version);
286                changed = true;
287            }
288        }
289    }
290
291    Ok(changed.then(|| FileUpdate { file: lockfile, updated_content: document.to_string() }))
292}
293
294/// Match a workspace `members` glob against a `/`-separated relative path.
295/// `*` and `?` stay within one path segment; `**` may span several.
296fn matches_member_pattern(pattern: &str, path: &str) -> bool {
297    let pattern_segments: Vec<&str> =
298        pattern.split('/').filter(|segment| !segment.is_empty()).collect();
299    let path_segments: Vec<&str> = path.split('/').filter(|segment| !segment.is_empty()).collect();
300    match_segments(&pattern_segments, &path_segments)
301}
302
303fn match_segments(pattern: &[&str], path: &[&str]) -> bool {
304    match pattern.first() {
305        None => path.is_empty(),
306        Some(&"**") => {
307            (0..=path.len()).any(|skipped| match_segments(&pattern[1..], &path[skipped..]))
308        }
309        Some(segment) => {
310            !path.is_empty()
311                && matches_segment(segment, path[0])
312                && match_segments(&pattern[1..], &path[1..])
313        }
314    }
315}
316
317fn matches_segment(pattern: &str, text: &str) -> bool {
318    let pattern: Vec<char> = pattern.chars().collect();
319    let text: Vec<char> = text.chars().collect();
320    match_chars(&pattern, &text)
321}
322
323fn match_chars(pattern: &[char], text: &[char]) -> bool {
324    match pattern.first() {
325        None => text.is_empty(),
326        Some('*') => (0..=text.len()).any(|skipped| match_chars(&pattern[1..], &text[skipped..])),
327        Some('?') => !text.is_empty() && match_chars(&pattern[1..], &text[1..]),
328        Some(expected) => text.first() == Some(expected) && match_chars(&pattern[1..], &text[1..]),
329    }
330}
331
332// Tests live in a sibling file to keep this module within the repository's
333// file-size lint budget; they remain `super::`-scoped unit tests.
334#[cfg(test)]
335#[path = "versioning_tests.rs"]
336mod tests;