Skip to main content

crate_cli/bump/
fn.rs

1use super::*;
2
3/// Parse a version string into Version struct
4///
5/// # Arguments
6///
7/// - `&str`: The version string to parse (e.g., "0.1.0" or "0.1.0-alpha")
8///
9/// # Returns
10///
11/// - `Option<Version>`: Parsed version if successful, None otherwise
12fn parse_version(version_str: &str) -> Option<Version> {
13    let parts: Vec<&str> = version_str.split('-').collect();
14    let version_part: &str = parts.first()?;
15    let prerelease: Option<String> = parts.get(1).map(|s: &&str| s.to_string());
16    let nums: Vec<&str> = version_part.split('.').collect();
17    if nums.len() != 3 {
18        return None;
19    }
20    let major: u64 = nums.first()?.parse().ok()?;
21    let minor: u64 = nums.get(1)?.parse().ok()?;
22    let patch: u64 = nums.get(2)?.parse().ok()?;
23    Some(Version {
24        major,
25        minor,
26        patch,
27        prerelease,
28    })
29}
30
31/// Parse pre-release identifier to extract type and number
32///
33/// # Arguments
34///
35/// - `&str`: The pre-release string (e.g., "alpha", "alpha.1", "beta.2")
36///
37/// # Returns
38///
39/// - `Option<(&str, u64)>`: Tuple of (pre_release_type, number) if parsed successfully
40fn parse_prerelease(prerelease: &str) -> Option<(&str, u64)> {
41    let parts: Vec<&str> = prerelease.split('.').collect();
42    let pre_type: &str = parts.first()?;
43    let number: u64 = parts
44        .get(1)
45        .and_then(|s: &&str| s.parse().ok())
46        .unwrap_or(0);
47    Some((pre_type, number))
48}
49
50/// Get the next pre-release version string
51///
52/// # Arguments
53///
54/// - `Option<&String>`: Current pre-release identifier
55/// - `&str`: Target pre-release type ("alpha", "beta", "rc")
56///
57/// # Returns
58///
59/// - `String`: The new pre-release identifier
60fn get_next_prerelease(current: Option<&String>, target_type: &str) -> String {
61    match current {
62        Some(pre) => {
63            if let Some((pre_type, number)) = parse_prerelease(pre)
64                && pre_type == target_type
65                && number > 0
66            {
67                return format!("{}.{}", target_type, number + 1);
68            }
69            format!("{target_type}.1")
70        }
71        None => target_type.to_string(),
72    }
73}
74
75/// Convert Version back to string representation
76///
77/// # Arguments
78///
79/// - `&Version`: The Version struct to convert
80///
81/// # Returns
82///
83/// - `String`: Version string (e.g., "0.1.0" or "0.1.0-alpha")
84fn version_to_string(version: &Version) -> String {
85    let base: String = format!("{}.{}.{}", version.major, version.minor, version.patch);
86    match &version.prerelease {
87        Some(pre) => format!("{base}-{pre}"),
88        None => base,
89    }
90}
91
92/// Apply version bump according to the specified type
93///
94/// # Arguments
95///
96/// - `&Version`: The current version
97/// - `&BumpVersionType`: The type of version bump to apply
98///
99/// # Returns
100///
101/// - `Version`: The new version after bumping
102fn bump_version(version: &Version, bump_type: &BumpVersionType) -> Version {
103    match bump_type {
104        BumpVersionType::Patch => Version {
105            major: version.major,
106            minor: version.minor,
107            patch: version.patch + 1,
108            prerelease: None,
109        },
110        BumpVersionType::Minor => Version {
111            major: version.major,
112            minor: version.minor + 1,
113            patch: 0,
114            prerelease: None,
115        },
116        BumpVersionType::Major => Version {
117            major: version.major + 1,
118            minor: 0,
119            patch: 0,
120            prerelease: None,
121        },
122        BumpVersionType::Release => Version {
123            major: version.major,
124            minor: version.minor,
125            patch: version.patch,
126            prerelease: None,
127        },
128        BumpVersionType::Alpha => {
129            let prerelease: String = get_next_prerelease(version.prerelease.as_ref(), "alpha");
130            Version {
131                major: version.major,
132                minor: version.minor,
133                patch: version.patch,
134                prerelease: Some(prerelease),
135            }
136        }
137        BumpVersionType::Beta => {
138            let prerelease: String = get_next_prerelease(version.prerelease.as_ref(), "beta");
139            Version {
140                major: version.major,
141                minor: version.minor,
142                patch: version.patch,
143                prerelease: Some(prerelease),
144            }
145        }
146        BumpVersionType::Rc => {
147            let prerelease: String = get_next_prerelease(version.prerelease.as_ref(), "rc");
148            Version {
149                major: version.major,
150                minor: version.minor,
151                patch: version.patch,
152                prerelease: Some(prerelease),
153            }
154        }
155    }
156}
157
158/// Parse, bump and re-render a version string in one step
159///
160/// # Arguments
161///
162/// - `&str`: Current version string (e.g., "0.1.0" or "0.1.0-alpha.1")
163/// - `&BumpVersionType`: The type of version bump to apply
164///
165/// # Returns
166///
167/// - `Option<String>`: New version string, or None if parsing fails
168fn bump_version_str(version_str: &str, bump_type: &BumpVersionType) -> Option<String> {
169    let version: Version = parse_version(version_str)?;
170    Some(version_to_string(&bump_version(&version, bump_type)))
171}
172
173/// Expand one `[workspace.members]` entry into member directories
174///
175/// Plain relative paths resolve against the workspace root; a trailing
176/// `/*` glob expands to every immediate subdirectory that contains a
177/// Cargo.toml, sorted by path for deterministic behavior.
178///
179/// # Arguments
180///
181/// - `&Path`: Workspace root directory
182/// - `&str`: Raw members entry (e.g., "core" or "crates/*")
183///
184/// # Returns
185///
186/// - `Vec<PathBuf>`: Resolved member directories
187fn expand_member_entry(root_dir: &Path, entry: &str) -> Vec<PathBuf> {
188    match entry.strip_suffix("/*") {
189        Some(prefix) => {
190            let mut dirs: Vec<PathBuf> = Vec::new();
191            if let Ok(entries) = std::fs::read_dir(root_dir.join(prefix)) {
192                for entry in entries.flatten() {
193                    let path: PathBuf = entry.path();
194                    if path.is_dir() && path.join("Cargo.toml").exists() {
195                        dirs.push(path);
196                    }
197                }
198            }
199            dirs.sort();
200            dirs
201        }
202        None => vec![root_dir.join(entry)],
203    }
204}
205
206/// Realign the `version` field of every local path dependency in a
207/// dependency table with freshly bumped member versions
208///
209/// Only entries whose `path` resolves to a bumped member directory and
210/// that already carry a `version` field are rewritten; path-only entries
211/// (deliberate dev-dependency style) are left untouched.
212///
213/// # Arguments
214///
215/// - `&mut dyn TableLike`: Dependency table to scan
216/// - `&Path`: Base directory that relative `path` values resolve against
217/// - `&[(PathBuf, String)]`: Bumped members as (canonical dir, new version)
218///
219/// # Returns
220///
221/// - `bool`: True if at least one entry was rewritten
222fn realign_dep_versions(
223    deps: &mut dyn TableLike,
224    base_dir: &Path,
225    bumped: &[(PathBuf, String)],
226) -> bool {
227    let mut changed: bool = false;
228    for (_alias, entry) in deps.iter_mut() {
229        let Some(dep_path) = entry
230            .get("path")
231            .and_then(|path_item: &Item| path_item.as_str())
232        else {
233            continue;
234        };
235        let Ok(canonical) = base_dir.join(dep_path).canonicalize() else {
236            continue;
237        };
238        let Some((_, new_version)) = bumped.iter().find(|(dir, _)| *dir == canonical) else {
239            continue;
240        };
241        if entry.get("version").is_none() {
242            continue;
243        }
244        let Some(version_slot) = entry.get_mut("version") else {
245            continue;
246        };
247        if version_slot.as_str() == Some(new_version.as_str()) {
248            continue;
249        }
250        set_item_string_preserving_decor(version_slot, new_version);
251        changed = true;
252    }
253    changed
254}
255
256/// Realign member-to-member path dependency versions inside one member
257/// manifest, across `[dependencies]` / `[dev-dependencies]` /
258/// `[build-dependencies]` and their `[target.*]` variants
259///
260/// # Arguments
261///
262/// - `&Path`: Member directory that relative dep paths resolve against
263/// - `&Path`: Member Cargo.toml path
264/// - `&[(PathBuf, String)]`: Bumped members as (canonical dir, new version)
265async fn realign_member_manifest(
266    member_dir: &Path,
267    member_manifest_path: &Path,
268    bumped: &[(PathBuf, String)],
269) -> Result<(), Box<dyn std::error::Error>> {
270    let member_content: String = read_to_string(member_manifest_path).await?;
271    let mut member_doc: DocumentMut = member_content.parse().map_err(|e: TomlError| {
272        format!("failed to parse {}: {}", member_manifest_path.display(), e)
273    })?;
274    let mut changed: bool = false;
275    for section in ["dependencies", "dev-dependencies", "build-dependencies"] {
276        if let Some(deps) = member_doc
277            .get_mut(section)
278            .and_then(|deps_item: &mut Item| deps_item.as_table_like_mut())
279        {
280            changed |= realign_dep_versions(deps, member_dir, bumped);
281        }
282    }
283    if let Some(targets) = member_doc
284        .get_mut("target")
285        .and_then(|target_item: &mut Item| target_item.as_table_like_mut())
286    {
287        let target_keys: Vec<String> = targets.iter().map(|(key, _)| key.to_string()).collect();
288        for target_key in target_keys {
289            let Some(target_table) = targets
290                .get_mut(&target_key)
291                .and_then(|target_item: &mut Item| target_item.as_table_like_mut())
292            else {
293                continue;
294            };
295            for section in ["dependencies", "dev-dependencies", "build-dependencies"] {
296                if let Some(deps) = target_table
297                    .get_mut(section)
298                    .and_then(|deps_item: &mut Item| deps_item.as_table_like_mut())
299                {
300                    changed |= realign_dep_versions(deps, member_dir, bumped);
301                }
302            }
303        }
304    }
305    if changed {
306        write(member_manifest_path, member_doc.to_string()).await?;
307    }
308    Ok(())
309}
310
311/// Bump every workspace member's own `[package].version` and realign all
312/// local path dependency versions with the results
313///
314/// Used for virtual-workspace repositories where members carry
315/// independent versions (no `[workspace.package].version` and no root
316/// `[package]`). Members using `version.workspace = true` are skipped.
317///
318/// # Arguments
319///
320/// - `&Path`: Workspace root Cargo.toml path
321/// - `&mut DocumentMut`: Parsed root manifest, updated and written when a
322///   `[workspace.dependencies]` entry needs realigning
323/// - `&BumpVersionType`: Type of version bump to apply to each member
324///
325/// # Returns
326///
327/// - `Result<String, Box<dyn std::error::Error>>`: Summary string
328async fn bump_workspace_members(
329    root_path: &Path,
330    doc: &mut DocumentMut,
331    bump_type: &BumpVersionType,
332) -> Result<String, Box<dyn std::error::Error>> {
333    let root_dir: &Path = root_path.parent().unwrap_or_else(|| Path::new("."));
334    let member_entries: Vec<String> = doc
335        .get("workspace")
336        .and_then(|workspace: &Item| workspace.get("members"))
337        .and_then(|members_item: &Item| members_item.as_array())
338        .map(|members: &toml_edit::Array| {
339            members
340                .iter()
341                .filter_map(|member: &TomlEditValue| member.as_str().map(|s: &str| s.to_string()))
342                .collect()
343        })
344        .unwrap_or_default();
345    let mut member_dirs: Vec<PathBuf> = Vec::new();
346    for entry in &member_entries {
347        member_dirs.extend(expand_member_entry(root_dir, entry));
348    }
349    member_dirs.sort();
350    member_dirs.dedup();
351    let mut bumped: Vec<(PathBuf, String)> = Vec::new();
352    for dir in &member_dirs {
353        let member_manifest_path: PathBuf = dir.join("Cargo.toml");
354        if !member_manifest_path.exists() {
355            return Err(format!(
356                "member manifest not found: {}",
357                member_manifest_path.display()
358            )
359            .into());
360        }
361        let member_content: String = read_to_string(&member_manifest_path).await?;
362        let mut member_doc: DocumentMut = member_content.parse().map_err(|e: TomlError| {
363            format!("failed to parse {}: {}", member_manifest_path.display(), e)
364        })?;
365        let version_slot: &mut Item = member_doc
366            .get_mut("package")
367            .and_then(|package: &mut Item| package.get_mut("version"))
368            .ok_or_else(|| -> Box<dyn std::error::Error> {
369                format!(
370                    "package.version not found in {}",
371                    member_manifest_path.display()
372                )
373                .into()
374            })?;
375        let Some(old_version) = version_slot.as_str().map(|s: &str| s.to_string()) else {
376            continue;
377        };
378        let new_version: String = bump_version_str(&old_version, bump_type).ok_or_else(
379            || -> Box<dyn std::error::Error> {
380                format!("failed to parse version: {}", old_version).into()
381            },
382        )?;
383        set_item_string_preserving_decor(version_slot, &new_version);
384        write(&member_manifest_path, member_doc.to_string()).await?;
385        log::info!(
386            "bump: {} -> {}",
387            member_manifest_path.display(),
388            new_version
389        );
390        bumped.push((dir.canonicalize()?, new_version));
391    }
392    if bumped.is_empty() {
393        return Ok("0 workspace members".to_string());
394    }
395    let root_changed: bool = doc
396        .get_mut("workspace")
397        .and_then(|workspace: &mut Item| workspace.get_mut("dependencies"))
398        .and_then(|deps_item: &mut Item| deps_item.as_table_like_mut())
399        .is_some_and(|deps: &mut dyn TableLike| realign_dep_versions(deps, root_dir, &bumped));
400    if root_changed {
401        write(root_path, doc.to_string()).await?;
402    }
403    for dir in &member_dirs {
404        realign_member_manifest(dir, &dir.join("Cargo.toml"), &bumped).await?;
405    }
406    Ok(format!("{} workspace members", bumped.len()))
407}
408
409/// Read and update version in Cargo.toml
410///
411/// Three repository architectures are supported:
412///
413/// - Shared-version monorepo (`[workspace.package].version` present): the
414///   workspace root version is bumped; run `sync` to propagate it.
415/// - Virtual workspace (no root `[package]`, members carry independent
416///   versions): every member's own version is bumped and local path
417///   dependency versions are realigned in one pass.
418/// - Non-monorepo single crate (plain `[package].version`): the crate
419///   version is bumped.
420///
421/// The edit is applied on a `toml_edit::DocumentMut`, so comments, key
422/// order and inline-table formatting outside the version value are
423/// preserved byte-for-byte.
424///
425/// # Arguments
426///
427/// - `&str`: Path to Cargo.toml file
428/// - `&BumpVersionType`: Type of version bump to apply
429///
430/// # Returns
431///
432/// - `Result<String, Box<dyn std::error::Error>>`: The new version string, a
433///   workspace bump summary, or an error
434pub async fn execute_bump(
435    manifest_path: &str,
436    bump_type: &BumpVersionType,
437) -> Result<String, Box<dyn std::error::Error>> {
438    let path: &Path = Path::new(manifest_path);
439    let content: String = read_to_string(path).await?;
440    let mut doc: DocumentMut = content
441        .parse()
442        .map_err(|e: TomlError| format!("failed to parse {}: {}", manifest_path, e))?;
443    let has_workspace_version: bool = doc
444        .get("workspace")
445        .and_then(|workspace: &Item| workspace.get("package"))
446        .and_then(|package: &Item| package.get("version"))
447        .is_some();
448    let has_root_package: bool = doc.get("package").is_some();
449    let has_members: bool = doc
450        .get("workspace")
451        .and_then(|workspace: &Item| workspace.get("members"))
452        .and_then(|members_item: &Item| members_item.as_array())
453        .is_some_and(|members: &toml_edit::Array| !members.is_empty());
454    if !has_workspace_version && !has_root_package && has_members {
455        return bump_workspace_members(path, &mut doc, bump_type).await;
456    }
457    let version_slot: &mut Item = if has_workspace_version {
458        doc.get_mut("workspace")
459            .and_then(|workspace: &mut Item| workspace.get_mut("package"))
460            .and_then(|package: &mut Item| package.get_mut("version"))
461            .ok_or_else(|| -> Box<dyn std::error::Error> {
462                "workspace.package.version not found".into()
463            })?
464    } else if has_root_package {
465        doc.get_mut("package")
466            .and_then(|package: &mut Item| package.get_mut("version"))
467            .ok_or_else(|| -> Box<dyn std::error::Error> { "package.version not found".into() })?
468    } else {
469        return Err("neither [package] nor [workspace.package] found in Cargo.toml".into());
470    };
471    let version_str: String = version_slot
472        .as_str()
473        .ok_or_else(|| -> Box<dyn std::error::Error> { "version field is not a string".into() })?
474        .to_string();
475    let version: Version =
476        parse_version(&version_str).ok_or_else(|| -> Box<dyn std::error::Error> {
477            format!("failed to parse version: {}", version_str).into()
478        })?;
479    let bumped: Version = bump_version(&version, bump_type);
480    let version_string: String = version_to_string(&bumped);
481    set_item_string_preserving_decor(version_slot, &version_string);
482    write(path, doc.to_string()).await?;
483    Ok(version_string)
484}