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::finalize::Finalize;
6use crate::ndarray::{NDArray, NDDataType, NDDimension};
7
8use super::file_base::{NDFileMode, NDFileWriter, NDPluginFileBase, with_open_file};
9use super::runtime::{
10    ParamChangeResult, ParamChangeValue, ParamUpdate, PluginParamSnapshot, ProcessResult,
11};
12
13/// Param indices for file plugin control (looked up once at registration time).
14#[derive(Default)]
15pub struct FileParamIndices {
16    pub file_path: Option<usize>,
17    pub file_name: Option<usize>,
18    pub file_number: Option<usize>,
19    pub file_template: Option<usize>,
20    pub auto_increment: Option<usize>,
21    pub write_file: Option<usize>,
22    pub read_file: Option<usize>,
23    pub write_mode: Option<usize>,
24    pub num_capture: Option<usize>,
25    pub capture: Option<usize>,
26    pub auto_save: Option<usize>,
27    pub create_dir: Option<usize>,
28    pub file_path_exists: Option<usize>,
29    pub write_status: Option<usize>,
30    pub write_message: Option<usize>,
31    pub full_file_name: Option<usize>,
32    pub file_temp_suffix: Option<usize>,
33    pub num_captured: Option<usize>,
34    pub lazy_open: Option<usize>,
35    pub delete_driver_file: Option<usize>,
36    pub free_capture: Option<usize>,
37    /// ARRAY_COUNTER — overridden by the file plugin so it counts only
38    /// saved frames, not every callback (G10).
39    pub array_counter: Option<usize>,
40}
41
42/// Generic file plugin controller that wraps any NDFileWriter with the full
43/// C ADCore NDPluginFile control-plane logic: auto_save, capture, stream,
44/// temp_suffix rename, create_dir, param updates, error reporting.
45///
46/// Each file format plugin (TIFF, HDF5, JPEG) creates one of these and
47/// delegates `process_array`, `register_params`, and `on_param_change` to it.
48///
49/// # Capture state invariant (B8)
50///
51/// MUST: `capture_active`, `file_base.capture_buffer` / `file_base.is_open`,
52/// `file_base.num_captured` and the `CAPTURE` PV are only mutated together,
53/// and only through `start_capture()` / `stop_capture()`. No other method may
54/// flip `capture_active` directly. This makes capture start/stop a single
55/// owned transition so a mode switch or buffer-full event cannot leave the
56/// state inconsistent (e.g. a stream open while `capture_active` is false).
57pub struct FilePluginController<W: NDFileWriter> {
58    pub file_base: NDPluginFileBase,
59    pub writer: W,
60    pub params: FileParamIndices,
61    pub auto_save: bool,
62    /// Capture/stream-in-progress flag. INVARIANT: only `start_capture` /
63    /// `stop_capture` may write this (B8).
64    pub capture_active: bool,
65    pub lazy_open: bool,
66    pub delete_driver_file: bool,
67    pub latest_array: Option<Arc<NDArray>>,
68    /// Recorded dimensions of the first captured/streamed frame, for
69    /// `isFrameValid` validation (G12 — applies to Capture and Stream).
70    stream_dims: Option<Vec<usize>>,
71    /// Recorded data type of the first captured/streamed frame.
72    stream_data_type: Option<NDDataType>,
73    /// This plugin's asyn port name, for FilePluginDestination matching (G9).
74    port_name: String,
75    /// Count of frames actually saved to disk. G10: ArrayCounter on a file
76    /// plugin must count saved frames, not every callback — the runtime bumps
77    /// ArrayCounter per callback, so the controller overrides it with this.
78    saved_frames: i32,
79}
80
81impl<W: NDFileWriter> FilePluginController<W> {
82    pub fn new(writer: W) -> Self {
83        Self {
84            file_base: NDPluginFileBase::new(),
85            writer,
86            params: FileParamIndices::default(),
87            auto_save: false,
88            capture_active: false,
89            lazy_open: false,
90            delete_driver_file: false,
91            latest_array: None,
92            stream_dims: None,
93            stream_data_type: None,
94            port_name: String::new(),
95            saved_frames: 0,
96        }
97    }
98
99    /// Set this plugin's asyn port name (used for FilePluginDestination
100    /// routing, G9). Called once during plugin construction.
101    pub fn set_port_name(&mut self, name: impl Into<String>) {
102        self.port_name = name.into();
103    }
104
105    /// Look up all standard file param indices from the port driver base.
106    pub fn register_params(
107        &mut self,
108        base: &mut asyn_rs::port::PortDriverBase,
109    ) -> asyn_rs::error::AsynResult<()> {
110        self.params.file_path = base.find_param("FILE_PATH");
111        self.params.file_name = base.find_param("FILE_NAME");
112        self.params.file_number = base.find_param("FILE_NUMBER");
113        self.params.file_template = base.find_param("FILE_TEMPLATE");
114        self.params.auto_increment = base.find_param("AUTO_INCREMENT");
115        self.params.write_file = base.find_param("WRITE_FILE");
116        self.params.read_file = base.find_param("READ_FILE");
117        self.params.write_mode = base.find_param("WRITE_MODE");
118        self.params.num_capture = base.find_param("NUM_CAPTURE");
119        self.params.capture = base.find_param("CAPTURE");
120        self.params.auto_save = base.find_param("AUTO_SAVE");
121        self.params.create_dir = base.find_param("CREATE_DIR");
122        self.params.file_path_exists = base.find_param("FILE_PATH_EXISTS");
123        self.params.write_status = base.find_param("WRITE_STATUS");
124        self.params.write_message = base.find_param("WRITE_MESSAGE");
125        self.params.full_file_name = base.find_param("FULL_FILE_NAME");
126        self.params.file_temp_suffix = base.find_param("FILE_TEMP_SUFFIX");
127        self.params.num_captured = base.find_param("NUM_CAPTURED");
128        self.params.lazy_open = base.find_param("FILE_LAZY_OPEN");
129        self.params.delete_driver_file = base.find_param("DELETE_DRIVER_FILE");
130        self.params.free_capture = base.find_param("FREE_CAPTURE");
131        self.params.array_counter = base.find_param("ARRAY_COUNTER");
132        Ok(())
133    }
134
135    // ── capture state owner (B8) ──
136
137    /// Start capture/stream (single owner of the capture-state transition).
138    ///
139    /// B8: clears the capture buffer, resets validation state, sets
140    /// `capture_active`, and emits the `CAPTURE`/`NUM_CAPTURED` PV updates.
141    /// B9: for a non-lazy Stream plugin whose writer supports multiple arrays
142    /// the file is opened eagerly here (C++ `doCapture`) so a bad path is
143    /// reported at capture-start; a lazy plugin defers to the first frame.
144    fn start_capture(&mut self, updates: &mut Vec<ParamUpdate>) -> ADResult<()> {
145        self.file_base.clear_capture();
146        self.stream_dims = None;
147        self.stream_data_type = None;
148        self.file_base.lazy_open = self.lazy_open;
149        self.file_base.delete_driver_file = self.delete_driver_file;
150
151        if self.file_base.mode() == NDFileMode::Stream
152            && !self.lazy_open
153            && self.writer.supports_multiple_arrays()
154        {
155            // B9: eager open — needs a frame to know the layout.
156            if let Some(array) = self.latest_array.clone() {
157                self.file_base.open_stream(&mut self.writer, &array)?;
158            }
159        }
160        self.capture_active = true;
161        self.push_capture_update(updates);
162        self.push_num_captured_update(updates);
163        Ok(())
164    }
165
166    /// Stop capture/stream (single owner of the capture-state transition).
167    ///
168    /// B8/B17: closes any open stream file, clears `capture_active`, and
169    /// emits the `CAPTURE` PV update. Idempotent.
170    ///
171    /// The B8 invariant — that `capture_active`, the file-base capture state
172    /// and the `CAPTURE` PV move together — holds for the failing close as
173    /// well: the finalizer performs the whole transition on every exit path, so
174    /// a `close_stream` error is reported without latching `Capture_RBV` at 1
175    /// against a plugin that is no longer capturing.
176    fn stop_capture(&mut self, updates: &mut Vec<ParamUpdate>) -> ADResult<()> {
177        let mut stopping = Finalize::new(self, |ctrl: &mut Self| {
178            ctrl.capture_active = false;
179            ctrl.stream_dims = None;
180            ctrl.stream_data_type = None;
181            ctrl.push_capture_update(updates);
182        });
183        stopping.run(|ctrl| {
184            if ctrl.file_base.mode() == NDFileMode::Stream {
185                ctrl.file_base.close_stream(&mut ctrl.writer)?;
186            }
187            Ok(())
188        })
189    }
190
191    /// `isFrameValid` (C++ NDPluginFile.cpp:665): a frame is valid if its
192    /// dimensions and data type match the first frame of the capture/stream.
193    /// G12: applies to Capture mode as well as Stream.
194    fn frame_valid(&mut self, array: &NDArray) -> bool {
195        let frame_dims: Vec<usize> = array.dims.iter().map(|d| d.size).collect();
196        let frame_dtype = array.data.data_type();
197        match (&self.stream_dims, self.stream_data_type) {
198            (Some(dims), Some(dtype)) => &frame_dims == dims && frame_dtype == dtype,
199            _ => {
200                self.stream_dims = Some(frame_dims);
201                self.stream_data_type = Some(frame_dtype);
202                true
203            }
204        }
205    }
206
207    /// G9: decide whether this plugin should process the frame, based on the
208    /// `FilePluginDestination` attribute (C++ `attrIsProcessingRequired`).
209    /// If the attribute is set and is neither "all" nor this plugin's port
210    /// name, the frame is not for this plugin.
211    fn destination_matches(&self, array: &NDArray) -> bool {
212        match array
213            .attributes
214            .get("FilePluginDestination")
215            .and_then(|attr| attr.value.as_string_typed())
216        {
217            // C `getValueInfo` only runs the compare when the attribute is
218            // string-typed; a numeric attribute is read as `ND_ERROR` and the
219            // frame is processed (treated as "no destination set").
220            Some(dest) => {
221                // C runs the compare for any non-empty string (getValueInfo
222                // size = strlen+1, guarded `> 1`).
223                if dest.is_empty() {
224                    return true;
225                }
226                // C tests "all" with `epicsStrnCaseCmp(dest,"all",min(len,3))`
227                // — a 3-char (or shorter, for 1-2 char dest) prefix match — and
228                // the port name with a full-length compare.
229                let prefix = dest.len().min(3);
230                let matches_all = dest.as_bytes()[..prefix].eq_ignore_ascii_case(&b"all"[..prefix]);
231                matches_all || dest.eq_ignore_ascii_case(&self.port_name)
232            }
233            None => true,
234        }
235    }
236
237    /// Re-evaluate whether the current `FilePath` directory exists and push a
238    /// `FilePathExists` param update. C++ `NDPluginFile::checkPath()` runs this
239    /// before each write, not only when FilePath changes.
240    fn refresh_file_path_exists(&mut self, updates: &mut Vec<ParamUpdate>) {
241        let idx = match self.params.file_path_exists {
242            Some(idx) => idx,
243            None => return,
244        };
245        let (normalized, exists) = check_file_path(&self.file_base.file_path);
246        self.file_base.file_path = normalized;
247        updates.push(ParamUpdate::Int32 {
248            reason: idx,
249            addr: 0,
250            value: if exists { 1 } else { 0 },
251        });
252    }
253
254    /// G9: apply the `FilePluginFileName` / `FilePluginFileNumber` attributes
255    /// to the file base, returning param updates for the changed PVs and
256    /// whether a mid-stream file reopen is required (C++ `attrFileNameSet` /
257    /// `attrFileNameCheck`).
258    fn apply_filename_attributes(
259        &mut self,
260        array: &NDArray,
261        updates: &mut Vec<ParamUpdate>,
262    ) -> bool {
263        let mut reopen = false;
264        // C `attrFileNameSet` guards on `attrDataType == NDAttrString` before
265        // touching NDFileName; a numeric FilePluginFileName attribute is ignored
266        // (the filename is not redefined to its decimal rendering).
267        if let Some(name) = array
268            .attributes
269            .get("FilePluginFileName")
270            .and_then(|attr| attr.value.as_string_typed())
271        {
272            if !name.is_empty() && name != self.file_base.file_name {
273                self.file_base.file_name = name.to_string();
274                reopen = true;
275                if let Some(idx) = self.params.file_name {
276                    updates.push(ParamUpdate::Octet {
277                        reason: idx,
278                        addr: 0,
279                        value: name.to_string(),
280                    });
281                }
282            }
283        }
284        if let Some(attr) = array.attributes.get("FilePluginFileNumber") {
285            if let Some(num) = attr.value.as_i64() {
286                let num = num as i32;
287                if num != self.file_base.file_number {
288                    self.file_base.file_number = num;
289                    self.file_base.auto_increment = false; // C parity
290                    reopen = true;
291                    if let Some(idx) = self.params.file_number {
292                        updates.push(ParamUpdate::Int32 {
293                            reason: idx,
294                            addr: 0,
295                            value: num,
296                        });
297                    }
298                }
299            }
300        }
301        reopen
302    }
303
304    /// Process an incoming array: auto_save, capture buffering, stream write.
305    pub fn process_array(&mut self, array: &NDArray) -> ProcessResult {
306        let mut proc_result = ProcessResult::empty();
307        let array = Arc::new(array.clone());
308        self.latest_array = Some(array.clone());
309
310        // G9: FilePluginDestination routing — skip frames not for this plugin.
311        if !self.destination_matches(&array) {
312            return proc_result;
313        }
314
315        // Re-check file-path existence before every write. C++ NDPluginFile
316        // calls checkPath() before each write so a directory deleted after
317        // FilePath was set is reflected; the Rust port previously updated
318        // FilePathExists only on the FilePath param change.
319        self.refresh_file_path_exists(&mut proc_result.param_updates);
320
321        // G9: FilePluginClose attribute forces an immediate file close.
322        let force_close = array
323            .attributes
324            .get("FilePluginClose")
325            .and_then(|a| a.value.as_i64())
326            .map(|v| v != 0)
327            .unwrap_or(false);
328        if force_close {
329            if let Err(e) = self.file_base.force_close(&mut self.writer) {
330                self.fail_cycle(&mut proc_result, e.to_string());
331                return proc_result;
332            }
333            let _ = self.stop_capture(&mut proc_result.param_updates);
334            return proc_result;
335        }
336
337        let result = match self.file_base.mode() {
338            NDFileMode::Single => {
339                if self.auto_save {
340                    let r = self.write_single(array);
341                    if r.is_ok() {
342                        self.saved_frames += 1; // G10: count saved frame
343                    }
344                    r
345                } else {
346                    Ok(())
347                }
348            }
349            NDFileMode::Capture => {
350                if self.capture_active {
351                    // G12: validate frame dims/dtype in Capture mode too.
352                    if !self.frame_valid(&array) {
353                        return proc_result;
354                    }
355                    self.file_base.capture_array(array);
356                    self.push_num_captured_update(&mut proc_result.param_updates);
357                    let target = self.file_base.num_capture_target();
358                    if target > 0 && self.file_base.num_captured() >= target {
359                        if self.auto_save {
360                            let to_save = self.file_base.num_captured() as i32;
361                            if let Err(err) = self.file_base.flush_capture(&mut self.writer) {
362                                Err(err)
363                            } else {
364                                self.saved_frames += to_save; // G10
365                                self.push_full_file_name_update(&mut proc_result.param_updates);
366                                self.push_num_captured_update(&mut proc_result.param_updates);
367                                self.stop_capture(&mut proc_result.param_updates).ok();
368                                Ok(())
369                            }
370                        } else {
371                            self.stop_capture(&mut proc_result.param_updates).ok();
372                            Ok(())
373                        }
374                    } else {
375                        Ok(())
376                    }
377                } else {
378                    Ok(())
379                }
380            }
381            NDFileMode::Stream => {
382                if self.capture_active {
383                    // G12: validate frame dims/dtype against the first frame.
384                    if !self.frame_valid(&array) {
385                        return proc_result;
386                    }
387                    // G9: attribute-driven filename override / mid-stream reopen.
388                    let reopen =
389                        self.apply_filename_attributes(&array, &mut proc_result.param_updates);
390                    if reopen && self.file_base.is_open() {
391                        if let Err(e) = self.file_base.force_close(&mut self.writer) {
392                            self.fail_cycle(&mut proc_result, e.to_string());
393                            return proc_result;
394                        }
395                    }
396                    let r = self.file_base.process_array(array, &mut self.writer);
397                    if r.is_ok() {
398                        self.saved_frames += 1; // G10: count saved frame
399                        // C++ NDPluginFile::processCallbacks publishes
400                        // NDFileNumCaptured after every successful stream
401                        // write — without this an unbounded stream
402                        // (NumCapture=0) reports NumCaptured_RBV=0 forever.
403                        self.push_num_captured_update(&mut proc_result.param_updates);
404                    }
405                    let target = self.file_base.num_capture_target();
406                    if r.is_ok() && target > 0 && self.file_base.num_captured() >= target {
407                        if let Err(e) = self.file_base.close_stream(&mut self.writer) {
408                            self.fail_cycle(&mut proc_result, e.to_string());
409                            return proc_result;
410                        }
411                        self.stop_capture(&mut proc_result.param_updates).ok();
412                        self.push_full_file_name_update(&mut proc_result.param_updates);
413                    }
414                    r
415                } else {
416                    Ok(())
417                }
418            }
419        };
420
421        if result.is_ok() {
422            proc_result.param_updates.extend(self.success_updates());
423            if self.file_base.mode() == NDFileMode::Single && self.auto_save {
424                self.push_full_file_name_update(&mut proc_result.param_updates);
425            }
426            if self.file_base.mode() == NDFileMode::Stream && self.capture_active {
427                self.push_full_file_name_update(&mut proc_result.param_updates);
428            }
429            // G10: override ArrayCounter (the runtime bumped it per callback)
430            // with the saved-frame count so a file plugin's ArrayCounter_RBV
431            // reflects frames actually written, not callbacks received.
432            if let Some(idx) = self.params.array_counter {
433                proc_result.param_updates.push(ParamUpdate::Int32 {
434                    reason: idx,
435                    addr: 0,
436                    value: self.saved_frames,
437                });
438            }
439        } else if let Err(err) = result {
440            self.fail_cycle(&mut proc_result, err.to_string());
441        }
442        proc_result
443    }
444
445    /// Handle a control-plane param change. Returns true if the reason was handled.
446    pub fn on_param_change(
447        &mut self,
448        reason: usize,
449        params: &PluginParamSnapshot,
450    ) -> ParamChangeResult {
451        let mut updates = Vec::new();
452
453        if Some(reason) == self.params.file_path {
454            if let ParamChangeValue::Octet(s) = &params.value {
455                let (normalized, exists) = check_file_path(s);
456                self.file_base.file_path = normalized;
457                if let Some(idx) = self.params.file_path_exists {
458                    updates.push(ParamUpdate::Int32 {
459                        reason: idx,
460                        addr: 0,
461                        value: if exists { 1 } else { 0 },
462                    });
463                }
464            }
465        } else if Some(reason) == self.params.file_name {
466            if let ParamChangeValue::Octet(s) = &params.value {
467                self.file_base.file_name = s.clone();
468            }
469        } else if Some(reason) == self.params.file_number {
470            self.file_base.file_number = params.value.as_i32();
471        } else if Some(reason) == self.params.file_template {
472            if let ParamChangeValue::Octet(s) = &params.value {
473                self.file_base.file_template = s.clone();
474            }
475        } else if Some(reason) == self.params.auto_increment {
476            self.file_base.auto_increment = params.value.as_i32() != 0;
477        } else if Some(reason) == self.params.auto_save {
478            self.auto_save = params.value.as_i32() != 0;
479        } else if Some(reason) == self.params.write_mode {
480            // B17: WriteMode transitions are gated through stop_capture so a
481            // mode switch mid-capture cannot leave a stream file open.
482            let new_mode = NDFileMode::from_i32(params.value.as_i32());
483            if self.capture_active && new_mode != self.file_base.mode() {
484                if let Err(e) = self.stop_capture(&mut updates) {
485                    self.push_error_updates(&mut updates, false, false, e.to_string());
486                    return ParamChangeResult::updates(updates);
487                }
488            }
489            self.file_base.set_mode(new_mode);
490        } else if Some(reason) == self.params.num_capture {
491            // B7: numCapture==0 means "capture forever" — C++ buffers
492            // indefinitely. Do not force a minimum of 1.
493            self.file_base
494                .set_num_capture(params.value.as_i32().max(0) as usize);
495        } else if Some(reason) == self.params.create_dir {
496            self.file_base.create_dir = params.value.as_i32();
497        } else if Some(reason) == self.params.file_temp_suffix {
498            if let ParamChangeValue::Octet(s) = &params.value {
499                self.file_base.temp_suffix = s.clone();
500            }
501        } else if Some(reason) == self.params.write_file {
502            if params.value.as_i32() != 0 {
503                let result = match self.file_base.mode() {
504                    NDFileMode::Single => {
505                        if let Some(array) = self.latest_array.clone() {
506                            self.write_single(array)
507                        } else {
508                            Err(ADError::UnsupportedConversion(
509                                "no array available for write".into(),
510                            ))
511                        }
512                    }
513                    NDFileMode::Capture => self.file_base.flush_capture(&mut self.writer),
514                    NDFileMode::Stream => {
515                        if let Some(array) = self.latest_array.clone() {
516                            self.file_base.process_array(array, &mut self.writer)
517                        } else {
518                            Err(ADError::UnsupportedConversion(
519                                "no array available for write".into(),
520                            ))
521                        }
522                    }
523                };
524                match result {
525                    Ok(()) => {
526                        updates.extend(self.success_updates());
527                        self.push_num_captured_update(&mut updates);
528                        self.push_full_file_name_update(&mut updates);
529                    }
530                    Err(err) => {
531                        // The write that failed is `flush_capture` in Capture
532                        // mode, which lands frames one at a time, so the
533                        // readbacks have to come back off `file_base`.
534                        self.push_error_updates(&mut updates, false, true, err.to_string());
535                        self.push_file_base_readbacks(&mut updates);
536                        return ParamChangeResult::updates(updates);
537                    }
538                }
539            }
540        } else if Some(reason) == self.params.read_file {
541            if params.value.as_i32() != 0 {
542                let result = (|| -> ADResult<Arc<NDArray>> {
543                    let path = PathBuf::from(self.file_base.create_file_name());
544                    let layout = NDArray::new(vec![NDDimension::new(1)], NDDataType::UInt8);
545                    let array = with_open_file(
546                        &mut self.writer,
547                        &path,
548                        NDFileMode::Single,
549                        &layout,
550                        |w| w.read_file().map(Arc::new),
551                    )?;
552                    self.latest_array = Some(array.clone());
553                    Ok(array)
554                })();
555                match result {
556                    Ok(array) => {
557                        updates.extend(self.success_updates());
558                        self.push_full_file_name_update(&mut updates);
559                        return ParamChangeResult::combined(vec![array], updates);
560                    }
561                    Err(err) => {
562                        self.push_error_updates(&mut updates, true, false, err.to_string());
563                        return ParamChangeResult::updates(updates);
564                    }
565                }
566            }
567        } else if Some(reason) == self.params.lazy_open {
568            self.lazy_open = params.value.as_i32() != 0;
569        } else if Some(reason) == self.params.delete_driver_file {
570            self.delete_driver_file = params.value.as_i32() != 0;
571        } else if Some(reason) == self.params.free_capture {
572            if params.value.as_i32() != 0 {
573                self.file_base.clear_capture();
574                self.push_num_captured_update(&mut updates);
575            }
576        } else if Some(reason) == self.params.capture {
577            // B8: capture start/stop routes through the single owner.
578            if params.value.as_i32() != 0 {
579                if self.file_base.mode() == NDFileMode::Single {
580                    // Capture is invalid in Single mode — leave it stopped.
581                    let _ = self.stop_capture(&mut updates);
582                    self.push_error_updates(
583                        &mut updates,
584                        false,
585                        false,
586                        "ERROR: capture not supported in Single mode".into(),
587                    );
588                    return ParamChangeResult::updates(updates);
589                }
590                if let Err(e) = self.start_capture(&mut updates) {
591                    self.push_error_updates(&mut updates, false, false, e.to_string());
592                    return ParamChangeResult::updates(updates);
593                }
594            } else if let Err(e) = self.stop_capture(&mut updates) {
595                self.push_error_updates(&mut updates, false, false, e.to_string());
596                return ParamChangeResult::updates(updates);
597            }
598        }
599
600        ParamChangeResult::updates(updates)
601    }
602
603    // ── helpers ──
604
605    fn write_single(&mut self, array: Arc<NDArray>) -> ADResult<()> {
606        self.file_base.ensure_directory()?;
607        self.file_base.process_array(array, &mut self.writer)
608    }
609
610    fn success_updates(&self) -> Vec<ParamUpdate> {
611        let mut updates = Vec::new();
612        self.push_file_number_update(&mut updates);
613        if let Some(idx) = self.params.write_status {
614            updates.push(ParamUpdate::Int32 {
615                reason: idx,
616                addr: 0,
617                value: 0,
618            });
619        }
620        if let Some(idx) = self.params.write_message {
621            updates.push(ParamUpdate::Octet {
622                reason: idx,
623                addr: 0,
624                value: String::new(),
625            });
626        }
627        if let Some(idx) = self.params.write_file {
628            updates.push(ParamUpdate::Int32 {
629                reason: idx,
630                addr: 0,
631                value: 0,
632            });
633        }
634        self.push_capture_update(&mut updates);
635        if let Some(idx) = self.params.read_file {
636            updates.push(ParamUpdate::Int32 {
637                reason: idx,
638                addr: 0,
639                value: 0,
640            });
641        }
642        updates
643    }
644
645    /// Emit the `CAPTURE` PV update reflecting the current `capture_active`
646    /// flag (B8 — the single owner of the capture-state transition).
647    fn push_capture_update(&self, updates: &mut Vec<ParamUpdate>) {
648        if let Some(idx) = self.params.capture {
649            updates.push(ParamUpdate::Int32 {
650                reason: idx,
651                addr: 0,
652                value: if self.capture_active { 1 } else { 0 },
653            });
654        }
655    }
656
657    fn push_num_captured_update(&self, updates: &mut Vec<ParamUpdate>) {
658        if let Some(idx) = self.params.num_captured {
659            updates.push(ParamUpdate::Int32 {
660                reason: idx,
661                addr: 0,
662                value: self.file_base.num_captured() as i32,
663            });
664        }
665    }
666
667    fn push_file_number_update(&self, updates: &mut Vec<ParamUpdate>) {
668        if let Some(idx) = self.params.file_number {
669            updates.push(ParamUpdate::Int32 {
670                reason: idx,
671                addr: 0,
672                value: self.file_base.file_number,
673            });
674        }
675    }
676
677    /// Re-publish every readback `NDFileBase` owns, read back out of it.
678    ///
679    /// A file operation can fail part-way through and leave that state moved.
680    /// `flush_capture`'s single-image arm walks the capture buffer frame by
681    /// frame (`file_base.rs:419-436`), dropping each landed frame through
682    /// `take_landed` — which is what sets `num_captured` — and bumping
683    /// `file_number` per frame, so a `?` on frame 5 of a 10-frame flush leaves
684    /// `num_captured` at 5 and `file_number` five higher than the cycle began
685    /// with, while the value the controller pushed before the flush still says
686    /// 10. Re-reading is the only way the readbacks can name what is actually
687    /// on disk, so every exit that follows a file operation does it rather than
688    /// trusting what an earlier line in the same cycle pushed.
689    fn push_file_base_readbacks(&self, updates: &mut Vec<ParamUpdate>) {
690        self.push_num_captured_update(updates);
691        self.push_file_number_update(updates);
692        self.push_full_file_name_update(updates);
693    }
694
695    /// The failure exit of one `process_array` cycle: keep everything the cycle
696    /// accumulated, add the error surface, and re-derive the `file_base`
697    /// readbacks over the top of whatever the cycle published before it failed.
698    fn fail_cycle(&mut self, proc_result: &mut ProcessResult, message: String) {
699        self.push_error_updates(&mut proc_result.param_updates, false, false, message);
700        self.push_file_base_readbacks(&mut proc_result.param_updates);
701    }
702
703    fn push_full_file_name_update(&self, updates: &mut Vec<ParamUpdate>) {
704        if let Some(idx) = self.params.full_file_name {
705            updates.push(ParamUpdate::Octet {
706                reason: idx,
707                addr: 0,
708                value: self.file_base.last_written_name().to_string(),
709            });
710        }
711    }
712
713    /// Append the error surface for a failed file operation onto the updates
714    /// the caller has already accumulated.
715    ///
716    /// Takes the accumulator rather than returning a fresh `Vec`, so an error
717    /// exit **cannot** replace what the same cycle published before it failed:
718    /// there is no return value to assign over one. Every site here used to
719    /// build a fresh vector and then either assign it (`process_array`) or
720    /// return it in place of the accumulator (`on_param_change`), which threw
721    /// away the `NDFileNumCaptured`, `FilePathExists` and `Capture_RBV` updates
722    /// that same cycle had already produced.
723    ///
724    /// C++ has no equivalent move. `NDPluginFile::processCallbacks` writes into
725    /// the asyn parameter library as it goes and flushes once at its single
726    /// exit, `callParamCallbacks()` (`NDPluginFile.cpp:789`), and
727    /// `writeFileBase` sets `NDFileWriteStatus`/`NDFileWriteMessage` clean at
728    /// `:231-232` then overwrites them with the error at `:259-260`. No line in
729    /// it discards a parameter another line already set.
730    fn push_error_updates(
731        &self,
732        updates: &mut Vec<ParamUpdate>,
733        read_reason: bool,
734        write_reason: bool,
735        message: String,
736    ) {
737        if write_reason {
738            if let Some(idx) = self.params.write_file {
739                updates.push(ParamUpdate::Int32 {
740                    reason: idx,
741                    addr: 0,
742                    value: 0,
743                });
744            }
745        }
746        if read_reason {
747            if let Some(idx) = self.params.read_file {
748                updates.push(ParamUpdate::Int32 {
749                    reason: idx,
750                    addr: 0,
751                    value: 0,
752                });
753            }
754        }
755        if let Some(idx) = self.params.write_status {
756            updates.push(ParamUpdate::Int32 {
757                reason: idx,
758                addr: 0,
759                value: 1,
760            });
761        }
762        if let Some(idx) = self.params.write_message {
763            updates.push(ParamUpdate::Octet {
764                reason: idx,
765                addr: 0,
766                value: message,
767            });
768        }
769        // The capture state belongs on the error surface in its own right: a
770        // `stop_capture` that failed must not report the error with
771        // `Capture_RBV` still reading 1.
772        self.push_capture_update(updates);
773    }
774}
775
776/// Normalize `path` and report whether the directory exists, through the one
777/// implementation of C++ `asynNDArrayDriver::checkPath` that the file plugins
778/// inherit (`NDPluginFile` -> `NDPluginDriver` -> `asynNDArrayDriver`).
779fn check_file_path(path: &str) -> (String, bool) {
780    let mut normalized = path.to_string();
781    let exists = crate::driver::ndarray_driver::check_path_str(&mut normalized);
782    (normalized, exists)
783}
784
785#[cfg(test)]
786mod tests {
787    use super::*;
788    use crate::attributes::{NDAttrSource, NDAttrValue, NDAttribute};
789    use crate::ndarray::{NDArray, NDDataType, NDDimension};
790    use std::path::Path;
791
792    /// Mock writer that records operations.
793    struct MockWriter {
794        opens: usize,
795        writes: usize,
796        closes: usize,
797        multi: bool,
798        /// Make `close_file` fail, standing in for the ENOSPC / read-only-mount
799        /// close that a real writer cannot complete.
800        fail_close: bool,
801        /// Fail every `write_file` past this many successful ones, standing in
802        /// for a volume that fills part way through a multi-frame flush.
803        fail_write_after: Option<usize>,
804    }
805    impl MockWriter {
806        fn new(multi: bool) -> Self {
807            Self {
808                opens: 0,
809                writes: 0,
810                closes: 0,
811                multi,
812                fail_close: false,
813                fail_write_after: None,
814            }
815        }
816    }
817    impl NDFileWriter for MockWriter {
818        fn open_file(&mut self, _p: &Path, _m: NDFileMode, _a: &NDArray) -> ADResult<()> {
819            self.opens += 1;
820            Ok(())
821        }
822        fn write_file(&mut self, _a: &NDArray) -> ADResult<()> {
823            self.writes += 1;
824            if let Some(n) = self.fail_write_after {
825                if self.writes > n {
826                    return Err(ADError::UnsupportedConversion("disk full".into()));
827                }
828            }
829            Ok(())
830        }
831        fn read_file(&mut self) -> ADResult<NDArray> {
832            Err(ADError::UnsupportedConversion("n/a".into()))
833        }
834        fn close_file(&mut self) -> ADResult<()> {
835            self.closes += 1;
836            if self.fail_close {
837                return Err(ADError::UnsupportedConversion("disk full".into()));
838            }
839            Ok(())
840        }
841        fn supports_multiple_arrays(&self) -> bool {
842            self.multi
843        }
844    }
845
846    fn array(id: i32) -> NDArray {
847        let mut a = NDArray::new(vec![NDDimension::new(4)], NDDataType::UInt8);
848        a.unique_id = id;
849        a
850    }
851
852    fn with_str_attr(mut a: NDArray, name: &str, val: &str) -> NDArray {
853        a.attributes.add(NDAttribute::new_static(
854            name,
855            "",
856            NDAttrSource::Driver,
857            NDAttrValue::String(val.to_string()),
858        ));
859        a
860    }
861
862    fn with_i32_attr(mut a: NDArray, name: &str, val: i32) -> NDArray {
863        a.attributes.add(NDAttribute::new_static(
864            name,
865            "",
866            NDAttrSource::Driver,
867            NDAttrValue::Int32(val),
868        ));
869        a
870    }
871
872    #[test]
873    fn test_g9_destination_routing_skips_other_port() {
874        // G9: a frame addressed to another plugin via FilePluginDestination
875        // is not written by this plugin.
876        let mut c = FilePluginController::new(MockWriter::new(true));
877        c.set_port_name("MYFILE");
878        c.file_base.set_mode(NDFileMode::Single);
879        c.auto_save = true;
880
881        // Destination = a different port → skipped.
882        c.process_array(&with_str_attr(array(1), "FilePluginDestination", "OTHER"));
883        assert_eq!(c.writer.writes, 0, "frame for OTHER port must be skipped");
884
885        // Destination = this port → written.
886        c.process_array(&with_str_attr(array(2), "FilePluginDestination", "MYFILE"));
887        assert_eq!(c.writer.writes, 1);
888
889        // Destination = "all" → written.
890        c.process_array(&with_str_attr(array(3), "FilePluginDestination", "all"));
891        assert_eq!(c.writer.writes, 2);
892    }
893
894    #[test]
895    fn test_g9_numeric_destination_attr_processed_not_stringified() {
896        // ADC-11 parity: C `attrIsProcessingRequired` only compares a
897        // *string-typed* FilePluginDestination (getValueInfo guard); a numeric
898        // attribute reads as ND_ERROR and the frame is processed. The Rust port
899        // must not stringify the numeric (42 -> "42") and then skip the frame.
900        let mut c = FilePluginController::new(MockWriter::new(true));
901        c.set_port_name("MYFILE");
902        c.file_base.set_mode(NDFileMode::Single);
903        c.auto_save = true;
904
905        c.process_array(&with_i32_attr(array(1), "FilePluginDestination", 42));
906        assert_eq!(
907            c.writer.writes, 1,
908            "numeric FilePluginDestination must be ignored (frame processed), \
909             not stringified to \"42\" and skipped"
910        );
911    }
912
913    #[test]
914    fn test_g9_numeric_filename_attr_ignored() {
915        // ADC-11 parity: C `attrFileNameSet` only adopts a string-typed
916        // FilePluginFileName; a numeric attribute leaves NDFileName unchanged
917        // and forces no reopen.
918        let mut c = FilePluginController::new(MockWriter::new(true));
919        c.set_port_name("F");
920        let before = c.file_base.file_name.clone();
921        let mut updates = Vec::new();
922        let reopen = c.apply_filename_attributes(
923            &with_i32_attr(array(1), "FilePluginFileName", 7),
924            &mut updates,
925        );
926        assert!(
927            !reopen,
928            "numeric FilePluginFileName must not force a reopen"
929        );
930        assert_eq!(
931            c.file_base.file_name, before,
932            "numeric FilePluginFileName must not redefine the filename"
933        );
934        assert!(
935            updates.is_empty(),
936            "numeric FilePluginFileName must not post a FileName param update"
937        );
938    }
939
940    #[test]
941    fn test_g9_destination_compare_matches_c_attr_is_processing_required() {
942        // ADC-12: replicate C `attrIsProcessingRequired` comparison exactly —
943        // "all" is a 3-char prefix match and any non-empty string is compared
944        // (no blanket `len <= 1 -> process`).
945        let mut c = FilePluginController::new(MockWriter::new(true));
946        c.set_port_name("MYFILE");
947        c.file_base.set_mode(NDFileMode::Single);
948        c.auto_save = true;
949
950        // "all" 3-char prefix (C: epicsStrnCaseCmp(dest,"all",3)==0) → processed.
951        c.process_array(&with_str_attr(array(1), "FilePluginDestination", "allfoo"));
952        assert_eq!(
953            c.writer.writes, 1,
954            "destination with \"all\" prefix is processed (C 3-char prefix match)"
955        );
956
957        // Non-empty 1-char destination that is neither an "all" prefix nor the
958        // port name → skipped (C compares it; Rust must not blanket-process).
959        c.process_array(&with_str_attr(array(2), "FilePluginDestination", "x"));
960        assert_eq!(
961            c.writer.writes, 1,
962            "1-char non-matching destination is skipped, not blanket-processed"
963        );
964    }
965
966    #[test]
967    fn test_g9_file_close_attribute_forces_close() {
968        // G9: a FilePluginClose attribute forces an open stream to close.
969        let mut c = FilePluginController::new(MockWriter::new(true));
970        c.set_port_name("F");
971        c.file_base.set_mode(NDFileMode::Stream);
972        c.file_base.set_num_capture(10);
973        c.lazy_open = true;
974        let mut updates = Vec::new();
975        c.process_array(&array(1)); // cache an array
976        c.start_capture(&mut updates).unwrap();
977        let _ = &updates;
978        c.process_array(&array(2)); // opens + writes
979        assert!(c.file_base.is_open());
980
981        c.process_array(&with_i32_attr(array(3), "FilePluginClose", 1));
982        assert!(
983            !c.file_base.is_open(),
984            "FilePluginClose must close the file"
985        );
986        assert!(!c.capture_active, "close attribute stops capture");
987    }
988
989    #[test]
990    fn test_b8_capture_owner_round_trip() {
991        // B8: start_capture / stop_capture own the capture state together.
992        let mut c = FilePluginController::new(MockWriter::new(true));
993        c.set_port_name("F");
994        c.file_base.set_mode(NDFileMode::Capture);
995        c.params.capture = Some(7);
996        let mut updates = Vec::new();
997        c.start_capture(&mut updates).unwrap();
998        assert!(c.capture_active);
999        c.stop_capture(&mut updates).unwrap();
1000        assert!(!c.capture_active);
1001        // CAPTURE PV updates emitted on each transition.
1002        assert!(!updates.is_empty());
1003    }
1004
1005    #[test]
1006    fn test_b9_non_lazy_opens_eagerly_at_capture_start() {
1007        // B9: a non-lazy stream plugin opens the file at capture-start.
1008        let mut c = FilePluginController::new(MockWriter::new(true));
1009        c.set_port_name("F");
1010        c.file_base.set_mode(NDFileMode::Stream);
1011        c.lazy_open = false;
1012        c.process_array(&array(1)); // cache a frame for the layout
1013        let mut updates = Vec::new();
1014        c.start_capture(&mut updates).unwrap();
1015        assert!(
1016            c.file_base.is_open(),
1017            "non-lazy stream opens at capture start"
1018        );
1019        assert_eq!(c.writer.opens, 1);
1020    }
1021
1022    #[test]
1023    fn test_b9_lazy_defers_open_to_first_frame() {
1024        let mut c = FilePluginController::new(MockWriter::new(true));
1025        c.set_port_name("F");
1026        c.file_base.set_mode(NDFileMode::Stream);
1027        c.file_base.set_num_capture(10);
1028        c.lazy_open = true;
1029        c.process_array(&array(1));
1030        let mut updates = Vec::new();
1031        c.start_capture(&mut updates).unwrap();
1032        assert!(
1033            !c.file_base.is_open(),
1034            "lazy stream does NOT open at capture start"
1035        );
1036        c.process_array(&array(2));
1037        assert!(c.file_base.is_open(), "lazy stream opens on first frame");
1038    }
1039
1040    #[test]
1041    fn test_g12_capture_mode_validates_frames() {
1042        // G12: Capture mode rejects frames of mismatched dimensions.
1043        let mut c = FilePluginController::new(MockWriter::new(true));
1044        c.set_port_name("F");
1045        c.file_base.set_mode(NDFileMode::Capture);
1046        c.file_base.set_num_capture(10);
1047        let mut updates = Vec::new();
1048        c.start_capture(&mut updates).unwrap();
1049
1050        c.process_array(&array(1)); // first frame: 4-element, recorded
1051        assert_eq!(c.file_base.num_captured(), 1);
1052
1053        // Mismatched frame: different dimension size → rejected.
1054        let mut big = NDArray::new(vec![NDDimension::new(8)], NDDataType::UInt8);
1055        big.unique_id = 2;
1056        c.process_array(&big);
1057        assert_eq!(c.file_base.num_captured(), 1, "mismatched frame rejected");
1058
1059        // Matching frame: accepted.
1060        c.process_array(&array(3));
1061        assert_eq!(c.file_base.num_captured(), 2);
1062    }
1063
1064    /// D6: `ReadFile` on a truncated or malformed file must still close the
1065    /// writer it opened, or every attempt leaks another open handle and leaves
1066    /// `current_path` set for the next Single-mode write to run against.
1067    #[test]
1068    fn read_file_closes_the_writer_when_the_read_fails() {
1069        let mut c = FilePluginController::new(MockWriter::new(false));
1070        c.set_port_name("F");
1071        c.params.read_file = Some(9);
1072
1073        // MockWriter::read_file always fails, standing in for the malformed file.
1074        let snap = PluginParamSnapshot {
1075            enable_callbacks: true,
1076            reason: 9,
1077            addr: 0,
1078            value: ParamChangeValue::Int32(1),
1079        };
1080        c.on_param_change(9, &snap);
1081
1082        assert_eq!(c.writer.opens, 1);
1083        assert_eq!(
1084            c.writer.closes, 1,
1085            "a failed read must still close the file it opened"
1086        );
1087    }
1088
1089    /// D3: `stop_capture` owns the whole B8 transition, so a failing
1090    /// `close_stream` must not leave `capture_active` set — the error is
1091    /// reported, the state still moves.
1092    #[test]
1093    fn stop_capture_clears_capture_active_when_the_close_fails() {
1094        let mut c = FilePluginController::new(MockWriter::new(true));
1095        c.set_port_name("F");
1096        c.params.capture = Some(7);
1097        c.file_base.set_mode(NDFileMode::Stream);
1098        c.file_base.set_num_capture(0);
1099        c.process_array(&array(1));
1100
1101        let mut updates = Vec::new();
1102        c.start_capture(&mut updates).unwrap();
1103        assert!(c.capture_active);
1104        assert!(c.file_base.is_open());
1105
1106        c.writer.fail_close = true;
1107        let mut updates = Vec::new();
1108        assert!(
1109            c.stop_capture(&mut updates).is_err(),
1110            "the close failure is still reported"
1111        );
1112        assert!(
1113            !c.capture_active,
1114            "capture state must not latch on a failed close"
1115        );
1116        assert!(
1117            updates.iter().any(|u| matches!(
1118                u,
1119                ParamUpdate::Int32 {
1120                    reason: 7,
1121                    value: 0,
1122                    ..
1123                }
1124            )),
1125            "CAPTURE=0 must still be posted"
1126        );
1127    }
1128
1129    /// D3, the operator-visible path: `caput Capture 0` with a failing stream
1130    /// close must leave `Capture_RBV` at 0, not latched at 1.
1131    #[test]
1132    fn capture_off_param_write_reports_error_with_capture_rbv_cleared() {
1133        let mut c = FilePluginController::new(MockWriter::new(true));
1134        c.set_port_name("F");
1135        c.params.capture = Some(7);
1136        c.file_base.set_mode(NDFileMode::Stream);
1137        c.file_base.set_num_capture(0);
1138        c.process_array(&array(1));
1139
1140        let on = PluginParamSnapshot {
1141            enable_callbacks: true,
1142            reason: 7,
1143            addr: 0,
1144            value: ParamChangeValue::Int32(1),
1145        };
1146        c.on_param_change(7, &on);
1147        assert!(c.capture_active);
1148
1149        c.writer.fail_close = true;
1150        let off = PluginParamSnapshot {
1151            enable_callbacks: true,
1152            reason: 7,
1153            addr: 0,
1154            value: ParamChangeValue::Int32(0),
1155        };
1156        let result = c.on_param_change(7, &off);
1157        assert!(!c.capture_active);
1158        assert!(
1159            result.param_updates.iter().any(|u| matches!(
1160                u,
1161                ParamUpdate::Int32 {
1162                    reason: 7,
1163                    value: 0,
1164                    ..
1165                }
1166            )),
1167            "Capture_RBV must read 0 after the failed close"
1168        );
1169    }
1170
1171    #[test]
1172    fn test_b17_write_mode_switch_closes_open_stream() {
1173        // B17: switching WriteMode mid-stream closes the open file.
1174        let mut c = FilePluginController::new(MockWriter::new(true));
1175        c.set_port_name("F");
1176        c.file_base.set_mode(NDFileMode::Stream);
1177        c.file_base.set_num_capture(10);
1178        c.params.write_mode = Some(5);
1179        c.lazy_open = false;
1180        c.process_array(&array(1));
1181        let mut updates = Vec::new();
1182        c.start_capture(&mut updates).unwrap();
1183        assert!(c.file_base.is_open());
1184
1185        // Switch to Capture mode while a stream is open.
1186        let snap = PluginParamSnapshot {
1187            enable_callbacks: true,
1188            reason: 5,
1189            addr: 0,
1190            value: ParamChangeValue::Int32(NDFileMode::Capture as i32),
1191        };
1192        c.on_param_change(5, &snap);
1193        assert!(
1194            !c.file_base.is_open(),
1195            "mode switch must close the open stream"
1196        );
1197        assert!(!c.capture_active);
1198    }
1199
1200    #[test]
1201    fn test_b7_capture_num_capture_zero_buffers_forever() {
1202        // B7: num_capture==0 buffers indefinitely (no auto-flush).
1203        let mut c = FilePluginController::new(MockWriter::new(true));
1204        c.set_port_name("F");
1205        c.file_base.set_mode(NDFileMode::Capture);
1206        c.file_base.set_num_capture(0);
1207        c.auto_save = true;
1208        let mut updates = Vec::new();
1209        c.start_capture(&mut updates).unwrap();
1210        for id in 1..=5 {
1211            c.process_array(&array(id));
1212        }
1213        assert_eq!(
1214            c.file_base.num_captured(),
1215            5,
1216            "all frames buffered, no flush"
1217        );
1218        assert_eq!(c.writer.writes, 0, "num_capture==0 never auto-flushes");
1219        assert!(c.capture_active, "still capturing");
1220    }
1221
1222    #[test]
1223    fn stream_mode_publishes_num_captured_per_frame() {
1224        // C++ NDPluginFile::processCallbacks sets NDFileNumCaptured after
1225        // every successful stream write. With NumCapture=0 (unbounded — the
1226        // ophyd-async writer convention) the target-reached branch never
1227        // runs, so a per-frame update is the only way NumCaptured_RBV moves.
1228        let mut c = FilePluginController::new(MockWriter::new(true));
1229        c.set_port_name("F");
1230        c.params.num_captured = Some(42);
1231        c.file_base.set_mode(NDFileMode::Stream);
1232        c.file_base.set_num_capture(0);
1233        let mut updates = Vec::new();
1234        c.process_array(&array(1)); // latest_array for the eager open
1235        c.start_capture(&mut updates).unwrap();
1236        for (id, expected) in [(2, 1), (3, 2)] {
1237            let r = c.process_array(&array(id));
1238            let published = r.param_updates.iter().find_map(|u| match u {
1239                ParamUpdate::Int32 {
1240                    reason: 42, value, ..
1241                } => Some(*value),
1242                _ => None,
1243            });
1244            assert_eq!(
1245                published,
1246                Some(expected),
1247                "frame {id}: NUM_CAPTURED update missing or wrong"
1248            );
1249        }
1250    }
1251
1252    #[test]
1253    fn test_g10_array_counter_counts_saved_frames() {
1254        // G10: ArrayCounter override reflects saved frames.
1255        let mut c = FilePluginController::new(MockWriter::new(false));
1256        c.set_port_name("F");
1257        c.params.array_counter = Some(99);
1258        c.file_base.set_mode(NDFileMode::Single);
1259        c.auto_save = true;
1260        let r1 = c.process_array(&array(1));
1261        let counter1 = r1.param_updates.iter().find_map(|u| match u {
1262            ParamUpdate::Int32 {
1263                reason: 99, value, ..
1264            } => Some(*value),
1265            _ => None,
1266        });
1267        assert_eq!(counter1, Some(1), "first saved frame → ArrayCounter 1");
1268        let r2 = c.process_array(&array(2));
1269        let counter2 = r2.param_updates.iter().find_map(|u| match u {
1270            ParamUpdate::Int32 {
1271                reason: 99, value, ..
1272            } => Some(*value),
1273            _ => None,
1274        });
1275        assert_eq!(counter2, Some(2));
1276    }
1277
1278    /// The value the parameter library would end up holding for `reason`:
1279    /// the LAST update in the cycle wins, exactly as repeated
1280    /// `setIntegerParam` calls do before a single `callParamCallbacks`.
1281    fn int_update(updates: &[ParamUpdate], reason: usize) -> Option<i32> {
1282        updates.iter().rev().find_map(|u| match u {
1283            ParamUpdate::Int32 {
1284                reason: r, value, ..
1285            } if *r == reason => Some(*value),
1286            _ => None,
1287        })
1288    }
1289
1290    const NUM_CAPTURED: usize = 61;
1291    const WRITE_STATUS: usize = 62;
1292    const FILE_NUMBER: usize = 63;
1293    const PATH_EXISTS: usize = 64;
1294    const WRITE_FILE: usize = 65;
1295
1296    fn capture_controller(multi: bool) -> FilePluginController<MockWriter> {
1297        let mut c = FilePluginController::new(MockWriter::new(multi));
1298        c.set_port_name("F");
1299        c.params.num_captured = Some(NUM_CAPTURED);
1300        c.params.write_status = Some(WRITE_STATUS);
1301        c.params.file_number = Some(FILE_NUMBER);
1302        c.params.file_path_exists = Some(PATH_EXISTS);
1303        c.params.write_file = Some(WRITE_FILE);
1304        c.file_base.set_mode(NDFileMode::Capture);
1305        // The single-image arm burns a file number per frame that lands, which
1306        // is what makes NDFileNumber_RBV stale after a partial flush.
1307        c.file_base.auto_increment = true;
1308        c.auto_save = true;
1309        c.capture_active = true;
1310        c
1311    }
1312
1313    /// The headline case. A single-image flush lands frames one at a time, so a
1314    /// writer that fills the volume on frame 3 of 4 leaves two frames queued
1315    /// and `file_number` two higher. `NDFileNumCaptured_RBV` has to name the
1316    /// two that are still owed — before this it kept the 4 the controller
1317    /// pushed BEFORE the flush, because the error exit assigned over it.
1318    #[test]
1319    fn a_partial_capture_flush_republishes_the_frames_still_queued() {
1320        let mut c = capture_controller(false);
1321        c.file_base.set_num_capture(4);
1322        c.writer.fail_write_after = Some(2);
1323        let first_number = c.file_base.file_number;
1324
1325        let mut last = ProcessResult::empty();
1326        for id in 1..=4 {
1327            last = c.process_array(&array(id));
1328        }
1329
1330        assert_eq!(
1331            c.file_base.num_captured(),
1332            2,
1333            "two frames never reached disk and stay queued"
1334        );
1335        assert_eq!(
1336            int_update(&last.param_updates, NUM_CAPTURED),
1337            Some(2),
1338            "the readback must name the frames still owed, not the 4 pushed \
1339             before the flush was attempted"
1340        );
1341        assert_eq!(
1342            int_update(&last.param_updates, FILE_NUMBER),
1343            Some(first_number + 2),
1344            "the two frames that DID land burned two file numbers"
1345        );
1346        assert_eq!(
1347            int_update(&last.param_updates, WRITE_STATUS),
1348            Some(1),
1349            "the failure is still reported"
1350        );
1351    }
1352
1353    /// The same failing cycle also ran `refresh_file_path_exists` before it
1354    /// touched the writer. That update belongs to the cycle and the error exit
1355    /// must not drop it — C++ accumulates into the parameter library and
1356    /// flushes once at `NDPluginFile.cpp:789`.
1357    #[test]
1358    fn a_partial_capture_flush_keeps_the_path_check_of_the_same_cycle() {
1359        let mut c = capture_controller(false);
1360        c.file_base.set_num_capture(4);
1361        c.writer.fail_write_after = Some(2);
1362
1363        let mut last = ProcessResult::empty();
1364        for id in 1..=4 {
1365            last = c.process_array(&array(id));
1366        }
1367
1368        assert!(
1369            int_update(&last.param_updates, PATH_EXISTS).is_some(),
1370            "FilePathExists was checked this cycle; the error exit dropped it"
1371        );
1372    }
1373
1374    /// A formerly-bypassing exit: the Stream close ran its own
1375    /// `return ProcessResult::sink(..)` past the tail, so the
1376    /// `NDFileNumCaptured` this cycle had already published for the frame that
1377    /// DID reach disk went with it.
1378    #[test]
1379    fn a_failed_stream_close_keeps_the_count_the_same_cycle_published() {
1380        let mut c = FilePluginController::new(MockWriter::new(true));
1381        c.set_port_name("F");
1382        c.params.num_captured = Some(NUM_CAPTURED);
1383        c.params.write_status = Some(WRITE_STATUS);
1384        c.file_base.set_mode(NDFileMode::Stream);
1385        c.file_base.set_num_capture(1);
1386        c.capture_active = true;
1387        c.writer.fail_close = true;
1388
1389        let r = c.process_array(&array(1));
1390
1391        assert_eq!(
1392            int_update(&r.param_updates, NUM_CAPTURED),
1393            Some(1),
1394            "the frame reached disk before the close failed"
1395        );
1396        assert_eq!(
1397            int_update(&r.param_updates, WRITE_STATUS),
1398            Some(1),
1399            "the close failure is still reported"
1400        );
1401    }
1402
1403    /// The control-plane twin of the headline case: a manual `WriteFile` on a
1404    /// buffered capture flushes through the same partial path, and its error
1405    /// exit published no count at all.
1406    #[test]
1407    fn a_failed_manual_write_republishes_the_capture_count() {
1408        let mut c = capture_controller(false);
1409        c.file_base.set_num_capture(0); // buffer forever, never auto-flush
1410        for id in 1..=4 {
1411            c.process_array(&array(id));
1412        }
1413        assert_eq!(c.file_base.num_captured(), 4, "all four are buffered");
1414
1415        c.writer.fail_write_after = Some(2);
1416        let snap = PluginParamSnapshot {
1417            enable_callbacks: true,
1418            reason: WRITE_FILE,
1419            addr: 0,
1420            value: ParamChangeValue::Int32(1),
1421        };
1422        let r = c.on_param_change(WRITE_FILE, &snap);
1423
1424        assert_eq!(
1425            int_update(&r.param_updates, NUM_CAPTURED),
1426            Some(2),
1427            "a failed manual flush must still say how many frames are owed"
1428        );
1429        assert_eq!(int_update(&r.param_updates, WRITE_STATUS), Some(1));
1430    }
1431
1432    /// The success boundary: nothing above may turn a clean flush into an
1433    /// error surface or leave a stale count behind.
1434    #[test]
1435    fn a_clean_capture_flush_reports_no_frames_left_and_no_error() {
1436        let mut c = capture_controller(false);
1437        c.file_base.set_num_capture(4);
1438
1439        let mut last = ProcessResult::empty();
1440        for id in 1..=4 {
1441            last = c.process_array(&array(id));
1442        }
1443
1444        assert_eq!(c.file_base.num_captured(), 0);
1445        assert_eq!(int_update(&last.param_updates, NUM_CAPTURED), Some(0));
1446        assert_eq!(int_update(&last.param_updates, WRITE_STATUS), Some(0));
1447    }
1448}