Skip to main content

laminar_connectors/files/config/
mod.rs

1//! Configuration types for the file source and sink connectors.
2
3use std::collections::HashMap;
4use std::time::Duration;
5
6use crate::config::ConnectorConfig;
7use crate::error::ConnectorError;
8use crate::storage::{StorageConsumer, StorageLocation, StorageLocationError, StorageProvider};
9use laminar_core::time::parse_duration_str;
10
11/// Parsed configuration for [`super::source::FileSource`].
12#[derive(Debug, Clone)]
13pub struct FileSourceConfig {
14    /// Local directory path or glob pattern.
15    pub path: String,
16
17    /// Data format (`csv`, `json`, `text`, `parquet`). `None` = auto-detect.
18    pub format: Option<FileFormat>,
19
20    /// Discovery polling interval.
21    pub poll_interval: Duration,
22
23    /// Wait after last modify event before considering a file complete.
24    pub stabilisation_delay: Duration,
25
26    /// Maximum files to process per `poll_batch` call.
27    pub max_files_per_poll: usize,
28
29    /// Whether to append a `_metadata` struct column.
30    pub include_metadata: bool,
31
32    /// Safety limit for reading a single file (bytes). Primarily for Parquet.
33    pub max_file_bytes: usize,
34
35    /// Additional glob pattern to filter discovered file names.
36    pub glob_pattern: Option<String>,
37
38    /// CSV-specific: field delimiter.
39    pub csv_delimiter: u8,
40
41    /// CSV-specific: whether the first row is a header.
42    pub csv_has_header: bool,
43}
44
45impl FileSourceConfig {
46    /// Parse from connector properties.
47    ///
48    /// # Errors
49    ///
50    /// Returns `ConnectorError::ConfigurationError` if required options are
51    /// missing or values cannot be parsed.
52    pub fn from_connector_config(config: &ConnectorConfig) -> Result<Self, ConnectorError> {
53        let props = config.properties();
54
55        let path = props
56            .get("path")
57            .ok_or_else(|| ConnectorError::ConfigurationError("'path' is required".into()))
58            .and_then(|path| local_files_path(path))?;
59
60        let format = props
61            .get("format")
62            .map(|s| FileFormat::parse(s))
63            .transpose()?;
64
65        let poll_interval = parse_duration(props, "poll_interval", Duration::from_secs(10))?;
66        let stabilisation_delay =
67            parse_duration(props, "stabilisation_delay", Duration::from_secs(1))?;
68        let max_files_per_poll = parse_usize(props, "max_files_per_poll", 100)?;
69        if max_files_per_poll == 0 {
70            return Err(ConnectorError::ConfigurationError(
71                "'max_files_per_poll' must be greater than zero".into(),
72            ));
73        }
74        let include_metadata = parse_bool(props, "include_metadata", false)?;
75        for removed in [
76            "allow_overwrites",
77            "manifest_retention_count",
78            "manifest_retention_age_days",
79        ] {
80            if props.contains_key(removed) {
81                return Err(ConnectorError::ConfigurationError(format!(
82                    "file source option '{removed}' was removed; processed paths are immutable and retained in one exact checkpoint inventory"
83                )));
84            }
85        }
86        let max_file_bytes = parse_usize(props, "max_file_bytes", 256 * 1024 * 1024)?;
87        if max_file_bytes == 0 {
88            return Err(ConnectorError::ConfigurationError(
89                "'max_file_bytes' must be greater than zero".into(),
90            ));
91        }
92        let glob_pattern = props.get("glob_pattern").cloned();
93
94        // Default delimiter: tab for tsv, comma otherwise.
95        let is_tsv = props
96            .get("format")
97            .is_some_and(|f| f.eq_ignore_ascii_case("tsv"));
98        let csv_delimiter = props
99            .get("csv.delimiter")
100            .and_then(|s| s.as_bytes().first().copied())
101            .unwrap_or(if is_tsv { b'\t' } else { b',' });
102        let csv_has_header = parse_bool(props, "csv.has_header", true)?;
103
104        Ok(Self {
105            path,
106            format,
107            poll_interval,
108            stabilisation_delay,
109            max_files_per_poll,
110            include_metadata,
111            max_file_bytes,
112            glob_pattern,
113            csv_delimiter,
114            csv_has_header,
115        })
116    }
117}
118
119/// Parsed configuration for [`super::sink::FileSink`].
120#[derive(Debug, Clone)]
121pub struct FileSinkConfig {
122    /// Output directory path.
123    pub path: String,
124
125    /// Output format.
126    pub format: FileFormat,
127
128    /// File name prefix for immutable output files.
129    pub prefix: String,
130
131    /// Maximum file size before rotation (row formats only).
132    pub max_file_size: Option<usize>,
133
134    /// Parquet compression codec.
135    pub compression: String,
136
137    /// Maximum batches to buffer between bulk-format flushes (default: 10,000).
138    pub max_buffered_batches: usize,
139}
140
141impl FileSinkConfig {
142    /// Parse from connector properties.
143    ///
144    /// # Errors
145    ///
146    /// Returns `ConnectorError::ConfigurationError` if required options are
147    /// missing or values cannot be parsed.
148    pub fn from_connector_config(config: &ConnectorConfig) -> Result<Self, ConnectorError> {
149        let props = config.properties();
150
151        let path = props
152            .get("path")
153            .ok_or_else(|| ConnectorError::ConfigurationError("'path' is required".into()))
154            .and_then(|path| local_files_path(path))?;
155
156        let format = props
157            .get("format")
158            .ok_or_else(|| {
159                ConnectorError::ConfigurationError("'format' is required for file sink".into())
160            })
161            .and_then(|s| FileFormat::parse(s))?;
162        #[cfg(feature = "fpml")]
163        if format == FileFormat::Fpml {
164            return Err(ConnectorError::ConfigurationError(
165                "the file sink does not write FpML; write projected rows as parquet, arrow, csv, or json".into(),
166            ));
167        }
168
169        if props.contains_key("mode") {
170            return Err(ConnectorError::ConfigurationError(
171                "file sink option 'mode' was removed; files are always published as immutable rolling files"
172                    .into(),
173            ));
174        }
175
176        let prefix = props
177            .get("prefix")
178            .cloned()
179            .unwrap_or_else(|| "part".to_string());
180        if prefix.is_empty()
181            || prefix == "."
182            || prefix == ".."
183            || prefix.contains('/')
184            || prefix.contains('\\')
185        {
186            return Err(ConnectorError::ConfigurationError(
187                "file sink 'prefix' must be a non-empty file-name component".into(),
188            ));
189        }
190
191        let max_file_size = props
192            .get("max_file_size")
193            .map(|s| {
194                s.parse::<usize>().map_err(|e| {
195                    ConnectorError::ConfigurationError(format!("invalid max_file_size: {e}"))
196                })
197            })
198            .transpose()?;
199
200        let compression = props
201            .get("compression")
202            .cloned()
203            .unwrap_or_else(|| "snappy".to_string());
204
205        if props.contains_key("max_epoch_batches") {
206            return Err(ConnectorError::ConfigurationError(
207                "file sink option 'max_epoch_batches' was replaced by 'max_buffered_batches'"
208                    .into(),
209            ));
210        }
211        let max_buffered_batches = props
212            .get("max_buffered_batches")
213            .map(|s| {
214                s.parse::<usize>().map_err(|e| {
215                    ConnectorError::ConfigurationError(format!("invalid max_buffered_batches: {e}"))
216                })
217            })
218            .transpose()?
219            .unwrap_or(10_000);
220        if max_buffered_batches == 0 {
221            return Err(ConnectorError::ConfigurationError(
222                "max_buffered_batches must be > 0".into(),
223            ));
224        }
225
226        Ok(Self {
227            path,
228            format,
229            prefix,
230            max_file_size,
231            compression,
232            max_buffered_batches,
233        })
234    }
235}
236
237pub(super) fn local_files_path(path: &str) -> Result<String, ConnectorError> {
238    if path.trim().is_empty() {
239        return Err(ConnectorError::ConfigurationError(
240            "file connector 'path' must not be empty".into(),
241        ));
242    }
243    if std::path::Path::new(path).is_absolute() || !path.contains("://") {
244        return Ok(path.to_string());
245    }
246    let location = match StorageLocation::parse(path) {
247        Ok(location) => location,
248        Err(StorageLocationError::UnsupportedScheme(_)) => return Err(remote_files_error()),
249        Err(error) => {
250            return Err(ConnectorError::ConfigurationError(format!(
251                "invalid file connector path: {error}"
252            )))
253        }
254    };
255    if location.provider != StorageProvider::Local {
256        return Err(remote_files_error());
257    }
258    let adapted = location
259        .adapt(StorageConsumer::ObjectStore)
260        .map_err(|error| ConnectorError::ConfigurationError(error.to_string()))?;
261    let url = url::Url::parse(&adapted.url).map_err(|error| {
262        ConnectorError::ConfigurationError(format!("invalid canonical file URL: {error}"))
263    })?;
264    url.to_file_path()
265        .map_err(|()| {
266            ConnectorError::ConfigurationError(
267                "file connector URL must identify an absolute local path".into(),
268            )
269        })?
270        .into_os_string()
271        .into_string()
272        .map_err(|_| {
273            ConnectorError::ConfigurationError(
274                "file connector path cannot be represented as UTF-8".into(),
275            )
276        })
277}
278
279fn remote_files_error() -> ConnectorError {
280    ConnectorError::FeatureUnsupported(
281        "remote Files connector backends are not enabled; use a local path or absolute file:// URL"
282            .into(),
283    )
284}
285
286/// Supported file formats.
287#[derive(Debug, Clone, Copy, PartialEq, Eq)]
288pub enum FileFormat {
289    /// Comma-separated values.
290    Csv,
291    /// Newline-delimited JSON.
292    Json,
293    /// Plain text (one line per record).
294    Text,
295    /// Apache Parquet.
296    Parquet,
297    /// Arrow IPC file format (random-access `.arrow` files).
298    ArrowIpc,
299    /// One complete document per file, validated and projected by an  profile.
300    #[cfg(feature = "fpml")]
301    Fpml,
302}
303
304impl FileFormat {
305    /// Parse a format string.
306    ///
307    /// # Errors
308    ///
309    /// Returns `ConnectorError::ConfigurationError` for unknown formats.
310    pub fn parse(s: &str) -> Result<Self, ConnectorError> {
311        match s.to_lowercase().as_str() {
312            "csv" | "tsv" => Ok(Self::Csv),
313            "json" | "jsonl" | "ndjson" | "json_lines" => Ok(Self::Json),
314            "text" | "txt" | "plain" => Ok(Self::Text),
315            "parquet" | "parq" => Ok(Self::Parquet),
316            "arrow" | "ipc" | "arrow_ipc" => Ok(Self::ArrowIpc),
317            #[cfg(feature = "fpml")]
318            "fpml" => Ok(Self::Fpml),
319            other => Err(ConnectorError::ConfigurationError(format!(
320                "unknown file format: '{other}' (expected csv, json, text, parquet, or arrow)"
321            ))),
322        }
323    }
324
325    /// Detect format from a file path extension.
326    pub fn from_extension(path: &str) -> Option<Self> {
327        let ext = path.rsplit('.').next()?.to_lowercase();
328        match ext.as_str() {
329            "csv" | "tsv" => Some(Self::Csv),
330            "json" | "jsonl" | "ndjson" => Some(Self::Json),
331            "txt" | "log" => Some(Self::Text),
332            "parquet" | "parq" => Some(Self::Parquet),
333            "arrow" | "ipc" => Some(Self::ArrowIpc),
334            _ => None,
335        }
336    }
337
338    /// Returns the canonical file extension for this format.
339    #[must_use]
340    pub fn extension(&self) -> &'static str {
341        match self {
342            Self::Csv => "csv",
343            Self::Json => "jsonl",
344            Self::Text => "txt",
345            Self::Parquet => "parquet",
346            Self::ArrowIpc => "arrow",
347            #[cfg(feature = "fpml")]
348            Self::Fpml => "xml",
349        }
350    }
351
352    /// Whether this is a columnar/bulk format (cannot be truncated mid-file).
353    #[must_use]
354    pub fn is_bulk_format(&self) -> bool {
355        matches!(self, Self::Parquet | Self::ArrowIpc)
356    }
357}
358
359// ── Helpers ──────────────────────────────────────────────────────────
360
361fn parse_duration(
362    props: &HashMap<String, String>,
363    key: &str,
364    default: Duration,
365) -> Result<Duration, ConnectorError> {
366    match props.get(key) {
367        Some(s) => parse_duration_str(s).ok_or_else(|| {
368            ConnectorError::ConfigurationError(format!("invalid duration for {key}: '{s}'"))
369        }),
370        None => Ok(default),
371    }
372}
373
374fn parse_usize(
375    props: &HashMap<String, String>,
376    key: &str,
377    default: usize,
378) -> Result<usize, ConnectorError> {
379    match props.get(key) {
380        Some(s) => s
381            .parse()
382            .map_err(|e| ConnectorError::ConfigurationError(format!("invalid {key}: {e}"))),
383        None => Ok(default),
384    }
385}
386
387fn parse_bool(
388    props: &HashMap<String, String>,
389    key: &str,
390    default: bool,
391) -> Result<bool, ConnectorError> {
392    match props.get(key) {
393        Some(s) => match s.to_lowercase().as_str() {
394            "true" | "1" | "yes" => Ok(true),
395            "false" | "0" | "no" => Ok(false),
396            other => Err(ConnectorError::ConfigurationError(format!(
397                "invalid boolean for {key}: '{other}'"
398            ))),
399        },
400        None => Ok(default),
401    }
402}
403#[cfg(test)]
404mod tests;