github_actions_maintainer/
versioning.rs1use 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
29pub 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
38pub 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 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
294fn 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#[cfg(test)]
335#[path = "versioning_tests.rs"]
336mod tests;