fhir-diff 0.1.2

Semantic diff of FHIR packages and profiles with a breaking-change gate
Documentation
//! FHIR packages as the diff reads them: a folder (an unpacked package, or its `package`
//! subfolder) or `name@version`, installed from the package registry into the local package
//! store when it is not there yet.

#[cfg(feature = "registry")]
use std::collections::VecDeque;
use std::collections::{BTreeMap, BTreeSet};
use std::path::{Path, PathBuf};

#[cfg(feature = "registry")]
use octofhir_canonical_manager::{CanonicalManager, FcmConfig};
use serde_json::Value;

/// A StructureDefinition as found in a package.
#[derive(Debug, Clone)]
pub struct Definition {
    /// File name inside the package (`StructureDefinition-us-core-patient.json`).
    pub file: String,
    pub json: Value,
}

/// The StructureDefinitions of a package and what the package says about itself.
#[derive(Debug, Clone, Default)]
pub struct Package {
    pub name: String,
    pub version: String,
    /// `fhirVersions` of the manifest (`4.0.1`).
    pub fhir_versions: Vec<String>,
    /// `dependencies` of the manifest: name → version.
    pub dependencies: BTreeMap<String, String>,
    pub definitions: Vec<Definition>,
}

impl Package {
    /// `name@version`.
    pub fn id(&self) -> String {
        format!("{}@{}", self.name, self.version)
    }
}

/// A package and the packages it depends on (core packages excepted: their definitions are
/// built in), whose definitions profiles may be based on.
#[derive(Debug, Clone)]
pub struct Loaded {
    pub package: Package,
    pub dependencies: Vec<Package>,
}

/// Core packages: their StructureDefinitions come from the built-in FHIR Schemas.
pub fn is_core(name: &str) -> bool {
    name.starts_with("hl7.fhir.r") && name.ends_with(".core")
}

/// Packages of value sets and code systems, which no profile is based on; some are very large
/// (VSAC), so they are never read for bases.
pub fn holds_no_profiles(name: &str) -> bool {
    name.starts_with("hl7.terminology")
        || matches!(
            name,
            "us.nlm.vsac" | "us.cdc.phinvads" | "ihe.formatcode.fhir"
        )
}

/// The dependencies of `package` that may hold a base: neither core nor terminology packages.
pub fn base_dependencies(package: &Package) -> impl Iterator<Item = (&str, &str)> {
    package
        .dependencies
        .iter()
        .filter(|(name, _)| !is_core(name) && !holds_no_profiles(name))
        .map(|(name, version)| (name.as_str(), version.as_str()))
}

/// The base definitions `packages` use but none of them defines, core bases excepted.
pub fn missing_bases<'a>(
    packages: impl IntoIterator<Item = &'a Package> + Clone,
) -> BTreeSet<String> {
    let defined: BTreeSet<&str> = packages
        .clone()
        .into_iter()
        .flat_map(|p| &p.definitions)
        .filter_map(|d| d.json["url"].as_str())
        .collect();
    packages
        .into_iter()
        .flat_map(|p| &p.definitions)
        .filter_map(|d| d.json["baseDefinition"].as_str())
        .map(|url| url.split('|').next().unwrap_or(url))
        .filter(|url| !url.starts_with(CORE_PREFIX) && !defined.contains(url))
        .map(str::to_owned)
        .collect()
}

const CORE_PREFIX: &str = "http://hl7.org/fhir/StructureDefinition/";

/// Load `spec`: a folder, or `name@version` (installed with its dependencies when missing; needs
/// the `registry` feature).
pub fn load(spec: &str) -> Result<Loaded, String> {
    let path = Path::new(spec);
    if path.is_dir() {
        let package = read_folder(&package_folder(path))?;
        return Ok(Loaded {
            package,
            dependencies: Vec::new(),
        });
    }
    let Some((name, version)) = spec.split_once('@') else {
        return Err(format!("`{spec}` is neither a folder nor name@version"));
    };
    from_registry(name, version)
}

#[cfg(not(feature = "registry"))]
fn from_registry(name: &str, version: &str) -> Result<Loaded, String> {
    Err(format!(
        "{name}@{version}: loading from the package registry needs the `registry` feature"
    ))
}

/// `name@version` from the local package store, installed from the registry with its
/// dependencies when missing.
#[cfg(feature = "registry")]
fn from_registry(name: &str, version: &str) -> Result<Loaded, String> {
    let runtime = tokio::runtime::Builder::new_current_thread()
        .enable_all()
        .build()
        .map_err(|e| format!("cannot start the runtime: {e}"))?;
    runtime.block_on(async {
        let config = FcmConfig::load().await.map_err(|e| e.to_string())?;
        let manager = CanonicalManager::new(config)
            .await
            .map_err(|e| e.to_string())?;
        let folder = installed_folder(&manager, name, version).await?;
        let package = read_folder(&folder)?;
        // Dependencies, transitively, only while a base is still missing.
        let mut dependencies: Vec<Package> = Vec::new();
        let mut seen = BTreeSet::from([package.id()]);
        let mut queue: VecDeque<(String, String)> = base_dependencies(&package)
            .map(|(n, v)| (n.to_owned(), v.to_owned()))
            .collect();
        while !missing_bases(std::iter::once(&package).chain(&dependencies)).is_empty() {
            let Some((dep_name, dep_version)) = queue.pop_front() else {
                break;
            };
            if !seen.insert(format!("{dep_name}@{dep_version}")) {
                continue;
            }
            // A dependency that cannot be installed only costs the profiles based on it.
            match installed_folder(&manager, &dep_name, &dep_version).await {
                Ok(folder) => {
                    let dependency = read_folder(&folder)?;
                    queue.extend(
                        base_dependencies(&dependency).map(|(n, v)| (n.to_owned(), v.to_owned())),
                    );
                    dependencies.push(dependency);
                }
                Err(error) => eprintln!("warning: {dep_name}@{dep_version}: {error}"),
            }
        }
        Ok(Loaded {
            package,
            dependencies,
        })
    })
}

#[cfg(feature = "registry")]
async fn installed_folder(
    manager: &CanonicalManager,
    name: &str,
    version: &str,
) -> Result<PathBuf, String> {
    let spec = format!("{name}@{version}");
    let installed = manager.list_packages().await.map_err(|e| e.to_string())?;
    if !installed.iter().any(|p| p == &spec) {
        manager
            .install_package(name, version)
            .await
            .map_err(|e| format!("cannot install {spec}: {e}"))?;
    }
    let dir = manager
        .package_dir(name, version)
        .await
        .map_err(|e| format!("{spec}: {e}"))?;
    Ok(package_folder(&dir))
}

/// The folder holding the package's resources: `package/` when there is one.
fn package_folder(path: &Path) -> PathBuf {
    let inner = path.join("package");
    if inner.is_dir() {
        inner
    } else {
        path.to_path_buf()
    }
}

/// The manifest and StructureDefinitions of an unpacked package.
pub fn read_folder(folder: &Path) -> Result<Package, String> {
    let manifest: Value = std::fs::read_to_string(folder.join("package.json"))
        .ok()
        .and_then(|text| serde_json::from_str(&text).ok())
        .unwrap_or(Value::Null);
    let text = |value: &Value| value.as_str().unwrap_or_default().to_owned();
    let mut package = Package {
        name: text(&manifest["name"]),
        version: text(&manifest["version"]),
        fhir_versions: manifest["fhirVersions"]
            .as_array()
            .into_iter()
            .flatten()
            .filter_map(|v| v.as_str().map(str::to_owned))
            .collect(),
        dependencies: manifest["dependencies"]
            .as_object()
            .into_iter()
            .flatten()
            .filter_map(|(name, version)| Some((name.clone(), version.as_str()?.to_owned())))
            .collect(),
        definitions: Vec::new(),
    };
    if package.name.is_empty() {
        package.name = folder
            .file_name()
            .map(|name| name.to_string_lossy().into_owned())
            .unwrap_or_default();
    }
    let mut entries: Vec<PathBuf> = std::fs::read_dir(folder)
        .map_err(|e| format!("{}: {e}", folder.display()))?
        .filter_map(|entry| entry.ok().map(|entry| entry.path()))
        .filter(|path| path.extension().is_some_and(|e| e == "json"))
        .collect();
    entries.sort();
    for path in entries {
        let Ok(text) = std::fs::read_to_string(&path) else {
            continue;
        };
        // Most files are examples, value sets and code systems: parse only what may be a
        // definition.
        if !text.contains("\"StructureDefinition\"") {
            continue;
        }
        let Ok(json) = serde_json::from_str::<Value>(&text) else {
            continue;
        };
        if json["resourceType"] == "StructureDefinition" {
            package.definitions.push(Definition {
                file: path
                    .file_name()
                    .map(|name| name.to_string_lossy().into_owned())
                    .unwrap_or_default(),
                json,
            });
        }
    }
    Ok(package)
}