Skip to main content

cimschema/
import.rs

1use std::collections::HashMap;
2use std::fs;
3
4use quick_xml::events::Event;
5use quick_xml::Reader;
6
7use super::family::Family;
8use super::model::*;
9
10// Collects all fields from one rdf:Description block during XML parsing.
11#[derive(Debug, Default)]
12struct Block {
13    about: String,
14    rdf_types: Vec<String>,
15    label: String,
16    comment: String,
17    sub_class_of: String,
18    domain: String,
19    range: String,
20    // Every `cims:stereotype` value on the block, in document order. Which one
21    // classifies the block depends on the family (see `Block::classifier`).
22    stereotypes: Vec<String>,
23    /// Any of the block's `cims:stereotype` values is `concrete`.
24    concrete: bool,
25    multiplicity: String,
26    association_used: String,
27    inverse_role: String,
28    data_type: String,
29    is_fixed: String,
30    category: String,
31    version_info: String,
32    version_iri: String,
33    keyword: String,
34    title: String,
35}
36
37/// Stereotypes that decide which bucket a block lands in. Everything else
38/// (`NC`, `profcim`, `deprecated`, `concrete`, ...) is an annotation.
39const CLASSIFIERS: &[&str] = &["enumeration", "Primitive", "CIMDatatype", "Compound"];
40
41impl Block {
42    /// The stereotype that classifies this block.
43    ///
44    /// CGMES blocks carry at most one meaningful stereotype, so the historical
45    /// last-wins reading is kept. NCP writes the classifier first and trails
46    /// profile tags, which last-wins would misread: 94 enumeration blocks and
47    /// 72+ Primitive blocks end on `NC`/`profcim`/`deprecated` and would be
48    /// classified as plain classes.
49    fn classifier(&self, family: &Family) -> String {
50        if family.stereotype_first_wins {
51            if let Some(s) = self
52                .stereotypes
53                .iter()
54                .map(|s| uri_end(s))
55                .find(|s| CLASSIFIERS.contains(&s.as_str()))
56            {
57                return s;
58            }
59        }
60        self.stereotypes.last().map(|s| uri_end(s)).unwrap_or_default()
61    }
62
63    fn set(&mut self, tag: &str, value: String) {
64        match tag {
65            "rdf:type" => self.rdf_types.push(value),
66            "rdfs:label" => self.label = value,
67            "rdfs:comment" => self.comment = value,
68            "rdfs:subClassOf" => self.sub_class_of = value,
69            "rdfs:domain" => self.domain = value,
70            "rdfs:range" => self.range = value,
71            "cims:stereotype" => {
72                self.concrete |= uri_end(&value) == "concrete";
73                self.stereotypes.push(value);
74            }
75            "cims:multiplicity" => self.multiplicity = value,
76            "cims:AssociationUsed" => self.association_used = value,
77            "cims:inverseRoleName" => self.inverse_role = value,
78            "cims:dataType" => self.data_type = value,
79            "cims:isFixed" => self.is_fixed = value,
80            "cims:belongsToCategory" => self.category = value,
81            "owl:versionInfo" => self.version_info = value,
82            "owl:versionIRI" => self.version_iri = value,
83            "dcat:keyword" => self.keyword = value,
84            "dcterms:title" => self.title = value,
85            _ => {}
86        }
87    }
88}
89
90pub fn import_schema_files(
91    pattern: &str,
92    family: &'static Family,
93    verbose: bool,
94) -> Result<CimSpecification, Box<dyn std::error::Error>> {
95    let mut paths: Vec<String> = glob::glob(pattern)?
96        .filter_map(Result::ok)
97        .map(|p| p.to_string_lossy().into_owned())
98        .collect();
99
100    if paths.is_empty() {
101        return Err(format!("no schema files matched: {pattern}").into());
102    }
103    paths.sort();
104
105    let mut spec = CimSpecification::new(family);
106    for path in &paths {
107        if verbose {
108            eprintln!("parsing {path}");
109        }
110        parse_file(path, family, &mut spec)?;
111    }
112
113    super::processing::postprocess(&mut spec);
114    Ok(spec)
115}
116
117fn parse_file(
118    path: &str,
119    family: &'static Family,
120    spec: &mut CimSpecification,
121) -> Result<(), Box<dyn std::error::Error>> {
122    let content = fs::read_to_string(path)
123        .map_err(|e| format!("cannot read {path}: {e}"))?;
124
125    let mut reader = Reader::from_str(&content);
126    let mut buf = Vec::new();
127
128    let mut ns_map: HashMap<String, String> = HashMap::new();
129    let mut blocks: Vec<Block> = Vec::new();
130    let mut current: Option<Block> = None;
131    let mut current_tag: Option<String> = None;
132
133    loop {
134        match reader.read_event_into(&mut buf) {
135            Ok(Event::Start(ref e)) => {
136                let name = bytes_to_str(e.name().as_ref())?;
137
138                if name == "rdf:RDF" {
139                    for attr in e.attributes().flatten() {
140                        let key = bytes_to_str(attr.key.as_ref())?;
141                        let val = std::str::from_utf8(&attr.value)?.to_string();
142                        if let Some(prefix) = key.strip_prefix("xmlns:").or_else(|| key.strip_prefix("xml:")) {
143                            let ns = ensure_terminated(val);
144                            ns_map.insert(prefix.to_string(), ns);
145                        }
146                    }
147                } else if name == "rdf:Description" {
148                    let mut b = Block::default();
149                    for attr in e.attributes().flatten() {
150                        let key = bytes_to_str(attr.key.as_ref())?;
151                        if key == "rdf:about" {
152                            b.about = std::str::from_utf8(&attr.value)?.to_string();
153                        }
154                    }
155                    current = Some(b);
156                } else if current.is_some() {
157                    let resource = find_resource(e.attributes());
158                    if let Some(res) = resource {
159                        if let Some(ref mut b) = current {
160                            b.set(&name, res);
161                        }
162                        current_tag = None;
163                    } else {
164                        current_tag = Some(name.to_string());
165                    }
166                }
167            }
168
169            Ok(Event::Empty(ref e)) => {
170                let name = bytes_to_str(e.name().as_ref())?;
171
172                if name == "rdf:Description" {
173                    let mut b = Block::default();
174                    for attr in e.attributes().flatten() {
175                        let key = bytes_to_str(attr.key.as_ref())?;
176                        if key == "rdf:about" {
177                            b.about = std::str::from_utf8(&attr.value)?.to_string();
178                        }
179                    }
180                    blocks.push(b);
181                } else if current.is_some() {
182                    if let Some(res) = find_resource(e.attributes()) {
183                        if let Some(ref mut b) = current {
184                            b.set(&name, res);
185                        }
186                    }
187                }
188            }
189
190            Ok(Event::Text(ref e)) => {
191                if let Some(tag) = current_tag.take() {
192                    let text = e.unescape()?.into_owned();
193                    let text = text.trim().to_string();
194                    if !text.is_empty() {
195                        if let Some(ref mut b) = current {
196                            b.set(&tag, text);
197                        }
198                    }
199                }
200            }
201
202            Ok(Event::End(ref e)) => {
203                let name = bytes_to_str(e.name().as_ref())?;
204                if name == "rdf:Description" {
205                    if let Some(b) = current.take() {
206                        blocks.push(b);
207                    }
208                }
209                current_tag = None;
210            }
211
212            Ok(Event::Eof) => break,
213            Err(e) => return Err(Box::new(e)),
214            _ => {}
215        }
216        buf.clear();
217    }
218
219    // Find ontology keyword first (needed to set origin on all other objects)
220    let keyword = blocks
221        .iter()
222        .find(|b| b.rdf_types.iter().any(|t| t.contains("owl#Ontology")))
223        .map(|b| b.keyword.clone())
224        .unwrap_or_default();
225
226    // Classify and collect per-file results
227    let mut types: HashMap<String, CimType> = HashMap::new();
228    let mut enums: HashMap<String, CimEnum> = HashMap::new();
229    let mut datatypes: HashMap<String, CimDatatype> = HashMap::new();
230    let mut primitives: HashMap<String, CimPrimitive> = HashMap::new();
231    let mut attributes: Vec<CimAttribute> = Vec::new();
232    let mut enum_values: Vec<CimEnumValue> = Vec::new();
233    let mut ontology = CimOntology::default();
234
235    for b in blocks {
236        let type_str = b.rdf_types.join(" ");
237        if type_str.contains("rdf-schema#Class") {
238            let stereo = b.classifier(family);
239            if stereo == "enumeration" {
240                let mut e = make_enum(&b, family);
241                e.origin = keyword.clone();
242                e.origins = vec![keyword.clone()];
243                enums.insert(e.id.clone(), e);
244            } else if stereo == "CIMDatatype" {
245                let d = make_datatype(&b, family);
246                datatypes.insert(d.id.clone(), d);
247            } else if stereo == "Primitive" {
248                let p = make_primitive(&b, family);
249                primitives.insert(p.id.clone(), p);
250            } else {
251                let mut t = make_type(&b, family);
252                t.origin = keyword.clone();
253                t.origins = vec![keyword.clone()];
254                if b.concrete {
255                    t.concrete_in = vec![keyword.clone()];
256                }
257                types.insert(t.id.clone(), t);
258            }
259        } else if type_str.contains("rdf-syntax-ns#Property") {
260            let mut attr = make_attribute(&b, family);
261            attr.origin = keyword.clone();
262            attr.origins = vec![keyword.clone()];
263            attributes.push(attr);
264        } else if type_str.contains("owl#Ontology") {
265            ontology = make_ontology(&b);
266        } else if type_str.contains("ClassCategory") {
267            // skip package category nodes
268        } else if !type_str.is_empty() {
269            // Enum value: rdf:type points to the owning enum class
270            enum_values.push(make_enum_value(&b, family));
271        }
272    }
273
274    assign_attrs_to_types(&mut types, &attributes);
275    assign_attrs_to_datatypes(&mut datatypes, &attributes);
276    assign_values_to_enums(&mut enums, &enum_values);
277
278    merge_types(&mut spec.types, types);
279    merge_enums(&mut spec.enums, enums);
280    merge_datatypes(&mut spec.cim_datatypes, datatypes);
281    merge_primitives(&mut spec.primitive_types, primitives);
282
283    if !ontology.keyword.is_empty() {
284        spec.ontologies.insert(ontology.keyword.clone(), ontology);
285    }
286
287    for (k, v) in ns_map {
288        spec.specification_namespaces.entry(k).or_insert(v);
289    }
290
291    Ok(())
292}
293
294// --- constructors -----------------------------------------------------------
295
296fn make_type(b: &Block, family: &Family) -> CimType {
297    CimType {
298        id: uri_end(&b.about),
299        label: b.label.clone(),
300        comment: clean_text(&b.comment),
301        namespace: uri_path(&b.about),
302        super_type: uri_end(&b.sub_class_of),
303        cim_stereotype: b.classifier(family),
304        rdf_type: b.rdf_types.first().map(|s| uri_end(s)).unwrap_or_default(),
305        cim_categories: vec![uri_end(&b.category)],
306        ..Default::default()
307    }
308}
309
310fn make_primitive(b: &Block, family: &Family) -> CimPrimitive {
311    CimPrimitive {
312        id: uri_end(&b.about),
313        label: b.label.clone(),
314        comment: clean_text(&b.comment),
315        namespace: uri_path(&b.about),
316        cim_stereotype: b.classifier(family),
317        rdf_type: b.rdf_types.first().map(|s| uri_end(s)).unwrap_or_default(),
318        ..Default::default()
319    }
320}
321
322fn make_datatype(b: &Block, family: &Family) -> CimDatatype {
323    CimDatatype {
324        id: uri_end(&b.about),
325        label: b.label.clone(),
326        comment: clean_text(&b.comment),
327        namespace: uri_path(&b.about),
328        cim_stereotype: b.classifier(family),
329        rdf_type: b.rdf_types.first().map(|s| uri_end(s)).unwrap_or_default(),
330        cim_category: uri_end(&b.category),
331        ..Default::default()
332    }
333}
334
335fn make_enum(b: &Block, family: &Family) -> CimEnum {
336    CimEnum {
337        id: uri_end(&b.about),
338        label: b.label.clone(),
339        comment: clean_text(&b.comment),
340        namespace: uri_path(&b.about),
341        cim_stereotype: b.classifier(family),
342        rdf_type: b.rdf_types.first().map(|s| uri_end(s)).unwrap_or_default(),
343        ..Default::default()
344    }
345}
346
347fn make_enum_value(b: &Block, family: &Family) -> CimEnumValue {
348    CimEnumValue {
349        id: uri_end(&b.about),
350        label: b.label.clone(),
351        comment: clean_text(&b.comment),
352        cim_stereotype: b.classifier(family),
353        rdf_type: b.rdf_types.first().map(|s| uri_end(s)).unwrap_or_default(),
354    }
355}
356
357fn make_ontology(b: &Block) -> CimOntology {
358    CimOntology {
359        id: uri_end(&b.about),
360        namespace: uri_path(&b.about),
361        rdf_type: b.rdf_types.first().map(|s| uri_end(s)).unwrap_or_default(),
362        owl_version_iri: b.version_iri.clone(),
363        owl_version_info: b.version_info.clone(),
364        keyword: b.keyword.clone(),
365        name: b.title.trim_end_matches(" Vocabulary").to_string(),
366        priority: 0,
367    }
368}
369
370fn make_attribute(b: &Block, family: &Family) -> CimAttribute {
371    let assoc = b.association_used.to_lowercase();
372    let is_assoc = assoc == "yes" || assoc.is_empty();
373    let is_list = is_list_multiplicity(&b.multiplicity);
374    CimAttribute {
375        id: uri_end(&b.about),
376        label: b.label.clone(),
377        comment: clean_text(&b.comment),
378        namespace: uri_path(&b.about),
379        cim_stereotype: b.classifier(family),
380        rdf_domain: uri_end(&b.domain),
381        rdf_range: uri_end(&b.range),
382        cim_data_type: uri_end(&b.data_type),
383        rdf_type: b.rdf_types.first().map(|s| uri_end(s)).unwrap_or_default(),
384        cim_association_used: assoc,
385        is_association_used: is_assoc,
386        cim_inverse_role: uri_end(&b.inverse_role),
387        cim_multiplicity: b.multiplicity.clone(),
388        is_list,
389        cim_is_fixed: b.is_fixed.clone(),
390        ..Default::default()
391    }
392}
393
394// --- assignment -------------------------------------------------------------
395
396fn assign_attrs_to_types(types: &mut HashMap<String, CimType>, attrs: &[CimAttribute]) {
397    for attr in attrs {
398        if let Some(t) = types.get_mut(&attr.rdf_domain) {
399            let mut a = attr.clone();
400            a.cim_categories = t.cim_categories.clone();
401            t.attributes.push(a);
402        }
403    }
404}
405
406fn assign_attrs_to_datatypes(dt: &mut HashMap<String, CimDatatype>, attrs: &[CimAttribute]) {
407    for attr in attrs {
408        if let Some(d) = dt.get_mut(&attr.rdf_domain) {
409            d.attributes.push(attr.clone());
410        }
411    }
412}
413
414fn assign_values_to_enums(enums: &mut HashMap<String, CimEnum>, values: &[CimEnumValue]) {
415    for val in values {
416        if let Some(e) = enums.get_mut(&val.rdf_type) {
417            e.values.push(val.clone());
418        }
419    }
420}
421
422// --- merge ------------------------------------------------------------------
423
424fn merge_types(target: &mut HashMap<String, CimType>, source: HashMap<String, CimType>) {
425    for (k, src) in source {
426        if let Some(existing) = target.get_mut(&k) {
427            if !src.super_type.is_empty() {
428                existing.super_type = src.super_type;
429            }
430            if !src.cim_stereotype.is_empty() {
431                existing.cim_stereotype = src.cim_stereotype;
432            }
433            // The first profile (in file-name order) to describe a class or
434            // attribute gives its comment.
435            if existing.comment.is_empty() {
436                existing.comment = src.comment;
437            }
438            existing.cim_categories.extend(src.cim_categories);
439            if !src.origin.is_empty() {
440                existing.origins.push(src.origin);
441            }
442            existing.concrete_in.extend(src.concrete_in);
443            for attr in src.attributes {
444                if let Some(idx) = existing.attributes.iter().position(|a| a.id == attr.id) {
445                    if existing.attributes[idx].comment.is_empty() {
446                        existing.attributes[idx].comment = attr.comment;
447                    }
448                    existing.attributes[idx].origins.extend(attr.origins);
449                    existing.attributes[idx]
450                        .cim_categories
451                        .extend(attr.cim_categories);
452                } else {
453                    existing.attributes.push(attr);
454                }
455            }
456        } else {
457            target.insert(k, src);
458        }
459    }
460}
461
462fn merge_enums(target: &mut HashMap<String, CimEnum>, source: HashMap<String, CimEnum>) {
463    for (k, src) in source {
464        if let Some(existing) = target.get_mut(&k) {
465            if !src.cim_stereotype.is_empty() {
466                existing.cim_stereotype = src.cim_stereotype;
467            }
468            if existing.comment.is_empty() {
469                existing.comment = src.comment;
470            }
471            if !src.origin.is_empty() {
472                existing.origins.push(src.origin);
473            }
474            for val in src.values {
475                match existing.values.iter_mut().find(|v| v.id == val.id) {
476                    Some(v) if v.comment.is_empty() => v.comment = val.comment,
477                    Some(_) => {}
478                    None => existing.values.push(val),
479                }
480            }
481        } else {
482            target.insert(k, src);
483        }
484    }
485}
486
487fn merge_datatypes(
488    target: &mut HashMap<String, CimDatatype>,
489    source: HashMap<String, CimDatatype>,
490) {
491    for (k, src) in source {
492        if let Some(existing) = target.get_mut(&k) {
493            if !src.cim_stereotype.is_empty() {
494                existing.cim_stereotype = src.cim_stereotype;
495            }
496        } else {
497            target.insert(k, src);
498        }
499    }
500}
501
502fn merge_primitives(
503    target: &mut HashMap<String, CimPrimitive>,
504    source: HashMap<String, CimPrimitive>,
505) {
506    for (k, src) in source {
507        if let Some(existing) = target.get_mut(&k) {
508            if !src.cim_stereotype.is_empty() {
509                existing.cim_stereotype = src.cim_stereotype;
510            }
511        } else {
512            target.insert(k, src);
513        }
514    }
515}
516
517// --- helpers ----------------------------------------------------------------
518
519fn bytes_to_str(b: &[u8]) -> Result<String, Box<dyn std::error::Error>> {
520    Ok(std::str::from_utf8(b)?.to_string())
521}
522
523fn find_resource(
524    attrs: quick_xml::events::attributes::Attributes,
525) -> Option<String> {
526    for attr in attrs.flatten() {
527        if let Ok(key) = std::str::from_utf8(attr.key.as_ref()) {
528            if key == "rdf:resource" {
529                if let Ok(s) = std::str::from_utf8(&attr.value) {
530                    return Some(s.to_string());
531                }
532            }
533        }
534    }
535    None
536}
537
538/// Local name of a URI. `#` wins; `/` is the fallback for slash-terminated
539/// vocabularies such as `http://purl.org/dc/terms/title`, which NCP's
540/// DatasetMetadata profile uses and CGMES never does.
541pub fn uri_end(uri: &str) -> String {
542    if let Some(pos) = uri.rfind('#') {
543        uri[pos + 1..].to_string()
544    } else if let Some(pos) = uri.rfind('/') {
545        uri[pos + 1..].to_string()
546    } else {
547        uri.to_string()
548    }
549}
550
551/// Namespace of a URI, including its trailing delimiter.
552pub fn uri_path(uri: &str) -> String {
553    if let Some(pos) = uri.rfind('#') {
554        uri[..=pos].to_string()
555    } else if let Some(pos) = uri.rfind('/') {
556        uri[..=pos].to_string()
557    } else {
558        String::new()
559    }
560}
561
562/// True when the multiplicity's upper bound admits more than one value.
563///
564/// Reproduces the CGMES set (`0..n`, `1..n`, `2..n`, `0..2`) exactly and also
565/// covers NCP's `0..3`, `1..2` and `2..2`, which a literal match would silently
566/// treat as scalar.
567fn is_list_multiplicity(m: &str) -> bool {
568    let token = match m.rfind("#M:") {
569        Some(pos) => &m[pos + 3..],
570        None => return false,
571    };
572    let upper = token.rsplit("..").next().unwrap_or(token);
573    upper == "n" || upper.parse::<u32>().is_ok_and(|n| n > 1)
574}
575
576/// Namespaces are compared verbatim downstream, so they must carry a delimiter.
577/// A slash-terminated namespace is already terminated.
578fn ensure_terminated(mut ns: String) -> String {
579    if !ns.ends_with('#') && !ns.ends_with('/') {
580        ns.push('#');
581    }
582    ns
583}
584
585pub fn clean_text(s: &str) -> String {
586    // Strip HTML tags with a simple state machine
587    let mut out = String::with_capacity(s.len());
588    let mut in_tag = false;
589    for ch in s.chars() {
590        match ch {
591            '<' => in_tag = true,
592            '>' => in_tag = false,
593            _ if !in_tag => out.push(ch),
594            _ => {}
595        }
596    }
597    let out = out
598        .replace("&amp;", "&")
599        .replace("&lt;", "<")
600        .replace("&gt;", ">")
601        .replace("&quot;", "'")
602        .replace("&apos;", "'")
603        .replace('"', "'")
604        .replace('\u{2013}', "-"); // en dash
605
606    out.split_whitespace().collect::<Vec<_>>().join(" ")
607}
608
609// ---------------------------------------------------------------------------
610// Profile index
611// ---------------------------------------------------------------------------
612
613/// One application profile's identity: which IRIs a dataset can declare
614/// conformance to, and the short code the constraint manifests use.
615#[derive(Debug)]
616pub struct ProfileInfo {
617    /// `dcat:keyword` — the short code (`"CO"`, `"ER"`, …).
618    pub keyword: String,
619    /// The profile IRI and every version IRI of it. A dataset's
620    /// `dcterms:conformsTo` names one of these.
621    pub iris: Vec<String>,
622}
623
624/// Read the profile descriptors in a `PROF` directory (`NCP/PROF`,
625/// `CGMES/PROF`).
626///
627/// These are the authority on profile identity. Without them the profile IRI a
628/// dataset declares — NC's `dcterms:conformsTo`, CGMES's `md:Model.profile` —
629/// is opaque, and nothing can say which shapes apply.
630pub fn import_profile_index(
631    dir: &std::path::Path,
632) -> Result<Vec<ProfileInfo>, Box<dyn std::error::Error>> {
633    let mut paths: Vec<std::path::PathBuf> = std::fs::read_dir(dir)?
634        .filter_map(|e| e.ok())
635        .map(|e| e.path())
636        .filter(|p| p.extension().and_then(|x| x.to_str()) == Some("rdf"))
637        .collect();
638    paths.sort();
639
640    let mut out = Vec::new();
641    for path in &paths {
642        if let Some(info) = read_profile(path)? {
643            out.push(info);
644        }
645    }
646    out.sort_by(|a, b| a.keyword.cmp(&b.keyword));
647    Ok(out)
648}
649
650fn read_profile(
651    path: &std::path::Path,
652) -> Result<Option<ProfileInfo>, Box<dyn std::error::Error>> {
653    use quick_xml::events::Event;
654
655    let mut reader = quick_xml::Reader::from_file(path)?;
656    reader.config_mut().trim_text(true);
657
658    let mut buf = Vec::new();
659    let mut keyword: Option<String> = None;
660    // A PROF file holds several sibling rdf:Description blocks: the profile
661    // itself, then one per artifact. Only the profile carries dcat:keyword, so
662    // the current subject is tracked per block and kept when the keyword
663    // arrives — taking the last subject seen would name an artifact instead.
664    let mut current: Option<String> = None;
665    let mut subject: Option<String> = None;
666    let mut iris: Vec<String> = Vec::new();
667    let mut versions: Vec<(String, String)> = Vec::new();
668    let mut in_keyword = false;
669    let mut depth = 0usize;
670
671    loop {
672        match reader.read_event_into(&mut buf)? {
673            Event::Eof => break,
674            Event::Start(e) | Event::Empty(e) => {
675                let name = e.name();
676                let local = local_name(name.as_ref());
677                match local {
678                    b"Description" => {
679                        depth += 1;
680                        if depth == 1 {
681                            current = attr(&e, b"about")?;
682                        }
683                    }
684                    b"keyword" if depth == 1 => in_keyword = true,
685                    // Recorded against the block that declares it; which
686                    // block was the profile is only known once the keyword
687                    // turns up, and it can appear either side of this.
688                    b"versionIRI" if depth == 1 => {
689                        if let (Some(subj), Some(v)) = (current.clone(), attr(&e, b"resource")?) {
690                            versions.push((subj, v));
691                        }
692                    }
693                    _ => {}
694                }
695            }
696            Event::End(e) => {
697                let name = e.name();
698                match local_name(name.as_ref()) {
699                    b"Description" => depth = depth.saturating_sub(1),
700                    b"keyword" => in_keyword = false,
701                    _ => {}
702                }
703            }
704            Event::Text(t) if in_keyword => {
705                let s = t.unescape()?.trim().to_string();
706                if !s.is_empty() && keyword.is_none() {
707                    keyword = Some(s);
708                    subject = current.clone();
709                }
710            }
711            _ => {}
712        }
713        buf.clear();
714    }
715
716    let Some(keyword) = keyword else { return Ok(None) };
717    let Some(subject) = subject else { return Ok(None) };
718    iris.extend(
719        versions
720            .into_iter()
721            .filter(|(subj, _)| *subj == subject)
722            .map(|(_, v)| v),
723    );
724    iris.push(subject);
725    iris.sort();
726    iris.dedup();
727    if iris.is_empty() {
728        return Ok(None);
729    }
730    Ok(Some(ProfileInfo { keyword, iris }))
731}
732
733fn local_name(qname: &[u8]) -> &[u8] {
734    match qname.iter().position(|b| *b == b':') {
735        Some(i) => &qname[i + 1..],
736        None => qname,
737    }
738}
739
740fn attr(
741    e: &quick_xml::events::BytesStart<'_>,
742    want: &[u8],
743) -> Result<Option<String>, Box<dyn std::error::Error>> {
744    for a in e.attributes().flatten() {
745        if local_name(a.key.as_ref()) == want {
746            return Ok(Some(String::from_utf8(a.value.to_vec())?));
747        }
748    }
749    Ok(None)
750}