#![expect(
clippy::disallowed_types,
reason = "canonical JSON / Simplified Formats operate on the wire value by definition \
(ITS-JSON; ITS-REST Simplified Formats) — serde_json::Value IS the subject matter \
(#1694)"
)]
use std::collections::HashSet;
use std::sync::LazyLock;
use openehr_rm::paths::{PathSegment, is_archetype_root_node_id};
use serde_json::{Map, Value, json};
use crate::flat::error::FlatError;
use crate::flat::rmpath;
use crate::flat::webtemplate::{WebTemplate, WebTemplateNode};
pub const TDD_TEMPLATE_NS: &str = "http://schemas.oceanehr.com/templates";
const RM_VERSION: &str = "1.2.0";
const DEFAULT_TIME: &str = "1970-01-01T00:00:00Z";
struct El {
name: String,
attrs: Vec<(String, String)>,
text: String,
children: Vec<El>,
}
impl El {
fn xsi_type(&self) -> Option<&str> {
self.attrs
.iter()
.find(|(k, _)| k == "xsi:type" || (k.ends_with(":type") && k.contains("xsi")))
.map(|(_, v)| v.rsplit(':').next().unwrap_or(v))
}
fn child(&self, name: &str) -> Option<&El> {
self.children.iter().find(|c| c.name == name)
}
}
fn local_name(raw: &[u8]) -> String {
let s = String::from_utf8_lossy(raw);
s.rsplit(':').next().unwrap_or(&s).to_string()
}
fn parse_tree(xml: &str) -> Result<El, FlatError> {
use quick_xml::Reader;
use quick_xml::events::Event;
let mut reader = Reader::from_str(xml);
let mut stack: Vec<El> = Vec::new();
let mut root: Option<El> = None;
let start_el = |e: &quick_xml::events::BytesStart<'_>| -> Result<El, FlatError> {
let mut attrs = Vec::new();
for a in e.attributes() {
let a =
a.map_err(|err| FlatError::Conversion(format!("malformed TDD attribute: {err}")))?;
let key = String::from_utf8_lossy(a.key.as_ref()).into_owned();
let val = String::from_utf8_lossy(&a.value).into_owned();
attrs.push((key, val));
}
Ok(El {
name: local_name(e.name().as_ref()),
attrs,
text: String::new(),
children: Vec::new(),
})
};
let close = |stack: &mut Vec<El>, root: &mut Option<El>| {
if let Some(done) = stack.pop() {
match stack.last_mut() {
Some(parent) => parent.children.push(done),
None => *root = Some(done),
}
}
};
loop {
match reader
.read_event()
.map_err(|e| FlatError::Conversion(format!("TDD is not well-formed XML: {e}")))?
{
Event::Start(e) => stack.push(start_el(&e)?),
Event::Empty(e) => {
stack.push(start_el(&e)?);
close(&mut stack, &mut root);
}
Event::End(_) => close(&mut stack, &mut root),
Event::Text(t) => {
if let Some(top) = stack.last_mut() {
let txt = t
.decode()
.map_err(|e| FlatError::Conversion(format!("TDD text decode: {e}")))?;
top.text.push_str(txt.trim());
}
}
Event::CData(t) => {
if let Some(top) = stack.last_mut() {
let bytes = t.into_inner();
top.text.push_str(&String::from_utf8_lossy(&bytes));
}
}
Event::Eof => break,
_ => {}
}
}
root.ok_or_else(|| FlatError::Conversion("TDD has no root element".into()))
}
fn to_canonical_xml(el: &El, type_hint: Option<&str>) -> String {
let mut out = String::new();
write_el(el, true, type_hint, &mut out);
out
}
fn write_el(el: &El, is_root: bool, type_hint: Option<&str>, out: &mut String) {
out.push('<');
out.push_str(&el.name);
if is_root {
out.push_str(" xmlns=\"http://schemas.openehr.org/v1\"");
out.push_str(" xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\"");
}
let mut has_xsi = false;
for (k, v) in &el.attrs {
if k.starts_with("xmlns") {
continue;
}
if k == "xsi:type" || (k.ends_with(":type") && k.contains("xsi")) {
has_xsi = true;
let v = v.rsplit(':').next().unwrap_or(v);
out.push_str(" xsi:type=\"");
out.push_str(&xml_escape(v));
out.push('"');
}
}
if is_root
&& !has_xsi
&& let Some(h) = type_hint
{
out.push_str(" xsi:type=\"");
out.push_str(&xml_escape(h));
out.push('"');
}
if el.children.is_empty() && el.text.is_empty() {
out.push_str("/>");
return;
}
out.push('>');
if !el.text.is_empty() {
out.push_str(&xml_escape(&el.text));
}
for c in &el.children {
write_el(c, false, None, out);
}
out.push_str("</");
out.push_str(&el.name);
out.push('>');
}
fn xml_escape(s: &str) -> String {
s.replace('&', "&")
.replace('<', "<")
.replace('>', ">")
.replace('"', """)
}
fn parse_typed(el: &El, type_name: &str) -> Result<Value, FlatError> {
use crate::xml::from_canonical_xml as fx;
use openehr_rm::prelude::{
CodePhrase, DataValue, DvBoolean, DvCodedText, DvCount, DvDate, DvDateTime, DvDuration,
DvEhrUri, DvIdentifier, DvMultimedia, DvOrdinal, DvParagraph, DvParsable, DvProportion,
DvQuantity, DvScale, DvState, DvText, DvTime, DvUri, EventContext, IsmTransition, Link,
Participation, PartyProxy,
};
let resolved = el.xsi_type().unwrap_or(type_name);
let xml = to_canonical_xml(el, Some(resolved));
macro_rules! p {
($t:ty) => {{
let v: $t = fx(&xml)
.map_err(|e| FlatError::Conversion(format!("TDD {}: {e}", stringify!($t))))?;
crate::json::to_canonical_value(&v)
}};
}
let value = match resolved {
"DV_TEXT" => p!(DvText),
"DV_PARAGRAPH" => p!(DvParagraph),
"DV_CODED_TEXT" => p!(DvCodedText),
"DV_STATE" => p!(DvState),
"CODE_PHRASE" => p!(CodePhrase),
"DV_DATE_TIME" => p!(DvDateTime),
"DV_DATE" => p!(DvDate),
"DV_TIME" => p!(DvTime),
"DV_DURATION" => p!(DvDuration),
"DV_QUANTITY" => p!(DvQuantity),
"DV_COUNT" => p!(DvCount),
"DV_PROPORTION" => p!(DvProportion),
"DV_ORDINAL" => p!(DvOrdinal),
"DV_SCALE" => p!(DvScale),
"DV_BOOLEAN" => p!(DvBoolean),
"DV_IDENTIFIER" => p!(DvIdentifier),
"DV_MULTIMEDIA" => p!(DvMultimedia),
"DV_URI" => p!(DvUri),
"DV_EHR_URI" => p!(DvEhrUri),
"DV_PARSABLE" => p!(DvParsable),
"PARTY_PROXY" | "PARTY_IDENTIFIED" | "PARTY_SELF" | "PARTY_RELATED" => p!(PartyProxy),
"PARTICIPATION" => p!(Participation),
"EVENT_CONTEXT" => p!(EventContext),
"LINK" => p!(Link),
"ISM_TRANSITION" => p!(IsmTransition),
_ => p!(DataValue),
};
Ok(value)
}
enum Simple {
Typed(&'static str),
Text,
}
fn simple_attr(rm_type: &str, attr: &str) -> Option<Simple> {
use Simple::{Text, Typed};
let comp = rm_type == "COMPOSITION";
let entry = entry_family(rm_type);
Some(match attr {
"name" if comp || entry || matches!(rm_type, "SECTION" | "CLUSTER" | "ACTIVITY") => {
Typed("DV_TEXT")
}
"language" if comp || entry => Typed("CODE_PHRASE"),
"territory" if comp => Typed("CODE_PHRASE"),
"composer" if comp => Typed("PARTY_PROXY"),
"context" if comp => Typed("EVENT_CONTEXT"),
"links" if comp => Typed("LINK"),
"encoding" if entry => Typed("CODE_PHRASE"),
"subject" | "provider" if entry => Typed("PARTY_PROXY"),
"other_participations" if entry => Typed("PARTICIPATION"),
"narrative" if entry => Typed("DV_TEXT"),
"expiry_time" | "time" if entry => Typed("DV_DATE_TIME"),
"ism_transition" if entry => Typed("ISM_TRANSITION"),
"timing" if rm_type == "ACTIVITY" => Typed("DV_PARSABLE"),
"action_archetype_id" if rm_type == "ACTIVITY" => Text,
_ => return None,
})
}
fn is_multi_simple(attr: &str) -> bool {
matches!(attr, "links" | "other_participations")
}
fn entry_family(rm_type: &str) -> bool {
matches!(
rm_type,
"OBSERVATION" | "EVALUATION" | "INSTRUCTION" | "ACTION" | "ADMIN_ENTRY" | "GENERIC_ENTRY"
)
}
fn is_locatable(rm_type: &str) -> bool {
!(rm_type.starts_with("DV_")
|| matches!(
rm_type,
"CODE_PHRASE" | "EVENT_CONTEXT" | "ISM_TRANSITION" | "PARTY_PROXY" | "PARTICIPATION"
))
}
pub fn from_tdd(tdd_xml: &str, wt: &WebTemplate) -> Result<Value, FlatError> {
let root_el = parse_tree(tdd_xml)?;
let root_id = &wt.tree.id;
if !name_matches(&root_el.name, node_display(&wt.tree)) {
return Err(FlatError::Conversion(format!(
"TDD root element <{}> does not match template root {:?} ({})",
root_el.name, root_id, wt.template_id
)));
}
check_conformance(&root_el, &wt.tree, "COMPOSITION")?;
let mut comp = build_node(&root_el, &wt.tree, "COMPOSITION", "", true)?;
if let Value::Object(m) = &mut comp {
ensure_template_id(m, &wt.tree, &wt.template_id);
ensure_category(m);
}
complete_tree(&mut comp);
Ok(comp)
}
const WRAPPER_METADATA: [&str; 6] = ["name", "uid", "links", "feeder_audit", "origin", "time"];
#[expect(
clippy::indexing_slicing,
reason = "`simple`, `matched` and `wrapper` are all sized `el.children.len()` and every index into them is either a `match_indices(el, ..)` position over those same children or the `enumerate()` index of the same iteration, so all are in bounds by construction"
)]
fn check_conformance(el: &El, wt: &WebTemplateNode, rm_type: &str) -> Result<(), FlatError> {
let simple: Vec<bool> = el
.children
.iter()
.map(|c| simple_attr(rm_type, &c.name).is_some())
.collect();
let mut matched: Vec<Option<&WebTemplateNode>> = vec![None; el.children.len()];
let mut wrapper: Vec<bool> = vec![false; el.children.len()];
for wc in &wt.children {
for (idx, is_direct) in match_indices(el, node_display(wc)) {
if is_direct {
matched[idx] = Some(wc);
} else {
wrapper[idx] = true;
}
}
}
for (idx, child) in el.children.iter().enumerate() {
if simple[idx] {
continue;
}
if let Some(wc) = matched[idx] {
if !wc.has_input() {
check_conformance(child, wc, concrete_type(&wc.rm_type))?;
}
continue;
}
if wrapper[idx] {
check_conformance(child, wt, rm_type)?;
continue;
}
if WRAPPER_METADATA.contains(&child.name.as_str()) {
continue;
}
return Err(FlatError::Conversion(format!(
"TDD element <{}> (under <{}>) matches no node of the operational template — \
the document does not conform to the template-derived TDS",
child.name, el.name
)));
}
Ok(())
}
fn match_indices(parent: &El, display: &str) -> Vec<(usize, bool)> {
let mut out = Vec::new();
for (idx, c) in parent.children.iter().enumerate() {
if name_matches(&c.name, display) {
out.push((idx, true));
} else if !find_matches(c, display).is_empty() {
out.push((idx, false));
}
}
out
}
fn build_node(
el: &El,
wt: &WebTemplateNode,
rm_type: &str,
path: &str,
is_root: bool,
) -> Result<Value, FlatError> {
let mut obj = Map::new();
obj.insert("_type".into(), json!(rm_type));
let node_id = if is_root {
wt.node_id.clone()
} else {
last_node_id_str(path)
.map(str::to_owned)
.or_else(|| wt.node_id.clone())
};
if is_locatable(rm_type)
&& let Some(nid) = &node_id
{
obj.insert("archetype_node_id".into(), json!(nid));
if is_archetype_root_node_id(nid) {
obj.insert("archetype_details".into(), archetyped(nid, None));
}
}
let mut i = 0;
while let Some(child) = el.children.get(i) {
i += 1;
let Some(hint) = simple_attr(rm_type, &child.name) else {
continue;
};
match hint {
Simple::Text => {
obj.insert(child.name.clone(), json!(child.text));
}
Simple::Typed(ty) => {
if is_multi_simple(&child.name) {
let arr = obj
.entry(child.name.clone())
.or_insert_with(|| json!([]))
.as_array_mut();
if let Some(arr) = arr {
arr.push(parse_typed(child, ty)?);
}
} else if !obj.contains_key(&child.name) {
obj.insert(child.name.clone(), parse_typed(child, ty)?);
}
}
}
}
for wc in &wt.children {
let rel = rmpath::relative(path, &wc.aql_path);
if rel.is_empty() {
continue;
}
if (wc.in_context == Some(true) || wc.rm_type == "EVENT_CONTEXT")
&& rel
.last()
.is_some_and(|s| simple_attr(rm_type, &s.attribute).is_some())
{
continue;
}
let matches = find_matches(el, node_display(wc));
for cel in matches {
let child_value = if wc.has_input() {
leaf_value(cel, wc)?
} else {
build_node(cel, wc, concrete_type(&wc.rm_type), &wc.aql_path, false)?
};
place(&mut obj, &rel, child_value, wc);
}
}
Ok(Value::Object(obj))
}
fn leaf_value(el: &El, wc: &WebTemplateNode) -> Result<Value, FlatError> {
let dv_type = concrete_type(&wc.rm_type);
let frag = if wc.aql_path.ends_with("/value") {
el.child("value").unwrap_or(el)
} else {
el
};
parse_typed(frag, dv_type)
}
fn is_multiple(attr: &str) -> bool {
is_multiple_attr(attr)
}
static MULTIVALUED_ATTRS: LazyLock<HashSet<&'static str>> = LazyLock::new(|| {
let mut set: HashSet<&'static str> = HashSet::new();
let mut seen: HashSet<&'static str> = HashSet::new();
let mut queue: Vec<&'static str> = vec!["COMPOSITION", "EHR_STATUS", "FOLDER"];
while let Some(cls) = queue.pop() {
if !seen.insert(cls) {
continue;
}
for attr in openehr_rm::model::attributes(cls) {
if openehr_rm::model::class(attr.declared_type).is_none() {
continue;
}
if attr.container != openehr_rm::model::Container::None {
set.insert(attr.name);
}
queue.push(attr.declared_type);
queue.extend(
openehr_rm::model::descendants(attr.declared_type)
.iter()
.copied(),
);
}
}
set
});
pub(crate) fn is_multiple_attr(attr: &str) -> bool {
MULTIVALUED_ATTRS.contains(attr)
}
fn place(
parent: &mut Map<String, Value>,
rel: &[PathSegment],
child_value: Value,
wc: &WebTemplateNode,
) {
if rel.is_empty() {
return;
}
let id_idx = rel.iter().rposition(|s| is_multiple(&s.attribute));
place_rec(parent, rel, 0, id_idx, child_value, wc);
}
#[expect(
clippy::indexing_slicing,
reason = "`i` is a cursor into `rel` that the recursion only advances while `i + 1 < rel.len()` (the `last` guard), so `rel[i]` and `rel[i + 1..]` are in bounds at every call"
)]
fn place_rec(
cur: &mut Map<String, Value>,
rel: &[PathSegment],
i: usize,
id_idx: Option<usize>,
child_value: Value,
wc: &WebTemplateNode,
) {
let seg = &rel[i];
let node_id = seg.predicate.archetype_node_id.as_deref();
let last = i + 1 == rel.len();
if Some(i) == id_idx {
let Some(arr) = cur
.entry(seg.attribute.clone())
.or_insert_with(|| json!([]))
.as_array_mut()
else {
return;
};
if last {
let mut el = child_value;
set_node_id(&mut el, node_id);
arr.push(el);
} else {
let mut el = new_struct(seg, rel.get(i + 1), wc.name.as_deref());
if let Value::Object(m) = &mut el {
place(m, &rel[i + 1..], child_value, wc);
}
arr.push(el);
}
return;
}
if is_multiple(&seg.attribute) {
let Some(arr) = cur
.entry(seg.attribute.clone())
.or_insert_with(|| json!([]))
.as_array_mut()
else {
return;
};
let pos = arr
.iter()
.position(|e| e.get("archetype_node_id").and_then(Value::as_str) == node_id);
let idx = pos.unwrap_or_else(|| {
arr.push(new_struct(seg, rel.get(i + 1), None));
arr.len() - 1
});
if let Some(Value::Object(m)) = arr.get_mut(idx) {
place_rec(m, rel, i + 1, id_idx, child_value, wc);
}
return;
}
if last {
cur.insert(seg.attribute.clone(), child_value);
return;
}
if !cur.contains_key(&seg.attribute) {
cur.insert(seg.attribute.clone(), new_struct(seg, rel.get(i + 1), None));
}
if let Some(Value::Object(m)) = cur.get_mut(&seg.attribute) {
place_rec(m, rel, i + 1, id_idx, child_value, wc);
}
}
fn new_struct(seg: &PathSegment, next: Option<&PathSegment>, name: Option<&str>) -> Value {
let rm_type = infer_type(&seg.attribute, next.map(|s| s.attribute.as_str()));
let node_id = seg.predicate.archetype_node_id.as_deref();
let mut o = Map::new();
o.insert("_type".into(), json!(rm_type));
let display = name.or(node_id).unwrap_or(rm_type).to_owned();
o.insert("name".into(), json!({"_type": "DV_TEXT", "value": display}));
if let Some(nid) = node_id {
o.insert("archetype_node_id".into(), json!(nid));
if is_archetype_root_node_id(nid) {
o.insert("archetype_details".into(), archetyped(nid, None));
}
}
match rm_type {
"HISTORY" => {
o.insert(
"origin".into(),
json!({"_type": "DV_DATE_TIME", "value": DEFAULT_TIME}),
);
o.insert("events".into(), json!([]));
}
"POINT_EVENT" | "EVENT" | "INTERVAL_EVENT" => {
o.insert(
"time".into(),
json!({"_type": "DV_DATE_TIME", "value": DEFAULT_TIME}),
);
}
"ITEM_TREE" | "ITEM_LIST" | "ITEM_SINGLE" | "ITEM_TABLE" | "CLUSTER" => {
o.insert("items".into(), json!([]));
}
_ => {}
}
Value::Object(o)
}
fn infer_type(attr: &str, next: Option<&str>) -> &'static str {
match (attr, next) {
("data", Some("events")) => "HISTORY",
("events", _) => "POINT_EVENT",
("items", _) => "ELEMENT",
("activities", _) => "ACTIVITY",
_ => "ITEM_TREE",
}
}
fn set_node_id(v: &mut Value, node_id: Option<&str>) {
if let (Value::Object(m), Some(nid)) = (v, node_id) {
m.entry("archetype_node_id".to_owned())
.or_insert_with(|| json!(nid));
}
}
fn complete_tree(v: &mut Value) {
match v {
Value::Object(m) => {
if let Some(ty) = m.get("_type").and_then(Value::as_str).map(str::to_owned) {
fill_structural_mandatory(m, &ty);
}
for child in m.values_mut() {
complete_tree(child);
}
}
Value::Array(a) => a.iter_mut().for_each(complete_tree),
_ => {}
}
}
fn fill_structural_mandatory(obj: &mut Map<String, Value>, rm_type: &str) {
let dt = || json!({"_type": "DV_DATE_TIME", "value": DEFAULT_TIME});
let empty_tree = || json!({"_type": "ITEM_TREE", "name": {"_type": "DV_TEXT", "value": "Tree"}, "items": []});
match rm_type {
"HISTORY" => {
obj.entry("origin".to_owned()).or_insert_with(dt);
obj.entry("events".to_owned()).or_insert_with(|| json!([]));
}
"POINT_EVENT" | "EVENT" => {
obj.entry("time".to_owned()).or_insert_with(dt);
obj.entry("data".to_owned()).or_insert_with(empty_tree);
}
"INTERVAL_EVENT" => {
obj.entry("time".to_owned()).or_insert_with(dt);
obj.entry("data".to_owned()).or_insert_with(empty_tree);
obj.entry("width".to_owned())
.or_insert_with(|| json!({"_type": "DV_DURATION", "value": "P0D"}));
obj.entry("math_function".to_owned()).or_insert_with(|| {
json!({"_type": "DV_CODED_TEXT", "value": "mean",
"defining_code": code_phrase("openehr", "146")})
});
}
"ITEM_TREE" | "ITEM_LIST" | "ITEM_SINGLE" | "ITEM_TABLE" | "CLUSTER" => {
obj.entry("items".to_owned()).or_insert_with(|| json!([]));
}
"ACTIVITY" => {
obj.entry("action_archetype_id".to_owned())
.or_insert_with(|| json!("/.*/"));
obj.entry("description".to_owned())
.or_insert_with(empty_tree);
}
"ISM_TRANSITION" => {
obj.entry("current_state".to_owned())
.or_insert_with(|| dv_coded_text("initial", "openehr", "524"));
}
_ => {}
}
}
fn code_phrase(terminology: &str, code: &str) -> Value {
json!({
"_type": "CODE_PHRASE",
"terminology_id": {"_type": "TERMINOLOGY_ID", "value": terminology},
"code_string": code,
})
}
fn dv_coded_text(value: &str, terminology: &str, code: &str) -> Value {
json!({"_type": "DV_CODED_TEXT", "value": value, "defining_code": code_phrase(terminology, code)})
}
fn archetyped(archetype_id: &str, template_id: Option<&str>) -> Value {
let mut a = Map::new();
a.insert("_type".into(), json!("ARCHETYPED"));
a.insert(
"archetype_id".into(),
json!({"_type": "ARCHETYPE_ID", "value": archetype_id}),
);
if let Some(t) = template_id {
a.insert(
"template_id".into(),
json!({"_type": "TEMPLATE_ID", "value": t}),
);
}
a.insert("rm_version".into(), json!(RM_VERSION));
Value::Object(a)
}
fn ensure_template_id(comp: &mut Map<String, Value>, root: &WebTemplateNode, template_id: &str) {
let ad = comp
.entry("archetype_details".to_owned())
.or_insert_with(|| {
json!({"_type": "ARCHETYPED",
"archetype_id": {"_type": "ARCHETYPE_ID", "value": root.node_id},
"rm_version": RM_VERSION})
});
if let Value::Object(ad) = ad {
ad.entry("template_id".to_owned())
.or_insert_with(|| json!({"_type": "TEMPLATE_ID", "value": template_id}));
}
}
fn ensure_category(comp: &mut Map<String, Value>) {
comp.entry("category".to_owned())
.or_insert_with(|| dv_coded_text("event", "openehr", "433"));
}
fn node_display(wc: &WebTemplateNode) -> &str {
wc.name.as_deref().unwrap_or(&wc.id)
}
fn name_matches(el_name: &str, display: &str) -> bool {
let base = el_name.split("_as_").next().unwrap_or(el_name);
normalise(base) == normalise(display)
}
fn normalise(s: &str) -> String {
s.replace(' ', "_")
}
fn find_matches<'a>(parent: &'a El, display: &str) -> Vec<&'a El> {
let mut out = Vec::new();
for c in &parent.children {
if name_matches(&c.name, display) {
out.push(c);
} else {
out.extend(find_matches(c, display));
}
}
out
}
fn concrete_type(rm_type: &str) -> &str {
match rm_type.split('<').next().unwrap_or(rm_type) {
"EVENT" => "POINT_EVENT",
other => other,
}
}
fn last_node_id_str(path: &str) -> Option<&str> {
let close = path.rfind(']')?;
let open = path.get(..close)?.rfind('[')?;
path.get(open + 1..close)
}
#[cfg(test)]
mod multiplicity_tests {
use super::is_multiple_attr;
#[test]
fn covers_legacy_hardcoded_set() {
for attr in ["content", "items", "events", "activities"] {
assert!(
is_multiple_attr(attr),
"model-driven multiplicity set must include the legacy member `{attr}`",
);
}
}
#[test]
fn data_stays_single() {
assert!(
!is_multiple_attr("data"),
"`data` must not be treated as multi-valued (OBSERVATION.data is a single HISTORY)",
);
}
#[test]
fn covers_reachable_multi_valued_attributes() {
assert!(is_multiple_attr("other_participations"));
}
#[test]
fn rejects_single_and_unknown() {
assert!(!is_multiple_attr("value"));
assert!(!is_multiple_attr("not_a_real_attribute"));
}
}