Skip to main content

standard_version/
calver.rs

1//! Calendar versioning (calver) support.
2//!
3//! Computes the next calver version from a format string, the current date,
4//! and the previous version string. The format string uses tokens like
5//! `YYYY`, `MM`, `PATCH`, etc.
6//!
7//! This module is pure — it takes the date as a parameter and performs no I/O.
8
9/// Date information needed for calver computation.
10///
11/// All fields are simple integers — the caller is responsible for computing
12/// them from the current date. This keeps the library pure (no clock access).
13#[derive(Debug, Clone, Copy, PartialEq, Eq)]
14pub struct CalverDate {
15    /// Full year (e.g. 2026).
16    pub year: u32,
17    /// Month (1–12).
18    pub month: u32,
19    /// Day of month (1–31).
20    pub day: u32,
21    /// ISO week number (1–53).
22    pub iso_week: u32,
23    /// ISO day of week (1=Monday, 7=Sunday).
24    pub day_of_week: u32,
25}
26
27/// The default calver format when none is specified.
28pub const DEFAULT_FORMAT: &str = "YYYY.MM.PATCH";
29
30/// Errors that can occur during calver computation.
31#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
32pub enum CalverError {
33    /// The format string contains no `PATCH` token.
34    #[error("calver format must contain the PATCH token")]
35    NoPatchToken,
36    /// The format string contains an unrecognised token.
37    #[error("unknown calver format token: {0}")]
38    UnknownToken(String),
39    /// The format string is empty.
40    #[error("calver format string is empty")]
41    EmptyFormat,
42}
43
44/// A parsed token from the calver format string.
45#[derive(Debug, Clone, PartialEq, Eq)]
46enum Token {
47    /// Full year (e.g. `2026`).
48    FullYear,
49    /// Short year (e.g. `26`).
50    ShortYear,
51    /// Zero-padded month (e.g. `03`).
52    ZeroPaddedMonth,
53    /// Month without padding (e.g. `3`).
54    Month,
55    /// ISO week number (e.g. `11`).
56    IsoWeek,
57    /// Day of month (e.g. `13`).
58    Day,
59    /// Auto-incrementing patch counter.
60    Patch,
61    /// A literal separator (e.g. `.`).
62    Separator(String),
63}
64
65/// Parse a calver format string into tokens.
66fn parse_format(format: &str) -> Result<Vec<Token>, CalverError> {
67    if format.is_empty() {
68        return Err(CalverError::EmptyFormat);
69    }
70
71    let mut tokens = Vec::new();
72    let mut remaining = format;
73
74    while !remaining.is_empty() {
75        // Try to match known tokens (longest first to avoid ambiguity).
76        if let Some(rest) = remaining.strip_prefix("YYYY") {
77            tokens.push(Token::FullYear);
78            remaining = rest;
79        } else if let Some(rest) = remaining.strip_prefix("YY") {
80            tokens.push(Token::ShortYear);
81            remaining = rest;
82        } else if let Some(rest) = remaining.strip_prefix("0M") {
83            tokens.push(Token::ZeroPaddedMonth);
84            remaining = rest;
85        } else if let Some(rest) = remaining.strip_prefix("MM") {
86            tokens.push(Token::Month);
87            remaining = rest;
88        } else if let Some(rest) = remaining.strip_prefix("WW") {
89            tokens.push(Token::IsoWeek);
90            remaining = rest;
91        } else if let Some(rest) = remaining.strip_prefix("DD") {
92            tokens.push(Token::Day);
93            remaining = rest;
94        } else if let Some(rest) = remaining.strip_prefix("PATCH") {
95            tokens.push(Token::Patch);
96            remaining = rest;
97        } else {
98            // Consume separator characters (`.`, `-`, etc.).
99            let ch = remaining.chars().next().unwrap();
100            if ch == '.' || ch == '-' || ch == '_' {
101                // Merge consecutive separators of the same kind.
102                if let Some(Token::Separator(s)) = tokens.last_mut() {
103                    s.push(ch);
104                } else {
105                    tokens.push(Token::Separator(ch.to_string()));
106                }
107                remaining = &remaining[ch.len_utf8()..];
108            } else {
109                // Unknown character sequence — find the next known token boundary.
110                return Err(CalverError::UnknownToken(remaining.to_string()));
111            }
112        }
113    }
114
115    // Validate that PATCH is present.
116    if !tokens.iter().any(|t| matches!(t, Token::Patch)) {
117        return Err(CalverError::NoPatchToken);
118    }
119
120    Ok(tokens)
121}
122
123/// Build the date prefix from the format (everything before PATCH, including
124/// the separator before PATCH).
125fn build_date_prefix(tokens: &[Token], date: CalverDate) -> String {
126    let mut prefix = String::new();
127    for token in tokens {
128        match token {
129            Token::Patch => break,
130            Token::FullYear => prefix.push_str(&date.year.to_string()),
131            Token::ShortYear => prefix.push_str(&format!("{}", date.year % 100)),
132            Token::ZeroPaddedMonth => prefix.push_str(&format!("{:02}", date.month)),
133            Token::Month => prefix.push_str(&date.month.to_string()),
134            Token::IsoWeek => prefix.push_str(&date.iso_week.to_string()),
135            Token::Day => prefix.push_str(&date.day.to_string()),
136            Token::Separator(s) => prefix.push_str(s),
137        }
138    }
139    prefix
140}
141
142/// Build the suffix after PATCH from the format tokens.
143fn build_date_suffix(tokens: &[Token], date: CalverDate) -> String {
144    let mut suffix = String::new();
145    let mut past_patch = false;
146    for token in tokens {
147        if matches!(token, Token::Patch) {
148            past_patch = true;
149            continue;
150        }
151        if !past_patch {
152            continue;
153        }
154        match token {
155            Token::FullYear => suffix.push_str(&date.year.to_string()),
156            Token::ShortYear => suffix.push_str(&format!("{}", date.year % 100)),
157            Token::ZeroPaddedMonth => suffix.push_str(&format!("{:02}", date.month)),
158            Token::Month => suffix.push_str(&date.month.to_string()),
159            Token::IsoWeek => suffix.push_str(&date.iso_week.to_string()),
160            Token::Day => suffix.push_str(&date.day.to_string()),
161            Token::Separator(s) => suffix.push_str(s),
162            Token::Patch => {} // only one PATCH allowed, already past it
163        }
164    }
165    suffix
166}
167
168/// Compute the next calver version string.
169///
170/// # Arguments
171///
172/// * `format` — The calver format string (e.g. `"YYYY.MM.PATCH"`).
173/// * `date` — The current date.
174/// * `previous_version` — The previous version string (without tag prefix), or
175///   `None` if this is the first release.
176///
177/// # Returns
178///
179/// The next version string (e.g. `"2026.3.0"` or `"2026.3.1"`).
180///
181/// # Errors
182///
183/// Returns a [`CalverError`] if the format string is invalid.
184pub fn next_version(
185    format: &str,
186    date: CalverDate,
187    previous_version: Option<&str>,
188) -> Result<String, CalverError> {
189    let tokens = parse_format(format)?;
190
191    let date_prefix = build_date_prefix(&tokens, date);
192    let date_suffix = build_date_suffix(&tokens, date);
193
194    // Determine patch number.
195    let patch = match previous_version {
196        Some(prev) => {
197            // Check if the date segments match.
198            if date_segments_match(prev, &tokens, date) {
199                // Extract the current patch number and increment.
200                extract_patch(prev, &tokens) + 1
201            } else {
202                0
203            }
204        }
205        None => 0,
206    };
207
208    Ok(format!("{date_prefix}{patch}{date_suffix}"))
209}
210
211/// Check if the date segments of the previous version match the current date.
212fn date_segments_match(previous: &str, tokens: &[Token], date: CalverDate) -> bool {
213    // Build the expected date prefix from the current date.
214    let expected_prefix = build_date_prefix(tokens, date);
215    // Build the expected date suffix from the current date.
216    let expected_suffix = build_date_suffix(tokens, date);
217
218    // The previous version should start with the date prefix and end with the date suffix.
219    let prefix_matches = previous.starts_with(&expected_prefix);
220    let suffix_matches = if expected_suffix.is_empty() {
221        true
222    } else {
223        previous.ends_with(&expected_suffix)
224    };
225
226    prefix_matches && suffix_matches
227}
228
229/// Extract the patch number from a previous version string.
230fn extract_patch(previous: &str, tokens: &[Token]) -> u64 {
231    // Count the number of segments before PATCH and after PATCH.
232    let mut segments_before_patch = 0;
233    let mut segments_after_patch = 0;
234    let mut past_patch = false;
235    for token in tokens {
236        match token {
237            Token::Separator(_) => {}
238            Token::Patch => {
239                past_patch = true;
240            }
241            _ => {
242                if past_patch {
243                    segments_after_patch += 1;
244                } else {
245                    segments_before_patch += 1;
246                }
247            }
248        }
249    }
250
251    // Split the version by the separator (detect from tokens).
252    let sep = find_separator(tokens);
253    let parts: Vec<&str> = previous.split(&sep).collect();
254
255    // The patch is at index `segments_before_patch` from the left.
256    if parts.len() > segments_before_patch {
257        let patch_idx = segments_before_patch;
258        // If there are segments after PATCH, the patch is not the last segment.
259        let _ = segments_after_patch; // used for validation
260        parts[patch_idx].parse().unwrap_or(0)
261    } else {
262        0
263    }
264}
265
266/// Find the primary separator character from the format tokens.
267fn find_separator(tokens: &[Token]) -> String {
268    for token in tokens {
269        if let Token::Separator(s) = token {
270            // Return first char as the separator.
271            return s.chars().next().unwrap().to_string();
272        }
273    }
274    ".".to_string()
275}
276
277/// Validate a calver format string without computing a version.
278///
279/// Returns `Ok(())` if the format is valid, or an error describing the problem.
280pub fn validate_format(format: &str) -> Result<(), CalverError> {
281    parse_format(format)?;
282    Ok(())
283}
284
285#[cfg(test)]
286mod tests {
287    use super::*;
288
289    fn date_2026_03() -> CalverDate {
290        CalverDate {
291            year: 2026,
292            month: 3,
293            day: 16,
294            iso_week: 12,
295            day_of_week: 1, // Monday
296        }
297    }
298
299    fn date_2026_04() -> CalverDate {
300        CalverDate {
301            year: 2026,
302            month: 4,
303            day: 1,
304            iso_week: 14,
305            day_of_week: 3, // Wednesday
306        }
307    }
308
309    // ── Format parsing ──────────────────────────────────────────────
310
311    #[test]
312    fn parse_default_format() {
313        let tokens = parse_format("YYYY.MM.PATCH").unwrap();
314        assert_eq!(tokens.len(), 5); // YYYY, ., MM, ., PATCH
315    }
316
317    #[test]
318    fn parse_zero_padded_month() {
319        let tokens = parse_format("YYYY.0M.PATCH").unwrap();
320        assert_eq!(tokens.len(), 5);
321        assert_eq!(tokens[2], Token::ZeroPaddedMonth);
322    }
323
324    #[test]
325    fn parse_daily_format() {
326        let tokens = parse_format("YYYY.MM.DD.PATCH").unwrap();
327        assert_eq!(tokens.len(), 7); // YYYY . MM . DD . PATCH
328    }
329
330    #[test]
331    fn parse_weekly_format() {
332        let tokens = parse_format("YY.WW.PATCH").unwrap();
333        assert_eq!(tokens.len(), 5);
334        assert_eq!(tokens[0], Token::ShortYear);
335        assert_eq!(tokens[2], Token::IsoWeek);
336    }
337
338    #[test]
339    fn error_no_patch_token() {
340        let err = parse_format("YYYY.MM").unwrap_err();
341        assert_eq!(err, CalverError::NoPatchToken);
342    }
343
344    #[test]
345    fn error_empty_format() {
346        let err = parse_format("").unwrap_err();
347        assert_eq!(err, CalverError::EmptyFormat);
348    }
349
350    #[test]
351    fn error_unknown_token() {
352        let err = parse_format("YYYY.MM.PATCH.UNKNOWN").unwrap_err();
353        assert!(matches!(err, CalverError::UnknownToken(_)));
354    }
355
356    // ── First release ───────────────────────────────────────────────
357
358    #[test]
359    fn first_release_default_format() {
360        let v = next_version("YYYY.MM.PATCH", date_2026_03(), None).unwrap();
361        assert_eq!(v, "2026.3.0");
362    }
363
364    #[test]
365    fn first_release_zero_padded() {
366        let v = next_version("YYYY.0M.PATCH", date_2026_03(), None).unwrap();
367        assert_eq!(v, "2026.03.0");
368    }
369
370    #[test]
371    fn first_release_daily() {
372        let v = next_version("YYYY.MM.DD.PATCH", date_2026_03(), None).unwrap();
373        assert_eq!(v, "2026.3.16.0");
374    }
375
376    #[test]
377    fn first_release_short_year() {
378        let v = next_version("YY.MM.PATCH", date_2026_03(), None).unwrap();
379        assert_eq!(v, "26.3.0");
380    }
381
382    #[test]
383    fn first_release_weekly() {
384        let v = next_version("YY.WW.PATCH", date_2026_03(), None).unwrap();
385        assert_eq!(v, "26.12.0");
386    }
387
388    // ── Patch increment (same period) ───────────────────────────────
389
390    #[test]
391    fn patch_increments_same_month() {
392        let v = next_version("YYYY.MM.PATCH", date_2026_03(), Some("2026.3.0")).unwrap();
393        assert_eq!(v, "2026.3.1");
394    }
395
396    #[test]
397    fn patch_increments_twice() {
398        let v = next_version("YYYY.MM.PATCH", date_2026_03(), Some("2026.3.4")).unwrap();
399        assert_eq!(v, "2026.3.5");
400    }
401
402    #[test]
403    fn patch_increments_zero_padded() {
404        let v = next_version("YYYY.0M.PATCH", date_2026_03(), Some("2026.03.2")).unwrap();
405        assert_eq!(v, "2026.03.3");
406    }
407
408    #[test]
409    fn patch_increments_daily() {
410        let v = next_version("YYYY.MM.DD.PATCH", date_2026_03(), Some("2026.3.16.0")).unwrap();
411        assert_eq!(v, "2026.3.16.1");
412    }
413
414    // ── Patch reset (new period) ────────────────────────────────────
415
416    #[test]
417    fn patch_resets_new_month() {
418        let v = next_version("YYYY.MM.PATCH", date_2026_04(), Some("2026.3.5")).unwrap();
419        assert_eq!(v, "2026.4.0");
420    }
421
422    #[test]
423    fn patch_resets_new_year() {
424        let date = CalverDate {
425            year: 2027,
426            month: 1,
427            day: 1,
428            iso_week: 53,
429            day_of_week: 5,
430        };
431        let v = next_version("YYYY.MM.PATCH", date, Some("2026.12.3")).unwrap();
432        assert_eq!(v, "2027.1.0");
433    }
434
435    #[test]
436    fn patch_resets_new_day() {
437        let date = CalverDate {
438            year: 2026,
439            month: 3,
440            day: 17,
441            iso_week: 12,
442            day_of_week: 2,
443        };
444        let v = next_version("YYYY.MM.DD.PATCH", date, Some("2026.3.16.3")).unwrap();
445        assert_eq!(v, "2026.3.17.0");
446    }
447
448    #[test]
449    fn patch_resets_new_week() {
450        let date = CalverDate {
451            year: 2026,
452            month: 3,
453            day: 23,
454            iso_week: 13,
455            day_of_week: 1,
456        };
457        let v = next_version("YY.WW.PATCH", date, Some("26.12.2")).unwrap();
458        assert_eq!(v, "26.13.0");
459    }
460
461    // ── Format validation ───────────────────────────────────────────
462
463    #[test]
464    fn validate_valid_format() {
465        assert!(validate_format("YYYY.MM.PATCH").is_ok());
466        assert!(validate_format("YYYY.0M.PATCH").is_ok());
467        assert!(validate_format("YY.WW.PATCH").is_ok());
468        assert!(validate_format("YYYY.MM.DD.PATCH").is_ok());
469    }
470
471    #[test]
472    fn validate_invalid_format() {
473        assert!(validate_format("YYYY.MM").is_err());
474        assert!(validate_format("").is_err());
475    }
476
477    // ── Edge cases ──────────────────────────────────────────────────
478
479    #[test]
480    fn previous_version_is_completely_different() {
481        // Previous version from a totally different format/period.
482        let v = next_version("YYYY.MM.PATCH", date_2026_03(), Some("1.2.3")).unwrap();
483        assert_eq!(v, "2026.3.0");
484    }
485
486    #[test]
487    fn previous_version_unparseable_patch() {
488        // If the patch segment isn't a number, treat as 0 and reset.
489        let v = next_version("YYYY.MM.PATCH", date_2026_03(), Some("2026.3.abc")).unwrap();
490        assert_eq!(v, "2026.3.1");
491    }
492
493    #[test]
494    fn dash_separator() {
495        let v = next_version("YYYY-MM-PATCH", date_2026_03(), None).unwrap();
496        assert_eq!(v, "2026-3-0");
497    }
498
499    #[test]
500    fn dash_separator_increment() {
501        let v = next_version("YYYY-MM-PATCH", date_2026_03(), Some("2026-3-2")).unwrap();
502        assert_eq!(v, "2026-3-3");
503    }
504
505    // ── CalverError Display ─────────────────────────────────────────
506
507    #[test]
508    fn error_display() {
509        assert_eq!(
510            CalverError::NoPatchToken.to_string(),
511            "calver format must contain the PATCH token"
512        );
513        assert_eq!(
514            CalverError::EmptyFormat.to_string(),
515            "calver format string is empty"
516        );
517        assert!(
518            CalverError::UnknownToken("X".into())
519                .to_string()
520                .contains("unknown calver format token")
521        );
522    }
523}