1use std::fs::File;
7use std::io::{BufWriter, Write};
8use std::path::{Path, PathBuf};
9
10use datasynth_core::error::SynthResult;
11use datasynth_core::models::{
12 ControlAccountMapping, ControlDocTypeMapping, ControlMappingRegistry, ControlProcessMapping,
13 ControlThresholdMapping, InternalControl, SodConflictPair, SodRule,
14};
15
16pub struct ControlExporter {
18 output_dir: PathBuf,
19}
20
21impl ControlExporter {
22 pub fn new(output_dir: impl AsRef<Path>) -> Self {
24 Self {
25 output_dir: output_dir.as_ref().to_path_buf(),
26 }
27 }
28
29 pub fn export_all(
41 &self,
42 controls: &[InternalControl],
43 registry: &ControlMappingRegistry,
44 sod_conflicts: &[SodConflictPair],
45 sod_rules: &[SodRule],
46 ) -> SynthResult<ExportSummary> {
47 std::fs::create_dir_all(&self.output_dir)?;
48
49 let summary = ExportSummary {
50 controls_count: self.export_controls(controls)?,
51 account_mappings_count: self.export_account_mappings(®istry.account_mappings)?,
52 process_mappings_count: self.export_process_mappings(®istry.process_mappings)?,
53 threshold_mappings_count: self
54 .export_threshold_mappings(®istry.threshold_mappings)?,
55 doctype_mappings_count: self.export_doctype_mappings(®istry.doc_type_mappings)?,
56 sod_conflicts_count: self.export_sod_conflicts(sod_conflicts)?,
57 sod_rules_count: self.export_sod_rules(sod_rules)?,
58 coso_mappings_count: self.export_coso_mapping(controls)?,
59 };
60
61 Ok(summary)
62 }
63
64 pub fn export_controls(&self, controls: &[InternalControl]) -> SynthResult<usize> {
66 let path = self.output_dir.join("internal_controls.csv");
67 let file = File::create(&path)?;
68 let mut writer = BufWriter::new(file);
69
70 writeln!(
72 writer,
73 "control_id,control_name,control_type,objective,frequency,owner_role,\
74 risk_level,is_key_control,sox_assertion,coso_component,coso_principles,control_scope,maturity_level"
75 )?;
76
77 for control in controls {
78 let principles: Vec<String> = control
80 .coso_principles
81 .iter()
82 .map(|p| format!("{}", p))
83 .collect();
84
85 writeln!(
86 writer,
87 "{},{},{:?},{},{:?},{:?},{:?},{},{:?},{},{},{},{}",
88 escape_csv(&control.control_id),
89 escape_csv(&control.control_name),
90 control.control_type,
91 escape_csv(&control.objective),
92 control.frequency,
93 control.owner_role,
94 control.risk_level,
95 control.is_key_control,
96 control.sox_assertion,
97 escape_csv(&control.coso_component.to_string()),
98 escape_csv(&principles.join(";")),
99 escape_csv(&control.control_scope.to_string()),
100 escape_csv(&control.maturity_level.to_string()),
101 )?;
102 }
103
104 writer.flush()?;
105 Ok(controls.len())
106 }
107
108 pub fn export_account_mappings(
110 &self,
111 mappings: &[ControlAccountMapping],
112 ) -> SynthResult<usize> {
113 let path = self.output_dir.join("control_account_mappings.csv");
114 let file = File::create(&path)?;
115 let mut writer = BufWriter::new(file);
116
117 writeln!(writer, "control_id,account_numbers,account_sub_types")?;
119
120 for mapping in mappings {
121 let account_numbers = mapping.account_numbers.join(";");
122 let sub_types: Vec<String> = mapping
123 .account_sub_types
124 .iter()
125 .map(|st| format!("{:?}", st))
126 .collect();
127
128 writeln!(
129 writer,
130 "{},{},{}",
131 escape_csv(&mapping.control_id),
132 escape_csv(&account_numbers),
133 escape_csv(&sub_types.join(";"))
134 )?;
135 }
136
137 writer.flush()?;
138 Ok(mappings.len())
139 }
140
141 pub fn export_process_mappings(
143 &self,
144 mappings: &[ControlProcessMapping],
145 ) -> SynthResult<usize> {
146 let path = self.output_dir.join("control_process_mappings.csv");
147 let file = File::create(&path)?;
148 let mut writer = BufWriter::new(file);
149
150 writeln!(writer, "control_id,business_processes")?;
152
153 for mapping in mappings {
154 let processes: Vec<String> = mapping
155 .business_processes
156 .iter()
157 .map(|bp| format!("{:?}", bp))
158 .collect();
159
160 writeln!(
161 writer,
162 "{},{}",
163 escape_csv(&mapping.control_id),
164 escape_csv(&processes.join(";"))
165 )?;
166 }
167
168 writer.flush()?;
169 Ok(mappings.len())
170 }
171
172 pub fn export_threshold_mappings(
174 &self,
175 mappings: &[ControlThresholdMapping],
176 ) -> SynthResult<usize> {
177 let path = self.output_dir.join("control_threshold_mappings.csv");
178 let file = File::create(&path)?;
179 let mut writer = BufWriter::new(file);
180
181 writeln!(
183 writer,
184 "control_id,amount_threshold,upper_threshold,comparison"
185 )?;
186
187 for mapping in mappings {
188 writeln!(
189 writer,
190 "{},{},{},{:?}",
191 escape_csv(&mapping.control_id),
192 mapping.amount_threshold,
193 mapping
194 .upper_threshold
195 .map(|t| t.to_string())
196 .unwrap_or_default(),
197 mapping.comparison
198 )?;
199 }
200
201 writer.flush()?;
202 Ok(mappings.len())
203 }
204
205 pub fn export_doctype_mappings(
207 &self,
208 mappings: &[ControlDocTypeMapping],
209 ) -> SynthResult<usize> {
210 let path = self.output_dir.join("control_doctype_mappings.csv");
211 let file = File::create(&path)?;
212 let mut writer = BufWriter::new(file);
213
214 writeln!(writer, "control_id,document_types")?;
216
217 for mapping in mappings {
218 writeln!(
219 writer,
220 "{},{}",
221 escape_csv(&mapping.control_id),
222 escape_csv(&mapping.document_types.join(";"))
223 )?;
224 }
225
226 writer.flush()?;
227 Ok(mappings.len())
228 }
229
230 pub fn export_sod_conflicts(&self, conflicts: &[SodConflictPair]) -> SynthResult<usize> {
232 let path = self.output_dir.join("sod_conflict_pairs.csv");
233 let file = File::create(&path)?;
234 let mut writer = BufWriter::new(file);
235
236 writeln!(writer, "conflict_type,role_a,role_b,description,severity")?;
238
239 for conflict in conflicts {
240 writeln!(
241 writer,
242 "{:?},{:?},{:?},{},{:?}",
243 conflict.conflict_type,
244 conflict.role_a,
245 conflict.role_b,
246 escape_csv(&conflict.description),
247 conflict.severity
248 )?;
249 }
250
251 writer.flush()?;
252 Ok(conflicts.len())
253 }
254
255 pub fn export_sod_rules(&self, rules: &[SodRule]) -> SynthResult<usize> {
257 let path = self.output_dir.join("sod_rules.csv");
258 let file = File::create(&path)?;
259 let mut writer = BufWriter::new(file);
260
261 writeln!(
263 writer,
264 "rule_id,name,conflict_type,description,is_active,risk_level"
265 )?;
266
267 for rule in rules {
268 writeln!(
269 writer,
270 "{},{},{:?},{},{},{:?}",
271 escape_csv(&rule.rule_id),
272 escape_csv(&rule.name),
273 rule.conflict_type,
274 escape_csv(&rule.description),
275 rule.is_active,
276 rule.risk_level
277 )?;
278 }
279
280 writer.flush()?;
281 Ok(rules.len())
282 }
283
284 pub fn export_coso_mapping(&self, controls: &[InternalControl]) -> SynthResult<usize> {
289 let path = self.output_dir.join("coso_control_mapping.csv");
290 let file = File::create(&path)?;
291 let mut writer = BufWriter::new(file);
292
293 writeln!(
295 writer,
296 "control_id,coso_component,principle_number,principle_name,control_scope"
297 )?;
298
299 let mut row_count = 0;
300 for control in controls {
301 for principle in &control.coso_principles {
302 writeln!(
303 writer,
304 "{},{},{},{},{}",
305 escape_csv(&control.control_id),
306 escape_csv(&control.coso_component.to_string()),
307 principle.principle_number(),
308 escape_csv(&principle.to_string()),
309 escape_csv(&control.control_scope.to_string()),
310 )?;
311 row_count += 1;
312 }
313 }
314
315 writer.flush()?;
316 Ok(row_count)
317 }
318
319 pub fn export_standard(&self) -> SynthResult<ExportSummary> {
324 let controls = InternalControl::standard_controls();
325 let registry = ControlMappingRegistry::standard();
326 let sod_conflicts = SodConflictPair::standard_conflicts();
327 let sod_rules = SodRule::standard_rules();
328
329 self.export_all(&controls, ®istry, &sod_conflicts, &sod_rules)
330 }
331}
332
333#[derive(Debug, Default)]
335pub struct ExportSummary {
336 pub controls_count: usize,
338 pub account_mappings_count: usize,
340 pub process_mappings_count: usize,
342 pub threshold_mappings_count: usize,
344 pub doctype_mappings_count: usize,
346 pub sod_conflicts_count: usize,
348 pub sod_rules_count: usize,
350 pub coso_mappings_count: usize,
352}
353
354impl ExportSummary {
355 pub fn total(&self) -> usize {
357 self.controls_count
358 + self.account_mappings_count
359 + self.process_mappings_count
360 + self.threshold_mappings_count
361 + self.doctype_mappings_count
362 + self.sod_conflicts_count
363 + self.sod_rules_count
364 + self.coso_mappings_count
365 }
366}
367
368fn escape_csv(s: &str) -> String {
370 if s.contains(',') || s.contains('"') || s.contains('\n') || s.contains('\r') {
371 format!("\"{}\"", s.replace('"', "\"\""))
372 } else {
373 s.to_string()
374 }
375}
376
377#[cfg(test)]
378mod tests {
379 use super::*;
380 use tempfile::TempDir;
381
382 #[test]
383 fn test_export_standard() {
384 let temp_dir = TempDir::new().unwrap();
385 let exporter = ControlExporter::new(temp_dir.path());
386
387 let summary = exporter.export_standard().unwrap();
388
389 assert!(summary.controls_count > 0);
390 assert!(summary.account_mappings_count > 0);
391 assert!(summary.process_mappings_count > 0);
392 assert!(summary.sod_conflicts_count > 0);
393 assert!(summary.sod_rules_count > 0);
394 assert!(summary.coso_mappings_count > 0);
395
396 assert!(temp_dir.path().join("internal_controls.csv").exists());
398 assert!(temp_dir
399 .path()
400 .join("control_account_mappings.csv")
401 .exists());
402 assert!(temp_dir
403 .path()
404 .join("control_process_mappings.csv")
405 .exists());
406 assert!(temp_dir.path().join("sod_conflict_pairs.csv").exists());
407 assert!(temp_dir.path().join("sod_rules.csv").exists());
408 assert!(temp_dir.path().join("coso_control_mapping.csv").exists());
409 }
410
411 #[test]
412 fn test_escape_csv() {
413 assert_eq!(escape_csv("hello"), "hello");
414 assert_eq!(escape_csv("hello,world"), "\"hello,world\"");
415 assert_eq!(escape_csv("hello\"world"), "\"hello\"\"world\"");
416 assert_eq!(escape_csv("hello\nworld"), "\"hello\nworld\"");
417 }
418
419 #[test]
420 fn test_export_controls() {
421 let temp_dir = TempDir::new().unwrap();
422 let exporter = ControlExporter::new(temp_dir.path());
423
424 let controls = InternalControl::standard_controls();
425 let count = exporter.export_controls(&controls).unwrap();
426
427 assert_eq!(count, controls.len());
428
429 let content =
431 std::fs::read_to_string(temp_dir.path().join("internal_controls.csv")).unwrap();
432 assert!(content.contains("control_id"));
433 assert!(content.contains("C001")); }
435
436 #[test]
437 fn test_export_sod_conflicts() {
438 let temp_dir = TempDir::new().unwrap();
439 let exporter = ControlExporter::new(temp_dir.path());
440
441 let conflicts = SodConflictPair::standard_conflicts();
442 let count = exporter.export_sod_conflicts(&conflicts).unwrap();
443
444 assert_eq!(count, conflicts.len());
445
446 let content =
447 std::fs::read_to_string(temp_dir.path().join("sod_conflict_pairs.csv")).unwrap();
448 assert!(content.contains("PreparerApprover"));
449 }
450}