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::{BTreeMap, 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
20#[path = "versioning_nested.rs"]
21mod nested;
22pub(crate) use nested::{
23    plan_read_only_rewrite, plan_version_rewrite_in_tree, tracked_nested_lockfiles,
24};
25
26pub(crate) type SourceFiles = BTreeMap<PathBuf, String>;
27
28const DEPENDENCY_TABLES: [&str; 3] = ["dependencies", "dev-dependencies", "build-dependencies"];
29
30#[derive(Debug, Clone, Eq, PartialEq)]
31pub struct VersionRewritePlan {
32    pub current_version: String,
33    pub internal_packages: Vec<String>,
34    pub file_updates: Vec<FileUpdate>,
35}
36
37/// Read the release version from the repository root manifest.
38pub fn current_version(repo_root: &Path) -> Result<String> {
39    let manifest = repo_root.join("Cargo.toml");
40    let document = parse_manifest(&manifest)?;
41    version_from_document(&document).ok_or_else(|| {
42        anyhow::anyhow!("unable to determine current version from '{}'", manifest.display())
43    })
44}
45
46/// Plan the rewrite of every member manifest and the lockfile to
47/// `new_version`, without touching the filesystem.
48pub fn plan_version_rewrite(repo_root: &Path, new_version: &str) -> Result<VersionRewritePlan> {
49    plan_with_sources(repo_root, new_version).map(|(plan, _)| plan)
50}
51
52fn plan_with_sources(
53    repo_root: &Path,
54    new_version: &str,
55) -> Result<(VersionRewritePlan, SourceFiles)> {
56    let mut sources = SourceFiles::new();
57    let repo_root = repo_root
58        .canonicalize()
59        .with_context(|| format!("failed to resolve repository root '{}'", repo_root.display()))?;
60    let root_manifest = repo_root.join("Cargo.toml");
61    let root_document = source_document(&root_manifest, &mut sources)?;
62    let current_version = version_from_document(&root_document).ok_or_else(|| {
63        anyhow::anyhow!("unable to determine current version from '{}'", root_manifest.display())
64    })?;
65
66    let manifests = member_manifests(&repo_root, &root_document);
67    let internal_packages = internal_package_names(&manifests, &mut sources)?;
68    let mut file_updates =
69        plan_manifest_updates(&manifests, &internal_packages, new_version, &mut sources)?;
70
71    if let Some(lockfile_update) = plan_lockfile_update(
72        &repo_root.join("Cargo.lock"),
73        &internal_packages,
74        None,
75        new_version,
76        &mut sources,
77    )? {
78        file_updates.push(lockfile_update);
79    }
80
81    if file_updates.is_empty() {
82        bail!("no Cargo.toml files were updated");
83    }
84
85    Ok((
86        VersionRewritePlan {
87            current_version,
88            internal_packages: internal_packages.into_iter().collect(),
89            file_updates,
90        },
91        sources,
92    ))
93}
94
95fn plan_manifest_updates(
96    manifests: &[PathBuf],
97    internal_packages: &BTreeSet<String>,
98    new_version: &str,
99    sources: &mut SourceFiles,
100) -> Result<Vec<FileUpdate>> {
101    let mut file_updates = Vec::new();
102    for manifest in manifests {
103        let original = source_text(manifest, sources)?;
104        let mut document = original
105            .parse::<DocumentMut>()
106            .with_context(|| format!("failed to parse Cargo manifest '{}'", manifest.display()))?;
107        if rewrite_document(&mut document, internal_packages, new_version) {
108            let updated_content = document.to_string();
109            if updated_content != original {
110                file_updates.push(FileUpdate { file: manifest.clone(), updated_content });
111            }
112        }
113    }
114    Ok(file_updates)
115}
116
117fn source_text(file: &Path, sources: &mut SourceFiles) -> Result<String> {
118    if let Some(original) = sources.get(file) {
119        return Ok(original.clone());
120    }
121    let original = fs::read_to_string(file)
122        .with_context(|| format!("failed to read release source '{}'", file.display()))?;
123    sources.insert(file.to_path_buf(), original.clone());
124    Ok(original)
125}
126
127fn source_document(file: &Path, sources: &mut SourceFiles) -> Result<DocumentMut> {
128    source_text(file, sources)?
129        .parse::<DocumentMut>()
130        .with_context(|| format!("failed to parse release source '{}'", file.display()))
131}
132
133fn parse_manifest(manifest: &Path) -> Result<DocumentMut> {
134    let content = fs::read_to_string(manifest)
135        .with_context(|| format!("failed to read Cargo manifest '{}'", manifest.display()))?;
136    content
137        .parse::<DocumentMut>()
138        .with_context(|| format!("failed to parse Cargo manifest '{}'", manifest.display()))
139}
140
141fn version_from_document(document: &DocumentMut) -> Option<String> {
142    let package_version = document
143        .get("package")
144        .and_then(Item::as_table_like)
145        .and_then(|package| package.get("version"))
146        .and_then(Item::as_str);
147    if let Some(version) = package_version {
148        return Some(version.to_owned());
149    }
150
151    document
152        .get("workspace")
153        .and_then(Item::as_table_like)
154        .and_then(|workspace| workspace.get("package"))
155        .and_then(Item::as_table_like)
156        .and_then(|package| package.get("version"))
157        .and_then(Item::as_str)
158        .map(ToOwned::to_owned)
159}
160
161fn member_manifests(repo_root: &Path, root_document: &DocumentMut) -> Vec<PathBuf> {
162    member_manifests_from_paths(repo_root, root_document, cargo::discover_manifest_files(repo_root))
163}
164
165fn member_manifests_from_paths(
166    repo_root: &Path,
167    root_document: &DocumentMut,
168    candidates: impl IntoIterator<Item = PathBuf>,
169) -> Vec<PathBuf> {
170    let root_manifest = repo_root.join("Cargo.toml");
171    let mut manifests = vec![root_manifest.clone()];
172
173    let patterns = workspace_path_list(root_document, "members");
174    if patterns.is_empty() {
175        return manifests;
176    }
177    let excludes = workspace_path_list(root_document, "exclude");
178
179    for manifest in candidates {
180        if manifest == root_manifest {
181            continue;
182        }
183        let Some(parent) = manifest.parent() else {
184            continue;
185        };
186        let Ok(relative) = parent.strip_prefix(repo_root) else {
187            continue;
188        };
189        let relative = relative.to_string_lossy().replace('\\', "/");
190        if patterns.iter().any(|pattern| matches_member_pattern(pattern, &relative))
191            && !excludes.iter().any(|pattern| matches_member_pattern(pattern, &relative))
192        {
193            manifests.push(manifest);
194        }
195    }
196
197    manifests
198}
199
200fn workspace_path_list(root_document: &DocumentMut, key: &str) -> Vec<String> {
201    root_document
202        .get("workspace")
203        .and_then(Item::as_table_like)
204        .and_then(|workspace| workspace.get(key))
205        .and_then(Item::as_array)
206        .map(|entries| entries.iter().filter_map(Value::as_str).map(ToOwned::to_owned).collect())
207        .unwrap_or_default()
208}
209
210fn internal_package_names(
211    manifests: &[PathBuf],
212    sources: &mut SourceFiles,
213) -> Result<BTreeSet<String>> {
214    let mut names = BTreeSet::new();
215    for manifest in manifests {
216        let document = source_document(manifest, sources)?;
217        if let Some(name) = document
218            .get("package")
219            .and_then(Item::as_table_like)
220            .and_then(|package| package.get("name"))
221            .and_then(Item::as_str)
222        {
223            names.insert(name.to_owned());
224        }
225    }
226    Ok(names)
227}
228
229fn rewrite_document(
230    document: &mut DocumentMut,
231    internal_packages: &BTreeSet<String>,
232    new_version: &str,
233) -> bool {
234    let mut changed = set_version_string(document, &["package", "version"], new_version);
235    changed |= set_version_string(document, &["workspace", "package", "version"], new_version);
236
237    for table_path in dependency_table_paths(document) {
238        for name in internal_packages {
239            let mut item_path = table_path.clone();
240            item_path.push(name.clone());
241            if let Some(item) = cargo::get_item_mut(document.as_item_mut(), &item_path) {
242                changed |= set_dependency_version(item, new_version);
243            }
244        }
245    }
246
247    changed
248}
249
250fn dependency_table_paths(document: &DocumentMut) -> Vec<Vec<String>> {
251    let mut paths: Vec<Vec<String>> =
252        DEPENDENCY_TABLES.iter().map(|table| vec![(*table).to_owned()]).collect();
253    paths.push(vec![String::from("workspace"), String::from("dependencies")]);
254
255    if let Some(target_table) = document.get("target").and_then(Item::as_table) {
256        for (target_name, _) in target_table {
257            for table in DEPENDENCY_TABLES {
258                paths.push(vec![String::from("target"), target_name.to_owned(), table.to_owned()]);
259            }
260        }
261    }
262
263    paths
264}
265
266fn set_version_string(document: &mut DocumentMut, path: &[&str], new_version: &str) -> bool {
267    let item_path: Vec<String> = path.iter().map(|segment| (*segment).to_owned()).collect();
268    if let Some(item) = cargo::get_item_mut(document.as_item_mut(), &item_path)
269        && item.as_str().is_some()
270    {
271        *item = value(new_version);
272        return true;
273    }
274    false
275}
276
277fn set_dependency_version(item: &mut Item, new_version: &str) -> bool {
278    if let Some(inline_table) = item.as_inline_table_mut() {
279        if inline_table.get("version").is_some_and(|version| version.as_str().is_some()) {
280            inline_table.insert("version", Value::from(new_version));
281            return true;
282        }
283        return false;
284    }
285
286    if let Some(table) = item.as_table_mut() {
287        if let Some(version_item) = table.get_mut("version")
288            && version_item.as_str().is_some()
289        {
290            *version_item = value(new_version);
291            return true;
292        }
293        return false;
294    }
295
296    if item.as_str().is_some() {
297        *item = value(new_version);
298        return true;
299    }
300
301    false
302}
303
304#[derive(Clone, Copy)]
305struct LockfileOwnership<'a> {
306    versions: &'a BTreeMap<String, String>,
307    required: &'a BTreeSet<String>,
308}
309
310fn plan_lockfile_update(
311    lockfile: &Path,
312    internal_packages: &BTreeSet<String>,
313    current_versions: Option<LockfileOwnership<'_>>,
314    new_version: &str,
315    sources: &mut SourceFiles,
316) -> Result<Option<FileUpdate>> {
317    if !lockfile.is_file() {
318        return Ok(None);
319    }
320
321    let original = source_text(lockfile, sources)?;
322    let mut document = original
323        .parse::<DocumentMut>()
324        .with_context(|| format!("failed to parse lockfile '{}'", lockfile.display()))?;
325    if let Some(versions) = current_versions {
326        validate_owned_lock_versions(&document, versions, lockfile)?;
327    }
328    let mut changed = false;
329    let mut rewritten = BTreeSet::new();
330
331    if let Some(packages) = document.get_mut("package").and_then(Item::as_array_of_tables_mut) {
332        for package in packages.iter_mut() {
333            let Some(identity) = owned_lock_identity(package, internal_packages, current_versions)
334            else {
335                continue;
336            };
337            rewritten.insert(identity);
338            if let Some(version_item) = package.get_mut("version")
339                && version_item.as_str().is_some()
340            {
341                *version_item = value(new_version);
342                changed = true;
343            }
344        }
345        rewrite_lockfile_references(packages, &rewritten, new_version);
346    }
347
348    Ok(changed.then(|| FileUpdate {
349        file: lockfile.to_path_buf(),
350        updated_content: document.to_string(),
351    }))
352}
353
354fn owned_lock_identity(
355    package: &toml_edit::Table,
356    names: &BTreeSet<String>,
357    ownership: Option<LockfileOwnership<'_>>,
358) -> Option<(String, String)> {
359    let name = package.get("name").and_then(Item::as_str)?;
360    let version = package.get("version").and_then(Item::as_str)?;
361    // Registry/git entries can share an owned package's name and version.
362    if !names.contains(name)
363        || package.get("source").is_some()
364        || ownership
365            .is_some_and(|owned| owned.versions.get(name).is_none_or(|current| current != version))
366    {
367        return None;
368    }
369    Some((name.to_owned(), version.to_owned()))
370}
371
372fn validate_owned_lock_versions(
373    document: &DocumentMut,
374    ownership: LockfileOwnership<'_>,
375    lockfile: &Path,
376) -> Result<()> {
377    let packages = document.get("package").and_then(Item::as_array_of_tables);
378    for (name, current) in ownership.versions {
379        let locked: BTreeSet<_> = packages
380            .into_iter()
381            .flat_map(|entries| entries.iter())
382            .filter(|package| {
383                package.get("source").is_none()
384                    && package.get("name").and_then(Item::as_str) == Some(name.as_str())
385            })
386            .filter_map(|package| package.get("version").and_then(Item::as_str))
387            .collect();
388        if (ownership.required.contains(name) || !locked.is_empty())
389            && !locked.contains(current.as_str())
390        {
391            bail!(
392                "nested lockfile '{}' has stale or ambiguous owned path package '{name}': expected {current}; refresh this consumer lock before releasing",
393                lockfile.display()
394            );
395        }
396    }
397    Ok(())
398}
399
400fn rewrite_lockfile_references(
401    packages: &mut toml_edit::ArrayOfTables,
402    rewritten: &BTreeSet<(String, String)>,
403    new_version: &str,
404) {
405    for package in packages.iter_mut() {
406        let Some(dependencies) = package.get_mut("dependencies").and_then(Item::as_array_mut)
407        else {
408            continue;
409        };
410        for dependency in dependencies.iter_mut() {
411            let Some(reference) = dependency.as_str() else {
412                continue;
413            };
414            let fields: Vec<_> = reference.split_whitespace().collect();
415            // Registry/git-qualified references have a third source component.
416            if fields.len() == 2
417                && rewritten.contains(&(fields[0].to_owned(), fields[1].to_owned()))
418            {
419                let decor = dependency.decor().clone();
420                *dependency = Value::from(format!("{} {new_version}", fields[0]));
421                *dependency.decor_mut() = decor;
422            }
423        }
424    }
425}
426
427/// Match a workspace `members` glob against a `/`-separated relative path.
428/// `*` and `?` stay within one path segment; `**` may span several.
429fn matches_member_pattern(pattern: &str, path: &str) -> bool {
430    let pattern_segments: Vec<&str> =
431        pattern.split('/').filter(|segment| !segment.is_empty()).collect();
432    let path_segments: Vec<&str> = path.split('/').filter(|segment| !segment.is_empty()).collect();
433    match_segments(&pattern_segments, &path_segments)
434}
435
436fn match_segments(pattern: &[&str], path: &[&str]) -> bool {
437    match pattern.first() {
438        None => path.is_empty(),
439        Some(&"**") => {
440            (0..=path.len()).any(|skipped| match_segments(&pattern[1..], &path[skipped..]))
441        }
442        Some(segment) => {
443            !path.is_empty()
444                && matches_segment(segment, path[0])
445                && match_segments(&pattern[1..], &path[1..])
446        }
447    }
448}
449
450fn matches_segment(pattern: &str, text: &str) -> bool {
451    let pattern: Vec<char> = pattern.chars().collect();
452    let text: Vec<char> = text.chars().collect();
453    match_chars(&pattern, &text)
454}
455
456fn match_chars(pattern: &[char], text: &[char]) -> bool {
457    match pattern.first() {
458        None => text.is_empty(),
459        Some('*') => (0..=text.len()).any(|skipped| match_chars(&pattern[1..], &text[skipped..])),
460        Some('?') => !text.is_empty() && match_chars(&pattern[1..], &text[1..]),
461        Some(expected) => text.first() == Some(expected) && match_chars(&pattern[1..], &text[1..]),
462    }
463}
464
465// Tests live in a sibling file to keep this module within the repository's
466// file-size lint budget; they remain `super::`-scoped unit tests.
467#[cfg(test)]
468#[path = "versioning_tests.rs"]
469mod tests;