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    /// C `NDPluginFile.cpp:948` (base of every file writer) sets
1010    /// `NDArrayCallbacks = 0`: file plugins write to disk, not downstream.
1011    fn does_array_callbacks(&self) -> bool {
1012        false
1013    }
1014
1015    fn register_params(
1016        &mut self,
1017        base: &mut asyn_rs::port::PortDriverBase,
1018    ) -> asyn_rs::error::AsynResult<()> {
1019        self.ctrl.register_params(base)?;
1020        use asyn_rs::param::ParamType;
1021        let path_idx = base.create_param("NEXUS_TEMPLATE_PATH", ParamType::Octet)?;
1022        let file_idx = base.create_param("NEXUS_TEMPLATE_FILE", ParamType::Octet)?;
1023        let valid_idx = base.create_param("NEXUS_TEMPLATE_VALID", ParamType::Int32)?;
1024        base.create_param("TEMPLATE_FILE_PATH", ParamType::Octet)?;
1025        base.create_param("TEMPLATE_FILE_NAME", ParamType::Octet)?;
1026        base.create_param("TEMPLATE_FILE_VALID", ParamType::Int32)?;
1027        // C++ NDFileNexus.cpp:883 seeds NDFileNexusTemplateValid = 0.
1028        base.set_int32_param(valid_idx, 0, 0)?;
1029        self.template_path_idx = Some(path_idx);
1030        self.template_file_idx = Some(file_idx);
1031        self.template_valid_idx = Some(valid_idx);
1032        Ok(())
1033    }
1034
1035    fn on_param_change(
1036        &mut self,
1037        reason: usize,
1038        params: &PluginParamSnapshot,
1039    ) -> ParamChangeResult {
1040        use ad_core_rs::plugin::runtime::ParamChangeValue;
1041
1042        // NeXus XML template path / file changes trigger a template reload
1043        // (C++ NDFileNexus::writeInt32 / writeOctet → loadTemplateFile).
1044        if Some(reason) == self.template_path_idx {
1045            if let ParamChangeValue::Octet(s) = &params.value {
1046                self.template_path = s.clone();
1047            }
1048            let valid = self.reload_template();
1049            return self.template_valid_result(valid);
1050        }
1051        if Some(reason) == self.template_file_idx {
1052            if let ParamChangeValue::Octet(s) = &params.value {
1053                self.template_file = s.clone();
1054            }
1055            let valid = self.reload_template();
1056            return self.template_valid_result(valid);
1057        }
1058        self.ctrl.on_param_change(reason, params)
1059    }
1060}
1061
1062impl NexusFileProcessor {
1063    /// Build a ParamChangeResult that updates `NEXUS_TEMPLATE_VALID`.
1064    fn template_valid_result(&self, valid: i32) -> ParamChangeResult {
1065        use ad_core_rs::plugin::runtime::ParamUpdate;
1066        match self.template_valid_idx {
1067            Some(idx) => ParamChangeResult::updates(vec![ParamUpdate::Int32 {
1068                reason: idx,
1069                addr: 0,
1070                value: valid,
1071            }]),
1072            None => ParamChangeResult::empty(),
1073        }
1074    }
1075}
1076
1077#[cfg(test)]
1078mod tests {
1079    use super::*;
1080
1081    fn temp_path(prefix: &str) -> PathBuf {
1082        use std::sync::atomic::{AtomicU32, Ordering};
1083        static COUNTER: AtomicU32 = AtomicU32::new(0);
1084        let n = COUNTER.fetch_add(1, Ordering::Relaxed);
1085        std::env::temp_dir().join(format!("adcore_test_{}_{}.nxs", prefix, n))
1086    }
1087
1088    #[test]
1089    fn test_nexus_write_read() {
1090        let path = temp_path("nexus_basic");
1091        let mut writer = NexusWriter::new();
1092
1093        let mut arr = NDArray::new(
1094            vec![NDDimension::new(4), NDDimension::new(4)],
1095            NDDataType::UInt8,
1096        );
1097        if let NDDataBuffer::U8(ref mut v) = arr.data {
1098            for i in 0..16 {
1099                v[i] = i as u8;
1100            }
1101        }
1102
1103        writer.open_file(&path, NDFileMode::Single, &arr).unwrap();
1104        writer.write_file(&arr).unwrap();
1105        writer.close_file().unwrap();
1106
1107        // Verify NeXus structure
1108        let h5file = H5File::open(&path).unwrap();
1109        let ds = h5file.dataset("entry/instrument/detector/data").unwrap();
1110        let data: Vec<u8> = ds.read_raw().unwrap();
1111        assert_eq!(data.len(), 16);
1112        assert_eq!(data[0], 0);
1113        assert_eq!(data[15], 15);
1114
1115        std::fs::remove_file(&path).ok();
1116    }
1117
1118    #[test]
1119    fn test_nexus_multiple_frames() {
1120        let path = temp_path("nexus_multi");
1121        let mut writer = NexusWriter::new();
1122
1123        let mut arr1 = NDArray::new(
1124            vec![NDDimension::new(4), NDDimension::new(4)],
1125            NDDataType::UInt8,
1126        );
1127        if let NDDataBuffer::U8(ref mut v) = arr1.data {
1128            for i in 0..16 {
1129                v[i] = i as u8;
1130            }
1131        }
1132
1133        let mut arr2 = NDArray::new(
1134            vec![NDDimension::new(4), NDDimension::new(4)],
1135            NDDataType::UInt8,
1136        );
1137        if let NDDataBuffer::U8(ref mut v) = arr2.data {
1138            for i in 0..16 {
1139                v[i] = (i + 100) as u8;
1140            }
1141        }
1142
1143        writer.open_file(&path, NDFileMode::Stream, &arr1).unwrap();
1144        writer.write_file(&arr1).unwrap();
1145        writer.write_file(&arr2).unwrap();
1146        writer.close_file().unwrap();
1147
1148        assert_eq!(writer.frame_count(), 2);
1149
1150        // Verify single dataset with leading frame dimension [2, 4, 4]
1151        let h5file = H5File::open(&path).unwrap();
1152        let ds = h5file.dataset("entry/instrument/detector/data").unwrap();
1153        let shape = ds.shape();
1154        assert_eq!(shape, vec![2, 4, 4]);
1155
1156        let data: Vec<u8> = ds.read_raw().unwrap();
1157        assert_eq!(data.len(), 32);
1158        // First frame
1159        assert_eq!(data[0], 0);
1160        assert_eq!(data[15], 15);
1161        // Second frame
1162        assert_eq!(data[16], 100);
1163        assert_eq!(data[31], 115);
1164
1165        std::fs::remove_file(&path).ok();
1166    }
1167
1168    #[test]
1169    fn test_per_frame_metadata_are_datasets_not_attributes() {
1170        let path = temp_path("nexus_meta");
1171        let mut writer = NexusWriter::new();
1172
1173        let mut a1 = NDArray::new(
1174            vec![NDDimension::new(2), NDDimension::new(2)],
1175            NDDataType::UInt8,
1176        );
1177        a1.unique_id = 10;
1178        a1.time_stamp = 1.5;
1179        let mut a2 = NDArray::new(
1180            vec![NDDimension::new(2), NDDimension::new(2)],
1181            NDDataType::UInt8,
1182        );
1183        a2.unique_id = 11;
1184        a2.time_stamp = 2.5;
1185
1186        writer.open_file(&path, NDFileMode::Stream, &a1).unwrap();
1187        writer.write_file(&a1).unwrap();
1188        writer.write_file(&a2).unwrap();
1189        writer.close_file().unwrap();
1190
1191        let h5file = H5File::open(&path).unwrap();
1192        // uniqueId / timeStamp are proper datasets with a leading frame dim,
1193        // not N numbered attributes on the data dataset.
1194        let uid = h5file
1195            .dataset("entry/instrument/detector/uniqueId")
1196            .unwrap();
1197        assert_eq!(uid.shape(), vec![2]);
1198        let uid_data: Vec<i32> = uid.read_raw().unwrap();
1199        assert_eq!(uid_data, vec![10, 11]);
1200
1201        let ts = h5file
1202            .dataset("entry/instrument/detector/timeStamp")
1203            .unwrap();
1204        assert_eq!(ts.shape(), vec![2]);
1205        let ts_data: Vec<f64> = ts.read_raw().unwrap();
1206        assert_eq!(ts_data, vec![1.5, 2.5]);
1207
1208        std::fs::remove_file(&path).ok();
1209    }
1210
1211    #[test]
1212    fn test_xml_template_parser() {
1213        let xml = r#"<?xml version="1.0"?>
1214            <NXroot>
1215              <NXentry name="entry">
1216                <NXdata name="data">
1217                  <data type="pArray"/>
1218                </NXdata>
1219                <title>My Experiment</title>
1220              </NXentry>
1221            </NXroot>"#;
1222        let root = parse_nexus_template(xml).unwrap();
1223        assert_eq!(root.name, "NXroot");
1224        assert_eq!(root.children.len(), 1);
1225        let entry = &root.children[0];
1226        assert_eq!(entry.name, "NXentry");
1227        assert_eq!(entry.attr("name"), Some("entry"));
1228        assert_eq!(entry.children.len(), 2);
1229        assert_eq!(entry.children[1].name, "title");
1230        assert_eq!(entry.children[1].text, "My Experiment");
1231        let data = &entry.children[0].children[0];
1232        assert_eq!(data.attr("type"), Some("pArray"));
1233    }
1234
1235    #[test]
1236    fn test_template_drives_file_structure() {
1237        // Template places the array dataset at a non-default path.
1238        let xml = r#"<NXroot>
1239              <NXentry name="scan">
1240                <NXdata name="measurement">
1241                  <frames type="pArray"/>
1242                  <title>const-title</title>
1243                </NXdata>
1244              </NXentry>
1245            </NXroot>"#;
1246        let mut writer = NexusWriter::new();
1247        assert!(writer.load_template(xml));
1248        assert!(writer.has_template());
1249
1250        let path = temp_path("nexus_tmpl");
1251        let mut arr = NDArray::new(
1252            vec![NDDimension::new(3), NDDimension::new(2)],
1253            NDDataType::UInt8,
1254        );
1255        if let NDDataBuffer::U8(ref mut v) = arr.data {
1256            for (i, x) in v.iter_mut().enumerate() {
1257                *x = i as u8;
1258            }
1259        }
1260        writer.open_file(&path, NDFileMode::Single, &arr).unwrap();
1261        writer.write_file(&arr).unwrap();
1262        writer.close_file().unwrap();
1263
1264        let h5file = H5File::open(&path).unwrap();
1265        // Array dataset lives at the template-specified path.
1266        let ds = h5file.dataset("scan/measurement/frames").unwrap();
1267        assert_eq!(ds.shape(), vec![1, 2, 3]);
1268        // Constant title dataset created from the template node text.
1269        let title = h5file.dataset("scan/measurement/title").unwrap();
1270        let title_bytes: Vec<u8> = title.read_raw().unwrap();
1271        assert_eq!(
1272            String::from_utf8_lossy(&title_bytes).trim_end_matches('\0'),
1273            "const-title"
1274        );
1275
1276        std::fs::remove_file(&path).ok();
1277    }
1278
1279    #[test]
1280    fn test_load_template_invalid_xml_returns_false() {
1281        let mut writer = NexusWriter::new();
1282        assert!(!writer.load_template("<NXroot><unclosed>"));
1283        assert!(!writer.has_template());
1284    }
1285
1286    #[test]
1287    fn test_template_param_change_sets_valid_flag() {
1288        use ad_core_rs::plugin::runtime::{ParamChangeValue, ParamUpdate, PluginParamSnapshot};
1289        use asyn_rs::port::{PortDriverBase, PortFlags};
1290
1291        let dir = std::env::temp_dir();
1292        let tmpl_path = dir.join("adcore_nexus_template_test.xml");
1293        std::fs::write(
1294            &tmpl_path,
1295            r#"<NXroot><NXentry name="e"><NXdata name="d"><x type="pArray"/></NXdata></NXentry></NXroot>"#,
1296        )
1297        .unwrap();
1298
1299        let mut base = PortDriverBase::new("nexus_tmpl_test", 1, PortFlags::default());
1300        let mut proc = NexusFileProcessor::new();
1301        proc.register_params(&mut base).unwrap();
1302
1303        let file_idx = proc.template_file_idx.unwrap();
1304        let valid_idx = proc.template_valid_idx.unwrap();
1305
1306        let result = proc.on_param_change(
1307            file_idx,
1308            &PluginParamSnapshot {
1309                enable_callbacks: true,
1310                reason: file_idx,
1311                addr: 0,
1312                value: ParamChangeValue::Octet(tmpl_path.to_string_lossy().into_owned()),
1313            },
1314        );
1315        assert!(result.param_updates.iter().any(|u| matches!(
1316            u,
1317            ParamUpdate::Int32 { reason, value: 1, .. } if *reason == valid_idx
1318        )));
1319        assert!(proc.ctrl.writer.has_template());
1320
1321        std::fs::remove_file(&tmpl_path).ok();
1322    }
1323
1324    #[test]
1325    fn test_nxdata_group_contains_image_data() {
1326        // N2: the built-in NXdata group `/entry/data` must actually contain
1327        // the image dataset, not be an empty group.
1328        let path = temp_path("nexus_nxdata");
1329        let mut writer = NexusWriter::new();
1330
1331        let mk = |base: u8| {
1332            let mut arr = NDArray::new(
1333                vec![NDDimension::new(3), NDDimension::new(2)],
1334                NDDataType::UInt8,
1335            );
1336            if let NDDataBuffer::U8(ref mut v) = arr.data {
1337                for (i, x) in v.iter_mut().enumerate() {
1338                    *x = base + i as u8;
1339                }
1340            }
1341            arr
1342        };
1343
1344        let a0 = mk(0);
1345        writer.open_file(&path, NDFileMode::Stream, &a0).unwrap();
1346        writer.write_file(&a0).unwrap();
1347        writer.write_file(&mk(100)).unwrap();
1348        writer.close_file().unwrap();
1349
1350        let h5 = H5File::open(&path).unwrap();
1351        // The NXdata group dataset exists and carries the data.
1352        let nx = h5
1353            .dataset("entry/data/data")
1354            .expect("NXdata group must contain the `data` dataset");
1355        assert_eq!(nx.shape(), vec![2, 2, 3]);
1356        let nx_vals: Vec<u8> = nx.read_raw().unwrap();
1357        assert_eq!(nx_vals.len(), 2 * 6);
1358        assert_eq!(nx_vals[0], 0);
1359        assert_eq!(nx_vals[6], 100);
1360        // It mirrors the detector dataset content.
1361        let det: Vec<u8> = h5
1362            .dataset("entry/instrument/detector/data")
1363            .unwrap()
1364            .read_raw()
1365            .unwrap();
1366        assert_eq!(nx_vals, det);
1367
1368        std::fs::remove_file(&path).ok();
1369    }
1370
1371    #[test]
1372    fn test_nx_class_is_true_group_attribute() {
1373        // NeXus requires NX_class to be an HDF5 group attribute, not a child
1374        // dataset. rust-hdf5 0.2.15 supports group attributes on every group.
1375        let path = temp_path("nexus_nxclass");
1376        let mut writer = NexusWriter::new();
1377        let mut arr = NDArray::new(
1378            vec![NDDimension::new(3), NDDimension::new(2)],
1379            NDDataType::UInt8,
1380        );
1381        if let NDDataBuffer::U8(ref mut v) = arr.data {
1382            v.iter_mut().enumerate().for_each(|(i, x)| *x = i as u8);
1383        }
1384        writer.open_file(&path, NDFileMode::Single, &arr).unwrap();
1385        writer.write_file(&arr).unwrap();
1386        writer.close_file().unwrap();
1387
1388        let h5 = H5File::open(&path).unwrap();
1389        // Non-root groups carry NX_class as a real group attribute.
1390        for (group, class) in [
1391            ("entry", "NXentry"),
1392            ("entry/instrument", "NXinstrument"),
1393            ("entry/instrument/detector", "NXdetector"),
1394            ("entry/data", "NXdata"),
1395        ] {
1396            let g = h5.root_group().group(group).expect("group exists");
1397            let got = g
1398                .attr_string("NX_class")
1399                .unwrap_or_else(|_| panic!("{} must have NX_class group attribute", group));
1400            assert_eq!(got, class, "{} NX_class", group);
1401        }
1402        // No child `NX_class` dataset (the old workaround) anywhere.
1403        assert!(h5.dataset("entry/NX_class").is_err());
1404
1405        std::fs::remove_file(&path).ok();
1406    }
1407
1408    #[test]
1409    fn test_read_file_roundtrips_all_data_types() {
1410        // N4: every NDArray data type must round-trip through read_file,
1411        // not just u8/u16/f64.
1412        use ad_core_rs::ndarray::NDDataType::*;
1413        for dt in [
1414            Int8, UInt8, Int16, UInt16, Int32, UInt32, Int64, UInt64, Float32, Float64,
1415        ] {
1416            let path = temp_path(&format!("nexus_type_{:?}", dt));
1417            let mut writer = NexusWriter::new();
1418            let mut arr = NDArray::new(vec![NDDimension::new(2), NDDimension::new(2)], dt);
1419            // Distinct, type-revealing values: negatives for signed types.
1420            match arr.data {
1421                NDDataBuffer::I8(ref mut v) => v.copy_from_slice(&[-1, 2, -3, 4]),
1422                NDDataBuffer::U8(ref mut v) => v.copy_from_slice(&[1, 2, 3, 4]),
1423                NDDataBuffer::I16(ref mut v) => v.copy_from_slice(&[-1, 2, -3, 4]),
1424                NDDataBuffer::U16(ref mut v) => v.copy_from_slice(&[1, 2, 3, 4]),
1425                NDDataBuffer::I32(ref mut v) => v.copy_from_slice(&[-1, 2, -3, 4]),
1426                NDDataBuffer::U32(ref mut v) => v.copy_from_slice(&[1, 2, 3, 4]),
1427                NDDataBuffer::I64(ref mut v) => v.copy_from_slice(&[-1, 2, -3, 4]),
1428                NDDataBuffer::U64(ref mut v) => v.copy_from_slice(&[1, 2, 3, 4]),
1429                NDDataBuffer::F32(ref mut v) => v.copy_from_slice(&[-1.5, 2.5, -3.5, 4.5]),
1430                NDDataBuffer::F64(ref mut v) => v.copy_from_slice(&[-1.5, 2.5, -3.5, 4.5]),
1431            }
1432
1433            writer.open_file(&path, NDFileMode::Single, &arr).unwrap();
1434            writer.write_file(&arr).unwrap();
1435            writer.close_file().unwrap();
1436
1437            let mut reader = NexusWriter::new();
1438            reader.current_path = Some(path.clone());
1439            let read = reader
1440                .read_file()
1441                .unwrap_or_else(|e| panic!("{:?} read failed: {}", dt, e));
1442            assert_eq!(read.data.data_type(), dt, "{:?}: type must round-trip", dt);
1443            // Leading frame dim [1] + 2x2 => 3 dims, 4 elements.
1444            assert_eq!(read.data.len(), 4, "{:?}: element count", dt);
1445            for i in 0..4 {
1446                assert_eq!(
1447                    arr.data.get_as_f64(i),
1448                    read.data.get_as_f64(i),
1449                    "{:?}: element {} must round-trip",
1450                    dt,
1451                    i
1452                );
1453            }
1454
1455            std::fs::remove_file(&path).ok();
1456        }
1457    }
1458}