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    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(&registry.account_mappings)?,
51            process_mappings_count: self.export_process_mappings(&registry.process_mappings)?,
52            threshold_mappings_count: self
53                .export_threshold_mappings(&registry.threshold_mappings)?,
54            doctype_mappings_count: self.export_doctype_mappings(&registry.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    /// Export internal control definitions.
63    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        // Header
69        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    /// Export control-to-account mappings.
96    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        // Header
105        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    /// Export control-to-process mappings.
129    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        // Header
138        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    /// Export control-to-threshold mappings.
160    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        // Header
169        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    /// Export control-to-document type mappings.
193    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        // Header
202        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    /// Export SoD conflict pairs.
218    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        // Header
224        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    /// Export SoD rules.
243    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        // Header
249        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    /// Export standard control master data.
272    ///
273    /// This is a convenience method that exports standard controls,
274    /// mappings, and SoD definitions.
275    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, &registry, &sod_conflicts, &sod_rules)
282    }
283}
284
285/// Summary of exported control data.
286#[derive(Debug, Default)]
287pub struct ExportSummary {
288    /// Number of control definitions exported.
289    pub controls_count: usize,
290    /// Number of account mappings exported.
291    pub account_mappings_count: usize,
292    /// Number of process mappings exported.
293    pub process_mappings_count: usize,
294    /// Number of threshold mappings exported.
295    pub threshold_mappings_count: usize,
296    /// Number of document type mappings exported.
297    pub doctype_mappings_count: usize,
298    /// Number of SoD conflict pairs exported.
299    pub sod_conflicts_count: usize,
300    /// Number of SoD rules exported.
301    pub sod_rules_count: usize,
302}
303
304impl ExportSummary {
305    /// Get the total number of records exported.
306    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
317/// Escape a string for CSV output.
318fn 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        // Verify files were created
345        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        // Read the file and verify content
377        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")); // Cash control
381    }
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}