1use crate::context::Context;
2use crate::error::IxaError;
3use crate::people::ContextPeopleExt;
4use crate::{define_data_plugin, Tabulator};
5use crate::{error, trace};
6use crate::{HashMap, HashMapExt, PluginContext};
7use csv::Writer;
8use serde::Serializer;
9use std::any::TypeId;
10use std::cell::{RefCell, RefMut};
11use std::env;
12use std::fs::File;
13use std::path::PathBuf;
14
15pub struct ConfigReportOptions {
21 pub file_prefix: String,
22 pub output_dir: PathBuf,
23 pub overwrite: bool,
24}
25
26impl ConfigReportOptions {
27 #[must_use]
28 #[allow(clippy::missing_panics_doc)]
29 pub fn new() -> Self {
30 trace!("new ConfigReportOptions");
31 ConfigReportOptions {
33 file_prefix: String::new(),
34 output_dir: env::current_dir().unwrap(),
35 overwrite: false,
36 }
37 }
38 pub fn file_prefix(&mut self, file_prefix: String) -> &mut ConfigReportOptions {
40 trace!("setting report prefix to {file_prefix}");
41 self.file_prefix = file_prefix;
42 self
43 }
44 pub fn directory(&mut self, directory: PathBuf) -> &mut ConfigReportOptions {
46 trace!("setting report directory to {directory:?}");
47 self.output_dir = directory;
48 self
49 }
50 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 fn type_id(&self) -> TypeId;
67 fn serialize(&self, writer: &mut Writer<File>);
69}
70
71#[macro_export]
73macro_rules! define_report {
74 ($name:ident) => {
75 impl $crate::Report for $name {
76 fn type_id(&self) -> std::any::TypeId {
77 std::any::TypeId::of::<$name>()
78 }
79
80 fn serialize(&self, writer: &mut $crate::csv::Writer<std::fs::File>) {
81 writer.serialize(self).unwrap();
82 }
83 }
84 };
85}
86
87#[allow(clippy::trivially_copy_pass_by_ref)]
90#[allow(dead_code)]
91pub fn serialize_f64<S, const N: usize>(value: &f64, serializer: S) -> Result<S::Ok, S::Error>
92where
93 S: Serializer,
94{
95 let formatted = format!("{value:.N$}");
96 serializer.serialize_str(&formatted)
97}
98
99#[allow(clippy::trivially_copy_pass_by_ref)]
102#[allow(dead_code)]
103pub fn serialize_f32<S, const N: usize>(value: &f32, serializer: S) -> Result<S::Ok, S::Error>
104where
105 S: Serializer,
106{
107 let formatted = format!("{value:.N$}");
108 serializer.serialize_str(&formatted)
109}
110
111struct ReportData {
112 file_writers: RefCell<HashMap<TypeId, Writer<File>>>,
113 config: ConfigReportOptions,
114}
115
116define_data_plugin!(
120 ReportPlugin,
121 ReportData,
122 ReportData {
123 file_writers: RefCell::new(HashMap::new()),
124 config: ConfigReportOptions::new(),
125 }
126);
127
128pub trait ContextReportExt: PluginContext {
129 fn generate_filename(&mut self, short_name: &str) -> PathBuf {
133 let data_container = self.get_data_mut(ReportPlugin);
134 let prefix = &data_container.config.file_prefix;
135 let directory = &data_container.config.output_dir;
136 let short_name = short_name.to_string();
137 let basename = format!("{prefix}{short_name}");
138 directory.join(basename).with_extension("csv")
139 }
140
141 fn add_report_by_type_id(&mut self, type_id: TypeId, short_name: &str) -> Result<(), IxaError> {
148 trace!("adding report {short_name} by type_id {type_id:?}");
149 let path = self.generate_filename(short_name);
150
151 let data_container = self.get_data_mut(ReportPlugin);
152
153 let file_creation_result = File::create_new(&path);
154 let created_file = match file_creation_result {
155 Ok(file) => file,
156 Err(e) => match e.kind() {
157 std::io::ErrorKind::AlreadyExists => {
158 if data_container.config.overwrite {
159 File::create(&path)?
160 } else {
161 error!("File already exists: {}. Please set `overwrite` to true in the file configuration and rerun.", path.display());
162 return Err(IxaError::IoError(e));
163 }
164 }
165 _ => {
166 return Err(IxaError::IoError(e));
167 }
168 },
169 };
170 let writer = Writer::from_writer(created_file);
171 let mut file_writer = data_container.file_writers.borrow_mut();
172 file_writer.insert(type_id, writer);
173 Ok(())
174 }
175
176 fn add_report<T: Report + 'static>(&mut self, short_name: &str) -> Result<(), IxaError> {
183 trace!("Adding report {short_name}");
184 self.add_report_by_type_id(TypeId::of::<T>(), short_name)
185 }
186
187 fn add_periodic_report<T: Tabulator + Clone + 'static>(
193 &mut self,
194 short_name: &str,
195 period: f64,
196 tabulator: T,
197 ) -> Result<(), IxaError> {
198 trace!("Adding periodic report {short_name}");
199
200 self.add_report_by_type_id(TypeId::of::<T>(), short_name)?;
201
202 {
203 let mut writer = self.get_writer(TypeId::of::<T>());
205 let columns = tabulator.get_columns();
206 let mut header = vec!["t".to_string()];
207 header.extend(columns);
208 header.push("count".to_string());
209 writer
210 .write_record(&header)
211 .expect("Failed to write header");
212 }
213
214 self.add_periodic_plan_with_phase(
215 period,
216 move |context: &mut Context| {
217 context.tabulate_person_properties(&tabulator, move |context, values, count| {
218 let mut writer = context.get_writer(TypeId::of::<T>());
219 let mut row = vec![context.get_current_time().to_string()];
220 row.extend(values.to_owned());
221 row.push(count.to_string());
222
223 writer.write_record(&row).expect("Failed to write row");
224 });
225 },
226 crate::context::ExecutionPhase::Last,
227 );
228
229 Ok(())
230 }
231
232 fn get_writer(&self, type_id: TypeId) -> RefMut<Writer<File>> {
233 let data_container = self.get_data(ReportPlugin);
235 let writers = data_container.file_writers.try_borrow_mut().unwrap();
236 RefMut::map(writers, |writers| {
237 writers
238 .get_mut(&type_id)
239 .expect("No writer found for the report type")
240 })
241 }
242
243 fn send_report<T: Report>(&self, report: T) {
245 let writer = &mut self.get_writer(report.type_id());
246 report.serialize(writer);
247 }
248
249 fn report_options(&mut self) -> &mut ConfigReportOptions {
251 let data_container = self.get_data_mut(ReportPlugin);
252 &mut data_container.config
253 }
254}
255impl ContextReportExt for Context {}
256
257#[cfg(test)]
258mod test {
259 use crate::{define_person_property_with_default, info};
260
261 use super::*;
262 use core::convert::TryInto;
263 use serde_derive::{Deserialize, Serialize};
264 use std::thread;
265 use tempfile::tempdir;
266
267 define_person_property_with_default!(IsRunner, bool, false);
268
269 #[derive(Serialize, Deserialize)]
270 struct SampleReport {
271 id: u32,
272 value: String,
273 }
274
275 define_report!(SampleReport);
276
277 #[test]
278 fn add_and_send_report() {
279 let temp_dir = tempdir().unwrap();
280 let path = PathBuf::from(&temp_dir.path());
281 {
283 let mut context = Context::new();
284 let config = context.report_options();
285 config
286 .file_prefix("prefix1_".to_string())
287 .directory(path.clone());
288 context.add_report::<SampleReport>("sample_report").unwrap();
289 let report = SampleReport {
290 id: 1,
291 value: "Test Value".to_string(),
292 };
293
294 context.send_report(report);
295 }
296
297 let file_path = path.join("prefix1_sample_report.csv");
298 assert!(file_path.exists(), "CSV file should exist");
299 assert!(file_path.metadata().unwrap().len() > 0);
300
301 let mut reader = csv::Reader::from_path(file_path).unwrap();
302 for result in reader.deserialize() {
303 let record: SampleReport = result.unwrap();
304 assert_eq!(record.id, 1);
305 assert_eq!(record.value, "Test Value");
306 }
307 }
308
309 #[test]
310 fn add_report_empty_prefix() {
311 let temp_dir = tempdir().unwrap();
312 let path = PathBuf::from(&temp_dir.path());
313 {
315 let mut context = Context::new();
316 let config = context.report_options();
317 config.directory(path.clone());
318 context.add_report::<SampleReport>("sample_report").unwrap();
319 let report = SampleReport {
320 id: 1,
321 value: "Test Value".to_string(),
322 };
323
324 context.send_report(report);
325 }
326 let file_path = path.join("sample_report.csv");
327 assert!(file_path.exists(), "CSV file should exist");
328 assert!(file_path.metadata().unwrap().len() > 0);
329
330 let mut reader = csv::Reader::from_path(file_path).unwrap();
331 for result in reader.deserialize() {
332 let record: SampleReport = result.unwrap();
333 assert_eq!(record.id, 1);
334 assert_eq!(record.value, "Test Value");
335 }
336 }
337
338 struct PathBufWithDrop {
339 file: PathBuf,
340 }
341
342 impl Drop for PathBufWithDrop {
343 fn drop(&mut self) {
344 std::fs::remove_file(&self.file).unwrap();
345 }
346 }
347
348 #[test]
349 fn add_report_no_dir() {
350 {
352 let mut context = Context::new();
353 let config = context.report_options();
354 config.file_prefix("test_prefix_".to_string());
355 context.add_report::<SampleReport>("sample_report").unwrap();
356 let report = SampleReport {
357 id: 1,
358 value: "Test Value".to_string(),
359 };
360
361 context.send_report(report);
362 }
363
364 let path = env::current_dir().unwrap();
365 let file_path = PathBufWithDrop {
366 file: path.join("test_prefix_sample_report.csv"),
367 };
368 assert!(file_path.file.exists(), "CSV file should exist");
369 assert!(file_path.file.metadata().unwrap().len() > 0);
370
371 let mut reader = csv::Reader::from_path(&file_path.file).unwrap();
372 for result in reader.deserialize() {
373 let record: SampleReport = result.unwrap();
374 assert_eq!(record.id, 1);
375 assert_eq!(record.value, "Test Value");
376 }
377 }
378
379 #[test]
380 #[should_panic(expected = "No writer found for the report type")]
381 fn send_report_without_adding_report() {
382 let context = Context::new();
383 let report = SampleReport {
384 id: 1,
385 value: "Test Value".to_string(),
386 };
387
388 context.send_report(report);
389 }
390
391 #[test]
392 fn multiple_reports_one_context() {
393 let temp_dir = tempdir().unwrap();
394 let path = PathBuf::from(&temp_dir.path());
395 {
397 let mut context = Context::new();
398 let config = context.report_options();
399 config
400 .file_prefix("mult_report_".to_string())
401 .directory(path.clone());
402 context.add_report::<SampleReport>("sample_report").unwrap();
403 let report1 = SampleReport {
404 id: 1,
405 value: "Value,1".to_string(),
406 };
407 let report2 = SampleReport {
408 id: 2,
409 value: "Value\n2".to_string(),
410 };
411
412 context.send_report(report1);
413 context.send_report(report2);
414 }
415
416 let file_path = path.join("mult_report_sample_report.csv");
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 mut records = reader.deserialize::<SampleReport>();
421
422 let item1: SampleReport = records
423 .next()
424 .expect("No record found")
425 .expect("Failed to deserialize record");
426 assert_eq!(item1.id, 1);
427 assert_eq!(item1.value, "Value,1");
428
429 let item2: SampleReport = records
430 .next()
431 .expect("No second record found")
432 .expect("Failed to deserialize record");
433 assert_eq!(item2.id, 2);
434 assert_eq!(item2.value, "Value\n2");
435 }
436
437 #[test]
438 fn multithreaded_report_generation_thread_local() {
439 let num_threads = 10;
440 let num_reports_per_thread = 5;
441
442 let mut handles = vec![];
443 let temp_dir = tempdir().unwrap();
444 let base_path = temp_dir.path().to_path_buf();
445
446 for i in 0..num_threads {
447 let path = base_path.clone();
448 let handle = thread::spawn(move || {
449 let mut context = Context::new();
450 let config = context.report_options();
451 config.file_prefix(i.to_string()).directory(path);
452 context.add_report::<SampleReport>("sample_report").unwrap();
453
454 for j in 0..num_reports_per_thread {
455 let report = SampleReport {
456 id: u32::try_from(i * num_reports_per_thread + j).unwrap(),
457 value: format!("Thread {i} Report {j}"),
458 };
459 context.send_report(report);
460 }
461 });
462
463 handles.push(handle);
464 }
465
466 for handle in handles {
467 handle.join().expect("Thread failed");
468 }
469
470 for i in 0..num_threads {
471 let file_name = format!("{i}sample_report.csv");
472 let file_path = base_path.join(file_name);
473 assert!(file_path.exists(), "CSV file should exist");
474
475 let mut reader = csv::Reader::from_path(file_path).expect("Failed to open CSV file");
476 let records = reader.deserialize::<SampleReport>();
477
478 for (j, record) in records.enumerate() {
479 let record: SampleReport = record.expect("Failed to deserialize record");
480 let id_expected = TryInto::<u32>::try_into(i * num_reports_per_thread + j).unwrap();
481 assert_eq!(record.id, id_expected);
482 }
483 }
484 }
485
486 #[test]
487 fn dont_overwrite_report() {
488 let mut context1 = Context::new();
489 let temp_dir = tempdir().unwrap();
490 let path = PathBuf::from(&temp_dir.path());
491 let config = context1.report_options();
492 config
493 .file_prefix("prefix1_".to_string())
494 .directory(path.clone());
495 context1
496 .add_report::<SampleReport>("sample_report")
497 .unwrap();
498 let report = SampleReport {
499 id: 1,
500 value: "Test Value".to_string(),
501 };
502
503 context1.send_report(report);
504
505 let file_path = path.join("prefix1_sample_report.csv");
506 assert!(file_path.exists(), "CSV file should exist");
507
508 let mut context2 = Context::new();
509 let config = context2.report_options();
510 config.file_prefix("prefix1_".to_string()).directory(path);
511 info!("The next 'file already exists' error is intended for a passing test.");
512 let result = context2.add_report::<SampleReport>("sample_report");
513 assert!(result.is_err());
514 let error = result.err().unwrap();
515 match error {
516 IxaError::IoError(e) => {
517 assert_eq!(e.kind(), std::io::ErrorKind::AlreadyExists);
518 }
519 _ => {
520 panic!("Unexpected error type");
521 }
522 }
523 }
524
525 #[test]
526 fn overwrite_report() {
527 let mut context1 = Context::new();
528 let temp_dir = tempdir().unwrap();
529 let path = PathBuf::from(&temp_dir.path());
530 let config = context1.report_options();
531 config
532 .file_prefix("prefix1_".to_string())
533 .directory(path.clone());
534 context1
535 .add_report::<SampleReport>("sample_report")
536 .unwrap();
537 let report = SampleReport {
538 id: 1,
539 value: "Test Value".to_string(),
540 };
541
542 context1.send_report(report);
543
544 let file_path = path.join("prefix1_sample_report.csv");
545 assert!(file_path.exists(), "CSV file should exist");
546
547 let mut context2 = Context::new();
548 let config = context2.report_options();
549 config
550 .file_prefix("prefix1_".to_string())
551 .directory(path)
552 .overwrite(true);
553 let result = context2.add_report::<SampleReport>("sample_report");
554 assert!(result.is_ok());
555 let file = File::open(file_path).unwrap();
556 let reader = csv::Reader::from_reader(file);
557 let records = reader.into_records();
558 assert_eq!(records.count(), 0);
559 }
560
561 #[derive(PartialEq, Copy, Clone, Debug, Serialize, Deserialize)]
562 pub enum SymptomValue {
563 Presymptomatic,
564 Category1,
565 Category2,
566 Category3,
567 Category4,
568 }
569
570 define_person_property_with_default!(Symptoms, Option<SymptomValue>, None);
571
572 #[test]
573 fn add_periodic_report() {
574 let temp_dir = tempdir().unwrap();
575 let path = PathBuf::from(&temp_dir.path());
576 {
578 let mut context = Context::new();
579 let config = context.report_options();
580 config
581 .file_prefix("test_".to_string())
582 .directory(path.clone());
583 let _ = context.add_periodic_report("periodic", 1.2, (IsRunner, Symptoms));
584 let person = context.add_person(()).unwrap();
585 context.add_person(()).unwrap();
586
587 context.add_plan(1.2, move |context: &mut Context| {
588 context.set_person_property(person, IsRunner, true);
589 context.set_person_property(person, Symptoms, Some(SymptomValue::Category1));
590 });
591 context.execute();
592 }
593 let file_path = path.join("test_periodic.csv");
594 assert!(file_path.exists(), "CSV file should exist");
595
596 let mut reader = csv::Reader::from_path(file_path).unwrap();
597
598 assert_eq!(
599 reader.headers().unwrap(),
600 vec!["t", "IsRunner", "Symptoms", "count"]
601 );
602
603 let mut actual: Vec<Vec<String>> = reader
604 .records()
605 .map(|result| result.unwrap().iter().map(String::from).collect())
606 .collect();
607 let mut expected = vec![
608 vec!["0", "false", "None", "2"],
609 vec!["1.2", "false", "Category1", "0"],
610 vec!["1.2", "false", "None", "1"],
611 vec!["1.2", "true", "Category1", "1"],
612 vec!["1.2", "true", "None", "0"],
613 ];
614
615 actual.sort();
616 expected.sort();
617
618 assert_eq!(actual, expected, "CSV file should contain the correct data");
619 }
620}