ixa/
report.rs

1use std::any::TypeId;
2use std::cell::{RefCell, RefMut};
3use std::env;
4use std::fs::File;
5use std::path::PathBuf;
6
7use csv::Writer;
8use serde::Serializer;
9
10use crate::context::Context;
11use crate::error::IxaError;
12use crate::{define_data_plugin, error, trace, ContextBase, HashMap, HashMapExt};
13
14// * file_prefix: precedes the report name in the filename. An example of a
15// potential prefix might be scenario or simulation name
16// * directory: location that the CSVs are written to. An example of this might
17// be /data/
18// * overwrite: if true, will overwrite existing files in the same location
19pub struct ConfigReportOptions {
20    pub file_prefix: String,
21    pub output_dir: PathBuf,
22    pub overwrite: bool,
23}
24
25impl ConfigReportOptions {
26    #[must_use]
27    pub fn new() -> Self {
28        trace!("new ConfigReportOptions");
29        // Sets the defaults
30        ConfigReportOptions {
31            file_prefix: String::new(),
32            output_dir: env::current_dir().unwrap(),
33            overwrite: false,
34        }
35    }
36    /// Sets the file prefix option (e.g., "report_")
37    pub fn file_prefix(&mut self, file_prefix: impl Into<String>) -> &mut ConfigReportOptions {
38        let file_prefix = file_prefix.into();
39        trace!("setting report prefix to {file_prefix}");
40        self.file_prefix = file_prefix;
41        self
42    }
43    /// Sets the directory where reports will be output
44    pub fn directory(&mut self, directory: impl Into<PathBuf>) -> &mut ConfigReportOptions {
45        let directory = directory.into();
46        trace!("setting report directory to {directory:?}");
47        self.output_dir = directory;
48        self
49    }
50    /// Sets whether to overwrite existing reports of the same name if they exist
51    pub fn overwrite(&mut self, overwrite: bool) -> &mut ConfigReportOptions {
52        trace!("setting report overwrite {overwrite}");
53        self.overwrite = overwrite;
54        self
55    }
56}
57
58impl Default for ConfigReportOptions {
59    fn default() -> Self {
60        Self::new()
61    }
62}
63
64pub trait Report: 'static {
65    // Returns report type
66    fn type_id(&self) -> TypeId;
67    // Serializes the data with the correct writer
68    fn serialize(&self, writer: &mut Writer<File>);
69}
70
71/// # Errors
72/// function will return Error if it fails to `serialize_str`
73#[allow(clippy::trivially_copy_pass_by_ref)]
74#[allow(dead_code)]
75pub fn serialize_f64<S, const N: usize>(value: &f64, serializer: S) -> Result<S::Ok, S::Error>
76where
77    S: Serializer,
78{
79    let formatted = format!("{value:.N$}");
80    serializer.serialize_str(&formatted)
81}
82
83/// # Errors
84/// function will return Error if it fails to `serialize_str`
85#[allow(clippy::trivially_copy_pass_by_ref)]
86#[allow(dead_code)]
87pub fn serialize_f32<S, const N: usize>(value: &f32, serializer: S) -> Result<S::Ok, S::Error>
88where
89    S: Serializer,
90{
91    let formatted = format!("{value:.N$}");
92    serializer.serialize_str(&formatted)
93}
94
95struct ReportData {
96    file_writers: RefCell<HashMap<TypeId, Writer<File>>>,
97    config: ConfigReportOptions,
98}
99
100// Registers a data container that stores
101// * file_writers: Maps report type to file writer
102// * config: Contains all the customizable filename options that the user supplies
103define_data_plugin!(
104    ReportPlugin,
105    ReportData,
106    ReportData {
107        file_writers: RefCell::new(HashMap::new()),
108        config: ConfigReportOptions::new(),
109    }
110);
111
112pub trait ContextReportExt: ContextBase {
113    // Builds the filename. Called by `add_report`, `short_name` refers to the
114    // report type. The three main components are `prefix`, `directory`, and
115    // `short_name`.
116    fn generate_filename(&mut self, short_name: &str) -> PathBuf {
117        let data_container = self.get_data_mut(ReportPlugin);
118        let prefix = &data_container.config.file_prefix;
119        let directory = &data_container.config.output_dir;
120        let short_name = short_name.to_string();
121        let basename = format!("{prefix}{short_name}");
122        directory.join(basename).with_extension("csv")
123    }
124
125    /// Add a report file keyed by a [`TypeId`].
126    /// The `short_name` is used for file naming to distinguish what data each
127    /// output file points to.
128    /// # Errors
129    /// If the file already exists and `overwrite` is set to false, raises an error and info message.
130    /// If the file cannot be created, raises an error.
131    fn add_report_by_type_id(&mut self, type_id: TypeId, short_name: &str) -> Result<(), IxaError> {
132        trace!("adding report {short_name} by type_id {type_id:?}");
133        let path = self.generate_filename(short_name);
134
135        let data_container = self.get_data_mut(ReportPlugin);
136
137        let file_creation_result = File::create_new(&path);
138        let created_file = match file_creation_result {
139            Ok(file) => file,
140            Err(e) => match e.kind() {
141                std::io::ErrorKind::AlreadyExists => {
142                    if data_container.config.overwrite {
143                        File::create(&path)?
144                    } else {
145                        error!("File already exists: {}. Please set `overwrite` to true in the file configuration and rerun.", path.display());
146                        return Err(IxaError::IoError(e));
147                    }
148                }
149                _ => {
150                    return Err(IxaError::IoError(e));
151                }
152            },
153        };
154        let writer = Writer::from_writer(created_file);
155        let mut file_writer = data_container.file_writers.borrow_mut();
156        file_writer.insert(type_id, writer);
157        Ok(())
158    }
159
160    /// Call `add_report` with each report type, passing the name of the report type.
161    /// The `short_name` is used for file naming to distinguish what data each
162    /// output file points to.
163    /// # Errors
164    /// If the file already exists and `overwrite` is set to false, raises an error and info message.
165    /// If the file cannot be created, raises an error.
166    fn add_report<T: Report + 'static>(&mut self, short_name: &str) -> Result<(), IxaError> {
167        trace!("Adding report {short_name}");
168        self.add_report_by_type_id(TypeId::of::<T>(), short_name)
169    }
170
171    fn get_writer(&self, type_id: TypeId) -> RefMut<Writer<File>> {
172        // No data container will exist if no reports have been added
173        let data_container = self.get_data(ReportPlugin);
174        let writers = data_container.file_writers.try_borrow_mut().unwrap();
175        RefMut::map(writers, |writers| {
176            writers
177                .get_mut(&type_id)
178                .expect("No writer found for the report type")
179        })
180    }
181
182    /// Write a new row to the appropriate report file
183    fn send_report<T: Report>(&self, report: T) {
184        let writer = &mut self.get_writer(report.type_id());
185        report.serialize(writer);
186    }
187
188    /// Returns a `ConfigReportOptions` object which has setter methods for report configuration
189    fn report_options(&mut self) -> &mut ConfigReportOptions {
190        let data_container = self.get_data_mut(ReportPlugin);
191        &mut data_container.config
192    }
193}
194impl ContextReportExt for Context {}
195
196#[cfg(test)]
197mod test {
198    use core::convert::TryInto;
199    use std::thread;
200
201    use serde_derive::{Deserialize, Serialize};
202    use tempfile::tempdir;
203
204    use super::*;
205    use crate::{define_entity, define_property, define_report, info};
206
207    define_entity!(Person);
208
209    define_property!(
210        struct IsRunner(bool),
211        Person,
212        default_const = IsRunner(false)
213    );
214
215    #[derive(Serialize, Deserialize)]
216    struct SampleReport {
217        id: u32,
218        value: String,
219    }
220
221    define_report!(SampleReport);
222
223    #[test]
224    fn add_and_send_report() {
225        let temp_dir = tempdir().unwrap();
226        let path = PathBuf::from(&temp_dir.path());
227        // We need the writer to go out of scope so the file is flushed
228        {
229            let mut context = Context::new();
230            let config = context.report_options();
231            config
232                .file_prefix("prefix1_".to_string())
233                .directory(path.clone());
234            context.add_report::<SampleReport>("sample_report").unwrap();
235            let report = SampleReport {
236                id: 1,
237                value: "Test Value".to_string(),
238            };
239
240            context.send_report(report);
241        }
242
243        let file_path = path.join("prefix1_sample_report.csv");
244        assert!(file_path.exists(), "CSV file should exist");
245        assert!(file_path.metadata().unwrap().len() > 0);
246
247        let mut reader = csv::Reader::from_path(file_path).unwrap();
248        for result in reader.deserialize() {
249            let record: SampleReport = result.unwrap();
250            assert_eq!(record.id, 1);
251            assert_eq!(record.value, "Test Value");
252        }
253    }
254
255    #[test]
256    fn add_report_empty_prefix() {
257        let temp_dir = tempdir().unwrap();
258        let path = PathBuf::from(&temp_dir.path());
259        // We need the writer to go out of scope so the file is flushed
260        {
261            let mut context = Context::new();
262            let config = context.report_options();
263            config.directory(path.clone());
264            context.add_report::<SampleReport>("sample_report").unwrap();
265            let report = SampleReport {
266                id: 1,
267                value: "Test Value".to_string(),
268            };
269
270            context.send_report(report);
271        }
272        let file_path = path.join("sample_report.csv");
273        assert!(file_path.exists(), "CSV file should exist");
274        assert!(file_path.metadata().unwrap().len() > 0);
275
276        let mut reader = csv::Reader::from_path(file_path).unwrap();
277        for result in reader.deserialize() {
278            let record: SampleReport = result.unwrap();
279            assert_eq!(record.id, 1);
280            assert_eq!(record.value, "Test Value");
281        }
282    }
283
284    struct PathBufWithDrop {
285        file: PathBuf,
286    }
287
288    impl Drop for PathBufWithDrop {
289        fn drop(&mut self) {
290            std::fs::remove_file(&self.file).unwrap();
291        }
292    }
293
294    #[test]
295    fn add_report_no_dir() {
296        // We need the writer to go out of scope so the file is flushed
297        {
298            let mut context = Context::new();
299            let config = context.report_options();
300            config.file_prefix("test_prefix_".to_string());
301            context.add_report::<SampleReport>("sample_report").unwrap();
302            let report = SampleReport {
303                id: 1,
304                value: "Test Value".to_string(),
305            };
306
307            context.send_report(report);
308        }
309
310        let path = env::current_dir().unwrap();
311        let file_path = PathBufWithDrop {
312            file: path.join("test_prefix_sample_report.csv"),
313        };
314        assert!(file_path.file.exists(), "CSV file should exist");
315        assert!(file_path.file.metadata().unwrap().len() > 0);
316
317        let mut reader = csv::Reader::from_path(&file_path.file).unwrap();
318        for result in reader.deserialize() {
319            let record: SampleReport = result.unwrap();
320            assert_eq!(record.id, 1);
321            assert_eq!(record.value, "Test Value");
322        }
323    }
324
325    #[test]
326    #[should_panic(expected = "No writer found for the report type")]
327    fn send_report_without_adding_report() {
328        let context = Context::new();
329        let report = SampleReport {
330            id: 1,
331            value: "Test Value".to_string(),
332        };
333
334        context.send_report(report);
335    }
336
337    #[test]
338    fn multiple_reports_one_context() {
339        let temp_dir = tempdir().unwrap();
340        let path = PathBuf::from(&temp_dir.path());
341        // We need the writer to go out of scope so the file is flushed
342        {
343            let mut context = Context::new();
344            let config = context.report_options();
345            config
346                .file_prefix("mult_report_".to_string())
347                .directory(path.clone());
348            context.add_report::<SampleReport>("sample_report").unwrap();
349            let report1 = SampleReport {
350                id: 1,
351                value: "Value,1".to_string(),
352            };
353            let report2 = SampleReport {
354                id: 2,
355                value: "Value\n2".to_string(),
356            };
357
358            context.send_report(report1);
359            context.send_report(report2);
360        }
361
362        let file_path = path.join("mult_report_sample_report.csv");
363        assert!(file_path.exists(), "CSV file should exist");
364
365        let mut reader = csv::Reader::from_path(file_path).expect("Failed to open CSV file");
366        let mut records = reader.deserialize::<SampleReport>();
367
368        let item1: SampleReport = records
369            .next()
370            .expect("No record found")
371            .expect("Failed to deserialize record");
372        assert_eq!(item1.id, 1);
373        assert_eq!(item1.value, "Value,1");
374
375        let item2: SampleReport = records
376            .next()
377            .expect("No second record found")
378            .expect("Failed to deserialize record");
379        assert_eq!(item2.id, 2);
380        assert_eq!(item2.value, "Value\n2");
381    }
382
383    #[test]
384    fn multithreaded_report_generation_thread_local() {
385        let num_threads = 10;
386        let num_reports_per_thread = 5;
387
388        let mut handles = vec![];
389        let temp_dir = tempdir().unwrap();
390        let base_path = temp_dir.path().to_path_buf();
391
392        for i in 0..num_threads {
393            let path = base_path.clone();
394            let handle = thread::spawn(move || {
395                let mut context = Context::new();
396                let config = context.report_options();
397                config.file_prefix(i.to_string()).directory(path);
398                context.add_report::<SampleReport>("sample_report").unwrap();
399
400                for j in 0..num_reports_per_thread {
401                    let report = SampleReport {
402                        id: u32::try_from(i * num_reports_per_thread + j).unwrap(),
403                        value: format!("Thread {i} Report {j}"),
404                    };
405                    context.send_report(report);
406                }
407            });
408
409            handles.push(handle);
410        }
411
412        for handle in handles {
413            handle.join().expect("Thread failed");
414        }
415
416        for i in 0..num_threads {
417            let file_name = format!("{i}sample_report.csv");
418            let file_path = base_path.join(file_name);
419            assert!(file_path.exists(), "CSV file should exist");
420
421            let mut reader = csv::Reader::from_path(file_path).expect("Failed to open CSV file");
422            let records = reader.deserialize::<SampleReport>();
423
424            for (j, record) in records.enumerate() {
425                let record: SampleReport = record.expect("Failed to deserialize record");
426                let id_expected = TryInto::<u32>::try_into(i * num_reports_per_thread + j).unwrap();
427                assert_eq!(record.id, id_expected);
428            }
429        }
430    }
431
432    #[test]
433    fn dont_overwrite_report() {
434        let mut context1 = Context::new();
435        let temp_dir = tempdir().unwrap();
436        let path = PathBuf::from(&temp_dir.path());
437        let config = context1.report_options();
438        config
439            .file_prefix("prefix1_".to_string())
440            .directory(path.clone());
441        context1
442            .add_report::<SampleReport>("sample_report")
443            .unwrap();
444        let report = SampleReport {
445            id: 1,
446            value: "Test Value".to_string(),
447        };
448
449        context1.send_report(report);
450
451        let file_path = path.join("prefix1_sample_report.csv");
452        assert!(file_path.exists(), "CSV file should exist");
453
454        let mut context2 = Context::new();
455        let config = context2.report_options();
456        config.file_prefix("prefix1_".to_string()).directory(path);
457        info!("The next 'file already exists' error is intended for a passing test.");
458        let result = context2.add_report::<SampleReport>("sample_report");
459        assert!(result.is_err());
460        let error = result.err().unwrap();
461        match error {
462            IxaError::IoError(e) => {
463                assert_eq!(e.kind(), std::io::ErrorKind::AlreadyExists);
464            }
465            _ => {
466                panic!("Unexpected error type");
467            }
468        }
469    }
470
471    #[test]
472    fn overwrite_report() {
473        let mut context1 = Context::new();
474        let temp_dir = tempdir().unwrap();
475        let path = PathBuf::from(&temp_dir.path());
476        let config = context1.report_options();
477        config
478            .file_prefix("prefix1_".to_string())
479            .directory(path.clone());
480        context1
481            .add_report::<SampleReport>("sample_report")
482            .unwrap();
483        let report = SampleReport {
484            id: 1,
485            value: "Test Value".to_string(),
486        };
487
488        context1.send_report(report);
489
490        let file_path = path.join("prefix1_sample_report.csv");
491        assert!(file_path.exists(), "CSV file should exist");
492
493        let mut context2 = Context::new();
494        let config = context2.report_options();
495        config
496            .file_prefix("prefix1_".to_string())
497            .directory(path)
498            .overwrite(true);
499        let result = context2.add_report::<SampleReport>("sample_report");
500        assert!(result.is_ok());
501        let file = File::open(file_path).unwrap();
502        let reader = csv::Reader::from_reader(file);
503        let records = reader.into_records();
504        assert_eq!(records.count(), 0);
505    }
506}