sampo_core/
manifest.rs

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/// Metadata extracted from `cargo_metadata` for the workspace.
11#[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
100/// Update a crate manifest, setting the crate version (if provided) and retargeting
101/// internal dependency version requirements to the latest planned versions.
102/// Returns the updated TOML string along with a list of (dep_name, new_version) applied.
103pub 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}