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.attributes.get("FilePluginDestination") {
202            Some(attr) => {
203                let dest = attr.value.as_string();
204                if dest.len() <= 1 {
205                    return true;
206                }
207                dest.eq_ignore_ascii_case("all") || dest.eq_ignore_ascii_case(&self.port_name)
208            }
209            None => true,
210        }
211    }
212
213    /// Re-evaluate whether the current `FilePath` directory exists and push a
214    /// `FilePathExists` param update. C++ `NDPluginFile::checkPath()` runs this
215    /// before each write, not only when FilePath changes.
216    fn refresh_file_path_exists(&self, updates: &mut Vec<ParamUpdate>) {
217        let idx = match self.params.file_path_exists {
218            Some(idx) => idx,
219            None => return,
220        };
221        let path = self
222            .file_base
223            .file_path
224            .trim_end_matches(std::path::MAIN_SEPARATOR);
225        let exists = !path.is_empty() && std::path::Path::new(path).is_dir();
226        updates.push(ParamUpdate::Int32 {
227            reason: idx,
228            addr: 0,
229            value: if exists { 1 } else { 0 },
230        });
231    }
232
233    /// G9: apply the `FilePluginFileName` / `FilePluginFileNumber` attributes
234    /// to the file base, returning param updates for the changed PVs and
235    /// whether a mid-stream file reopen is required (C++ `attrFileNameSet` /
236    /// `attrFileNameCheck`).
237    fn apply_filename_attributes(
238        &mut self,
239        array: &NDArray,
240        updates: &mut Vec<ParamUpdate>,
241    ) -> bool {
242        let mut reopen = false;
243        if let Some(attr) = array.attributes.get("FilePluginFileName") {
244            let name = attr.value.as_string();
245            if !name.is_empty() {
246                if name != self.file_base.file_name {
247                    self.file_base.file_name = name.clone();
248                    reopen = true;
249                    if let Some(idx) = self.params.file_name {
250                        updates.push(ParamUpdate::Octet {
251                            reason: idx,
252                            addr: 0,
253                            value: name,
254                        });
255                    }
256                }
257            }
258        }
259        if let Some(attr) = array.attributes.get("FilePluginFileNumber") {
260            if let Some(num) = attr.value.as_i64() {
261                let num = num as i32;
262                if num != self.file_base.file_number {
263                    self.file_base.file_number = num;
264                    self.file_base.auto_increment = false; // C parity
265                    reopen = true;
266                    if let Some(idx) = self.params.file_number {
267                        updates.push(ParamUpdate::Int32 {
268                            reason: idx,
269                            addr: 0,
270                            value: num,
271                        });
272                    }
273                }
274            }
275        }
276        reopen
277    }
278
279    /// Process an incoming array: auto_save, capture buffering, stream write.
280    pub fn process_array(&mut self, array: &NDArray) -> ProcessResult {
281        let mut proc_result = ProcessResult::empty();
282        let array = Arc::new(array.clone());
283        self.latest_array = Some(array.clone());
284
285        // G9: FilePluginDestination routing — skip frames not for this plugin.
286        if !self.destination_matches(&array) {
287            return proc_result;
288        }
289
290        // Re-check file-path existence before every write. C++ NDPluginFile
291        // calls checkPath() before each write so a directory deleted after
292        // FilePath was set is reflected; the Rust port previously updated
293        // FilePathExists only on the FilePath param change.
294        self.refresh_file_path_exists(&mut proc_result.param_updates);
295
296        // G9: FilePluginClose attribute forces an immediate file close.
297        let force_close = array
298            .attributes
299            .get("FilePluginClose")
300            .and_then(|a| a.value.as_i64())
301            .map(|v| v != 0)
302            .unwrap_or(false);
303        if force_close {
304            if let Err(e) = self.file_base.force_close(&mut self.writer) {
305                return ProcessResult::sink(self.error_updates(false, false, e.to_string()));
306            }
307            let _ = self.stop_capture(&mut proc_result.param_updates);
308            return proc_result;
309        }
310
311        let result = match self.file_base.mode() {
312            NDFileMode::Single => {
313                if self.auto_save {
314                    let r = self.write_single(array);
315                    if r.is_ok() {
316                        self.saved_frames += 1; // G10: count saved frame
317                    }
318                    r
319                } else {
320                    Ok(())
321                }
322            }
323            NDFileMode::Capture => {
324                if self.capture_active {
325                    // G12: validate frame dims/dtype in Capture mode too.
326                    if !self.frame_valid(&array) {
327                        return proc_result;
328                    }
329                    self.file_base.capture_array(array);
330                    self.push_num_captured_update(&mut proc_result.param_updates);
331                    let target = self.file_base.num_capture_target();
332                    if target > 0 && self.file_base.num_captured() >= target {
333                        if self.auto_save {
334                            let to_save = self.file_base.num_captured() as i32;
335                            if let Err(err) = self.file_base.flush_capture(&mut self.writer) {
336                                Err(err)
337                            } else {
338                                self.saved_frames += to_save; // G10
339                                self.push_full_file_name_update(&mut proc_result.param_updates);
340                                self.push_num_captured_update(&mut proc_result.param_updates);
341                                self.stop_capture(&mut proc_result.param_updates).ok();
342                                Ok(())
343                            }
344                        } else {
345                            self.stop_capture(&mut proc_result.param_updates).ok();
346                            Ok(())
347                        }
348                    } else {
349                        Ok(())
350                    }
351                } else {
352                    Ok(())
353                }
354            }
355            NDFileMode::Stream => {
356                if self.capture_active {
357                    // G12: validate frame dims/dtype against the first frame.
358                    if !self.frame_valid(&array) {
359                        return proc_result;
360                    }
361                    // G9: attribute-driven filename override / mid-stream reopen.
362                    let reopen =
363                        self.apply_filename_attributes(&array, &mut proc_result.param_updates);
364                    if reopen && self.file_base.is_open() {
365                        if let Err(e) = self.file_base.force_close(&mut self.writer) {
366                            return ProcessResult::sink(self.error_updates(
367                                false,
368                                false,
369                                e.to_string(),
370                            ));
371                        }
372                    }
373                    let r = self.file_base.process_array(array, &mut self.writer);
374                    if r.is_ok() {
375                        self.saved_frames += 1; // G10: count saved frame
376                    }
377                    let target = self.file_base.num_capture_target();
378                    if r.is_ok() && target > 0 && self.file_base.num_captured() >= target {
379                        if let Err(e) = self.file_base.close_stream(&mut self.writer) {
380                            return ProcessResult::sink(self.error_updates(
381                                false,
382                                false,
383                                e.to_string(),
384                            ));
385                        }
386                        self.stop_capture(&mut proc_result.param_updates).ok();
387                        self.push_full_file_name_update(&mut proc_result.param_updates);
388                        self.push_num_captured_update(&mut proc_result.param_updates);
389                    }
390                    r
391                } else {
392                    Ok(())
393                }
394            }
395        };
396
397        if result.is_ok() {
398            proc_result.param_updates.extend(self.success_updates());
399            if self.file_base.mode() == NDFileMode::Single && self.auto_save {
400                self.push_full_file_name_update(&mut proc_result.param_updates);
401            }
402            if self.file_base.mode() == NDFileMode::Stream && self.capture_active {
403                self.push_full_file_name_update(&mut proc_result.param_updates);
404            }
405            // G10: override ArrayCounter (the runtime bumped it per callback)
406            // with the saved-frame count so a file plugin's ArrayCounter_RBV
407            // reflects frames actually written, not callbacks received.
408            if let Some(idx) = self.params.array_counter {
409                proc_result.param_updates.push(ParamUpdate::Int32 {
410                    reason: idx,
411                    addr: 0,
412                    value: self.saved_frames,
413                });
414            }
415        } else if let Err(err) = result {
416            proc_result.param_updates = self.error_updates(false, false, err.to_string());
417        }
418        proc_result
419    }
420
421    /// Handle a control-plane param change. Returns true if the reason was handled.
422    pub fn on_param_change(
423        &mut self,
424        reason: usize,
425        params: &PluginParamSnapshot,
426    ) -> ParamChangeResult {
427        let mut updates = Vec::new();
428
429        if Some(reason) == self.params.file_path {
430            if let ParamChangeValue::Octet(s) = &params.value {
431                let normalized = normalize_file_path(s);
432                self.file_base.file_path = normalized.clone();
433                let exists =
434                    std::path::Path::new(normalized.trim_end_matches(std::path::MAIN_SEPARATOR))
435                        .is_dir();
436                if let Some(idx) = self.params.file_path_exists {
437                    updates.push(ParamUpdate::Int32 {
438                        reason: idx,
439                        addr: 0,
440                        value: if exists { 1 } else { 0 },
441                    });
442                }
443            }
444        } else if Some(reason) == self.params.file_name {
445            if let ParamChangeValue::Octet(s) = &params.value {
446                self.file_base.file_name = s.clone();
447            }
448        } else if Some(reason) == self.params.file_number {
449            self.file_base.file_number = params.value.as_i32();
450        } else if Some(reason) == self.params.file_template {
451            if let ParamChangeValue::Octet(s) = &params.value {
452                self.file_base.file_template = s.clone();
453            }
454        } else if Some(reason) == self.params.auto_increment {
455            self.file_base.auto_increment = params.value.as_i32() != 0;
456        } else if Some(reason) == self.params.auto_save {
457            self.auto_save = params.value.as_i32() != 0;
458        } else if Some(reason) == self.params.write_mode {
459            // B17: WriteMode transitions are gated through stop_capture so a
460            // mode switch mid-capture cannot leave a stream file open.
461            let new_mode = NDFileMode::from_i32(params.value.as_i32());
462            if self.capture_active && new_mode != self.file_base.mode() {
463                if let Err(e) = self.stop_capture(&mut updates) {
464                    return ParamChangeResult::updates(self.error_updates(
465                        false,
466                        false,
467                        e.to_string(),
468                    ));
469                }
470            }
471            self.file_base.set_mode(new_mode);
472        } else if Some(reason) == self.params.num_capture {
473            // B7: numCapture==0 means "capture forever" — C++ buffers
474            // indefinitely. Do not force a minimum of 1.
475            self.file_base
476                .set_num_capture(params.value.as_i32().max(0) as usize);
477        } else if Some(reason) == self.params.create_dir {
478            self.file_base.create_dir = params.value.as_i32();
479        } else if Some(reason) == self.params.file_temp_suffix {
480            if let ParamChangeValue::Octet(s) = &params.value {
481                self.file_base.temp_suffix = s.clone();
482            }
483        } else if Some(reason) == self.params.write_file {
484            if params.value.as_i32() != 0 {
485                let result = match self.file_base.mode() {
486                    NDFileMode::Single => {
487                        if let Some(array) = self.latest_array.clone() {
488                            self.write_single(array)
489                        } else {
490                            Err(ADError::UnsupportedConversion(
491                                "no array available for write".into(),
492                            ))
493                        }
494                    }
495                    NDFileMode::Capture => self.file_base.flush_capture(&mut self.writer),
496                    NDFileMode::Stream => {
497                        if let Some(array) = self.latest_array.clone() {
498                            self.file_base.process_array(array, &mut self.writer)
499                        } else {
500                            Err(ADError::UnsupportedConversion(
501                                "no array available for write".into(),
502                            ))
503                        }
504                    }
505                };
506                match result {
507                    Ok(()) => {
508                        updates.extend(self.success_updates());
509                        self.push_num_captured_update(&mut updates);
510                        self.push_full_file_name_update(&mut updates);
511                    }
512                    Err(err) => {
513                        return ParamChangeResult::updates(self.error_updates(
514                            false,
515                            true,
516                            err.to_string(),
517                        ));
518                    }
519                }
520            }
521        } else if Some(reason) == self.params.read_file {
522            if params.value.as_i32() != 0 {
523                let result = (|| -> ADResult<Arc<NDArray>> {
524                    let path = PathBuf::from(self.file_base.create_file_name());
525                    self.writer.open_file(
526                        &path,
527                        NDFileMode::Single,
528                        &NDArray::new(vec![NDDimension::new(1)], NDDataType::UInt8),
529                    )?;
530                    let array = Arc::new(self.writer.read_file()?);
531                    self.writer.close_file()?;
532                    self.latest_array = Some(array.clone());
533                    Ok(array)
534                })();
535                match result {
536                    Ok(array) => {
537                        updates.extend(self.success_updates());
538                        self.push_full_file_name_update(&mut updates);
539                        return ParamChangeResult::combined(vec![array], updates);
540                    }
541                    Err(err) => {
542                        return ParamChangeResult::updates(self.error_updates(
543                            true,
544                            false,
545                            err.to_string(),
546                        ));
547                    }
548                }
549            }
550        } else if Some(reason) == self.params.lazy_open {
551            self.lazy_open = params.value.as_i32() != 0;
552        } else if Some(reason) == self.params.delete_driver_file {
553            self.delete_driver_file = params.value.as_i32() != 0;
554        } else if Some(reason) == self.params.free_capture {
555            if params.value.as_i32() != 0 {
556                self.file_base.clear_capture();
557                self.push_num_captured_update(&mut updates);
558            }
559        } else if Some(reason) == self.params.capture {
560            // B8: capture start/stop routes through the single owner.
561            if params.value.as_i32() != 0 {
562                if self.file_base.mode() == NDFileMode::Single {
563                    // Capture is invalid in Single mode — leave it stopped.
564                    let _ = self.stop_capture(&mut updates);
565                    return ParamChangeResult::updates(self.error_updates(
566                        false,
567                        false,
568                        "ERROR: capture not supported in Single mode".into(),
569                    ));
570                }
571                if let Err(e) = self.start_capture(&mut updates) {
572                    return ParamChangeResult::updates(self.error_updates(
573                        false,
574                        false,
575                        e.to_string(),
576                    ));
577                }
578            } else if let Err(e) = self.stop_capture(&mut updates) {
579                return ParamChangeResult::updates(self.error_updates(false, false, e.to_string()));
580            }
581        }
582
583        ParamChangeResult::updates(updates)
584    }
585
586    // ── helpers ──
587
588    fn write_single(&mut self, array: Arc<NDArray>) -> ADResult<()> {
589        self.file_base.ensure_directory()?;
590        self.file_base.process_array(array, &mut self.writer)
591    }
592
593    fn success_updates(&self) -> Vec<ParamUpdate> {
594        let mut updates = Vec::new();
595        if let Some(idx) = self.params.file_number {
596            updates.push(ParamUpdate::Int32 {
597                reason: idx,
598                addr: 0,
599                value: self.file_base.file_number,
600            });
601        }
602        if let Some(idx) = self.params.write_status {
603            updates.push(ParamUpdate::Int32 {
604                reason: idx,
605                addr: 0,
606                value: 0,
607            });
608        }
609        if let Some(idx) = self.params.write_message {
610            updates.push(ParamUpdate::Octet {
611                reason: idx,
612                addr: 0,
613                value: String::new(),
614            });
615        }
616        if let Some(idx) = self.params.write_file {
617            updates.push(ParamUpdate::Int32 {
618                reason: idx,
619                addr: 0,
620                value: 0,
621            });
622        }
623        if let Some(idx) = self.params.capture {
624            updates.push(ParamUpdate::Int32 {
625                reason: idx,
626                addr: 0,
627                value: if self.capture_active { 1 } else { 0 },
628            });
629        }
630        if let Some(idx) = self.params.read_file {
631            updates.push(ParamUpdate::Int32 {
632                reason: idx,
633                addr: 0,
634                value: 0,
635            });
636        }
637        updates
638    }
639
640    /// Emit the `CAPTURE` PV update reflecting the current `capture_active`
641    /// flag (B8 — the single owner of the capture-state transition).
642    fn push_capture_update(&self, updates: &mut Vec<ParamUpdate>) {
643        if let Some(idx) = self.params.capture {
644            updates.push(ParamUpdate::Int32 {
645                reason: idx,
646                addr: 0,
647                value: if self.capture_active { 1 } else { 0 },
648            });
649        }
650    }
651
652    fn push_num_captured_update(&self, updates: &mut Vec<ParamUpdate>) {
653        if let Some(idx) = self.params.num_captured {
654            updates.push(ParamUpdate::Int32 {
655                reason: idx,
656                addr: 0,
657                value: self.file_base.num_captured() as i32,
658            });
659        }
660    }
661
662    fn push_full_file_name_update(&self, updates: &mut Vec<ParamUpdate>) {
663        if let Some(idx) = self.params.full_file_name {
664            updates.push(ParamUpdate::Octet {
665                reason: idx,
666                addr: 0,
667                value: self.file_base.last_written_name().to_string(),
668            });
669        }
670    }
671
672    fn error_updates(
673        &self,
674        read_reason: bool,
675        write_reason: bool,
676        message: String,
677    ) -> Vec<ParamUpdate> {
678        let mut updates = Vec::new();
679        if write_reason {
680            if let Some(idx) = self.params.write_file {
681                updates.push(ParamUpdate::Int32 {
682                    reason: idx,
683                    addr: 0,
684                    value: 0,
685                });
686            }
687        }
688        if read_reason {
689            if let Some(idx) = self.params.read_file {
690                updates.push(ParamUpdate::Int32 {
691                    reason: idx,
692                    addr: 0,
693                    value: 0,
694                });
695            }
696        }
697        if let Some(idx) = self.params.write_status {
698            updates.push(ParamUpdate::Int32 {
699                reason: idx,
700                addr: 0,
701                value: 1,
702            });
703        }
704        if let Some(idx) = self.params.write_message {
705            updates.push(ParamUpdate::Octet {
706                reason: idx,
707                addr: 0,
708                value: message,
709            });
710        }
711        updates
712    }
713}
714
715fn normalize_file_path(path: &str) -> String {
716    if path.is_empty() || path.ends_with(std::path::MAIN_SEPARATOR) {
717        path.to_string()
718    } else {
719        format!("{path}{}", std::path::MAIN_SEPARATOR)
720    }
721}
722
723#[cfg(test)]
724mod tests {
725    use super::*;
726    use crate::attributes::{NDAttrSource, NDAttrValue, NDAttribute};
727    use crate::ndarray::{NDArray, NDDataType, NDDimension};
728    use std::path::Path;
729
730    /// Mock writer that records operations.
731    struct MockWriter {
732        opens: usize,
733        writes: usize,
734        closes: usize,
735        multi: bool,
736    }
737    impl MockWriter {
738        fn new(multi: bool) -> Self {
739            Self {
740                opens: 0,
741                writes: 0,
742                closes: 0,
743                multi,
744            }
745        }
746    }
747    impl NDFileWriter for MockWriter {
748        fn open_file(&mut self, _p: &Path, _m: NDFileMode, _a: &NDArray) -> ADResult<()> {
749            self.opens += 1;
750            Ok(())
751        }
752        fn write_file(&mut self, _a: &NDArray) -> ADResult<()> {
753            self.writes += 1;
754            Ok(())
755        }
756        fn read_file(&mut self) -> ADResult<NDArray> {
757            Err(ADError::UnsupportedConversion("n/a".into()))
758        }
759        fn close_file(&mut self) -> ADResult<()> {
760            self.closes += 1;
761            Ok(())
762        }
763        fn supports_multiple_arrays(&self) -> bool {
764            self.multi
765        }
766    }
767
768    fn array(id: i32) -> NDArray {
769        let mut a = NDArray::new(vec![NDDimension::new(4)], NDDataType::UInt8);
770        a.unique_id = id;
771        a
772    }
773
774    fn with_str_attr(mut a: NDArray, name: &str, val: &str) -> NDArray {
775        a.attributes.add(NDAttribute::new_static(
776            name,
777            "",
778            NDAttrSource::Driver,
779            NDAttrValue::String(val.to_string()),
780        ));
781        a
782    }
783
784    fn with_i32_attr(mut a: NDArray, name: &str, val: i32) -> NDArray {
785        a.attributes.add(NDAttribute::new_static(
786            name,
787            "",
788            NDAttrSource::Driver,
789            NDAttrValue::Int32(val),
790        ));
791        a
792    }
793
794    #[test]
795    fn test_g9_destination_routing_skips_other_port() {
796        // G9: a frame addressed to another plugin via FilePluginDestination
797        // is not written by this plugin.
798        let mut c = FilePluginController::new(MockWriter::new(true));
799        c.set_port_name("MYFILE");
800        c.file_base.set_mode(NDFileMode::Single);
801        c.auto_save = true;
802
803        // Destination = a different port → skipped.
804        c.process_array(&with_str_attr(array(1), "FilePluginDestination", "OTHER"));
805        assert_eq!(c.writer.writes, 0, "frame for OTHER port must be skipped");
806
807        // Destination = this port → written.
808        c.process_array(&with_str_attr(array(2), "FilePluginDestination", "MYFILE"));
809        assert_eq!(c.writer.writes, 1);
810
811        // Destination = "all" → written.
812        c.process_array(&with_str_attr(array(3), "FilePluginDestination", "all"));
813        assert_eq!(c.writer.writes, 2);
814    }
815
816    #[test]
817    fn test_g9_file_close_attribute_forces_close() {
818        // G9: a FilePluginClose attribute forces an open stream to close.
819        let mut c = FilePluginController::new(MockWriter::new(true));
820        c.set_port_name("F");
821        c.file_base.set_mode(NDFileMode::Stream);
822        c.file_base.set_num_capture(10);
823        c.lazy_open = true;
824        let mut updates = Vec::new();
825        c.process_array(&array(1)); // cache an array
826        c.start_capture(&mut updates).unwrap();
827        let _ = &updates;
828        c.process_array(&array(2)); // opens + writes
829        assert!(c.file_base.is_open());
830
831        c.process_array(&with_i32_attr(array(3), "FilePluginClose", 1));
832        assert!(
833            !c.file_base.is_open(),
834            "FilePluginClose must close the file"
835        );
836        assert!(!c.capture_active, "close attribute stops capture");
837    }
838
839    #[test]
840    fn test_b8_capture_owner_round_trip() {
841        // B8: start_capture / stop_capture own the capture state together.
842        let mut c = FilePluginController::new(MockWriter::new(true));
843        c.set_port_name("F");
844        c.file_base.set_mode(NDFileMode::Capture);
845        c.params.capture = Some(7);
846        let mut updates = Vec::new();
847        c.start_capture(&mut updates).unwrap();
848        assert!(c.capture_active);
849        c.stop_capture(&mut updates).unwrap();
850        assert!(!c.capture_active);
851        // CAPTURE PV updates emitted on each transition.
852        assert!(!updates.is_empty());
853    }
854
855    #[test]
856    fn test_b9_non_lazy_opens_eagerly_at_capture_start() {
857        // B9: a non-lazy stream plugin opens the file at capture-start.
858        let mut c = FilePluginController::new(MockWriter::new(true));
859        c.set_port_name("F");
860        c.file_base.set_mode(NDFileMode::Stream);
861        c.lazy_open = false;
862        c.process_array(&array(1)); // cache a frame for the layout
863        let mut updates = Vec::new();
864        c.start_capture(&mut updates).unwrap();
865        assert!(
866            c.file_base.is_open(),
867            "non-lazy stream opens at capture start"
868        );
869        assert_eq!(c.writer.opens, 1);
870    }
871
872    #[test]
873    fn test_b9_lazy_defers_open_to_first_frame() {
874        let mut c = FilePluginController::new(MockWriter::new(true));
875        c.set_port_name("F");
876        c.file_base.set_mode(NDFileMode::Stream);
877        c.file_base.set_num_capture(10);
878        c.lazy_open = true;
879        c.process_array(&array(1));
880        let mut updates = Vec::new();
881        c.start_capture(&mut updates).unwrap();
882        assert!(
883            !c.file_base.is_open(),
884            "lazy stream does NOT open at capture start"
885        );
886        c.process_array(&array(2));
887        assert!(c.file_base.is_open(), "lazy stream opens on first frame");
888    }
889
890    #[test]
891    fn test_g12_capture_mode_validates_frames() {
892        // G12: Capture mode rejects frames of mismatched dimensions.
893        let mut c = FilePluginController::new(MockWriter::new(true));
894        c.set_port_name("F");
895        c.file_base.set_mode(NDFileMode::Capture);
896        c.file_base.set_num_capture(10);
897        let mut updates = Vec::new();
898        c.start_capture(&mut updates).unwrap();
899
900        c.process_array(&array(1)); // first frame: 4-element, recorded
901        assert_eq!(c.file_base.num_captured(), 1);
902
903        // Mismatched frame: different dimension size → rejected.
904        let mut big = NDArray::new(vec![NDDimension::new(8)], NDDataType::UInt8);
905        big.unique_id = 2;
906        c.process_array(&big);
907        assert_eq!(c.file_base.num_captured(), 1, "mismatched frame rejected");
908
909        // Matching frame: accepted.
910        c.process_array(&array(3));
911        assert_eq!(c.file_base.num_captured(), 2);
912    }
913
914    #[test]
915    fn test_b17_write_mode_switch_closes_open_stream() {
916        // B17: switching WriteMode mid-stream closes the open file.
917        let mut c = FilePluginController::new(MockWriter::new(true));
918        c.set_port_name("F");
919        c.file_base.set_mode(NDFileMode::Stream);
920        c.file_base.set_num_capture(10);
921        c.params.write_mode = Some(5);
922        c.lazy_open = false;
923        c.process_array(&array(1));
924        let mut updates = Vec::new();
925        c.start_capture(&mut updates).unwrap();
926        assert!(c.file_base.is_open());
927
928        // Switch to Capture mode while a stream is open.
929        let snap = PluginParamSnapshot {
930            enable_callbacks: true,
931            reason: 5,
932            addr: 0,
933            value: ParamChangeValue::Int32(NDFileMode::Capture as i32),
934        };
935        c.on_param_change(5, &snap);
936        assert!(
937            !c.file_base.is_open(),
938            "mode switch must close the open stream"
939        );
940        assert!(!c.capture_active);
941    }
942
943    #[test]
944    fn test_b7_capture_num_capture_zero_buffers_forever() {
945        // B7: num_capture==0 buffers indefinitely (no auto-flush).
946        let mut c = FilePluginController::new(MockWriter::new(true));
947        c.set_port_name("F");
948        c.file_base.set_mode(NDFileMode::Capture);
949        c.file_base.set_num_capture(0);
950        c.auto_save = true;
951        let mut updates = Vec::new();
952        c.start_capture(&mut updates).unwrap();
953        for id in 1..=5 {
954            c.process_array(&array(id));
955        }
956        assert_eq!(
957            c.file_base.num_captured(),
958            5,
959            "all frames buffered, no flush"
960        );
961        assert_eq!(c.writer.writes, 0, "num_capture==0 never auto-flushes");
962        assert!(c.capture_active, "still capturing");
963    }
964
965    #[test]
966    fn test_g10_array_counter_counts_saved_frames() {
967        // G10: ArrayCounter override reflects saved frames.
968        let mut c = FilePluginController::new(MockWriter::new(false));
969        c.set_port_name("F");
970        c.params.array_counter = Some(99);
971        c.file_base.set_mode(NDFileMode::Single);
972        c.auto_save = true;
973        let r1 = c.process_array(&array(1));
974        let counter1 = r1.param_updates.iter().find_map(|u| match u {
975            ParamUpdate::Int32 {
976                reason: 99, value, ..
977            } => Some(*value),
978            _ => None,
979        });
980        assert_eq!(counter1, Some(1), "first saved frame → ArrayCounter 1");
981        let r2 = c.process_array(&array(2));
982        let counter2 = r2.param_updates.iter().find_map(|u| match u {
983            ParamUpdate::Int32 {
984                reason: 99, value, ..
985            } => Some(*value),
986            _ => None,
987        });
988        assert_eq!(counter2, Some(2));
989    }
990}