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    fn get_writer(&self, type_id: TypeId) -> RefMut<Writer<File>> {
170        // No data container will exist if no reports have been added
171        let data_container = self.get_data(ReportPlugin);
172        let writers = data_container.file_writers.try_borrow_mut().unwrap();
173        RefMut::map(writers, |writers| {
174            writers
175                .get_mut(&type_id)
176                .expect("No writer found for the report type")
177        })
178    }
179
180    /// Write a new row to the appropriate report file
181    fn send_report<T: Report>(&self, report: T) {
182        let writer = &mut self.get_writer(report.type_id());
183        report.serialize(writer);
184    }
185
186    /// Returns a `ConfigReportOptions` object which has setter methods for report configuration
187    fn report_options(&mut self) -> &mut ConfigReportOptions {
188        let data_container = self.get_data_mut(ReportPlugin);
189        &mut data_container.config
190    }
191}
192impl ContextReportExt for Context {}
193
194#[cfg(test)]
195mod test {
196    use core::convert::TryInto;
197    use std::thread;
198
199    use serde_derive::{Deserialize, Serialize};
200    use tempfile::tempdir;
201
202    use super::*;
203    use crate::{define_entity, define_property, define_report, info};
204
205    define_entity!(Person);
206
207    define_property!(
208        struct IsRunner(bool),
209        Person,
210        default_const = IsRunner(false)
211    );
212
213    #[derive(Serialize, Deserialize)]
214    struct SampleReport {
215        id: u32,
216        value: String,
217    }
218
219    define_report!(SampleReport);
220
221    #[test]
222    fn add_and_send_report() {
223        let temp_dir = tempdir().unwrap();
224        let path = PathBuf::from(&temp_dir.path());
225        // We need the writer to go out of scope so the file is flushed
226        {
227            let mut context = Context::new();
228            let config = context.report_options();
229            config
230                .file_prefix("prefix1_".to_string())
231                .directory(path.clone());
232            context.add_report::<SampleReport>("sample_report").unwrap();
233            let report = SampleReport {
234                id: 1,
235                value: "Test Value".to_string(),
236            };
237
238            context.send_report(report);
239        }
240
241        let file_path = path.join("prefix1_sample_report.csv");
242        assert!(file_path.exists(), "CSV file should exist");
243        assert!(file_path.metadata().unwrap().len() > 0);
244
245        let mut reader = csv::Reader::from_path(file_path).unwrap();
246        for result in reader.deserialize() {
247            let record: SampleReport = result.unwrap();
248            assert_eq!(record.id, 1);
249            assert_eq!(record.value, "Test Value");
250        }
251    }
252
253    #[test]
254    fn add_report_empty_prefix() {
255        let temp_dir = tempdir().unwrap();
256        let path = PathBuf::from(&temp_dir.path());
257        // We need the writer to go out of scope so the file is flushed
258        {
259            let mut context = Context::new();
260            let config = context.report_options();
261            config.directory(path.clone());
262            context.add_report::<SampleReport>("sample_report").unwrap();
263            let report = SampleReport {
264                id: 1,
265                value: "Test Value".to_string(),
266            };
267
268            context.send_report(report);
269        }
270        let file_path = path.join("sample_report.csv");
271        assert!(file_path.exists(), "CSV file should exist");
272        assert!(file_path.metadata().unwrap().len() > 0);
273
274        let mut reader = csv::Reader::from_path(file_path).unwrap();
275        for result in reader.deserialize() {
276            let record: SampleReport = result.unwrap();
277            assert_eq!(record.id, 1);
278            assert_eq!(record.value, "Test Value");
279        }
280    }
281
282    struct PathBufWithDrop {
283        file: PathBuf,
284    }
285
286    impl Drop for PathBufWithDrop {
287        fn drop(&mut self) {
288            std::fs::remove_file(&self.file).unwrap();
289        }
290    }
291
292    #[test]
293    fn add_report_no_dir() {
294        // We need the writer to go out of scope so the file is flushed
295        {
296            let mut context = Context::new();
297            let config = context.report_options();
298            config.file_prefix("test_prefix_".to_string());
299            context.add_report::<SampleReport>("sample_report").unwrap();
300            let report = SampleReport {
301                id: 1,
302                value: "Test Value".to_string(),
303            };
304
305            context.send_report(report);
306        }
307
308        let path = env::current_dir().unwrap();
309        let file_path = PathBufWithDrop {
310            file: path.join("test_prefix_sample_report.csv"),
311        };
312        assert!(file_path.file.exists(), "CSV file should exist");
313        assert!(file_path.file.metadata().unwrap().len() > 0);
314
315        let mut reader = csv::Reader::from_path(&file_path.file).unwrap();
316        for result in reader.deserialize() {
317            let record: SampleReport = result.unwrap();
318            assert_eq!(record.id, 1);
319            assert_eq!(record.value, "Test Value");
320        }
321    }
322
323    #[test]
324    #[should_panic(expected = "No writer found for the report type")]
325    fn send_report_without_adding_report() {
326        let context = Context::new();
327        let report = SampleReport {
328            id: 1,
329            value: "Test Value".to_string(),
330        };
331
332        context.send_report(report);
333    }
334
335    #[test]
336    fn multiple_reports_one_context() {
337        let temp_dir = tempdir().unwrap();
338        let path = PathBuf::from(&temp_dir.path());
339        // We need the writer to go out of scope so the file is flushed
340        {
341            let mut context = Context::new();
342            let config = context.report_options();
343            config
344                .file_prefix("mult_report_".to_string())
345                .directory(path.clone());
346            context.add_report::<SampleReport>("sample_report").unwrap();
347            let report1 = SampleReport {
348                id: 1,
349                value: "Value,1".to_string(),
350            };
351            let report2 = SampleReport {
352                id: 2,
353                value: "Value\n2".to_string(),
354            };
355
356            context.send_report(report1);
357            context.send_report(report2);
358        }
359
360        let file_path = path.join("mult_report_sample_report.csv");
361        assert!(file_path.exists(), "CSV file should exist");
362
363        let mut reader = csv::Reader::from_path(file_path).expect("Failed to open CSV file");
364        let mut records = reader.deserialize::<SampleReport>();
365
366        let item1: SampleReport = records
367            .next()
368            .expect("No record found")
369            .expect("Failed to deserialize record");
370        assert_eq!(item1.id, 1);
371        assert_eq!(item1.value, "Value,1");
372
373        let item2: SampleReport = records
374            .next()
375            .expect("No second record found")
376            .expect("Failed to deserialize record");
377        assert_eq!(item2.id, 2);
378        assert_eq!(item2.value, "Value\n2");
379    }
380
381    #[test]
382    fn multithreaded_report_generation_thread_local() {
383        let num_threads = 10;
384        let num_reports_per_thread = 5;
385
386        let mut handles = vec![];
387        let temp_dir = tempdir().unwrap();
388        let base_path = temp_dir.path().to_path_buf();
389
390        for i in 0..num_threads {
391            let path = base_path.clone();
392            let handle = thread::spawn(move || {
393                let mut context = Context::new();
394                let config = context.report_options();
395                config.file_prefix(i.to_string()).directory(path);
396                context.add_report::<SampleReport>("sample_report").unwrap();
397
398                for j in 0..num_reports_per_thread {
399                    let report = SampleReport {
400                        id: u32::try_from(i * num_reports_per_thread + j).unwrap(),
401                        value: format!("Thread {i} Report {j}"),
402                    };
403                    context.send_report(report);
404                }
405            });
406
407            handles.push(handle);
408        }
409
410        for handle in handles {
411            handle.join().expect("Thread failed");
412        }
413
414        for i in 0..num_threads {
415            let file_name = format!("{i}sample_report.csv");
416            let file_path = base_path.join(file_name);
417            assert!(file_path.exists(), "CSV file should exist");
418
419            let mut reader = csv::Reader::from_path(file_path).expect("Failed to open CSV file");
420            let records = reader.deserialize::<SampleReport>();
421
422            for (j, record) in records.enumerate() {
423                let record: SampleReport = record.expect("Failed to deserialize record");
424                let id_expected = TryInto::<u32>::try_into(i * num_reports_per_thread + j).unwrap();
425                assert_eq!(record.id, id_expected);
426            }
427        }
428    }
429
430    #[test]
431    fn dont_overwrite_report() {
432        let mut context1 = Context::new();
433        let temp_dir = tempdir().unwrap();
434        let path = PathBuf::from(&temp_dir.path());
435        let config = context1.report_options();
436        config
437            .file_prefix("prefix1_".to_string())
438            .directory(path.clone());
439        context1
440            .add_report::<SampleReport>("sample_report")
441            .unwrap();
442        let report = SampleReport {
443            id: 1,
444            value: "Test Value".to_string(),
445        };
446
447        context1.send_report(report);
448
449        let file_path = path.join("prefix1_sample_report.csv");
450        assert!(file_path.exists(), "CSV file should exist");
451
452        let mut context2 = Context::new();
453        let config = context2.report_options();
454        config.file_prefix("prefix1_".to_string()).directory(path);
455        info!("The next 'file already exists' error is intended for a passing test.");
456        let result = context2.add_report::<SampleReport>("sample_report");
457        assert!(result.is_err());
458        let error = result.err().unwrap();
459        match error {
460            IxaError::IoError(e) => {
461                assert_eq!(e.kind(), std::io::ErrorKind::AlreadyExists);
462            }
463            _ => {
464                panic!("Unexpected error type");
465            }
466        }
467    }
468
469    #[test]
470    fn overwrite_report() {
471        let mut context1 = Context::new();
472        let temp_dir = tempdir().unwrap();
473        let path = PathBuf::from(&temp_dir.path());
474        let config = context1.report_options();
475        config
476            .file_prefix("prefix1_".to_string())
477            .directory(path.clone());
478        context1
479            .add_report::<SampleReport>("sample_report")
480            .unwrap();
481        let report = SampleReport {
482            id: 1,
483            value: "Test Value".to_string(),
484        };
485
486        context1.send_report(report);
487
488        let file_path = path.join("prefix1_sample_report.csv");
489        assert!(file_path.exists(), "CSV file should exist");
490
491        let mut context2 = Context::new();
492        let config = context2.report_options();
493        config
494            .file_prefix("prefix1_".to_string())
495            .directory(path)
496            .overwrite(true);
497        let result = context2.add_report::<SampleReport>("sample_report");
498        assert!(result.is_ok());
499        let file = File::open(file_path).unwrap();
500        let reader = csv::Reader::from_reader(file);
501        let records = reader.into_records();
502        assert_eq!(records.count(), 0);
503    }
504}