Skip to main content

datasynth_output/
control_export.rs

1//! Export internal controls master data to CSV files.
2//!
3//! Exports control definitions, mappings, and SoD conflict pairs
4//! as separate CSV files for use in BI/analytics systems.
5
6use 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
16/// Exporter for internal controls master data.
17pub struct ControlExporter {
18    output_dir: PathBuf,
19}
20
21impl ControlExporter {
22    /// Create a new control exporter.
23    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    /// Export all control master data.
30    ///
31    /// Creates the following CSV files:
32    /// - internal_controls.csv
33    /// - control_account_mappings.csv
34    /// - control_process_mappings.csv
35    /// - control_threshold_mappings.csv
36    /// - control_doctype_mappings.csv
37    /// - sod_conflict_pairs.csv
38    /// - sod_rules.csv
39    /// - coso_control_mapping.csv
40    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(&registry.account_mappings)?,
52            process_mappings_count: self.export_process_mappings(&registry.process_mappings)?,
53            threshold_mappings_count: self
54                .export_threshold_mappings(&registry.threshold_mappings)?,
55            doctype_mappings_count: self.export_doctype_mappings(&registry.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    /// Export internal control definitions.
65    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        // Header
71        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            // Format COSO principles as semicolon-separated list
79            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    /// Export control-to-account mappings.
109    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        // Header
118        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    /// Export control-to-process mappings.
142    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        // Header
151        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    /// Export control-to-threshold mappings.
173    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        // Header
182        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    /// Export control-to-document type mappings.
206    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        // Header
215        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    /// Export SoD conflict pairs.
231    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        // Header
237        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    /// Export SoD rules.
256    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        // Header
262        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    /// Export COSO control mapping.
285    ///
286    /// Creates a detailed mapping of controls to COSO components and principles.
287    /// Each row represents one principle mapped to a control.
288    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        // Header
294        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    /// Export standard control master data.
320    ///
321    /// This is a convenience method that exports standard controls,
322    /// mappings, and SoD definitions.
323    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, &registry, &sod_conflicts, &sod_rules)
330    }
331}
332
333/// Summary of exported control data.
334#[derive(Debug, Default)]
335pub struct ExportSummary {
336    /// Number of control definitions exported.
337    pub controls_count: usize,
338    /// Number of account mappings exported.
339    pub account_mappings_count: usize,
340    /// Number of process mappings exported.
341    pub process_mappings_count: usize,
342    /// Number of threshold mappings exported.
343    pub threshold_mappings_count: usize,
344    /// Number of document type mappings exported.
345    pub doctype_mappings_count: usize,
346    /// Number of SoD conflict pairs exported.
347    pub sod_conflicts_count: usize,
348    /// Number of SoD rules exported.
349    pub sod_rules_count: usize,
350    /// Number of COSO control-principle mappings exported.
351    pub coso_mappings_count: usize,
352}
353
354impl ExportSummary {
355    /// Get the total number of records exported.
356    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
368/// Escape a string for CSV output.
369fn 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        // Verify files were created
397        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        // Read the file and verify content
430        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")); // Cash control
434    }
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}