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