github_actions_maintainer/
versioning.rs1use 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
37pub 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
46pub 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 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 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
427fn 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#[cfg(test)]
468#[path = "versioning_tests.rs"]
469mod tests;