1use once_cell::sync::Lazy;
4use regex::Regex;
5use semver::Version as SemVer;
6use serde::{Deserialize, Serialize};
7use thiserror::Error;
8
9static CALVER_REGEX: Lazy<Regex> = Lazy::new(|| {
10 Regex::new(r"^(?<year>[0-9]{4})[-.](?<month>(0?[1-9]|10|11|12))(?:\.(?<day>(0?[1-9]|[1-3][0-9])))?(?:\+(?<micro>[0-9]+))?(?:-(?<pre>[a-zA-Z][-0-9a-zA-Z.]+))?$").unwrap()
11});
12
13static PARTIAL_REGEX: Lazy<Regex> = Lazy::new(|| {
14 Regex::new(r"^(?:(?<major>[0-9]+))?(?:\.(?<minor>[0-9]+))?(?:\.(?<patch>[0-9]+))?(?:-(?<pre>[a-zA-Z][-0-9a-zA-Z.]*))?(?:\+(?<build>[-0-9a-zA-Z.]+))?(?:lts)?$").unwrap()
15});
16
17#[derive(Debug, Error)]
18pub enum VersionError {
19 #[error("invalid semver")]
20 SemVer(#[from] semver::Error),
21 #[error(transparent)]
22 CalVer(#[from] CalVerError),
23 #[error(transparent)]
24 Partial(#[from] PartialError),
25 #[error("unknown version scheme")]
26 Unknown,
27}
28
29#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
30pub enum VersionKindType {
31 SemVer,
32 CalVer,
33 Partial,
34}
35
36#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
37pub enum VersionKind {
38 SemVer(SemVer),
39 CalVer(CalVer),
40 Partial(Partial),
41}
42
43#[derive(Debug, Error)]
44#[error("invalid CalVer format: {0}")]
45pub struct CalVerError(pub String);
46
47#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
48pub struct CalVer(SemVer);
49
50impl CalVer {
51 pub fn parse(s: &str) -> Result<Self, CalVerError> {
52 let caps = CALVER_REGEX
53 .captures(s)
54 .ok_or_else(|| CalVerError(s.to_string()))?;
55
56 let year = caps
57 .name("year")
58 .map(|c| c.as_str().trim_start_matches('0'))
59 .unwrap_or("0");
60 let year = if year.len() < 4 {
61 format!("20{}", year)
62 } else {
63 year.to_string()
64 };
65
66 let month = caps
67 .name("month")
68 .map(|c| c.as_str().trim_start_matches('0'))
69 .unwrap_or("0");
70 let day = caps
71 .name("day")
72 .map(|c| c.as_str().trim_start_matches('0'))
73 .unwrap_or("0");
74
75 let mut version = format!("{}.{}.{}", year, month, day);
76
77 if let Some(pre) = caps.name("pre") {
78 version.push('-');
79 version.push_str(pre.as_str());
80 }
81
82 if let Some(micro) = caps.name("micro") {
83 version.push('+');
84 version.push_str(micro.as_str());
85 }
86
87 Ok(Self(
88 SemVer::parse(&version).map_err(|_| CalVerError(s.to_string()))?,
89 ))
90 }
91
92 pub fn from_ymd(year: u64, month: u64, day: u64) -> Result<Self, CalVerError> {
93 if !(1..=12).contains(&month) {
94 return Err(CalVerError(format!("invalid month: {}", month)));
95 }
96 if !(1..=31).contains(&day) {
97 return Err(CalVerError(format!("invalid day: {}", day)));
98 }
99
100 let version = format!("{:04}.{:02}.{:02}", year, month, day);
101 Ok(Self(
102 SemVer::parse(&version).map_err(|_| CalVerError(version))?,
103 ))
104 }
105
106 pub fn year(&self) -> u64 {
107 self.0.major
108 }
109 pub fn month(&self) -> u64 {
110 self.0.minor
111 }
112 pub fn day(&self) -> u64 {
113 self.0.patch
114 }
115}
116
117impl std::str::FromStr for CalVer {
118 type Err = CalVerError;
119
120 fn from_str(s: &str) -> Result<Self, Self::Err> {
121 CalVer::parse(s)
122 }
123}
124
125impl std::fmt::Display for CalVer {
126 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
127 write!(f, "{:04}.{:02}", self.0.major, self.0.minor)?;
128 if self.0.patch > 0 {
129 write!(f, ".{:02}", self.0.patch)?;
130 }
131 if !self.0.pre.is_empty() {
132 write!(f, "-{}", self.0.pre)?;
133 }
134 if !self.0.build.is_empty() {
135 write!(f, "+{}", self.0.build)?;
136 }
137 Ok(())
138 }
139}
140
141impl std::ops::Deref for CalVer {
142 type Target = SemVer;
143
144 fn deref(&self) -> &Self::Target {
145 &self.0
146 }
147}
148
149#[derive(Debug, Error)]
150#[error("invalid partial version: {0}")]
151pub struct PartialError(pub String);
152
153#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
154pub struct Partial {
155 pub major: Option<u64>,
156 pub minor: Option<u64>,
157 pub patch: Option<u64>,
158 pub pre_release: Option<String>,
159 pub build_metadata: Option<String>,
160 pub lts: bool,
161}
162
163impl Partial {
164 pub fn parse(s: &str) -> Result<Self, PartialError> {
165 let trimmed = s.trim();
166 let lts = trimmed.ends_with("lts");
167 let trimmed = trimmed.trim_end_matches("lts");
168
169 let (parts, build) = trimmed
170 .split_once('+')
171 .map(|(c, b)| (c, Some(b)))
172 .unwrap_or((trimmed, None));
173 let (parts, pre) = parts
174 .split_once('-')
175 .map(|(c, p)| (c, Some(p)))
176 .unwrap_or((parts, None));
177
178 let caps = PARTIAL_REGEX
179 .captures(parts)
180 .ok_or_else(|| PartialError(s.to_string()))?;
181
182 let major = caps.name("major").and_then(|m| m.as_str().parse().ok());
183 let minor = caps.name("minor").and_then(|m| m.as_str().parse().ok());
184 let patch = caps.name("patch").and_then(|m| m.as_str().parse().ok());
185
186 if major.is_none() && minor.is_none() && patch.is_none() {
187 return Err(PartialError(s.to_string()));
188 }
189
190 Ok(Partial {
191 major,
192 minor,
193 patch,
194 pre_release: pre.map(|s| s.to_string()),
195 build_metadata: build.map(|s| s.to_string()),
196 lts,
197 })
198 }
199
200 pub fn matches(&self, version: &VersionKind) -> bool {
201 match version {
202 VersionKind::SemVer(v) => {
203 self.major.is_none_or(|m| m == v.major)
204 && self.minor.is_none_or(|m| m == v.minor)
205 && self.patch.is_none_or(|m| m == v.patch)
206 }
207 VersionKind::CalVer(v) => {
208 self.major.is_none_or(|m| m == v.year())
209 && self.minor.is_none_or(|m| m == v.month())
210 && self.patch.is_none_or(|m| m == v.day())
211 }
212 VersionKind::Partial(other) => {
213 self.major == other.major && self.minor == other.minor && self.patch == other.patch
214 }
215 }
216 }
217}
218
219impl std::str::FromStr for Partial {
220 type Err = PartialError;
221
222 fn from_str(s: &str) -> Result<Self, Self::Err> {
223 Partial::parse(s)
224 }
225}
226
227impl std::fmt::Display for Partial {
228 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
229 if let Some(major) = self.major {
230 write!(f, "{}", major)?;
231 }
232 if let Some(minor) = self.minor {
233 write!(f, ".{}", minor)?;
234 }
235 if let Some(patch) = self.patch {
236 write!(f, ".{}", patch)?;
237 }
238 if let Some(pre) = &self.pre_release {
239 write!(f, "-{}", pre)?;
240 }
241 if let Some(build) = &self.build_metadata {
242 write!(f, "+{}", build)?;
243 }
244 if self.lts {
245 write!(f, "lts")?;
246 }
247 Ok(())
248 }
249}
250
251impl std::str::FromStr for VersionKind {
252 type Err = VersionError;
253
254 fn from_str(s: &str) -> Result<Self, Self::Err> {
255 if let Ok(v) = s.parse::<SemVer>() {
256 return Ok(VersionKind::SemVer(v));
257 }
258 if let Ok(v) = s.parse::<CalVer>() {
259 return Ok(VersionKind::CalVer(v));
260 }
261 match s.parse::<Partial>() {
262 Ok(p) => Ok(VersionKind::Partial(p)),
263 Err(e) => Err(VersionError::Partial(e)),
264 }
265 }
266}
267
268impl std::fmt::Display for VersionKind {
269 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
270 match self {
271 VersionKind::SemVer(v) => write!(f, "{}", v),
272 VersionKind::CalVer(v) => write!(f, "{}", v),
273 VersionKind::Partial(v) => write!(f, "{}", v),
274 }
275 }
276}
277
278impl VersionKind {
279 pub fn parse(s: &str) -> Result<Self, VersionError> {
280 s.parse()
281 }
282
283 pub fn as_semver(&self) -> Option<&SemVer> {
284 match self {
285 VersionKind::SemVer(v) => Some(v),
286 _ => None,
287 }
288 }
289
290 pub fn kind(&self) -> VersionKindType {
291 match self {
292 VersionKind::SemVer(_) => VersionKindType::SemVer,
293 VersionKind::CalVer(_) => VersionKindType::CalVer,
294 VersionKind::Partial(_) => VersionKindType::Partial,
295 }
296 }
297}
298
299#[derive(Debug, Error)]
300pub enum VersionPreferenceError {
301 #[error("invalid version requirement: {0}")]
302 InvalidRequirement(String),
303}
304
305#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
306pub enum VersionRequirement {
307 Any,
308 Exact(VersionKind),
309 Partial(Partial),
310 SemVer(semver::VersionReq),
311}
312
313impl VersionRequirement {
314 pub fn parse(input: &str) -> Result<Self, VersionPreferenceError> {
315 let trimmed = input.trim();
316 if trimmed.is_empty() || trimmed == "*" {
317 return Ok(Self::Any);
318 }
319
320 if let Ok(requirement) = semver::VersionReq::parse(trimmed) {
321 return Ok(Self::SemVer(requirement));
322 }
323
324 if let Ok(partial) = Partial::parse(trimmed) {
325 return Ok(Self::Partial(partial));
326 }
327
328 if let Ok(version) = VersionKind::parse(trimmed) {
329 return Ok(Self::Exact(version));
330 }
331
332 Err(VersionPreferenceError::InvalidRequirement(
333 trimmed.to_string(),
334 ))
335 }
336
337 pub fn matches(&self, version: &VersionKind) -> bool {
338 match self {
339 Self::Any => true,
340 Self::Exact(expected) => expected == version,
341 Self::Partial(partial) => partial.matches(version),
342 Self::SemVer(requirement) => version
343 .as_semver()
344 .is_some_and(|value| requirement.matches(value)),
345 }
346 }
347}
348
349#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
350pub enum VersionPreference {
351 Latest,
352 Lowest,
353 HighestStable,
354 Lts,
355 Pinned(VersionKind),
356}
357
358#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
359pub struct SelectionPolicy {
360 pub requirement: VersionRequirement,
361 pub preference: VersionPreference,
362}
363
364impl Default for SelectionPolicy {
365 fn default() -> Self {
366 Self {
367 requirement: VersionRequirement::Any,
368 preference: VersionPreference::Latest,
369 }
370 }
371}
372
373pub fn select_preferred<'a>(
374 versions: &'a [VersionKind],
375 policy: &SelectionPolicy,
376) -> Option<&'a VersionKind> {
377 let candidates = matching_candidates(versions, &policy.requirement);
378
379 if candidates.is_empty() {
380 return None;
381 }
382
383 match &policy.preference {
384 VersionPreference::Lowest => candidates.into_iter().min(),
385 VersionPreference::Latest => candidates.into_iter().max(),
386 VersionPreference::HighestStable => candidates
387 .into_iter()
388 .filter(|version| version.is_stable())
389 .max()
390 .or_else(|| {
391 matching_candidates(versions, &policy.requirement)
392 .into_iter()
393 .max()
394 }),
395 VersionPreference::Lts => candidates
396 .into_iter()
397 .filter(|version| matches!(version, VersionKind::Partial(partial) if partial.lts))
398 .max()
399 .or_else(|| {
400 matching_candidates(versions, &policy.requirement)
401 .into_iter()
402 .max()
403 }),
404 VersionPreference::Pinned(version) => {
405 versions.iter().find(|candidate| *candidate == version)
406 }
407 }
408}
409
410fn matching_candidates<'a>(
411 versions: &'a [VersionKind],
412 requirement: &VersionRequirement,
413) -> Vec<&'a VersionKind> {
414 versions
415 .iter()
416 .filter(|version| requirement.matches(version))
417 .collect()
418}
419
420impl VersionKind {
421 pub fn is_stable(&self) -> bool {
422 match self {
423 VersionKind::SemVer(version) => version.pre.is_empty(),
424 VersionKind::CalVer(version) => version.pre.is_empty(),
425 VersionKind::Partial(version) => version.pre_release.is_none(),
426 }
427 }
428}
429
430#[cfg(test)]
431mod tests {
432 use proptest::prelude::*;
433
434 use super::{
435 Partial, SelectionPolicy, VersionKind, VersionKindType, VersionPreference,
436 VersionRequirement, select_preferred,
437 };
438
439 #[test]
440 fn test_semver_parse() {
441 let v: VersionKind = "1.2.3".parse().unwrap();
442 assert_eq!(v.kind(), VersionKindType::SemVer);
443 }
444
445 #[test]
446 fn test_calver_parse() {
447 let v: VersionKind = "2024.01".parse().unwrap();
448 assert_eq!(v.kind(), VersionKindType::CalVer);
449 }
450
451 #[test]
452 fn test_partial_major() {
453 let v: VersionKind = "18".parse().unwrap();
454 assert_eq!(v.kind(), VersionKindType::Partial);
455 }
456
457 #[test]
458 fn test_version_comparison() {
459 let v1: VersionKind = "1.0.0".parse().unwrap();
460 let v2: VersionKind = "2.0.0".parse().unwrap();
461 assert!(v1 < v2);
462 }
463
464 #[test]
465 fn test_version_display() {
466 let v: VersionKind = "1.2.3".parse().unwrap();
467 assert_eq!(format!("{}", v), "1.2.3");
468 }
469
470 #[test]
471 fn semver_requirement_matches_semver() {
472 let requirement = VersionRequirement::parse("^1.2").unwrap();
473 assert!(requirement.matches(&VersionKind::parse("1.2.3").unwrap()));
474 assert!(!requirement.matches(&VersionKind::parse("2.0.0").unwrap()));
475 }
476
477 #[test]
478 fn partial_requirement_matches_cross_scheme() {
479 let requirement = VersionRequirement::Partial(Partial::parse("2024.01").unwrap());
480 assert!(requirement.matches(&VersionKind::parse("2024.01.15").unwrap()));
481 }
482
483 #[test]
484 fn select_latest_prefers_highest_match() {
485 let versions = vec![
486 VersionKind::parse("1.2.3").unwrap(),
487 VersionKind::parse("1.3.0").unwrap(),
488 VersionKind::parse("1.2.9").unwrap(),
489 ];
490
491 let selected = select_preferred(
492 &versions,
493 &SelectionPolicy {
494 requirement: VersionRequirement::parse("^1.2").unwrap(),
495 preference: VersionPreference::Latest,
496 },
497 )
498 .unwrap();
499
500 assert_eq!(selected.to_string(), "1.3.0");
501 }
502
503 #[test]
504 fn select_highest_stable_skips_prerelease() {
505 let versions = vec![
506 VersionKind::parse("1.2.3-alpha.1").unwrap(),
507 VersionKind::parse("1.2.2").unwrap(),
508 ];
509
510 let selected = select_preferred(
511 &versions,
512 &SelectionPolicy {
513 requirement: VersionRequirement::Any,
514 preference: VersionPreference::HighestStable,
515 },
516 )
517 .unwrap();
518
519 assert_eq!(selected.to_string(), "1.2.2");
520 }
521
522 #[test]
523 fn pinned_preference_returns_exact_version() {
524 let pinned = VersionKind::parse("20.12.1").unwrap();
525 let versions = vec![pinned.clone(), VersionKind::parse("20.11.0").unwrap()];
526
527 let selected = select_preferred(
528 &versions,
529 &SelectionPolicy {
530 requirement: VersionRequirement::Any,
531 preference: VersionPreference::Pinned(pinned),
532 },
533 )
534 .unwrap();
535
536 assert_eq!(selected.to_string(), "20.12.1");
537 }
538
539 #[test]
540 fn semver_requirement_does_not_match_calver_values() {
541 let requirement = VersionRequirement::parse("^1.2").unwrap();
542 assert!(!requirement.matches(&VersionKind::parse("2024.01.15").unwrap()));
543 }
544
545 #[test]
546 fn highest_stable_falls_back_to_latest_when_only_prerelease_exists() {
547 let versions = vec![
548 VersionKind::parse("1.2.3-alpha.1").unwrap(),
549 VersionKind::parse("1.2.3-alpha.2").unwrap(),
550 ];
551
552 let selected = select_preferred(
553 &versions,
554 &SelectionPolicy {
555 requirement: VersionRequirement::Any,
556 preference: VersionPreference::HighestStable,
557 },
558 )
559 .unwrap();
560
561 assert_eq!(selected.to_string(), "1.2.3-alpha.2");
562 }
563
564 #[test]
565 fn lts_preference_prefers_lts_partial_entries() {
566 let versions = vec![
567 VersionKind::parse("22").unwrap(),
568 VersionKind::parse("20lts").unwrap(),
569 VersionKind::parse("18lts").unwrap(),
570 ];
571
572 let selected = select_preferred(
573 &versions,
574 &SelectionPolicy {
575 requirement: VersionRequirement::Any,
576 preference: VersionPreference::Lts,
577 },
578 )
579 .unwrap();
580
581 assert_eq!(selected.to_string(), "20lts");
582 }
583
584 #[test]
585 fn malformed_version_inputs_are_rejected() {
586 for input in ["", "abc", "1.2.3.4"] {
587 assert!(
588 VersionKind::parse(input).is_err(),
589 "input should fail: {input}"
590 );
591 }
592 }
593
594 proptest! {
595 #[test]
596 fn semver_roundtrip_is_stable(major in 0u64..1000, minor in 0u64..1000, patch in 0u64..1000) {
597 let input = format!("{major}.{minor}.{patch}");
598 let parsed = VersionKind::parse(&input).unwrap();
599 prop_assert_eq!(parsed.to_string(), input);
600 }
601
602 #[test]
603 fn semver_ordering_tracks_numeric_components(
604 a_major in 0u64..100,
605 a_minor in 0u64..100,
606 a_patch in 0u64..100,
607 b_major in 0u64..100,
608 b_minor in 0u64..100,
609 b_patch in 0u64..100,
610 ) {
611 let left = VersionKind::parse(&format!("{a_major}.{a_minor}.{a_patch}")).unwrap();
612 let right = VersionKind::parse(&format!("{b_major}.{b_minor}.{b_patch}")).unwrap();
613
614 let tuple_cmp = (a_major, a_minor, a_patch).cmp(&(b_major, b_minor, b_patch));
615 prop_assert_eq!(left.cmp(&right), tuple_cmp);
616 }
617 }
618}