Skip to main content

ad_plugins_rs/
hdf5_layout.rs

1//! HDF5 custom-layout XML engine.
2//!
3//! Ports the C++ `NDFileHDF5LayoutXML.cpp` + `NDFileHDF5Layout.cpp` parser.
4//! Builds the group/dataset tree from a user XML file (`HDF5_layoutFilename`),
5//! mirroring `XML_schema/hdf5_xml_layout_schema.xsd`.
6//!
7//! The parser is hand-written in the same focused-parser style as
8//! `ad-core-rs` `read_nd_attributes_file` — no external XML crate dependency.
9
10use std::fmt;
11
12/// Data source kind for a layout node (schema `dsetSourceEnum`).
13#[derive(Debug, Clone, Copy, PartialEq, Eq)]
14pub enum LayoutSource {
15    /// Detector image data destination.
16    Detector,
17    /// Fixed constant value.
18    Constant,
19    /// Value taken from an NDAttribute.
20    NdAttribute,
21    /// No `source` attribute given (C++ `DataSource` default `notset`). Used
22    /// by the built-in `<dataset name="timestamp">` performance dataset in
23    /// the C ADCore `DEFAULT_LAYOUT`, whose data the writer fills directly.
24    Unset,
25}
26
27/// Scalar datatype for constant/attribute layout nodes (schema `attrSourceTypeEnum`).
28#[derive(Debug, Clone, Copy, PartialEq, Eq)]
29pub enum LayoutDataType {
30    Int,
31    Float,
32    String,
33}
34
35impl LayoutDataType {
36    fn parse(s: &str) -> LayoutDataType {
37        match s {
38            "int" => LayoutDataType::Int,
39            "float" => LayoutDataType::Float,
40            _ => LayoutDataType::String,
41        }
42    }
43}
44
45/// When a constant/attribute value is written (schema `when` enum).
46#[derive(Debug, Clone, Copy, PartialEq, Eq)]
47pub enum LayoutWhen {
48    OnFileOpen,
49    OnFileClose,
50    OnFileWrite,
51    /// Default — written per frame.
52    OnFrame,
53}
54
55impl LayoutWhen {
56    fn parse(s: &str) -> LayoutWhen {
57        match s {
58            "OnFileOpen" => LayoutWhen::OnFileOpen,
59            "OnFileClose" => LayoutWhen::OnFileClose,
60            "OnFileWrite" => LayoutWhen::OnFileWrite,
61            _ => LayoutWhen::OnFrame,
62        }
63    }
64}
65
66/// An `<attribute>` element: HDF5 metadata attached to a group or dataset.
67#[derive(Debug, Clone)]
68pub struct LayoutAttribute {
69    pub name: String,
70    pub source: LayoutSource,
71    pub data_type: LayoutDataType,
72    pub value: String,
73    /// NDAttribute name when `source == NdAttribute`.
74    pub ndattribute: String,
75    pub when: LayoutWhen,
76}
77
78/// A `<attribute source="ndattribute">` declared on a group or dataset,
79/// resolved to the owning element's full path. Used by the writer to attach
80/// an HDF5 attribute carrying the live NDAttribute value (C
81/// `storeOnOpenCloseAttribute`).
82#[derive(Debug, Clone)]
83pub struct NdAttrElementAttr {
84    /// Full path (leading slash) of the owning group or dataset.
85    pub element_path: String,
86    /// True if the owning element is a dataset, false if a group.
87    pub is_dataset: bool,
88    /// HDF5 attribute name to create on the element.
89    pub attr_name: String,
90    /// NDAttribute name supplying the value.
91    pub ndattribute: String,
92    /// `OnFileOpen`/`OnFrame` take the first frame's value, `OnFileClose` the
93    /// last (C `is_onFileOpen`/`is_onFileClose`, NDFileHDF5Layout.cpp:54-64).
94    pub when: LayoutWhen,
95}
96
97/// A `<dataset>` element.
98#[derive(Debug, Clone)]
99pub struct LayoutDataset {
100    pub name: String,
101    pub source: LayoutSource,
102    pub data_type: LayoutDataType,
103    pub value: String,
104    /// NDAttribute name when `source == NdAttribute`.
105    pub ndattribute: String,
106    /// True if this is the default detector dataset (`det_default="true"`).
107    pub det_default: bool,
108    pub when: LayoutWhen,
109    /// HDF5 attributes attached to this dataset.
110    pub attributes: Vec<LayoutAttribute>,
111}
112
113/// A `<hardlink>` element.
114#[derive(Debug, Clone)]
115pub struct LayoutHardlink {
116    pub name: String,
117    pub target: String,
118}
119
120/// A `<group>` element: an HDF5 group node.
121#[derive(Debug, Clone)]
122pub struct LayoutGroup {
123    pub name: String,
124    /// `ndattr_default="true"` — NDAttribute datasets land in this group.
125    pub ndattr_default: bool,
126    pub attributes: Vec<LayoutAttribute>,
127    pub datasets: Vec<LayoutDataset>,
128    pub hardlinks: Vec<LayoutHardlink>,
129    pub groups: Vec<LayoutGroup>,
130}
131
132impl LayoutGroup {
133    fn new(name: String, ndattr_default: bool) -> Self {
134        Self {
135            name,
136            ndattr_default,
137            attributes: Vec::new(),
138            datasets: Vec::new(),
139            hardlinks: Vec::new(),
140            groups: Vec::new(),
141        }
142    }
143}
144
145/// The parsed HDF5 layout tree (`<hdf5_layout>` root).
146#[derive(Debug, Clone, Default)]
147pub struct Hdf5Layout {
148    /// Top-level groups.
149    pub groups: Vec<LayoutGroup>,
150    /// `<global name="detector_data_destination" ndattribute="..."/>`.
151    pub detector_data_destination: Option<String>,
152}
153
154/// Error from layout XML parsing.
155#[derive(Debug, Clone)]
156pub struct LayoutError(pub String);
157
158impl fmt::Display for LayoutError {
159    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
160        write!(f, "{}", self.0)
161    }
162}
163
164impl std::error::Error for LayoutError {}
165
166impl Hdf5Layout {
167    /// Parse a layout XML file from disk.
168    pub fn from_file(path: &std::path::Path) -> Result<Hdf5Layout, LayoutError> {
169        let text = std::fs::read_to_string(path)
170            .map_err(|e| LayoutError(format!("cannot read layout file: {}", e)))?;
171        Self::parse(&text)
172    }
173
174    /// Parse a layout XML document from a string.
175    pub fn parse(text: &str) -> Result<Hdf5Layout, LayoutError> {
176        let tokens = tokenize(text)?;
177        let mut parser = Parser { tokens, pos: 0 };
178        parser.parse_document()
179    }
180
181    /// Walk every dataset in the tree, yielding `(full_group_path, dataset)`.
182    pub fn for_each_dataset<F: FnMut(&str, &LayoutDataset)>(&self, mut f: F) {
183        fn recurse<F: FnMut(&str, &LayoutDataset)>(g: &LayoutGroup, path: &str, f: &mut F) {
184            let here = if path.is_empty() {
185                format!("/{}", g.name)
186            } else {
187                format!("{}/{}", path, g.name)
188            };
189            for d in &g.datasets {
190                f(&here, d);
191            }
192            for sub in &g.groups {
193                recurse(sub, &here, f);
194            }
195        }
196        for g in &self.groups {
197            recurse(g, "", &mut f);
198        }
199    }
200
201    /// Find the dataset flagged `det_default`, returning its full path.
202    pub fn detector_dataset_path(&self) -> Option<String> {
203        let mut found = None;
204        self.for_each_dataset(|path, d| {
205            if d.det_default && d.source == LayoutSource::Detector && found.is_none() {
206                found = Some(format!("{}/{}", path, d.name));
207            }
208        });
209        if found.is_none() {
210            // Fall back to the first detector-source dataset.
211            self.for_each_dataset(|path, d| {
212                if d.source == LayoutSource::Detector && found.is_none() {
213                    found = Some(format!("{}/{}", path, d.name));
214                }
215            });
216        }
217        found
218    }
219
220    /// Enumerate every detector-source dataset's full path (leading slash), in
221    /// document order. C `NDFileHDF5` holds one `NDFileHDF5Dataset` per
222    /// `<dataset source="detector">` node in `detDataMap`, each created by
223    /// `createDatasetDetector` (NDFileHDF5.cpp:1324-1357) and keyed by
224    /// `get_full_name()` — the same leading-slash full path produced here. The
225    /// writer creates *all* of them up front and routes each frame to one of
226    /// them by the `detector_data_destination` NDAttribute.
227    pub fn detector_dataset_paths(&self) -> Vec<String> {
228        let mut out = Vec::new();
229        self.for_each_dataset(|path, d| {
230            if d.source == LayoutSource::Detector {
231                out.push(format!("{}/{}", path, d.name));
232            }
233        });
234        out
235    }
236
237    /// Find the group path of the first dataset named `name`, returning the
238    /// owning group's full path (the C `NDFileHDF5` performance dataset is a
239    /// `<dataset name="timestamp">` in the layout tree).
240    pub fn dataset_group_path(&self, name: &str) -> Option<String> {
241        let mut found = None;
242        self.for_each_dataset(|path, d| {
243            if d.name == name && found.is_none() {
244                found = Some(path.to_string());
245            }
246        });
247        found
248    }
249
250    /// Find the layout `<dataset source="ndattribute" ndattribute="name">`
251    /// declared for the NDAttribute `name`, returning `(parent group full path,
252    /// dataset name)`. C `find_dset_ndattr` (NDFileHDF5.cpp:2792) routes a
253    /// matching NDAttribute into its XML-declared dataset/group instead of the
254    /// default attribute group.
255    pub fn ndattribute_dataset(&self, name: &str) -> Option<(String, String)> {
256        let mut found = None;
257        self.for_each_dataset(|path, d| {
258            if found.is_none() && d.source == LayoutSource::NdAttribute && d.ndattribute == name {
259                found = Some((path.to_string(), d.name.clone()));
260            }
261        });
262        found
263    }
264
265    /// Enumerate every `<attribute source="ndattribute">` declared on a group
266    /// or dataset in the tree, in document order, with the owning element's
267    /// full path. C `storeOnOpenCloseAttribute` (NDFileHDF5.cpp:553-632) writes
268    /// each as an HDF5 attribute on its element, sourcing the value from the
269    /// live NDAttribute (`get_attributes()` over groups *and* datasets).
270    pub fn ndattribute_element_attrs(&self) -> Vec<NdAttrElementAttr> {
271        fn push_attrs(
272            out: &mut Vec<NdAttrElementAttr>,
273            element_path: &str,
274            is_dataset: bool,
275            attrs: &[LayoutAttribute],
276        ) {
277            for a in attrs {
278                if a.source == LayoutSource::NdAttribute {
279                    out.push(NdAttrElementAttr {
280                        element_path: element_path.to_string(),
281                        is_dataset,
282                        attr_name: a.name.clone(),
283                        ndattribute: a.ndattribute.clone(),
284                        when: a.when,
285                    });
286                }
287            }
288        }
289        fn recurse(out: &mut Vec<NdAttrElementAttr>, g: &LayoutGroup, path: &str) {
290            let here = if path.is_empty() {
291                format!("/{}", g.name)
292            } else {
293                format!("{}/{}", path, g.name)
294            };
295            push_attrs(out, &here, false, &g.attributes);
296            for d in &g.datasets {
297                let dpath = format!("{}/{}", here, d.name);
298                push_attrs(out, &dpath, true, &d.attributes);
299            }
300            for sub in &g.groups {
301                recurse(out, sub, &here);
302            }
303        }
304        let mut out = Vec::new();
305        for g in &self.groups {
306            recurse(&mut out, g, "");
307        }
308        out
309    }
310
311    /// Find the group flagged `ndattr_default`, returning its full path.
312    pub fn ndattr_default_group(&self) -> Option<String> {
313        fn recurse(g: &LayoutGroup, path: &str) -> Option<String> {
314            let here = if path.is_empty() {
315                format!("/{}", g.name)
316            } else {
317                format!("{}/{}", path, g.name)
318            };
319            if g.ndattr_default {
320                return Some(here.clone());
321            }
322            for sub in &g.groups {
323                if let Some(p) = recurse(sub, &here) {
324                    return Some(p);
325                }
326            }
327            None
328        }
329        for g in &self.groups {
330            if let Some(p) = recurse(g, "") {
331                return Some(p);
332            }
333        }
334        None
335    }
336}
337
338// ===========================================================================
339// Tokenizer
340// ===========================================================================
341
342#[derive(Debug, Clone)]
343enum Token {
344    /// `<tag attr="val" ...>` — name, attributes, self-closing flag.
345    Open {
346        name: String,
347        attrs: Vec<(String, String)>,
348        self_closing: bool,
349    },
350    /// `</tag>`.
351    Close(String),
352}
353
354/// Tokenize an XML document into element open/close tokens. Text content,
355/// comments, processing instructions and the prolog are discarded — the
356/// layout schema has no text-bearing elements.
357fn tokenize(text: &str) -> Result<Vec<Token>, LayoutError> {
358    let bytes = text.as_bytes();
359    let mut tokens = Vec::new();
360    let mut i = 0;
361    while i < bytes.len() {
362        if bytes[i] != b'<' {
363            i += 1;
364            continue;
365        }
366        // Comment <!-- ... -->
367        if text[i..].starts_with("<!--") {
368            match text[i..].find("-->") {
369                Some(end) => {
370                    i += end + 3;
371                    continue;
372                }
373                None => return Err(LayoutError("unterminated XML comment".into())),
374            }
375        }
376        // Processing instruction / declaration <? ... ?>  or  <! ... >
377        if text[i..].starts_with("<?") || text[i..].starts_with("<!") {
378            match text[i..].find('>') {
379                Some(end) => {
380                    i += end + 1;
381                    continue;
382                }
383                None => return Err(LayoutError("unterminated XML declaration".into())),
384            }
385        }
386        // Find the matching '>'
387        let close = text[i..]
388            .find('>')
389            .ok_or_else(|| LayoutError("unterminated XML tag".into()))?;
390        let inner = &text[i + 1..i + close];
391        i += close + 1;
392
393        let inner_trim = inner.trim();
394        if let Some(rest) = inner_trim.strip_prefix('/') {
395            tokens.push(Token::Close(rest.trim().to_string()));
396            continue;
397        }
398        let self_closing = inner_trim.ends_with('/');
399        let body = if self_closing {
400            inner_trim[..inner_trim.len() - 1].trim()
401        } else {
402            inner_trim
403        };
404        let (name, attrs) = parse_tag_body(body)?;
405        tokens.push(Token::Open {
406            name,
407            attrs,
408            self_closing,
409        });
410    }
411    Ok(tokens)
412}
413
414/// Parse `name attr="val" attr2='val2'` into a name and attribute list.
415fn parse_tag_body(body: &str) -> Result<(String, Vec<(String, String)>), LayoutError> {
416    let chars: Vec<char> = body.chars().collect();
417    let mut idx = 0;
418    // Element name
419    let name_start = idx;
420    while idx < chars.len() && !chars[idx].is_whitespace() {
421        idx += 1;
422    }
423    let name: String = chars[name_start..idx].iter().collect();
424    if name.is_empty() {
425        return Err(LayoutError("empty XML tag name".into()));
426    }
427    let mut attrs = Vec::new();
428    loop {
429        while idx < chars.len() && chars[idx].is_whitespace() {
430            idx += 1;
431        }
432        if idx >= chars.len() {
433            break;
434        }
435        // attribute name up to '='
436        let attr_start = idx;
437        while idx < chars.len() && chars[idx] != '=' && !chars[idx].is_whitespace() {
438            idx += 1;
439        }
440        let attr_name: String = chars[attr_start..idx].iter().collect();
441        while idx < chars.len() && chars[idx].is_whitespace() {
442            idx += 1;
443        }
444        if idx >= chars.len() || chars[idx] != '=' {
445            return Err(LayoutError(format!(
446                "malformed attribute '{}' in tag '{}'",
447                attr_name, name
448            )));
449        }
450        idx += 1; // skip '='
451        while idx < chars.len() && chars[idx].is_whitespace() {
452            idx += 1;
453        }
454        if idx >= chars.len() || (chars[idx] != '"' && chars[idx] != '\'') {
455            return Err(LayoutError(format!(
456                "unquoted attribute value for '{}' in tag '{}'",
457                attr_name, name
458            )));
459        }
460        let quote = chars[idx];
461        idx += 1;
462        let val_start = idx;
463        while idx < chars.len() && chars[idx] != quote {
464            idx += 1;
465        }
466        if idx >= chars.len() {
467            return Err(LayoutError(format!(
468                "unterminated attribute value for '{}'",
469                attr_name
470            )));
471        }
472        let raw: String = chars[val_start..idx].iter().collect();
473        idx += 1; // skip closing quote
474        attrs.push((attr_name, unescape(&raw)));
475    }
476    Ok((name, attrs))
477}
478
479/// Decode the five standard XML entities.
480fn unescape(s: &str) -> String {
481    if !s.contains('&') {
482        return s.to_string();
483    }
484    s.replace("&lt;", "<")
485        .replace("&gt;", ">")
486        .replace("&quot;", "\"")
487        .replace("&apos;", "'")
488        .replace("&amp;", "&")
489}
490
491// ===========================================================================
492// Parser
493// ===========================================================================
494
495struct Parser {
496    tokens: Vec<Token>,
497    pos: usize,
498}
499
500impl Parser {
501    fn parse_document(&mut self) -> Result<Hdf5Layout, LayoutError> {
502        // Find the <hdf5_layout> root.
503        let mut layout = Hdf5Layout::default();
504        match self.tokens.get(self.pos).cloned() {
505            Some(Token::Open {
506                name, self_closing, ..
507            }) if name == "hdf5_layout" => {
508                self.pos += 1;
509                if self_closing {
510                    return Ok(layout);
511                }
512            }
513            _ => return Err(LayoutError("root element <hdf5_layout> not found".into())),
514        }
515
516        loop {
517            match self.tokens.get(self.pos).cloned() {
518                Some(Token::Open {
519                    name,
520                    attrs,
521                    self_closing,
522                }) => {
523                    self.pos += 1;
524                    match name.as_str() {
525                        "group" => {
526                            let g = self.parse_group(attrs, self_closing)?;
527                            layout.groups.push(g);
528                        }
529                        "global" => {
530                            let n = attr_get(&attrs, "name").unwrap_or_default();
531                            if n == "detector_data_destination" {
532                                layout.detector_data_destination = attr_get(&attrs, "ndattribute");
533                            }
534                            if !self_closing {
535                                self.skip_to_close("global")?;
536                            }
537                        }
538                        other => {
539                            return Err(LayoutError(format!(
540                                "unexpected element <{}> in <hdf5_layout>",
541                                other
542                            )));
543                        }
544                    }
545                }
546                Some(Token::Close(name)) if name == "hdf5_layout" => {
547                    self.pos += 1;
548                    break;
549                }
550                Some(Token::Close(other)) => {
551                    return Err(LayoutError(format!(
552                        "unexpected </{}> at document level",
553                        other
554                    )));
555                }
556                None => return Err(LayoutError("unterminated <hdf5_layout> element".into())),
557            }
558        }
559        Ok(layout)
560    }
561
562    fn parse_group(
563        &mut self,
564        attrs: Vec<(String, String)>,
565        self_closing: bool,
566    ) -> Result<LayoutGroup, LayoutError> {
567        let name = attr_get(&attrs, "name")
568            .ok_or_else(|| LayoutError("<group> missing required 'name'".into()))?;
569        let ndattr_default = attr_bool(&attrs, "ndattr_default");
570        let mut group = LayoutGroup::new(name.clone(), ndattr_default);
571        if self_closing {
572            return Ok(group);
573        }
574        loop {
575            match self.tokens.get(self.pos).cloned() {
576                Some(Token::Open {
577                    name: child,
578                    attrs: cattrs,
579                    self_closing: sc,
580                }) => {
581                    self.pos += 1;
582                    match child.as_str() {
583                        "group" => group.groups.push(self.parse_group(cattrs, sc)?),
584                        "dataset" => group.datasets.push(self.parse_dataset(cattrs, sc)?),
585                        "attribute" => {
586                            group.attributes.push(parse_attribute(&cattrs)?);
587                            if !sc {
588                                self.skip_to_close("attribute")?;
589                            }
590                        }
591                        "hardlink" => {
592                            group.hardlinks.push(LayoutHardlink {
593                                name: attr_get(&cattrs, "name").ok_or_else(|| {
594                                    LayoutError("<hardlink> missing 'name'".into())
595                                })?,
596                                target: attr_get(&cattrs, "target").ok_or_else(|| {
597                                    LayoutError("<hardlink> missing 'target'".into())
598                                })?,
599                            });
600                            if !sc {
601                                self.skip_to_close("hardlink")?;
602                            }
603                        }
604                        other => {
605                            return Err(LayoutError(format!(
606                                "unexpected <{}> inside <group name=\"{}\">",
607                                other, name
608                            )));
609                        }
610                    }
611                }
612                Some(Token::Close(close_name)) if close_name == "group" => {
613                    self.pos += 1;
614                    break;
615                }
616                Some(Token::Close(other)) => {
617                    return Err(LayoutError(format!(
618                        "mismatched </{}>, expected </group>",
619                        other
620                    )));
621                }
622                None => {
623                    return Err(LayoutError(format!(
624                        "unterminated <group name=\"{}\">",
625                        name
626                    )));
627                }
628            }
629        }
630        Ok(group)
631    }
632
633    fn parse_dataset(
634        &mut self,
635        attrs: Vec<(String, String)>,
636        self_closing: bool,
637    ) -> Result<LayoutDataset, LayoutError> {
638        let name = attr_get(&attrs, "name")
639            .ok_or_else(|| LayoutError("<dataset> missing required 'name'".into()))?;
640        let source = parse_source(&attrs, &name)?;
641        let mut ds = LayoutDataset {
642            name: name.clone(),
643            source,
644            data_type: LayoutDataType::parse(
645                &attr_get(&attrs, "type").unwrap_or_else(|| "string".into()),
646            ),
647            value: attr_get(&attrs, "value").unwrap_or_default(),
648            ndattribute: attr_get(&attrs, "ndattribute").unwrap_or_default(),
649            det_default: attr_bool(&attrs, "det_default"),
650            when: LayoutWhen::parse(&attr_get(&attrs, "when").unwrap_or_default()),
651            attributes: Vec::new(),
652        };
653        if self_closing {
654            return Ok(ds);
655        }
656        loop {
657            match self.tokens.get(self.pos).cloned() {
658                Some(Token::Open {
659                    name: child,
660                    attrs: cattrs,
661                    self_closing: sc,
662                }) => {
663                    self.pos += 1;
664                    if child == "attribute" {
665                        ds.attributes.push(parse_attribute(&cattrs)?);
666                        if !sc {
667                            self.skip_to_close("attribute")?;
668                        }
669                    } else {
670                        return Err(LayoutError(format!(
671                            "unexpected <{}> inside <dataset name=\"{}\">",
672                            child, name
673                        )));
674                    }
675                }
676                Some(Token::Close(close_name)) if close_name == "dataset" => {
677                    self.pos += 1;
678                    break;
679                }
680                Some(Token::Close(other)) => {
681                    return Err(LayoutError(format!(
682                        "mismatched </{}>, expected </dataset>",
683                        other
684                    )));
685                }
686                None => {
687                    return Err(LayoutError(format!(
688                        "unterminated <dataset name=\"{}\">",
689                        name
690                    )));
691                }
692            }
693        }
694        Ok(ds)
695    }
696
697    /// Skip tokens until the matching close tag (for childless elements that
698    /// were not written self-closing).
699    fn skip_to_close(&mut self, tag: &str) -> Result<(), LayoutError> {
700        let mut depth = 1;
701        while let Some(tok) = self.tokens.get(self.pos).cloned() {
702            self.pos += 1;
703            match tok {
704                Token::Open {
705                    name, self_closing, ..
706                } if name == tag && !self_closing => depth += 1,
707                Token::Close(name) if name == tag => {
708                    depth -= 1;
709                    if depth == 0 {
710                        return Ok(());
711                    }
712                }
713                _ => {}
714            }
715        }
716        Err(LayoutError(format!("unterminated <{}> element", tag)))
717    }
718}
719
720fn parse_attribute(attrs: &[(String, String)]) -> Result<LayoutAttribute, LayoutError> {
721    let name = attr_get(attrs, "name")
722        .ok_or_else(|| LayoutError("<attribute> missing required 'name'".into()))?;
723    let source_str = attr_get(attrs, "source")
724        .ok_or_else(|| LayoutError(format!("<attribute name=\"{}\"> missing 'source'", name)))?;
725    let source = match source_str.as_str() {
726        "constant" => LayoutSource::Constant,
727        "ndattribute" => LayoutSource::NdAttribute,
728        other => {
729            return Err(LayoutError(format!(
730                "<attribute name=\"{}\"> invalid source '{}'",
731                name, other
732            )));
733        }
734    };
735    Ok(LayoutAttribute {
736        name,
737        source,
738        data_type: LayoutDataType::parse(
739            &attr_get(attrs, "type").unwrap_or_else(|| "string".into()),
740        ),
741        value: attr_get(attrs, "value").unwrap_or_default(),
742        ndattribute: attr_get(attrs, "ndattribute").unwrap_or_default(),
743        when: LayoutWhen::parse(&attr_get(attrs, "when").unwrap_or_default()),
744    })
745}
746
747fn parse_source(attrs: &[(String, String)], name: &str) -> Result<LayoutSource, LayoutError> {
748    // C++ `process_dset_xml_attribute` leaves the DataSource at its default
749    // (`notset`) when no `source` attribute is present, so a sourceless
750    // `<dataset>` (the built-in performance `timestamp` dataset) is valid.
751    let s = match attr_get(attrs, "source") {
752        Some(s) => s,
753        None => return Ok(LayoutSource::Unset),
754    };
755    match s.as_str() {
756        "detector" => Ok(LayoutSource::Detector),
757        "constant" => Ok(LayoutSource::Constant),
758        "ndattribute" => Ok(LayoutSource::NdAttribute),
759        other => Err(LayoutError(format!(
760            "<dataset name=\"{}\"> invalid source '{}'",
761            name, other
762        ))),
763    }
764}
765
766fn attr_get(attrs: &[(String, String)], key: &str) -> Option<String> {
767    attrs.iter().find(|(k, _)| k == key).map(|(_, v)| v.clone())
768}
769
770fn attr_bool(attrs: &[(String, String)], key: &str) -> bool {
771    matches!(attr_get(attrs, key).as_deref(), Some("true") | Some("1"))
772}
773
774#[cfg(test)]
775mod tests {
776    use super::*;
777
778    const SAMPLE: &str = r#"<?xml version="1.0"?>
779<hdf5_layout>
780  <global name="detector_data_destination" ndattribute="detdest" />
781  <group name="entry">
782    <attribute name="NX_class" source="constant" value="NXentry" type="string" />
783    <group name="data" ndattr_default="true">
784      <dataset name="data" source="detector" det_default="true">
785        <attribute name="signal" source="constant" value="1" type="int" />
786      </dataset>
787      <dataset name="exposure" source="ndattribute" ndattribute="AcquireTime" type="float" />
788    </group>
789    <group name="instrument">
790      <dataset name="name" source="constant" value="MyBeamline" type="string" />
791      <hardlink name="link_to_data" target="/entry/data/data" />
792    </group>
793  </group>
794</hdf5_layout>"#;
795
796    #[test]
797    fn parses_full_tree() {
798        let layout = Hdf5Layout::parse(SAMPLE).unwrap();
799        assert_eq!(layout.groups.len(), 1);
800        assert_eq!(layout.detector_data_destination.as_deref(), Some("detdest"));
801        let entry = &layout.groups[0];
802        assert_eq!(entry.name, "entry");
803        assert_eq!(entry.attributes.len(), 1);
804        assert_eq!(entry.attributes[0].name, "NX_class");
805        assert_eq!(entry.attributes[0].value, "NXentry");
806        assert_eq!(entry.groups.len(), 2);
807
808        let data_group = &entry.groups[0];
809        assert!(data_group.ndattr_default);
810        assert_eq!(data_group.datasets.len(), 2);
811        assert!(data_group.datasets[0].det_default);
812        assert_eq!(data_group.datasets[0].source, LayoutSource::Detector);
813        assert_eq!(data_group.datasets[0].attributes.len(), 1);
814        assert_eq!(
815            data_group.datasets[0].attributes[0].data_type,
816            LayoutDataType::Int
817        );
818        assert_eq!(data_group.datasets[1].source, LayoutSource::NdAttribute);
819        assert_eq!(data_group.datasets[1].ndattribute, "AcquireTime");
820        assert_eq!(data_group.datasets[1].data_type, LayoutDataType::Float);
821
822        let instr = &entry.groups[1];
823        assert_eq!(instr.hardlinks.len(), 1);
824        assert_eq!(instr.hardlinks[0].target, "/entry/data/data");
825    }
826
827    #[test]
828    fn detector_path_resolves() {
829        let layout = Hdf5Layout::parse(SAMPLE).unwrap();
830        assert_eq!(
831            layout.detector_dataset_path().as_deref(),
832            Some("/entry/data/data")
833        );
834        assert_eq!(
835            layout.ndattr_default_group().as_deref(),
836            Some("/entry/data")
837        );
838    }
839
840    #[test]
841    fn rejects_missing_root() {
842        let err = Hdf5Layout::parse("<foo/>").unwrap_err();
843        assert!(err.0.contains("hdf5_layout"));
844    }
845
846    #[test]
847    fn rejects_missing_dataset_name() {
848        let xml =
849            r#"<hdf5_layout><group name="g"><dataset source="detector"/></group></hdf5_layout>"#;
850        let err = Hdf5Layout::parse(xml).unwrap_err();
851        assert!(err.0.contains("name"));
852    }
853
854    #[test]
855    fn rejects_bad_source() {
856        let xml = r#"<hdf5_layout><group name="g"><dataset name="d" source="bogus"/></group></hdf5_layout>"#;
857        let err = Hdf5Layout::parse(xml).unwrap_err();
858        assert!(err.0.contains("bogus"));
859    }
860
861    #[test]
862    fn handles_comments_and_entities() {
863        let xml = r#"<hdf5_layout>
864          <!-- a comment -->
865          <group name="g">
866            <dataset name="d" source="constant" value="a &amp; b" type="string"/>
867          </group>
868        </hdf5_layout>"#;
869        let layout = Hdf5Layout::parse(xml).unwrap();
870        assert_eq!(layout.groups[0].datasets[0].value, "a & b");
871    }
872}