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 =
130                get_next_prerelease(version.prerelease.as_ref(), PRERELEASE_ALPHA);
131            Version {
132                major: version.major,
133                minor: version.minor,
134                patch: version.patch,
135                prerelease: Some(prerelease),
136            }
137        }
138        BumpVersionType::Beta => {
139            let prerelease: String =
140                get_next_prerelease(version.prerelease.as_ref(), PRERELEASE_BETA);
141            Version {
142                major: version.major,
143                minor: version.minor,
144                patch: version.patch,
145                prerelease: Some(prerelease),
146            }
147        }
148        BumpVersionType::Rc => {
149            let prerelease: String = get_next_prerelease(version.prerelease.as_ref(), "rc");
150            Version {
151                major: version.major,
152                minor: version.minor,
153                patch: version.patch,
154                prerelease: Some(prerelease),
155            }
156        }
157    }
158}
159
160/// Parse, bump and re-render a version string in one step
161///
162/// # Arguments
163///
164/// - `&str` - Current version string (e.g. 0.1.0 or 0.1.0-alpha.1)
165/// - `&BumpVersionType` - The type of version bump to apply
166///
167/// # Returns
168///
169/// - `Option<String>` - New version string, or None if parsing fails
170fn bump_version_str(version_str: &str, bump_type: &BumpVersionType) -> Option<String> {
171    let version: Version = parse_version(version_str)?;
172    Some(version_to_string(&bump_version(&version, bump_type)))
173}
174
175/// Expand one `[workspace.members]` entry into member directories
176///
177/// Plain relative paths resolve against the workspace root; a trailing
178/// `/*` glob expands to every immediate subdirectory that contains a
179/// Cargo.toml, sorted by path for deterministic behavior.
180///
181/// # Arguments
182///
183/// - `&Path` - Workspace root directory
184/// - `&str` - Raw members entry (e.g. core or crates/*)
185///
186/// # Returns
187///
188/// - `Vec<PathBuf>` - Resolved member directories
189fn expand_member_entry(root_dir: &Path, entry: &str) -> Vec<PathBuf> {
190    match entry.strip_suffix("/*") {
191        Some(prefix) => {
192            let mut dirs: Vec<PathBuf> = Vec::new();
193            if let Ok(entries) = std::fs::read_dir(root_dir.join(prefix)) {
194                for entry in entries.flatten() {
195                    let path: PathBuf = entry.path();
196                    if path.is_dir() && path.join(CARGO_TOML).exists() {
197                        dirs.push(path);
198                    }
199                }
200            }
201            dirs.sort();
202            dirs
203        }
204        None => vec![root_dir.join(entry)],
205    }
206}
207
208/// Realign the `version` field of every local path dependency in a
209/// dependency table with freshly bumped member versions
210///
211/// Only entries whose `path` resolves to a bumped member directory and
212/// that already carry a `version` field are rewritten; path-only entries
213/// (deliberate dev-dependency style) are left untouched.
214///
215/// # Arguments
216///
217/// - `&mut dyn TableLike` - Dependency table to scan
218/// - `&Path` - Base directory that relative `path` values resolve against
219/// - `&[(PathBuf, String)]` - Bumped members as (canonical dir, new version)
220///
221/// # Returns
222///
223/// - `bool` - True if at least one entry was rewritten
224fn realign_dep_versions(
225    deps: &mut dyn TableLike,
226    base_dir: &Path,
227    bumped: &[(PathBuf, String)],
228) -> bool {
229    let mut changed: bool = false;
230    for (_alias, entry) in deps.iter_mut() {
231        let Some(dep_path) = entry
232            .get(TOML_PATH)
233            .and_then(|path_item: &Item| path_item.as_str())
234        else {
235            continue;
236        };
237        let Ok(canonical) = base_dir.join(dep_path).canonicalize() else {
238            continue;
239        };
240        let Some((_, new_version)) = bumped
241            .iter()
242            .find(|(dir, _bumped_version): &&(PathBuf, String)| *dir == canonical)
243        else {
244            continue;
245        };
246        if entry.get(TOML_VERSION).is_none() {
247            continue;
248        }
249        let Some(version_slot) = entry.get_mut(TOML_VERSION) else {
250            continue;
251        };
252        if version_slot.as_str() == Some(new_version.as_str()) {
253            continue;
254        }
255        set_item_string_preserving_decor(version_slot, new_version);
256        changed = true;
257    }
258    changed
259}
260
261/// Realign member-to-member path dependency versions inside one member
262/// manifest, across `[dependencies]` / `[dev-dependencies]` /
263/// `[build-dependencies]` and their `[target.*]` variants
264///
265/// # Arguments
266///
267/// - `&Path` - Member directory that relative dep paths resolve against
268/// - `&Path` - Member Cargo.toml path
269/// - `&[(PathBuf, String)]` - Bumped members as (canonical dir, new version)
270///
271/// # Returns
272///
273/// - `Result<(), Box<dyn std::error::Error>>` - Success or error
274async fn realign_member_manifest(
275    member_dir: &Path,
276    member_manifest_path: &Path,
277    bumped: &[(PathBuf, String)],
278) -> Result<(), Box<dyn std::error::Error>> {
279    let member_content: String = read_to_string(member_manifest_path).await?;
280    let mut member_doc: DocumentMut = member_content.parse().map_err(|e: TomlError| {
281        format!("failed to parse {}: {}", member_manifest_path.display(), e)
282    })?;
283    let mut changed: bool = false;
284    for section in [
285        TOML_DEPENDENCIES,
286        TOML_DEV_DEPENDENCIES,
287        TOML_BUILD_DEPENDENCIES,
288    ] {
289        if let Some(deps) = member_doc
290            .get_mut(section)
291            .and_then(|deps_item: &mut Item| deps_item.as_table_like_mut())
292        {
293            changed |= realign_dep_versions(deps, member_dir, bumped);
294        }
295    }
296    if let Some(targets) = member_doc
297        .get_mut(TOML_TARGET)
298        .and_then(|target_item: &mut Item| target_item.as_table_like_mut())
299    {
300        let target_keys: Vec<String> = targets
301            .iter()
302            .map(|(key, _target_value): (&str, &Item)| key.to_string())
303            .collect();
304        for target_key in target_keys {
305            let Some(target_table) = targets
306                .get_mut(&target_key)
307                .and_then(|target_item: &mut Item| target_item.as_table_like_mut())
308            else {
309                continue;
310            };
311            for section in [
312                TOML_DEPENDENCIES,
313                TOML_DEV_DEPENDENCIES,
314                TOML_BUILD_DEPENDENCIES,
315            ] {
316                if let Some(deps) = target_table
317                    .get_mut(section)
318                    .and_then(|deps_item: &mut Item| deps_item.as_table_like_mut())
319                {
320                    changed |= realign_dep_versions(deps, member_dir, bumped);
321                }
322            }
323        }
324    }
325    if changed {
326        write(member_manifest_path, member_doc.to_string()).await?;
327    }
328    Ok(())
329}
330
331/// Bump every workspace member's own `[package].version` and realign all
332/// local path dependency versions with the results
333///
334/// Used for virtual-workspace repositories where members carry
335/// independent versions (no `[workspace.package].version` and no root
336/// `[package]`). Members using `version.workspace = true` are skipped.
337///
338/// # Arguments
339///
340/// - `&Path` - Workspace root Cargo.toml path
341/// - `&mut DocumentMut` - Parsed root manifest, updated and written when a
342///   `[workspace.dependencies]` entry needs realigning
343/// - `&BumpVersionType` - Type of version bump to apply to each member
344///
345/// # Returns
346///
347/// - `Result<String, Box<dyn std::error::Error>>` - Summary string
348async fn bump_workspace_members(
349    root_path: &Path,
350    doc: &mut DocumentMut,
351    bump_type: &BumpVersionType,
352) -> Result<String, Box<dyn std::error::Error>> {
353    let root_dir: &Path = root_path.parent().unwrap_or_else(|| Path::new("."));
354    let member_entries: Vec<String> = doc
355        .get(TOML_WORKSPACE)
356        .and_then(|workspace: &Item| workspace.get(TOML_MEMBERS))
357        .and_then(|members_item: &Item| members_item.as_array())
358        .map(|members: &toml_edit::Array| {
359            members
360                .iter()
361                .filter_map(|member: &toml_edit::Value| {
362                    member.as_str().map(|s: &str| s.to_string())
363                })
364                .collect()
365        })
366        .unwrap_or_default();
367    let mut member_dirs: Vec<PathBuf> = Vec::new();
368    for entry in &member_entries {
369        member_dirs.extend(expand_member_entry(root_dir, entry));
370    }
371    member_dirs.sort();
372    member_dirs.dedup();
373    let mut bumped: Vec<(PathBuf, String)> = Vec::new();
374    for dir in &member_dirs {
375        let member_manifest_path: PathBuf = dir.join(CARGO_TOML);
376        if !member_manifest_path.exists() {
377            return Err(format!(
378                "member manifest not found: {}",
379                member_manifest_path.display()
380            )
381            .into());
382        }
383        let member_content: String = read_to_string(&member_manifest_path).await?;
384        let mut member_doc: DocumentMut = member_content.parse().map_err(|e: TomlError| {
385            format!("failed to parse {}: {}", member_manifest_path.display(), e)
386        })?;
387        let version_slot: &mut Item = member_doc
388            .get_mut(TOML_PACKAGE)
389            .and_then(|package: &mut Item| package.get_mut(TOML_VERSION))
390            .ok_or_else(|| -> Box<dyn std::error::Error> {
391                format!(
392                    "package.version not found in {}",
393                    member_manifest_path.display()
394                )
395                .into()
396            })?;
397        let Some(old_version) = version_slot.as_str().map(|s: &str| s.to_string()) else {
398            continue;
399        };
400        let new_version: String = bump_version_str(&old_version, bump_type).ok_or_else(
401            || -> Box<dyn std::error::Error> {
402                format!("failed to parse version: {}", old_version).into()
403            },
404        )?;
405        set_item_string_preserving_decor(version_slot, &new_version);
406        write(&member_manifest_path, member_doc.to_string()).await?;
407        log::info!(
408            "bump: {} -> {}",
409            member_manifest_path.display(),
410            new_version
411        );
412        bumped.push((dir.canonicalize()?, new_version));
413    }
414    if bumped.is_empty() {
415        return Ok(REPORT_ZERO_WORKSPACE_MEMBERS.to_string());
416    }
417    let root_changed: bool = doc
418        .get_mut(TOML_WORKSPACE)
419        .and_then(|workspace: &mut Item| workspace.get_mut(TOML_DEPENDENCIES))
420        .and_then(|deps_item: &mut Item| deps_item.as_table_like_mut())
421        .is_some_and(|deps: &mut dyn TableLike| realign_dep_versions(deps, root_dir, &bumped));
422    if root_changed {
423        write(root_path, doc.to_string()).await?;
424    }
425    for dir in &member_dirs {
426        realign_member_manifest(dir, &dir.join(CARGO_TOML), &bumped).await?;
427    }
428    Ok(format!("{} workspace members", bumped.len()))
429}
430
431/// Read and update version in Cargo.toml
432///
433/// Three repository architectures are supported:
434///
435/// - Shared-version monorepo (`[workspace.package].version` present): the
436///   workspace root version is bumped; run `sync` to propagate it.
437/// - Virtual workspace (no root `[package]`, members carry independent
438///   versions): every member's own version is bumped and local path
439///   dependency versions are realigned in one pass.
440/// - Non-monorepo single crate (plain `[package].version`): the crate
441///   version is bumped.
442///
443/// The edit is applied on a `toml_edit::DocumentMut`, so comments, key
444/// order and inline-table formatting outside the version value are
445/// preserved byte-for-byte.
446///
447/// # Arguments
448///
449/// - `&str` - Path to Cargo.toml file
450/// - `&BumpVersionType` - Type of version bump to apply
451///
452/// # Returns
453///
454/// - `Result<String, Box<dyn std::error::Error>>` - The new version string, a
455///   workspace bump summary, or an error
456pub async fn execute_bump(
457    manifest_path: &str,
458    bump_type: &BumpVersionType,
459) -> Result<String, Box<dyn std::error::Error>> {
460    let path: &Path = Path::new(manifest_path);
461    let content: String = read_to_string(path).await?;
462    let mut doc: DocumentMut = content
463        .parse()
464        .map_err(|e: TomlError| format!("failed to parse {}: {}", manifest_path, e))?;
465    let has_workspace_version: bool = doc
466        .get(TOML_WORKSPACE)
467        .and_then(|workspace: &Item| workspace.get(TOML_PACKAGE))
468        .and_then(|package: &Item| package.get(TOML_VERSION))
469        .is_some();
470    let has_root_package: bool = doc.get(TOML_PACKAGE).is_some();
471    let has_members: bool = doc
472        .get(TOML_WORKSPACE)
473        .and_then(|workspace: &Item| workspace.get(TOML_MEMBERS))
474        .and_then(|members_item: &Item| members_item.as_array())
475        .is_some_and(|members: &toml_edit::Array| !members.is_empty());
476    if !has_workspace_version && !has_root_package && has_members {
477        return bump_workspace_members(path, &mut doc, bump_type).await;
478    }
479    let version_slot: &mut Item = if has_workspace_version {
480        doc.get_mut(TOML_WORKSPACE)
481            .and_then(|workspace: &mut Item| workspace.get_mut(TOML_PACKAGE))
482            .and_then(|package: &mut Item| package.get_mut(TOML_VERSION))
483            .ok_or_else(|| -> Box<dyn std::error::Error> {
484                ERROR_WORKSPACE_PACKAGE_VERSION_MISSING.into()
485            })?
486    } else if has_root_package {
487        doc.get_mut(TOML_PACKAGE)
488            .and_then(|package: &mut Item| package.get_mut(TOML_VERSION))
489            .ok_or_else(|| -> Box<dyn std::error::Error> { ERROR_PACKAGE_VERSION_MISSING.into() })?
490    } else {
491        return Err(ERROR_NO_VERSION_SLOT.into());
492    };
493    let version_str: String = version_slot
494        .as_str()
495        .ok_or_else(|| -> Box<dyn std::error::Error> { ERROR_VERSION_NOT_STRING.into() })?
496        .to_string();
497    let version: Version =
498        parse_version(&version_str).ok_or_else(|| -> Box<dyn std::error::Error> {
499            format!("failed to parse version: {}", version_str).into()
500        })?;
501    let bumped: Version = bump_version(&version, bump_type);
502    let version_string: String = version_to_string(&bumped);
503    set_item_string_preserving_decor(version_slot, &version_string);
504    write(path, doc.to_string()).await?;
505    Ok(version_string)
506}