Skip to main content

datasynth_output/
lib.rs

1#![cfg_attr(not(test), deny(clippy::unwrap_used))]
2//! # synth-output
3//!
4//! Output sinks for CSV, Parquet, JSON, and streaming formats.
5//! Also provides ERP-specific export formats for SAP, Oracle EBS, and NetSuite.
6
7use std::path::{Path, PathBuf};
8
9pub mod compressed;
10pub mod control_export;
11pub mod csv_sink;
12pub mod esg_export;
13pub mod fast_csv;
14pub mod formats;
15pub mod json_sink;
16pub mod parquet_sink;
17pub mod project_accounting_export;
18pub mod streaming;
19pub mod tax_export;
20pub mod treasury_export;
21
22/// Output routing config for the enhanced orchestrator's file-writer.
23///
24/// In single-entity mode (default) [`OutputRootConfig::flat`] preserves the
25/// pre-v5.0 layout: all generated files land directly under `root_dir`.
26///
27/// In group-audit shard mode, the runner sets `per_entity_subtree: true`
28/// and `entity_code: Some(code)` so each entity's archive is written under
29/// `{root_dir}/entities/{code}/`, leaving `{root_dir}/` itself available
30/// for group-wide artifacts (consolidated FS, aggregate summary, etc).
31#[derive(Debug, Clone, PartialEq, Eq)]
32pub struct OutputRootConfig {
33    /// Root output directory as configured by the caller.
34    pub root_dir: PathBuf,
35    /// When `true`, output is routed under `{root_dir}/entities/{entity_code}/`
36    /// so group-wide artifacts (consolidated FS, summary) can live alongside
37    /// per-entity shard archives at `{root_dir}/`.
38    pub per_entity_subtree: bool,
39    /// Entity code identifying the shard. Ignored when `per_entity_subtree`
40    /// is `false`. When `per_entity_subtree` is `true` but this is `None`,
41    /// [`OutputRootConfig::effective_dir`] safely falls back to `root_dir`.
42    pub entity_code: Option<String>,
43}
44
45impl OutputRootConfig {
46    /// Flat single-entity layout: files written directly under `root_dir`.
47    pub fn flat(root_dir: impl Into<PathBuf>) -> Self {
48        Self {
49            root_dir: root_dir.into(),
50            per_entity_subtree: false,
51            entity_code: None,
52        }
53    }
54
55    /// Per-entity subtree layout: files written under
56    /// `{root_dir}/entities/{entity_code}/`.
57    pub fn per_entity(root_dir: impl Into<PathBuf>, entity_code: impl Into<String>) -> Self {
58        Self {
59            root_dir: root_dir.into(),
60            per_entity_subtree: true,
61            entity_code: Some(entity_code.into()),
62        }
63    }
64
65    /// Resolve the effective `output_dir` path for the file-writer. This
66    /// is the directory into which all the existing `write_*` helpers
67    /// compose their `.join("subdir")` paths.
68    ///
69    /// Defensive fallback: if `per_entity_subtree` is `true` but
70    /// `entity_code` is `None`, we revert to `root_dir` rather than write
71    /// into a nameless `{root_dir}/entities/` directory.
72    pub fn effective_dir(&self) -> PathBuf {
73        match (self.per_entity_subtree, &self.entity_code) {
74            (true, Some(code)) => self.root_dir.join("entities").join(code),
75            (false, _) | (true, None) => self.root_dir.clone(),
76        }
77    }
78
79    /// Root directory borrowed as a `&Path` — useful for group-wide
80    /// artifacts (consolidated FS, aggregate summary) that must sit
81    /// alongside the per-entity subtrees rather than inside one of them.
82    pub fn root_path(&self) -> &Path {
83        &self.root_dir
84    }
85}
86
87impl Default for OutputRootConfig {
88    fn default() -> Self {
89        Self::flat(PathBuf::from("."))
90    }
91}
92
93pub use compressed::{CompressedWriter, CompressionConfig};
94pub use control_export::*;
95pub use csv_sink::*;
96pub use esg_export::*;
97pub use formats::{
98    saft_naive_date, write_anla, write_bsad, write_bsak, write_bsas, write_bsid, write_bsik,
99    write_bsis, write_cepc, write_csks, write_ekko, write_ekpo, write_fec_csv,
100    write_gobd_accounts_csv, write_gobd_index_xml, write_gobd_journal_csv, write_kna1, write_knb1,
101    write_lfa1, write_lfb1, write_likp, write_lips, write_mara, write_mard, write_mkpf, write_mseg,
102    write_saft, write_ska1, write_skb1, write_vbak, write_vbap, NetSuiteExporter,
103    NetSuiteJournalEntry, NetSuiteJournalLine, OracleExporter, OracleJeHeader, OracleJeLine,
104    SaftConfig, SaftData, SaftJurisdiction, SapAsset, SapAssetExportable, SapClearedItemRow,
105    SapCostCenter, SapCostCenterExportable, SapCustomer, SapCustomerCompanyCode,
106    SapCustomerCompanyCodeExportable, SapCustomerExportable, SapDeliveryExportable,
107    SapDeliveryHeader, SapDeliveryItem, SapDialect, SapExportConfig, SapExporter,
108    SapGlAccountCompanyCode, SapGlAccountExportable, SapGlAccountGeneral, SapMatDocExportable,
109    SapMatDocHeader, SapMatDocItem, SapMaterial, SapMaterialExportable, SapMaterialStorage,
110    SapMaterialStorageExportable, SapOpenItemRow, SapPoExportable, SapPoHeader, SapPoItem,
111    SapProfitCenter, SapProfitCenterExportable, SapSoExportable, SapSoHeader, SapSoItem,
112    SapTableType, SapVendor, SapVendorCompanyCode, SapVendorCompanyCodeExportable,
113    SapVendorExportable, XbrlExporter,
114};
115pub use json_sink::*;
116pub use parquet_sink::*;
117pub use project_accounting_export::*;
118pub use streaming::{
119    CsvStreamingSink, JsonStreamingSink, NdjsonStreamingSink, ParquetStreamingSink,
120};
121pub use tax_export::*;
122pub use treasury_export::*;
123
124#[cfg(test)]
125mod test_helpers;
126
127#[cfg(test)]
128mod output_root_config_tests {
129    use super::*;
130    use std::path::Path;
131
132    #[test]
133    fn flat_mode_returns_root_dir() {
134        let cfg = OutputRootConfig::flat("/tmp/out");
135        assert_eq!(cfg.effective_dir(), Path::new("/tmp/out"));
136        assert!(!cfg.per_entity_subtree);
137        assert!(cfg.entity_code.is_none());
138    }
139
140    #[test]
141    fn per_entity_mode_routes_under_entities_code() {
142        let cfg = OutputRootConfig::per_entity("/tmp/out", "ACME_SA");
143        assert_eq!(cfg.effective_dir(), Path::new("/tmp/out/entities/ACME_SA"));
144        assert!(cfg.per_entity_subtree);
145        assert_eq!(cfg.entity_code.as_deref(), Some("ACME_SA"));
146    }
147
148    #[test]
149    fn per_entity_true_without_code_falls_back_to_root() {
150        // Defensive: if a caller sets per_entity_subtree: true but forgets
151        // to set entity_code, we must not write to a bogus
152        // "/tmp/out/entities/" path — silently revert to flat.
153        let cfg = OutputRootConfig {
154            root_dir: "/tmp/out".into(),
155            per_entity_subtree: true,
156            entity_code: None,
157        };
158        assert_eq!(cfg.effective_dir(), Path::new("/tmp/out"));
159    }
160
161    #[test]
162    fn default_is_flat_at_cwd() {
163        let cfg = OutputRootConfig::default();
164        assert_eq!(cfg.effective_dir(), Path::new("."));
165        assert!(!cfg.per_entity_subtree);
166        assert!(cfg.entity_code.is_none());
167    }
168
169    #[test]
170    fn root_path_always_returns_root_regardless_of_mode() {
171        // root_path() exposes the configured root for group-wide artifacts
172        // (consolidated FS, aggregate summary) that must NOT land inside
173        // one entity's subtree.
174        let flat = OutputRootConfig::flat("/tmp/out");
175        assert_eq!(flat.root_path(), Path::new("/tmp/out"));
176
177        let per_entity = OutputRootConfig::per_entity("/tmp/out", "ENT_A");
178        assert_eq!(per_entity.root_path(), Path::new("/tmp/out"));
179    }
180}