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