Skip to main content

git_claw/core/
semver.rs

1//! Semantic versioning validation, parsing, and bumping.
2
3use std::fmt;
4
5/// Semantic version bump type.
6#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
7pub enum BumpType {
8    /// Increments the patch version (e.g., 1.0.0 -> 1.0.1).
9    Patch,
10    /// Increments the minor version and resets patch (e.g., 1.0.1 -> 1.1.0).
11    Minor,
12    /// Increments the major version and resets minor and patch (e.g., 1.1.0 -> 2.0.0).
13    Major,
14}
15
16/// Represents a parsed Semantic Version (SemVer 2.0.0).
17#[derive(Debug, Clone, PartialEq, Eq)]
18pub struct SemVer {
19    /// Major version number.
20    pub major: u64,
21    /// Minor version number.
22    pub minor: u64,
23    /// Patch version number.
24    pub patch: u64,
25    /// Whether the original version string was prefixed with 'v'.
26    pub has_v_prefix: bool,
27}
28
29impl SemVer {
30    /// Bumps this semantic version according to the specified [`BumpType`].
31    ///
32    /// Preserves the `has_v_prefix` flag.
33    ///
34    /// # Arguments
35    ///
36    /// * `bump` - The type of bump to perform (Patch, Minor, Major).
37    ///
38    /// # Example
39    ///
40    /// ```
41    /// use git_claw::core::semver::{BumpType, SemVer};
42    ///
43    /// let v = SemVer { major: 1, minor: 2, patch: 3, has_v_prefix: true };
44    /// assert_eq!(v.bump(BumpType::Patch).to_string(), "v1.2.4");
45    /// assert_eq!(v.bump(BumpType::Minor).to_string(), "v1.3.0");
46    /// assert_eq!(v.bump(BumpType::Major).to_string(), "v2.0.0");
47    /// ```
48    pub fn bump(&self, bump: BumpType) -> SemVer {
49        match bump {
50            BumpType::Major => SemVer {
51                major: self.major + 1,
52                minor: 0,
53                patch: 0,
54                has_v_prefix: self.has_v_prefix,
55            },
56            BumpType::Minor => SemVer {
57                major: self.major,
58                minor: self.minor + 1,
59                patch: 0,
60                has_v_prefix: self.has_v_prefix,
61            },
62            BumpType::Patch => SemVer {
63                major: self.major,
64                minor: self.minor,
65                patch: self.patch + 1,
66                has_v_prefix: self.has_v_prefix,
67            },
68        }
69    }
70}
71
72impl fmt::Display for SemVer {
73    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
74        if self.has_v_prefix {
75            write!(f, "v{}.{}.{}", self.major, self.minor, self.patch)
76        } else {
77            write!(f, "{}.{}.{}", self.major, self.minor, self.patch)
78        }
79    }
80}
81
82/// Parses a version string into a [`SemVer`] struct.
83///
84/// Validates SemVer 2.0.0 format (with optional leading 'v') and extracts
85/// major, minor, and patch numbers while discarding any prerelease/build metadata.
86///
87/// # Arguments
88///
89/// * `version` - Version string to parse (e.g., "1.2.3", "v0.4.0", "v1.0.0-beta.1").
90///
91/// # Example
92///
93/// ```
94/// use git_claw::core::semver::{parse_semver, SemVer};
95///
96/// let v = parse_semver("v1.2.3").unwrap();
97/// assert_eq!(v, SemVer { major: 1, minor: 2, patch: 3, has_v_prefix: true });
98///
99/// let v2 = parse_semver("0.1.0").unwrap();
100/// assert_eq!(v2, SemVer { major: 0, minor: 1, patch: 0, has_v_prefix: false });
101///
102/// assert!(parse_semver("invalid").is_none());
103/// ```
104pub fn parse_semver(version: &str) -> Option<SemVer> {
105    if !validate_semver(version) {
106        return None;
107    }
108
109    let has_v_prefix = version.starts_with('v');
110    let s = version.strip_prefix('v').unwrap_or(version);
111    let core = s.split(['+', '-']).next().unwrap_or(s);
112    let parts: Vec<&str> = core.split('.').collect();
113    if parts.len() != 3 {
114        return None;
115    }
116
117    let major = parts[0].parse::<u64>().ok()?;
118    let minor = parts[1].parse::<u64>().ok()?;
119    let patch = parts[2].parse::<u64>().ok()?;
120
121    Some(SemVer {
122        major,
123        minor,
124        patch,
125        has_v_prefix,
126    })
127}
128
129/// Validates whether a version string matches SemVer 2.0.0 (with optional leading 'v').
130///
131/// # Arguments
132///
133/// * `version` - Version string to validate.
134///
135/// # Example
136///
137/// ```
138/// use git_claw::core::semver::validate_semver;
139///
140/// assert!(validate_semver("v1.2.3"));
141/// assert!(validate_semver("0.1.0"));
142/// assert!(!validate_semver("1.0"));
143/// ```
144pub fn validate_semver(version: &str) -> bool {
145    let s = version.strip_prefix('v').unwrap_or(version);
146    if s.is_empty() {
147        return false;
148    }
149
150    // Split off build metadata (+)
151    let (s, _build) = match s.split_once('+') {
152        Some((core, b)) => {
153            if b.is_empty()
154                || !b
155                    .chars()
156                    .all(|c| c.is_ascii_alphanumeric() || c == '.' || c == '-')
157            {
158                return false;
159            }
160            (core, Some(b))
161        }
162        None => (s, None),
163    };
164
165    // Split off pre-release (-)
166    let (core, _pre) = match s.split_once('-') {
167        Some((c, p)) => {
168            if p.is_empty()
169                || !p
170                    .chars()
171                    .all(|c| c.is_ascii_alphanumeric() || c == '.' || c == '-')
172            {
173                return false;
174            }
175            (c, Some(p))
176        }
177        None => (s, None),
178    };
179
180    // Core must be MAJOR.MINOR.PATCH
181    let parts: Vec<&str> = core.split('.').collect();
182    if parts.len() != 3 {
183        return false;
184    }
185
186    for part in parts {
187        if part.is_empty() || !part.chars().all(|c| c.is_ascii_digit()) {
188            return false;
189        }
190        // No leading zero if length > 1
191        if part.len() > 1 && part.starts_with('0') {
192            return false;
193        }
194    }
195
196    true
197}
198
199#[cfg(test)]
200mod tests {
201    use super::*;
202
203    #[test]
204    fn test_valid_semver() {
205        assert!(validate_semver("1.0.0"));
206        assert!(validate_semver("v1.0.0"));
207        assert!(validate_semver("v0.1.0"));
208        assert!(validate_semver("v1.2.3-alpha.1"));
209        assert!(validate_semver("v1.2.3+build.42"));
210        assert!(validate_semver("v1.2.3-rc.1+build.1"));
211    }
212
213    #[test]
214    fn test_invalid_semver() {
215        assert!(!validate_semver(""));
216        assert!(!validate_semver("v"));
217        assert!(!validate_semver("1.0"));
218        assert!(!validate_semver("v1.0"));
219        assert!(!validate_semver("1.0.0.0"));
220        assert!(!validate_semver("v01.0.0"));
221        assert!(!validate_semver("latest"));
222        assert!(!validate_semver("v1.0.0-"));
223    }
224
225    #[test]
226    fn test_parse_semver() {
227        let v1 = parse_semver("v1.2.3").expect("valid");
228        assert_eq!(
229            v1,
230            SemVer {
231                major: 1,
232                minor: 2,
233                patch: 3,
234                has_v_prefix: true
235            }
236        );
237        assert_eq!(v1.to_string(), "v1.2.3");
238
239        let v2 = parse_semver("0.4.12").expect("valid");
240        assert_eq!(
241            v2,
242            SemVer {
243                major: 0,
244                minor: 4,
245                patch: 12,
246                has_v_prefix: false
247            }
248        );
249        assert_eq!(v2.to_string(), "0.4.12");
250
251        let v3 = parse_semver("v2.0.0-rc.1+build.5").expect("valid");
252        assert_eq!(
253            v3,
254            SemVer {
255                major: 2,
256                minor: 0,
257                patch: 0,
258                has_v_prefix: true
259            }
260        );
261
262        assert!(parse_semver("not-semver").is_none());
263        assert!(parse_semver("v1.0").is_none());
264    }
265
266    #[test]
267    fn test_semver_bump() {
268        let v = SemVer {
269            major: 1,
270            minor: 2,
271            patch: 3,
272            has_v_prefix: true,
273        };
274        assert_eq!(v.bump(BumpType::Patch).to_string(), "v1.2.4");
275        assert_eq!(v.bump(BumpType::Minor).to_string(), "v1.3.0");
276        assert_eq!(v.bump(BumpType::Major).to_string(), "v2.0.0");
277
278        let v_plain = SemVer {
279            major: 0,
280            minor: 1,
281            patch: 0,
282            has_v_prefix: false,
283        };
284        assert_eq!(v_plain.bump(BumpType::Patch).to_string(), "0.1.1");
285        assert_eq!(v_plain.bump(BumpType::Minor).to_string(), "0.2.0");
286        assert_eq!(v_plain.bump(BumpType::Major).to_string(), "1.0.0");
287    }
288}