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(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#[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
100define_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 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 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 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 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 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 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 {
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 {
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 {
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 {
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}