Skip to main content

souk_core/
version.rs

1//! Semantic version bumping and unique name generation.
2//!
3//! Provides helpers for incrementing semver version strings and generating
4//! unique plugin names when name conflicts arise during `souk add`.
5//!
6//! Version strings are parsed with the [`semver`] crate to ensure correctness.
7//! Pre-release and build metadata are stripped on bump, following standard
8//! semver increment semantics.
9
10use std::collections::HashSet;
11
12use crate::SoukError;
13
14/// Bumps the major component of a semver version string.
15///
16/// The minor and patch components are reset to zero and any pre-release /
17/// build metadata is dropped.
18///
19/// # Examples
20///
21/// ```
22/// # use souk_core::version::bump_major;
23/// assert_eq!(bump_major("1.2.3").unwrap(), "2.0.0");
24/// assert_eq!(bump_major("0.9.1").unwrap(), "1.0.0");
25/// assert_eq!(bump_major("1.2.3-beta.1").unwrap(), "2.0.0");
26/// ```
27///
28/// # Errors
29///
30/// Returns [`SoukError::Semver`] if `version` is not a valid semver string.
31pub fn bump_major(version: &str) -> Result<String, SoukError> {
32    let v = semver::Version::parse(version)?;
33    let bumped = semver::Version::new(v.major + 1, 0, 0);
34    Ok(bumped.to_string())
35}
36
37/// Bumps the minor component of a semver version string.
38///
39/// The patch component is reset to zero and any pre-release / build metadata
40/// is dropped.
41///
42/// # Examples
43///
44/// ```
45/// # use souk_core::version::bump_minor;
46/// assert_eq!(bump_minor("1.2.3").unwrap(), "1.3.0");
47/// assert_eq!(bump_minor("0.1.0").unwrap(), "0.2.0");
48/// assert_eq!(bump_minor("2.0.0-rc.1").unwrap(), "2.1.0");
49/// ```
50///
51/// # Errors
52///
53/// Returns [`SoukError::Semver`] if `version` is not a valid semver string.
54pub fn bump_minor(version: &str) -> Result<String, SoukError> {
55    let v = semver::Version::parse(version)?;
56    let bumped = semver::Version::new(v.major, v.minor + 1, 0);
57    Ok(bumped.to_string())
58}
59
60/// Bumps the patch component of a semver version string.
61///
62/// Any pre-release / build metadata is dropped.
63///
64/// # Examples
65///
66/// ```
67/// # use souk_core::version::bump_patch;
68/// assert_eq!(bump_patch("1.2.3").unwrap(), "1.2.4");
69/// assert_eq!(bump_patch("0.0.0").unwrap(), "0.0.1");
70/// assert_eq!(bump_patch("3.1.4-alpha").unwrap(), "3.1.5");
71/// ```
72///
73/// # Errors
74///
75/// Returns [`SoukError::Semver`] if `version` is not a valid semver string.
76pub fn bump_patch(version: &str) -> Result<String, SoukError> {
77    let v = semver::Version::parse(version)?;
78    let bumped = semver::Version::new(v.major, v.minor, v.patch + 1);
79    Ok(bumped.to_string())
80}
81
82/// Generates a unique name by appending a numeric suffix if `base` already
83/// exists in `existing`.
84///
85/// If `base` is not in `existing`, it is returned unchanged. Otherwise, the
86/// function tries `base-2`, `base-3`, etc. until a name not in `existing` is
87/// found.
88///
89/// This mirrors the shell-based `generate_unique_plugin_name` from the
90/// reference scripts in `temp-reference-scripts/lib/atomic.sh`.
91///
92/// # Examples
93///
94/// ```
95/// # use std::collections::HashSet;
96/// # use souk_core::version::generate_unique_name;
97/// let existing: HashSet<String> = ["foo".into(), "foo-2".into()].into();
98/// assert_eq!(generate_unique_name("foo", &existing), "foo-3");
99/// assert_eq!(generate_unique_name("bar", &existing), "bar");
100/// ```
101pub fn generate_unique_name(base: &str, existing: &HashSet<String>) -> String {
102    if !existing.contains(base) {
103        return base.to_string();
104    }
105
106    let mut counter = 2u64;
107    loop {
108        let candidate = format!("{base}-{counter}");
109        if !existing.contains(&candidate) {
110            return candidate;
111        }
112        counter += 1;
113    }
114}
115
116#[cfg(test)]
117mod tests {
118    use super::*;
119
120    // -----------------------------------------------------------------------
121    // bump_major
122    // -----------------------------------------------------------------------
123
124    #[test]
125    fn bump_major_standard() {
126        assert_eq!(bump_major("1.2.3").unwrap(), "2.0.0");
127    }
128
129    #[test]
130    fn bump_major_from_zero() {
131        assert_eq!(bump_major("0.1.0").unwrap(), "1.0.0");
132    }
133
134    #[test]
135    fn bump_major_strips_prerelease() {
136        assert_eq!(bump_major("1.2.3-beta.1").unwrap(), "2.0.0");
137    }
138
139    #[test]
140    fn bump_major_strips_build_metadata() {
141        assert_eq!(bump_major("1.2.3+build.42").unwrap(), "2.0.0");
142    }
143
144    #[test]
145    fn bump_major_resets_minor_and_patch() {
146        assert_eq!(bump_major("3.9.27").unwrap(), "4.0.0");
147    }
148
149    #[test]
150    fn bump_major_invalid_version() {
151        assert!(bump_major("not-a-version").is_err());
152    }
153
154    #[test]
155    fn bump_major_incomplete_version() {
156        // semver crate requires all three components
157        assert!(bump_major("1.2").is_err());
158    }
159
160    // -----------------------------------------------------------------------
161    // bump_minor
162    // -----------------------------------------------------------------------
163
164    #[test]
165    fn bump_minor_standard() {
166        assert_eq!(bump_minor("1.2.3").unwrap(), "1.3.0");
167    }
168
169    #[test]
170    fn bump_minor_from_zero() {
171        assert_eq!(bump_minor("0.0.0").unwrap(), "0.1.0");
172    }
173
174    #[test]
175    fn bump_minor_strips_prerelease() {
176        assert_eq!(bump_minor("2.0.0-rc.1").unwrap(), "2.1.0");
177    }
178
179    #[test]
180    fn bump_minor_resets_patch() {
181        assert_eq!(bump_minor("1.5.99").unwrap(), "1.6.0");
182    }
183
184    #[test]
185    fn bump_minor_zero_x_version() {
186        assert_eq!(bump_minor("0.9.1").unwrap(), "0.10.0");
187    }
188
189    #[test]
190    fn bump_minor_invalid_version() {
191        assert!(bump_minor("abc").is_err());
192    }
193
194    // -----------------------------------------------------------------------
195    // bump_patch
196    // -----------------------------------------------------------------------
197
198    #[test]
199    fn bump_patch_standard() {
200        assert_eq!(bump_patch("1.2.3").unwrap(), "1.2.4");
201    }
202
203    #[test]
204    fn bump_patch_from_zero() {
205        assert_eq!(bump_patch("0.0.0").unwrap(), "0.0.1");
206    }
207
208    #[test]
209    fn bump_patch_strips_prerelease() {
210        assert_eq!(bump_patch("3.1.4-alpha").unwrap(), "3.1.5");
211    }
212
213    #[test]
214    fn bump_patch_large_numbers() {
215        assert_eq!(bump_patch("999.999.999").unwrap(), "999.999.1000");
216    }
217
218    #[test]
219    fn bump_patch_invalid_version() {
220        assert!(bump_patch("").is_err());
221    }
222
223    #[test]
224    fn bump_patch_with_build_and_prerelease() {
225        assert_eq!(bump_patch("1.0.0-alpha+build.1").unwrap(), "1.0.1");
226    }
227
228    // -----------------------------------------------------------------------
229    // generate_unique_name
230    // -----------------------------------------------------------------------
231
232    #[test]
233    fn unique_name_no_conflict() {
234        let existing: HashSet<String> = HashSet::new();
235        assert_eq!(generate_unique_name("my-plugin", &existing), "my-plugin");
236    }
237
238    #[test]
239    fn unique_name_base_conflict() {
240        let existing: HashSet<String> = ["my-plugin".into()].into();
241        assert_eq!(generate_unique_name("my-plugin", &existing), "my-plugin-2");
242    }
243
244    #[test]
245    fn unique_name_multiple_conflicts() {
246        let existing: HashSet<String> = ["foo".into(), "foo-2".into(), "foo-3".into()].into();
247        assert_eq!(generate_unique_name("foo", &existing), "foo-4");
248    }
249
250    #[test]
251    fn unique_name_gap_in_numbers() {
252        // If foo and foo-3 exist but foo-2 does not, it should pick foo-2.
253        let existing: HashSet<String> = ["foo".into(), "foo-3".into()].into();
254        assert_eq!(generate_unique_name("foo", &existing), "foo-2");
255    }
256
257    #[test]
258    fn unique_name_with_existing_suffix() {
259        // Even if the base name itself has a number suffix, it works correctly.
260        let existing: HashSet<String> = ["plugin-2".into()].into();
261        assert_eq!(generate_unique_name("plugin-2", &existing), "plugin-2-2");
262    }
263
264    #[test]
265    fn unique_name_empty_base() {
266        let existing: HashSet<String> = ["".into()].into();
267        assert_eq!(generate_unique_name("", &existing), "-2");
268    }
269}