Skip to main content

cimmodel/
convert.rs

1use std::collections::{HashMap, HashSet};
2use std::error::Error;
3use std::fmt::Write as FmtWrite;
4use std::sync::OnceLock;
5
6use crate::CimDataset;
7use crate::base::{Element, FieldValue};
8use crate::base::{AttrKind, Schema};
9use crate::registry::type_registry;
10
11/// Prefix for a class or attribute absent from the generated tables — a third-party
12/// extension, or a CIM version skew between the data and the schema it was generated from.
13const FALLBACK_PREFIX: &str = "cim";
14
15/// RDF metadata for one attribute, resolved to the prefix this writer emits.
16#[derive(Clone, Copy)]
17struct AttrMeta {
18    prefix: &'static str,
19    /// The enum's namespace IRI when `is_enum`; unused otherwise.
20    range: &'static str,
21    is_enum: bool,
22}
23
24/// The CGMES schema, indexed once for writing: prefixes, profile membership and the
25/// namespaces the decoder dropped.
26struct Tables {
27    schema: &'static Schema,
28    type_origins: HashMap<&'static str, &'static [&'static str]>,
29    attr_origins: HashMap<&'static str, &'static [&'static str]>,
30    type_prefix: HashMap<&'static str, &'static str>,
31    type_ns: HashMap<&'static str, &'static str>,
32    attrs: HashMap<&'static str, AttrMeta>,
33    /// Profiles that are the dominant origin of no attribute at all — see
34    /// [`dataset_to_xml_for_profile`].
35    self_defining: HashSet<&'static str>,
36}
37
38fn tables() -> &'static Tables {
39    static T: OnceLock<Tables> = OnceLock::new();
40    T.get_or_init(|| {
41        let schema = type_registry().schema("cgmes").expect("the CGMES family is registered");
42        // The decoder throws namespaces away, so the schema is the only runtime source of
43        // them; its classes and attributes draw on exactly the namespaces it binds, so this
44        // reverse map is total over them.
45        let prefix_of: HashMap<&str, &'static str> =
46            schema.namespaces.iter().map(|(p, ns)| (*ns, *p)).collect();
47        let lookup = |ns: &str| prefix_of.get(ns).copied().unwrap_or(FALLBACK_PREFIX);
48
49        // An attribute's profiles over every class that declares it, the export's dominant
50        // profile first: EQ, then the rest alphabetically.
51        let mut attr_origins: HashMap<&'static str, Vec<&'static str>> = HashMap::new();
52        for a in schema.classes.iter().flat_map(|c| c.attrs).filter(|a| a.used) {
53            let merged = attr_origins.entry(a.id).or_default();
54            for o in a.origins {
55                if !merged.contains(o) {
56                    merged.push(o);
57                }
58            }
59        }
60        attr_origins.retain(|_, o| !o.is_empty());
61        let attr_origins: HashMap<&'static str, &'static [&'static str]> = attr_origins
62            .into_iter()
63            .map(|(id, mut o)| {
64                o.sort_by(|a, b| (*a != "EQ", *a).cmp(&(*b != "EQ", *b)));
65                (id, &*Vec::leak(o))
66            })
67            .collect();
68
69        let mut attrs: HashMap<&'static str, AttrMeta> = HashMap::new();
70        for a in schema.classes.iter().flat_map(|c| c.attrs).filter(|a| !a.ns.is_empty()) {
71            attrs.entry(a.id).or_insert(AttrMeta {
72                prefix: lookup(a.ns),
73                range: a.value_ns,
74                is_enum: a.kind == AttrKind::Enum,
75            });
76        }
77
78        Tables {
79            schema,
80            type_origins: schema
81                .classes
82                .iter()
83                .filter(|c| !c.origins.is_empty())
84                .map(|c| (c.local, c.origins))
85                .collect(),
86            self_defining: schema
87                .profiles
88                .iter()
89                .map(|(code, _)| *code)
90                .filter(|code| !attr_origins.values().any(|o| o.first() == Some(code)))
91                .collect(),
92            attr_origins,
93            type_prefix: schema.classes.iter().map(|c| (c.local, lookup(c.ns))).collect(),
94            type_ns: schema.classes.iter().filter(|c| !c.ns.is_empty()).map(|c| (c.local, c.ns)).collect(),
95            attrs,
96        }
97    })
98}
99
100/// XML prefix for a CIM class, e.g. `eu` for `BoundaryPoint`.
101fn prefix_for_type(type_name: &str) -> &'static str {
102    tables().type_prefix.get(type_name).copied().unwrap_or(FALLBACK_PREFIX)
103}
104
105/// Namespace IRI of a CIM class, for rebuilding a stripped enum value.
106fn type_ns_iri(type_name: &str) -> Option<&'static str> {
107    tables().type_ns.get(type_name).copied()
108}
109
110/// RDF metadata for an `Element::fields` key owned by `type_name`.
111///
112/// Most keys are already `Class.attr` and hit directly. A few arrive bare — the decoder's
113/// `local_name()` reduces `<dm:forwardDifferences>` to `forwardDifferences`, while the schema
114/// holds `DifferenceModel.forwardDifferences` — so retry qualified before giving up.
115fn attr_meta(key: &str, type_name: &str) -> Option<AttrMeta> {
116    let t = tables();
117    t.attrs
118        .get(key)
119        .or_else(|| t.attrs.get(format!("{type_name}.{key}").as_str()))
120        .copied()
121}
122
123pub fn dataset_to_json(ds: &CimDataset) -> serde_json::Value {
124    let mut map = serde_json::Map::new();
125    for (mrid, entry) in &ds.entries {
126        let mut obj = entry.to_json_value().as_object().cloned().unwrap_or_default();
127        obj.insert("_type".into(), entry.type_name().into());
128        map.insert(mrid.clone(), serde_json::Value::Object(obj));
129    }
130    serde_json::Value::Object(map)
131}
132
133pub fn dataset_from_json(json: &str) -> Result<CimDataset, Box<dyn Error>> {
134    let root: serde_json::Map<String, serde_json::Value> = serde_json::from_str(json)?;
135    let reg = type_registry();
136    let mut ds = CimDataset::new();
137    for (mrid, val) in root {
138        let type_name = val["_type"].as_str().unwrap_or("");
139        if let Some(class) = reg.by_type_name(type_name) {
140            ds.set(mrid, Element::from_json(class, reg, &val)?);
141        }
142    }
143    Ok(ds)
144}
145
146/// True when the element belongs to another family than CGMES, whose
147/// qualified type names (`nc:Contingency`) the CGMES writer cannot emit;
148/// [`crate::convert_nc`] writes those.
149fn is_foreign_family(type_name: &str) -> bool {
150    type_name.contains(':')
151}
152
153/// The whole dataset as RDF/XML: every CGMES element under the CGMES
154/// prefixes, then every NC element and every object of a class no table knows
155/// ([`CimDataset::foreign`]) as it was read ([`crate::convert_nc`]), under
156/// prefixes that do not clash with CGMES's (NC's `cim:` is another namespace).
157pub fn dataset_to_xml(ds: &CimDataset) -> Result<String, Box<dyn Error>> {
158    let mut body = String::new();
159    let cgmes = tables().schema.namespaces;
160    let mut prefixes = crate::convert_nc::Prefixes::seeded(ds, cgmes);
161
162    let mut mrids: Vec<&str> = ds.entries.keys().map(String::as_str).collect();
163    mrids.sort();
164
165    for mrid in mrids {
166        let entry = &ds.entries[mrid];
167        let block = entry;
168        let type_name = block.type_name();
169        if is_foreign_family(type_name) {
170            continue;
171        }
172        // The namespaces it was read in, where they differ from the schema's.
173        let ns = match ds.type_ns.get(mrid) {
174            Some(written) => prefixes.of(written),
175            None => prefix_for_type(type_name).to_string(),
176        };
177        write!(body, "  <{ns}:{type_name} {}>", about(mrid))?;
178
179        // Emit fields sorted for deterministic output
180        let mut fields: Vec<(&&'static str, &FieldValue)> = block.fields().iter().collect();
181        fields.sort_by_key(|(k, _)| **k);
182
183        let mut children = String::new();
184        for (key, val) in fields {
185            let written = ds.field_forms.get(&(type_name, *key)).and_then(|f| f.ns).map(|n| prefixes.of(n));
186            write_field_as(&mut children, key, val, type_name, "#", ds, written.as_deref())?;
187        }
188
189        if children.is_empty() {
190            write!(body, "/>\n")?;
191        } else {
192            write!(body, "{children}\n  </{ns}:{type_name}>\n")?;
193        }
194    }
195
196    crate::convert_nc::write_as_read(&mut body, &mut prefixes, ds)?;
197
198    let mut out = String::new();
199    out.push_str("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n");
200    out.push_str("<rdf:RDF");
201    for (prefix, uri) in cgmes {
202        write!(out, " xmlns:{prefix}=\"{uri}\"")?;
203    }
204    let mut extra: Vec<(&String, &&str)> = prefixes
205        .bound
206        .iter()
207        .filter(|(ns, _)| !cgmes.iter().any(|(_, c)| c == *ns))
208        .map(|(ns, p)| (p, ns))
209        .collect();
210    extra.sort();
211    for (prefix, uri) in extra {
212        write!(out, " xmlns:{prefix}=\"{}\"", escape_attr(uri))?;
213    }
214    out.push_str(">\n");
215    out.push_str(&body);
216    out.push_str("</rdf:RDF>\n");
217    Ok(out)
218}
219
220/// Export the dataset's content for one profile, CGMES (`EQ`, `SSH`, …) or NC
221/// (`CO`, `AE`, … — [`crate::convert_nc`]), with that profile's header.
222///
223/// A code neither family defines is an error: an empty document would look
224/// like an export of nothing.
225pub fn dataset_to_xml_for_profile(
226    ds: &CimDataset,
227    profile_code: &str,
228) -> Result<String, Box<dyn Error>> {
229    let tables = tables();
230    if !tables.schema.profiles.iter().any(|(k, _)| *k == profile_code) {
231        if crate::convert_nc::nc_profile_uri(profile_code).is_some() {
232            return crate::convert_nc::dataset_to_xml_for_nc_profile(ds, profile_code);
233        }
234        return Err(format!("unknown profile code {profile_code:?}: neither a CGMES nor an NC profile").into());
235    }
236    let type_map = &tables.type_origins;
237    let attr_map = &tables.attr_origins;
238
239    // A profile that is the dominant origin of no attribute cannot select any content under
240    // the secondary-element rule below — every element would empty out and be dropped, and
241    // the file would contain nothing but its header. EQBD is the only such profile today: the
242    // RDFS declares its classes and attributes identically in the Equipment profile, so EQ
243    // always outranks it. Real boundary files define their objects outright (all `rdf:ID`,
244    // carrying name and mRID), so fall back to plain profile membership, which is all the
245    // schema actually asserts.
246    let self_defining = tables.self_defining.contains(profile_code);
247
248    let mut out = String::new();
249    out.push_str("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n");
250    out.push_str("<rdf:RDF");
251    for (prefix, uri) in tables.schema.namespaces {
252        write!(out, " xmlns:{prefix}=\"{uri}\"")?;
253    }
254    out.push_str(">\n");
255
256    // FullModel header — declares the profile this file belongs to. Reuse a real
257    // decoded FullModel entry for this profile if the dataset has one (preserves
258    // scenarioTime/modelingAuthoritySet/DependentOn/version/etc.), else fall back
259    // to a minimal synthetic header.
260    if let Some(&(_, uri)) = tables.schema.profiles.iter().find(|(k, _)| *k == profile_code) {
261        // `md` via the same tables the body uses: the schema puts FullModel and every
262        // Model.* field in the ModelDescription namespace. The header keeps only its
263        // genuinely different behaviour — `rdf:about` with no `#`, and no fragment on
264        // resource references.
265        let hdr = prefix_for_type("FullModel");
266        if let Some((mrid, block)) = find_full_model_header(ds, uri) {
267            write!(out, "  <{hdr}:FullModel rdf:about=\"{}\">", escape_attr(mrid))?;
268
269            let mut fields: Vec<(&&'static str, &FieldValue)> = block.fields().iter().collect();
270            fields.sort_by_key(|(k, _)| **k);
271
272            let mut children = String::new();
273            for (key, val) in fields {
274                write_field(&mut children, key, val, "FullModel", "", ds)?;
275            }
276
277            if children.is_empty() {
278                write!(out, "/>\n")?;
279            } else {
280                write!(out, "{children}\n  </{hdr}:FullModel>\n")?;
281            }
282        } else {
283            write_synthesized_header(&mut out, ds, profile_code, uri)?;
284        }
285    }
286
287    let mut mrids: Vec<&str> = ds.entries.keys().map(String::as_str).collect();
288    mrids.sort();
289
290    for mrid in mrids {
291        let entry = &ds.entries[mrid];
292        let type_name = entry.type_name();
293
294        // Another family's element has no CGMES profile membership and no entry
295        // in these tables, so it would fall through to `write_carried_fields`
296        // and emit nothing. Skip it outright rather than rely on that.
297        if is_foreign_family(type_name) {
298            continue;
299        }
300
301        let type_origins: &[&str] = type_map.get(type_name).copied().unwrap_or(&[]);
302        if !type_origins.contains(&profile_code) {
303            write_carried_fields(&mut out, mrid, entry, profile_code, type_map, attr_map, ds)?;
304            continue;
305        }
306
307        let is_primary =
308            self_defining || type_origins.first().map_or(false, |&o| o == profile_code);
309        let block = entry;
310
311        let include_field = |key: &str| -> bool {
312            let origins = attr_map.get(key).copied().unwrap_or(&[]);
313            if is_primary {
314                origins.contains(&profile_code)
315            } else {
316                origins.first().map_or(false, |&o| o == profile_code)
317            }
318        };
319
320        let mut fields: Vec<(&&'static str, &FieldValue)> =
321            block.fields().iter().filter(|(k, _)| include_field(k)).collect();
322
323        if fields.is_empty() {
324            continue;
325        }
326
327        let ns = prefix_for_type(type_name);
328        if is_primary {
329            if mrid.contains(':') {
330                write!(out, "  <{ns}:{type_name} {}>", about(mrid))?;
331            } else {
332                write!(out, "  <{ns}:{type_name} rdf:ID=\"{}\">", escape_attr(mrid))?;
333            }
334        } else {
335            write!(out, "  <{ns}:{type_name} {}>", about(mrid))?;
336        }
337
338        fields.sort_by_key(|(k, _)| **k);
339        let mut children = String::new();
340        for (key, val) in fields {
341            write_field(&mut children, key, val, type_name, "#", ds)?;
342        }
343
344        if children.is_empty() {
345            write!(out, "/>\n")?;
346        } else {
347            write!(out, "{children}\n  </{ns}:{type_name}>\n")?;
348        }
349    }
350
351    out.push_str("</rdf:RDF>\n");
352    Ok(out)
353}
354
355/// Namespace for the v5 UUIDs of synthesized FullModel headers.
356const HEADER_UUID_NAMESPACE: [u8; 16] = [
357    0x3b, 0x8e, 0x5f, 0x0a, 0x9c, 0x41, 0x4d, 0x2e, 0xa7, 0x16, 0x5d, 0x0b, 0x6f, 0x93, 0xc2, 0x48,
358];
359/// Placeholders for mandatory header values the dataset does not provide.
360const UNKNOWN_TIME: &str = "1970-01-01T00:00:00Z";
361const UNKNOWN_AUTHORITY: &str = "urn:cimoxide:unknown-modeling-authority-set";
362
363/// Writes a FullModel header for a profile the dataset has no header for.
364///
365/// The Header profile (IEC 61970-552, `Header-AP-Voc-RDFS2020`) makes `Model.created`,
366/// `Model.description`, `Model.modelingAuthoritySet`, `Model.scenarioTime` and
367/// `Model.version` mandatory (1..1) and `Model.profile` 1..n, and a model is identified by
368/// a `urn:uuid:` URN. Readers rely on this: PowSyBl, for one, ignores an SSH file whose
369/// header has no `Model.modelingAuthoritySet`, silently importing no loads, setpoints or
370/// switch states.
371///
372/// - The identifier is a v5 UUID of the profile and the dataset's mRIDs, so encoding stays a
373///   pure function of the dataset and different datasets get different model ids.
374/// - `scenarioTime`, `modelingAuthoritySet` and `created` come from another FullModel
375///   decoded into the dataset when there is one (one dataset describes one scenario from
376///   one authority), else from recognisable placeholders. Callers that know the real values
377///   add a FullModel entry for the profile, which is then written as is.
378fn write_synthesized_header(
379    out: &mut String,
380    ds: &CimDataset,
381    profile_code: &str,
382    profile_uri: &str,
383) -> Result<(), Box<dyn Error>> {
384    let hdr = prefix_for_type("FullModel");
385    let md = |field: &str| {
386        attr_meta(&format!("Model.{field}"), "FullModel").map_or(FALLBACK_PREFIX, |m| m.prefix)
387    };
388    let inherited = |field: &str| -> Option<String> {
389        let mut headers: Vec<(&String, &Element)> = ds
390            .entries
391            .iter()
392            .filter(|(_, e)| e.type_name() == "FullModel")
393            .collect();
394        headers.sort_by_key(|(m, _)| m.as_str());
395        headers.into_iter().find_map(|(_, b)| match b.fields().get(format!("Model.{field}").as_str()) {
396            Some(FieldValue::Text(v)) if !v.is_empty() => Some(v.clone()),
397            _ => None,
398        })
399    };
400    let scenario_time = inherited("scenarioTime").unwrap_or_else(|| UNKNOWN_TIME.to_string());
401    let created = inherited("created").unwrap_or_else(|| scenario_time.clone());
402    let authority = inherited("modelingAuthoritySet").unwrap_or_else(|| UNKNOWN_AUTHORITY.to_string());
403
404    writeln!(out, "  <{hdr}:FullModel rdf:about=\"{}\">", header_urn(ds, profile_code))?;
405    let text = |out: &mut String, field: &str, value: &str| -> std::fmt::Result {
406        let p = md(field);
407        writeln!(out, "    <{p}:Model.{field}>{}</{p}:Model.{field}>", escape_text(value))
408    };
409    text(out, "created", &created)?;
410    text(out, "description", &format!("{profile_code} profile, header synthesized by cimoxide"))?;
411    text(out, "modelingAuthoritySet", &authority)?;
412    text(out, "profile", profile_uri)?;
413    text(out, "scenarioTime", &scenario_time)?;
414    text(out, "version", "1")?;
415    writeln!(out, "  </{hdr}:FullModel>")?;
416    Ok(())
417}
418
419/// `urn:uuid:` + a v5 UUID (RFC 9562, SHA-1 name-based) of the profile code and the
420/// dataset's sorted mRIDs.
421pub(crate) fn header_urn(ds: &CimDataset, profile_code: &str) -> String {
422    use sha1::{Digest, Sha1};
423    let mut mrids: Vec<&str> = ds.entries.keys().map(String::as_str).collect();
424    mrids.sort_unstable();
425    let mut h = Sha1::new();
426    h.update(HEADER_UUID_NAMESPACE);
427    h.update(profile_code.as_bytes());
428    for m in mrids {
429        h.update([0u8]);
430        h.update(m.as_bytes());
431    }
432    let d = h.finalize();
433    let mut b = [0u8; 16];
434    b.copy_from_slice(&d[..16]);
435    b[6] = (b[6] & 0x0f) | 0x50; // version 5
436    b[8] = (b[8] & 0x3f) | 0x80; // RFC 4122 variant
437    let hex: String = b.iter().map(|x| format!("{x:02x}")).collect();
438    format!("urn:uuid:{}-{}-{}-{}-{}", &hex[0..8], &hex[8..12], &hex[12..16], &hex[16..20], &hex[20..32])
439}
440
441/// Find the decoded `FullModel` entry (if any) whose `Model.profile` field names
442/// `profile_uri`. If more than one matches, the lexicographically smallest MRID
443/// wins, for deterministic output.
444fn find_full_model_header<'a>(ds: &'a CimDataset, profile_uri: &str) -> Option<(&'a str, &'a Element)> {
445    let mut best: Option<(&str, &Element)> = None;
446    for (mrid, entry) in &ds.entries {
447        if entry.type_name() != "FullModel" {
448            continue;
449        }
450        let matches = match entry.fields().get("Model.profile") {
451            Some(FieldValue::Text(s)) => s == profile_uri,
452            Some(FieldValue::TextList(list)) => list.iter().any(|s| s == profile_uri),
453            _ => false,
454        };
455        if !matches {
456            continue;
457        }
458        if best.is_none_or(|(m, _)| mrid.as_str() < m) {
459            best = Some((mrid.as_str(), entry));
460        }
461    }
462    best
463}
464
465/// Writes the fields of an element whose own class does not belong to `profile_code`,
466/// but which carries attributes of a superclass that does, typed as that superclass.
467///
468/// CGMES 3.0 states `Equipment.inService` in SSH for equipment whose class SSH does not
469/// list (ACLineSegment, PowerTransformer, BusbarSection, ...) as
470/// `<cim:Equipment rdf:about="#...">`; that is why the SSH RDFS declares Equipment concrete.
471/// Without this, those elements were skipped entirely: re-encoding the SmallGrid fixture
472/// dropped all 314 of them, and readers that require the statement (cgmes2pgm converts no
473/// line without it) lost the equipment.
474///
475/// A field qualifies when its attribute exists in `profile_code` only (so no other profile
476/// can state it) and the class declaring it (the `Class.` part of its key) belongs to
477/// `profile_code`. Attributes shared across profiles, such as `IdentifiedObject.name`, never
478/// qualify: they are stated where the element's own class is. Always `rdf:about`: the
479/// element itself is defined by its own class's profile.
480fn write_carried_fields(
481    out: &mut String,
482    mrid: &str,
483    block: &Element,
484    profile_code: &str,
485    type_map: &HashMap<&'static str, &'static [&'static str]>,
486    attr_map: &HashMap<&'static str, &'static [&'static str]>,
487    ds: &CimDataset,
488) -> Result<(), Box<dyn Error>> {
489    let mut by_class: std::collections::BTreeMap<&str, Vec<(&&'static str, &FieldValue)>> =
490        std::collections::BTreeMap::new();
491    for (key, val) in block.fields() {
492        let only_here = attr_map.get(*key).is_some_and(|o| *o == [profile_code]);
493        let Some((class, _)) = key.split_once('.') else { continue };
494        let class_in_profile = type_map.get(class).is_some_and(|o| o.contains(&profile_code));
495        if only_here && class_in_profile {
496            by_class.entry(class).or_default().push((key, val));
497        }
498    }
499    for (class, mut fields) in by_class {
500        let ns = prefix_for_type(class);
501        write!(out, "  <{ns}:{class} {}>", about(mrid))?;
502        fields.sort_by_key(|(k, _)| **k);
503        let mut children = String::new();
504        for (key, val) in fields {
505            write_field(&mut children, key, val, class, "#", ds)?;
506        }
507        write!(out, "{children}\n  </{ns}:{class}>\n")?;
508    }
509    Ok(())
510}
511
512/// Write one field, with its own namespace prefix taken from the schema.
513///
514/// The prefix is per field, never inherited from the owning class: a single
515/// `eu:BoundaryPoint` carries both `eu:BoundaryPoint.toEndName` and
516/// `cim:IdentifiedObject.description`.
517///
518/// `resource_prefix` is `"#"` for ordinary fields (local fragment references) and `""` for
519/// FullModel header fields (`Model.DependentOn`/`Model.Supersedes` reference another
520/// FullModel's full URN, not a local fragment) — matches how the decoder strips at most one
521/// leading `#` from `rdf:resource` values on the way in. Enum values ignore it entirely and
522/// are written as absolute IRIs, which is what real CGMES files carry:
523/// `rdf:resource="http://iec.ch/TC57/CIM100#DCPolarityKind.positive"`. The decoder's
524/// `strip_fragment` reduces either form to the same key, so this round-trips unchanged.
525fn write_field(
526    children: &mut String,
527    key: &str,
528    val: &FieldValue,
529    type_name: &str,
530    resource_prefix: &str,
531    ds: &CimDataset,
532) -> Result<(), Box<dyn Error>> {
533    write_field_as(children, key, val, type_name, resource_prefix, ds, None)
534}
535
536/// [`write_field`] under `prefix` instead of the schema's, when given.
537fn write_field_as(
538    children: &mut String,
539    key: &str,
540    val: &FieldValue,
541    type_name: &str,
542    resource_prefix: &str,
543    ds: &CimDataset,
544    prefix: Option<&str>,
545) -> Result<(), Box<dyn Error>> {
546    let meta = attr_meta(key, type_name);
547    let ns = prefix.unwrap_or_else(|| meta.map_or(FALLBACK_PREFIX, |m| m.prefix));
548    let resource = |r: &str| {
549        if let Some(m) = meta
550            && m.is_enum
551        {
552            return format!("{}{}", m.range, escape_attr(r));
553        }
554        // `eu:LimitKind` and `eu:SVCControlMode` are enumerations generated as marker structs
555        // — `cims:stereotype` parsing is last-write-wins and their `European` stereotype
556        // overwrites the `enumeration` one — so the schema marks them as plain references. A
557        // value naming no entry but matching a known `Type.value` is that case; rebuild the
558        // IRI the decoder stripped, as `cimsparql::iri::reference_iri` does on the way in.
559        if !ds.entries.contains_key(r)
560            && let Some((owner, _)) = r.split_once('.')
561            && let Some(ns_iri) = type_ns_iri(owner)
562        {
563            return format!("{ns_iri}{}", escape_attr(r));
564        }
565        // An IRI or URN (`urn:uuid:…`) is already absolute.
566        if r.contains(':') {
567            return escape_attr(r);
568        }
569        format!("{resource_prefix}{}", escape_attr(r))
570    };
571
572    match val {
573        FieldValue::Text(s) => {
574            write!(children, "\n    <{ns}:{key}>{}</{ns}:{key}>", escape_text(s))?;
575        }
576        FieldValue::TextList(ss) => {
577            for s in ss {
578                write!(children, "\n    <{ns}:{key}>{}</{ns}:{key}>", escape_text(s))?;
579            }
580        }
581        FieldValue::Resource(r) => {
582            write!(children, "\n    <{ns}:{key} rdf:resource=\"{}\"/>", resource(r))?;
583        }
584        FieldValue::ResourceList(rs) => {
585            for r in rs {
586                write!(children, "\n    <{ns}:{key} rdf:resource=\"{}\"/>", resource(r))?;
587            }
588        }
589    }
590    Ok(())
591}
592
593/// `rdf:about` for an object: its mRID as a fragment of the document, or as
594/// written when it is an IRI or URN (a header's `urn:uuid:…`).
595fn about(mrid: &str) -> String {
596    if mrid.contains(':') {
597        format!("rdf:about=\"{}\"", escape_attr(mrid))
598    } else {
599        format!("rdf:about=\"#{}\"", escape_attr(mrid))
600    }
601}
602
603fn escape_text(s: &str) -> String {
604    s.replace('&', "&amp;")
605        .replace('<', "&lt;")
606        .replace('>', "&gt;")
607}
608
609fn escape_attr(s: &str) -> String {
610    s.replace('&', "&amp;")
611        .replace('<', "&lt;")
612        .replace('"', "&quot;")
613}