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
163        if props.contains_key("mode") {
164            return Err(ConnectorError::ConfigurationError(
165                "file sink option 'mode' was removed; files are always published as immutable rolling files"
166                    .into(),
167            ));
168        }
169
170        let prefix = props
171            .get("prefix")
172            .cloned()
173            .unwrap_or_else(|| "part".to_string());
174        if prefix.is_empty()
175            || prefix == "."
176            || prefix == ".."
177            || prefix.contains('/')
178            || prefix.contains('\\')
179        {
180            return Err(ConnectorError::ConfigurationError(
181                "file sink 'prefix' must be a non-empty file-name component".into(),
182            ));
183        }
184
185        let max_file_size = props
186            .get("max_file_size")
187            .map(|s| {
188                s.parse::<usize>().map_err(|e| {
189                    ConnectorError::ConfigurationError(format!("invalid max_file_size: {e}"))
190                })
191            })
192            .transpose()?;
193
194        let compression = props
195            .get("compression")
196            .cloned()
197            .unwrap_or_else(|| "snappy".to_string());
198
199        if props.contains_key("max_epoch_batches") {
200            return Err(ConnectorError::ConfigurationError(
201                "file sink option 'max_epoch_batches' was replaced by 'max_buffered_batches'"
202                    .into(),
203            ));
204        }
205        let max_buffered_batches = props
206            .get("max_buffered_batches")
207            .map(|s| {
208                s.parse::<usize>().map_err(|e| {
209                    ConnectorError::ConfigurationError(format!("invalid max_buffered_batches: {e}"))
210                })
211            })
212            .transpose()?
213            .unwrap_or(10_000);
214        if max_buffered_batches == 0 {
215            return Err(ConnectorError::ConfigurationError(
216                "max_buffered_batches must be > 0".into(),
217            ));
218        }
219
220        Ok(Self {
221            path,
222            format,
223            prefix,
224            max_file_size,
225            compression,
226            max_buffered_batches,
227        })
228    }
229}
230
231pub(super) fn local_files_path(path: &str) -> Result<String, ConnectorError> {
232    if path.trim().is_empty() {
233        return Err(ConnectorError::ConfigurationError(
234            "file connector 'path' must not be empty".into(),
235        ));
236    }
237    if std::path::Path::new(path).is_absolute() || !path.contains("://") {
238        return Ok(path.to_string());
239    }
240    let location = match StorageLocation::parse(path) {
241        Ok(location) => location,
242        Err(StorageLocationError::UnsupportedScheme(_)) => return Err(remote_files_error()),
243        Err(error) => {
244            return Err(ConnectorError::ConfigurationError(format!(
245                "invalid file connector path: {error}"
246            )))
247        }
248    };
249    if location.provider != StorageProvider::Local {
250        return Err(remote_files_error());
251    }
252    let adapted = location
253        .adapt(StorageConsumer::ObjectStore)
254        .map_err(|error| ConnectorError::ConfigurationError(error.to_string()))?;
255    let url = url::Url::parse(&adapted.url).map_err(|error| {
256        ConnectorError::ConfigurationError(format!("invalid canonical file URL: {error}"))
257    })?;
258    url.to_file_path()
259        .map_err(|()| {
260            ConnectorError::ConfigurationError(
261                "file connector URL must identify an absolute local path".into(),
262            )
263        })?
264        .into_os_string()
265        .into_string()
266        .map_err(|_| {
267            ConnectorError::ConfigurationError(
268                "file connector path cannot be represented as UTF-8".into(),
269            )
270        })
271}
272
273fn remote_files_error() -> ConnectorError {
274    ConnectorError::FeatureUnsupported(
275        "remote Files connector backends are not enabled; use a local path or absolute file:// URL"
276            .into(),
277    )
278}
279
280/// Supported file formats.
281#[derive(Debug, Clone, Copy, PartialEq, Eq)]
282pub enum FileFormat {
283    /// Comma-separated values.
284    Csv,
285    /// Newline-delimited JSON.
286    Json,
287    /// Plain text (one line per record).
288    Text,
289    /// Apache Parquet.
290    Parquet,
291    /// Arrow IPC file format (random-access `.arrow` files).
292    ArrowIpc,
293}
294
295impl FileFormat {
296    /// Parse a format string.
297    ///
298    /// # Errors
299    ///
300    /// Returns `ConnectorError::ConfigurationError` for unknown formats.
301    pub fn parse(s: &str) -> Result<Self, ConnectorError> {
302        match s.to_lowercase().as_str() {
303            "csv" | "tsv" => Ok(Self::Csv),
304            "json" | "jsonl" | "ndjson" | "json_lines" => Ok(Self::Json),
305            "text" | "txt" | "plain" => Ok(Self::Text),
306            "parquet" | "parq" => Ok(Self::Parquet),
307            "arrow" | "ipc" | "arrow_ipc" => Ok(Self::ArrowIpc),
308            other => Err(ConnectorError::ConfigurationError(format!(
309                "unknown file format: '{other}' (expected csv, json, text, parquet, or arrow)"
310            ))),
311        }
312    }
313
314    /// Detect format from a file path extension.
315    pub fn from_extension(path: &str) -> Option<Self> {
316        let ext = path.rsplit('.').next()?.to_lowercase();
317        match ext.as_str() {
318            "csv" | "tsv" => Some(Self::Csv),
319            "json" | "jsonl" | "ndjson" => Some(Self::Json),
320            "txt" | "log" => Some(Self::Text),
321            "parquet" | "parq" => Some(Self::Parquet),
322            "arrow" | "ipc" => Some(Self::ArrowIpc),
323            _ => None,
324        }
325    }
326
327    /// Returns the canonical file extension for this format.
328    #[must_use]
329    pub fn extension(&self) -> &'static str {
330        match self {
331            Self::Csv => "csv",
332            Self::Json => "jsonl",
333            Self::Text => "txt",
334            Self::Parquet => "parquet",
335            Self::ArrowIpc => "arrow",
336        }
337    }
338
339    /// Whether this is a columnar/bulk format (cannot be truncated mid-file).
340    #[must_use]
341    pub fn is_bulk_format(&self) -> bool {
342        matches!(self, Self::Parquet | Self::ArrowIpc)
343    }
344}
345
346// ── Helpers ──────────────────────────────────────────────────────────
347
348fn parse_duration(
349    props: &HashMap<String, String>,
350    key: &str,
351    default: Duration,
352) -> Result<Duration, ConnectorError> {
353    match props.get(key) {
354        Some(s) => parse_duration_str(s).ok_or_else(|| {
355            ConnectorError::ConfigurationError(format!("invalid duration for {key}: '{s}'"))
356        }),
357        None => Ok(default),
358    }
359}
360
361fn parse_usize(
362    props: &HashMap<String, String>,
363    key: &str,
364    default: usize,
365) -> Result<usize, ConnectorError> {
366    match props.get(key) {
367        Some(s) => s
368            .parse()
369            .map_err(|e| ConnectorError::ConfigurationError(format!("invalid {key}: {e}"))),
370        None => Ok(default),
371    }
372}
373
374fn parse_bool(
375    props: &HashMap<String, String>,
376    key: &str,
377    default: bool,
378) -> Result<bool, ConnectorError> {
379    match props.get(key) {
380        Some(s) => match s.to_lowercase().as_str() {
381            "true" | "1" | "yes" => Ok(true),
382            "false" | "0" | "no" => Ok(false),
383            other => Err(ConnectorError::ConfigurationError(format!(
384                "invalid boolean for {key}: '{other}'"
385            ))),
386        },
387        None => Ok(default),
388    }
389}
390#[cfg(test)]
391mod tests;