fhir-diff 0.1.1

Semantic diff of FHIR packages and profiles with a breaking-change gate
Documentation
//! The diff of two small packages (`tests/fixtures/{old,new}`): one profile changed in every
//! way a rule names, one removed, one added; and the three report formats.

use fhir_diff::{Filter, Pool, Report, Severity, Status, diff, load, report};

fn fixture() -> Report {
    let root = env!("CARGO_MANIFEST_DIR");
    let old = load(&format!("{root}/tests/fixtures/old")).expect("old package");
    let new = load(&format!("{root}/tests/fixtures/new")).expect("new package");
    diff(
        &old.package.id(),
        &Pool::new(&old),
        &new.package.id(),
        &Pool::new(&new),
        &Filter::default(),
    )
}

fn change<'a>(report: &'a Report, rule: &str, path: &str) -> Option<&'a fhir_diff::Change> {
    report
        .profiles
        .iter()
        .flat_map(|p| &p.changes)
        .find(|c| c.rule == rule && c.path == path)
}

#[test]
fn every_kind_of_change_is_classified() {
    let report = fixture();
    let expect = |rule: &str, path: &str, severity: Severity| {
        let found = change(&report, rule, path)
            .unwrap_or_else(|| panic!("{rule} at {path} in {:#?}", report.profiles));
        assert_eq!(found.severity, severity, "{rule} at {path}");
    };
    expect("min-increased", "Patient.identifier", Severity::Breaking);
    expect("max-decreased", "Patient.name", Severity::Breaking);
    expect("must-support-added", "Patient.name", Severity::Breaking);
    expect("binding-stronger", "Patient.gender", Severity::Breaking);
    expect("invariant-added", "Patient", Severity::Breaking);
    expect("type-removed", "Patient.deceased", Severity::Breaking);
    expect(
        "target-narrowed",
        "Patient.generalPractitioner",
        Severity::Warning,
    );
    expect(
        "target-removed",
        "Patient.generalPractitioner",
        Severity::Breaking,
    );
    // Unchanged elements say nothing.
    assert!(change(&report, "must-support-added", "Patient.identifier").is_none());
}

#[test]
fn profiles_are_matched_by_url() {
    let report = fixture();
    let status = |name: &str| {
        report
            .profiles
            .iter()
            .find(|p| p.name == name)
            .map(|p| p.status)
    };
    assert_eq!(status("ExamplePatient"), Some(Status::Changed));
    assert_eq!(status("Retired"), Some(Status::Removed));
    assert_eq!(status("ExamplePractitioner"), Some(Status::Added));
    assert_eq!(report.worst(), Some(Severity::Breaking));
    assert!(change(&report, "profile-removed", "Observation").is_some());
}

#[test]
fn reports_render() {
    let report = fixture();
    let markdown = report::markdown(&report, Severity::Info);
    assert!(markdown.starts_with("# fhir-diff: example.pkg@1.0.0 → example.pkg@2.0.0"));
    assert!(markdown.contains("| **breaking** | `min-increased` | `Patient.identifier` |"));

    let text = report::text(&report, Severity::Info);
    assert!(text.starts_with("fhir-diff example.pkg@1.0.0 → example.pkg@2.0.0\n"));
    assert!(
        text.lines().any(|l| l.contains("breaking")
            && l.contains("min-increased")
            && l.contains("Patient.identifier")),
        "{text}"
    );
    assert!(
        text.contains(" profiles: 1 added, 1 removed, 1 changed; "),
        "{text}"
    );

    let json: serde_json::Value = serde_json::from_str(&report::json(&report)).unwrap();
    assert_eq!(json["old"], "example.pkg@1.0.0");

    let sarif: serde_json::Value =
        serde_json::from_str(&report::sarif(&report, Severity::Breaking)).unwrap();
    assert_eq!(sarif["version"], "2.1.0");
    let results = sarif["runs"][0]["results"].as_array().unwrap();
    assert!(!results.is_empty());
    assert!(results.iter().all(|r| r["level"] == "error"));
    let located = &results[0]["locations"][0];
    assert!(
        located["physicalLocation"]["artifactLocation"]["uri"]
            .as_str()
            .unwrap()
            .starts_with("package/StructureDefinition-")
    );
}

#[test]
fn a_filter_limits_the_profiles() {
    let root = env!("CARGO_MANIFEST_DIR");
    let old = load(&format!("{root}/tests/fixtures/old")).unwrap();
    let new = load(&format!("{root}/tests/fixtures/new")).unwrap();
    let report = diff(
        "old",
        &Pool::new(&old),
        "new",
        &Pool::new(&new),
        &Filter {
            profiles: vec!["ExamplePractitioner".into()],
        },
    );
    assert_eq!(report.profiles.len(), 1);
    assert_eq!(report.worst(), Some(Severity::Info));
}

/// A base the old package does not provide (US Core 6.1.0 bases a profile on SDC without
/// listing SDC) is taken from the new package's, so the base's own constraints are not reported
/// as changes of the profile.
#[test]
fn a_missing_base_is_taken_from_the_other_package() {
    let root = env!("CARGO_MANIFEST_DIR");
    let old = load(&format!("{root}/tests/fixtures/borrow/old")).unwrap();
    let new = load(&format!("{root}/tests/fixtures/borrow/new")).unwrap();
    let (old_pool, new_pool) = Pool::pair(&old, &new);
    let report = diff("old", &old_pool, "new", &new_pool, &Filter::default());
    let derived = report
        .profiles
        .iter()
        .find(|p| p.url.ends_with("/derived-obs"))
        .expect("derived-obs");
    assert!(derived.changes.is_empty(), "{:#?}", derived.changes);
    assert!(
        report
            .notes
            .iter()
            .any(|note| note.contains("base-obs") && note.contains("taken from new")),
        "{:?}",
        report.notes
    );

    // Without the other package the profile is compared over the core type.
    let report = diff(
        "old",
        &Pool::new(&old),
        "new",
        &Pool::new(&new),
        &Filter::default(),
    );
    let derived = report
        .profiles
        .iter()
        .find(|p| p.url.ends_with("/derived-obs"))
        .expect("derived-obs");
    assert!(
        derived.changes.iter().any(|c| c.rule == "min-increased"),
        "{:#?}",
        derived.changes
    );
}

/// A profile chosen by its name in one version is compared with its other version, whatever
/// that one is called (US Core renamed `USCoreLocation` to `USCoreLocationProfile`).
#[test]
fn a_filter_pairs_a_renamed_profile_by_url() {
    let root = env!("CARGO_MANIFEST_DIR");
    let old = load(&format!("{root}/tests/fixtures/borrow/old")).unwrap();
    let new = load(&format!("{root}/tests/fixtures/borrow/new")).unwrap();
    let (old_pool, new_pool) = Pool::pair(&old, &new);
    for name in ["DerivedObs", "DerivedObservation"] {
        let report = diff(
            "old",
            &old_pool,
            "new",
            &new_pool,
            &Filter {
                profiles: vec![name.into()],
            },
        );
        assert_eq!(report.profiles.len(), 1, "{name}: {:#?}", report.profiles);
        assert_eq!(report.profiles[0].status, Status::Unchanged, "{name}");
    }
}

/// Bases are looked for only where a profile can be: not in core or terminology packages, and
/// not among the definitions already at hand.
#[test]
fn missing_bases_skip_core_terminology_and_known() {
    use fhir_diff::package::{base_dependencies, missing_bases};
    let root = env!("CARGO_MANIFEST_DIR");
    let old = load(&format!("{root}/tests/fixtures/borrow/old")).unwrap();
    let new = load(&format!("{root}/tests/fixtures/borrow/new")).unwrap();
    assert_eq!(
        missing_bases([&old.package]),
        ["http://example.org/StructureDefinition/base-obs".to_owned()].into()
    );
    assert!(missing_bases([&new.package]).is_empty());

    let mut package = old.package.clone();
    package.dependencies = [
        ("hl7.fhir.r4.core", "4.0.1"),
        ("hl7.terminology.r4", "7.0.1"),
        ("us.nlm.vsac", "0.11.0"),
        ("hl7.fhir.uv.sdc", "3.0.0"),
    ]
    .into_iter()
    .map(|(n, v)| (n.to_owned(), v.to_owned()))
    .collect();
    assert_eq!(
        base_dependencies(&package).collect::<Vec<_>>(),
        [("hl7.fhir.uv.sdc", "3.0.0")]
    );
}