Skip to main content

ad_core_rs/plugin/
file_controller.rs

1use std::path::PathBuf;
2use std::sync::Arc;
3
4use crate::error::{ADError, ADResult};
5use crate::ndarray::{NDArray, NDDataType, NDDimension};
6
7use super::file_base::{NDFileMode, NDFileWriter, NDPluginFileBase};
8use super::runtime::{
9    ParamChangeResult, ParamChangeValue, ParamUpdate, PluginParamSnapshot, ProcessResult,
10};
11
12/// Param indices for file plugin control (looked up once at registration time).
13#[derive(Default)]
14pub struct FileParamIndices {
15    pub file_path: Option<usize>,
16    pub file_name: Option<usize>,
17    pub file_number: Option<usize>,
18    pub file_template: Option<usize>,
19    pub auto_increment: Option<usize>,
20    pub write_file: Option<usize>,
21    pub read_file: Option<usize>,
22    pub write_mode: Option<usize>,
23    pub num_capture: Option<usize>,
24    pub capture: Option<usize>,
25    pub auto_save: Option<usize>,
26    pub create_dir: Option<usize>,
27    pub file_path_exists: Option<usize>,
28    pub write_status: Option<usize>,
29    pub write_message: Option<usize>,
30    pub full_file_name: Option<usize>,
31    pub file_temp_suffix: Option<usize>,
32    pub num_captured: Option<usize>,
33    pub lazy_open: Option<usize>,
34    pub delete_driver_file: Option<usize>,
35    pub free_capture: Option<usize>,
36    /// ARRAY_COUNTER — overridden by the file plugin so it counts only
37    /// saved frames, not every callback (G10).
38    pub array_counter: Option<usize>,
39}
40
41/// Generic file plugin controller that wraps any NDFileWriter with the full
42/// C ADCore NDPluginFile control-plane logic: auto_save, capture, stream,
43/// temp_suffix rename, create_dir, param updates, error reporting.
44///
45/// Each file format plugin (TIFF, HDF5, JPEG) creates one of these and
46/// delegates `process_array`, `register_params`, and `on_param_change` to it.
47///
48/// # Capture state invariant (B8)
49///
50/// MUST: `capture_active`, `file_base.capture_buffer` / `file_base.is_open`,
51/// `file_base.num_captured` and the `CAPTURE` PV are only mutated together,
52/// and only through `start_capture()` / `stop_capture()`. No other method may
53/// flip `capture_active` directly. This makes capture start/stop a single
54/// owned transition so a mode switch or buffer-full event cannot leave the
55/// state inconsistent (e.g. a stream open while `capture_active` is false).
56pub struct FilePluginController<W: NDFileWriter> {
57    pub file_base: NDPluginFileBase,
58    pub writer: W,
59    pub params: FileParamIndices,
60    pub auto_save: bool,
61    /// Capture/stream-in-progress flag. INVARIANT: only `start_capture` /
62    /// `stop_capture` may write this (B8).
63    pub capture_active: bool,
64    pub lazy_open: bool,
65    pub delete_driver_file: bool,
66    pub latest_array: Option<Arc<NDArray>>,
67    /// Recorded dimensions of the first captured/streamed frame, for
68    /// `isFrameValid` validation (G12 — applies to Capture and Stream).
69    stream_dims: Option<Vec<usize>>,
70    /// Recorded data type of the first captured/streamed frame.
71    stream_data_type: Option<NDDataType>,
72    /// This plugin's asyn port name, for FilePluginDestination matching (G9).
73    port_name: String,
74    /// Count of frames actually saved to disk. G10: ArrayCounter on a file
75    /// plugin must count saved frames, not every callback — the runtime bumps
76    /// ArrayCounter per callback, so the controller overrides it with this.
77    saved_frames: i32,
78}
79
80impl<W: NDFileWriter> FilePluginController<W> {
81    pub fn new(writer: W) -> Self {
82        Self {
83            file_base: NDPluginFileBase::new(),
84            writer,
85            params: FileParamIndices::default(),
86            auto_save: false,
87            capture_active: false,
88            lazy_open: false,
89            delete_driver_file: false,
90            latest_array: None,
91            stream_dims: None,
92            stream_data_type: None,
93            port_name: String::new(),
94            saved_frames: 0,
95        }
96    }
97
98    /// Set this plugin's asyn port name (used for FilePluginDestination
99    /// routing, G9). Called once during plugin construction.
100    pub fn set_port_name(&mut self, name: impl Into<String>) {
101        self.port_name = name.into();
102    }
103
104    /// Look up all standard file param indices from the port driver base.
105    pub fn register_params(
106        &mut self,
107        base: &mut asyn_rs::port::PortDriverBase,
108    ) -> asyn_rs::error::AsynResult<()> {
109        self.params.file_path = base.find_param("FILE_PATH");
110        self.params.file_name = base.find_param("FILE_NAME");
111        self.params.file_number = base.find_param("FILE_NUMBER");
112        self.params.file_template = base.find_param("FILE_TEMPLATE");
113        self.params.auto_increment = base.find_param("AUTO_INCREMENT");
114        self.params.write_file = base.find_param("WRITE_FILE");
115        self.params.read_file = base.find_param("READ_FILE");
116        self.params.write_mode = base.find_param("WRITE_MODE");
117        self.params.num_capture = base.find_param("NUM_CAPTURE");
118        self.params.capture = base.find_param("CAPTURE");
119        self.params.auto_save = base.find_param("AUTO_SAVE");
120        self.params.create_dir = base.find_param("CREATE_DIR");
121        self.params.file_path_exists = base.find_param("FILE_PATH_EXISTS");
122        self.params.write_status = base.find_param("WRITE_STATUS");
123        self.params.write_message = base.find_param("WRITE_MESSAGE");
124        self.params.full_file_name = base.find_param("FULL_FILE_NAME");
125        self.params.file_temp_suffix = base.find_param("FILE_TEMP_SUFFIX");
126        self.params.num_captured = base.find_param("NUM_CAPTURED");
127        self.params.lazy_open = base.find_param("FILE_LAZY_OPEN");
128        self.params.delete_driver_file = base.find_param("DELETE_DRIVER_FILE");
129        self.params.free_capture = base.find_param("FREE_CAPTURE");
130        self.params.array_counter = base.find_param("ARRAY_COUNTER");
131        Ok(())
132    }
133
134    // ── capture state owner (B8) ──
135
136    /// Start capture/stream (single owner of the capture-state transition).
137    ///
138    /// B8: clears the capture buffer, resets validation state, sets
139    /// `capture_active`, and emits the `CAPTURE`/`NUM_CAPTURED` PV updates.
140    /// B9: for a non-lazy Stream plugin whose writer supports multiple arrays
141    /// the file is opened eagerly here (C++ `doCapture`) so a bad path is
142    /// reported at capture-start; a lazy plugin defers to the first frame.
143    fn start_capture(&mut self, updates: &mut Vec<ParamUpdate>) -> ADResult<()> {
144        self.file_base.clear_capture();
145        self.stream_dims = None;
146        self.stream_data_type = None;
147        self.file_base.lazy_open = self.lazy_open;
148        self.file_base.delete_driver_file = self.delete_driver_file;
149
150        if self.file_base.mode() == NDFileMode::Stream
151            && !self.lazy_open
152            && self.writer.supports_multiple_arrays()
153        {
154            // B9: eager open — needs a frame to know the layout.
155            if let Some(array) = self.latest_array.clone() {
156                self.file_base.open_stream_eager(&mut self.writer, &array)?;
157            }
158        }
159        self.capture_active = true;
160        self.push_capture_update(updates);
161        self.push_num_captured_update(updates);
162        Ok(())
163    }
164
165    /// Stop capture/stream (single owner of the capture-state transition).
166    ///
167    /// B8/B17: closes any open stream file, clears `capture_active`, and
168    /// emits the `CAPTURE` PV update. Idempotent.
169    fn stop_capture(&mut self, updates: &mut Vec<ParamUpdate>) -> ADResult<()> {
170        if self.file_base.mode() == NDFileMode::Stream {
171            self.file_base.close_stream(&mut self.writer)?;
172        }
173        self.capture_active = false;
174        self.stream_dims = None;
175        self.stream_data_type = None;
176        self.push_capture_update(updates);
177        Ok(())
178    }
179
180    /// `isFrameValid` (C++ NDPluginFile.cpp:665): a frame is valid if its
181    /// dimensions and data type match the first frame of the capture/stream.
182    /// G12: applies to Capture mode as well as Stream.
183    fn frame_valid(&mut self, array: &NDArray) -> bool {
184        let frame_dims: Vec<usize> = array.dims.iter().map(|d| d.size).collect();
185        let frame_dtype = array.data.data_type();
186        match (&self.stream_dims, self.stream_data_type) {
187            (Some(dims), Some(dtype)) => &frame_dims == dims && frame_dtype == dtype,
188            _ => {
189                self.stream_dims = Some(frame_dims);
190                self.stream_data_type = Some(frame_dtype);
191                true
192            }
193        }
194    }
195
196    /// G9: decide whether this plugin should process the frame, based on the
197    /// `FilePluginDestination` attribute (C++ `attrIsProcessingRequired`).
198    /// If the attribute is set and is neither "all" nor this plugin's port
199    /// name, the frame is not for this plugin.
200    fn destination_matches(&self, array: &NDArray) -> bool {
201        match array
202            .attributes
203            .get("FilePluginDestination")
204            .and_then(|attr| attr.value.as_string_typed())
205        {
206            // C `getValueInfo` only runs the compare when the attribute is
207            // string-typed; a numeric attribute is read as `ND_ERROR` and the
208            // frame is processed (treated as "no destination set").
209            Some(dest) => {
210                // C runs the compare for any non-empty string (getValueInfo
211                // size = strlen+1, guarded `> 1`).
212                if dest.is_empty() {
213                    return true;
214                }
215                // C tests "all" with `epicsStrnCaseCmp(dest,"all",min(len,3))`
216                // — a 3-char (or shorter, for 1-2 char dest) prefix match — and
217                // the port name with a full-length compare.
218                let prefix = dest.len().min(3);
219                let matches_all = dest.as_bytes()[..prefix].eq_ignore_ascii_case(&b"all"[..prefix]);
220                matches_all || dest.eq_ignore_ascii_case(&self.port_name)
221            }
222            None => true,
223        }
224    }
225
226    /// Re-evaluate whether the current `FilePath` directory exists and push a
227    /// `FilePathExists` param update. C++ `NDPluginFile::checkPath()` runs this
228    /// before each write, not only when FilePath changes.
229    fn refresh_file_path_exists(&mut self, updates: &mut Vec<ParamUpdate>) {
230        let idx = match self.params.file_path_exists {
231            Some(idx) => idx,
232            None => return,
233        };
234        let (normalized, exists) = check_file_path(&self.file_base.file_path);
235        self.file_base.file_path = normalized;
236        updates.push(ParamUpdate::Int32 {
237            reason: idx,
238            addr: 0,
239            value: if exists { 1 } else { 0 },
240        });
241    }
242
243    /// G9: apply the `FilePluginFileName` / `FilePluginFileNumber` attributes
244    /// to the file base, returning param updates for the changed PVs and
245    /// whether a mid-stream file reopen is required (C++ `attrFileNameSet` /
246    /// `attrFileNameCheck`).
247    fn apply_filename_attributes(
248        &mut self,
249        array: &NDArray,
250        updates: &mut Vec<ParamUpdate>,
251    ) -> bool {
252        let mut reopen = false;
253        // C `attrFileNameSet` guards on `attrDataType == NDAttrString` before
254        // touching NDFileName; a numeric FilePluginFileName attribute is ignored
255        // (the filename is not redefined to its decimal rendering).
256        if let Some(name) = array
257            .attributes
258            .get("FilePluginFileName")
259            .and_then(|attr| attr.value.as_string_typed())
260        {
261            if !name.is_empty() && name != self.file_base.file_name {
262                self.file_base.file_name = name.to_string();
263                reopen = true;
264                if let Some(idx) = self.params.file_name {
265                    updates.push(ParamUpdate::Octet {
266                        reason: idx,
267                        addr: 0,
268                        value: name.to_string(),
269                    });
270                }
271            }
272        }
273        if let Some(attr) = array.attributes.get("FilePluginFileNumber") {
274            if let Some(num) = attr.value.as_i64() {
275                let num = num as i32;
276                if num != self.file_base.file_number {
277                    self.file_base.file_number = num;
278                    self.file_base.auto_increment = false; // C parity
279                    reopen = true;
280                    if let Some(idx) = self.params.file_number {
281                        updates.push(ParamUpdate::Int32 {
282                            reason: idx,
283                            addr: 0,
284                            value: num,
285                        });
286                    }
287                }
288            }
289        }
290        reopen
291    }
292
293    /// Process an incoming array: auto_save, capture buffering, stream write.
294    pub fn process_array(&mut self, array: &NDArray) -> ProcessResult {
295        let mut proc_result = ProcessResult::empty();
296        let array = Arc::new(array.clone());
297        self.latest_array = Some(array.clone());
298
299        // G9: FilePluginDestination routing — skip frames not for this plugin.
300        if !self.destination_matches(&array) {
301            return proc_result;
302        }
303
304        // Re-check file-path existence before every write. C++ NDPluginFile
305        // calls checkPath() before each write so a directory deleted after
306        // FilePath was set is reflected; the Rust port previously updated
307        // FilePathExists only on the FilePath param change.
308        self.refresh_file_path_exists(&mut proc_result.param_updates);
309
310        // G9: FilePluginClose attribute forces an immediate file close.
311        let force_close = array
312            .attributes
313            .get("FilePluginClose")
314            .and_then(|a| a.value.as_i64())
315            .map(|v| v != 0)
316            .unwrap_or(false);
317        if force_close {
318            if let Err(e) = self.file_base.force_close(&mut self.writer) {
319                return ProcessResult::sink(self.error_updates(false, false, e.to_string()));
320            }
321            let _ = self.stop_capture(&mut proc_result.param_updates);
322            return proc_result;
323        }
324
325        let result = match self.file_base.mode() {
326            NDFileMode::Single => {
327                if self.auto_save {
328                    let r = self.write_single(array);
329                    if r.is_ok() {
330                        self.saved_frames += 1; // G10: count saved frame
331                    }
332                    r
333                } else {
334                    Ok(())
335                }
336            }
337            NDFileMode::Capture => {
338                if self.capture_active {
339                    // G12: validate frame dims/dtype in Capture mode too.
340                    if !self.frame_valid(&array) {
341                        return proc_result;
342                    }
343                    self.file_base.capture_array(array);
344                    self.push_num_captured_update(&mut proc_result.param_updates);
345                    let target = self.file_base.num_capture_target();
346                    if target > 0 && self.file_base.num_captured() >= target {
347                        if self.auto_save {
348                            let to_save = self.file_base.num_captured() as i32;
349                            if let Err(err) = self.file_base.flush_capture(&mut self.writer) {
350                                Err(err)
351                            } else {
352                                self.saved_frames += to_save; // G10
353                                self.push_full_file_name_update(&mut proc_result.param_updates);
354                                self.push_num_captured_update(&mut proc_result.param_updates);
355                                self.stop_capture(&mut proc_result.param_updates).ok();
356                                Ok(())
357                            }
358                        } else {
359                            self.stop_capture(&mut proc_result.param_updates).ok();
360                            Ok(())
361                        }
362                    } else {
363                        Ok(())
364                    }
365                } else {
366                    Ok(())
367                }
368            }
369            NDFileMode::Stream => {
370                if self.capture_active {
371                    // G12: validate frame dims/dtype against the first frame.
372                    if !self.frame_valid(&array) {
373                        return proc_result;
374                    }
375                    // G9: attribute-driven filename override / mid-stream reopen.
376                    let reopen =
377                        self.apply_filename_attributes(&array, &mut proc_result.param_updates);
378                    if reopen && self.file_base.is_open() {
379                        if let Err(e) = self.file_base.force_close(&mut self.writer) {
380                            return ProcessResult::sink(self.error_updates(
381                                false,
382                                false,
383                                e.to_string(),
384                            ));
385                        }
386                    }
387                    let r = self.file_base.process_array(array, &mut self.writer);
388                    if r.is_ok() {
389                        self.saved_frames += 1; // G10: count saved frame
390                        // C++ NDPluginFile::processCallbacks publishes
391                        // NDFileNumCaptured after every successful stream
392                        // write — without this an unbounded stream
393                        // (NumCapture=0) reports NumCaptured_RBV=0 forever.
394                        self.push_num_captured_update(&mut proc_result.param_updates);
395                    }
396                    let target = self.file_base.num_capture_target();
397                    if r.is_ok() && target > 0 && self.file_base.num_captured() >= target {
398                        if let Err(e) = self.file_base.close_stream(&mut self.writer) {
399                            return ProcessResult::sink(self.error_updates(
400                                false,
401                                false,
402                                e.to_string(),
403                            ));
404                        }
405                        self.stop_capture(&mut proc_result.param_updates).ok();
406                        self.push_full_file_name_update(&mut proc_result.param_updates);
407                    }
408                    r
409                } else {
410                    Ok(())
411                }
412            }
413        };
414
415        if result.is_ok() {
416            proc_result.param_updates.extend(self.success_updates());
417            if self.file_base.mode() == NDFileMode::Single && self.auto_save {
418                self.push_full_file_name_update(&mut proc_result.param_updates);
419            }
420            if self.file_base.mode() == NDFileMode::Stream && self.capture_active {
421                self.push_full_file_name_update(&mut proc_result.param_updates);
422            }
423            // G10: override ArrayCounter (the runtime bumped it per callback)
424            // with the saved-frame count so a file plugin's ArrayCounter_RBV
425            // reflects frames actually written, not callbacks received.
426            if let Some(idx) = self.params.array_counter {
427                proc_result.param_updates.push(ParamUpdate::Int32 {
428                    reason: idx,
429                    addr: 0,
430                    value: self.saved_frames,
431                });
432            }
433        } else if let Err(err) = result {
434            proc_result.param_updates = self.error_updates(false, false, err.to_string());
435        }
436        proc_result
437    }
438
439    /// Handle a control-plane param change. Returns true if the reason was handled.
440    pub fn on_param_change(
441        &mut self,
442        reason: usize,
443        params: &PluginParamSnapshot,
444    ) -> ParamChangeResult {
445        let mut updates = Vec::new();
446
447        if Some(reason) == self.params.file_path {
448            if let ParamChangeValue::Octet(s) = &params.value {
449                let (normalized, exists) = check_file_path(s);
450                self.file_base.file_path = normalized;
451                if let Some(idx) = self.params.file_path_exists {
452                    updates.push(ParamUpdate::Int32 {
453                        reason: idx,
454                        addr: 0,
455                        value: if exists { 1 } else { 0 },
456                    });
457                }
458            }
459        } else if Some(reason) == self.params.file_name {
460            if let ParamChangeValue::Octet(s) = &params.value {
461                self.file_base.file_name = s.clone();
462            }
463        } else if Some(reason) == self.params.file_number {
464            self.file_base.file_number = params.value.as_i32();
465        } else if Some(reason) == self.params.file_template {
466            if let ParamChangeValue::Octet(s) = &params.value {
467                self.file_base.file_template = s.clone();
468            }
469        } else if Some(reason) == self.params.auto_increment {
470            self.file_base.auto_increment = params.value.as_i32() != 0;
471        } else if Some(reason) == self.params.auto_save {
472            self.auto_save = params.value.as_i32() != 0;
473        } else if Some(reason) == self.params.write_mode {
474            // B17: WriteMode transitions are gated through stop_capture so a
475            // mode switch mid-capture cannot leave a stream file open.
476            let new_mode = NDFileMode::from_i32(params.value.as_i32());
477            if self.capture_active && new_mode != self.file_base.mode() {
478                if let Err(e) = self.stop_capture(&mut updates) {
479                    return ParamChangeResult::updates(self.error_updates(
480                        false,
481                        false,
482                        e.to_string(),
483                    ));
484                }
485            }
486            self.file_base.set_mode(new_mode);
487        } else if Some(reason) == self.params.num_capture {
488            // B7: numCapture==0 means "capture forever" — C++ buffers
489            // indefinitely. Do not force a minimum of 1.
490            self.file_base
491                .set_num_capture(params.value.as_i32().max(0) as usize);
492        } else if Some(reason) == self.params.create_dir {
493            self.file_base.create_dir = params.value.as_i32();
494        } else if Some(reason) == self.params.file_temp_suffix {
495            if let ParamChangeValue::Octet(s) = &params.value {
496                self.file_base.temp_suffix = s.clone();
497            }
498        } else if Some(reason) == self.params.write_file {
499            if params.value.as_i32() != 0 {
500                let result = match self.file_base.mode() {
501                    NDFileMode::Single => {
502                        if let Some(array) = self.latest_array.clone() {
503                            self.write_single(array)
504                        } else {
505                            Err(ADError::UnsupportedConversion(
506                                "no array available for write".into(),
507                            ))
508                        }
509                    }
510                    NDFileMode::Capture => self.file_base.flush_capture(&mut self.writer),
511                    NDFileMode::Stream => {
512                        if let Some(array) = self.latest_array.clone() {
513                            self.file_base.process_array(array, &mut self.writer)
514                        } else {
515                            Err(ADError::UnsupportedConversion(
516                                "no array available for write".into(),
517                            ))
518                        }
519                    }
520                };
521                match result {
522                    Ok(()) => {
523                        updates.extend(self.success_updates());
524                        self.push_num_captured_update(&mut updates);
525                        self.push_full_file_name_update(&mut updates);
526                    }
527                    Err(err) => {
528                        return ParamChangeResult::updates(self.error_updates(
529                            false,
530                            true,
531                            err.to_string(),
532                        ));
533                    }
534                }
535            }
536        } else if Some(reason) == self.params.read_file {
537            if params.value.as_i32() != 0 {
538                let result = (|| -> ADResult<Arc<NDArray>> {
539                    let path = PathBuf::from(self.file_base.create_file_name());
540                    self.writer.open_file(
541                        &path,
542                        NDFileMode::Single,
543                        &NDArray::new(vec![NDDimension::new(1)], NDDataType::UInt8),
544                    )?;
545                    let array = Arc::new(self.writer.read_file()?);
546                    self.writer.close_file()?;
547                    self.latest_array = Some(array.clone());
548                    Ok(array)
549                })();
550                match result {
551                    Ok(array) => {
552                        updates.extend(self.success_updates());
553                        self.push_full_file_name_update(&mut updates);
554                        return ParamChangeResult::combined(vec![array], updates);
555                    }
556                    Err(err) => {
557                        return ParamChangeResult::updates(self.error_updates(
558                            true,
559                            false,
560                            err.to_string(),
561                        ));
562                    }
563                }
564            }
565        } else if Some(reason) == self.params.lazy_open {
566            self.lazy_open = params.value.as_i32() != 0;
567        } else if Some(reason) == self.params.delete_driver_file {
568            self.delete_driver_file = params.value.as_i32() != 0;
569        } else if Some(reason) == self.params.free_capture {
570            if params.value.as_i32() != 0 {
571                self.file_base.clear_capture();
572                self.push_num_captured_update(&mut updates);
573            }
574        } else if Some(reason) == self.params.capture {
575            // B8: capture start/stop routes through the single owner.
576            if params.value.as_i32() != 0 {
577                if self.file_base.mode() == NDFileMode::Single {
578                    // Capture is invalid in Single mode — leave it stopped.
579                    let _ = self.stop_capture(&mut updates);
580                    return ParamChangeResult::updates(self.error_updates(
581                        false,
582                        false,
583                        "ERROR: capture not supported in Single mode".into(),
584                    ));
585                }
586                if let Err(e) = self.start_capture(&mut updates) {
587                    return ParamChangeResult::updates(self.error_updates(
588                        false,
589                        false,
590                        e.to_string(),
591                    ));
592                }
593            } else if let Err(e) = self.stop_capture(&mut updates) {
594                return ParamChangeResult::updates(self.error_updates(false, false, e.to_string()));
595            }
596        }
597
598        ParamChangeResult::updates(updates)
599    }
600
601    // ── helpers ──
602
603    fn write_single(&mut self, array: Arc<NDArray>) -> ADResult<()> {
604        self.file_base.ensure_directory()?;
605        self.file_base.process_array(array, &mut self.writer)
606    }
607
608    fn success_updates(&self) -> Vec<ParamUpdate> {
609        let mut updates = Vec::new();
610        if let Some(idx) = self.params.file_number {
611            updates.push(ParamUpdate::Int32 {
612                reason: idx,
613                addr: 0,
614                value: self.file_base.file_number,
615            });
616        }
617        if let Some(idx) = self.params.write_status {
618            updates.push(ParamUpdate::Int32 {
619                reason: idx,
620                addr: 0,
621                value: 0,
622            });
623        }
624        if let Some(idx) = self.params.write_message {
625            updates.push(ParamUpdate::Octet {
626                reason: idx,
627                addr: 0,
628                value: String::new(),
629            });
630        }
631        if let Some(idx) = self.params.write_file {
632            updates.push(ParamUpdate::Int32 {
633                reason: idx,
634                addr: 0,
635                value: 0,
636            });
637        }
638        if let Some(idx) = self.params.capture {
639            updates.push(ParamUpdate::Int32 {
640                reason: idx,
641                addr: 0,
642                value: if self.capture_active { 1 } else { 0 },
643            });
644        }
645        if let Some(idx) = self.params.read_file {
646            updates.push(ParamUpdate::Int32 {
647                reason: idx,
648                addr: 0,
649                value: 0,
650            });
651        }
652        updates
653    }
654
655    /// Emit the `CAPTURE` PV update reflecting the current `capture_active`
656    /// flag (B8 — the single owner of the capture-state transition).
657    fn push_capture_update(&self, updates: &mut Vec<ParamUpdate>) {
658        if let Some(idx) = self.params.capture {
659            updates.push(ParamUpdate::Int32 {
660                reason: idx,
661                addr: 0,
662                value: if self.capture_active { 1 } else { 0 },
663            });
664        }
665    }
666
667    fn push_num_captured_update(&self, updates: &mut Vec<ParamUpdate>) {
668        if let Some(idx) = self.params.num_captured {
669            updates.push(ParamUpdate::Int32 {
670                reason: idx,
671                addr: 0,
672                value: self.file_base.num_captured() as i32,
673            });
674        }
675    }
676
677    fn push_full_file_name_update(&self, updates: &mut Vec<ParamUpdate>) {
678        if let Some(idx) = self.params.full_file_name {
679            updates.push(ParamUpdate::Octet {
680                reason: idx,
681                addr: 0,
682                value: self.file_base.last_written_name().to_string(),
683            });
684        }
685    }
686
687    fn error_updates(
688        &self,
689        read_reason: bool,
690        write_reason: bool,
691        message: String,
692    ) -> Vec<ParamUpdate> {
693        let mut updates = Vec::new();
694        if write_reason {
695            if let Some(idx) = self.params.write_file {
696                updates.push(ParamUpdate::Int32 {
697                    reason: idx,
698                    addr: 0,
699                    value: 0,
700                });
701            }
702        }
703        if read_reason {
704            if let Some(idx) = self.params.read_file {
705                updates.push(ParamUpdate::Int32 {
706                    reason: idx,
707                    addr: 0,
708                    value: 0,
709                });
710            }
711        }
712        if let Some(idx) = self.params.write_status {
713            updates.push(ParamUpdate::Int32 {
714                reason: idx,
715                addr: 0,
716                value: 1,
717            });
718        }
719        if let Some(idx) = self.params.write_message {
720            updates.push(ParamUpdate::Octet {
721                reason: idx,
722                addr: 0,
723                value: message,
724            });
725        }
726        updates
727    }
728}
729
730/// Normalize `path` and report whether the directory exists, through the one
731/// implementation of C++ `asynNDArrayDriver::checkPath` that the file plugins
732/// inherit (`NDPluginFile` -> `NDPluginDriver` -> `asynNDArrayDriver`).
733fn check_file_path(path: &str) -> (String, bool) {
734    let mut normalized = path.to_string();
735    let exists = crate::driver::ndarray_driver::check_path_str(&mut normalized);
736    (normalized, exists)
737}
738
739#[cfg(test)]
740mod tests {
741    use super::*;
742    use crate::attributes::{NDAttrSource, NDAttrValue, NDAttribute};
743    use crate::ndarray::{NDArray, NDDataType, NDDimension};
744    use std::path::Path;
745
746    /// Mock writer that records operations.
747    struct MockWriter {
748        opens: usize,
749        writes: usize,
750        closes: usize,
751        multi: bool,
752    }
753    impl MockWriter {
754        fn new(multi: bool) -> Self {
755            Self {
756                opens: 0,
757                writes: 0,
758                closes: 0,
759                multi,
760            }
761        }
762    }
763    impl NDFileWriter for MockWriter {
764        fn open_file(&mut self, _p: &Path, _m: NDFileMode, _a: &NDArray) -> ADResult<()> {
765            self.opens += 1;
766            Ok(())
767        }
768        fn write_file(&mut self, _a: &NDArray) -> ADResult<()> {
769            self.writes += 1;
770            Ok(())
771        }
772        fn read_file(&mut self) -> ADResult<NDArray> {
773            Err(ADError::UnsupportedConversion("n/a".into()))
774        }
775        fn close_file(&mut self) -> ADResult<()> {
776            self.closes += 1;
777            Ok(())
778        }
779        fn supports_multiple_arrays(&self) -> bool {
780            self.multi
781        }
782    }
783
784    fn array(id: i32) -> NDArray {
785        let mut a = NDArray::new(vec![NDDimension::new(4)], NDDataType::UInt8);
786        a.unique_id = id;
787        a
788    }
789
790    fn with_str_attr(mut a: NDArray, name: &str, val: &str) -> NDArray {
791        a.attributes.add(NDAttribute::new_static(
792            name,
793            "",
794            NDAttrSource::Driver,
795            NDAttrValue::String(val.to_string()),
796        ));
797        a
798    }
799
800    fn with_i32_attr(mut a: NDArray, name: &str, val: i32) -> NDArray {
801        a.attributes.add(NDAttribute::new_static(
802            name,
803            "",
804            NDAttrSource::Driver,
805            NDAttrValue::Int32(val),
806        ));
807        a
808    }
809
810    #[test]
811    fn test_g9_destination_routing_skips_other_port() {
812        // G9: a frame addressed to another plugin via FilePluginDestination
813        // is not written by this plugin.
814        let mut c = FilePluginController::new(MockWriter::new(true));
815        c.set_port_name("MYFILE");
816        c.file_base.set_mode(NDFileMode::Single);
817        c.auto_save = true;
818
819        // Destination = a different port → skipped.
820        c.process_array(&with_str_attr(array(1), "FilePluginDestination", "OTHER"));
821        assert_eq!(c.writer.writes, 0, "frame for OTHER port must be skipped");
822
823        // Destination = this port → written.
824        c.process_array(&with_str_attr(array(2), "FilePluginDestination", "MYFILE"));
825        assert_eq!(c.writer.writes, 1);
826
827        // Destination = "all" → written.
828        c.process_array(&with_str_attr(array(3), "FilePluginDestination", "all"));
829        assert_eq!(c.writer.writes, 2);
830    }
831
832    #[test]
833    fn test_g9_numeric_destination_attr_processed_not_stringified() {
834        // ADC-11 parity: C `attrIsProcessingRequired` only compares a
835        // *string-typed* FilePluginDestination (getValueInfo guard); a numeric
836        // attribute reads as ND_ERROR and the frame is processed. The Rust port
837        // must not stringify the numeric (42 -> "42") and then skip the frame.
838        let mut c = FilePluginController::new(MockWriter::new(true));
839        c.set_port_name("MYFILE");
840        c.file_base.set_mode(NDFileMode::Single);
841        c.auto_save = true;
842
843        c.process_array(&with_i32_attr(array(1), "FilePluginDestination", 42));
844        assert_eq!(
845            c.writer.writes, 1,
846            "numeric FilePluginDestination must be ignored (frame processed), \
847             not stringified to \"42\" and skipped"
848        );
849    }
850
851    #[test]
852    fn test_g9_numeric_filename_attr_ignored() {
853        // ADC-11 parity: C `attrFileNameSet` only adopts a string-typed
854        // FilePluginFileName; a numeric attribute leaves NDFileName unchanged
855        // and forces no reopen.
856        let mut c = FilePluginController::new(MockWriter::new(true));
857        c.set_port_name("F");
858        let before = c.file_base.file_name.clone();
859        let mut updates = Vec::new();
860        let reopen = c.apply_filename_attributes(
861            &with_i32_attr(array(1), "FilePluginFileName", 7),
862            &mut updates,
863        );
864        assert!(
865            !reopen,
866            "numeric FilePluginFileName must not force a reopen"
867        );
868        assert_eq!(
869            c.file_base.file_name, before,
870            "numeric FilePluginFileName must not redefine the filename"
871        );
872        assert!(
873            updates.is_empty(),
874            "numeric FilePluginFileName must not post a FileName param update"
875        );
876    }
877
878    #[test]
879    fn test_g9_destination_compare_matches_c_attr_is_processing_required() {
880        // ADC-12: replicate C `attrIsProcessingRequired` comparison exactly —
881        // "all" is a 3-char prefix match and any non-empty string is compared
882        // (no blanket `len <= 1 -> process`).
883        let mut c = FilePluginController::new(MockWriter::new(true));
884        c.set_port_name("MYFILE");
885        c.file_base.set_mode(NDFileMode::Single);
886        c.auto_save = true;
887
888        // "all" 3-char prefix (C: epicsStrnCaseCmp(dest,"all",3)==0) → processed.
889        c.process_array(&with_str_attr(array(1), "FilePluginDestination", "allfoo"));
890        assert_eq!(
891            c.writer.writes, 1,
892            "destination with \"all\" prefix is processed (C 3-char prefix match)"
893        );
894
895        // Non-empty 1-char destination that is neither an "all" prefix nor the
896        // port name → skipped (C compares it; Rust must not blanket-process).
897        c.process_array(&with_str_attr(array(2), "FilePluginDestination", "x"));
898        assert_eq!(
899            c.writer.writes, 1,
900            "1-char non-matching destination is skipped, not blanket-processed"
901        );
902    }
903
904    #[test]
905    fn test_g9_file_close_attribute_forces_close() {
906        // G9: a FilePluginClose attribute forces an open stream to close.
907        let mut c = FilePluginController::new(MockWriter::new(true));
908        c.set_port_name("F");
909        c.file_base.set_mode(NDFileMode::Stream);
910        c.file_base.set_num_capture(10);
911        c.lazy_open = true;
912        let mut updates = Vec::new();
913        c.process_array(&array(1)); // cache an array
914        c.start_capture(&mut updates).unwrap();
915        let _ = &updates;
916        c.process_array(&array(2)); // opens + writes
917        assert!(c.file_base.is_open());
918
919        c.process_array(&with_i32_attr(array(3), "FilePluginClose", 1));
920        assert!(
921            !c.file_base.is_open(),
922            "FilePluginClose must close the file"
923        );
924        assert!(!c.capture_active, "close attribute stops capture");
925    }
926
927    #[test]
928    fn test_b8_capture_owner_round_trip() {
929        // B8: start_capture / stop_capture own the capture state together.
930        let mut c = FilePluginController::new(MockWriter::new(true));
931        c.set_port_name("F");
932        c.file_base.set_mode(NDFileMode::Capture);
933        c.params.capture = Some(7);
934        let mut updates = Vec::new();
935        c.start_capture(&mut updates).unwrap();
936        assert!(c.capture_active);
937        c.stop_capture(&mut updates).unwrap();
938        assert!(!c.capture_active);
939        // CAPTURE PV updates emitted on each transition.
940        assert!(!updates.is_empty());
941    }
942
943    #[test]
944    fn test_b9_non_lazy_opens_eagerly_at_capture_start() {
945        // B9: a non-lazy stream plugin opens the file at capture-start.
946        let mut c = FilePluginController::new(MockWriter::new(true));
947        c.set_port_name("F");
948        c.file_base.set_mode(NDFileMode::Stream);
949        c.lazy_open = false;
950        c.process_array(&array(1)); // cache a frame for the layout
951        let mut updates = Vec::new();
952        c.start_capture(&mut updates).unwrap();
953        assert!(
954            c.file_base.is_open(),
955            "non-lazy stream opens at capture start"
956        );
957        assert_eq!(c.writer.opens, 1);
958    }
959
960    #[test]
961    fn test_b9_lazy_defers_open_to_first_frame() {
962        let mut c = FilePluginController::new(MockWriter::new(true));
963        c.set_port_name("F");
964        c.file_base.set_mode(NDFileMode::Stream);
965        c.file_base.set_num_capture(10);
966        c.lazy_open = true;
967        c.process_array(&array(1));
968        let mut updates = Vec::new();
969        c.start_capture(&mut updates).unwrap();
970        assert!(
971            !c.file_base.is_open(),
972            "lazy stream does NOT open at capture start"
973        );
974        c.process_array(&array(2));
975        assert!(c.file_base.is_open(), "lazy stream opens on first frame");
976    }
977
978    #[test]
979    fn test_g12_capture_mode_validates_frames() {
980        // G12: Capture mode rejects frames of mismatched dimensions.
981        let mut c = FilePluginController::new(MockWriter::new(true));
982        c.set_port_name("F");
983        c.file_base.set_mode(NDFileMode::Capture);
984        c.file_base.set_num_capture(10);
985        let mut updates = Vec::new();
986        c.start_capture(&mut updates).unwrap();
987
988        c.process_array(&array(1)); // first frame: 4-element, recorded
989        assert_eq!(c.file_base.num_captured(), 1);
990
991        // Mismatched frame: different dimension size → rejected.
992        let mut big = NDArray::new(vec![NDDimension::new(8)], NDDataType::UInt8);
993        big.unique_id = 2;
994        c.process_array(&big);
995        assert_eq!(c.file_base.num_captured(), 1, "mismatched frame rejected");
996
997        // Matching frame: accepted.
998        c.process_array(&array(3));
999        assert_eq!(c.file_base.num_captured(), 2);
1000    }
1001
1002    #[test]
1003    fn test_b17_write_mode_switch_closes_open_stream() {
1004        // B17: switching WriteMode mid-stream closes the open file.
1005        let mut c = FilePluginController::new(MockWriter::new(true));
1006        c.set_port_name("F");
1007        c.file_base.set_mode(NDFileMode::Stream);
1008        c.file_base.set_num_capture(10);
1009        c.params.write_mode = Some(5);
1010        c.lazy_open = false;
1011        c.process_array(&array(1));
1012        let mut updates = Vec::new();
1013        c.start_capture(&mut updates).unwrap();
1014        assert!(c.file_base.is_open());
1015
1016        // Switch to Capture mode while a stream is open.
1017        let snap = PluginParamSnapshot {
1018            enable_callbacks: true,
1019            reason: 5,
1020            addr: 0,
1021            value: ParamChangeValue::Int32(NDFileMode::Capture as i32),
1022        };
1023        c.on_param_change(5, &snap);
1024        assert!(
1025            !c.file_base.is_open(),
1026            "mode switch must close the open stream"
1027        );
1028        assert!(!c.capture_active);
1029    }
1030
1031    #[test]
1032    fn test_b7_capture_num_capture_zero_buffers_forever() {
1033        // B7: num_capture==0 buffers indefinitely (no auto-flush).
1034        let mut c = FilePluginController::new(MockWriter::new(true));
1035        c.set_port_name("F");
1036        c.file_base.set_mode(NDFileMode::Capture);
1037        c.file_base.set_num_capture(0);
1038        c.auto_save = true;
1039        let mut updates = Vec::new();
1040        c.start_capture(&mut updates).unwrap();
1041        for id in 1..=5 {
1042            c.process_array(&array(id));
1043        }
1044        assert_eq!(
1045            c.file_base.num_captured(),
1046            5,
1047            "all frames buffered, no flush"
1048        );
1049        assert_eq!(c.writer.writes, 0, "num_capture==0 never auto-flushes");
1050        assert!(c.capture_active, "still capturing");
1051    }
1052
1053    #[test]
1054    fn stream_mode_publishes_num_captured_per_frame() {
1055        // C++ NDPluginFile::processCallbacks sets NDFileNumCaptured after
1056        // every successful stream write. With NumCapture=0 (unbounded — the
1057        // ophyd-async writer convention) the target-reached branch never
1058        // runs, so a per-frame update is the only way NumCaptured_RBV moves.
1059        let mut c = FilePluginController::new(MockWriter::new(true));
1060        c.set_port_name("F");
1061        c.params.num_captured = Some(42);
1062        c.file_base.set_mode(NDFileMode::Stream);
1063        c.file_base.set_num_capture(0);
1064        let mut updates = Vec::new();
1065        c.process_array(&array(1)); // latest_array for the eager open
1066        c.start_capture(&mut updates).unwrap();
1067        for (id, expected) in [(2, 1), (3, 2)] {
1068            let r = c.process_array(&array(id));
1069            let published = r.param_updates.iter().find_map(|u| match u {
1070                ParamUpdate::Int32 {
1071                    reason: 42, value, ..
1072                } => Some(*value),
1073                _ => None,
1074            });
1075            assert_eq!(
1076                published,
1077                Some(expected),
1078                "frame {id}: NUM_CAPTURED update missing or wrong"
1079            );
1080        }
1081    }
1082
1083    #[test]
1084    fn test_g10_array_counter_counts_saved_frames() {
1085        // G10: ArrayCounter override reflects saved frames.
1086        let mut c = FilePluginController::new(MockWriter::new(false));
1087        c.set_port_name("F");
1088        c.params.array_counter = Some(99);
1089        c.file_base.set_mode(NDFileMode::Single);
1090        c.auto_save = true;
1091        let r1 = c.process_array(&array(1));
1092        let counter1 = r1.param_updates.iter().find_map(|u| match u {
1093            ParamUpdate::Int32 {
1094                reason: 99, value, ..
1095            } => Some(*value),
1096            _ => None,
1097        });
1098        assert_eq!(counter1, Some(1), "first saved frame → ArrayCounter 1");
1099        let r2 = c.process_array(&array(2));
1100        let counter2 = r2.param_updates.iter().find_map(|u| match u {
1101            ParamUpdate::Int32 {
1102                reason: 99, value, ..
1103            } => Some(*value),
1104            _ => None,
1105        });
1106        assert_eq!(counter2, Some(2));
1107    }
1108}