#![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::{AmbiguityId, OptionFamilyName, OptionTag};
use crate::vocab::Disposition;
#[derive(Debug, Clone, Default)]
pub struct OptionFamilies {
families: Vec<(OptionFamilyName, Vec<OptionTag>)>,
}
impl OptionFamilies {
#[must_use]
pub fn families(&self) -> &[(OptionFamilyName, Vec<OptionTag>)] {
&self.families
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.families.is_empty()
}
pub fn tags(&self) -> impl Iterator<Item = &OptionTag> {
self.families.iter().flat_map(|(_, arms)| arms.iter())
}
#[must_use]
pub fn contains(&self, tag: &OptionTag) -> bool {
self.tags().any(|arm| arm == tag)
}
#[must_use]
pub fn family_of(&self, tag: &OptionTag) -> Option<(&OptionFamilyName, &[OptionTag])> {
self.families
.iter()
.find(|(_, arms)| arms.contains(tag))
.map(|(name, arms)| (name, arms.as_slice()))
}
}
impl<'de> Deserialize<'de> for OptionFamilies {
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
let families = crate::model::de::ordered_map(deserializer)?;
Ok(Self { families })
}
}
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct AmbiguityEntry {
pub ambiguity: String,
pub source: String,
pub handling: String,
pub disposition: Disposition,
#[serde(default)]
pub options: OptionFamilies,
#[serde(default)]
pub upstream_issue: Option<u64>,
}
impl AmbiguityEntry {
pub fn check_invariants(&self) -> Result<(), String> {
if matches!(
self.disposition,
Disposition::ReportOnly | Disposition::Editorial
) && self.upstream_issue.is_none()
{
return Err(format!(
"disposition {:?} must carry an upstream_issue (the GitHub issue number of the outbound upstream-report issue)",
self.disposition
));
}
match self.disposition {
Disposition::OptionSelect if self.options.is_empty() => {
Err("option_select entry must enumerate at least one option family".to_owned())
}
Disposition::OptionSelect => self.check_families(),
_ if !self.options.is_empty() => Err(format!(
"disposition {:?} carries option families (only option_select may)",
self.disposition
)),
_ => Ok(()),
}
}
fn check_families(&self) -> Result<(), String> {
let mut seen: Vec<&OptionTag> = Vec::new();
for (name, arms) in self.options.families() {
if arms.len() < 2 {
return Err(format!(
"option family {name} must enumerate at least two option tags"
));
}
for arm in arms {
if seen.contains(&arm) {
return Err(format!(
"option tag {arm} is an arm of more than one family of this entry"
));
}
seen.push(arm);
}
}
Ok(())
}
}
#[derive(Debug, Clone)]
pub struct AmbiguityRegister {
entries: Vec<(AmbiguityId, AmbiguityEntry)>,
}
impl AmbiguityRegister {
#[must_use]
pub fn get(&self, id: &AmbiguityId) -> Option<&AmbiguityEntry> {
self.entries.iter().find(|(k, _)| k == id).map(|(_, e)| e)
}
#[must_use]
pub fn entries(&self) -> &[(AmbiguityId, AmbiguityEntry)] {
&self.entries
}
#[must_use]
pub fn declares_option(&self, tag: &OptionTag) -> bool {
self.entries.iter().any(|(_, e)| e.options.contains(tag))
}
#[must_use]
pub fn option_family_of(
&self,
tag: &OptionTag,
) -> Option<(&AmbiguityId, &OptionFamilyName, &[OptionTag])> {
self.entries.iter().find_map(|(id, entry)| {
entry
.options
.family_of(tag)
.map(|(name, arms)| (id, name, arms))
})
}
}
impl<'de> Deserialize<'de> for AmbiguityRegister {
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 disposition_shapes() {
let r: AmbiguityRegister = serde_json::from_value(serde_json::json!({
"AMB-4": {
"ambiguity": "a duplicate ADL 1.4 template_id upload may be refused as a conflict or replace the stored template",
"source": "SM i_definition_adl14.adoc §upload_opt (silent on duplicates) vs ITS-REST 409_template_already_exists.yaml",
"handling": "sibling cases carry option tags; the ICS options declaration selects",
"disposition": "option_select",
"options": { "adl14-duplicate": ["adl14-duplicate-conflict", "adl14-duplicate-replace"] }
}
}))
.unwrap();
let (_, entry) = &r.entries()[0];
assert!(entry.check_invariants().is_ok());
assert!(r.declares_option(&OptionTag::parse("adl14-duplicate-conflict").unwrap()));
let e: AmbiguityEntry = serde_json::from_value(serde_json::json!({
"ambiguity": "x", "source": "s", "handling": "h",
"disposition": "report_only", "options": { "stray": ["a", "b"] }
}))
.unwrap();
assert!(e.check_invariants().is_err());
}
fn entry(disposition: &str, options: &serde_json::Value) -> AmbiguityEntry {
serde_json::from_value(serde_json::json!({
"ambiguity": "x", "source": "s", "handling": "h",
"disposition": disposition, "options": options
}))
.unwrap()
}
#[test]
fn an_option_select_entry_enumerates_at_least_two_branches() {
let message = entry(
"option_select",
&serde_json::json!({ "solo": ["only-one"] }),
)
.check_invariants()
.expect_err("one branch is not a choice");
assert_eq!(
message,
"option family solo must enumerate at least two option tags"
);
assert!(
entry(
"option_select",
&serde_json::json!({ "pair": ["one", "two"] })
)
.check_invariants()
.is_ok()
);
}
#[test]
fn an_option_select_entry_enumerates_at_least_one_family() {
let message = entry("option_select", &serde_json::json!({}))
.check_invariants()
.expect_err("no family is no choice");
assert_eq!(
message,
"option_select entry must enumerate at least one option family"
);
}
#[test]
fn an_arm_belongs_to_exactly_one_family() {
let message = entry(
"option_select",
&serde_json::json!({ "a": ["one", "two"], "b": ["two", "three"] }),
)
.check_invariants()
.expect_err("a shared arm answers two families at once");
assert_eq!(
message,
"option tag two is an arm of more than one family of this entry"
);
}
#[test]
fn an_arm_names_its_own_family() {
let e = entry(
"option_select",
&serde_json::json!({
"ehr-xml": ["ehr-xml-supported", "ehr-xml-unsupported"],
"ehr-xml-write": ["ehr-xml-write-accepted", "ehr-xml-write-refused"]
}),
);
let tag = OptionTag::parse("ehr-xml-write-refused").unwrap();
let (name, arms) = e.options.family_of(&tag).expect("the arm names a family");
assert_eq!(name.as_str(), "ehr-xml-write");
assert_eq!(arms.len(), 2);
assert!(
e.options
.family_of(&OptionTag::parse("nobody-declares-this").unwrap())
.is_none()
);
}
#[test]
fn only_option_select_may_carry_option_tags() {
let message = entry(
"fixed_handling",
&serde_json::json!({ "stray": ["a", "b"] }),
)
.check_invariants()
.expect_err("a fixed handling has no branches");
assert_eq!(
message,
"disposition FixedHandling carries option families (only option_select may)"
);
assert!(
entry("fixed_handling", &serde_json::json!({}))
.check_invariants()
.is_ok()
);
}
}