<p align="center"><img src=".github/banner.png" alt="octo/diff — FHIR profiles compared, breaking changes caught in CI" width="100%"></p>
<p align="center"><b>Semantic diff of FHIR packages and profiles, with a breaking-change gate for CI.</b></p>
<p align="center">
<a href="https://github.com/octofhir/fhir-diff/actions/workflows/ci.yml"><img src="https://github.com/octofhir/fhir-diff/actions/workflows/ci.yml/badge.svg" alt="CI"></a>
<a href="#license"><img src="https://img.shields.io/badge/license-MIT%20OR%20Apache--2.0-blue.svg" alt="License: MIT OR Apache-2.0"></a>
</p>
```sh
fhir-diff hl7.fhir.us.core@6.1.0 hl7.fhir.us.core@9.0.0
fhir-diff ./old-ig/output ./new-ig/output --fail-on breaking --format sarif -o fhir-diff.sarif
```
The arguments are either `name@version`, fetched from the FHIR package registry into the local
package store along with the package's dependencies, or a folder holding an unpacked package (a
`package/` subfolder or the files directly).
The first report is [US Core 6.1.0 → 9.0.0](reports/us-core-6.1.0-to-9.0.0.md). It compares 70
profiles and extensions: 11 added, 1 replaced, 48 changed, with 64 breaking changes.
## How it works
1. Every StructureDefinition of both packages is converted to
[FHIR Schema](https://github.com/octofhir/fhirschema).
2. Each profile is merged over its whole base chain, down to the core type, into an
**effective element tree**. A change inherited from a base profile counts as much as one the
profile makes itself. The diff never trusts a differential on its own.
3. The two trees of each profile (matched by canonical URL) are compared element by element,
slices included (`Patient.identifier:mrn.system`).
4. Every change is given a rule and a severity.
Choice elements are compared as one element whatever form the profile uses to narrow them.
`effective[x]` restricted to `dateTime` and a constraint on `effectiveDateTime` compare equal.
Reference targets that move from a core type to a profile of the same type
(`Practitioner` → `us-core-practitioner`) are reported once as `target-narrowed`.
A package never says that one profile replaces another. When the only profile of a type that
disappeared meets the only one of that type that appeared, the two are compared as a pair and
reported as `profile-replaced`, so the changes between them show. Extensions are never paired.
An invariant that left one element and arrived on another is reported once, as `invariant-moved`
when its expression says the same from the new element, as `invariant-changed` otherwise.
`mustSupport` on a choice covers the types marked with `elementdefinition-type-must-support`, or
all of them if none is marked.
## Rules
| **breaking**: an instance valid against the old profile may be invalid against the new one, or implementers gain an obligation | `profile-removed`, `profile-replaced`, `min-increased` (including a new required slice), `max-decreased`, `type-removed`, `target-removed`, `must-support-added`, `binding-stronger` (to extensible or required), `binding-added` (required or extensible), `invariant-added` (error), `fixed-added`, `fixed-changed`, `slicing-closed` |
| **warning**: may break, depending on content the diff does not see | `value-set-changed` (under a required or extensible binding), `invariant-changed`, `invariant-warning-added`, `target-narrowed`, `base-changed`, `slice-removed`, `modifier-changed` |
| **info**: relaxations and additions | `profile-added`, `min-decreased`, `max-increased`, `type-added`, `target-added`, `must-support-removed`, `binding-stronger-advisory` (example → preferred), `binding-weaker`, `invariant-moved`, `invariant-removed`, `fixed-removed`, `slicing-opened`, `slice-added` |
## Options
| `--format text\|markdown\|json\|sarif` | Text for a terminal or CI log (the default), Markdown for people, JSON for tools, SARIF 2.1.0 for code scanning |
| `--min-severity info\|warning\|breaking` | Report only changes at least this severe |
| `--fail-on never\|warning\|breaking` | Exit 1 when a change at least this severe is found (exit 2 on errors) |
| `--profile URL\|NAME` | Compare only these profiles (repeatable) |
| `-o FILE` | Write the report to a file |
### GitHub Actions
```yaml
- run: fhir-diff hl7.fhir.us.core@9.0.0 ./output --fail-on breaking --format sarif -o fhir-diff.sarif
- uses: github/codeql-action/upload-sarif@v3
if: always()
with:
sarif_file: fhir-diff.sarif
```
SARIF results point at `package/StructureDefinition-*.json`. Their logical location is
`<profile url>#<element path>`.
## Accuracy
`cargo run --example selfcheck -- <package>` compares each effective tree with the snapshot
the package publishes, on min, max and mustSupport of every element:
| Package | Elements | Agree |
|---|---:|---:|
| `hl7.fhir.us.core@9.0.0` | 2,455 | 99.4% |
| `hl7.fhir.us.core@6.1.0` | 2,133 | 99.3% |
Known gaps:
- Bases are looked for in the package's dependencies, transitively, while one is still missing;
value set and code system packages (`hl7.terminology.*`, VSAC, PHIN VADS) are never read. When
no dependency defines a base, it is taken from the other package's dependencies when it has
one with the same canonical URL, and the profile is compared over the core type otherwise. The
report says which under *Notes*.
- mustSupport set on a choice through a type slice (`value[x]:valueCodeableConcept`) shows on
the slice, not on the choice.
- Value set content is not compared. A different value set under a required binding is a
warning, not an expansion diff.
## Library
```toml
[dependencies]
fhir-diff = { version = "0.1", default-features = false }
```
Without default features the crate reads packages from folders or from definitions you hand it
(`Loaded`), and needs neither a network stack nor a package store. Features:
- `registry`: `load("name@version")` installs from the FHIR package registry into the local
package store (octofhir-canonical-manager).
- `cli` (default): the `fhir-diff` command; enables `registry`.
```rust
let (old, new) = (fhir_diff::load("./old")?, fhir_diff::load("./new")?);
let (old_pool, new_pool) = fhir_diff::Pool::pair(&old, &new);
let report = fhir_diff::diff("old", &old_pool, "new", &new_pool, &Default::default());
println!("{}", fhir_diff::report::text(&report, fhir_diff::Severity::Info));
```
## Install
```sh
cargo install --git https://github.com/octofhir/fhir-diff
```
## Building
```sh
cargo build --release
cargo test
```
## License
MIT OR Apache-2.0.