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
14pub 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 ConfigReportOptions {
31 file_prefix: String::new(),
32 output_dir: env::current_dir().unwrap(),
33 overwrite: false,
34 }
35 }
36 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 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 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#[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#[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
98define_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 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 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 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>> {
176 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 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 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 {
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 {
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 {
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 {
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}