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(
40 &self,
41 controls: &[InternalControl],
42 registry: &ControlMappingRegistry,
43 sod_conflicts: &[SodConflictPair],
44 sod_rules: &[SodRule],
45 ) -> SynthResult<ExportSummary> {
46 std::fs::create_dir_all(&self.output_dir)?;
47
48 let summary = ExportSummary {
49 controls_count: self.export_controls(controls)?,
50 account_mappings_count: self.export_account_mappings(®istry.account_mappings)?,
51 process_mappings_count: self.export_process_mappings(®istry.process_mappings)?,
52 threshold_mappings_count: self
53 .export_threshold_mappings(®istry.threshold_mappings)?,
54 doctype_mappings_count: self.export_doctype_mappings(®istry.doc_type_mappings)?,
55 sod_conflicts_count: self.export_sod_conflicts(sod_conflicts)?,
56 sod_rules_count: self.export_sod_rules(sod_rules)?,
57 };
58
59 Ok(summary)
60 }
61
62 pub fn export_controls(&self, controls: &[InternalControl]) -> SynthResult<usize> {
64 let path = self.output_dir.join("internal_controls.csv");
65 let file = File::create(&path)?;
66 let mut writer = BufWriter::new(file);
67
68 writeln!(
70 writer,
71 "control_id,control_name,control_type,objective,frequency,owner_role,\
72 risk_level,is_key_control,sox_assertion"
73 )?;
74
75 for control in controls {
76 writeln!(
77 writer,
78 "{},{},{:?},{},{:?},{:?},{:?},{},{:?}",
79 escape_csv(&control.control_id),
80 escape_csv(&control.control_name),
81 control.control_type,
82 escape_csv(&control.objective),
83 control.frequency,
84 control.owner_role,
85 control.risk_level,
86 control.is_key_control,
87 control.sox_assertion,
88 )?;
89 }
90
91 writer.flush()?;
92 Ok(controls.len())
93 }
94
95 pub fn export_account_mappings(
97 &self,
98 mappings: &[ControlAccountMapping],
99 ) -> SynthResult<usize> {
100 let path = self.output_dir.join("control_account_mappings.csv");
101 let file = File::create(&path)?;
102 let mut writer = BufWriter::new(file);
103
104 writeln!(writer, "control_id,account_numbers,account_sub_types")?;
106
107 for mapping in mappings {
108 let account_numbers = mapping.account_numbers.join(";");
109 let sub_types: Vec<String> = mapping
110 .account_sub_types
111 .iter()
112 .map(|st| format!("{:?}", st))
113 .collect();
114
115 writeln!(
116 writer,
117 "{},{},{}",
118 escape_csv(&mapping.control_id),
119 escape_csv(&account_numbers),
120 escape_csv(&sub_types.join(";"))
121 )?;
122 }
123
124 writer.flush()?;
125 Ok(mappings.len())
126 }
127
128 pub fn export_process_mappings(
130 &self,
131 mappings: &[ControlProcessMapping],
132 ) -> SynthResult<usize> {
133 let path = self.output_dir.join("control_process_mappings.csv");
134 let file = File::create(&path)?;
135 let mut writer = BufWriter::new(file);
136
137 writeln!(writer, "control_id,business_processes")?;
139
140 for mapping in mappings {
141 let processes: Vec<String> = mapping
142 .business_processes
143 .iter()
144 .map(|bp| format!("{:?}", bp))
145 .collect();
146
147 writeln!(
148 writer,
149 "{},{}",
150 escape_csv(&mapping.control_id),
151 escape_csv(&processes.join(";"))
152 )?;
153 }
154
155 writer.flush()?;
156 Ok(mappings.len())
157 }
158
159 pub fn export_threshold_mappings(
161 &self,
162 mappings: &[ControlThresholdMapping],
163 ) -> SynthResult<usize> {
164 let path = self.output_dir.join("control_threshold_mappings.csv");
165 let file = File::create(&path)?;
166 let mut writer = BufWriter::new(file);
167
168 writeln!(
170 writer,
171 "control_id,amount_threshold,upper_threshold,comparison"
172 )?;
173
174 for mapping in mappings {
175 writeln!(
176 writer,
177 "{},{},{},{:?}",
178 escape_csv(&mapping.control_id),
179 mapping.amount_threshold,
180 mapping
181 .upper_threshold
182 .map(|t| t.to_string())
183 .unwrap_or_default(),
184 mapping.comparison
185 )?;
186 }
187
188 writer.flush()?;
189 Ok(mappings.len())
190 }
191
192 pub fn export_doctype_mappings(
194 &self,
195 mappings: &[ControlDocTypeMapping],
196 ) -> SynthResult<usize> {
197 let path = self.output_dir.join("control_doctype_mappings.csv");
198 let file = File::create(&path)?;
199 let mut writer = BufWriter::new(file);
200
201 writeln!(writer, "control_id,document_types")?;
203
204 for mapping in mappings {
205 writeln!(
206 writer,
207 "{},{}",
208 escape_csv(&mapping.control_id),
209 escape_csv(&mapping.document_types.join(";"))
210 )?;
211 }
212
213 writer.flush()?;
214 Ok(mappings.len())
215 }
216
217 pub fn export_sod_conflicts(&self, conflicts: &[SodConflictPair]) -> SynthResult<usize> {
219 let path = self.output_dir.join("sod_conflict_pairs.csv");
220 let file = File::create(&path)?;
221 let mut writer = BufWriter::new(file);
222
223 writeln!(writer, "conflict_type,role_a,role_b,description,severity")?;
225
226 for conflict in conflicts {
227 writeln!(
228 writer,
229 "{:?},{:?},{:?},{},{:?}",
230 conflict.conflict_type,
231 conflict.role_a,
232 conflict.role_b,
233 escape_csv(&conflict.description),
234 conflict.severity
235 )?;
236 }
237
238 writer.flush()?;
239 Ok(conflicts.len())
240 }
241
242 pub fn export_sod_rules(&self, rules: &[SodRule]) -> SynthResult<usize> {
244 let path = self.output_dir.join("sod_rules.csv");
245 let file = File::create(&path)?;
246 let mut writer = BufWriter::new(file);
247
248 writeln!(
250 writer,
251 "rule_id,name,conflict_type,description,is_active,risk_level"
252 )?;
253
254 for rule in rules {
255 writeln!(
256 writer,
257 "{},{},{:?},{},{},{:?}",
258 escape_csv(&rule.rule_id),
259 escape_csv(&rule.name),
260 rule.conflict_type,
261 escape_csv(&rule.description),
262 rule.is_active,
263 rule.risk_level
264 )?;
265 }
266
267 writer.flush()?;
268 Ok(rules.len())
269 }
270
271 pub fn export_standard(&self) -> SynthResult<ExportSummary> {
276 let controls = InternalControl::standard_controls();
277 let registry = ControlMappingRegistry::standard();
278 let sod_conflicts = SodConflictPair::standard_conflicts();
279 let sod_rules = SodRule::standard_rules();
280
281 self.export_all(&controls, ®istry, &sod_conflicts, &sod_rules)
282 }
283}
284
285#[derive(Debug, Default)]
287pub struct ExportSummary {
288 pub controls_count: usize,
290 pub account_mappings_count: usize,
292 pub process_mappings_count: usize,
294 pub threshold_mappings_count: usize,
296 pub doctype_mappings_count: usize,
298 pub sod_conflicts_count: usize,
300 pub sod_rules_count: usize,
302}
303
304impl ExportSummary {
305 pub fn total(&self) -> usize {
307 self.controls_count
308 + self.account_mappings_count
309 + self.process_mappings_count
310 + self.threshold_mappings_count
311 + self.doctype_mappings_count
312 + self.sod_conflicts_count
313 + self.sod_rules_count
314 }
315}
316
317fn escape_csv(s: &str) -> String {
319 if s.contains(',') || s.contains('"') || s.contains('\n') || s.contains('\r') {
320 format!("\"{}\"", s.replace('"', "\"\""))
321 } else {
322 s.to_string()
323 }
324}
325
326#[cfg(test)]
327mod tests {
328 use super::*;
329 use tempfile::TempDir;
330
331 #[test]
332 fn test_export_standard() {
333 let temp_dir = TempDir::new().unwrap();
334 let exporter = ControlExporter::new(temp_dir.path());
335
336 let summary = exporter.export_standard().unwrap();
337
338 assert!(summary.controls_count > 0);
339 assert!(summary.account_mappings_count > 0);
340 assert!(summary.process_mappings_count > 0);
341 assert!(summary.sod_conflicts_count > 0);
342 assert!(summary.sod_rules_count > 0);
343
344 assert!(temp_dir.path().join("internal_controls.csv").exists());
346 assert!(temp_dir
347 .path()
348 .join("control_account_mappings.csv")
349 .exists());
350 assert!(temp_dir
351 .path()
352 .join("control_process_mappings.csv")
353 .exists());
354 assert!(temp_dir.path().join("sod_conflict_pairs.csv").exists());
355 assert!(temp_dir.path().join("sod_rules.csv").exists());
356 }
357
358 #[test]
359 fn test_escape_csv() {
360 assert_eq!(escape_csv("hello"), "hello");
361 assert_eq!(escape_csv("hello,world"), "\"hello,world\"");
362 assert_eq!(escape_csv("hello\"world"), "\"hello\"\"world\"");
363 assert_eq!(escape_csv("hello\nworld"), "\"hello\nworld\"");
364 }
365
366 #[test]
367 fn test_export_controls() {
368 let temp_dir = TempDir::new().unwrap();
369 let exporter = ControlExporter::new(temp_dir.path());
370
371 let controls = InternalControl::standard_controls();
372 let count = exporter.export_controls(&controls).unwrap();
373
374 assert_eq!(count, controls.len());
375
376 let content =
378 std::fs::read_to_string(temp_dir.path().join("internal_controls.csv")).unwrap();
379 assert!(content.contains("control_id"));
380 assert!(content.contains("C001")); }
382
383 #[test]
384 fn test_export_sod_conflicts() {
385 let temp_dir = TempDir::new().unwrap();
386 let exporter = ControlExporter::new(temp_dir.path());
387
388 let conflicts = SodConflictPair::standard_conflicts();
389 let count = exporter.export_sod_conflicts(&conflicts).unwrap();
390
391 assert_eq!(count, conflicts.len());
392
393 let content =
394 std::fs::read_to_string(temp_dir.path().join("sod_conflict_pairs.csv")).unwrap();
395 assert!(content.contains("PreparerApprover"));
396 }
397}