1#[derive(Debug, Clone, Copy, PartialEq, Eq)]
14pub struct CalverDate {
15 pub year: u32,
17 pub month: u32,
19 pub day: u32,
21 pub iso_week: u32,
23 pub day_of_week: u32,
25}
26
27pub const DEFAULT_FORMAT: &str = "YYYY.MM.PATCH";
29
30#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
32pub enum CalverError {
33 #[error("calver format must contain the PATCH token")]
35 NoPatchToken,
36 #[error("unknown calver format token: {0}")]
38 UnknownToken(String),
39 #[error("calver format string is empty")]
41 EmptyFormat,
42}
43
44#[derive(Debug, Clone, PartialEq, Eq)]
46enum Token {
47 FullYear,
49 ShortYear,
51 ZeroPaddedMonth,
53 Month,
55 IsoWeek,
57 Day,
59 Patch,
61 Separator(String),
63}
64
65fn 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 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 let ch = remaining.chars().next().unwrap();
100 if ch == '.' || ch == '-' || ch == '_' {
101 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 return Err(CalverError::UnknownToken(remaining.to_string()));
111 }
112 }
113 }
114
115 if !tokens.iter().any(|t| matches!(t, Token::Patch)) {
117 return Err(CalverError::NoPatchToken);
118 }
119
120 Ok(tokens)
121}
122
123fn 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
142fn 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 => {} }
164 }
165 suffix
166}
167
168pub 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 let patch = match previous_version {
196 Some(prev) => {
197 if date_segments_match(prev, &tokens, date) {
199 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
211fn date_segments_match(previous: &str, tokens: &[Token], date: CalverDate) -> bool {
213 let expected_prefix = build_date_prefix(tokens, date);
215 let expected_suffix = build_date_suffix(tokens, date);
217
218 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
229fn extract_patch(previous: &str, tokens: &[Token]) -> u64 {
231 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 let sep = find_separator(tokens);
253 let parts: Vec<&str> = previous.split(&sep).collect();
254
255 if parts.len() > segments_before_patch {
257 let patch_idx = segments_before_patch;
258 let _ = segments_after_patch; parts[patch_idx].parse().unwrap_or(0)
261 } else {
262 0
263 }
264}
265
266fn find_separator(tokens: &[Token]) -> String {
268 for token in tokens {
269 if let Token::Separator(s) = token {
270 return s.chars().next().unwrap().to_string();
272 }
273 }
274 ".".to_string()
275}
276
277pub 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, }
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, }
307 }
308
309 #[test]
312 fn parse_default_format() {
313 let tokens = parse_format("YYYY.MM.PATCH").unwrap();
314 assert_eq!(tokens.len(), 5); }
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); }
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 #[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 #[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 #[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 #[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 #[test]
480 fn previous_version_is_completely_different() {
481 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 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 #[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}