#![cfg(feature = "fhirpath")]
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use fml_check::{CoreDefinitions, Definitions, PackageDefinitions, Release};
use fml_engine::{ConceptMapSet, Engine, FhirPathExpressions, MapResolver, MapSet};
use serde_json::Value;
fn files(dir: &Path, extension: &str) -> Vec<PathBuf> {
let mut out: Vec<PathBuf> = std::fs::read_dir(dir)
.map(|entries| {
entries
.flatten()
.map(|e| e.path())
.filter(|p| p.extension().is_some_and(|e| e == extension))
.collect()
})
.unwrap_or_default();
out.sort();
out
}
fn corpus_path(variable: &str, default: &str) -> PathBuf {
std::env::var_os(variable)
.map(PathBuf::from)
.unwrap_or_else(|| {
Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../corpus")
.join(default)
})
}
struct Direction {
folder: &'static str,
suffix: &'static str,
target: &'static str,
}
struct Outcome {
converted: usize,
}
async fn convert(
direction: &Direction,
definitions: &dyn Definitions,
release: Release,
expected: &Path,
) -> Option<Outcome> {
let xver = corpus_path("FML_XVER", "fhir-cross-version/input");
let examples = corpus_path("FML_XVER_EXAMPLES", "r4-examples");
if !xver.join(direction.folder).is_dir() || !examples.is_dir() {
eprintln!("no cross-version maps or R4 examples; run scripts/fetch-corpus.sh");
return None;
}
let mut maps = MapSet::new();
for path in files(&xver.join(direction.folder), "fml") {
let compiled = fml_model::compile(&std::fs::read_to_string(&path).unwrap());
maps.add(compiled.map);
}
let mut concept_maps = ConceptMapSet::new();
for folder in ["codes", "types", "elements", "resources"] {
for path in files(&xver.join(folder), "json") {
if let Ok(json) = serde_json::from_str(&std::fs::read_to_string(&path).unwrap()) {
concept_maps.add(json);
}
}
}
let engine = Engine::new(
definitions,
FhirPathExpressions::new(release).await.unwrap(),
)
.with_maps(&maps)
.with_concept_maps(&concept_maps);
let mut converted = 0;
let mut compared = 0;
let mut equal = 0;
let mut equal_without_text = 0;
let mut differences: BTreeMap<String, Vec<String>> = BTreeMap::new();
let mut with_warnings = 0;
let mut no_map = 0;
let mut errors: BTreeMap<String, Vec<String>> = BTreeMap::new();
let examples = files(&examples, "json");
for path in &examples {
let Ok(source) = serde_json::from_str::<Value>(&std::fs::read_to_string(path).unwrap())
else {
continue;
};
let Some(resource_type) = source["resourceType"].as_str() else {
continue;
};
let url = format!(
"http://hl7.org/fhir/uv/xver/StructureMap/{resource_type}{}",
direction.suffix
);
let Some(map) = maps.resolve(&url).into_iter().next() else {
no_map += 1;
continue;
};
match engine.transform(&map, &source).await {
Ok(result) => {
converted += 1;
if !result.warnings.is_empty() {
with_warnings += 1;
}
let name = path.file_name().unwrap();
if let Ok(text) = std::fs::read_to_string(expected.join(name))
&& let Ok(want) = serde_json::from_str::<Value>(&text)
{
compared += 1;
if result.target == want {
equal += 1;
}
let mut paths = Vec::new();
diff(
resource_type,
&without_text(&result.target),
&without_text(&want),
&mut paths,
);
if paths.is_empty() {
equal_without_text += 1;
}
if CONFORMANCE.contains(&resource_type) {
continue;
}
for found in paths {
differences
.entry(found)
.or_default()
.push(name.to_string_lossy().into_owned());
}
}
}
Err(error) => {
let mut key = String::new();
let mut quoted = false;
for c in error.message.chars() {
if c == '`' {
quoted = !quoted;
key.push_str(if quoted { "`…" } else { "`" });
} else if !quoted {
key.push(c);
}
}
errors.entry(key).or_default().push(format!(
"{} ({})",
path.file_name().unwrap().to_string_lossy(),
error.message
));
}
}
}
let failed: usize = errors.values().map(Vec::len).sum();
eprintln!(
"{}: {} examples, {converted} converted ({with_warnings} with values left out), {failed} failed, {no_map} without a map",
direction.folder,
examples.len()
);
eprintln!(
"compared with the {} examples: {compared}; equal {equal}, equal apart from the narrative {equal_without_text}",
direction.target
);
let mut ranked: Vec<_> = differences.into_iter().collect();
ranked.sort_by_key(|(_, files)| std::cmp::Reverse(files.len()));
eprintln!(" where the other examples differ (element, examples):");
for (path, files) in ranked.iter().take(40) {
eprintln!(" {:4} {path} e.g. {}", files.len(), files[0]);
}
let mut ranked: Vec<_> = errors.into_iter().collect();
ranked.sort_by_key(|(_, files)| std::cmp::Reverse(files.len()));
for (message, files) in ranked.iter().take(25) {
eprintln!(" {:4} {message}\n e.g. {}", files.len(), files[0]);
}
Some(Outcome { converted })
}
#[tokio::test]
async fn r4_examples_to_r5() {
let definitions = CoreDefinitions::new(Release::R5);
let direction = Direction {
folder: "R4toR5",
suffix: "4to5",
target: "R5",
};
let expected = corpus_path("FML_XVER_EXPECTED", "r5-examples");
let Some(outcome) = convert(&direction, &definitions, Release::R5, &expected).await else {
return;
};
assert!(
outcome.converted >= 2_850,
"only {} R4 examples converted to R5",
outcome.converted
);
}
#[tokio::test]
async fn r4_examples_to_r3() {
let core = corpus_path("FML_R3_CORE", "r3-core/package");
if !core.is_dir() {
eprintln!("no STU3 core package; run scripts/fetch-corpus.sh");
return;
}
let mut definitions = PackageDefinitions::new(CoreDefinitions::new(Release::R4));
let (added, problems) = definitions.add_release_folder(&core, "3.0").unwrap();
eprintln!("STU3 definitions: {added}, {} unreadable", problems.len());
for problem in problems.iter().take(5) {
eprintln!(" {problem}");
}
let direction = Direction {
folder: "R4toR3",
suffix: "4to3",
target: "STU3",
};
let expected = corpus_path("FML_XVER_EXPECTED_R3", "r3-examples");
convert(&direction, &definitions, Release::R4, &expected).await;
}
const CONFORMANCE: &[&str] = &[
"CapabilityStatement",
"CodeSystem",
"CompartmentDefinition",
"ConceptMap",
"GraphDefinition",
"ImplementationGuide",
"MessageDefinition",
"NamingSystem",
"OperationDefinition",
"Questionnaire",
"SearchParameter",
"StructureDefinition",
"StructureMap",
"TerminologyCapabilities",
"ValueSet",
];
fn without_text(resource: &Value) -> Value {
let mut resource = resource.clone();
if let Some(object) = resource.as_object_mut() {
object.remove("text");
}
resource
}
fn diff(path: &str, ours: &Value, want: &Value, out: &mut Vec<String>) {
match (ours, want) {
(Value::Object(a), Value::Object(b)) => {
for key in a.keys().chain(b.keys().filter(|k| !a.contains_key(*k))) {
let child = format!("{path}.{}", key.trim_start_matches('_'));
match (a.get(key), b.get(key)) {
(Some(x), Some(y)) => diff(&child, x, y, out),
(Some(_), None) => out.push(format!("{child} (extra)")),
(None, Some(_)) => out.push(format!("{child} (missing)")),
(None, None) => {}
}
}
}
(Value::Array(a), Value::Array(b)) if a.len() == b.len() => {
for (x, y) in a.iter().zip(b) {
diff(path, x, y, out);
}
}
(Value::Array(_), Value::Array(_)) => out.push(format!("{path} (count)")),
(a, b) if a != b => out.push(path.to_owned()),
_ => {}
}
out.dedup();
}