1use crate::errors::{Result, SampoError};
2use crate::types::Workspace;
3use cargo_metadata::{DependencyKind, MetadataCommand};
4use rustc_hash::FxHashSet;
5use semver::{Version, VersionReq};
6use std::collections::{BTreeMap, HashMap};
7use std::path::{Path, PathBuf};
8use toml_edit::{DocumentMut, InlineTable, Item, Table, Value};
9
10#[derive(Debug, Clone)]
12pub struct ManifestMetadata {
13 packages: Vec<PackageInfo>,
14 by_manifest: HashMap<PathBuf, usize>,
15 by_name: HashMap<String, usize>,
16}
17
18#[derive(Debug, Clone)]
19struct PackageInfo {
20 #[allow(dead_code)]
21 name: String,
22 #[allow(dead_code)]
23 manifest_path: PathBuf,
24 dependencies: Vec<DependencyInfo>,
25}
26
27#[derive(Debug, Clone)]
28struct DependencyInfo {
29 manifest_key: String,
30 package_name: String,
31 kind: DependencyKind,
32 target: Option<String>,
33}
34
35impl ManifestMetadata {
36 pub fn load(workspace: &Workspace) -> Result<Self> {
37 let manifest_path = workspace.root.join("Cargo.toml");
38 let metadata = MetadataCommand::new()
39 .manifest_path(&manifest_path)
40 .no_deps()
41 .exec()
42 .map_err(|err| {
43 SampoError::Release(format!(
44 "Failed to load cargo metadata for {}: {err}",
45 manifest_path.display()
46 ))
47 })?;
48
49 let workspace_ids: FxHashSet<_> = metadata.workspace_members.iter().cloned().collect();
50
51 let mut packages = Vec::new();
52 let mut by_manifest = HashMap::new();
53 let mut by_name = HashMap::new();
54
55 for package in metadata.packages {
56 if !workspace_ids.contains(&package.id) {
57 continue;
58 }
59
60 let manifest_path: PathBuf = package.manifest_path.clone().into();
61 let dependencies = package
62 .dependencies
63 .iter()
64 .map(|dep| DependencyInfo {
65 manifest_key: dep.rename.clone().unwrap_or_else(|| dep.name.clone()),
66 package_name: dep.name.clone(),
67 kind: dep.kind,
68 target: dep.target.as_ref().map(|platform| platform.to_string()),
69 })
70 .collect();
71
72 let idx = packages.len();
73 by_manifest.insert(manifest_path.clone(), idx);
74 by_name.insert(package.name.clone(), idx);
75 packages.push(PackageInfo {
76 name: package.name,
77 manifest_path,
78 dependencies,
79 });
80 }
81
82 Ok(Self {
83 packages,
84 by_manifest,
85 by_name,
86 })
87 }
88
89 fn package_for_manifest(&self, manifest_path: &Path) -> Option<&PackageInfo> {
90 self.by_manifest
91 .get(manifest_path)
92 .and_then(|idx| self.packages.get(*idx))
93 }
94
95 fn is_workspace_package(&self, name: &str) -> bool {
96 self.by_name.contains_key(name)
97 }
98}
99
100pub fn update_manifest_versions(
104 manifest_path: &Path,
105 input: &str,
106 new_pkg_version: Option<&str>,
107 new_version_by_name: &BTreeMap<String, String>,
108 metadata: Option<&ManifestMetadata>,
109) -> Result<(String, Vec<(String, String)>)> {
110 let mut doc: DocumentMut = input.parse().map_err(|err| {
111 SampoError::Release(format!(
112 "Failed to parse manifest {}: {err}",
113 manifest_path.display()
114 ))
115 })?;
116
117 if let Some(version) = new_pkg_version {
118 update_package_version(&mut doc, manifest_path, version)?;
119 }
120
121 let mut applied = Vec::new();
122 let package_info = metadata.and_then(|data| data.package_for_manifest(manifest_path));
123
124 for (dep_name, new_version) in new_version_by_name {
125 if let Some(meta) = metadata
126 && !meta.is_workspace_package(dep_name)
127 {
128 continue;
129 }
130
131 let mut changed = false;
132
133 if let Some(package) = package_info {
134 changed |= update_dependencies_from_metadata(&mut doc, package, dep_name, new_version);
135 }
136
137 let workspace_changed = update_workspace_dependency(&mut doc, dep_name, new_version);
138 changed |= workspace_changed;
139
140 if !changed {
141 changed |= update_dependencies_fallback(&mut doc, dep_name, new_version);
142 }
143
144 if changed {
145 applied.push((dep_name.clone(), new_version.clone()));
146 }
147 }
148
149 Ok((doc.to_string(), applied))
150}
151
152fn update_package_version(
153 doc: &mut DocumentMut,
154 manifest_path: &Path,
155 new_version: &str,
156) -> Result<()> {
157 let package_table = doc
158 .as_table_mut()
159 .get_mut("package")
160 .and_then(Item::as_table_mut)
161 .ok_or_else(|| {
162 SampoError::Release(format!(
163 "Manifest {} is missing a [package] section",
164 manifest_path.display()
165 ))
166 })?;
167
168 let current = package_table
169 .get("version")
170 .and_then(Item::as_value)
171 .and_then(Value::as_str);
172
173 if current == Some(new_version) {
174 return Ok(());
175 }
176
177 package_table.insert("version", Item::Value(Value::from(new_version)));
178 Ok(())
179}
180
181fn update_dependencies_from_metadata(
182 doc: &mut DocumentMut,
183 package: &PackageInfo,
184 dep_name: &str,
185 new_version: &str,
186) -> bool {
187 let mut changed = false;
188
189 for dependency in &package.dependencies {
190 if dependency.package_name != dep_name {
191 continue;
192 }
193
194 if let Some(table) =
195 dependency_table_mut(doc, dependency.target.as_deref(), dependency.kind)
196 && let Some(item) = table.get_mut(&dependency.manifest_key)
197 {
198 changed |= update_standard_dependency_item(item, new_version);
199 }
200 }
201
202 changed
203}
204
205fn dependency_table_mut<'a>(
206 doc: &'a mut DocumentMut,
207 target: Option<&str>,
208 kind: DependencyKind,
209) -> Option<&'a mut Table> {
210 let section = dependency_section_name(kind);
211
212 match target {
213 None => doc.get_mut(section).and_then(Item::as_table_mut),
214 Some(target_spec) => doc
215 .get_mut("target")
216 .and_then(Item::as_table_mut)?
217 .get_mut(target_spec)
218 .and_then(Item::as_table_mut)?
219 .get_mut(section)
220 .and_then(Item::as_table_mut),
221 }
222}
223
224fn dependency_section_name(kind: DependencyKind) -> &'static str {
225 match kind {
226 DependencyKind::Normal | DependencyKind::Unknown => "dependencies",
227 DependencyKind::Development => "dev-dependencies",
228 DependencyKind::Build => "build-dependencies",
229 }
230}
231
232fn update_standard_dependency_item(item: &mut Item, new_version: &str) -> bool {
233 match item {
234 Item::Value(Value::InlineTable(table)) => update_inline_dependency(table, new_version),
235 Item::Table(table) => update_table_dependency(table, new_version),
236 Item::Value(value) => {
237 if value.as_str() == Some(new_version) {
238 false
239 } else {
240 *item = Item::Value(Value::from(new_version));
241 true
242 }
243 }
244 _ => false,
245 }
246}
247
248fn update_inline_dependency(table: &mut InlineTable, new_version: &str) -> bool {
249 if table
250 .get("workspace")
251 .and_then(Value::as_bool)
252 .unwrap_or(false)
253 {
254 return false;
255 }
256
257 let needs_update = table
258 .get("version")
259 .and_then(Value::as_str)
260 .map(|current| current != new_version)
261 .unwrap_or(true);
262
263 if needs_update {
264 table.insert("version", Value::from(new_version));
265 }
266
267 needs_update
268}
269
270fn update_table_dependency(table: &mut Table, new_version: &str) -> bool {
271 if table
272 .get("workspace")
273 .and_then(Item::as_value)
274 .and_then(Value::as_bool)
275 .unwrap_or(false)
276 {
277 return false;
278 }
279
280 let needs_update = table
281 .get("version")
282 .and_then(Item::as_value)
283 .and_then(Value::as_str)
284 .map(|current| current != new_version)
285 .unwrap_or(true);
286
287 if needs_update {
288 table.insert("version", Item::Value(Value::from(new_version)));
289 }
290
291 needs_update
292}
293
294fn update_dependencies_fallback(doc: &mut DocumentMut, dep_name: &str, new_version: &str) -> bool {
295 let mut changed = false;
296 let top_level = doc.as_table_mut();
297
298 for section in ["dependencies", "dev-dependencies", "build-dependencies"] {
299 if let Some(table) = top_level.get_mut(section).and_then(Item::as_table_mut)
300 && let Some(item) = table.get_mut(dep_name)
301 {
302 changed |= update_standard_dependency_item(item, new_version);
303 }
304 }
305
306 if let Some(targets) = top_level.get_mut("target").and_then(Item::as_table_mut) {
307 for (_, target_item) in targets.iter_mut() {
308 if let Some(target_table) = target_item.as_table_mut() {
309 for section in ["dependencies", "dev-dependencies", "build-dependencies"] {
310 if let Some(table) = target_table.get_mut(section).and_then(Item::as_table_mut)
311 && let Some(item) = table.get_mut(dep_name)
312 {
313 changed |= update_standard_dependency_item(item, new_version);
314 }
315 }
316 }
317 }
318 }
319
320 changed
321}
322
323fn update_workspace_dependency(doc: &mut DocumentMut, dep_name: &str, new_version: &str) -> bool {
324 let workspace_table = match doc
325 .as_table_mut()
326 .get_mut("workspace")
327 .and_then(Item::as_table_mut)
328 {
329 Some(table) => table,
330 None => return false,
331 };
332
333 let deps_item = match workspace_table.get_mut("dependencies") {
334 Some(item) => item,
335 None => return false,
336 };
337
338 match deps_item {
339 Item::Table(table) => {
340 if let Some(item) = table.get_mut(dep_name) {
341 update_workspace_dependency_item(item, new_version)
342 } else {
343 false
344 }
345 }
346 _ => false,
347 }
348}
349
350fn update_workspace_dependency_item(item: &mut Item, new_version: &str) -> bool {
351 match item {
352 Item::Value(Value::InlineTable(table)) => {
353 let current = table.get("version").and_then(Value::as_str);
354 let Some(existing) = current else {
355 return false;
356 };
357
358 match compute_workspace_dependency_version(existing, new_version) {
359 Some(resolved) if resolved != existing => {
360 table.insert("version", Value::from(resolved));
361 true
362 }
363 _ => false,
364 }
365 }
366 Item::Table(table) => {
367 let current = table
368 .get("version")
369 .and_then(Item::as_value)
370 .and_then(Value::as_str);
371 let Some(existing) = current else {
372 return false;
373 };
374
375 match compute_workspace_dependency_version(existing, new_version) {
376 Some(resolved) if resolved != existing => {
377 table.insert("version", Item::Value(Value::from(resolved)));
378 true
379 }
380 _ => false,
381 }
382 }
383 Item::Value(value) => {
384 let Some(existing) = value.as_str() else {
385 return false;
386 };
387
388 match compute_workspace_dependency_version(existing, new_version) {
389 Some(resolved) if resolved != existing => {
390 *item = Item::Value(Value::from(resolved));
391 true
392 }
393 _ => false,
394 }
395 }
396 _ => false,
397 }
398}
399
400fn compute_workspace_dependency_version(existing: &str, new_version: &str) -> Option<String> {
401 let trimmed_existing = existing.trim();
402 if trimmed_existing == "*" {
403 return None;
404 }
405
406 if Version::parse(trimmed_existing).is_ok() {
407 if trimmed_existing == new_version {
408 return None;
409 }
410 return Some(new_version.to_string());
411 }
412
413 let shorthand = parse_numeric_shorthand(trimmed_existing)?;
414 VersionReq::parse(trimmed_existing).ok()?;
415 let parsed_new = Version::parse(new_version).ok()?;
416
417 let resolved = match shorthand.len() {
418 1 => parsed_new.major.to_string(),
419 2 => format!("{}.{}", parsed_new.major, parsed_new.minor),
420 _ => return None,
421 };
422
423 if resolved == trimmed_existing {
424 None
425 } else {
426 Some(resolved)
427 }
428}
429
430fn parse_numeric_shorthand(value: &str) -> Option<Vec<u64>> {
431 let segments: Vec<&str> = value.split('.').collect();
432 if segments.is_empty() || segments.len() > 2 {
433 return None;
434 }
435
436 let mut numeric_segments = Vec::with_capacity(segments.len());
437 for segment in segments {
438 if segment.is_empty() || !segment.chars().all(|ch| ch.is_ascii_digit()) {
439 return None;
440 }
441 let parsed = segment.parse::<u64>().ok()?;
442 numeric_segments.push(parsed);
443 }
444
445 Some(numeric_segments)
446}
447
448#[cfg(test)]
449mod tests {
450 use super::*;
451 use std::collections::BTreeMap;
452 use std::path::Path;
453
454 #[test]
455 fn skips_workspace_dependencies_when_updating() {
456 let input = "[package]\nname=\"demo\"\nversion=\"0.1.0\"\n\n[dependencies]\nfoo = { workspace = true, optional = true }\n";
457 let mut updates = BTreeMap::new();
458 updates.insert("foo".to_string(), "1.2.3".to_string());
459
460 let (out, applied) =
461 update_manifest_versions(Path::new("/demo/Cargo.toml"), input, None, &updates, None)
462 .unwrap();
463
464 assert_eq!(out.trim_end(), input.trim_end());
465 assert!(applied.is_empty());
466 }
467
468 #[test]
469 fn updates_workspace_dependency_with_explicit_version() {
470 let input = "[workspace.dependencies]\nfoo = { version = \"0.1.0\", path = \"foo\" }\n";
471 let mut updates = BTreeMap::new();
472 updates.insert("foo".to_string(), "0.2.0".to_string());
473
474 let (out, applied) = update_manifest_versions(
475 Path::new("/workspace/Cargo.toml"),
476 input,
477 None,
478 &updates,
479 None,
480 )
481 .unwrap();
482
483 assert!(applied.contains(&("foo".to_string(), "0.2.0".to_string())));
484 assert!(out.contains("version = \"0.2.0\""));
485 }
486
487 #[test]
488 fn keeps_workspace_dependency_shorthand_for_patch_bump() {
489 assert!(compute_workspace_dependency_version("0.1", "0.1.14").is_none());
490 }
491
492 #[test]
493 fn updates_workspace_dependency_shorthand_for_minor_bump() {
494 let resolved = compute_workspace_dependency_version("0.1", "0.2.0")
495 .expect("minor bump should rewrite shorthand");
496 assert_eq!(resolved, "0.2");
497 }
498
499 #[test]
500 fn updates_workspace_dependency_major_shorthand() {
501 let resolved = compute_workspace_dependency_version("1", "2.0.0")
502 .expect("major bump should rewrite shorthand");
503 assert_eq!(resolved, "2");
504 }
505
506 #[test]
507 fn skips_workspace_dependency_with_wildcard_version() {
508 assert!(compute_workspace_dependency_version("*", "0.2.0").is_none());
509 }
510
511 #[test]
512 fn skips_workspace_dependency_without_version() {
513 let input = "[workspace.dependencies]\nfoo = { path = \"foo\" }\n";
514 let mut updates = BTreeMap::new();
515 updates.insert("foo".to_string(), "0.2.0".to_string());
516
517 let (out, applied) = update_manifest_versions(
518 Path::new("/workspace/Cargo.toml"),
519 input,
520 None,
521 &updates,
522 None,
523 )
524 .unwrap();
525
526 assert_eq!(out.trim_end(), input.trim_end());
527 assert!(applied.is_empty());
528 }
529
530 #[test]
531 fn converts_simple_dep_without_quotes() {
532 let input =
533 "[package]\nname=\"demo\"\nversion=\"0.1.0\"\n\n[dependencies]\nbar = \"0.1.0\"\n";
534 let mut updates = BTreeMap::new();
535 updates.insert("bar".to_string(), "0.2.0".to_string());
536
537 let (out, applied) =
538 update_manifest_versions(Path::new("/demo/Cargo.toml"), input, None, &updates, None)
539 .unwrap();
540
541 assert!(applied.contains(&("bar".to_string(), "0.2.0".to_string())));
542 assert!(out.contains("bar = \"0.2.0\""));
543 }
544}