Skip to main content

ad_core_rs/plugin/
file_base.rs

1use std::path::{Path, PathBuf};
2use std::sync::Arc;
3
4use crate::error::ADResult;
5use crate::ndarray::NDArray;
6
7/// File write modes matching C++ NDFileMode_t.
8#[derive(Debug, Clone, Copy, PartialEq, Eq)]
9pub enum NDFileMode {
10    Single = 0,
11    Capture = 1,
12    Stream = 2,
13}
14
15impl NDFileMode {
16    pub fn from_i32(v: i32) -> Self {
17        match v {
18            0 => Self::Single,
19            1 => Self::Capture,
20            _ => Self::Stream,
21        }
22    }
23}
24
25/// Trait for file format writers.
26pub trait NDFileWriter: Send + Sync {
27    fn open_file(&mut self, path: &Path, mode: NDFileMode, array: &NDArray) -> ADResult<()>;
28    fn write_file(&mut self, array: &NDArray) -> ADResult<()>;
29    fn read_file(&mut self) -> ADResult<NDArray>;
30    fn close_file(&mut self) -> ADResult<()>;
31    fn supports_multiple_arrays(&self) -> bool {
32        true
33    }
34    /// Inform the writer of the configured capture count (`NDFileNumCapture`)
35    /// before the next `open_file`. A writer whose on-disk layout depends on
36    /// the capture target (e.g. HDF5 chunk sizing, C `calculateAttributeChunking`)
37    /// uses it; the default ignores it.
38    fn set_num_capture(&mut self, _n: usize) {}
39}
40
41/// File path/name management and capture buffering for file plugins.
42pub struct NDPluginFileBase {
43    pub file_path: String,
44    pub file_name: String,
45    pub file_number: i32,
46    pub file_template: String,
47    pub auto_increment: bool,
48    pub temp_suffix: String,
49    pub create_dir: i32,
50    pub lazy_open: bool,
51    pub delete_driver_file: bool,
52    capture_buffer: Vec<Arc<NDArray>>,
53    num_capture: usize,
54    num_captured: usize,
55    is_open: bool,
56    mode: NDFileMode,
57    last_written_name: String,
58}
59
60impl NDPluginFileBase {
61    pub fn new() -> Self {
62        Self {
63            file_path: String::new(),
64            file_name: String::new(),
65            file_number: 0,
66            file_template: String::new(),
67            auto_increment: false,
68            temp_suffix: String::new(),
69            create_dir: 0,
70            lazy_open: false,
71            delete_driver_file: false,
72            capture_buffer: Vec::new(),
73            num_capture: 1,
74            num_captured: 0,
75            is_open: false,
76            mode: NDFileMode::Single,
77            last_written_name: String::new(),
78        }
79    }
80
81    /// Construct the full file path from template/path/name/number.
82    ///
83    /// Mimics C `epicsSnprintf(buf, ..., template, filePath, fileName, fileNumber)`.
84    /// Template uses printf-style: first `%s` → filePath, second `%s` → fileName,
85    /// `%d` (with optional width/precision like `%3.3d`) → fileNumber.
86    pub fn create_file_name(&self) -> String {
87        if self.file_template.is_empty() {
88            format!(
89                "{}{}{:04}",
90                self.file_path, self.file_name, self.file_number
91            )
92        } else {
93            let mut result = String::new();
94            let mut chars = self.file_template.chars().peekable();
95            let mut s_count = 0;
96            while let Some(c) = chars.next() {
97                if c == '%' {
98                    // Collect printf flags and the width/precision spec:
99                    // optional `-` (left-justify) / `0` (zero-pad) flags,
100                    // digits (width), `.digits` (precision). C++ uses real
101                    // epicsSnprintf.
102                    let mut left_justify = false;
103                    let mut zero_pad = false;
104                    loop {
105                        match chars.peek() {
106                            Some('-') => {
107                                left_justify = true;
108                                chars.next();
109                            }
110                            Some('0') => {
111                                zero_pad = true;
112                                chars.next();
113                            }
114                            _ => break,
115                        }
116                    }
117                    let mut spec = String::new();
118                    while let Some(&nc) = chars.peek() {
119                        if nc.is_ascii_digit() || nc == '.' {
120                            spec.push(nc);
121                            chars.next();
122                        } else {
123                            break;
124                        }
125                    }
126                    match chars.next() {
127                        // `%%` → literal percent.
128                        Some('%') if spec.is_empty() && !left_justify => result.push('%'),
129                        Some('s') => {
130                            s_count += 1;
131                            match s_count {
132                                1 => result.push_str(&self.file_path),
133                                2 => result.push_str(&self.file_name),
134                                _ => {}
135                            }
136                        }
137                        Some('d') => {
138                            // `width.precision`: precision = minimum digits
139                            // (zero-pad), width = minimum field width
140                            // (space-pad unless precision provided).
141                            let (width, precision) = match spec.split_once('.') {
142                                Some((w, p)) => (
143                                    w.parse::<usize>().unwrap_or(0),
144                                    p.parse::<usize>().unwrap_or(0),
145                                ),
146                                None => (spec.parse::<usize>().unwrap_or(0), 0),
147                            };
148                            // Apply precision first (zero-pad the number).
149                            let digits = format!("{:0>prec$}", self.file_number, prec = precision);
150                            // Then pad to the field width. The `0` flag
151                            // zero-pads (ignored when left-justified or when an
152                            // explicit precision is given, per C printf).
153                            if digits.len() >= width {
154                                result.push_str(&digits);
155                            } else if zero_pad && !left_justify && precision == 0 {
156                                result.push_str(&format!(
157                                    "{:0>width$}",
158                                    self.file_number,
159                                    width = width
160                                ));
161                            } else {
162                                let pad = " ".repeat(width - digits.len());
163                                if left_justify {
164                                    result.push_str(&digits);
165                                    result.push_str(&pad);
166                                } else {
167                                    result.push_str(&pad);
168                                    result.push_str(&digits);
169                                }
170                            }
171                        }
172                        Some(other) => {
173                            result.push('%');
174                            if left_justify {
175                                result.push('-');
176                            }
177                            if zero_pad {
178                                result.push('0');
179                            }
180                            result.push_str(&spec);
181                            result.push(other);
182                        }
183                        None => result.push('%'),
184                    }
185                } else {
186                    result.push(c);
187                }
188            }
189            result
190        }
191    }
192
193    /// Get the temp file path (if temp_suffix is set).
194    pub fn temp_file_path(&self) -> Option<PathBuf> {
195        if self.temp_suffix.is_empty() {
196            None
197        } else {
198            let name = self.create_file_name();
199            Some(PathBuf::from(format!("{}{}", name, self.temp_suffix)))
200        }
201    }
202
203    /// Return the full file name that was last written.
204    pub fn last_written_name(&self) -> &str {
205        &self.last_written_name
206    }
207
208    /// Create directory if needed.
209    /// C ADCore behavior: createDir != 0 → create directories.
210    /// Positive or negative values both trigger creation (negative = depth hint in C,
211    /// but in practice create_dir_all handles any depth).
212    pub fn ensure_directory(&self) -> ADResult<()> {
213        if self.create_dir != 0 && !self.file_path.is_empty() {
214            std::fs::create_dir_all(&self.file_path)?;
215        }
216        Ok(())
217    }
218
219    /// Write to temp path if temp_suffix is set, then rename to final path.
220    fn write_path(&self) -> (PathBuf, Option<PathBuf>) {
221        let final_path = PathBuf::from(self.create_file_name());
222        if self.temp_suffix.is_empty() {
223            (final_path, None)
224        } else {
225            let temp = PathBuf::from(format!("{}{}", final_path.display(), self.temp_suffix));
226            (temp, Some(final_path))
227        }
228    }
229
230    /// Rename temp file to final path if applicable.
231    fn rename_temp(temp_path: &Path, final_path: &Path) -> ADResult<()> {
232        std::fs::rename(temp_path, final_path)?;
233        Ok(())
234    }
235
236    /// Delete the driver's original file for `array` when `delete_driver_file`
237    /// is set. C++ NDPluginFile deletes the driver file in every write mode
238    /// (Single, Stream, and Capture), keyed off the `DriverFileName` attribute.
239    fn maybe_delete_driver_file(&self, array: &NDArray) {
240        if !self.delete_driver_file {
241            return;
242        }
243        // C reads DriverFileName via `getValue(NDAttrString, …)` and only
244        // deletes on `asynSuccess`; a numeric attribute returns `ND_ERROR`, so
245        // no file is removed (the decimal rendering is never used as a path).
246        if let Some(driver_file) = array
247            .attributes
248            .get("DriverFileName")
249            .and_then(|attr| attr.value.as_string_typed())
250        {
251            if !driver_file.is_empty() {
252                let _ = std::fs::remove_file(driver_file);
253            }
254        }
255    }
256
257    /// Process an incoming array according to the current file mode.
258    pub fn process_array(
259        &mut self,
260        array: Arc<NDArray>,
261        writer: &mut dyn NDFileWriter,
262    ) -> ADResult<()> {
263        // The writer's open-time layout (e.g. HDF5 attribute/performance chunk
264        // sizing) depends on the capture target; keep it current before any open.
265        writer.set_num_capture(self.num_capture);
266        match self.mode {
267            NDFileMode::Single => {
268                self.last_written_name = self.create_file_name();
269                let (write_path, final_path) = self.write_path();
270                writer.open_file(&write_path, NDFileMode::Single, &array)?;
271                writer.write_file(&array)?;
272                writer.close_file()?;
273                if let Some(final_path) = final_path {
274                    Self::rename_temp(&write_path, &final_path)?;
275                }
276                self.maybe_delete_driver_file(&array);
277                if self.auto_increment {
278                    self.file_number += 1;
279                }
280            }
281            NDFileMode::Capture => {
282                self.capture_buffer.push(array);
283                self.num_captured = self.capture_buffer.len();
284                // B7: num_capture==0 → buffer forever, never auto-flush.
285                if self.num_capture > 0 && self.num_captured >= self.num_capture {
286                    self.flush_capture(writer)?;
287                }
288            }
289            NDFileMode::Stream => {
290                if !self.is_open && !self.lazy_open {
291                    self.last_written_name = self.create_file_name();
292                    let (write_path, _) = self.write_path();
293                    writer.open_file(&write_path, NDFileMode::Stream, &array)?;
294                    self.is_open = true;
295                }
296                if self.lazy_open && !self.is_open {
297                    self.last_written_name = self.create_file_name();
298                    let (write_path, _) = self.write_path();
299                    writer.open_file(&write_path, NDFileMode::Stream, &array)?;
300                    self.is_open = true;
301                }
302                writer.write_file(&array)?;
303                self.maybe_delete_driver_file(&array);
304                self.num_captured += 1;
305            }
306        }
307        Ok(())
308    }
309
310    /// Flush capture buffer: open file, write all buffered arrays, close.
311    ///
312    /// For writers that support multiple arrays (HDF5, NeXus), we open once,
313    /// write all frames, and close once.
314    /// For single-image writers (JPEG, TIFF), we open/write/close for each
315    /// frame individually, auto-incrementing the filename between each.
316    pub fn flush_capture(&mut self, writer: &mut dyn NDFileWriter) -> ADResult<()> {
317        if self.capture_buffer.is_empty() {
318            return Ok(());
319        }
320        writer.set_num_capture(self.num_capture);
321
322        if writer.supports_multiple_arrays() {
323            // Multi-array format: open once, write all, close once.
324            self.last_written_name = self.create_file_name();
325            let (write_path, final_path) = self.write_path();
326            writer.open_file(&write_path, NDFileMode::Capture, &self.capture_buffer[0])?;
327            for arr in &self.capture_buffer {
328                writer.write_file(arr)?;
329            }
330            writer.close_file()?;
331            if let Some(final_path) = final_path {
332                Self::rename_temp(&write_path, &final_path)?;
333            }
334            // C++ deletes the driver file per frame in Capture mode too.
335            let buffer = std::mem::take(&mut self.capture_buffer);
336            for arr in &buffer {
337                self.maybe_delete_driver_file(arr);
338            }
339            self.capture_buffer = buffer;
340            if self.auto_increment {
341                self.file_number += 1;
342            }
343        } else {
344            // Single-image format: open/write/close per frame with auto-increment.
345            let buffer = std::mem::take(&mut self.capture_buffer);
346            for arr in &buffer {
347                self.last_written_name = self.create_file_name();
348                let (write_path, final_path) = self.write_path();
349                writer.open_file(&write_path, NDFileMode::Single, arr)?;
350                writer.write_file(arr)?;
351                writer.close_file()?;
352                if let Some(final_path) = final_path {
353                    Self::rename_temp(&write_path, &final_path)?;
354                }
355                self.maybe_delete_driver_file(arr);
356                if self.auto_increment {
357                    self.file_number += 1;
358                }
359            }
360            self.capture_buffer = buffer;
361        }
362
363        self.capture_buffer.clear();
364        self.num_captured = 0;
365        Ok(())
366    }
367
368    /// Eagerly open a stream file before the first frame arrives.
369    ///
370    /// C++ `doCapture` opens the file at capture-start for non-lazy stream
371    /// plugins (NDPluginFile.cpp:478-479) so a bad path is reported then,
372    /// not on the first frame (B9). `array` supplies the layout the writer
373    /// needs at open time.
374    pub fn open_stream_eager(
375        &mut self,
376        writer: &mut dyn NDFileWriter,
377        array: &NDArray,
378    ) -> ADResult<()> {
379        if self.is_open {
380            return Ok(());
381        }
382        writer.set_num_capture(self.num_capture);
383        self.last_written_name = self.create_file_name();
384        let (write_path, _) = self.write_path();
385        writer.open_file(&write_path, NDFileMode::Stream, array)?;
386        self.is_open = true;
387        Ok(())
388    }
389
390    /// Force a file close (used by the FilePluginClose attribute, G9).
391    /// Safe to call when no file is open.
392    pub fn force_close(&mut self, writer: &mut dyn NDFileWriter) -> ADResult<()> {
393        if self.is_open {
394            self.close_stream(writer)?;
395        }
396        Ok(())
397    }
398
399    /// Close stream mode.
400    pub fn close_stream(&mut self, writer: &mut dyn NDFileWriter) -> ADResult<()> {
401        if self.is_open {
402            writer.close_file()?;
403            // Rename temp to final if temp_suffix was set
404            if !self.temp_suffix.is_empty() {
405                let final_name = self.create_file_name();
406                let temp_name = format!("{}{}", final_name, self.temp_suffix);
407                Self::rename_temp(Path::new(&temp_name), Path::new(&final_name))?;
408            }
409            self.is_open = false;
410            if self.auto_increment {
411                self.file_number += 1;
412            }
413        }
414        Ok(())
415    }
416
417    pub fn is_open(&self) -> bool {
418        self.is_open
419    }
420
421    pub fn set_mode(&mut self, mode: NDFileMode) {
422        self.mode = mode;
423    }
424
425    pub fn set_num_capture(&mut self, n: usize) {
426        self.num_capture = n;
427    }
428
429    pub fn num_captured(&self) -> usize {
430        self.num_captured
431    }
432
433    pub fn mode(&self) -> NDFileMode {
434        self.mode
435    }
436
437    pub fn num_capture_target(&self) -> usize {
438        self.num_capture
439    }
440
441    pub fn capture_array(&mut self, array: Arc<NDArray>) {
442        self.capture_buffer.push(array);
443        self.num_captured = self.capture_buffer.len();
444    }
445
446    pub fn clear_capture(&mut self) {
447        self.capture_buffer.clear();
448        self.num_captured = 0;
449    }
450}
451
452#[cfg(test)]
453mod tests {
454    use super::*;
455    use crate::ndarray::{NDDataType, NDDimension};
456
457    /// Test file writer that records operations.
458    struct MockWriter {
459        opens: Vec<PathBuf>,
460        writes: usize,
461        closes: usize,
462        multi: bool,
463    }
464
465    impl MockWriter {
466        fn new(multi: bool) -> Self {
467            Self {
468                opens: Vec::new(),
469                writes: 0,
470                closes: 0,
471                multi,
472            }
473        }
474    }
475
476    impl NDFileWriter for MockWriter {
477        fn open_file(&mut self, path: &Path, _mode: NDFileMode, _array: &NDArray) -> ADResult<()> {
478            self.opens.push(path.to_path_buf());
479            Ok(())
480        }
481        fn write_file(&mut self, _array: &NDArray) -> ADResult<()> {
482            self.writes += 1;
483            Ok(())
484        }
485        fn read_file(&mut self) -> ADResult<NDArray> {
486            Err(crate::error::ADError::UnsupportedConversion(
487                "not implemented".into(),
488            ))
489        }
490        fn close_file(&mut self) -> ADResult<()> {
491            self.closes += 1;
492            Ok(())
493        }
494        fn supports_multiple_arrays(&self) -> bool {
495            self.multi
496        }
497    }
498
499    fn make_array(id: i32) -> Arc<NDArray> {
500        let mut arr = NDArray::new(vec![NDDimension::new(4)], NDDataType::UInt8);
501        arr.unique_id = id;
502        Arc::new(arr)
503    }
504
505    #[test]
506    fn test_single_mode() {
507        let mut fb = NDPluginFileBase::new();
508        fb.file_path = "/tmp/".into();
509        fb.file_name = "test_".into();
510        fb.file_number = 1;
511        fb.auto_increment = true;
512        fb.set_mode(NDFileMode::Single);
513
514        let mut writer = MockWriter::new(false);
515        fb.process_array(make_array(1), &mut writer).unwrap();
516
517        assert_eq!(writer.opens.len(), 1);
518        assert_eq!(writer.writes, 1);
519        assert_eq!(writer.closes, 1);
520        assert_eq!(fb.file_number, 2); // auto-incremented
521    }
522
523    #[test]
524    fn test_capture_mode() {
525        let mut fb = NDPluginFileBase::new();
526        fb.file_path = "/tmp/".into();
527        fb.file_name = "cap_".into();
528        fb.set_mode(NDFileMode::Capture);
529        fb.set_num_capture(3);
530
531        let mut writer = MockWriter::new(true);
532
533        // Buffer 3 arrays
534        fb.process_array(make_array(1), &mut writer).unwrap();
535        assert_eq!(writer.writes, 0); // not flushed yet
536        fb.process_array(make_array(2), &mut writer).unwrap();
537        assert_eq!(writer.writes, 0);
538        fb.process_array(make_array(3), &mut writer).unwrap();
539        // Should have flushed
540        assert_eq!(writer.opens.len(), 1);
541        assert_eq!(writer.writes, 3);
542        assert_eq!(writer.closes, 1);
543    }
544
545    #[test]
546    fn test_capture_mode_single_image_format() {
547        let mut fb = NDPluginFileBase::new();
548        fb.file_path = "/tmp/".into();
549        fb.file_name = "jpeg_".into();
550        fb.file_number = 0;
551        fb.auto_increment = true;
552        fb.set_mode(NDFileMode::Capture);
553        fb.set_num_capture(3);
554
555        let mut writer = MockWriter::new(false); // single-image format
556
557        fb.process_array(make_array(1), &mut writer).unwrap();
558        fb.process_array(make_array(2), &mut writer).unwrap();
559        fb.process_array(make_array(3), &mut writer).unwrap();
560        // Should have flushed with open/write/close per frame
561        assert_eq!(writer.opens.len(), 3);
562        assert_eq!(writer.writes, 3);
563        assert_eq!(writer.closes, 3);
564        assert_eq!(fb.file_number, 3); // auto-incremented 3 times
565    }
566
567    #[test]
568    fn test_stream_mode() {
569        let mut fb = NDPluginFileBase::new();
570        fb.file_path = "/tmp/".into();
571        fb.file_name = "stream_".into();
572        fb.set_mode(NDFileMode::Stream);
573
574        let mut writer = MockWriter::new(true);
575
576        fb.process_array(make_array(1), &mut writer).unwrap();
577        fb.process_array(make_array(2), &mut writer).unwrap();
578        fb.process_array(make_array(3), &mut writer).unwrap();
579
580        assert_eq!(writer.opens.len(), 1); // opened once
581        assert_eq!(writer.writes, 3);
582        assert_eq!(writer.closes, 0); // not closed yet
583
584        fb.close_stream(&mut writer).unwrap();
585        assert_eq!(writer.closes, 1);
586    }
587
588    #[test]
589    fn test_create_file_name_default() {
590        let mut fb = NDPluginFileBase::new();
591        fb.file_path = "/data/".into();
592        fb.file_name = "img_".into();
593        fb.file_number = 42;
594        assert_eq!(fb.create_file_name(), "/data/img_0042");
595    }
596
597    #[test]
598    fn test_create_file_name_template() {
599        let mut fb = NDPluginFileBase::new();
600        fb.file_path = "/data/".into();
601        fb.file_name = "img_".into();
602        fb.file_number = 5;
603        fb.file_template = "%s%s%d.tif".into();
604        assert_eq!(fb.create_file_name(), "/data/img_5.tif");
605    }
606
607    #[test]
608    fn test_create_file_name_printf_specs() {
609        // B10: %-, width-only space pad, %0Nd zero pad, %%.
610        let mut fb = NDPluginFileBase::new();
611        fb.file_path = "/d/".into();
612        fb.file_name = "f".into();
613        fb.file_number = 7;
614
615        fb.file_template = "%s%s_%3.3d.dat".into();
616        assert_eq!(fb.create_file_name(), "/d/f_007.dat");
617
618        // Width-only → space pad, right-justified.
619        fb.file_template = "%s%s_%5d".into();
620        assert_eq!(fb.create_file_name(), "/d/f_    7");
621
622        // Left-justify.
623        fb.file_template = "%s%s_%-5d".into();
624        assert_eq!(fb.create_file_name(), "/d/f_7    ");
625
626        // Zero-pad flag.
627        fb.file_template = "%s%s_%05d".into();
628        assert_eq!(fb.create_file_name(), "/d/f_00007");
629
630        // Literal percent.
631        fb.file_template = "%s%s_%d%%".into();
632        assert_eq!(fb.create_file_name(), "/d/f_7%");
633    }
634
635    #[test]
636    fn test_auto_increment() {
637        let mut fb = NDPluginFileBase::new();
638        fb.file_path = "/tmp/".into();
639        fb.file_name = "t_".into();
640        fb.file_number = 0;
641        fb.auto_increment = true;
642        fb.set_mode(NDFileMode::Single);
643
644        let mut writer = MockWriter::new(false);
645        fb.process_array(make_array(1), &mut writer).unwrap();
646        assert_eq!(fb.file_number, 1);
647        fb.process_array(make_array(2), &mut writer).unwrap();
648        assert_eq!(fb.file_number, 2);
649    }
650
651    #[test]
652    fn test_temp_suffix() {
653        let mut fb = NDPluginFileBase::new();
654        fb.file_path = "/data/".into();
655        fb.file_name = "img_".into();
656        fb.file_number = 1;
657        fb.temp_suffix = ".tmp".into();
658
659        let temp = fb.temp_file_path().unwrap();
660        assert_eq!(temp.to_str().unwrap(), "/data/img_0001.tmp");
661    }
662
663    fn make_array_with_driver_file(id: i32, driver_file: &str) -> Arc<NDArray> {
664        use crate::attributes::{NDAttrSource, NDAttrValue, NDAttribute};
665        let mut arr = NDArray::new(vec![NDDimension::new(4)], NDDataType::UInt8);
666        arr.unique_id = id;
667        arr.attributes.add(NDAttribute::new_static(
668            "DriverFileName",
669            "",
670            NDAttrSource::Driver,
671            NDAttrValue::String(driver_file.to_string()),
672        ));
673        Arc::new(arr)
674    }
675
676    #[test]
677    fn test_delete_driver_file_in_capture_mode() {
678        // C++ NDPluginFile deletes the driver file per frame in Capture mode
679        // too, not only Single/Stream.
680        let dir = std::env::temp_dir();
681        let f1 = dir.join("adcore_capture_driver_1.raw");
682        let f2 = dir.join("adcore_capture_driver_2.raw");
683        std::fs::write(&f1, b"x").unwrap();
684        std::fs::write(&f2, b"y").unwrap();
685
686        let mut fb = NDPluginFileBase::new();
687        fb.file_path = format!("{}/", dir.display());
688        fb.file_name = "capdel_".into();
689        fb.delete_driver_file = true;
690        fb.set_mode(NDFileMode::Capture);
691        fb.set_num_capture(2);
692
693        let mut writer = MockWriter::new(true);
694        fb.process_array(
695            make_array_with_driver_file(1, f1.to_str().unwrap()),
696            &mut writer,
697        )
698        .unwrap();
699        fb.process_array(
700            make_array_with_driver_file(2, f2.to_str().unwrap()),
701            &mut writer,
702        )
703        .unwrap();
704
705        // Capture buffer flushed at num_capture=2; both driver files deleted.
706        assert!(
707            !f1.exists(),
708            "driver file 1 should be deleted in capture mode"
709        );
710        assert!(
711            !f2.exists(),
712            "driver file 2 should be deleted in capture mode"
713        );
714    }
715
716    #[test]
717    fn test_delete_driver_file_capture_single_image_format() {
718        let dir = std::env::temp_dir();
719        let f1 = dir.join("adcore_capture_si_driver_1.raw");
720        std::fs::write(&f1, b"x").unwrap();
721
722        let mut fb = NDPluginFileBase::new();
723        fb.file_path = format!("{}/", dir.display());
724        fb.file_name = "capdelsi_".into();
725        fb.delete_driver_file = true;
726        fb.set_mode(NDFileMode::Capture);
727        fb.set_num_capture(1);
728
729        let mut writer = MockWriter::new(false); // single-image format
730        fb.process_array(
731            make_array_with_driver_file(1, f1.to_str().unwrap()),
732            &mut writer,
733        )
734        .unwrap();
735
736        assert!(
737            !f1.exists(),
738            "driver file should be deleted in capture mode"
739        );
740    }
741
742    #[test]
743    fn test_ensure_directory() {
744        let fb = NDPluginFileBase::new();
745        // With create_dir=0 and empty path, should be a no-op
746        fb.ensure_directory().unwrap();
747    }
748}