Skip to main content

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