#![expect(
clippy::disallowed_types,
reason = "dev/verification tooling over JSON artifacts (the catalogue, results, wire \
exchanges), whose shapes belong to the artifacts and the SUT"
)]
use std::collections::BTreeMap;
use serde::{Deserialize, Serialize};
use thiserror::Error;
use crate::exec::{CaseRecord, RowOutcome};
use crate::ids::{AmbiguityId, CapabilityName, CaseId, OptionTag};
use crate::vocab::{FormatName, ItsName, Tier};
#[derive(Debug, Error, PartialEq, Eq)]
pub enum PartyError {
#[error("outcome for case {case} (status {status}) is missing its mandatory citation")]
MissingCitation {
case: String,
status: &'static str,
},
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Product {
pub name: String,
pub version: String,
pub vendor: String,
pub identifier: String,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct SpecVersions {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub rm: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub base: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub am: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub aql: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub its_rest: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub term: Option<String>,
}
impl SpecVersions {
#[must_use]
pub fn get(&self, component: crate::vocab::SpecComponent) -> Option<&str> {
use crate::vocab::SpecComponent;
match component {
SpecComponent::Rm => self.rm.as_deref(),
SpecComponent::Base => self.base.as_deref(),
SpecComponent::Am => self.am.as_deref(),
SpecComponent::Aql => self.aql.as_deref(),
SpecComponent::ItsRest => self.its_rest.as_deref(),
SpecComponent::Term => self.term.as_deref(),
}
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct Claims {
#[serde(default)]
pub capabilities: Vec<CapabilityName>,
#[serde(default)]
pub profiles: Vec<Tier>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TechProfile {
pub its: ItsName,
#[serde(default)]
pub formats: Vec<FormatName>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Performance {
pub class: String,
pub environment_ref: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Evidence {
pub results_path: String,
pub sha256: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Attestation {
pub signatory: String,
pub role: String,
pub date: String,
pub statement: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Statement {
pub product: Product,
pub schedule_release: String,
#[serde(default)]
pub spec_versions: SpecVersions,
pub claims: Claims,
#[serde(default)]
pub tech_profiles: Vec<TechProfile>,
#[serde(default)]
pub options: Vec<OptionTag>,
#[serde(default)]
pub served_extensions: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub performance: Option<Performance>,
#[serde(default)]
pub non_functional: BTreeMap<String, serde_json::Value>,
#[serde(default)]
pub evidence: Vec<Evidence>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub attestation: Option<Attestation>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Sut {
pub name: String,
pub version: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum VerificationPackStatus {
Passed,
NotRun,
Failed,
}
impl VerificationPackStatus {
pub const ALL: &[VerificationPackStatus] = &[
VerificationPackStatus::Passed,
VerificationPackStatus::NotRun,
VerificationPackStatus::Failed,
];
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Runner {
pub name: String,
pub version: String,
pub verification_pack_status: VerificationPackStatus,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum OutcomeStatus {
Passed,
Failed,
Errored,
Skipped,
NotApplicable,
}
impl OutcomeStatus {
pub const ALL: &[OutcomeStatus] = &[
OutcomeStatus::Passed,
OutcomeStatus::Failed,
OutcomeStatus::Errored,
OutcomeStatus::Skipped,
OutcomeStatus::NotApplicable,
];
#[must_use]
pub fn token(self) -> &'static str {
match self {
OutcomeStatus::Passed => "passed",
OutcomeStatus::Failed => "failed",
OutcomeStatus::Errored => "errored",
OutcomeStatus::Skipped => "skipped",
OutcomeStatus::NotApplicable => "not_applicable",
}
}
#[must_use]
pub fn needs_citation(self) -> bool {
matches!(self, OutcomeStatus::Skipped | OutcomeStatus::NotApplicable)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FailedRow {
pub row: usize,
pub step: u32,
pub reason: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OutcomeRecord {
pub case: CaseId,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub format: Option<FormatName>,
pub status: OutcomeStatus,
pub rows_driven: usize,
pub rows_total: usize,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub failing_step: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub reason: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub citation: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub failed_rows: Vec<FailedRow>,
}
impl OutcomeRecord {
pub fn check_invariants(&self) -> Result<(), PartyError> {
if self.status.needs_citation() && self.citation.as_deref().unwrap_or_default().is_empty() {
return Err(PartyError::MissingCitation {
case: self.case.to_string(),
status: self.status.token(),
});
}
Ok(())
}
}
impl From<&CaseRecord> for OutcomeRecord {
fn from(record: &CaseRecord) -> Self {
let mut failing: Option<(u32, String)> = None;
let mut erroring: Option<(u32, String)> = None;
let mut na_citation: Option<String> = None;
let mut skip_citation: Option<String> = None;
let mut has_passed = false;
let mut failed_rows = Vec::new();
for (index, row) in record.rows.iter().enumerate() {
match row {
RowOutcome::Passed => has_passed = true,
RowOutcome::Failed { step, reason } => {
failed_rows.push(FailedRow {
row: index,
step: *step,
reason: reason.clone(),
});
if failing.is_none() {
failing = Some((*step, reason.clone()));
}
}
RowOutcome::Errored { step, reason } => {
if erroring.is_none() {
erroring = Some((*step, reason.clone()));
}
}
RowOutcome::NotApplicable { citation } => {
if na_citation.is_none() {
na_citation = Some(citation.clone());
}
}
RowOutcome::Skipped { citation } => {
if skip_citation.is_none() {
skip_citation = Some(citation.clone());
}
}
}
}
let base = |status, failing_step, reason, citation, failed_rows| OutcomeRecord {
case: record.case.clone(),
format: record.format,
status,
rows_driven: record.rows_driven,
rows_total: record.rows_total,
failing_step,
reason,
citation,
failed_rows,
};
if let Some((step, reason)) = failing {
base(
OutcomeStatus::Failed,
Some(step),
Some(reason),
None,
failed_rows,
)
} else if let Some((step, reason)) = erroring {
base(
OutcomeStatus::Errored,
Some(step),
Some(reason),
None,
Vec::new(),
)
} else if has_passed {
base(OutcomeStatus::Passed, None, None, None, Vec::new())
} else if let Some(citation) = na_citation {
base(
OutcomeStatus::NotApplicable,
None,
None,
Some(citation),
Vec::new(),
)
} else if let Some(citation) = skip_citation {
base(
OutcomeStatus::Skipped,
None,
None,
Some(citation),
Vec::new(),
)
} else {
base(
OutcomeStatus::Errored,
None,
Some("no rows driven".to_owned()),
None,
Vec::new(),
)
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AmbiguityDisposition {
pub ambiguity: AmbiguityId,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub option: Option<OptionTag>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Results {
pub sut: Sut,
pub runner: Runner,
pub schedule_release: String,
pub tech_profile: TechProfile,
pub ixit_digest: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub restapi_specs_version: Option<String>,
#[serde(default)]
pub outcomes: Vec<OutcomeRecord>,
#[serde(default)]
pub measurements: Vec<crate::perf::Measurement>,
#[serde(default)]
pub ambiguity_dispositions: Vec<AmbiguityDisposition>,
}
impl Results {
pub fn check_invariants(&self) -> Result<(), Vec<PartyError>> {
let errors: Vec<PartyError> = self
.outcomes
.iter()
.filter_map(|o| o.check_invariants().err())
.collect();
if errors.is_empty() {
Ok(())
} else {
Err(errors)
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn record(rows: Vec<RowOutcome>) -> CaseRecord {
CaseRecord {
case: CaseId::parse("I_EHR_SERVICE.create_ehr-main").unwrap(),
format: Some(FormatName::CanonicalJson),
rows_driven: rows.len(),
rows_total: rows.len(),
rows,
advisories: Vec::new(),
}
}
#[test]
fn rollup_prefers_failure_then_error() {
let r = OutcomeRecord::from(&record(vec![
RowOutcome::Passed,
RowOutcome::Failed {
step: 3,
reason: "boom".to_owned(),
},
RowOutcome::Errored {
step: 1,
reason: "conn".to_owned(),
},
]));
assert_eq!(r.status, OutcomeStatus::Failed);
assert_eq!(r.failing_step, Some(3));
let r = OutcomeRecord::from(&record(vec![
RowOutcome::Passed,
RowOutcome::Errored {
step: 2,
reason: "conn".to_owned(),
},
]));
assert_eq!(r.status, OutcomeStatus::Errored);
assert_eq!(r.failing_step, Some(2));
}
#[test]
fn rollup_passed_and_na_and_empty() {
let r = OutcomeRecord::from(&record(vec![
RowOutcome::Passed,
RowOutcome::NotApplicable {
citation: "c".to_owned(),
},
]));
assert_eq!(r.status, OutcomeStatus::Passed);
let r = OutcomeRecord::from(&record(vec![
RowOutcome::NotApplicable {
citation: "AMB-17".to_owned(),
},
RowOutcome::NotApplicable {
citation: "AMB-17b".to_owned(),
},
]));
assert_eq!(r.status, OutcomeStatus::NotApplicable);
assert_eq!(r.citation.as_deref(), Some("AMB-17"));
let r = OutcomeRecord::from(&record(vec![]));
assert_eq!(r.status, OutcomeStatus::Errored);
}
#[test]
fn a_wholly_skipped_case_rolls_up_carrying_its_first_citation() {
let r = OutcomeRecord::from(&record(vec![
RowOutcome::Skipped {
citation: "operator selection".to_owned(),
},
RowOutcome::Skipped {
citation: "operator selection (second row)".to_owned(),
},
]));
assert_eq!(r.status, OutcomeStatus::Skipped);
assert_eq!(r.citation.as_deref(), Some("operator selection"));
assert_eq!(r.failing_step, None);
}
#[test]
fn the_document_invariant_reports_every_uncited_outcome() {
let document = |outcomes: serde_json::Value| -> Results {
serde_json::from_value(serde_json::json!({
"sut": { "name": "s", "version": "1" },
"runner": { "name": "veredictum", "version": "0",
"verification_pack_status": "passed" },
"schedule_release": "CNF-2.0",
"tech_profile": { "its": "its-rest", "formats": ["canonical-json"] },
"ixit_digest": "d",
"outcomes": outcomes
}))
.unwrap()
};
let uncited = document(serde_json::json!([
{ "case": "A-x", "status": "not_applicable", "rows_driven": 0, "rows_total": 1 },
{ "case": "B-y", "status": "skipped", "rows_driven": 0, "rows_total": 1 },
{ "case": "C-z", "status": "passed", "rows_driven": 1, "rows_total": 1 }
]));
let errors = uncited
.check_invariants()
.expect_err("two outcomes carry no citation");
assert_eq!(errors.len(), 2, "{errors:?}");
let cited = document(serde_json::json!([
{ "case": "A-x", "status": "not_applicable", "rows_driven": 0, "rows_total": 1,
"citation": "AMB-32" },
{ "case": "C-z", "status": "passed", "rows_driven": 1, "rows_total": 1 }
]));
assert!(cited.check_invariants().is_ok());
}
#[test]
fn citation_invariant_bites() {
let mut r = OutcomeRecord::from(&record(vec![RowOutcome::NotApplicable {
citation: "AMB-1".to_owned(),
}]));
assert!(r.check_invariants().is_ok());
r.citation = None;
assert!(matches!(
r.check_invariants(),
Err(PartyError::MissingCitation { .. })
));
}
#[test]
fn statement_round_trips() {
let json = serde_json::json!({
"product": { "name": "FerroEHR", "version": "3.5.0",
"vendor": "Ruben Talstra", "identifier": "urn:rubentalstra:ferroehr" },
"schedule_release": "CNF-2.0",
"spec_versions": { "rm": "1.2.0", "its_rest": "1.1.0" },
"claims": { "capabilities": ["EhrOperations"], "profiles": ["CORE"] },
"tech_profiles": [ { "its": "its-rest", "formats": ["canonical-json"] } ],
"options": ["adl14-duplicate-conflict"],
"evidence": [ { "results_path": "results.json", "sha256": "abc" } ]
});
let s: Statement = serde_json::from_value(json).unwrap();
assert_eq!(s.spec_versions.rm.as_deref(), Some("1.2.0"));
assert_eq!(s.claims.capabilities.len(), 1);
let back = serde_json::to_value(&s).unwrap();
let s2: Statement = serde_json::from_value(back).unwrap();
assert_eq!(s2.product.name, "FerroEHR");
}
#[test]
fn results_round_trip_and_invariants() {
let json = serde_json::json!({
"sut": { "name": "ferroehr", "version": "3.5.0" },
"runner": { "name": "veredictum", "version": "0.1.0",
"verification_pack_status": "passed" },
"schedule_release": "CNF-2.0",
"tech_profile": { "its": "its-rest", "formats": ["canonical-json"] },
"ixit_digest": "deadbeef",
"outcomes": [
{ "case": "I_EHR_SERVICE.create_ehr-main", "format": "canonical-json",
"status": "passed", "rows_driven": 2, "rows_total": 2 },
{ "case": "I_ADMIN_SERVICE.list_contributions-x",
"status": "not_applicable", "rows_driven": 0, "rows_total": 1,
"citation": "AMB-33" }
]
});
let r: Results = serde_json::from_value(json).unwrap();
assert!(r.check_invariants().is_ok());
assert_eq!(r.outcomes.len(), 2);
}
}