1use crate::{
2 ext::anyhow::{bail, Context, Result},
3 logger::GRAY,
4};
5use bytes::Bytes;
6use once_cell::sync::Lazy;
7use std::{
8 fs::{self, File},
9 io::{Cursor, Write},
10 path::{Path, PathBuf},
11 sync::Once,
12};
13
14use std::env;
15
16use zip::ZipArchive;
17
18use super::util::{is_linux_musl_env, os_arch};
19
20use reqwest::ClientBuilder;
21#[cfg(target_family = "unix")]
22use std::os::unix::prelude::PermissionsExt;
23use std::time::{Duration, SystemTime};
24
25use semver::Version;
26
27#[derive(Debug)]
28pub struct ExeMeta {
29 name: &'static str,
30 version: String,
31 url: String,
32 exe: String,
33 manual: String,
34}
35
36static ON_STARTUP_DEBUG_ONCE: Lazy<Once> = Lazy::new(|| Once::new());
37
38pub const ENV_VAR_GLORY_CARGO_GENERATE_VERSION: &str = "GLORY_CARGO_GENERATE_VERSION";
39pub const ENV_VAR_GLORY_TAILWIND_VERSION: &str = "GLORY_TAILWIND_VERSION";
40pub const ENV_VAR_GLORY_SASS_VERSION: &str = "GLORY_SASS_VERSION";
41pub const ENV_VAR_GLORY_WASM_OPT_VERSION: &str = "GLORY_WASM_OPT_VERSION";
42
43impl ExeMeta {
44 #[allow(clippy::wrong_self_convention)]
45 fn from_global_path(&self) -> Option<PathBuf> {
46 which::which(self.name).ok()
47 }
48
49 fn get_name(&self) -> String {
50 format!("{}-{}", &self.name, &self.version)
51 }
52
53 async fn cached(&self) -> Result<PathBuf> {
54 let cache_dir = get_cache_dir()?.join(self.get_name());
55 self._with_cache_dir(&cache_dir).await
56 }
57
58 async fn _with_cache_dir(&self, cache_dir: &Path) -> Result<PathBuf> {
59 let exe_dir = cache_dir.join(self.get_name());
60 let c = ExeCache { meta: self, exe_dir };
61 c.get().await
62 }
63
64 #[cfg(test)]
65 pub async fn with_cache_dir(&self, cache_dir: &Path) -> Result<PathBuf> {
66 self._with_cache_dir(cache_dir).await
67 }
68}
69
70pub struct ExeCache<'a> {
71 exe_dir: PathBuf,
72 meta: &'a ExeMeta,
73}
74
75impl<'a> ExeCache<'a> {
76 fn exe_in_cache(&self) -> Result<PathBuf> {
77 let exe_path = self.exe_dir.join(PathBuf::from(&self.meta.exe));
78
79 if !exe_path.exists() {
80 bail!("The path {exe_path:?} doesn't exist");
81 }
82
83 Ok(exe_path)
84 }
85
86 async fn fetch_archive(&self) -> Result<Bytes> {
87 log::debug!("Install downloading {} {}", self.meta.name, GRAY.paint(&self.meta.url));
88
89 let response = reqwest::get(&self.meta.url).await?;
90
91 match response.status().is_success() {
92 true => Ok(response.bytes().await?),
93 false => bail!("Could not download from {}", self.meta.url),
94 }
95 }
96
97 fn extract_downloaded(&self, data: &Bytes) -> Result<()> {
98 if self.meta.url.ends_with(".zip") {
99 extract_zip(data, &self.exe_dir)?;
100 } else if self.meta.url.ends_with(".tar.gz") {
101 extract_tar(data, &self.exe_dir)?;
102 } else {
103 self.write_binary(data)
104 .context(format!("Could not write binary {}", self.meta.get_name()))?;
105 }
106
107 log::debug!("Install decompressing {} {}", self.meta.name, GRAY.paint(self.exe_dir.to_string_lossy()));
108
109 Ok(())
110 }
111
112 fn write_binary(&self, data: &Bytes) -> Result<()> {
113 fs::create_dir_all(&self.exe_dir).unwrap();
114 let path = self.exe_dir.join(Path::new(&self.meta.exe));
115 let mut file = File::create(&path).unwrap();
116 file.write_all(data).context(format!("Error writing binary file: {:?}", path))?;
117
118 #[cfg(target_family = "unix")]
119 {
120 let mut perm = fs::metadata(&path)?.permissions();
121 perm.set_mode(0o550);
124 fs::set_permissions(&path, perm)?;
125 }
126 Ok(())
127 }
128
129 async fn download(&self) -> Result<PathBuf> {
130 log::info!("Command installing {} ...", self.meta.get_name());
131
132 let data = self
133 .fetch_archive()
134 .await
135 .context(format!("Could not download {}", self.meta.get_name()))?;
136
137 self.extract_downloaded(&data)
138 .context(format!("Could not extract {}", self.meta.get_name()))?;
139
140 let binary_path = self.exe_in_cache().context(format!(
141 "Binary downloaded and extracted but could still not be found at {:?}",
142 self.exe_dir
143 ))?;
144 log::info!("Command {} installed.", self.meta.get_name());
145 Ok(binary_path)
146 }
147
148 async fn get(&self) -> Result<PathBuf> {
149 if let Ok(path) = self.exe_in_cache() {
150 Ok(path)
151 } else {
152 self.download().await
153 }
154 }
155}
156
157fn extract_tar(src: &Bytes, dest: &Path) -> Result<()> {
160 let content = Cursor::new(src);
161 let dec = flate2::read::GzDecoder::new(content);
162 let mut arch = tar::Archive::new(dec);
163 arch.unpack(dest).dot()?;
164 Ok(())
165}
166
167fn extract_zip(src: &Bytes, dest: &Path) -> Result<()> {
168 let content = Cursor::new(src);
169 let mut arch = ZipArchive::new(content).dot()?;
170 arch.extract(dest).dot().dot()?;
171 Ok(())
172}
173
174fn get_cache_dir() -> Result<PathBuf> {
185 let dir = dirs::cache_dir()
186 .ok_or_else(|| anyhow::anyhow!("Cache directory does not exist"))?
187 .join("glory-cli");
188
189 if !dir.exists() {
190 fs::create_dir_all(&dir).context(format!("Could not create dir {dir:?}"))?;
191 }
192
193 ON_STARTUP_DEBUG_ONCE.call_once(|| {
194 log::debug!("Command cache dir: {}", dir.to_string_lossy());
195 });
196
197 Ok(dir)
198}
199
200#[derive(Debug, Hash, Eq, PartialEq)]
201pub enum Exe {
202 CargoGenerate,
203 Sass,
204 WasmOpt,
205 Tailwind,
206}
207
208impl Exe {
209 pub async fn get(&self) -> Result<PathBuf> {
210 let meta = self.meta().await?;
211
212 let path = if let Some(path) = meta.from_global_path() {
213 path
214 } else if cfg!(feature = "no_downloads") {
215 bail!(
216 "{} is required but was not found. Please install it using your OS's tool of choice",
217 &meta.name
218 );
219 } else {
220 meta.cached().await.context(meta.manual)?
221 };
222
223 log::debug!("Command using {} {} {}", &meta.name, &meta.version, GRAY.paint(path.to_string_lossy()));
224
225 Ok(path)
226 }
227
228 pub async fn meta(&self) -> Result<ExeMeta> {
229 let (target_os, target_arch) = os_arch().unwrap();
230
231 let exe = match self {
232 Exe::CargoGenerate => CommandCargoGenerate.exe_meta(target_os, target_arch).await.dot()?,
238 Exe::Sass => CommandSass.exe_meta(target_os, target_arch).await.dot()?,
239 Exe::WasmOpt => CommandWasmOpt.exe_meta(target_os, target_arch).await.dot()?,
240 Exe::Tailwind => CommandTailwind.exe_meta(target_os, target_arch).await.dot()?,
241 };
242
243 Ok(exe)
244 }
245}
246
247#[inline]
254fn sanitize_version_prefix(ver_string: &str) -> String {
255 ver_string.chars().skip_while(|c| !c.is_ascii_digit() || *c == '_').collect::<String>()
256}
257
258fn normalize_version(ver_string: &str) -> Option<Version> {
262 let ver_string = sanitize_version_prefix(ver_string);
263 match Version::parse(&ver_string) {
264 Ok(v) => Some(v),
265 Err(_) => match &ver_string.parse::<u64>() {
266 Ok(num) => Some(Version::new(*num, 0, 0)),
267 Err(_) => match Version::parse(format!("{ver_string}.0").as_str()) {
268 Ok(v) => Some(v),
269 Err(e) => {
270 log::error!("Command failed to normalize version {ver_string}: {e}");
271 None
272 }
273 },
274 },
275 }
276}
277
278use async_trait::async_trait;
281
282struct CommandTailwind;
283struct CommandWasmOpt;
284struct CommandSass;
285struct CommandCargoGenerate;
286
287#[async_trait]
288impl Command for CommandTailwind {
289 fn name(&self) -> &'static str {
290 "tailwindcss"
291 }
292 fn default_version(&self) -> &'static str {
293 "v3.3.5"
294 }
295 fn env_var_version_name(&self) -> &'static str {
296 ENV_VAR_GLORY_TAILWIND_VERSION
297 }
298 fn github_owner(&self) -> &'static str {
299 "tailwindlabs"
300 }
301 fn github_repo(&self) -> &'static str {
302 "tailwindcss"
303 }
304
305 fn download_url(&self, target_os: &str, target_arch: &str, version: &str) -> Result<String> {
307 match (target_os, target_arch) {
308 ("windows", "x86_64") => Ok(format!(
309 "https://github.com/{}/{}/releases/download/{}/{}-windows-x64.exe",
310 self.github_owner(),
311 self.github_repo(),
312 version,
313 self.name()
314 )),
315 ("macos", "x86_64") => Ok(format!(
316 "https://github.com/{}/{}/releases/download/{}/{}-macos-x64",
317 self.github_owner(),
318 self.github_repo(),
319 version,
320 self.name()
321 )),
322 ("macos", "aarch64") => Ok(format!(
323 "https://github.com/{}/{}/releases/download/{}/{}-macos-arm64",
324 self.github_owner(),
325 self.github_repo(),
326 version,
327 self.name()
328 )),
329 ("linux", "x86_64") => Ok(format!(
330 "https://github.com/{}/{}/releases/download/{}/{}-linux-x64",
331 self.github_owner(),
332 self.github_repo(),
333 version,
334 self.name()
335 )),
336 ("linux", "aarch64") => Ok(format!(
337 "https://github.com/{}/{}/releases/download/{}/{}-linux-arm64",
338 self.github_owner(),
339 self.github_repo(),
340 version,
341 self.name()
342 )),
343 _ => bail!("Command [{}] failed to find a match for {}-{} ", self.name(), target_os, target_arch),
344 }
345 }
346
347 fn executable_name(&self, target_os: &str, target_arch: &str, _version: Option<&str>) -> Result<String> {
348 Ok(match (target_os, target_arch) {
349 ("windows", _) => format!("{}-windows-x64.exe", self.name()),
350 ("macos", "x86_64") => format!("{}-macos-x64", self.name()),
351 ("macos", "aarch64") => format!("{}-macos-arm64", self.name()),
352 ("linux", "x86_64") => format!("{}-linux-x64", self.name()),
353 (_, _) => format!("{}-linux-arm64", self.name()),
354 })
355 }
356
357 fn manual_install_instructions(&self) -> String {
358 "Try manually installing tailwindcss: https://tailwindcss.com/docs/installation".to_string()
359 }
360}
361
362#[async_trait]
363impl Command for CommandWasmOpt {
364 fn name(&self) -> &'static str {
365 "wasm-opt"
366 }
367 fn default_version(&self) -> &'static str {
368 "version_112"
369 }
370 fn env_var_version_name(&self) -> &'static str {
371 ENV_VAR_GLORY_WASM_OPT_VERSION
372 }
373 fn github_owner(&self) -> &'static str {
374 "WebAssembly"
375 }
376 fn github_repo(&self) -> &'static str {
377 "binaryen"
378 }
379
380 fn download_url(&self, target_os: &str, target_arch: &str, version: &str) -> Result<String> {
381 let target = match (target_os, target_arch) {
382 ("linux", _) => "x86_64-linux",
383 ("windows", _) => "x86_64-windows",
384 ("macos", "aarch64") => "arm64-macos",
385 ("macos", "x86_64") => "x86_64-macos",
386 _ => {
387 bail!("No wasm-opt tar binary found for {target_os} {target_arch}")
388 }
389 };
390
391 Ok(format!(
392 "https://github.com/{}/{}/releases/download/{}/binaryen-{}-{}.tar.gz",
393 self.github_owner(),
394 self.github_repo(),
395 version,
396 version,
397 target
398 ))
399 }
400
401 fn executable_name(&self, target_os: &str, _target_arch: &str, version: Option<&str>) -> Result<String> {
402 if version.is_none() {
403 bail!("Version is required for WASM Opt, none provided")
404 };
405
406 Ok(match target_os {
407 "windows" => format!("binaryen-{}/bin/{}.exe", version.unwrap_or_default(), self.name()),
408 _ => format!("binaryen-{}/bin/{}", version.unwrap_or_default(), self.name()),
409 })
410 }
411
412 fn manual_install_instructions(&self) -> String {
413 "Try manually installing binaryen: https://github.com/WebAssembly/binaryen".to_string()
414 }
415}
416
417#[async_trait]
418impl Command for CommandSass {
419 fn name(&self) -> &'static str {
420 "sass"
421 }
422 fn default_version(&self) -> &'static str {
423 "1.58.3"
424 }
425 fn env_var_version_name(&self) -> &'static str {
426 ENV_VAR_GLORY_SASS_VERSION
427 }
428 fn github_owner(&self) -> &'static str {
429 "dart-musl"
430 }
431 fn github_repo(&self) -> &'static str {
432 "dart-sass"
433 }
434
435 fn download_url(&self, target_os: &str, target_arch: &str, version: &str) -> Result<String> {
436 let is_musl_env = is_linux_musl_env();
437 Ok(if is_musl_env {
438 match target_arch {
439 "x86_64" => format!(
440 "https://github.com/{}/{}/releases/download/{}/dart-sass-{}-linux-x64.tar.gz",
441 self.github_owner(),
442 self.github_repo(),
443 version,
444 version
445 ),
446 "aarch64" => format!(
447 "https://github.com/{}/{}/releases/download/{}/dart-sass-{}-linux-arm64.tar.gz",
448 self.github_owner(),
449 self.github_repo(),
450 version,
451 version
452 ),
453 _ => bail!("No sass tar binary found for linux-musl {target_arch}"),
454 }
455 } else {
456 match (target_os, target_arch) {
457 ("windows", "x86_64") => format!(
459 "https://github.com/sass/{}/releases/download/{}/dart-sass-{}-windows-x64.zip",
460 self.github_repo(),
461 version,
462 version
463 ),
464 ("macos" | "linux", "x86_64") => format!(
465 "https://github.com/sass/{}/releases/download/{}/dart-sass-{}-{}-x64.tar.gz",
466 self.github_repo(),
467 version,
468 version,
469 target_os
470 ),
471 ("macos" | "linux", "aarch64") => format!(
472 "https://github.com/sass/{}/releases/download/{}/dart-sass-{}-{}-arm64.tar.gz",
473 self.github_repo(),
474 version,
475 version,
476 target_os
477 ),
478 _ => bail!("No sass tar binary found for {target_os} {target_arch}"),
479 }
480 })
481 }
482
483 fn executable_name(&self, target_os: &str, _target_arch: &str, _version: Option<&str>) -> Result<String> {
484 Ok(match target_os {
485 "windows" => "dart-sass/sass.bat".to_string(),
486 _ => "dart-sass/sass".to_string(),
487 })
488 }
489
490 fn manual_install_instructions(&self) -> String {
491 "Try manually installing sass: https://sass-lang.com/install".to_string()
492 }
493}
494
495#[async_trait]
496impl Command for CommandCargoGenerate {
497 fn name(&self) -> &'static str {
498 "cargo-generate"
499 }
500 fn default_version(&self) -> &'static str {
501 "v0.17.3"
502 }
503 fn env_var_version_name(&self) -> &'static str {
504 ENV_VAR_GLORY_CARGO_GENERATE_VERSION
505 }
506 fn github_owner(&self) -> &'static str {
507 "cargo-generate"
508 }
509 fn github_repo(&self) -> &'static str {
510 "cargo-generate"
511 }
512
513 fn download_url(&self, target_os: &str, target_arch: &str, version: &str) -> Result<String> {
514 let is_musl_env = is_linux_musl_env();
515
516 let target = if is_musl_env {
517 match (target_os, target_arch) {
518 ("linux", "aarch64") => "aarch64-unknown-linux-musl",
519 ("linux", "x86_64") => "x86_64-unknown-linux-musl",
520 _ => bail!("No cargo-generate tar binary found for linux-musl {target_arch}"),
521 }
522 } else {
523 match (target_os, target_arch) {
524 ("macos", "aarch64") => "aarch64-apple-darwin",
525 ("linux", "aarch64") => "aarch64-unknown-linux-gnu",
526 ("macos", "x86_64") => "x86_64-apple-darwin",
527 ("windows", "x86_64") => "x86_64-pc-windows-msvc",
528 ("linux", "x86_64") => "x86_64-unknown-linux-gnu",
529 _ => bail!("No cargo-generate tar binary found for {target_os} {target_arch}"),
530 }
531 };
532
533 Ok(format!(
534 "https://github.com/{}/{}/releases/download/{}/cargo-generate-{}-{}.tar.gz",
535 self.github_owner(),
536 self.github_repo(),
537 version,
538 version,
539 target
540 ))
541 }
542
543 fn executable_name(&self, target_os: &str, _target_arch: &str, _version: Option<&str>) -> Result<String> {
544 Ok(match target_os {
545 "windows" => "cargo-generate.exe".to_string(),
546 _ => "cargo-generate".to_string(),
547 })
548 }
549
550 fn manual_install_instructions(&self) -> String {
551 "Try manually installing cargo-generate: https://github.com/cargo-generate/cargo-generate#installation".to_string()
552 }
553}
554
555#[async_trait]
556trait Command {
561 fn name(&self) -> &'static str;
562 fn default_version(&self) -> &str;
563 fn env_var_version_name(&self) -> &str;
564 fn github_owner(&self) -> &str;
565 fn github_repo(&self) -> &str;
566 fn download_url(&self, target_os: &str, target_arch: &str, version: &str) -> Result<String>;
567 fn executable_name(&self, target_os: &str, target_arch: &str, version: Option<&str>) -> Result<String>;
568 #[allow(unused)]
569 fn manual_install_instructions(&self) -> String {
570 "Try manually installing the command".to_string()
572 }
573
574 async fn exe_meta(&self, target_os: &str, target_arch: &str) -> Result<ExeMeta> {
589 let version = self.resolve_version().await;
590 let url = self.download_url(target_os, target_arch, version.as_str())?;
591 let exe = self.executable_name(target_os, target_arch, Some(version.as_str()))?;
592 Ok(ExeMeta {
593 name: self.name(),
594 version,
595 url: url.to_owned(),
596 exe: exe.to_string(),
597 manual: self.manual_install_instructions(),
598 })
599 }
600
601 async fn should_check_for_new_version(&self) -> bool {
604 match get_cache_dir() {
605 Ok(dir) => {
606 let marker = dir.join(format!(".{}_last_checked", self.name()));
607 return match (marker.exists(), marker.is_dir()) {
608 (_, true) => {
609 log::warn!(
611 "Command [{}] encountered a conflicting dir in the cache, please delete {}",
612 self.name(),
613 marker.display()
614 );
615
616 false
617 }
618 (true, _) => {
619 let contents = tokio::fs::read_to_string(&marker).await;
621 let now = SystemTime::now().duration_since(SystemTime::UNIX_EPOCH);
622 if let Some(timestamp) = contents.ok().map(|s| s.parse::<u64>().ok().unwrap_or_default()) {
623 let last_checked = Duration::from_millis(timestamp);
624 let one_day = Duration::from_secs(24 * 60 * 60);
625 if let Ok(now) = now {
626 match (now - last_checked) > one_day {
627 true => tokio::fs::write(&marker, now.as_millis().to_string()).await.is_ok(),
628 false => false,
629 }
630 } else {
631 false
632 }
633 } else {
634 false
635 }
636 }
637 (false, _) => {
638 let now = SystemTime::now().duration_since(SystemTime::UNIX_EPOCH);
640 return if let Ok(unix_timestamp) = now {
641 tokio::fs::write(marker, unix_timestamp.as_millis().to_string()).await.is_ok()
642 } else {
643 false
644 };
645 }
646 };
647 }
648 Err(e) => {
649 log::warn!("Command {} failed to get cache dir: {}", self.name(), e);
650 false
651 }
652 }
653 }
654
655 async fn check_for_latest_version(&self) -> Option<String> {
656 log::debug!("Command [{}] checking for the latest available version", self.name());
657
658 let client = ClientBuilder::default()
659 .user_agent("glory-cli")
661 .build()
662 .unwrap_or_default();
663
664 if let Ok(response) = client
665 .get(format!(
666 "https://api.github.com/repos/{}/{}/releases/latest",
667 self.github_owner(),
668 self.github_repo()
669 ))
670 .send()
671 .await
672 {
673 if !response.status().is_success() {
674 log::error!("Command [{}] GitHub API request failed: {}", self.name(), response.status());
675 return None;
676 }
677
678 #[derive(serde::Deserialize)]
679 struct Github {
680 tag_name: String, }
682
683 let github: Github = match response.json().await {
684 Ok(json) => json,
685 Err(e) => {
686 log::debug!("Command [{}] failed to parse the response JSON from the GitHub API: {}", self.name(), e);
687 return None;
688 }
689 };
690
691 Some(github.tag_name)
692 } else {
693 log::debug!("Command [{}] failed to check for the latest version", self.name());
694 None
695 }
696 }
697
698 async fn resolve_version(&self) -> String {
703 let is_force_pin_version = env::var(self.env_var_version_name()).is_ok();
706 log::trace!(
707 "Command [{}] is_force_pin_version: {} - {:?}",
708 self.name(),
709 is_force_pin_version,
710 env::var(self.env_var_version_name())
711 );
712
713 if !is_force_pin_version && !self.should_check_for_new_version().await {
714 log::trace!("Command [{}] NOT checking for the latest available version", &self.name());
715 return self.default_version().into();
716 }
717
718 let version = env::var(self.env_var_version_name())
719 .unwrap_or_else(|_| self.default_version().into())
720 .to_owned();
721
722 let latest = self.check_for_latest_version().await;
723
724 match latest {
725 Some(latest) => {
726 let norm_latest = normalize_version(latest.as_str());
727 let norm_version = normalize_version(&version);
728 if norm_latest.is_some() && norm_version.is_some() {
729 match norm_version.cmp(&norm_latest) {
731 core::cmp::Ordering::Greater | core::cmp::Ordering::Equal => {
732 log::debug!(
733 "Command [{}] requested version {} is already same or newer than available version {}",
734 self.name(),
735 version,
736 &latest
737 )
738 }
739 core::cmp::Ordering::Less => {
740 log::info!(
741 "Command [{}] requested version {}, but a newer version {} is available, you can try it out by \
742 setting the {}={} env var and re-running the command",
743 self.name(),
744 version,
745 &latest,
746 self.env_var_version_name(),
747 &latest
748 )
749 }
750 }
751 }
752 }
753 None => log::warn!("Command [{}] failed to check for the latest version", self.name()),
754 }
755
756 version
757 }
758}
759
760#[cfg(test)]
761mod tests {
762 use super::*;
763 use cargo_metadata::semver::Version;
764
765 #[test]
766 fn test_sanitize_version_prefix() {
767 let version = sanitize_version_prefix("v1.2.3");
768 assert_eq!(version, "1.2.3");
769 assert!(Version::parse(&version).is_ok());
770 let version = sanitize_version_prefix("version_1.2.3");
771 assert_eq!(version, "1.2.3");
772 assert!(Version::parse(&version).is_ok());
773 }
774
775 #[test]
776 fn test_normalize_version() {
777 let version = normalize_version("version_112");
778 assert!(version.is_some_and(|v| { v.major == 112 && v.minor == 0 && v.patch == 0 }));
779
780 let version = normalize_version("v3.3.3");
781 assert!(version.is_some_and(|v| { v.major == 3 && v.minor == 3 && v.patch == 3 }));
782
783 let version = normalize_version("10.0.0");
784 assert!(version.is_some_and(|v| { v.major == 10 && v.minor == 0 && v.patch == 0 }));
785 }
786
787 #[test]
788 fn test_incomplete_version_strings() {
789 let version = normalize_version("5");
790 assert!(version.is_some_and(|v| { v.major == 5 && v.minor == 0 && v.patch == 0 }));
791
792 let version = normalize_version("0.2");
793 assert!(version.is_some_and(|v| { v.major == 0 && v.minor == 2 && v.patch == 0 }));
794 }
795
796 #[test]
797 fn test_invalid_versions() {
798 let version = normalize_version("1a-test");
799 assert_eq!(version, None);
800 }
801}