Skip to main content

datasynth_output/
lib.rs

1#![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)]
125#[allow(clippy::unwrap_used)]
126mod test_helpers;
127
128#[cfg(test)]
129mod output_root_config_tests {
130    use super::*;
131    use std::path::Path;
132
133    #[test]
134    fn flat_mode_returns_root_dir() {
135        let cfg = OutputRootConfig::flat("/tmp/out");
136        assert_eq!(cfg.effective_dir(), Path::new("/tmp/out"));
137        assert!(!cfg.per_entity_subtree);
138        assert!(cfg.entity_code.is_none());
139    }
140
141    #[test]
142    fn per_entity_mode_routes_under_entities_code() {
143        let cfg = OutputRootConfig::per_entity("/tmp/out", "NESTLE_SA");
144        assert_eq!(
145            cfg.effective_dir(),
146            Path::new("/tmp/out/entities/NESTLE_SA")
147        );
148        assert!(cfg.per_entity_subtree);
149        assert_eq!(cfg.entity_code.as_deref(), Some("NESTLE_SA"));
150    }
151
152    #[test]
153    fn per_entity_true_without_code_falls_back_to_root() {
154        // Defensive: if a caller sets per_entity_subtree: true but forgets
155        // to set entity_code, we must not write to a bogus
156        // "/tmp/out/entities/" path — silently revert to flat.
157        let cfg = OutputRootConfig {
158            root_dir: "/tmp/out".into(),
159            per_entity_subtree: true,
160            entity_code: None,
161        };
162        assert_eq!(cfg.effective_dir(), Path::new("/tmp/out"));
163    }
164
165    #[test]
166    fn default_is_flat_at_cwd() {
167        let cfg = OutputRootConfig::default();
168        assert_eq!(cfg.effective_dir(), Path::new("."));
169        assert!(!cfg.per_entity_subtree);
170        assert!(cfg.entity_code.is_none());
171    }
172
173    #[test]
174    fn root_path_always_returns_root_regardless_of_mode() {
175        // root_path() exposes the configured root for group-wide artifacts
176        // (consolidated FS, aggregate summary) that must NOT land inside
177        // one entity's subtree.
178        let flat = OutputRootConfig::flat("/tmp/out");
179        assert_eq!(flat.root_path(), Path::new("/tmp/out"));
180
181        let per_entity = OutputRootConfig::per_entity("/tmp/out", "ENT_A");
182        assert_eq!(per_entity.root_path(), Path::new("/tmp/out"));
183    }
184}