Skip to main content

ad_core_rs/plugin/
file_base.rs

1//! File-writing plugin base: file-name construction, the capture buffer, and
2//! the flush that drains it.
3//!
4//! Two deliberate deviations from C++ `NDPluginFile` / `asynNDArrayDriver`
5//! live here, and they are one decision taken twice.
6//!
7//! C frees the capture buffer unconditionally once a flush ends, at
8//! `NDPluginFile.cpp:325` (`freeCaptureBuffer()`), so a flush that fails on
9//! one frame discards every frame including the ones never written, and a
10//! second `WriteFile` finds an empty buffer and reports "no capture buffer
11//! present". [`NDPluginFileBase::flush_capture`] instead keeps exactly the
12//! frames that have not reached disk, so the operator can free the disk and
13//! resume where the flush stopped.
14//!
15//! C burns the file number when it builds the name, before the write is
16//! attempted, and never rolls it back: `asynNDArrayDriver::createFileName`
17//! increments `NDFileNumber` at `asynNDArrayDriver.cpp:216-219`, and again in
18//! the by-parts variant at `asynNDArrayDriver.cpp:256-259`, while
19//! `openFileBase` calls it at `NDPluginFile.cpp:43` ahead of `openFile` — once
20//! per frame in the single-image capture loop (`NDPluginFile.cpp:288-292`).
21//! So under C the number advances once per ATTEMPT. Here it advances once per
22//! completed file: the increment sits after the write and the rename in both
23//! branches of `flush_capture`.
24//!
25//! Increment-per-attempt is coherent in C only because C never retries. Laid
26//! over a resumable buffer it would put the partial file of a failed frame at
27//! N and its retry at N+1, so a three-frame capture that failed once would
28//! leave four files, two of them the same frame, with a hole in the numbering
29//! — the shape the resumable drain exists to make unconstructible.
30//! Increment-per-success keeps one frame to one file with consecutive
31//! numbers whether or not the flush was retried, at the cost that
32//! `NDFileNumber_RBV` advances more slowly than C's after a failure and that
33//! the retry reuses the number the failed attempt had already opened a file
34//! at. What sits at that number is not a partial image: every writer creates
35//! or truncates the file at open, before any image data reaches it —
36//! `TIFFOpen(fileName, "w")` (`NDFileTIFF.cpp:120`), `fopen(fileName, "wb")`
37//! followed straight away by `jpeg_start_compress` (`NDFileJPEG.cpp:87`,
38//! `:103`), `H5Fcreate(fileName, H5F_ACC_TRUNC, ..)`
39//! (`NDFileHDF5.cpp:3833`) — so the leftover is empty, header-only or an
40//! empty container according to which writer failed, and the retry's own open
41//! truncates it again. Tier 3, correctness over byte parity.
42//!
43//! C++ line numbers here resolve against ADCore `R3-14-111-g6c53844e`, the
44//! revision this port was written from. `NDPluginFile.cpp`, `NDFileTIFF.cpp`
45//! and `NDFileJPEG.cpp` are byte-identical between it and the checkout at
46//! `R3-14-173-g926bb4c8`; `NDFileHDF5.cpp` is not, and `:3833` above was read
47//! at the pin.
48
49use std::path::{Path, PathBuf};
50use std::sync::Arc;
51
52use crate::attributes::{NDAttribute, NDAttributeList};
53use crate::error::{ADError, ADResult};
54use crate::finalize::Finalize;
55use crate::ndarray::NDArray;
56
57/// File write modes matching C++ NDFileMode_t.
58#[derive(Debug, Clone, Copy, PartialEq, Eq)]
59pub enum NDFileMode {
60    Single = 0,
61    Capture = 1,
62    Stream = 2,
63}
64
65impl NDFileMode {
66    pub fn from_i32(v: i32) -> Self {
67        match v {
68            0 => Self::Single,
69            1 => Self::Capture,
70            _ => Self::Stream,
71        }
72    }
73}
74
75/// Trait for file format writers.
76pub trait NDFileWriter: Send + Sync {
77    fn open_file(&mut self, path: &Path, mode: NDFileMode, array: &NDArray) -> ADResult<()>;
78    fn write_file(&mut self, array: &NDArray) -> ADResult<()>;
79    fn read_file(&mut self) -> ADResult<NDArray>;
80    fn close_file(&mut self) -> ADResult<()>;
81    fn supports_multiple_arrays(&self) -> bool {
82        true
83    }
84    /// Inform the writer of the configured capture count (`NDFileNumCapture`)
85    /// before the next `open_file`. A writer whose on-disk layout depends on
86    /// the capture target (e.g. HDF5 chunk sizing, C `calculateAttributeChunking`)
87    /// uses it; the default ignores it.
88    fn set_num_capture(&mut self, _n: usize) {}
89    /// Whether [`write_file`](NDFileWriter::write_file) has put the frame on
90    /// disk by the time it returns.
91    ///
92    /// A writer that answers `false` only materialises the file in
93    /// `close_file`; see [`NDPluginFileBase::open_stream`] for what that rules
94    /// out. The default is `true`.
95    fn writes_incrementally(&self) -> bool {
96        true
97    }
98}
99
100/// A multi-frame writer's own NDAttribute list — C's `pFileAttributes`
101/// (`NDFileHDF5.h`, `NDFileNetCDF.h`, `NDFileNexus.h`), which is deliberately
102/// separate from the list on any one frame.
103///
104/// It is **sticky for the life of the file**. `NDAttributeList::copy`
105/// (`NDAttributeList.cpp:185-205`) walks the input list and either overwrites
106/// the same-named output entry or appends a new one — it never removes — and
107/// the writers clear it in `openFile` alone (`NDFileHDF5.cpp:243`,
108/// `NDFileNetCDF.cpp:72`) and merge each subsequent frame into it
109/// (`NDFileHDF5.cpp:1437`, `NDFileNetCDF.cpp:362`). So an attribute that
110/// appears in one frame and is absent from the next keeps its previous value
111/// for the rest of the file, and that value is what the later frame's record
112/// carries. Reading the frame's own list instead makes a drop-out look like a
113/// missing value, which each writer then has to invent a rule for — a zero, or
114/// the first frame's value.
115///
116/// Only [`open`](FileAttributes::open) may reset the contents, so the mid-file
117/// clear that would reintroduce that miss cannot be written; and because the
118/// list only ever grows, the order of the names is a stable prefix of every
119/// later state of it.
120#[derive(Debug, Clone, Default)]
121pub struct FileAttributes {
122    list: NDAttributeList,
123}
124
125impl FileAttributes {
126    /// Start a new file from `array`'s attributes (C `openFile`: `clear()`
127    /// then `copy`).
128    pub fn open(&mut self, array: &NDArray) {
129        self.list.clear();
130        self.list.copy_from(&array.attributes);
131    }
132
133    /// Merge one frame's attributes in (C `writeFile`: `copy` with no clear).
134    pub fn frame(&mut self, array: &NDArray) {
135        self.list.copy_from(&array.attributes);
136    }
137
138    /// The current value for `name`, which is the most recent frame that
139    /// carried it, not necessarily the frame being written.
140    pub fn get(&self, name: &str) -> Option<&NDAttribute> {
141        self.list.get(name)
142    }
143
144    /// Every attribute seen since [`open`](FileAttributes::open), in the order
145    /// the names first appeared.
146    pub fn iter(&self) -> impl Iterator<Item = &NDAttribute> {
147        self.list.iter()
148    }
149
150    pub fn len(&self) -> usize {
151        self.list.len()
152    }
153
154    pub fn is_empty(&self) -> bool {
155        self.list.is_empty()
156    }
157}
158
159/// Open `path`, run `body` against the open writer, and close — exactly once,
160/// on every exit path out of `body`.
161///
162/// The close is the finalizer, so a failing `write_file` or `read_file` cannot
163/// leave the writer holding an open file (for HDF5 an H5 file id, plus a stale
164/// `current_path`). Every [`NDFileWriter::close_file`] is a no-op when nothing
165/// is open, so an `open_file` that itself fails is safe to close as well, and
166/// the guard can cover the open too rather than starting just after it.
167///
168/// The close error is reported only when `body` succeeded: when the body
169/// failed, its error is the one worth returning.
170pub(crate) fn with_open_file<W: NDFileWriter + ?Sized, R>(
171    writer: &mut W,
172    path: &Path,
173    mode: NDFileMode,
174    layout: &NDArray,
175    body: impl FnOnce(&mut W) -> ADResult<R>,
176) -> ADResult<R> {
177    let mut open = Finalize::new(writer, |w: &mut W| {
178        let _ = w.close_file();
179    });
180    let value = open.run(|w| {
181        w.open_file(path, mode, layout)?;
182        body(w)
183    })?;
184    let closed = open.run(|w| w.close_file());
185    open.disarm();
186    closed.map(|()| value)
187}
188
189/// File path/name management and capture buffering for file plugins.
190pub struct NDPluginFileBase {
191    pub file_path: String,
192    pub file_name: String,
193    pub file_number: i32,
194    pub file_template: String,
195    pub auto_increment: bool,
196    pub temp_suffix: String,
197    pub create_dir: i32,
198    pub lazy_open: bool,
199    pub delete_driver_file: bool,
200    capture_buffer: Vec<Arc<NDArray>>,
201    num_capture: usize,
202    num_captured: usize,
203    is_open: bool,
204    mode: NDFileMode,
205    last_written_name: String,
206}
207
208impl NDPluginFileBase {
209    pub fn new() -> Self {
210        Self {
211            file_path: String::new(),
212            file_name: String::new(),
213            file_number: 0,
214            file_template: String::new(),
215            auto_increment: false,
216            temp_suffix: String::new(),
217            create_dir: 0,
218            lazy_open: false,
219            delete_driver_file: false,
220            capture_buffer: Vec::new(),
221            num_capture: 1,
222            num_captured: 0,
223            is_open: false,
224            mode: NDFileMode::Single,
225            last_written_name: String::new(),
226        }
227    }
228
229    /// Construct the full file path from template/path/name/number.
230    ///
231    /// Mimics C `epicsSnprintf(buf, ..., template, filePath, fileName, fileNumber)`.
232    /// Template uses printf-style: first `%s` → filePath, second `%s` → fileName,
233    /// `%d` (with optional width/precision like `%3.3d`) → fileNumber.
234    pub fn create_file_name(&self) -> String {
235        if self.file_template.is_empty() {
236            format!(
237                "{}{}{:04}",
238                self.file_path, self.file_name, self.file_number
239            )
240        } else {
241            let mut result = String::new();
242            let mut chars = self.file_template.chars().peekable();
243            let mut s_count = 0;
244            while let Some(c) = chars.next() {
245                if c == '%' {
246                    // Collect printf flags and the width/precision spec:
247                    // optional `-` (left-justify) / `0` (zero-pad) flags,
248                    // digits (width), `.digits` (precision). C++ uses real
249                    // epicsSnprintf.
250                    let mut left_justify = false;
251                    let mut zero_pad = false;
252                    loop {
253                        match chars.peek() {
254                            Some('-') => {
255                                left_justify = true;
256                                chars.next();
257                            }
258                            Some('0') => {
259                                zero_pad = true;
260                                chars.next();
261                            }
262                            _ => break,
263                        }
264                    }
265                    let mut spec = String::new();
266                    while let Some(&nc) = chars.peek() {
267                        if nc.is_ascii_digit() || nc == '.' {
268                            spec.push(nc);
269                            chars.next();
270                        } else {
271                            break;
272                        }
273                    }
274                    match chars.next() {
275                        // `%%` → literal percent.
276                        Some('%') if spec.is_empty() && !left_justify => result.push('%'),
277                        Some('s') => {
278                            s_count += 1;
279                            match s_count {
280                                1 => result.push_str(&self.file_path),
281                                2 => result.push_str(&self.file_name),
282                                _ => {}
283                            }
284                        }
285                        Some('d') => {
286                            // `width.precision`: precision = minimum digits
287                            // (zero-pad), width = minimum field width
288                            // (space-pad unless precision provided).
289                            let (width, precision) = match spec.split_once('.') {
290                                Some((w, p)) => (
291                                    w.parse::<usize>().unwrap_or(0),
292                                    p.parse::<usize>().unwrap_or(0),
293                                ),
294                                None => (spec.parse::<usize>().unwrap_or(0), 0),
295                            };
296                            // Apply precision first (zero-pad the number).
297                            let digits = format!("{:0>prec$}", self.file_number, prec = precision);
298                            // Then pad to the field width. The `0` flag
299                            // zero-pads (ignored when left-justified or when an
300                            // explicit precision is given, per C printf).
301                            if digits.len() >= width {
302                                result.push_str(&digits);
303                            } else if zero_pad && !left_justify && precision == 0 {
304                                result.push_str(&format!(
305                                    "{:0>width$}",
306                                    self.file_number,
307                                    width = width
308                                ));
309                            } else {
310                                let pad = " ".repeat(width - digits.len());
311                                if left_justify {
312                                    result.push_str(&digits);
313                                    result.push_str(&pad);
314                                } else {
315                                    result.push_str(&pad);
316                                    result.push_str(&digits);
317                                }
318                            }
319                        }
320                        Some(other) => {
321                            result.push('%');
322                            if left_justify {
323                                result.push('-');
324                            }
325                            if zero_pad {
326                                result.push('0');
327                            }
328                            result.push_str(&spec);
329                            result.push(other);
330                        }
331                        None => result.push('%'),
332                    }
333                } else {
334                    result.push(c);
335                }
336            }
337            result
338        }
339    }
340
341    /// Get the temp file path (if temp_suffix is set).
342    pub fn temp_file_path(&self) -> Option<PathBuf> {
343        if self.temp_suffix.is_empty() {
344            None
345        } else {
346            let name = self.create_file_name();
347            Some(PathBuf::from(format!("{}{}", name, self.temp_suffix)))
348        }
349    }
350
351    /// Return the full file name that was last written.
352    pub fn last_written_name(&self) -> &str {
353        &self.last_written_name
354    }
355
356    /// Create directory if needed.
357    /// C ADCore behavior: createDir != 0 → create directories.
358    /// Positive or negative values both trigger creation (negative = depth hint in C,
359    /// but in practice create_dir_all handles any depth).
360    pub fn ensure_directory(&self) -> ADResult<()> {
361        if self.create_dir != 0 && !self.file_path.is_empty() {
362            std::fs::create_dir_all(&self.file_path)?;
363        }
364        Ok(())
365    }
366
367    /// Write to temp path if temp_suffix is set, then rename to final path.
368    fn write_path(&self) -> (PathBuf, Option<PathBuf>) {
369        let final_path = PathBuf::from(self.create_file_name());
370        if self.temp_suffix.is_empty() {
371            (final_path, None)
372        } else {
373            let temp = PathBuf::from(format!("{}{}", final_path.display(), self.temp_suffix));
374            (temp, Some(final_path))
375        }
376    }
377
378    /// Rename temp file to final path if applicable.
379    fn rename_temp(temp_path: &Path, final_path: &Path) -> ADResult<()> {
380        std::fs::rename(temp_path, final_path)?;
381        Ok(())
382    }
383
384    /// Delete the driver's original file for `array` when `delete_driver_file`
385    /// is set. C++ NDPluginFile deletes the driver file in every write mode
386    /// (Single, Stream, and Capture), keyed off the `DriverFileName` attribute.
387    fn maybe_delete_driver_file(&self, array: &NDArray) {
388        if !self.delete_driver_file {
389            return;
390        }
391        // C reads DriverFileName via `getValue(NDAttrString, …)` and only
392        // deletes on `asynSuccess`; a numeric attribute returns `ND_ERROR`, so
393        // no file is removed (the decimal rendering is never used as a path).
394        if let Some(driver_file) = array
395            .attributes
396            .get("DriverFileName")
397            .and_then(|attr| attr.value.as_string_typed())
398        {
399            if !driver_file.is_empty() {
400                let _ = std::fs::remove_file(driver_file);
401            }
402        }
403    }
404
405    /// Process an incoming array according to the current file mode.
406    pub fn process_array(
407        &mut self,
408        array: Arc<NDArray>,
409        writer: &mut dyn NDFileWriter,
410    ) -> ADResult<()> {
411        // The writer's open-time layout (e.g. HDF5 attribute/performance chunk
412        // sizing) depends on the capture target; keep it current before any open.
413        writer.set_num_capture(self.num_capture);
414        match self.mode {
415            NDFileMode::Single => {
416                self.last_written_name = self.create_file_name();
417                let (write_path, final_path) = self.write_path();
418                with_open_file(writer, &write_path, NDFileMode::Single, &array, |w| {
419                    w.write_file(&array)
420                })?;
421                if let Some(final_path) = final_path {
422                    Self::rename_temp(&write_path, &final_path)?;
423                }
424                self.maybe_delete_driver_file(&array);
425                if self.auto_increment {
426                    self.file_number += 1;
427                }
428            }
429            NDFileMode::Capture => {
430                self.capture_buffer.push(array);
431                self.num_captured = self.capture_buffer.len();
432                // B7: num_capture==0 → buffer forever, never auto-flush.
433                if self.num_capture > 0 && self.num_captured >= self.num_capture {
434                    self.flush_capture(writer)?;
435                }
436            }
437            NDFileMode::Stream => {
438                self.open_stream(writer, &array)?;
439                writer.write_file(&array)?;
440                self.maybe_delete_driver_file(&array);
441                self.num_captured += 1;
442            }
443        }
444        Ok(())
445    }
446
447    /// Flush capture buffer: open file, write all buffered arrays, close.
448    ///
449    /// For writers that support multiple arrays (HDF5, NeXus), we open once,
450    /// write all frames, and close once.
451    /// For single-image writers (JPEG, TIFF), we open/write/close for each
452    /// frame individually, auto-incrementing the filename between each.
453    ///
454    /// `capture_buffer` means one thing throughout: the frames that have not
455    /// reached disk. A frame leaves it only once its own file is complete, so
456    /// a flush that fails part way keeps exactly the frames still owed and a
457    /// second `WriteFile` resumes at the first of them instead of starting
458    /// over on files that are already written under numbers `file_number` has
459    /// moved past.
460    pub fn flush_capture(&mut self, writer: &mut dyn NDFileWriter) -> ADResult<()> {
461        if self.capture_buffer.is_empty() {
462            return Ok(());
463        }
464        writer.set_num_capture(self.num_capture);
465
466        if writer.supports_multiple_arrays() {
467            // Multi-array format: open once, write all, close once. The frames
468            // share one file, so they land together or not at all.
469            self.last_written_name = self.create_file_name();
470            let (write_path, final_path) = self.write_path();
471            let buffer = &self.capture_buffer;
472            with_open_file(writer, &write_path, NDFileMode::Capture, &buffer[0], |w| {
473                for arr in buffer {
474                    w.write_file(arr)?;
475                }
476                Ok(())
477            })?;
478            if let Some(final_path) = final_path {
479                Self::rename_temp(&write_path, &final_path)?;
480            }
481            let landed = self.take_landed(self.capture_buffer.len());
482            // C++ deletes the driver file per frame in Capture mode too.
483            for arr in &landed {
484                self.maybe_delete_driver_file(arr);
485            }
486            if self.auto_increment {
487                self.file_number += 1;
488            }
489        } else {
490            // Single-image format: open/write/close per frame with
491            // auto-increment. The head of the buffer is the resume point: it
492            // is dropped once its file is complete, so the `?` below leaves
493            // the failing frame and everything behind it queued.
494            while !self.capture_buffer.is_empty() {
495                let arr = Arc::clone(&self.capture_buffer[0]);
496                self.last_written_name = self.create_file_name();
497                let (write_path, final_path) = self.write_path();
498                with_open_file(writer, &write_path, NDFileMode::Single, &arr, |w| {
499                    w.write_file(&arr)
500                })?;
501                if let Some(final_path) = final_path {
502                    Self::rename_temp(&write_path, &final_path)?;
503                }
504                self.take_landed(1);
505                self.maybe_delete_driver_file(&arr);
506                if self.auto_increment {
507                    self.file_number += 1;
508                }
509            }
510        }
511
512        Ok(())
513    }
514
515    /// Drop the first `n` frames from the capture buffer — the only place a
516    /// frame leaves it, and the only place `num_captured` is set while frames
517    /// are queued, so the readback cannot claim frames the buffer no longer
518    /// holds or hide ones it still does.
519    fn take_landed(&mut self, n: usize) -> Vec<Arc<NDArray>> {
520        let landed: Vec<Arc<NDArray>> = self.capture_buffer.drain(..n).collect();
521        self.num_captured = self.capture_buffer.len();
522        landed
523    }
524
525    /// Open the Stream-mode file — the single owner of a stream open.
526    ///
527    /// The eager open at capture start (C++ `doCapture` opens the file then so
528    /// a bad path is reported at capture-start rather than on the first frame,
529    /// NDPluginFile.cpp:478-479, B9) and the lazy open on the first frame both
530    /// come through here, so Stream mode's precondition is stated once: every
531    /// frame the mode accepts is on disk when `process_array` returns, which is
532    /// what lets the controller publish `NDFileNumCaptured` per frame. A writer
533    /// that holds frames until `close_file` cannot keep that promise, and an
534    /// unbounded stream (`NumCapture=0`) never reaches a close, so the open is
535    /// refused here instead of growing in RAM behind a readback that reports
536    /// those frames as captured.
537    ///
538    /// `array` supplies the layout the writer needs at open time. A no-op when
539    /// a file is already open.
540    pub fn open_stream(&mut self, writer: &mut dyn NDFileWriter, array: &NDArray) -> ADResult<()> {
541        if self.is_open {
542            return Ok(());
543        }
544        if !writer.writes_incrementally() {
545            return Err(ADError::UnsupportedConversion(
546                "this file format writes the whole file when it is closed and \
547                 cannot stream; use FileWriteMode=Capture"
548                    .into(),
549            ));
550        }
551        writer.set_num_capture(self.num_capture);
552        self.last_written_name = self.create_file_name();
553        let (write_path, _) = self.write_path();
554        writer.open_file(&write_path, NDFileMode::Stream, array)?;
555        self.is_open = true;
556        Ok(())
557    }
558
559    /// Force a file close (used by the FilePluginClose attribute, G9).
560    /// Safe to call when no file is open.
561    pub fn force_close(&mut self, writer: &mut dyn NDFileWriter) -> ADResult<()> {
562        if self.is_open {
563            self.close_stream(writer)?;
564        }
565        Ok(())
566    }
567
568    /// Close stream mode.
569    ///
570    /// This transition owns `is_open`: from the moment the close begins, the
571    /// guard clears the marker on every exit path, so a failing `close_file` or
572    /// temp rename ends the open state and reports the error instead of
573    /// latching the plugin open against a file it can no longer write.
574    pub fn close_stream(&mut self, writer: &mut dyn NDFileWriter) -> ADResult<()> {
575        if !self.is_open {
576            return Ok(());
577        }
578        let mut closing = Finalize::new(self, |base: &mut Self| base.is_open = false);
579        closing.run(|base| {
580            writer.close_file()?;
581            // Rename temp to final if temp_suffix was set
582            if !base.temp_suffix.is_empty() {
583                let final_name = base.create_file_name();
584                let temp_name = format!("{}{}", final_name, base.temp_suffix);
585                Self::rename_temp(Path::new(&temp_name), Path::new(&final_name))?;
586            }
587            if base.auto_increment {
588                base.file_number += 1;
589            }
590            Ok(())
591        })
592    }
593
594    pub fn is_open(&self) -> bool {
595        self.is_open
596    }
597
598    pub fn set_mode(&mut self, mode: NDFileMode) {
599        self.mode = mode;
600    }
601
602    pub fn set_num_capture(&mut self, n: usize) {
603        self.num_capture = n;
604    }
605
606    pub fn num_captured(&self) -> usize {
607        self.num_captured
608    }
609
610    pub fn mode(&self) -> NDFileMode {
611        self.mode
612    }
613
614    pub fn num_capture_target(&self) -> usize {
615        self.num_capture
616    }
617
618    pub fn capture_array(&mut self, array: Arc<NDArray>) {
619        self.capture_buffer.push(array);
620        self.num_captured = self.capture_buffer.len();
621    }
622
623    pub fn clear_capture(&mut self) {
624        self.capture_buffer.clear();
625        self.num_captured = 0;
626    }
627}
628
629#[cfg(test)]
630mod tests {
631    use super::*;
632    use crate::ndarray::{NDDataType, NDDimension};
633
634    /// Test file writer that records operations.
635    struct MockWriter {
636        opens: Vec<PathBuf>,
637        writes: usize,
638        closes: usize,
639        multi: bool,
640        /// Make `close_file` fail, standing in for the ENOSPC / read-only-mount
641        /// close that leaves a real writer unable to finalise.
642        fail_close: bool,
643        /// Make `write_file` fail, standing in for the ENOSPC write.
644        fail_write: bool,
645        /// Whether `write_file` is durable, as netCDF's is not.
646        incremental: bool,
647    }
648
649    impl MockWriter {
650        fn new(multi: bool) -> Self {
651            Self {
652                opens: Vec::new(),
653                writes: 0,
654                closes: 0,
655                multi,
656                fail_close: false,
657                fail_write: false,
658                incremental: true,
659            }
660        }
661    }
662
663    impl NDFileWriter for MockWriter {
664        fn open_file(&mut self, path: &Path, _mode: NDFileMode, _array: &NDArray) -> ADResult<()> {
665            self.opens.push(path.to_path_buf());
666            Ok(())
667        }
668        fn write_file(&mut self, _array: &NDArray) -> ADResult<()> {
669            if self.fail_write {
670                return Err(crate::error::ADError::UnsupportedConversion(
671                    "disk full".into(),
672                ));
673            }
674            self.writes += 1;
675            Ok(())
676        }
677        fn read_file(&mut self) -> ADResult<NDArray> {
678            Err(crate::error::ADError::UnsupportedConversion(
679                "not implemented".into(),
680            ))
681        }
682        fn close_file(&mut self) -> ADResult<()> {
683            self.closes += 1;
684            if self.fail_close {
685                return Err(crate::error::ADError::UnsupportedConversion(
686                    "disk full".into(),
687                ));
688            }
689            Ok(())
690        }
691        fn supports_multiple_arrays(&self) -> bool {
692            self.multi
693        }
694        fn writes_incrementally(&self) -> bool {
695            self.incremental
696        }
697    }
698
699    fn make_array(id: i32) -> Arc<NDArray> {
700        let mut arr = NDArray::new(vec![NDDimension::new(4)], NDDataType::UInt8);
701        arr.unique_id = id;
702        Arc::new(arr)
703    }
704
705    #[test]
706    fn test_single_mode() {
707        let mut fb = NDPluginFileBase::new();
708        fb.file_path = "/tmp/".into();
709        fb.file_name = "test_".into();
710        fb.file_number = 1;
711        fb.auto_increment = true;
712        fb.set_mode(NDFileMode::Single);
713
714        let mut writer = MockWriter::new(false);
715        fb.process_array(make_array(1), &mut writer).unwrap();
716
717        assert_eq!(writer.opens.len(), 1);
718        assert_eq!(writer.writes, 1);
719        assert_eq!(writer.closes, 1);
720        assert_eq!(fb.file_number, 2); // auto-incremented
721    }
722
723    #[test]
724    fn test_capture_mode() {
725        let mut fb = NDPluginFileBase::new();
726        fb.file_path = "/tmp/".into();
727        fb.file_name = "cap_".into();
728        fb.set_mode(NDFileMode::Capture);
729        fb.set_num_capture(3);
730
731        let mut writer = MockWriter::new(true);
732
733        // Buffer 3 arrays
734        fb.process_array(make_array(1), &mut writer).unwrap();
735        assert_eq!(writer.writes, 0); // not flushed yet
736        fb.process_array(make_array(2), &mut writer).unwrap();
737        assert_eq!(writer.writes, 0);
738        fb.process_array(make_array(3), &mut writer).unwrap();
739        // Should have flushed
740        assert_eq!(writer.opens.len(), 1);
741        assert_eq!(writer.writes, 3);
742        assert_eq!(writer.closes, 1);
743    }
744
745    #[test]
746    fn test_capture_mode_single_image_format() {
747        let mut fb = NDPluginFileBase::new();
748        fb.file_path = "/tmp/".into();
749        fb.file_name = "jpeg_".into();
750        fb.file_number = 0;
751        fb.auto_increment = true;
752        fb.set_mode(NDFileMode::Capture);
753        fb.set_num_capture(3);
754
755        let mut writer = MockWriter::new(false); // single-image format
756
757        fb.process_array(make_array(1), &mut writer).unwrap();
758        fb.process_array(make_array(2), &mut writer).unwrap();
759        fb.process_array(make_array(3), &mut writer).unwrap();
760        // Should have flushed with open/write/close per frame
761        assert_eq!(writer.opens.len(), 3);
762        assert_eq!(writer.writes, 3);
763        assert_eq!(writer.closes, 3);
764        assert_eq!(fb.file_number, 3); // auto-incremented 3 times
765    }
766
767    #[test]
768    fn test_stream_mode() {
769        let mut fb = NDPluginFileBase::new();
770        fb.file_path = "/tmp/".into();
771        fb.file_name = "stream_".into();
772        fb.set_mode(NDFileMode::Stream);
773
774        let mut writer = MockWriter::new(true);
775
776        fb.process_array(make_array(1), &mut writer).unwrap();
777        fb.process_array(make_array(2), &mut writer).unwrap();
778        fb.process_array(make_array(3), &mut writer).unwrap();
779
780        assert_eq!(writer.opens.len(), 1); // opened once
781        assert_eq!(writer.writes, 3);
782        assert_eq!(writer.closes, 0); // not closed yet
783
784        fb.close_stream(&mut writer).unwrap();
785        assert_eq!(writer.closes, 1);
786    }
787
788    #[test]
789    fn test_create_file_name_default() {
790        let mut fb = NDPluginFileBase::new();
791        fb.file_path = "/data/".into();
792        fb.file_name = "img_".into();
793        fb.file_number = 42;
794        assert_eq!(fb.create_file_name(), "/data/img_0042");
795    }
796
797    #[test]
798    fn test_create_file_name_template() {
799        let mut fb = NDPluginFileBase::new();
800        fb.file_path = "/data/".into();
801        fb.file_name = "img_".into();
802        fb.file_number = 5;
803        fb.file_template = "%s%s%d.tif".into();
804        assert_eq!(fb.create_file_name(), "/data/img_5.tif");
805    }
806
807    #[test]
808    fn test_create_file_name_printf_specs() {
809        // B10: %-, width-only space pad, %0Nd zero pad, %%.
810        let mut fb = NDPluginFileBase::new();
811        fb.file_path = "/d/".into();
812        fb.file_name = "f".into();
813        fb.file_number = 7;
814
815        fb.file_template = "%s%s_%3.3d.dat".into();
816        assert_eq!(fb.create_file_name(), "/d/f_007.dat");
817
818        // Width-only → space pad, right-justified.
819        fb.file_template = "%s%s_%5d".into();
820        assert_eq!(fb.create_file_name(), "/d/f_    7");
821
822        // Left-justify.
823        fb.file_template = "%s%s_%-5d".into();
824        assert_eq!(fb.create_file_name(), "/d/f_7    ");
825
826        // Zero-pad flag.
827        fb.file_template = "%s%s_%05d".into();
828        assert_eq!(fb.create_file_name(), "/d/f_00007");
829
830        // Literal percent.
831        fb.file_template = "%s%s_%d%%".into();
832        assert_eq!(fb.create_file_name(), "/d/f_7%");
833    }
834
835    #[test]
836    fn test_auto_increment() {
837        let mut fb = NDPluginFileBase::new();
838        fb.file_path = "/tmp/".into();
839        fb.file_name = "t_".into();
840        fb.file_number = 0;
841        fb.auto_increment = true;
842        fb.set_mode(NDFileMode::Single);
843
844        let mut writer = MockWriter::new(false);
845        fb.process_array(make_array(1), &mut writer).unwrap();
846        assert_eq!(fb.file_number, 1);
847        fb.process_array(make_array(2), &mut writer).unwrap();
848        assert_eq!(fb.file_number, 2);
849    }
850
851    #[test]
852    fn test_temp_suffix() {
853        let mut fb = NDPluginFileBase::new();
854        fb.file_path = "/data/".into();
855        fb.file_name = "img_".into();
856        fb.file_number = 1;
857        fb.temp_suffix = ".tmp".into();
858
859        let temp = fb.temp_file_path().unwrap();
860        assert_eq!(temp.to_str().unwrap(), "/data/img_0001.tmp");
861    }
862
863    fn make_array_with_driver_file(id: i32, driver_file: &str) -> Arc<NDArray> {
864        use crate::attributes::{NDAttrSource, NDAttrValue, NDAttribute};
865        let mut arr = NDArray::new(vec![NDDimension::new(4)], NDDataType::UInt8);
866        arr.unique_id = id;
867        arr.attributes.add(NDAttribute::new_static(
868            "DriverFileName",
869            "",
870            NDAttrSource::Driver,
871            NDAttrValue::String(driver_file.to_string()),
872        ));
873        Arc::new(arr)
874    }
875
876    #[test]
877    fn test_delete_driver_file_in_capture_mode() {
878        // C++ NDPluginFile deletes the driver file per frame in Capture mode
879        // too, not only Single/Stream.
880        let dir = std::env::temp_dir();
881        let f1 = dir.join(format!(
882            "adcore_capture_driver_1_{}.raw",
883            std::process::id()
884        ));
885        let f2 = dir.join(format!(
886            "adcore_capture_driver_2_{}.raw",
887            std::process::id()
888        ));
889        std::fs::write(&f1, b"x").unwrap();
890        std::fs::write(&f2, b"y").unwrap();
891
892        let mut fb = NDPluginFileBase::new();
893        fb.file_path = format!("{}/", dir.display());
894        fb.file_name = "capdel_".into();
895        fb.delete_driver_file = true;
896        fb.set_mode(NDFileMode::Capture);
897        fb.set_num_capture(2);
898
899        let mut writer = MockWriter::new(true);
900        fb.process_array(
901            make_array_with_driver_file(1, f1.to_str().unwrap()),
902            &mut writer,
903        )
904        .unwrap();
905        fb.process_array(
906            make_array_with_driver_file(2, f2.to_str().unwrap()),
907            &mut writer,
908        )
909        .unwrap();
910
911        // Capture buffer flushed at num_capture=2; both driver files deleted.
912        assert!(
913            !f1.exists(),
914            "driver file 1 should be deleted in capture mode"
915        );
916        assert!(
917            !f2.exists(),
918            "driver file 2 should be deleted in capture mode"
919        );
920    }
921
922    #[test]
923    fn test_delete_driver_file_capture_single_image_format() {
924        let dir = std::env::temp_dir();
925        let f1 = dir.join(format!(
926            "adcore_capture_si_driver_1_{}.raw",
927            std::process::id()
928        ));
929        std::fs::write(&f1, b"x").unwrap();
930
931        let mut fb = NDPluginFileBase::new();
932        fb.file_path = format!("{}/", dir.display());
933        fb.file_name = "capdelsi_".into();
934        fb.delete_driver_file = true;
935        fb.set_mode(NDFileMode::Capture);
936        fb.set_num_capture(1);
937
938        let mut writer = MockWriter::new(false); // single-image format
939        fb.process_array(
940            make_array_with_driver_file(1, f1.to_str().unwrap()),
941            &mut writer,
942        )
943        .unwrap();
944
945        assert!(
946            !f1.exists(),
947            "driver file should be deleted in capture mode"
948        );
949    }
950
951    #[test]
952    fn test_ensure_directory() {
953        let fb = NDPluginFileBase::new();
954        // With create_dir=0 and empty path, should be a no-op
955        fb.ensure_directory().unwrap();
956    }
957
958    /// D6: a Single-mode write that fails must still close the file it opened,
959    /// or the writer keeps the handle (for HDF5 an H5 file id) until some later
960    /// open happens to displace it.
961    #[test]
962    fn single_write_closes_the_file_when_the_write_fails() {
963        let mut fb = NDPluginFileBase::new();
964        fb.file_path = "/tmp/".into();
965        fb.file_name = "adcore_single_fail_".into();
966        fb.set_mode(NDFileMode::Single);
967
968        let mut writer = MockWriter::new(false);
969        writer.fail_write = true;
970        assert!(fb.process_array(make_array(1), &mut writer).is_err());
971        assert_eq!(writer.opens.len(), 1);
972        assert_eq!(
973            writer.closes, 1,
974            "a failed write must still close the file it opened"
975        );
976    }
977
978    /// D6: the same open/close pairing inside `flush_capture`.
979    #[test]
980    fn flush_capture_closes_the_file_when_a_frame_write_fails() {
981        let (mut fb, mut writer) = partial_flush_fixture();
982        writer.fail_write = true;
983        assert!(fb.flush_capture(&mut writer).is_err());
984        assert_eq!(
985            writer.closes, 1,
986            "the file opened for the failing frame is still closed"
987        );
988    }
989
990    /// D6, the second resource the failing frame used to take with it: the
991    /// buffer was moved out of `self` and only put back below the `?`, so a
992    /// failed flush silently discarded every queued frame and the retry then
993    /// reported success having written nothing.
994    #[test]
995    fn flush_capture_keeps_the_buffer_when_a_frame_write_fails() {
996        let (mut fb, mut writer) = partial_flush_fixture();
997        writer.fail_write = true;
998        assert!(fb.flush_capture(&mut writer).is_err());
999
1000        writer.fail_write = false;
1001        fb.flush_capture(&mut writer).unwrap();
1002        assert_eq!(
1003            writer.writes, 2,
1004            "both buffered frames survive the failed flush and are written on retry"
1005        );
1006    }
1007
1008    /// Two frames buffered for a single-image (open/write/close per frame)
1009    /// writer, ready to flush.
1010    fn partial_flush_fixture() -> (NDPluginFileBase, MockWriter) {
1011        let mut fb = NDPluginFileBase::new();
1012        fb.file_path = "/tmp/".into();
1013        fb.file_name = "adcore_partial_".into();
1014        fb.set_mode(NDFileMode::Capture);
1015        fb.set_num_capture(2);
1016        fb.capture_array(make_array(1));
1017        fb.capture_array(make_array(2));
1018        (fb, MockWriter::new(false))
1019    }
1020
1021    /// D1: a stream close whose `close_file` fails must still leave the base
1022    /// closed, so the plugin can open a new file once the disk is freed.
1023    #[test]
1024    fn close_stream_clears_is_open_when_close_file_fails() {
1025        let mut fb = NDPluginFileBase::new();
1026        fb.file_path = "/tmp/".into();
1027        fb.file_name = "adcore_wedge_".into();
1028        fb.set_mode(NDFileMode::Stream);
1029
1030        let mut writer = MockWriter::new(true);
1031        fb.process_array(make_array(1), &mut writer).unwrap();
1032        assert!(fb.is_open());
1033
1034        writer.fail_close = true;
1035        assert!(
1036            fb.close_stream(&mut writer).is_err(),
1037            "the close failure is still reported"
1038        );
1039        assert!(
1040            !fb.is_open(),
1041            "a failed close must still end the open state"
1042        );
1043
1044        writer.fail_close = false;
1045        fb.process_array(make_array(2), &mut writer).unwrap();
1046        assert_eq!(
1047            writer.opens.len(),
1048            2,
1049            "the next frame opens a new stream file instead of appending to the stale handle"
1050        );
1051    }
1052
1053    /// D1, second exit path: the temp-to-final rename is the other `?` between
1054    /// the close and the marker clear.
1055    #[test]
1056    fn close_stream_clears_is_open_when_temp_rename_fails() {
1057        let mut fb = NDPluginFileBase::new();
1058        // A directory that does not exist, so the rename fails with ENOENT.
1059        fb.file_path = "/tmp/adcore-no-such-dir-for-close-stream/".into();
1060        fb.file_name = "norename_".into();
1061        fb.temp_suffix = ".tmp".into();
1062        fb.set_mode(NDFileMode::Stream);
1063
1064        let mut writer = MockWriter::new(true);
1065        fb.process_array(make_array(1), &mut writer).unwrap();
1066        assert!(fb.is_open());
1067
1068        assert!(fb.close_stream(&mut writer).is_err());
1069        assert!(
1070            !fb.is_open(),
1071            "a failed temp rename must still end the open state"
1072        );
1073    }
1074
1075    /// F4: Stream mode's contract is that a frame `process_array` accepts is on
1076    /// disk when it returns — that is what `NDFileNumCaptured` reports. A
1077    /// writer that only materialises the file in `close_file` cannot keep it,
1078    /// and an unbounded stream never reaches a close, so the open is refused
1079    /// rather than accumulating the whole stream in RAM.
1080    #[test]
1081    fn stream_open_is_refused_for_a_writer_that_writes_only_on_close() {
1082        let mut fb = NDPluginFileBase::new();
1083        fb.file_path = "/tmp/".into();
1084        fb.file_name = "stream_".into();
1085        fb.set_mode(NDFileMode::Stream);
1086        fb.set_num_capture(0);
1087
1088        let mut writer = MockWriter::new(true);
1089        writer.incremental = false;
1090
1091        assert!(fb.process_array(make_array(1), &mut writer).is_err());
1092        assert!(writer.opens.is_empty(), "no file may be opened");
1093        assert_eq!(writer.writes, 0, "no frame may be handed to the writer");
1094        assert!(!fb.is_open());
1095        assert_eq!(
1096            fb.num_captured(),
1097            0,
1098            "NumCaptured must not count a frame that is not on disk"
1099        );
1100    }
1101}