tlq_fhir_package/
lib.rs

1//! Canonical models for the FHIR NPM Package specification.
2//!
3//! Provides serde-friendly representations of `package.json` manifests and
4//! `.index.json` files with support for extension fields.
5
6use 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
20/// Validate version string format per FHIR Package specification.
21///
22/// Versions must contain only alphanumeric characters, '.', '_', and '-'.
23/// Numeric versions (starting with a digit) must follow SemVer format.
24pub 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
61/// Parse version into base and optional label (e.g., "1.2.3-release" → ("1.2.3", Some("release"))).
62pub 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
70/// Compare versions numerically if both start with digits, otherwise lexicographically. Labels ignored.
71pub 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
101/// Check if version matches reference (supports exact match, patch wildcards like "1.2.x", and label variants).
102pub 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/// FHIR NPM Package manifest (`package/package.json`).
181#[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    /// Validate manifest (checks required fields, optionally validates version formats in strict mode).
217    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    /// Check if package has a core FHIR package dependency.
247    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/// Package index (`.index.json`).
255#[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/// File entry in package index.
265#[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/// Loaded FHIR package with manifest, optional index, and resources.
305///
306/// Resources are automatically indexed by ID, canonical URL, and type for fast lookups.
307#[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    // Indexed resources for fast lookups
315    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    /// Create a new FHIR package from manifest and resources.
322    ///
323    /// Indices are built automatically for fast lookups.
324    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    /// Load package from tar.gz reader.
340    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    /// Load package from tar.gz bytes.
388    pub fn from_tar_gz_bytes(bytes: &[u8]) -> PackageResult<Self> {
389        Self::from_tar_gz(std::io::Cursor::new(bytes))
390    }
391
392    /// Load package from directory.
393    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    /// Build indices from resources for fast lookups
473    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    /// Index a single resource by ID, URL, and type
486    fn index_resource(&mut self, resource: Value) {
487        if let Some(resource_type) = resource.get("resourceType").and_then(Value::as_str) {
488            // Index by type
489            self.resources_by_type
490                .entry(resource_type.to_string())
491                .or_default()
492                .push(resource.clone());
493
494            // Index by ID
495            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            // Index by canonical URL
501            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        // Unknown fields from the manifest should be preserved
655        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        // Verify manifest
669        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        // Verify index is loaded
674        assert!(package.index.is_some());
675        let index = package.index.as_ref().unwrap();
676        assert_eq!(index.index_version, 1);
677        // Note: index.files may be empty if the package doesn't populate it
678
679        // Verify resources are loaded (should have StructureDefinition-Definition.json)
680        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        // Verify examples are separate (should be empty for this test package)
688        // Examples would be in package/examples/ folder if present
689        assert_eq!(package.examples.len(), 0);
690
691        // Verify we can get resources by type
692        let (conformance, examples) = package.resources_by_type("StructureDefinition");
693        assert!(!conformance.is_empty());
694        assert_eq!(examples.len(), 0);
695
696        // Verify separation of examples from non-examples
697        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        // Valid versions
705        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()); // Non-numeric can use underscores
712        // Versions starting with digits must use dots for SemVer format
713        assert!(validate_version_format("1_2_3").is_err()); // Should use dots, not underscores
714
715        // Invalid versions
716        assert!(validate_version_format("").is_err());
717        assert!(validate_version_format("1.2.3@beta").is_err()); // @ not allowed
718        assert!(validate_version_format("1.2.3+metadata").is_err()); // + not allowed
719        assert!(validate_version_format("1.2.3 ").is_err()); // space not allowed
720    }
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        // Numeric comparison
740        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        // Labels are ignored
747        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        // Exact matches
754        assert!(version_matches("1.2.3", "1.2.3"));
755        assert!(version_matches("1.2.3-release", "1.2.3-release"));
756
757        // Patch wildcard
758        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        // Label preference (version without label matches reference without label)
765        assert!(version_matches("1.2.3", "1.2.3"));
766        assert!(version_matches("1.2.3-release", "1.2.3")); // Labeled version matches unlabeled reference
767    }
768}