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>> {
170 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 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 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 {
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 {
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 {
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 {
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}