#![allow(
clippy::disallowed_types,
reason = "dev/verification tooling over JSON artifacts (the catalogue, results, wire \
exchanges), whose shapes belong to the artifacts and the SUT; the carriers \
here are cfg(test)-only, so #[expect] would be unfulfilled in the non-test build"
)]
use serde::Deserialize;
use crate::ids::{CorpusKey, RecipeName, ViewName};
use crate::vocab::{CorpusFormat, FixtureVerdict, PlaceholderPolicy};
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Validity {
pub verdict: FixtureVerdict,
#[serde(default)]
pub defect: Option<String>,
#[serde(default)]
pub spec_ref: Option<String>,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ViewDecl {
pub select: String,
#[serde(default, rename = "where")]
pub where_clause: Option<String>,
#[serde(default)]
pub order_by: Option<String>,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct RecipeDecl {
pub digest: String,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct GeneratedBy {
pub recipe: RecipeName,
pub digest: String,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct CorpusEntry {
#[serde(default)]
pub source: Option<String>,
#[serde(default)]
pub generated_by: Option<GeneratedBy>,
pub format: CorpusFormat,
#[serde(default)]
pub template_id: Option<String>,
#[serde(default)]
pub rm_versions: Vec<String>,
pub validity: Validity,
#[serde(default, deserialize_with = "crate::model::de::optional_ordered_map")]
pub placeholders: Option<Vec<(String, PlaceholderPolicy)>>,
pub provenance: String,
#[serde(default, deserialize_with = "crate::model::de::optional_ordered_map")]
pub views: Option<Vec<(ViewName, ViewDecl)>>,
#[serde(default, deserialize_with = "crate::model::de::optional_ordered_map")]
pub recipes: Option<Vec<(RecipeName, RecipeDecl)>>,
}
impl CorpusEntry {
pub fn check_invariants(&self) -> Result<(), String> {
match (&self.source, &self.generated_by) {
(Some(_), Some(_)) => {
return Err("entry declares both source and generated_by".to_owned());
}
(None, None) => {
return Err("entry declares neither source nor generated_by".to_owned());
}
_ => {}
}
if self.validity.verdict == FixtureVerdict::Invalid
&& (self.validity.defect.is_none() || self.validity.spec_ref.is_none())
{
return Err(
"invalid fixture must carry validity.defect and validity.spec_ref".to_owned(),
);
}
if self.format == CorpusFormat::RawJson {
if self.generated_by.is_some() {
return Err(
"raw-json entry is generated: a recipe yields a Value, which has no \
byte-level form to preserve — declare a source"
.to_owned(),
);
}
if self.views.as_deref().is_some_and(|v| !v.is_empty()) {
return Err(
"raw-json entry declares views: a view projects PARSED structure, which \
the raw carrier deliberately does not have"
.to_owned(),
);
}
}
Ok(())
}
#[must_use]
pub fn view(&self, name: &ViewName) -> Option<&ViewDecl> {
self.views
.as_deref()
.unwrap_or_default()
.iter()
.find(|(n, _)| n == name)
.map(|(_, v)| v)
}
}
#[derive(Debug, Clone)]
pub struct CorpusManifest {
entries: Vec<(CorpusKey, CorpusEntry)>,
}
impl CorpusManifest {
#[must_use]
pub fn get(&self, key: &CorpusKey) -> Option<&CorpusEntry> {
self.entries.iter().find(|(k, _)| k == key).map(|(_, e)| e)
}
#[must_use]
pub fn entries(&self) -> &[(CorpusKey, CorpusEntry)] {
&self.entries
}
}
impl<'de> Deserialize<'de> for CorpusManifest {
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
let entries = crate::model::de::ordered_map(deserializer)?;
Ok(Self { entries })
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn entry_invariants() {
let e: CorpusEntry = serde_json::from_value(serde_json::json!({
"source": "fixtures/ehr/invalid/007.json",
"format": "canonical-json",
"validity": { "verdict": "invalid",
"defect": "RM/Schema: is_modifiable is mandatory",
"spec_ref": "RM ehr §EHR_STATUS" },
"provenance": "openEHR CNF Robot corpus @33251d2a; re-adjudicated 2026-07-21"
}))
.unwrap();
assert!(e.check_invariants().is_ok());
let e: CorpusEntry = serde_json::from_value(serde_json::json!({
"source": "x.json",
"format": "canonical-json",
"validity": { "verdict": "invalid" },
"provenance": "p"
}))
.unwrap();
assert!(e.check_invariants().is_err());
let e: CorpusEntry = serde_json::from_value(serde_json::json!({
"format": "canonical-json",
"validity": { "verdict": "valid" },
"provenance": "p"
}))
.unwrap();
assert!(e.check_invariants().is_err()); }
#[test]
fn raw_json_entries_must_carry_source_bytes() {
let entry = |extra: serde_json::Value| -> CorpusEntry {
let mut doc = serde_json::json!({
"format": "raw-json",
"validity": { "verdict": "invalid",
"defect": "JSON: `name` appears twice in the COMPOSITION object",
"spec_ref": "ITS-REST Resources.md §JSON Format" },
"provenance": "p"
});
if let (Some(map), Some(more)) = (doc.as_object_mut(), extra.as_object()) {
map.extend(more.clone());
}
serde_json::from_value(doc).unwrap()
};
assert!(
entry(serde_json::json!({ "source": "fixtures/raw/dup_member.json" }))
.check_invariants()
.is_ok()
);
assert!(
entry(serde_json::json!({
"generated_by": { "recipe": "bp_series", "digest": "sha256:x" }
}))
.check_invariants()
.is_err()
);
assert!(
entry(serde_json::json!({
"source": "fixtures/raw/dup_member.json",
"views": { "magnitude_ge_140_by_uid": { "select": "s" } }
}))
.check_invariants()
.is_err()
);
assert_eq!(
entry(serde_json::json!({
"source": "fixtures/raw/dup_member.json",
"generated_by": { "recipe": "bp_series", "digest": "sha256:x" }
}))
.check_invariants(),
Err("entry declares both source and generated_by".to_owned())
);
assert_eq!(
entry(serde_json::json!({})).check_invariants(),
Err("entry declares neither source nor generated_by".to_owned())
);
}
}