1use std::fmt;
10
11#[derive(Debug, Clone, Copy, PartialEq, Eq)]
16pub struct CalverDate {
17 pub year: u32,
19 pub month: u32,
21 pub day: u32,
23 pub iso_week: u32,
25 pub day_of_week: u32,
27}
28
29pub const DEFAULT_FORMAT: &str = "YYYY.MM.PATCH";
31
32#[derive(Debug, Clone, PartialEq, Eq)]
34pub enum CalverError {
35 NoPatchToken,
37 UnknownToken(String),
39 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#[derive(Debug, Clone, PartialEq, Eq)]
63enum Token {
64 FullYear,
66 ShortYear,
68 ZeroPaddedMonth,
70 Month,
72 IsoWeek,
74 Day,
76 Patch,
78 Separator(String),
80}
81
82fn 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 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 let ch = remaining.chars().next().unwrap();
117 if ch == '.' || ch == '-' || ch == '_' {
118 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 return Err(CalverError::UnknownToken(remaining.to_string()));
128 }
129 }
130 }
131
132 if !tokens.iter().any(|t| matches!(t, Token::Patch)) {
134 return Err(CalverError::NoPatchToken);
135 }
136
137 Ok(tokens)
138}
139
140fn 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
159fn 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 => {} }
181 }
182 suffix
183}
184
185pub 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 let patch = match previous_version {
213 Some(prev) => {
214 if date_segments_match(prev, &tokens, date) {
216 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
228fn date_segments_match(previous: &str, tokens: &[Token], date: CalverDate) -> bool {
230 let expected_prefix = build_date_prefix(tokens, date);
232 let expected_suffix = build_date_suffix(tokens, date);
234
235 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
246fn extract_patch(previous: &str, tokens: &[Token]) -> u64 {
248 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 let sep = find_separator(tokens);
270 let parts: Vec<&str> = previous.split(&sep).collect();
271
272 if parts.len() > segments_before_patch {
274 let patch_idx = segments_before_patch;
275 let _ = segments_after_patch; parts[patch_idx].parse().unwrap_or(0)
278 } else {
279 0
280 }
281}
282
283fn find_separator(tokens: &[Token]) -> String {
285 for token in tokens {
286 if let Token::Separator(s) = token {
287 return s.chars().next().unwrap().to_string();
289 }
290 }
291 ".".to_string()
292}
293
294pub 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, }
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, }
324 }
325
326 #[test]
329 fn parse_default_format() {
330 let tokens = parse_format("YYYY.MM.PATCH").unwrap();
331 assert_eq!(tokens.len(), 5); }
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); }
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 #[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 #[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 #[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 #[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 #[test]
497 fn previous_version_is_completely_different() {
498 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 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 #[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}