1use flate2::read::GzDecoder;
7use serde::{Deserialize, Serialize};
8use serde_json::{Map, Value};
9use std::collections::HashMap;
10use std::fs;
11use std::io::Read;
12use std::path::Path;
13use tar::Archive;
14use thiserror::Error;
15
16pub type PackageName = String;
17pub type Version = String;
18pub type VersionReference = String;
19
20pub fn validate_version_format(version: &str) -> Result<(), PackageError> {
25 if version.is_empty() {
26 return Err(PackageError::ValidationError(
27 "Version cannot be empty".into(),
28 ));
29 }
30
31 let allowed = |c: char| c.is_alphanumeric() || matches!(c, '.' | '_' | '-');
32 if !version.chars().all(allowed) {
33 return Err(PackageError::ValidationError(format!(
34 "Version '{}' contains invalid characters",
35 version
36 )));
37 }
38
39 if version.chars().next().is_some_and(|c| c.is_ascii_digit()) {
40 let base = version.split('-').next().unwrap_or(version);
41 let parts: Vec<&str> = base.split('.').collect();
42
43 if parts.len() < 2 {
44 return Err(PackageError::ValidationError(format!(
45 "Numeric version '{}' must follow SemVer format (e.g., '1.2.3')",
46 version
47 )));
48 }
49
50 if !parts.iter().all(|p| p.chars().all(|c| c.is_ascii_digit())) {
51 return Err(PackageError::ValidationError(format!(
52 "Version '{}' has non-numeric parts",
53 version
54 )));
55 }
56 }
57
58 Ok(())
59}
60
61pub fn parse_version(version: &str) -> (String, Option<String>) {
63 if let Some((base, label)) = version.split_once('-') {
64 (base.to_string(), Some(label.to_string()))
65 } else {
66 (version.to_string(), None)
67 }
68}
69
70pub fn compare_versions(v1: &str, v2: &str) -> std::cmp::Ordering {
72 let (base1, _) = parse_version(v1);
73 let (base2, _) = parse_version(v2);
74
75 let is_numeric = |s: &str| s.chars().next().is_some_and(|c| c.is_ascii_digit());
76
77 if is_numeric(&base1) && is_numeric(&base2) {
78 compare_numeric_versions(&base1, &base2)
79 } else {
80 base1.cmp(&base2)
81 }
82}
83
84fn compare_numeric_versions(v1: &str, v2: &str) -> std::cmp::Ordering {
85 let parts1: Vec<u32> = v1.split('.').filter_map(|p| p.parse().ok()).collect();
86 let parts2: Vec<u32> = v2.split('.').filter_map(|p| p.parse().ok()).collect();
87
88 let max_len = parts1.len().max(parts2.len());
89 for i in 0..max_len {
90 let p1 = parts1.get(i).copied().unwrap_or(0);
91 let p2 = parts2.get(i).copied().unwrap_or(0);
92 match p1.cmp(&p2) {
93 std::cmp::Ordering::Equal => continue,
94 other => return other,
95 }
96 }
97
98 std::cmp::Ordering::Equal
99}
100
101pub fn version_matches(version: &str, reference: &str) -> bool {
103 if version == reference {
104 return true;
105 }
106
107 if let Some(prefix) = reference.strip_suffix(".x") {
108 if let Some(suffix) = version.strip_prefix(&format!("{}.", prefix)) {
109 let (patch, _) = parse_version(suffix);
110 return patch.parse::<u32>().is_ok();
111 }
112 return false;
113 }
114
115 let (base_version, _) = parse_version(version);
116 let (base_reference, _) = parse_version(reference);
117 base_version == base_reference
118}
119
120pub type Url = String;
121
122#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
123pub struct Maintainer {
124 pub name: String,
125 #[serde(default, skip_serializing_if = "Option::is_none")]
126 pub email: Option<String>,
127 #[serde(default, skip_serializing_if = "Option::is_none")]
128 pub url: Option<String>,
129}
130
131#[derive(Debug, Clone, PartialEq, Eq)]
132pub enum PackageType {
133 Conformance,
134 Ig,
135 Core,
136 Examples,
137 Group,
138 Tool,
139 IgTemplate,
140 Unknown(String),
141}
142
143impl Serialize for PackageType {
144 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
145 where
146 S: serde::Serializer,
147 {
148 match self {
149 PackageType::Conformance => serializer.serialize_str("Conformance"),
150 PackageType::Ig => serializer.serialize_str("IG"),
151 PackageType::Core => serializer.serialize_str("Core"),
152 PackageType::Examples => serializer.serialize_str("Examples"),
153 PackageType::Group => serializer.serialize_str("Group"),
154 PackageType::Tool => serializer.serialize_str("Tool"),
155 PackageType::IgTemplate => serializer.serialize_str("IG-Template"),
156 PackageType::Unknown(s) => serializer.serialize_str(s),
157 }
158 }
159}
160
161impl<'de> Deserialize<'de> for PackageType {
162 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
163 where
164 D: serde::Deserializer<'de>,
165 {
166 let s = String::deserialize(deserializer)?;
167 Ok(match s.as_str() {
168 "Conformance" => PackageType::Conformance,
169 "IG" => PackageType::Ig,
170 "Core" => PackageType::Core,
171 "Examples" => PackageType::Examples,
172 "Group" => PackageType::Group,
173 "Tool" | "fhir.tool" => PackageType::Tool,
174 "IG-Template" => PackageType::IgTemplate,
175 _ => PackageType::Unknown(s),
176 })
177 }
178}
179
180#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
182#[serde(rename_all = "camelCase")]
183pub struct PackageManifest {
184 pub name: PackageName,
185 pub version: Version,
186 #[serde(default, skip_serializing_if = "Option::is_none")]
187 pub canonical: Option<Url>,
188 #[serde(default, skip_serializing_if = "Option::is_none")]
189 pub url: Option<Url>,
190 #[serde(default, skip_serializing_if = "Option::is_none")]
191 pub homepage: Option<Url>,
192 #[serde(default, skip_serializing_if = "Option::is_none")]
193 pub title: Option<String>,
194 #[serde(default)]
195 pub description: String,
196 #[serde(default, skip_serializing_if = "Vec::is_empty")]
197 pub fhir_versions: Vec<String>,
198 #[serde(default)]
199 pub dependencies: HashMap<PackageName, VersionReference>,
200 #[serde(default, skip_serializing_if = "Vec::is_empty")]
201 pub keywords: Vec<String>,
202 pub author: String,
203 #[serde(default, skip_serializing_if = "Vec::is_empty")]
204 pub maintainers: Vec<Maintainer>,
205 #[serde(rename = "type", default, skip_serializing_if = "Option::is_none")]
206 pub package_type: Option<PackageType>,
207 #[serde(default, skip_serializing_if = "Option::is_none")]
208 pub jurisdiction: Option<String>,
209 #[serde(default, skip_serializing_if = "Option::is_none")]
210 pub license: Option<String>,
211 #[serde(flatten, default, skip_serializing_if = "Map::is_empty")]
212 pub extra: Map<String, Value>,
213}
214
215impl PackageManifest {
216 pub fn validate(&self, strict: bool) -> Result<(), PackageError> {
218 if self.name.is_empty() {
219 return Err(PackageError::ValidationError(
220 "Package name required".into(),
221 ));
222 }
223 if self.version.is_empty() {
224 return Err(PackageError::ValidationError(
225 "Package version required".into(),
226 ));
227 }
228 if self.author.is_empty() {
229 return Err(PackageError::ValidationError(
230 "Package author required".into(),
231 ));
232 }
233
234 if strict {
235 validate_version_format(&self.version)?;
236
237 for dep_version in self.dependencies.values() {
238 let version_to_validate = dep_version.strip_suffix(".x").unwrap_or(dep_version);
239 validate_version_format(version_to_validate)?;
240 }
241 }
242
243 Ok(())
244 }
245
246 pub fn has_core_dependency(&self) -> bool {
248 self.dependencies.keys().any(|name| {
249 name == "hl7.fhir.core" || (name.starts_with("hl7.fhir.r") && name.ends_with(".core"))
250 })
251 }
252}
253
254#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
256pub struct PackageIndex {
257 #[serde(rename = "index-version")]
258 pub index_version: u8,
259 pub files: Vec<IndexedFile>,
260 #[serde(flatten, default, skip_serializing_if = "Map::is_empty")]
261 pub extra: Map<String, Value>,
262}
263
264#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
266pub struct IndexedFile {
267 pub filename: String,
268 #[serde(rename = "resourceType")]
269 pub resource_type: String,
270 #[serde(default, skip_serializing_if = "Option::is_none")]
271 pub id: Option<String>,
272 #[serde(default, skip_serializing_if = "Option::is_none")]
273 pub url: Option<String>,
274 #[serde(default, skip_serializing_if = "Option::is_none")]
275 pub version: Option<String>,
276 #[serde(default, skip_serializing_if = "Option::is_none")]
277 pub kind: Option<String>,
278 #[serde(default, skip_serializing_if = "Option::is_none")]
279 pub r#type: Option<String>,
280 #[serde(default, skip_serializing_if = "Option::is_none")]
281 pub supplements: Option<String>,
282 #[serde(default, skip_serializing_if = "Option::is_none")]
283 pub content: Option<String>,
284 #[serde(flatten, default, skip_serializing_if = "Map::is_empty")]
285 pub extra: Map<String, Value>,
286}
287
288#[derive(Debug, Error)]
289pub enum PackageError {
290 #[error("IO error: {0}")]
291 Io(#[from] std::io::Error),
292 #[error("JSON error: {0}")]
293 Json(#[from] serde_json::Error),
294 #[error("Invalid structure: {0}")]
295 InvalidStructure(String),
296 #[error("Missing file: {0}")]
297 MissingFile(String),
298 #[error("Validation error: {0}")]
299 ValidationError(String),
300}
301
302pub type PackageResult<T> = Result<T, PackageError>;
303
304#[derive(Debug, Clone)]
308pub struct FhirPackage {
309 pub manifest: PackageManifest,
310 pub index: Option<PackageIndex>,
311 pub resources: Vec<Value>,
312 pub examples: Vec<Value>,
313
314 resources_by_id: HashMap<String, Value>,
316 resources_by_url: HashMap<String, Value>,
317 resources_by_type: HashMap<String, Vec<Value>>,
318}
319
320impl FhirPackage {
321 pub fn new(manifest: PackageManifest, resources: Vec<Value>, examples: Vec<Value>) -> Self {
325 let mut package = Self {
326 manifest,
327 index: None,
328 resources,
329 examples,
330 resources_by_id: HashMap::new(),
331 resources_by_url: HashMap::new(),
332 resources_by_type: HashMap::new(),
333 };
334
335 package.build_indices();
336 package
337 }
338
339 pub fn from_tar_gz<R: Read>(mut reader: R) -> PackageResult<Self> {
341 let mut decoder = GzDecoder::new(&mut reader);
342 let mut decompressed = Vec::new();
343 decoder.read_to_end(&mut decompressed)?;
344
345 let mut archive = Archive::new(std::io::Cursor::new(decompressed));
346 let mut file_map: HashMap<String, Vec<u8>> = HashMap::new();
347
348 for entry in archive.entries()? {
349 let mut entry = entry?;
350 let path = entry.path()?.to_string_lossy().to_string();
351 let mut contents = Vec::new();
352 entry.read_to_end(&mut contents)?;
353 file_map.insert(path, contents);
354 }
355
356 let manifest_path = "package/package.json";
357 let manifest = file_map
358 .get(manifest_path)
359 .ok_or_else(|| PackageError::MissingFile(manifest_path.to_string()))
360 .and_then(|bytes| Self::parse_json::<PackageManifest>(bytes))?;
361
362 let index = file_map
363 .get("package/.index.json")
364 .and_then(|bytes| Self::parse_json::<PackageIndex>(bytes).ok());
365
366 let resources = Self::load_resources_from_map(
367 &file_map,
368 "package/",
369 &[manifest_path, "package/.index.json"],
370 )?;
371 let examples = Self::load_resources_from_map(&file_map, "package/examples/", &[])?;
372
373 let mut package = Self {
374 manifest,
375 index,
376 resources,
377 examples,
378 resources_by_id: HashMap::new(),
379 resources_by_url: HashMap::new(),
380 resources_by_type: HashMap::new(),
381 };
382
383 package.build_indices();
384 Ok(package)
385 }
386
387 pub fn from_tar_gz_bytes(bytes: &[u8]) -> PackageResult<Self> {
389 Self::from_tar_gz(std::io::Cursor::new(bytes))
390 }
391
392 pub fn from_directory(package_dir: &Path) -> PackageResult<Self> {
394 let manifest_path = package_dir.join("package.json");
395 if !manifest_path.exists() {
396 return Err(PackageError::MissingFile(
397 manifest_path.to_string_lossy().into(),
398 ));
399 }
400
401 let manifest = Self::parse_json::<PackageManifest>(&fs::read(manifest_path)?)?;
402
403 let index = package_dir
404 .join(".index.json")
405 .exists()
406 .then(|| package_dir.join(".index.json"))
407 .and_then(|p| fs::read(p).ok())
408 .and_then(|bytes| Self::parse_json::<PackageIndex>(&bytes).ok());
409
410 let resources =
411 Self::load_resources_from_dir(package_dir, &["package.json", ".index.json"])?;
412 let examples = package_dir
413 .join("examples")
414 .exists()
415 .then(|| Self::load_resources_from_dir(&package_dir.join("examples"), &[]))
416 .transpose()?
417 .unwrap_or_default();
418
419 let mut package = Self {
420 manifest,
421 index,
422 resources,
423 examples,
424 resources_by_id: HashMap::new(),
425 resources_by_url: HashMap::new(),
426 resources_by_type: HashMap::new(),
427 };
428
429 package.build_indices();
430 Ok(package)
431 }
432
433 pub fn all_resources(&self) -> (&[Value], &[Value]) {
434 (&self.resources, &self.examples)
435 }
436
437 pub fn conformance_resources(&self) -> &[Value] {
438 &self.resources
439 }
440
441 pub fn example_resources(&self) -> &[Value] {
442 &self.examples
443 }
444
445 pub fn all_resources_combined(&self) -> Vec<&Value> {
446 self.resources.iter().chain(self.examples.iter()).collect()
447 }
448
449 pub fn resources_by_type(&self, resource_type: &str) -> (Vec<&Value>, Vec<&Value>) {
450 let filter =
451 |r: &&Value| r.get("resourceType").and_then(Value::as_str) == Some(resource_type);
452 (
453 self.resources.iter().filter(filter).collect(),
454 self.examples.iter().filter(filter).collect(),
455 )
456 }
457
458 pub fn resource_by_id(&self, id: &str) -> Option<&Value> {
459 self.resources_by_id.get(id)
460 }
461
462 pub fn resource_by_url(&self, url: &str) -> Option<&Value> {
463 self.resources_by_url.get(url)
464 }
465
466 pub fn resources_of_type(&self, resource_type: &str) -> Option<&[Value]> {
467 self.resources_by_type
468 .get(resource_type)
469 .map(|v| v.as_slice())
470 }
471
472 fn build_indices(&mut self) {
474 let resources: Vec<Value> = self.resources.clone();
475 let examples: Vec<Value> = self.examples.clone();
476
477 for resource in resources {
478 self.index_resource(resource);
479 }
480 for resource in examples {
481 self.index_resource(resource);
482 }
483 }
484
485 fn index_resource(&mut self, resource: Value) {
487 if let Some(resource_type) = resource.get("resourceType").and_then(Value::as_str) {
488 self.resources_by_type
490 .entry(resource_type.to_string())
491 .or_default()
492 .push(resource.clone());
493
494 if let Some(id) = resource.get("id").and_then(Value::as_str) {
496 self.resources_by_id
497 .insert(id.to_string(), resource.clone());
498 }
499
500 if let Some(url) = resource.get("url").and_then(Value::as_str) {
502 self.resources_by_url.insert(url.to_string(), resource);
503 }
504 }
505 }
506
507 fn parse_json<T: serde::de::DeserializeOwned>(bytes: &[u8]) -> PackageResult<T> {
508 let cleaned = Self::clean_bytes(bytes)?;
509 Ok(serde_json::from_str(&cleaned)?)
510 }
511
512 fn load_resources_from_map(
513 file_map: &HashMap<String, Vec<u8>>,
514 prefix: &str,
515 exclude: &[&str],
516 ) -> PackageResult<Vec<Value>> {
517 file_map
518 .iter()
519 .filter(|(path, _)| {
520 path.starts_with(prefix)
521 && path.ends_with(".json")
522 && !exclude.contains(&path.as_str())
523 })
524 .map(|(_, contents)| Self::parse_json(contents))
525 .collect()
526 }
527
528 fn load_resources_from_dir(dir: &Path, exclude: &[&str]) -> PackageResult<Vec<Value>> {
529 let mut resources = Vec::new();
530 for entry in fs::read_dir(dir)? {
531 let path = entry?.path();
532 if path.extension() == Some("json".as_ref()) {
533 if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
534 if !exclude.contains(&name) {
535 resources.push(Self::parse_json(&fs::read(&path)?)?);
536 }
537 }
538 }
539 }
540 Ok(resources)
541 }
542
543 fn clean_bytes(bytes: &[u8]) -> PackageResult<String> {
544 let bytes = if bytes.len() >= 3 && &bytes[..3] == b"\xEF\xBB\xBF" {
545 &bytes[3..]
546 } else {
547 bytes
548 };
549
550 let content = String::from_utf8(bytes.to_vec())
551 .map_err(|e| PackageError::InvalidStructure(format!("Invalid UTF-8: {}", e)))?;
552
553 Ok(content
554 .chars()
555 .filter(|&c| matches!(c, '\t' | '\n' | '\r') || (c >= ' ' && c != '\x7F'))
556 .collect::<String>()
557 .trim()
558 .to_string())
559 }
560}
561
562#[cfg(test)]
563mod tests {
564 use super::*;
565 use serde_json::json;
566
567 #[test]
568 fn manifest_matches_spec_example() {
569 let manifest_json = json!({
570 "name": "hl7.fhir.us.acme",
571 "version": "0.1.0",
572 "canonical": "http://hl7.org/fhir/us/acme",
573 "url": "http://hl7.org/fhir/us/acme/Draft1",
574 "title": "ACME project IG",
575 "description": "Describes how the ACME project uses FHIR for it's primary API",
576 "fhirVersions": ["3.0.0"],
577 "dependencies": {
578 "hl7.fhir.core": "3.0.0",
579 "hl7.fhir.us.core": "1.1.0"
580 },
581 "keywords": ["us", "United States", "ACME"],
582 "author": "hl7",
583 "maintainers": [
584 { "name": "US Steering Committee", "email": "ussc@lists.hl7.com" }
585 ],
586 "jurisdiction": "http://unstats.un.org/unsd/methods/m49/m49.htm#001",
587 "license": "CC0-1.0"
588 });
589
590 let manifest: PackageManifest =
591 serde_json::from_value(manifest_json.clone()).expect("deserializes");
592
593 assert_eq!(manifest.name, "hl7.fhir.us.acme");
594 assert_eq!(manifest.version, "0.1.0");
595 assert_eq!(
596 manifest.description,
597 manifest_json["description"].as_str().unwrap()
598 );
599 assert_eq!(
600 manifest.dependencies.get("hl7.fhir.core"),
601 Some(&"3.0.0".to_string())
602 );
603
604 let round_trip = serde_json::to_value(&manifest).expect("serializes");
605 assert_eq!(round_trip["name"], manifest_json["name"]);
606 assert_eq!(round_trip["version"], manifest_json["version"]);
607 assert_eq!(round_trip["dependencies"], manifest_json["dependencies"]);
608 }
609
610 #[test]
611 fn index_round_trips() {
612 let index_json = json!({
613 "index-version": 1,
614 "files": [
615 {
616 "filename": "StructureDefinition-patient.json",
617 "resourceType": "StructureDefinition",
618 "id": "patient",
619 "url": "http://hl7.org/fhir/StructureDefinition/Patient",
620 "version": "5.0.0",
621 "kind": "resource",
622 "type": "Patient"
623 }
624 ]
625 });
626
627 let index: PackageIndex = serde_json::from_value(index_json.clone()).expect("deserializes");
628
629 assert_eq!(index.index_version, 1);
630 assert_eq!(index.files.len(), 1);
631 assert_eq!(index.files[0].resource_type, "StructureDefinition");
632
633 let round_trip = serde_json::to_value(&index).expect("serializes");
634 assert_eq!(round_trip, index_json);
635 }
636
637 #[test]
638 fn manifest_from_submodule_case_new_format() {
639 let raw = include_str!(concat!(
640 env!("CARGO_MANIFEST_DIR"),
641 "/../../fhir-test-cases/npm/test.format.new/package/package.json"
642 ));
643 let raw = raw.trim_start_matches('\u{feff}');
644 let manifest: PackageManifest =
645 serde_json::from_str(raw).expect("deserializes case manifest");
646
647 assert_eq!(manifest.name, "hl7.fhir.pubpack");
648 assert_eq!(manifest.version, "0.0.2");
649 assert_eq!(manifest.package_type, Some(PackageType::Tool));
650 assert_eq!(manifest.fhir_versions, vec!["4.1".to_string()]);
651 assert_eq!(manifest.author, "FHIR Project");
652 assert_eq!(manifest.license.as_deref(), Some("CC0-1.0"));
653
654 assert_eq!(manifest.extra.get("tools-version"), Some(&Value::from(3)));
656 }
657
658 #[test]
659 fn load_package_from_tar_gz() {
660 let tar_gz_bytes = include_bytes!(concat!(
661 env!("CARGO_MANIFEST_DIR"),
662 "/../../fhir-test-cases/npm/test.format.new.tgz"
663 ));
664
665 let package =
666 FhirPackage::from_tar_gz_bytes(tar_gz_bytes).expect("should load package from tar.gz");
667
668 assert_eq!(package.manifest.name, "hl7.fhir.pubpack");
670 assert_eq!(package.manifest.version, "0.0.2");
671 assert_eq!(package.manifest.package_type, Some(PackageType::Tool));
672
673 assert!(package.index.is_some());
675 let index = package.index.as_ref().unwrap();
676 assert_eq!(index.index_version, 1);
677 assert!(!package.resources.is_empty());
681 let has_structure_def = package
682 .resources
683 .iter()
684 .any(|r| r.get("resourceType").and_then(|v| v.as_str()) == Some("StructureDefinition"));
685 assert!(has_structure_def);
686
687 assert_eq!(package.examples.len(), 0);
690
691 let (conformance, examples) = package.resources_by_type("StructureDefinition");
693 assert!(!conformance.is_empty());
694 assert_eq!(examples.len(), 0);
695
696 let (resources, examples) = package.all_resources();
698 assert!(!resources.is_empty());
699 assert_eq!(examples.len(), 0);
700 }
701
702 #[test]
703 fn test_validate_version_format() {
704 assert!(validate_version_format("1.2.3").is_ok());
706 assert!(validate_version_format("1.2.3-release").is_ok());
707 assert!(validate_version_format("1.2").is_ok());
708 assert!(validate_version_format("0.1.0").is_ok());
709 assert!(validate_version_format("5.0.0-ballot").is_ok());
710 assert!(validate_version_format("abc.def").is_ok());
711 assert!(validate_version_format("abc_def").is_ok()); assert!(validate_version_format("1_2_3").is_err()); assert!(validate_version_format("").is_err());
717 assert!(validate_version_format("1.2.3@beta").is_err()); assert!(validate_version_format("1.2.3+metadata").is_err()); assert!(validate_version_format("1.2.3 ").is_err()); }
721
722 #[test]
723 fn test_parse_version() {
724 assert_eq!(parse_version("1.2.3"), ("1.2.3".to_string(), None));
725 assert_eq!(
726 parse_version("1.2.3-release"),
727 ("1.2.3".to_string(), Some("release".to_string()))
728 );
729 assert_eq!(
730 parse_version("5.0.0-ballot"),
731 ("5.0.0".to_string(), Some("ballot".to_string()))
732 );
733 }
734
735 #[test]
736 fn test_compare_versions() {
737 use std::cmp::Ordering;
738
739 assert_eq!(compare_versions("1.2.3", "1.2.4"), Ordering::Less);
741 assert_eq!(compare_versions("1.2.4", "1.2.3"), Ordering::Greater);
742 assert_eq!(compare_versions("1.2.3", "1.2.3"), Ordering::Equal);
743 assert_eq!(compare_versions("1.2.3", "1.3.0"), Ordering::Less);
744 assert_eq!(compare_versions("2.0.0", "1.9.9"), Ordering::Greater);
745
746 assert_eq!(compare_versions("1.2.3", "1.2.3-release"), Ordering::Equal);
748 assert_eq!(compare_versions("1.2.3-ballot", "1.2.4"), Ordering::Less);
749 }
750
751 #[test]
752 fn test_version_matches() {
753 assert!(version_matches("1.2.3", "1.2.3"));
755 assert!(version_matches("1.2.3-release", "1.2.3-release"));
756
757 assert!(version_matches("1.2.0", "1.2.x"));
759 assert!(version_matches("1.2.1", "1.2.x"));
760 assert!(version_matches("1.2.99", "1.2.x"));
761 assert!(!version_matches("1.3.0", "1.2.x"));
762 assert!(!version_matches("2.0.0", "1.2.x"));
763
764 assert!(version_matches("1.2.3", "1.2.3"));
766 assert!(version_matches("1.2.3-release", "1.2.3")); }
768}