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