Skip to main content

ad_plugins_rs/
file_nexus.rs

1//! NeXus file writer plugin.
2//!
3//! Writes NDArray data in NeXus/HDF5 format using the rust-hdf5 library.
4//! Follows the simplified NXdata convention:
5//!
6//! ```text
7//! /entry (NX_class=NXentry)
8//!   /instrument (NX_class=NXinstrument)
9//!     /detector (NX_class=NXdetector)
10//!       /data → dataset [frames × Y × X]
11//!   /data (NX_class=NXdata)
12//!     /data → same dataset
13//! ```
14
15use std::path::{Path, PathBuf};
16
17use ad_core_rs::error::{ADError, ADResult};
18use ad_core_rs::ndarray::{NDArray, NDDataBuffer, NDDataType, NDDimension};
19use ad_core_rs::ndarray_pool::NDArrayPool;
20use ad_core_rs::plugin::file_base::{NDFileMode, NDFileWriter};
21use ad_core_rs::plugin::file_controller::FilePluginController;
22use ad_core_rs::plugin::runtime::{
23    NDPluginProcess, ParamChangeResult, PluginParamSnapshot, ProcessResult,
24};
25
26use rust_hdf5::{H5Dataset, H5File};
27
28/// Name of the HDF5 attribute recording the NDArray data type ordinal
29/// (matches C `NDDataType_t`). `read_file` uses it to recover the exact type
30/// — without it `read_raw::<u8>` / `<u16>` cannot distinguish signed vs
31/// unsigned or i32 vs u32 vs f32 (all 4-byte) datasets.
32const DTYPE_ATTR: &str = "NDArrayDataType";
33
34/// Serialize an NDArray data buffer to **little-endian** bytes. `rust-hdf5`
35/// 0.2.15 records every numeric datatype message as little-endian and copies
36/// the `&[u8]` passed to `write_chunk` verbatim, so a typed dataset fed
37/// host-endian `as_u8_slice()` bytes is only correct on a little-endian host.
38/// This makes the on-disk bytes match the declared LE datatype on every host.
39fn nd_buffer_to_le_bytes(buf: &NDDataBuffer) -> Vec<u8> {
40    match buf {
41        NDDataBuffer::I8(v) => v.iter().map(|&x| x as u8).collect(),
42        NDDataBuffer::U8(v) => v.clone(),
43        NDDataBuffer::I16(v) => v.iter().flat_map(|&x| x.to_le_bytes()).collect(),
44        NDDataBuffer::U16(v) => v.iter().flat_map(|&x| x.to_le_bytes()).collect(),
45        NDDataBuffer::I32(v) => v.iter().flat_map(|&x| x.to_le_bytes()).collect(),
46        NDDataBuffer::U32(v) => v.iter().flat_map(|&x| x.to_le_bytes()).collect(),
47        NDDataBuffer::I64(v) => v.iter().flat_map(|&x| x.to_le_bytes()).collect(),
48        NDDataBuffer::U64(v) => v.iter().flat_map(|&x| x.to_le_bytes()).collect(),
49        NDDataBuffer::F32(v) => v.iter().flat_map(|&x| x.to_le_bytes()).collect(),
50        NDDataBuffer::F64(v) => v.iter().flat_map(|&x| x.to_le_bytes()).collect(),
51    }
52}
53
54// ===========================================================================
55// NeXus XML template parser
56// ===========================================================================
57
58/// A parsed XML element of a NeXus template (C++ `NDFileNexus` `loadTemplateFile`).
59/// Text content of leaf elements is preserved (CONST nodes need it).
60#[derive(Debug, Clone)]
61pub struct XmlElement {
62    pub name: String,
63    pub attrs: Vec<(String, String)>,
64    pub children: Vec<XmlElement>,
65    /// Concatenated direct text content (trimmed).
66    pub text: String,
67}
68
69impl XmlElement {
70    fn attr(&self, key: &str) -> Option<&str> {
71        self.attrs
72            .iter()
73            .find(|(k, _)| k == key)
74            .map(|(_, v)| v.as_str())
75    }
76}
77
78/// Error from NeXus XML template parsing.
79#[derive(Debug)]
80pub struct NexusTemplateError(pub String);
81
82impl std::fmt::Display for NexusTemplateError {
83    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
84        write!(f, "NeXus template error: {}", self.0)
85    }
86}
87
88/// Parse a NeXus XML template into a single root `XmlElement` tree.
89///
90/// Hand-written recursive parser — handles elements, attributes (single or
91/// double quoted), self-closing tags, text content, comments, and the
92/// `<?xml?>` prolog. No external XML crate is pulled in.
93pub fn parse_nexus_template(text: &str) -> Result<XmlElement, NexusTemplateError> {
94    let chars: Vec<char> = text.chars().collect();
95    let mut pos = 0;
96    skip_prolog_and_ws(&chars, &mut pos);
97    let root = parse_element(&chars, &mut pos)?;
98    Ok(root)
99}
100
101fn skip_prolog_and_ws(chars: &[char], pos: &mut usize) {
102    loop {
103        while *pos < chars.len() && chars[*pos].is_whitespace() {
104            *pos += 1;
105        }
106        if chars[*pos..].starts_with(&['<', '?']) {
107            // <?xml ... ?>
108            while *pos < chars.len() && !(chars[*pos] == '?' && chars.get(*pos + 1) == Some(&'>')) {
109                *pos += 1;
110            }
111            *pos += 2;
112        } else if chars[*pos..].starts_with(&['<', '!', '-', '-']) {
113            skip_comment(chars, pos);
114        } else {
115            break;
116        }
117    }
118}
119
120fn skip_comment(chars: &[char], pos: &mut usize) {
121    // assumes pos at "<!--"
122    *pos += 4;
123    while *pos < chars.len()
124        && !(chars[*pos] == '-'
125            && chars.get(*pos + 1) == Some(&'-')
126            && chars.get(*pos + 2) == Some(&'>'))
127    {
128        *pos += 1;
129    }
130    *pos += 3;
131}
132
133fn parse_element(chars: &[char], pos: &mut usize) -> Result<XmlElement, NexusTemplateError> {
134    if *pos >= chars.len() || chars[*pos] != '<' {
135        return Err(NexusTemplateError("expected element start '<'".into()));
136    }
137    *pos += 1;
138    // Element name.
139    let name = read_name(chars, pos);
140    if name.is_empty() {
141        return Err(NexusTemplateError("empty element name".into()));
142    }
143    // Attributes.
144    let mut attrs = Vec::new();
145    loop {
146        skip_ws(chars, pos);
147        if *pos >= chars.len() {
148            return Err(NexusTemplateError("unterminated tag".into()));
149        }
150        if chars[*pos] == '/' && chars.get(*pos + 1) == Some(&'>') {
151            *pos += 2;
152            return Ok(XmlElement {
153                name,
154                attrs,
155                children: Vec::new(),
156                text: String::new(),
157            });
158        }
159        if chars[*pos] == '>' {
160            *pos += 1;
161            break;
162        }
163        let attr_name = read_name(chars, pos);
164        if attr_name.is_empty() {
165            return Err(NexusTemplateError(format!(
166                "malformed attribute in tag '{}'",
167                name
168            )));
169        }
170        skip_ws(chars, pos);
171        if *pos >= chars.len() || chars[*pos] != '=' {
172            return Err(NexusTemplateError(format!(
173                "attribute '{}' missing '='",
174                attr_name
175            )));
176        }
177        *pos += 1;
178        skip_ws(chars, pos);
179        let value = read_quoted(chars, pos)?;
180        attrs.push((attr_name, value));
181    }
182    // Children + text until the matching close tag.
183    let mut children = Vec::new();
184    let mut text = String::new();
185    loop {
186        if *pos >= chars.len() {
187            return Err(NexusTemplateError(format!("unclosed element '{}'", name)));
188        }
189        if chars[*pos..].starts_with(&['<', '!', '-', '-']) {
190            skip_comment(chars, pos);
191            continue;
192        }
193        if chars[*pos] == '<' && chars.get(*pos + 1) == Some(&'/') {
194            // Close tag.
195            *pos += 2;
196            let close_name = read_name(chars, pos);
197            skip_ws(chars, pos);
198            if *pos < chars.len() && chars[*pos] == '>' {
199                *pos += 1;
200            }
201            if close_name != name {
202                return Err(NexusTemplateError(format!(
203                    "mismatched close tag: expected '{}', got '{}'",
204                    name, close_name
205                )));
206            }
207            break;
208        }
209        if chars[*pos] == '<' {
210            children.push(parse_element(chars, pos)?);
211        } else {
212            text.push(chars[*pos]);
213            *pos += 1;
214        }
215    }
216    Ok(XmlElement {
217        name,
218        attrs,
219        children,
220        text: text.trim().to_string(),
221    })
222}
223
224fn skip_ws(chars: &[char], pos: &mut usize) {
225    while *pos < chars.len() && chars[*pos].is_whitespace() {
226        *pos += 1;
227    }
228}
229
230fn read_name(chars: &[char], pos: &mut usize) -> String {
231    skip_ws(chars, pos);
232    let start = *pos;
233    while *pos < chars.len()
234        && !chars[*pos].is_whitespace()
235        && !matches!(chars[*pos], '=' | '>' | '/' | '<')
236    {
237        *pos += 1;
238    }
239    chars[start..*pos].iter().collect()
240}
241
242fn read_quoted(chars: &[char], pos: &mut usize) -> Result<String, NexusTemplateError> {
243    if *pos >= chars.len() || (chars[*pos] != '"' && chars[*pos] != '\'') {
244        return Err(NexusTemplateError("expected quoted attribute value".into()));
245    }
246    let quote = chars[*pos];
247    *pos += 1;
248    let start = *pos;
249    while *pos < chars.len() && chars[*pos] != quote {
250        *pos += 1;
251    }
252    if *pos >= chars.len() {
253        return Err(NexusTemplateError("unterminated attribute value".into()));
254    }
255    let value: String = chars[start..*pos].iter().collect();
256    *pos += 1;
257    Ok(value)
258}
259
260/// The set of NeXus base-class names recognised as group nodes
261/// (NDFileNexus.cpp:205-247). A node whose tag is one of these, or whose
262/// `type` attribute is `UserGroup`, becomes an HDF5 group.
263const NEXUS_GROUP_CLASSES: &[&str] = &[
264    "NXentry",
265    "NXinstrument",
266    "NXsample",
267    "NXmonitor",
268    "NXsource",
269    "NXuser",
270    "NXdata",
271    "NXdetector",
272    "NXaperature",
273    "NXattenuator",
274    "NXbeam_stop",
275    "NXbending_magnet",
276    "NXcollimator",
277    "NXcrystal",
278    "NXdisk_chopper",
279    "NXfermi_chopper",
280    "NXfilter",
281    "NXflipper",
282    "NXguide",
283    "NXinsertion_device",
284    "NXmirror",
285    "NXmoderator",
286    "NXmonochromator",
287    "NXpolarizer",
288    "NXpositioner",
289    "NXvelocity_selector",
290    "NXevent_data",
291    "NXprocess",
292    "NXcharacterization",
293    "NXlog",
294    "NXnote",
295    "NXbeam",
296    "NXgeometry",
297    "NXtranslation",
298    "NXshape",
299    "NXorientation",
300    "NXenvironment",
301    "NXsensor",
302    "NXcapillary",
303    "NXcollection",
304    "NXdetector_group",
305    "NXparameters",
306    "NXsubentry",
307    "NXxraylens",
308];
309
310/// Classify an XML element as a NeXus group node.
311fn is_nexus_group(el: &XmlElement) -> bool {
312    NEXUS_GROUP_CLASSES.contains(&el.name.as_str()) || el.attr("type") == Some("UserGroup")
313}
314
315/// NeXus file writer using HDF5 with NeXus group structure.
316pub struct NexusWriter {
317    current_path: Option<PathBuf>,
318    file: Option<H5File>,
319    frame_count: usize,
320    /// Reusable dataset handle for the main array data, multi-frame writes.
321    dataset: Option<H5Dataset>,
322    /// Per-frame `uniqueId` dataset (proper 1-D resizable dataset).
323    uid_dataset: Option<H5Dataset>,
324    /// Per-frame `timeStamp` dataset.
325    ts_dataset: Option<H5Dataset>,
326    /// Parsed NeXus XML template tree, if a valid template was loaded.
327    template: Option<XmlElement>,
328    /// HDF5 path of the group that contains the main array dataset (template
329    /// mode); the dataset itself is named by the template's `pArray` node.
330    data_group_path: String,
331    data_node_name: String,
332    /// HDF5 path of the NXdata group that receives the image-data copy
333    /// (built-in hierarchy only — `Some("entry/data")`). `None` in template
334    /// mode, where the template controls all dataset placement.
335    nxdata_group_path: Option<String>,
336}
337
338impl NexusWriter {
339    pub fn new() -> Self {
340        Self {
341            current_path: None,
342            file: None,
343            frame_count: 0,
344            dataset: None,
345            uid_dataset: None,
346            ts_dataset: None,
347            template: None,
348            data_group_path: "entry/instrument/detector".to_string(),
349            data_node_name: "data".to_string(),
350            nxdata_group_path: None,
351        }
352    }
353
354    pub fn frame_count(&self) -> usize {
355        self.frame_count
356    }
357
358    /// Whether a NeXus XML template is currently loaded.
359    pub fn has_template(&self) -> bool {
360        self.template.is_some()
361    }
362
363    /// Load (parse and validate) a NeXus XML template. On success the template
364    /// drives the file structure; on parse failure the template is cleared and
365    /// the writer falls back to its built-in NXentry/NXdata hierarchy. Returns
366    /// whether the template parsed successfully (C++ `NDFileNexusTemplateValid`).
367    pub fn load_template(&mut self, xml: &str) -> bool {
368        match parse_nexus_template(xml) {
369            Ok(root) => {
370                self.template = Some(root);
371                true
372            }
373            Err(_) => {
374                self.template = None;
375                false
376            }
377        }
378    }
379
380    /// Clear any loaded template (revert to the built-in hierarchy).
381    pub fn clear_template(&mut self) {
382        self.template = None;
383    }
384
385    /// Write the NeXus `NX_class` group marker as a true HDF5 group attribute.
386    ///
387    /// NeXus requires `NX_class` to be an HDF5 *group attribute*. rust-hdf5
388    /// 0.2.15 exposes `H5Group::set_attr_string`, so the class name is written
389    /// as a real attribute on the group itself — NeXus-aware readers (nexpy,
390    /// DAWN, h5py NeXus) recognise the group class.
391    fn write_nx_class(group: &rust_hdf5::H5Group, class_name: &str) -> ADResult<()> {
392        group.set_attr_string("NX_class", class_name).map_err(|e| {
393            ADError::UnsupportedConversion(format!("NX_class group attr error: {}", e))
394        })
395    }
396
397    /// Write `NX_class` as a true HDF5 attribute on the root group.
398    fn apply_root_nx_class(file: &H5File, class_name: &str) {
399        let _ = file.set_attr_string("NX_class", class_name);
400    }
401
402    /// Process the NeXus XML template to build the file's group/dataset tree
403    /// (port of C++ `NDFileNexus::processNode` / `iterateNodes`).
404    ///
405    /// Returns the HDF5 path of the group that should contain the main array
406    /// dataset and the dataset name, identified by the template's `pArray`
407    /// node. If the template contains no `pArray` node the built-in default
408    /// (`entry/instrument/detector` / `data`) is kept.
409    fn process_template(
410        h5file: &H5File,
411        template: &XmlElement,
412        array: &NDArray,
413    ) -> ADResult<(String, String)> {
414        let mut data_group = String::new();
415        let mut data_node = String::new();
416        // The root template node is typically NXroot — iterate its children.
417        let top_children: &[XmlElement] = if template.name == "NXroot" {
418            &template.children
419        } else {
420            std::slice::from_ref(template)
421        };
422        for child in top_children {
423            Self::process_node(
424                h5file,
425                None,
426                "",
427                child,
428                array,
429                &mut data_group,
430                &mut data_node,
431            )?;
432        }
433        if data_node.is_empty() {
434            Ok(("entry/instrument/detector".to_string(), "data".to_string()))
435        } else {
436            Ok((data_group, data_node))
437        }
438    }
439
440    /// Recursively process one template node. `parent` is the HDF5 group to
441    /// create children in (None = file root); `parent_path` is its HDF5 path.
442    fn process_node(
443        h5file: &H5File,
444        parent: Option<&rust_hdf5::H5Group>,
445        parent_path: &str,
446        node: &XmlElement,
447        array: &NDArray,
448        data_group: &mut String,
449        data_node: &mut String,
450    ) -> ADResult<()> {
451        let node_type = node.attr("type").map(|s| s.to_string());
452
453        if is_nexus_group(node) {
454            // Group node: HDF5 group named by `name` attr or the tag itself,
455            // NX_class = the tag.
456            let group_name = node.attr("name").unwrap_or(&node.name).to_string();
457            let group = match parent {
458                Some(p) => p.create_group(&group_name),
459                None => h5file.create_group(&group_name),
460            }
461            .map_err(|e| {
462                ADError::UnsupportedConversion(format!("NeXus group '{}': {}", group_name, e))
463            })?;
464            let class_name = if NEXUS_GROUP_CLASSES.contains(&node.name.as_str()) {
465                node.name.clone()
466            } else {
467                // UserGroup: NX_class taken from an explicit attr if present.
468                node.attr("type").unwrap_or("NXcollection").to_string()
469            };
470            Self::write_nx_class(&group, &class_name)?;
471            let child_path = if parent_path.is_empty() {
472                group_name.clone()
473            } else {
474                format!("{}/{}", parent_path, group_name)
475            };
476            for child in &node.children {
477                Self::process_node(
478                    h5file,
479                    Some(&group),
480                    &child_path,
481                    child,
482                    array,
483                    data_group,
484                    data_node,
485                )?;
486            }
487            return Ok(());
488        }
489
490        // Non-group node.
491        match node_type.as_deref() {
492            Some("pArray") => {
493                // The main NDArray dataset is created lazily in write_file;
494                // here we only record where it goes.
495                *data_group = parent_path.to_string();
496                *data_node = node.name.clone();
497            }
498            Some("CONST") => {
499                // Constant dataset: string text written once.
500                if let Some(parent) = parent {
501                    Self::write_const_dataset(parent, &node.name, &node.text)?;
502                }
503            }
504            Some("ND_ATTR") => {
505                // Dataset sourced from an NDAttribute, written once.
506                if let Some(parent) = parent {
507                    let source = node.attr("source").unwrap_or(&node.name);
508                    if let Some(attr) = array.attributes.get(source) {
509                        Self::write_attr_dataset(parent, &node.name, &attr.value)?;
510                    }
511                }
512            }
513            Some("Attr") | None if node.name == "Attr" => {
514                // Group attribute node — written as a true HDF5 group
515                // attribute (rust-hdf5 0.2.15 `H5Group::set_attr_string`).
516                if let Some(parent) = parent {
517                    let attr_name = node.attr("name").unwrap_or(&node.name);
518                    let value = if node.attr("type") == Some("ND_ATTR") {
519                        node.attr("source")
520                            .and_then(|s| array.attributes.get(s))
521                            .map(|a| a.value.as_string())
522                            .unwrap_or_default()
523                    } else {
524                        node.text.clone()
525                    };
526                    parent.set_attr_string(attr_name, &value).map_err(|e| {
527                        ADError::UnsupportedConversion(format!("NeXus group attr error: {}", e))
528                    })?;
529                }
530            }
531            _ => {
532                // Untyped leaf → constant char dataset of its text content.
533                if let Some(parent) = parent {
534                    let text = if node.text.is_empty() {
535                        "LEFT BLANK"
536                    } else {
537                        &node.text
538                    };
539                    Self::write_const_dataset(parent, &node.name, text)?;
540                }
541            }
542        }
543        Ok(())
544    }
545
546    /// Write a constant string dataset (NeXus CONST node, NX_CHAR).
547    fn write_const_dataset(group: &rust_hdf5::H5Group, name: &str, text: &str) -> ADResult<()> {
548        let bytes = text.as_bytes();
549        let len = bytes.len().max(1);
550        let ds = group
551            .new_dataset::<u8>()
552            .shape([len])
553            .create(name)
554            .map_err(|e| {
555                ADError::UnsupportedConversion(format!("NeXus const dataset '{}': {}", name, e))
556            })?;
557        let mut buf = bytes.to_vec();
558        if buf.is_empty() {
559            buf.push(0);
560        }
561        ds.write_raw(&buf).map_err(|e| {
562            ADError::UnsupportedConversion(format!("NeXus const write '{}': {}", name, e))
563        })?;
564        Ok(())
565    }
566
567    /// Write an NDAttribute as a typed scalar dataset (NeXus ND_ATTR node).
568    /// The numeric NDAttrValue type is preserved, not stringified.
569    fn write_attr_dataset(
570        group: &rust_hdf5::H5Group,
571        name: &str,
572        value: &ad_core_rs::attributes::NDAttrValue,
573    ) -> ADResult<()> {
574        use ad_core_rs::attributes::NDAttrValue;
575        macro_rules! scalar {
576            ($t:ty, $v:expr) => {{
577                let ds = group
578                    .new_dataset::<$t>()
579                    .shape([1usize])
580                    .create(name)
581                    .map_err(|e| {
582                        ADError::UnsupportedConversion(format!(
583                            "NeXus attr dataset '{}': {}",
584                            name, e
585                        ))
586                    })?;
587                ds.write_raw(&[$v]).map_err(|e| {
588                    ADError::UnsupportedConversion(format!("NeXus attr write '{}': {}", name, e))
589                })?;
590            }};
591        }
592        match value {
593            NDAttrValue::Int8(v) => scalar!(i8, *v),
594            NDAttrValue::UInt8(v) => scalar!(u8, *v),
595            NDAttrValue::Int16(v) => scalar!(i16, *v),
596            NDAttrValue::UInt16(v) => scalar!(u16, *v),
597            NDAttrValue::Int32(v) => scalar!(i32, *v),
598            NDAttrValue::UInt32(v) => scalar!(u32, *v),
599            NDAttrValue::Int64(v) => scalar!(i64, *v),
600            NDAttrValue::UInt64(v) => scalar!(u64, *v),
601            NDAttrValue::Float32(v) => scalar!(f32, *v),
602            NDAttrValue::Float64(v) => scalar!(f64, *v),
603            NDAttrValue::String(s) => Self::write_const_dataset(group, name, s)?,
604            NDAttrValue::Undefined => Self::write_const_dataset(group, name, "")?,
605        }
606        Ok(())
607    }
608}
609
610impl Default for NexusWriter {
611    fn default() -> Self {
612        Self::new()
613    }
614}
615
616impl NDFileWriter for NexusWriter {
617    fn open_file(&mut self, path: &Path, _mode: NDFileMode, array: &NDArray) -> ADResult<()> {
618        self.current_path = Some(path.to_path_buf());
619        self.frame_count = 0;
620        self.dataset = None;
621        self.uid_dataset = None;
622        self.ts_dataset = None;
623
624        let h5file = H5File::create(path)
625            .map_err(|e| ADError::UnsupportedConversion(format!("NeXus create error: {}", e)))?;
626
627        // Root-group NX_class is a true HDF5 attribute.
628        Self::apply_root_nx_class(&h5file, "NXroot");
629
630        self.dataset = None;
631        self.uid_dataset = None;
632        self.ts_dataset = None;
633        self.nxdata_group_path = None;
634
635        if let Some(template) = self.template.clone() {
636            // Template mode: build the group/dataset tree from the user's
637            // NeXus XML template (C++ NDFileNexus::loadTemplateFile).
638            let (data_group, data_node) = Self::process_template(&h5file, &template, array)?;
639            self.data_group_path = data_group;
640            self.data_node_name = data_node;
641        } else {
642            // Built-in NXentry/NXdata hierarchy. NX_class on every group is a
643            // true HDF5 group attribute (rust-hdf5 0.2.15).
644            let entry = h5file
645                .create_group("entry")
646                .map_err(|e| ADError::UnsupportedConversion(format!("NeXus group error: {}", e)))?;
647            Self::write_nx_class(&entry, "NXentry")?;
648            let instrument = entry
649                .create_group("instrument")
650                .map_err(|e| ADError::UnsupportedConversion(format!("NeXus group error: {}", e)))?;
651            Self::write_nx_class(&instrument, "NXinstrument")?;
652            let detector = instrument
653                .create_group("detector")
654                .map_err(|e| ADError::UnsupportedConversion(format!("NeXus group error: {}", e)))?;
655            Self::write_nx_class(&detector, "NXdetector")?;
656            let data_group = entry
657                .create_group("data")
658                .map_err(|e| ADError::UnsupportedConversion(format!("NeXus group error: {}", e)))?;
659            Self::write_nx_class(&data_group, "NXdata")?;
660            self.data_group_path = "entry/instrument/detector".to_string();
661            self.data_node_name = "data".to_string();
662            // The image data must appear inside the NXdata group so NeXus
663            // readers locate the signal. write_file hard-links the detector
664            // dataset into this group (rust-hdf5 0.2.15 `H5Group::link`).
665            self.nxdata_group_path = Some("entry/data".to_string());
666        }
667
668        self.file = Some(h5file);
669        Ok(())
670    }
671
672    fn write_file(&mut self, array: &NDArray) -> ADResult<()> {
673        let h5file = self
674            .file
675            .as_ref()
676            .ok_or_else(|| ADError::UnsupportedConversion("no NeXus file open".into()))?;
677
678        let frame_shape = array.dims.iter().rev().map(|d| d.size).collect::<Vec<_>>();
679        let data_node_name = self.data_node_name.clone();
680        let nxdata_group_path = self.nxdata_group_path.clone();
681
682        // Resolve a group from its slash-separated HDF5 path.
683        let resolve_group = |path: &str| -> ADResult<rust_hdf5::H5Group> {
684            let mut group = h5file.root_group();
685            for component in path.split('/') {
686                if component.is_empty() {
687                    continue;
688                }
689                group = group
690                    .group(component)
691                    .map_err(|e| ADError::UnsupportedConversion(e.to_string()))?;
692            }
693            Ok(group)
694        };
695        let data_group = resolve_group(&self.data_group_path)?;
696
697        // Element type recorded on the data dataset for lossless read-back,
698        // and the frame bytes serialized explicitly little-endian (rust-hdf5
699        // 0.2.15 records LE datatypes and copies write_chunk bytes verbatim).
700        let dtype_ordinal = array.data.data_type() as i32;
701        let frame_bytes = nd_buffer_to_le_bytes(&array.data);
702
703        if self.frame_count == 0 {
704            // First frame: create a chunked dataset with leading frame dim.
705            // Shape: [1, dim0, dim1, ...], chunk: [1, dim0, dim1, ...]
706            let mut ds_shape = vec![1usize];
707            ds_shape.extend_from_slice(&frame_shape);
708            let chunk_dims = ds_shape.clone();
709            // Only the leading frame axis is unlimited. rust-hdf5 0.2.15 picks
710            // the chunk index from the unlimited-dimension count: one unlimited
711            // dim → extensible array (linear `write_chunk`); two or more →
712            // v2 B-tree (which requires `write_chunk_at`). `.resizable()` would
713            // make every axis unlimited and force the v2 B-tree path.
714            let mut image_max_shape: Vec<Option<usize>> = vec![None];
715            image_max_shape.extend(frame_shape.iter().map(|&d| Some(d)));
716
717            // Create the image dataset typed per the NDArray data type; all
718            // ten NDArray types are covered so read_file can recover them.
719            macro_rules! create_image_ds {
720                ($group:expr, $t:ty, $name:expr) => {{
721                    $group
722                        .new_dataset::<$t>()
723                        .shape(&ds_shape[..])
724                        .chunk(&chunk_dims[..])
725                        .max_shape(&image_max_shape[..])
726                        .create($name)
727                        .map_err(|e| {
728                            ADError::UnsupportedConversion(format!("NeXus dataset error: {}", e))
729                        })?
730                }};
731            }
732            macro_rules! create_typed {
733                ($group:expr, $name:expr) => {{
734                    match array.data.data_type() {
735                        NDDataType::Int8 => create_image_ds!($group, i8, $name),
736                        NDDataType::UInt8 => create_image_ds!($group, u8, $name),
737                        NDDataType::Int16 => create_image_ds!($group, i16, $name),
738                        NDDataType::UInt16 => create_image_ds!($group, u16, $name),
739                        NDDataType::Int32 => create_image_ds!($group, i32, $name),
740                        NDDataType::UInt32 => create_image_ds!($group, u32, $name),
741                        NDDataType::Int64 => create_image_ds!($group, i64, $name),
742                        NDDataType::UInt64 => create_image_ds!($group, u64, $name),
743                        NDDataType::Float32 => create_image_ds!($group, f32, $name),
744                        NDDataType::Float64 => create_image_ds!($group, f64, $name),
745                    }
746                }};
747            }
748
749            let ds = create_typed!(data_group, &data_node_name);
750            ds.write_chunk(0, &frame_bytes)
751                .map_err(|e| ADError::UnsupportedConversion(format!("NeXus write error: {}", e)))?;
752            // Record the exact data type for lossless read-back.
753            let _ = ds
754                .new_attr::<i32>()
755                .shape(())
756                .create(DTYPE_ATTR)
757                .and_then(|a| a.write_numeric(&dtype_ordinal));
758            // Write NDArray attributes on the first frame.
759            for attr in array.attributes.iter() {
760                let val_str = attr.value.as_string();
761                let _ = ds
762                    .new_attr::<rust_hdf5::types::VarLenUnicode>()
763                    .shape(())
764                    .create(attr.name.as_str())
765                    .and_then(|a| {
766                        let s: rust_hdf5::types::VarLenUnicode =
767                            val_str.parse().unwrap_or_default();
768                        a.write_scalar(&s)
769                    });
770            }
771            self.dataset = Some(ds);
772
773            // Built-in hierarchy: also place the image data inside the
774            // NXdata group so a NeXus reader can locate the signal there.
775            if let Some(ref nxpath) = nxdata_group_path {
776                let nxdata_group = resolve_group(nxpath)?;
777                // Hard-link the detector dataset into the NXdata group: one
778                // physical dataset, two names (rust-hdf5 0.2.15 H5Group::link).
779                // Extending the detector dataset per frame is automatically
780                // visible through the link — no duplicate copy.
781                let target = format!("/{}/{}", self.data_group_path, data_node_name);
782                nxdata_group.link("data", &target).map_err(|e| {
783                    ADError::UnsupportedConversion(format!("NeXus NXdata link error: {}", e))
784                })?;
785            }
786
787            // Per-frame uniqueId / timeStamp are proper resizable 1-D
788            // datasets (C++ writes them as datasets, not as N numbered
789            // attributes). Created here on the first frame.
790            let uid = data_group
791                .new_dataset::<i32>()
792                .shape([1usize])
793                .chunk(&[1usize])
794                .resizable()
795                .create("uniqueId")
796                .map_err(|e| {
797                    ADError::UnsupportedConversion(format!("NeXus uniqueId dataset: {}", e))
798                })?;
799            uid.write_chunk(0, &array.unique_id.to_le_bytes())
800                .map_err(|e| {
801                    ADError::UnsupportedConversion(format!("NeXus uniqueId write: {}", e))
802                })?;
803            self.uid_dataset = Some(uid);
804
805            let ts = data_group
806                .new_dataset::<f64>()
807                .shape([1usize])
808                .chunk(&[1usize])
809                .resizable()
810                .create("timeStamp")
811                .map_err(|e| {
812                    ADError::UnsupportedConversion(format!("NeXus timeStamp dataset: {}", e))
813                })?;
814            ts.write_chunk(0, &array.time_stamp.to_le_bytes())
815                .map_err(|e| {
816                    ADError::UnsupportedConversion(format!("NeXus timeStamp write: {}", e))
817                })?;
818            self.ts_dataset = Some(ts);
819        } else {
820            // Subsequent frames: extend dataset and write new chunk.
821            let ds = self.dataset.as_ref().ok_or_else(|| {
822                ADError::UnsupportedConversion("no dataset for multi-frame write".into())
823            })?;
824
825            let new_frame_count = self.frame_count + 1;
826            let mut new_shape = vec![new_frame_count];
827            new_shape.extend_from_slice(&frame_shape);
828            ds.extend(&new_shape).map_err(|e| {
829                ADError::UnsupportedConversion(format!("NeXus extend error: {}", e))
830            })?;
831            ds.write_chunk(self.frame_count, &frame_bytes)
832                .map_err(|e| ADError::UnsupportedConversion(format!("NeXus write error: {}", e)))?;
833
834            // The NXdata-group `data` is a hard link to this same dataset, so
835            // the extension above is already visible there — no separate copy.
836
837            // Extend the per-frame metadata datasets and append this frame.
838            if let Some(uid) = self.uid_dataset.as_ref() {
839                uid.extend(&[new_frame_count]).map_err(|e| {
840                    ADError::UnsupportedConversion(format!("NeXus uniqueId extend: {}", e))
841                })?;
842                uid.write_chunk(self.frame_count, &array.unique_id.to_le_bytes())
843                    .map_err(|e| {
844                        ADError::UnsupportedConversion(format!("NeXus uniqueId write: {}", e))
845                    })?;
846            }
847            if let Some(ts) = self.ts_dataset.as_ref() {
848                ts.extend(&[new_frame_count]).map_err(|e| {
849                    ADError::UnsupportedConversion(format!("NeXus timeStamp extend: {}", e))
850                })?;
851                ts.write_chunk(self.frame_count, &array.time_stamp.to_le_bytes())
852                    .map_err(|e| {
853                        ADError::UnsupportedConversion(format!("NeXus timeStamp write: {}", e))
854                    })?;
855            }
856        }
857
858        self.frame_count += 1;
859        Ok(())
860    }
861
862    fn read_file(&mut self) -> ADResult<NDArray> {
863        let path = self
864            .current_path
865            .as_ref()
866            .ok_or_else(|| ADError::UnsupportedConversion("no file open".into()))?;
867
868        let h5file = H5File::open(path)
869            .map_err(|e| ADError::UnsupportedConversion(format!("NeXus open error: {}", e)))?;
870
871        // Read from the data path established at open_file time (the built-in
872        // entry/instrument/detector/data, or the template's pArray location).
873        let data_path = format!("{}/{}", self.data_group_path, self.data_node_name);
874        let ds = h5file
875            .dataset(&data_path)
876            .map_err(|e| ADError::UnsupportedConversion(format!("NeXus dataset error: {}", e)))?;
877
878        let shape = ds.shape();
879        let dims: Vec<NDDimension> = shape.iter().rev().map(|&s| NDDimension::new(s)).collect();
880        let element_size = ds.element_size();
881
882        // Recover the exact NDArray data type from the recorded attribute.
883        // Untyped read_raw cannot distinguish signed/unsigned or i32/u32/f32
884        // (all same element size), so the recorded type is authoritative.
885        let recorded: Option<NDDataType> = ds
886            .attr(DTYPE_ATTR)
887            .ok()
888            .and_then(|a| a.read_numeric::<i32>().ok())
889            .and_then(|v| NDDataType::from_ordinal(v as u8));
890
891        let data_type = recorded.unwrap_or(match element_size {
892            1 => NDDataType::UInt8,
893            2 => NDDataType::UInt16,
894            4 => NDDataType::Float32,
895            8 => NDDataType::Float64,
896            other => {
897                return Err(ADError::UnsupportedConversion(format!(
898                    "unsupported NeXus element size {}",
899                    other
900                )));
901            }
902        });
903
904        macro_rules! read_typed {
905            ($t:ty, $variant:ident) => {{
906                let data = ds.read_raw::<$t>().map_err(|e| {
907                    ADError::UnsupportedConversion(format!("NeXus read error: {}", e))
908                })?;
909                let mut arr = NDArray::new(dims, data_type);
910                arr.data = NDDataBuffer::$variant(data);
911                return Ok(arr);
912            }};
913        }
914
915        match data_type {
916            NDDataType::Int8 => read_typed!(i8, I8),
917            NDDataType::UInt8 => read_typed!(u8, U8),
918            NDDataType::Int16 => read_typed!(i16, I16),
919            NDDataType::UInt16 => read_typed!(u16, U16),
920            NDDataType::Int32 => read_typed!(i32, I32),
921            NDDataType::UInt32 => read_typed!(u32, U32),
922            NDDataType::Int64 => read_typed!(i64, I64),
923            NDDataType::UInt64 => read_typed!(u64, U64),
924            NDDataType::Float32 => read_typed!(f32, F32),
925            NDDataType::Float64 => read_typed!(f64, F64),
926        }
927    }
928
929    fn close_file(&mut self) -> ADResult<()> {
930        self.dataset = None;
931        self.uid_dataset = None;
932        self.ts_dataset = None;
933        self.file = None;
934        self.current_path = None;
935        Ok(())
936    }
937
938    fn supports_multiple_arrays(&self) -> bool {
939        true
940    }
941}
942
943// ============================================================
944// Processor
945// ============================================================
946
947pub struct NexusFileProcessor {
948    ctrl: FilePluginController<NexusWriter>,
949    template_path_idx: Option<usize>,
950    template_file_idx: Option<usize>,
951    template_valid_idx: Option<usize>,
952    template_path: String,
953    template_file: String,
954}
955
956impl NexusFileProcessor {
957    pub fn new() -> Self {
958        Self {
959            ctrl: FilePluginController::new(NexusWriter::new()),
960            template_path_idx: None,
961            template_file_idx: None,
962            template_valid_idx: None,
963            template_path: String::new(),
964            template_file: String::new(),
965        }
966    }
967
968    /// Load the NeXus XML template from `template_path + template_file`
969    /// (C++ NDFileNexus::loadTemplateFile). Returns the validity flag value
970    /// for `NEXUS_TEMPLATE_VALID`: 1 if a template was loaded and parsed,
971    /// 0 if the file is unset, missing, or fails to parse.
972    fn reload_template(&mut self) -> i32 {
973        if self.template_file.is_empty() {
974            self.ctrl.writer.clear_template();
975            return 0;
976        }
977        let full = format!("{}{}", self.template_path, self.template_file);
978        match std::fs::read_to_string(&full) {
979            Ok(xml) => {
980                if self.ctrl.writer.load_template(&xml) {
981                    1
982                } else {
983                    0
984                }
985            }
986            Err(_) => {
987                self.ctrl.writer.clear_template();
988                0
989            }
990        }
991    }
992}
993
994impl Default for NexusFileProcessor {
995    fn default() -> Self {
996        Self::new()
997    }
998}
999
1000impl NDPluginProcess for NexusFileProcessor {
1001    fn process_array(&mut self, array: &NDArray, _pool: &NDArrayPool) -> ProcessResult {
1002        self.ctrl.process_array(array)
1003    }
1004
1005    fn plugin_type(&self) -> &str {
1006        "NDFileNexus"
1007    }
1008
1009    fn register_params(
1010        &mut self,
1011        base: &mut asyn_rs::port::PortDriverBase,
1012    ) -> asyn_rs::error::AsynResult<()> {
1013        self.ctrl.register_params(base)?;
1014        use asyn_rs::param::ParamType;
1015        let path_idx = base.create_param("NEXUS_TEMPLATE_PATH", ParamType::Octet)?;
1016        let file_idx = base.create_param("NEXUS_TEMPLATE_FILE", ParamType::Octet)?;
1017        let valid_idx = base.create_param("NEXUS_TEMPLATE_VALID", ParamType::Int32)?;
1018        base.create_param("TEMPLATE_FILE_PATH", ParamType::Octet)?;
1019        base.create_param("TEMPLATE_FILE_NAME", ParamType::Octet)?;
1020        base.create_param("TEMPLATE_FILE_VALID", ParamType::Int32)?;
1021        // C++ NDFileNexus.cpp:883 seeds NDFileNexusTemplateValid = 0.
1022        base.set_int32_param(valid_idx, 0, 0)?;
1023        self.template_path_idx = Some(path_idx);
1024        self.template_file_idx = Some(file_idx);
1025        self.template_valid_idx = Some(valid_idx);
1026        Ok(())
1027    }
1028
1029    fn on_param_change(
1030        &mut self,
1031        reason: usize,
1032        params: &PluginParamSnapshot,
1033    ) -> ParamChangeResult {
1034        use ad_core_rs::plugin::runtime::ParamChangeValue;
1035
1036        // NeXus XML template path / file changes trigger a template reload
1037        // (C++ NDFileNexus::writeInt32 / writeOctet → loadTemplateFile).
1038        if Some(reason) == self.template_path_idx {
1039            if let ParamChangeValue::Octet(s) = &params.value {
1040                self.template_path = s.clone();
1041            }
1042            let valid = self.reload_template();
1043            return self.template_valid_result(valid);
1044        }
1045        if Some(reason) == self.template_file_idx {
1046            if let ParamChangeValue::Octet(s) = &params.value {
1047                self.template_file = s.clone();
1048            }
1049            let valid = self.reload_template();
1050            return self.template_valid_result(valid);
1051        }
1052        self.ctrl.on_param_change(reason, params)
1053    }
1054}
1055
1056impl NexusFileProcessor {
1057    /// Build a ParamChangeResult that updates `NEXUS_TEMPLATE_VALID`.
1058    fn template_valid_result(&self, valid: i32) -> ParamChangeResult {
1059        use ad_core_rs::plugin::runtime::ParamUpdate;
1060        match self.template_valid_idx {
1061            Some(idx) => ParamChangeResult::updates(vec![ParamUpdate::Int32 {
1062                reason: idx,
1063                addr: 0,
1064                value: valid,
1065            }]),
1066            None => ParamChangeResult::empty(),
1067        }
1068    }
1069}
1070
1071#[cfg(test)]
1072mod tests {
1073    use super::*;
1074
1075    fn temp_path(prefix: &str) -> PathBuf {
1076        use std::sync::atomic::{AtomicU32, Ordering};
1077        static COUNTER: AtomicU32 = AtomicU32::new(0);
1078        let n = COUNTER.fetch_add(1, Ordering::Relaxed);
1079        std::env::temp_dir().join(format!("adcore_test_{}_{}.nxs", prefix, n))
1080    }
1081
1082    #[test]
1083    fn test_nexus_write_read() {
1084        let path = temp_path("nexus_basic");
1085        let mut writer = NexusWriter::new();
1086
1087        let mut arr = NDArray::new(
1088            vec![NDDimension::new(4), NDDimension::new(4)],
1089            NDDataType::UInt8,
1090        );
1091        if let NDDataBuffer::U8(ref mut v) = arr.data {
1092            for i in 0..16 {
1093                v[i] = i as u8;
1094            }
1095        }
1096
1097        writer.open_file(&path, NDFileMode::Single, &arr).unwrap();
1098        writer.write_file(&arr).unwrap();
1099        writer.close_file().unwrap();
1100
1101        // Verify NeXus structure
1102        let h5file = H5File::open(&path).unwrap();
1103        let ds = h5file.dataset("entry/instrument/detector/data").unwrap();
1104        let data: Vec<u8> = ds.read_raw().unwrap();
1105        assert_eq!(data.len(), 16);
1106        assert_eq!(data[0], 0);
1107        assert_eq!(data[15], 15);
1108
1109        std::fs::remove_file(&path).ok();
1110    }
1111
1112    #[test]
1113    fn test_nexus_multiple_frames() {
1114        let path = temp_path("nexus_multi");
1115        let mut writer = NexusWriter::new();
1116
1117        let mut arr1 = NDArray::new(
1118            vec![NDDimension::new(4), NDDimension::new(4)],
1119            NDDataType::UInt8,
1120        );
1121        if let NDDataBuffer::U8(ref mut v) = arr1.data {
1122            for i in 0..16 {
1123                v[i] = i as u8;
1124            }
1125        }
1126
1127        let mut arr2 = NDArray::new(
1128            vec![NDDimension::new(4), NDDimension::new(4)],
1129            NDDataType::UInt8,
1130        );
1131        if let NDDataBuffer::U8(ref mut v) = arr2.data {
1132            for i in 0..16 {
1133                v[i] = (i + 100) as u8;
1134            }
1135        }
1136
1137        writer.open_file(&path, NDFileMode::Stream, &arr1).unwrap();
1138        writer.write_file(&arr1).unwrap();
1139        writer.write_file(&arr2).unwrap();
1140        writer.close_file().unwrap();
1141
1142        assert_eq!(writer.frame_count(), 2);
1143
1144        // Verify single dataset with leading frame dimension [2, 4, 4]
1145        let h5file = H5File::open(&path).unwrap();
1146        let ds = h5file.dataset("entry/instrument/detector/data").unwrap();
1147        let shape = ds.shape();
1148        assert_eq!(shape, vec![2, 4, 4]);
1149
1150        let data: Vec<u8> = ds.read_raw().unwrap();
1151        assert_eq!(data.len(), 32);
1152        // First frame
1153        assert_eq!(data[0], 0);
1154        assert_eq!(data[15], 15);
1155        // Second frame
1156        assert_eq!(data[16], 100);
1157        assert_eq!(data[31], 115);
1158
1159        std::fs::remove_file(&path).ok();
1160    }
1161
1162    #[test]
1163    fn test_per_frame_metadata_are_datasets_not_attributes() {
1164        let path = temp_path("nexus_meta");
1165        let mut writer = NexusWriter::new();
1166
1167        let mut a1 = NDArray::new(
1168            vec![NDDimension::new(2), NDDimension::new(2)],
1169            NDDataType::UInt8,
1170        );
1171        a1.unique_id = 10;
1172        a1.time_stamp = 1.5;
1173        let mut a2 = NDArray::new(
1174            vec![NDDimension::new(2), NDDimension::new(2)],
1175            NDDataType::UInt8,
1176        );
1177        a2.unique_id = 11;
1178        a2.time_stamp = 2.5;
1179
1180        writer.open_file(&path, NDFileMode::Stream, &a1).unwrap();
1181        writer.write_file(&a1).unwrap();
1182        writer.write_file(&a2).unwrap();
1183        writer.close_file().unwrap();
1184
1185        let h5file = H5File::open(&path).unwrap();
1186        // uniqueId / timeStamp are proper datasets with a leading frame dim,
1187        // not N numbered attributes on the data dataset.
1188        let uid = h5file
1189            .dataset("entry/instrument/detector/uniqueId")
1190            .unwrap();
1191        assert_eq!(uid.shape(), vec![2]);
1192        let uid_data: Vec<i32> = uid.read_raw().unwrap();
1193        assert_eq!(uid_data, vec![10, 11]);
1194
1195        let ts = h5file
1196            .dataset("entry/instrument/detector/timeStamp")
1197            .unwrap();
1198        assert_eq!(ts.shape(), vec![2]);
1199        let ts_data: Vec<f64> = ts.read_raw().unwrap();
1200        assert_eq!(ts_data, vec![1.5, 2.5]);
1201
1202        std::fs::remove_file(&path).ok();
1203    }
1204
1205    #[test]
1206    fn test_xml_template_parser() {
1207        let xml = r#"<?xml version="1.0"?>
1208            <NXroot>
1209              <NXentry name="entry">
1210                <NXdata name="data">
1211                  <data type="pArray"/>
1212                </NXdata>
1213                <title>My Experiment</title>
1214              </NXentry>
1215            </NXroot>"#;
1216        let root = parse_nexus_template(xml).unwrap();
1217        assert_eq!(root.name, "NXroot");
1218        assert_eq!(root.children.len(), 1);
1219        let entry = &root.children[0];
1220        assert_eq!(entry.name, "NXentry");
1221        assert_eq!(entry.attr("name"), Some("entry"));
1222        assert_eq!(entry.children.len(), 2);
1223        assert_eq!(entry.children[1].name, "title");
1224        assert_eq!(entry.children[1].text, "My Experiment");
1225        let data = &entry.children[0].children[0];
1226        assert_eq!(data.attr("type"), Some("pArray"));
1227    }
1228
1229    #[test]
1230    fn test_template_drives_file_structure() {
1231        // Template places the array dataset at a non-default path.
1232        let xml = r#"<NXroot>
1233              <NXentry name="scan">
1234                <NXdata name="measurement">
1235                  <frames type="pArray"/>
1236                  <title>const-title</title>
1237                </NXdata>
1238              </NXentry>
1239            </NXroot>"#;
1240        let mut writer = NexusWriter::new();
1241        assert!(writer.load_template(xml));
1242        assert!(writer.has_template());
1243
1244        let path = temp_path("nexus_tmpl");
1245        let mut arr = NDArray::new(
1246            vec![NDDimension::new(3), NDDimension::new(2)],
1247            NDDataType::UInt8,
1248        );
1249        if let NDDataBuffer::U8(ref mut v) = arr.data {
1250            for (i, x) in v.iter_mut().enumerate() {
1251                *x = i as u8;
1252            }
1253        }
1254        writer.open_file(&path, NDFileMode::Single, &arr).unwrap();
1255        writer.write_file(&arr).unwrap();
1256        writer.close_file().unwrap();
1257
1258        let h5file = H5File::open(&path).unwrap();
1259        // Array dataset lives at the template-specified path.
1260        let ds = h5file.dataset("scan/measurement/frames").unwrap();
1261        assert_eq!(ds.shape(), vec![1, 2, 3]);
1262        // Constant title dataset created from the template node text.
1263        let title = h5file.dataset("scan/measurement/title").unwrap();
1264        let title_bytes: Vec<u8> = title.read_raw().unwrap();
1265        assert_eq!(
1266            String::from_utf8_lossy(&title_bytes).trim_end_matches('\0'),
1267            "const-title"
1268        );
1269
1270        std::fs::remove_file(&path).ok();
1271    }
1272
1273    #[test]
1274    fn test_load_template_invalid_xml_returns_false() {
1275        let mut writer = NexusWriter::new();
1276        assert!(!writer.load_template("<NXroot><unclosed>"));
1277        assert!(!writer.has_template());
1278    }
1279
1280    #[test]
1281    fn test_template_param_change_sets_valid_flag() {
1282        use ad_core_rs::plugin::runtime::{ParamChangeValue, ParamUpdate, PluginParamSnapshot};
1283        use asyn_rs::port::{PortDriverBase, PortFlags};
1284
1285        let dir = std::env::temp_dir();
1286        let tmpl_path = dir.join("adcore_nexus_template_test.xml");
1287        std::fs::write(
1288            &tmpl_path,
1289            r#"<NXroot><NXentry name="e"><NXdata name="d"><x type="pArray"/></NXdata></NXentry></NXroot>"#,
1290        )
1291        .unwrap();
1292
1293        let mut base = PortDriverBase::new("nexus_tmpl_test", 1, PortFlags::default());
1294        let mut proc = NexusFileProcessor::new();
1295        proc.register_params(&mut base).unwrap();
1296
1297        let file_idx = proc.template_file_idx.unwrap();
1298        let valid_idx = proc.template_valid_idx.unwrap();
1299
1300        let result = proc.on_param_change(
1301            file_idx,
1302            &PluginParamSnapshot {
1303                enable_callbacks: true,
1304                reason: file_idx,
1305                addr: 0,
1306                value: ParamChangeValue::Octet(tmpl_path.to_string_lossy().into_owned()),
1307            },
1308        );
1309        assert!(result.param_updates.iter().any(|u| matches!(
1310            u,
1311            ParamUpdate::Int32 { reason, value: 1, .. } if *reason == valid_idx
1312        )));
1313        assert!(proc.ctrl.writer.has_template());
1314
1315        std::fs::remove_file(&tmpl_path).ok();
1316    }
1317
1318    #[test]
1319    fn test_nxdata_group_contains_image_data() {
1320        // N2: the built-in NXdata group `/entry/data` must actually contain
1321        // the image dataset, not be an empty group.
1322        let path = temp_path("nexus_nxdata");
1323        let mut writer = NexusWriter::new();
1324
1325        let mk = |base: u8| {
1326            let mut arr = NDArray::new(
1327                vec![NDDimension::new(3), NDDimension::new(2)],
1328                NDDataType::UInt8,
1329            );
1330            if let NDDataBuffer::U8(ref mut v) = arr.data {
1331                for (i, x) in v.iter_mut().enumerate() {
1332                    *x = base + i as u8;
1333                }
1334            }
1335            arr
1336        };
1337
1338        let a0 = mk(0);
1339        writer.open_file(&path, NDFileMode::Stream, &a0).unwrap();
1340        writer.write_file(&a0).unwrap();
1341        writer.write_file(&mk(100)).unwrap();
1342        writer.close_file().unwrap();
1343
1344        let h5 = H5File::open(&path).unwrap();
1345        // The NXdata group dataset exists and carries the data.
1346        let nx = h5
1347            .dataset("entry/data/data")
1348            .expect("NXdata group must contain the `data` dataset");
1349        assert_eq!(nx.shape(), vec![2, 2, 3]);
1350        let nx_vals: Vec<u8> = nx.read_raw().unwrap();
1351        assert_eq!(nx_vals.len(), 2 * 6);
1352        assert_eq!(nx_vals[0], 0);
1353        assert_eq!(nx_vals[6], 100);
1354        // It mirrors the detector dataset content.
1355        let det: Vec<u8> = h5
1356            .dataset("entry/instrument/detector/data")
1357            .unwrap()
1358            .read_raw()
1359            .unwrap();
1360        assert_eq!(nx_vals, det);
1361
1362        std::fs::remove_file(&path).ok();
1363    }
1364
1365    #[test]
1366    fn test_nx_class_is_true_group_attribute() {
1367        // NeXus requires NX_class to be an HDF5 group attribute, not a child
1368        // dataset. rust-hdf5 0.2.15 supports group attributes on every group.
1369        let path = temp_path("nexus_nxclass");
1370        let mut writer = NexusWriter::new();
1371        let mut arr = NDArray::new(
1372            vec![NDDimension::new(3), NDDimension::new(2)],
1373            NDDataType::UInt8,
1374        );
1375        if let NDDataBuffer::U8(ref mut v) = arr.data {
1376            v.iter_mut().enumerate().for_each(|(i, x)| *x = i as u8);
1377        }
1378        writer.open_file(&path, NDFileMode::Single, &arr).unwrap();
1379        writer.write_file(&arr).unwrap();
1380        writer.close_file().unwrap();
1381
1382        let h5 = H5File::open(&path).unwrap();
1383        // Non-root groups carry NX_class as a real group attribute.
1384        for (group, class) in [
1385            ("entry", "NXentry"),
1386            ("entry/instrument", "NXinstrument"),
1387            ("entry/instrument/detector", "NXdetector"),
1388            ("entry/data", "NXdata"),
1389        ] {
1390            let g = h5.root_group().group(group).expect("group exists");
1391            let got = g
1392                .attr_string("NX_class")
1393                .unwrap_or_else(|_| panic!("{} must have NX_class group attribute", group));
1394            assert_eq!(got, class, "{} NX_class", group);
1395        }
1396        // No child `NX_class` dataset (the old workaround) anywhere.
1397        assert!(h5.dataset("entry/NX_class").is_err());
1398
1399        std::fs::remove_file(&path).ok();
1400    }
1401
1402    #[test]
1403    fn test_read_file_roundtrips_all_data_types() {
1404        // N4: every NDArray data type must round-trip through read_file,
1405        // not just u8/u16/f64.
1406        use ad_core_rs::ndarray::NDDataType::*;
1407        for dt in [
1408            Int8, UInt8, Int16, UInt16, Int32, UInt32, Int64, UInt64, Float32, Float64,
1409        ] {
1410            let path = temp_path(&format!("nexus_type_{:?}", dt));
1411            let mut writer = NexusWriter::new();
1412            let mut arr = NDArray::new(vec![NDDimension::new(2), NDDimension::new(2)], dt);
1413            // Distinct, type-revealing values: negatives for signed types.
1414            match arr.data {
1415                NDDataBuffer::I8(ref mut v) => v.copy_from_slice(&[-1, 2, -3, 4]),
1416                NDDataBuffer::U8(ref mut v) => v.copy_from_slice(&[1, 2, 3, 4]),
1417                NDDataBuffer::I16(ref mut v) => v.copy_from_slice(&[-1, 2, -3, 4]),
1418                NDDataBuffer::U16(ref mut v) => v.copy_from_slice(&[1, 2, 3, 4]),
1419                NDDataBuffer::I32(ref mut v) => v.copy_from_slice(&[-1, 2, -3, 4]),
1420                NDDataBuffer::U32(ref mut v) => v.copy_from_slice(&[1, 2, 3, 4]),
1421                NDDataBuffer::I64(ref mut v) => v.copy_from_slice(&[-1, 2, -3, 4]),
1422                NDDataBuffer::U64(ref mut v) => v.copy_from_slice(&[1, 2, 3, 4]),
1423                NDDataBuffer::F32(ref mut v) => v.copy_from_slice(&[-1.5, 2.5, -3.5, 4.5]),
1424                NDDataBuffer::F64(ref mut v) => v.copy_from_slice(&[-1.5, 2.5, -3.5, 4.5]),
1425            }
1426
1427            writer.open_file(&path, NDFileMode::Single, &arr).unwrap();
1428            writer.write_file(&arr).unwrap();
1429            writer.close_file().unwrap();
1430
1431            let mut reader = NexusWriter::new();
1432            reader.current_path = Some(path.clone());
1433            let read = reader
1434                .read_file()
1435                .unwrap_or_else(|e| panic!("{:?} read failed: {}", dt, e));
1436            assert_eq!(read.data.data_type(), dt, "{:?}: type must round-trip", dt);
1437            // Leading frame dim [1] + 2x2 => 3 dims, 4 elements.
1438            assert_eq!(read.data.len(), 4, "{:?}: element count", dt);
1439            for i in 0..4 {
1440                assert_eq!(
1441                    arr.data.get_as_f64(i),
1442                    read.data.get_as_f64(i),
1443                    "{:?}: element {} must round-trip",
1444                    dt,
1445                    i
1446                );
1447            }
1448
1449            std::fs::remove_file(&path).ok();
1450        }
1451    }
1452}