1#![cfg_attr(docsrs, feature(doc_cfg))]
2use faucet_core::compression::{Compression, CompressionConfig};
9use faucet_core::{FaucetError, FileFormat};
10use schemars::JsonSchema;
11use serde::{Deserialize, Serialize};
12
13pub mod capabilities;
14#[cfg(feature = "encryption")]
15pub mod sealed_lines;
16pub mod write;
17
18#[doc(hidden)]
19pub mod __private {
20 pub use faucet_core::async_trait;
21 pub use faucet_core::check::{CheckContext, CheckReport};
22 pub use faucet_core::forward_sink;
23 pub use faucet_core::io::CallContext;
24 pub use faucet_core::observability::RoundtripRecorder;
25 pub use faucet_core::{
26 BatchAtomicity, Capabilities, FaucetError, LocalOutput, Sink, WriteMode,
27 };
28 pub use serde_json::Value;
29}
30
31#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, JsonSchema)]
33#[serde(rename_all = "snake_case")]
34pub enum FileFormatChoice {
35 #[default]
39 Auto,
40 JsonLines,
42 JsonArray,
44 Csv,
46 Xml,
48 Xlsx,
50 Parquet,
52 RawText,
54 Avro,
56 Orc,
58}
59
60impl FileFormatChoice {
61 pub fn explicit(self) -> Option<FileFormat> {
63 Some(match self {
64 Self::Auto => return None,
65 Self::JsonLines => FileFormat::JsonLines,
66 Self::JsonArray => FileFormat::JsonArray,
67 Self::Csv => FileFormat::Csv,
68 Self::Xml => FileFormat::Xml,
69 Self::Xlsx => FileFormat::Xlsx,
70 Self::Parquet => FileFormat::Parquet,
71 Self::RawText => FileFormat::RawText,
72 Self::Avro => FileFormat::Avro,
73 Self::Orc => FileFormat::Orc,
74 })
75 }
76
77 pub fn resolve(self, name: &str, strict: bool) -> Result<Option<FileFormat>, FaucetError> {
82 if let Some(f) = self.explicit() {
83 return Ok(Some(f));
84 }
85 match FileFormat::from_path(name) {
86 Some(f) => Ok(Some(f)),
87 None if strict => Err(FaucetError::Config(format!(
88 "'{name}' has no recognised extension and `strict` is on; set `format`"
89 ))),
90 None => Ok(None),
91 }
92 }
93}
94
95pub const WRITABLE_EXTENSIONS: &str = ".jsonl .json .csv .xml .xlsx .avro .parquet .txt";
97
98impl FileFormatChoice {
99 pub fn resolve_writable(self, name: &str) -> Result<FileFormat, FaucetError> {
103 let format = self.resolve(name, false)?.ok_or_else(|| {
104 FaucetError::Config(format!(
105 "'{name}' has no extension naming a writable format — use one of \
106 {WRITABLE_EXTENSIONS} (optionally + .gz/.zst) or set `format`"
107 ))
108 })?;
109 if !format.is_writable() && format != FileFormat::Parquet {
110 return Err(FaucetError::Config(format!(
111 "`{}` is read-only — there is no {} writer; write Parquet for a columnar output",
112 format.as_str(),
113 format.as_str().to_ascii_uppercase()
114 )));
115 }
116 Ok(format)
117 }
118}
119
120pub fn resolve_compression(config: CompressionConfig, path: &str) -> Compression {
123 config.resolve(resolution_name(path))
124}
125
126pub fn compresses_internally(format: FileFormat) -> bool {
129 matches!(
130 format,
131 FileFormat::Parquet | FileFormat::Avro | FileFormat::Xlsx
132 )
133}
134
135pub fn appendable(format: FileFormat) -> bool {
137 matches!(
138 format,
139 FileFormat::JsonLines | FileFormat::Csv | FileFormat::RawText
140 )
141}
142
143pub fn is_directory_path(path: &str) -> bool {
145 path.ends_with('/') || path.ends_with('\\')
146}
147
148pub fn require_path(connector: &str, path: &str) -> Result<(), FaucetError> {
150 if path.trim().is_empty() {
151 return Err(FaucetError::Config(format!("{connector}: `path` is empty")));
152 }
153 Ok(())
154}
155
156pub fn config_context(connector: &str, e: FaucetError) -> FaucetError {
159 match e {
160 FaucetError::Config(m) => FaucetError::Config(format!("{connector}: {m}")),
161 other => other,
162 }
163}
164
165pub fn is_http_path(path: &str) -> bool {
167 let p = path.trim_start().to_ascii_lowercase();
168 p.starts_with("http://") || p.starts_with("https://")
169}
170
171pub fn url_file_name(url: &str) -> &str {
174 let no_query = url.split(['?', '#']).next().unwrap_or(url);
175 no_query.rsplit('/').next().unwrap_or(no_query)
176}
177
178pub fn resolution_name(path: &str) -> &str {
181 if is_http_path(path) {
182 url_file_name(path)
183 } else {
184 path
185 }
186}
187
188#[derive(Debug, Clone, Default)]
195pub struct ObjectFilter {
196 include: Option<glob::Pattern>,
197}
198
199impl ObjectFilter {
200 pub fn new(include: Option<&str>) -> Result<Self, FaucetError> {
202 let include = include
203 .map(|g| {
204 glob::Pattern::new(g)
205 .map_err(|e| FaucetError::Config(format!("`include` glob {g:?}: {e}")))
206 })
207 .transpose()?;
208 Ok(Self { include })
209 }
210
211 pub fn keep(&self, key: &str, size: Option<u64>, prefix: &str) -> bool {
214 if (key.ends_with('/') || key.ends_with("_$folder$")) && size.unwrap_or(0) == 0 {
215 return false;
216 }
217 match &self.include {
218 Some(p) => p.matches_with(
219 key,
220 glob::MatchOptions {
221 case_sensitive: true,
222 require_literal_separator: false,
223 require_literal_leading_dot: false,
224 },
225 ),
226 None => !is_hidden_object(key, prefix),
227 }
228 }
229}
230
231pub fn is_hidden_object(key: &str, prefix: &str) -> bool {
235 let hidden = |s: &str| s.starts_with('_') || s.starts_with('.');
236 let Some(rest) = key.strip_prefix(prefix) else {
237 return key.split('/').any(hidden);
238 };
239 let mut segments = rest.split('/');
240 if !prefix.is_empty() && !prefix.ends_with('/') {
241 segments.next();
243 }
244 segments.any(hidden)
245}
246
247#[cfg(test)]
248mod tests {
249 use super::*;
250
251 #[test]
252 fn markers_and_hidden_objects_are_skipped_unless_included() {
253 let f = ObjectFilter::default();
254 assert!(f.keep("out/part-0001.parquet", Some(10), "out/"));
255 assert!(!f.keep("out/_SUCCESS", Some(0), "out/"));
256 assert!(!f.keep("out/day=1_$folder$", Some(0), "out/"));
257 assert!(f.keep("out/day=1_$folder$", Some(3), "out/"));
258 assert!(!f.keep("out/.part-0001.parquet.crc", Some(8), "out/"));
259 assert!(!f.keep("out/_temporary/0/part-1.json", Some(8), "out/"));
260 assert!(!f.keep("out/sub/", Some(0), "out/"));
261 assert!(!f.keep("out/sub/", None, "out/"));
262 assert!(
263 f.keep("_raw/data.json", Some(4), "_raw/"),
264 "the prefix itself is not judged"
265 );
266 assert!(f.keep("_raw/part-1.json", Some(4), "_raw/part"));
267 assert!(!f.keep("data/_x.json", Some(4), ""));
268 assert!(f.keep("exports/_daily-2026-10-01.jsonl", Some(4), "exports/_daily"));
269 assert!(f.keep("exports/_daily/part-0.jsonl", Some(4), "exports/_daily"));
270 assert!(!f.keep("exports/_daily/_tmp/x.jsonl", Some(4), "exports/_daily"));
271 assert!(!f.keep("out/pa_x/_SUCCESS", Some(4), "out/pa"));
272 assert!(!f.keep("elsewhere/_x.json", Some(4), "out/"));
273 let inc = ObjectFilter::new(Some("out/_*")).unwrap();
274 assert!(inc.keep("out/_metadata", Some(4), "out/"));
275 assert!(!inc.keep("out/part-1.parquet", Some(4), "out/"));
276 assert!(ObjectFilter::new(Some("[")).is_err());
277 }
278
279 #[test]
280 fn every_choice_maps_onto_the_shared_vocabulary() {
281 let all = [
282 (FileFormatChoice::JsonLines, FileFormat::JsonLines),
283 (FileFormatChoice::JsonArray, FileFormat::JsonArray),
284 (FileFormatChoice::Csv, FileFormat::Csv),
285 (FileFormatChoice::Xml, FileFormat::Xml),
286 (FileFormatChoice::Xlsx, FileFormat::Xlsx),
287 (FileFormatChoice::Parquet, FileFormat::Parquet),
288 (FileFormatChoice::RawText, FileFormat::RawText),
289 (FileFormatChoice::Avro, FileFormat::Avro),
290 (FileFormatChoice::Orc, FileFormat::Orc),
291 ];
292 for (c, f) in all {
293 assert_eq!(c.explicit(), Some(f));
294 assert_eq!(c.resolve("x.unknown", true).unwrap(), Some(f));
295 }
296 assert_eq!(FileFormatChoice::default().explicit(), None);
297 }
298
299 #[test]
300 fn auto_resolves_by_extension_and_strict_refuses_unknowns() {
301 let auto = FileFormatChoice::Auto;
302 assert_eq!(
303 auto.resolve("a/b.csv.gz", false).unwrap(),
304 Some(FileFormat::Csv)
305 );
306 assert_eq!(auto.resolve("notes.md", false).unwrap(), None);
307 let err = auto.resolve("notes.md", true).unwrap_err();
308 assert!(err.to_string().contains("notes.md"), "{err}");
309 }
310
311 #[test]
312 fn urls_and_paths() {
313 assert!(is_http_path("HTTPS://h/x") && is_http_path("http://h"));
314 assert!(!is_http_path("/tmp/x") && !is_http_path("ftp://h"));
315 assert_eq!(url_file_name("https://h/a/b.csv.gz?sig=1#x"), "b.csv.gz");
316 assert_eq!(url_file_name("https://h/"), "");
317 assert_eq!(resolution_name("https://h/a.avro?x=1"), "a.avro");
318 assert_eq!(resolution_name("dir/a.avro"), "dir/a.avro");
319 }
320
321 #[test]
322 fn writable_resolution_refuses_unknown_extensions_and_orc() {
323 let auto = FileFormatChoice::Auto;
324 assert_eq!(auto.resolve_writable("a.csv.gz").unwrap(), FileFormat::Csv);
325 assert_eq!(
326 auto.resolve_writable("a.parquet").unwrap(),
327 FileFormat::Parquet
328 );
329 let e = auto.resolve_writable("a.dat").unwrap_err().to_string();
330 assert!(e.contains("a.dat") && e.contains(".jsonl"), "{e}");
331 let e = auto.resolve_writable("a.orc").unwrap_err().to_string();
332 assert!(e.contains("read-only"), "{e}");
333 let e = FileFormatChoice::Orc
334 .resolve_writable("x")
335 .unwrap_err()
336 .to_string();
337 assert!(e.contains("ORC"), "{e}");
338 assert_eq!(
339 FileFormatChoice::Xml.resolve_writable("a.dat").unwrap(),
340 FileFormat::Xml
341 );
342 }
343
344 #[test]
345 fn compression_and_format_properties() {
346 assert_eq!(
347 resolve_compression(CompressionConfig::Auto, "a.jsonl.gz"),
348 Compression::Gzip
349 );
350 assert_eq!(
351 resolve_compression(CompressionConfig::Auto, "https://h/a.csv.zst?x=1"),
352 Compression::Zstd
353 );
354 assert_eq!(
355 resolve_compression(CompressionConfig::Auto, "a.csv"),
356 Compression::None
357 );
358 assert_eq!(
359 resolve_compression(CompressionConfig::Gzip, "a.csv"),
360 Compression::Gzip
361 );
362 for f in [FileFormat::Parquet, FileFormat::Avro, FileFormat::Xlsx] {
363 assert!(compresses_internally(f) && !appendable(f), "{f:?}");
364 }
365 for f in [FileFormat::JsonLines, FileFormat::Csv, FileFormat::RawText] {
366 assert!(appendable(f) && !compresses_internally(f), "{f:?}");
367 }
368 assert!(!appendable(FileFormat::JsonArray) && !compresses_internally(FileFormat::Xml));
369 }
370
371 #[test]
372 fn paths_directories_and_emptiness() {
373 assert!(is_directory_path("out/") && is_directory_path("out\\"));
374 assert!(!is_directory_path("out/a.jsonl"));
375 assert!(require_path("file sink", "a").is_ok());
376 let e = require_path("file sink", " ").unwrap_err().to_string();
377 assert!(e.contains("file sink") && e.contains("empty"), "{e}");
378 }
379
380 #[test]
381 fn serde_names_are_snake_case() {
382 let c: FileFormatChoice = serde_json::from_str("\"json_lines\"").unwrap();
383 assert_eq!(c, FileFormatChoice::JsonLines);
384 assert_eq!(
385 serde_json::to_string(&FileFormatChoice::Auto).unwrap(),
386 "\"auto\""
387 );
388 }
389
390 #[test]
391 fn config_context_prefixes_only_config_errors() {
392 assert_eq!(
393 config_context("S3 sink", FaucetError::Config("bad".into())).to_string(),
394 FaucetError::Config("S3 sink: bad".into()).to_string()
395 );
396 assert!(matches!(
397 config_context("S3 sink", FaucetError::Sink("io".into())),
398 FaucetError::Sink(m) if m == "io"
399 ));
400 }
401}