Skip to main content

cmtool_data/
case.rs

1// SPDX-License-Identifier: GPL-3.0-or-later
2
3use crate::{CMAExportType, DataError};
4use serde::{Deserialize, Serialize};
5use std::io::{BufReader, Read, Write};
6use std::{collections::HashMap, fs, path::Path};
7
8/// Represents a case configuration for a computational model analysis.
9///
10/// Each `CMCase` contains information about the number of divisions, a description of the case,
11/// the time spent per flow map, and paths to exported data based on different types.
12///
13/// # Fields
14///
15/// * `n_div` - An array of three unsigned integers representing the number of divisions in each dimension.
16/// * `description` - A string describing the case configuration.
17/// * `time_per_flow_map` - A floating-point value representing the time per flow map in seconds.
18/// * `paths` - A map from export types to file paths where the data can be accessed.
19#[derive(Serialize, Deserialize, Debug, Default, Clone)]
20pub struct CMCase {
21    pub n_div: [u32; 3],
22    pub description: String,
23    pub time_per_flow_map: f64,
24    paths: HashMap<CMAExportType, String>,
25    /// Whether the case is spread over sibling `i_0/`, `i_1/`, … folders.
26    ///
27    /// The serialized name is misspelled (`is_reursive`) and is kept as-is for
28    /// backwards compatibility with every case file already written; the
29    /// correctly spelled `is_recursive` is accepted on read as well.
30    #[serde(rename = "is_reursive", alias = "is_recursive")]
31    pub is_recursive: bool,
32}
33
34impl std::fmt::Display for CMCase {
35    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
36        writeln!(f, "CMCase Configuration:")?;
37        writeln!(
38            f,
39            "  - Number of Divisions: [{}x{}x{}]",
40            self.n_div[0], self.n_div[1], self.n_div[2]
41        )?;
42        writeln!(f, "  - Description: {}", self.description)?;
43        writeln!(
44            f,
45            "  - Time per Flow Map: {:.2} seconds",
46            self.time_per_flow_map
47        )?;
48
49        // Show paths, iterating over the HashMap
50        writeln!(f, "  - Export Paths:\n")?;
51        for export_type in self.paths.keys() {
52            writeln!(f, "    - {:?}\n", export_type)?;
53        }
54
55        writeln!(
56            f,
57            "  - Recursive: {}",
58            if self.is_recursive { "Yes" } else { "No" }
59        )?;
60        Ok(())
61    }
62}
63
64pub const DEFAULT_CASE_FILE_NAME: &str = "cma_case";
65
66impl CMCase {
67    pub fn n_compartment(&self) -> u32 {
68        if self.n_div.contains(&0) {
69            return 1; //FIXME
70        }
71        self.n_div.iter().product()
72    }
73
74    pub fn toggle_recursive(&mut self) {
75        self.is_recursive = !self.is_recursive;
76    }
77
78    pub fn is_two_phase_flow(&self) -> bool {
79        self.paths.contains_key(&CMAExportType::GasVolume)
80    }
81
82    pub fn add(&mut self, stype: CMAExportType, relative_path: &str) {
83        self.paths.insert(stype, relative_path.to_string());
84    }
85
86    pub fn resolve(&self, root: &str, stype: CMAExportType) -> Option<String> {
87        let rel = self.paths.get(&stype)?;
88        Some(Path::new(root).join(rel).to_str()?.to_string())
89    }
90
91    pub fn resolve_all(&self, root: &str, stype: CMAExportType) -> Option<Vec<String>> {
92        let rel = self.paths.get(&stype)?;
93        if self.is_recursive {
94            Some(
95                self.get_folders(root)
96                    .ok()?
97                    .iter()
98                    .map(|folder_name| {
99                        Path::new(root)
100                            .join(folder_name)
101                            .join(rel)
102                            .to_str()
103                            .map(|s| s.to_string())
104                    })
105                    .collect::<Option<Vec<_>>>()?,
106            )
107        } else {
108            let pa = self.resolve(root, stype)?;
109            Some(vec![pa])
110        }
111    }
112
113    pub fn prepend_path(mut self, prep: &str) -> Self {
114        for (_key, path) in self.paths.iter_mut() {
115            *path = format!("{}/{}", prep, path);
116        }
117        self
118    }
119
120    /// Lists the `i_<n>` sub-folders of `root`, ordered by `<n>`.
121    ///
122    /// # Errors
123    /// Returns `DataError::IO` if `root` cannot be read (missing, not a
124    /// directory, no permission).
125    pub fn get_folders(&self, root: &str) -> Result<Vec<String>, DataError> {
126        let mut folders: Vec<(usize, String)> = std::fs::read_dir(root)?
127            .filter_map(|entry| {
128                let dir = entry.ok()?;
129                let file_name = dir.file_name();
130                let file_name_str = file_name.to_string_lossy();
131                let index: usize = file_name_str.strip_prefix("i_")?.parse().ok()?;
132                Some((index, file_name_str.to_string()))
133            })
134            .collect();
135
136        folders.sort_by_key(|(index, _)| *index);
137        Ok(folders.into_iter().map(|(_, name)| name).collect())
138    }
139
140    fn check(&self) -> bool {
141        let has_gas_volume = self.paths.contains_key(&CMAExportType::GasVolume);
142        let has_gas_flow = self.paths.contains_key(&CMAExportType::GasFlow);
143
144        let has_liq_volume = self.paths.contains_key(&CMAExportType::LiquidVolume);
145        let has_liq_flow = self.paths.contains_key(&CMAExportType::LiquidFlow);
146
147        let ok_gas = if has_gas_volume && !has_gas_flow {
148            false
149        } else {
150            // !(has_gas_flow && !has_gas_volume)
151            !has_gas_flow || has_gas_volume
152        };
153
154        let ok_liq = if has_liq_volume && !has_liq_flow {
155            false
156        } else {
157            // !(has_liq_flow && !has_liq_volume)
158            !has_liq_flow || has_liq_volume
159        };
160
161        ok_liq && ok_gas
162    }
163
164    pub fn new(
165        n_div: [u32; 3],
166        time_per_flow_map: f64,
167        description: Option<String>,
168        recursive: bool,
169    ) -> Self {
170        let description = description.unwrap_or(String::from("Case"));
171        Self {
172            n_div,
173            time_per_flow_map,
174            is_recursive: recursive,
175            description,
176            paths: HashMap::new(),
177        }
178    }
179}
180
181/// A trait for reading a `CMCase` from a specified path.
182///
183/// Implement this trait for types that are capable of reading a case configuration
184/// from disk or another storage medium.
185pub trait CMCaseReader {
186    /// Reads a `CMCase` from the given path.
187    ///
188    /// # Arguments
189    ///
190    /// * `path` - A reference to the `Path` from which to read the case configuration.
191    ///
192    /// # Returns
193    ///
194    /// Returns a `Result` with a `CMCase` on success or a `DataError` on failure.
195    fn read_case(path: &Path) -> Result<CMCase, DataError>;
196}
197
198pub fn read_case(path: &Path) -> Result<CMCase, DataError> {
199    let mut file = std::fs::File::open(path)?;
200    let mut buffer = [0; 4]; // read the first few bytes
201    let n = file.read(&mut buffer)?;
202
203    // Simple heuristic: if starts with '{' or '[' treat as JSON
204    if n > 0 && (buffer[0] == b'{' || buffer[0] == b'[') {
205        CMCaseJson::read_case(path)
206    } else {
207        CCMCaseInfo::read_case(path)
208    }
209}
210
211/// A trait for writing a `CMCase` to a specified path.
212///
213/// Implement this trait for types that are capable of writing a case configuration
214/// to disk or another storage medium.
215pub trait CMCaseWriter {
216    /// Writes a `CMCase` to the given path.
217    ///
218    /// # Arguments
219    ///
220    /// * `case` - The `CMCase` instance to write.
221    /// * `path` - A reference to the `Path` where the case configuration should be written.
222    ///
223    /// # Returns
224    ///
225    /// Returns a `Result` indicating success or a `DataError` on failure.
226    fn write_case(case: CMCase, path: &Path) -> Result<(), DataError>;
227}
228/// A type responsible for reading and writing `CMCase` instances to/from JSON files.
229pub struct CMCaseJson;
230
231/// A type responsible for reading and writing `CMCase` instances C comparible (binary) files.
232pub struct CCMCaseInfo;
233
234impl CMCaseReader for CMCaseJson {
235    fn read_case(path: &Path) -> Result<CMCase, DataError> {
236        let mut file = std::fs::File::open(path)?;
237        let mut contents = String::new();
238        file.read_to_string(&mut contents)?;
239
240        let case = serde_json::from_str(&contents).map_err(|_| DataError::Serde)?;
241        Ok(case)
242    }
243}
244
245impl CMCaseWriter for CMCaseJson {
246    fn write_case(case: CMCase, path: &Path) -> Result<(), DataError> {
247        if !case.check() {
248            return Err(DataError::BadData);
249        }
250
251        let json_string = serde_json::to_string(&case).map_err(|_| DataError::Serde)?;
252        let mut file = std::fs::File::create(path)?;
253        file.write_all(json_string.as_bytes())?;
254
255        Ok(())
256    }
257}
258
259impl CMCaseReader for CCMCaseInfo {
260    fn read_case(path: &Path) -> Result<CMCase, DataError> {
261        //C Caseformat do not have recursive flag, manual detection here:
262
263        // `parent()` is `None` only for a root path, and `Some("")` for a bare
264        // file name — both mean "the current directory" here.
265        let root = match path.parent() {
266            Some(p) if !p.as_os_str().is_empty() => p,
267            _ => Path::new("."),
268        };
269        let is_recursive = std::fs::read_dir(root)
270            .map(|entries| {
271                entries.flatten().any(|dir| {
272                    dir.file_name()
273                        .to_string_lossy()
274                        .strip_prefix("i_")
275                        .is_some_and(|index| index.parse::<usize>().is_ok())
276                })
277            })
278            .unwrap_or(false);
279
280        let file = fs::File::open(path)?;
281        let mut buffer = BufReader::new(file);
282
283        let mut char_buf = [0u8; 1];
284        let mut buf = [0u8; 4];
285        let mut buffer_8bytes = [0u8; 8];
286
287        let mut case = CMCase::default();
288        if is_recursive {
289            case.toggle_recursive();
290        }
291        for i in &mut case.n_div {
292            buffer.read_exact(&mut buf)?;
293            *i = u32::from_le_bytes(buf);
294        }
295
296        buffer.read_exact(&mut buf)?;
297        let string_size = u32::from_le_bytes(buf);
298
299        let mut string_buf = vec![0; string_size as usize];
300        buffer.read_exact(&mut string_buf)?;
301        case.description = String::from_utf8(string_buf).map_err(|_| DataError::BadData)?;
302
303        buffer.read_exact(&mut buffer_8bytes)?;
304
305        case.time_per_flow_map = f64::from_le_bytes(buffer_8bytes);
306
307        buffer.read_exact(&mut buffer_8bytes)?;
308        let map_size = usize::from_le_bytes(buffer_8bytes);
309
310        for _ in 0..map_size {
311            buffer.read_exact(&mut char_buf)?;
312
313            let key = CMAExportType::from(i8::from_le_bytes(char_buf));
314
315            buffer.read_exact(&mut buf)?;
316            let string_size = u32::from_le_bytes(buf);
317
318            let mut string_buf = vec![0; string_size as usize];
319            buffer.read_exact(&mut string_buf)?;
320            let value = String::from_utf8(string_buf).map_err(|_| DataError::BadData)?;
321
322            case.paths.insert(key, value);
323        }
324
325        Ok(case)
326    }
327}
328
329impl CMCaseWriter for CCMCaseInfo {
330    fn write_case(case: CMCase, path: &Path) -> Result<(), DataError> {
331        if !case.check() {
332            return Err(DataError::BadData);
333        }
334
335        let mut file = std::fs::File::create(path)?;
336
337        for &div in &case.n_div {
338            file.write_all(&div.to_le_bytes())?;
339        }
340
341        let description_bytes = case.description.as_bytes();
342        file.write_all(&(description_bytes.len() as u32).to_le_bytes())?;
343        file.write_all(description_bytes)?;
344
345        file.write_all(&case.time_per_flow_map.to_le_bytes())?;
346
347        file.write_all(&(case.paths.len() as u64).to_le_bytes())?;
348
349        for (key, value) in &case.paths {
350            file.write_all(&(*key as i8).to_le_bytes())?;
351            let value_bytes = value.as_bytes();
352            file.write_all(&(value_bytes.len() as u32).to_le_bytes())?;
353            file.write_all(value_bytes)?;
354        }
355
356        Ok(())
357    }
358}
359
360#[cfg(test)]
361mod test {
362
363    use std::fs::remove_file;
364
365    use super::*;
366
367    fn commomn_read_test<T: CMCaseReader>() {
368        let manifest_dir = env!("CARGO_MANIFEST_DIR"); // compile-time
369        let binding = Path::new(manifest_dir)
370            .join("test_data")
371            .join(DEFAULT_CASE_FILE_NAME);
372        let path = binding.as_path();
373
374        println!("{:?}", path);
375
376        let rcase = T::read_case(path);
377
378        assert!(rcase.is_ok());
379
380        let case = rcase.unwrap();
381
382        assert!(case.n_div == [6, 6, 12]);
383        assert!(case.description == *"Sanofi");
384        assert!(case.time_per_flow_map == 0.);
385        assert!(case.paths.get(&CMAExportType::LiquidVolume).unwrap() == "./raw/./vofL.raw");
386
387        eprintln!("{:?}", case)
388    }
389
390    fn commomn_write_read_test<T: CMCaseReader, F: CMCaseWriter>() {
391        let manifest_dir = env!("CARGO_MANIFEST_DIR"); // compile-time
392        let binding = Path::new(manifest_dir).join("test_data/cma_case");
393        let path = binding.as_path();
394
395        let rcase = T::read_case(path);
396
397        assert!(rcase.is_ok());
398
399        let case = rcase.unwrap();
400
401        assert!(F::write_case(case, Path::new("./case_test")).is_ok());
402
403        let wr_case = T::read_case(Path::new("./case_test"));
404        assert!(wr_case.is_ok());
405        let wr_case = wr_case.unwrap();
406        assert!(wr_case.n_div == [6, 6, 12]);
407        assert!(wr_case.description == *"Sanofi");
408        assert!(wr_case.time_per_flow_map == 0.);
409        assert!(wr_case.paths.get(&CMAExportType::LiquidVolume).unwrap() == "./raw/./vofL.raw");
410
411        std::fs::remove_file("./case_test").unwrap();
412    }
413
414    #[test]
415    fn test_read_c_compatible() {
416        commomn_read_test::<CCMCaseInfo>();
417    }
418
419    #[test]
420    fn test_read_write_c_compatible() {
421        commomn_write_read_test::<CCMCaseInfo, CCMCaseInfo>();
422    }
423
424    #[test]
425    fn test_conversion() {
426        let manifest_dir = env!("CARGO_MANIFEST_DIR"); // compile-time
427        let binding = Path::new(manifest_dir)
428            .join("test_data")
429            .join(DEFAULT_CASE_FILE_NAME);
430        let c_path = binding.as_path();
431
432        let reference_case = CCMCaseInfo::read_case(c_path).unwrap();
433
434        let rcase = CCMCaseInfo::read_case(c_path).unwrap();
435
436        CMCaseJson::write_case(rcase, Path::new("test_2case.json")).unwrap();
437
438        let converted = CMCaseJson::read_case(Path::new("test_2case.json")).unwrap();
439
440        assert!(reference_case.n_div == converted.n_div);
441        assert!(reference_case.description == converted.description);
442        assert!(reference_case.time_per_flow_map == converted.time_per_flow_map);
443        assert!(reference_case.paths == converted.paths);
444
445        remove_file(Path::new("test_2case.json")).expect("Failed to remove test file")
446    }
447
448    fn common_write_read_test<T: CMCaseWriter + CMCaseReader>(path: &Path) -> Result<(), ()> {
449        let case = CMCase {
450            n_div: [4, 5, 1],
451            description: "Test".to_string(),
452            time_per_flow_map: 0.01,
453            paths: HashMap::new(),
454            is_recursive: false,
455        };
456
457        T::write_case(case, path).map_err(|_| ())?;
458        let read_case = T::read_case(path).map_err(|_| ())?;
459        assert!(read_case.n_div == [4, 5, 1]);
460        assert!(read_case.description == *"Test");
461        assert!(read_case.time_per_flow_map == 0.01);
462        Ok(())
463    }
464
465    #[test]
466    fn test_read_json() {
467        let path = Path::new("test_case.json");
468        let case = CMCase {
469            n_div: [4, 5, 1],
470            description: "Test".to_string(),
471            time_per_flow_map: 0.01,
472            paths: HashMap::new(),
473            is_recursive: false,
474        };
475
476        CMCaseJson::write_case(case, path).expect("Failed to write case");
477        let read_case = CMCaseJson::read_case(path).expect("Failed to read case");
478        assert!(read_case.n_div == [4, 5, 1]);
479        assert!(read_case.description == *"Test");
480        assert!(read_case.time_per_flow_map == 0.01);
481
482        remove_file(path).expect("Failed to remove test file");
483    }
484
485    #[test]
486    fn test_read_write() {
487        let path = Path::new("test_case_common.json");
488        common_write_read_test::<CMCaseJson>(path).expect("Common write-read test failed");
489        remove_file(path).expect("Failed to remove test file");
490    }
491
492    #[test]
493    fn get_folders_on_missing_root_is_an_error() {
494        let case = CMCase::new([1, 1, 1], 1., None, true);
495
496        assert!(case.get_folders("./this_directory_does_not_exist").is_err());
497        assert!(
498            case.resolve_all("./this_directory_does_not_exist", CMAExportType::LiquidFlow)
499                .is_none()
500        );
501    }
502
503    #[test]
504    fn get_folders_is_ordered_numerically() {
505        let root = std::env::temp_dir().join("cmtool_get_folders_test");
506        let _ = std::fs::remove_dir_all(&root);
507        // Created out of order, and with decoys that must be skipped.
508        for name in ["i_10", "i_2", "i_0", "i_notanumber", "raw"] {
509            std::fs::create_dir_all(root.join(name)).unwrap();
510        }
511
512        let case = CMCase::new([1, 1, 1], 1., None, true);
513        let folders = case.get_folders(root.to_str().unwrap()).unwrap();
514
515        assert_eq!(folders, vec!["i_0", "i_2", "i_10"]);
516
517        std::fs::remove_dir_all(&root).unwrap();
518    }
519
520    /// The legacy binary reader used to build its strings with
521    /// `from_utf8_unchecked`, which made a corrupt file undefined behaviour.
522    #[test]
523    fn c_compatible_rejects_invalid_utf8() {
524        let path = std::env::temp_dir().join("cmtool_bad_utf8_cma_case");
525
526        let mut bytes = Vec::new();
527        for div in [6u32, 6, 12] {
528            bytes.extend_from_slice(&div.to_le_bytes());
529        }
530        let description = [0xff_u8, 0xfe, 0xfd, 0xfc]; // not valid UTF-8
531        bytes.extend_from_slice(&(description.len() as u32).to_le_bytes());
532        bytes.extend_from_slice(&description);
533        bytes.extend_from_slice(&0f64.to_le_bytes());
534        bytes.extend_from_slice(&0u64.to_le_bytes()); // empty path map
535        std::fs::write(&path, &bytes).unwrap();
536
537        assert!(matches!(
538            CCMCaseInfo::read_case(&path),
539            Err(DataError::BadData)
540        ));
541
542        remove_file(&path).unwrap();
543    }
544
545    /// The `is_recursive` field is serialized under its historical misspelling,
546    /// so already-written case files keep loading.
547    #[test]
548    fn recursive_flag_keeps_its_legacy_wire_name() {
549        let mut case = CMCase::new([1, 1, 1], 1., None, false);
550        case.toggle_recursive();
551        assert!(case.is_recursive);
552
553        let json = serde_json::to_string(&case).unwrap();
554        assert!(json.contains("\"is_reursive\":true"), "{}", json);
555
556        let legacy: CMCase = serde_json::from_str(
557            r#"{"n_div":[1,1,1],"description":"d","time_per_flow_map":1.0,"paths":{},"is_reursive":true}"#,
558        )
559        .unwrap();
560        assert!(legacy.is_recursive);
561
562        let renamed: CMCase = serde_json::from_str(
563            r#"{"n_div":[1,1,1],"description":"d","time_per_flow_map":1.0,"paths":{},"is_recursive":true}"#,
564        )
565        .unwrap();
566        assert!(renamed.is_recursive);
567    }
568}