1use crate::config::{FileSourceConfig, IncrementalBy};
4use crate::http::HttpFetcher;
5use crate::plan::{self, Bookmark, Candidate};
6use async_trait::async_trait;
7use faucet_common_file::{resolution_name, url_file_name};
8use faucet_core::compression::Compression;
9use faucet_core::observability::RecorderSlot;
10use faucet_core::shard::{HashShard, ShardSpec, parse_hash_shard, plan_hash_shards};
11use faucet_core::{FaucetError, FileFormat, FileInput, FormatOptions, Stream, StreamPage};
12use futures::stream::StreamExt;
13use serde_json::{Value, json};
14use std::collections::HashMap;
15use std::pin::Pin;
16use std::sync::{Arc, Mutex};
17
18type Reader = Pin<Box<dyn tokio::io::AsyncBufRead + Send + Unpin>>;
19
20enum Opened {
25 Lines(Reader),
26 #[cfg(feature = "file-format-csv")]
27 Csv(Reader),
28 Bytes(Vec<u8>),
29 Local(std::fs::File),
30 Skip,
31}
32
33pub(crate) struct Decoders {
37 opts: FormatOptions,
38 max_decoded_bytes: u64,
39 containers: HashMap<&'static str, faucet_core::ContainerDecoder>,
40 #[cfg(feature = "file-format-parquet")]
41 parquet: Option<(String, arrow::datatypes::SchemaRef)>,
42 #[cfg_attr(not(feature = "file-format-parquet"), allow(dead_code))]
43 parquet_columns: Option<Vec<String>>,
44}
45
46impl Decoders {
47 fn new(
48 opts: FormatOptions,
49 max_decoded_bytes: u64,
50 parquet_columns: Option<Vec<String>>,
51 ) -> Arc<Mutex<Self>> {
52 Arc::new(Mutex::new(Self {
53 opts,
54 max_decoded_bytes,
55 parquet_columns,
56 containers: HashMap::new(),
57 #[cfg(feature = "file-format-parquet")]
58 parquet: None,
59 }))
60 }
61
62 fn container(
63 &mut self,
64 format: FileFormat,
65 ) -> Result<&mut faucet_core::ContainerDecoder, FaucetError> {
66 let key = format.as_str();
67 if !self.containers.contains_key(key) {
68 let d = faucet_core::ContainerDecoder::new(format, &self.opts)?
69 .with_max_decoded_bytes(self.max_decoded_bytes);
70 self.containers.insert(key, d);
71 }
72 Ok(self.containers.get_mut(key).expect("inserted above"))
73 }
74}
75
76pub struct FileSource {
78 config: FileSourceConfig,
79 http: Option<HttpFetcher>,
80 start: Mutex<Option<Bookmark>>,
81 shard: Mutex<Option<HashShard>>,
82 roundtrips: RecorderSlot,
83 calls: faucet_core::CallSlot,
85 #[cfg(feature = "encryption")]
86 encryption: Option<faucet_core::CompiledEncryption>,
87}
88
89impl FileSource {
90 pub fn new(config: FileSourceConfig) -> Result<Self, FaucetError> {
93 config.validate()?;
94 let http = if config.is_http() {
95 Some(HttpFetcher::with_timeouts(
96 &config.headers,
97 config.http_retries,
98 std::time::Duration::from_secs(config.http_connect_timeout_secs),
99 std::time::Duration::from_secs(config.http_read_timeout_secs),
100 )?)
101 } else {
102 None
103 };
104 Ok(Self {
105 http,
106 start: Mutex::new(None),
107 shard: Mutex::new(None),
108 roundtrips: RecorderSlot::new(),
109 calls: faucet_core::CallSlot::new(faucet_core::CallContext::new().without_timeout()),
110 #[cfg(feature = "encryption")]
111 encryption: config
112 .encryption
113 .as_ref()
114 .map(faucet_core::CompiledEncryption::compile)
115 .transpose()?,
116 config,
117 })
118 }
119
120 fn by(&self) -> Option<IncrementalBy> {
121 self.config.incremental.map(|i| i.by)
122 }
123
124 fn format_of(&self, path: &str) -> Result<Option<FileFormat>, FaucetError> {
127 let found = self
128 .config
129 .format
130 .resolve(resolution_name(path), self.config.strict)
131 .map_err(|e| FaucetError::Source(format!("file source: {e}")))?;
132 if found.is_none() {
133 tracing::warn!(path = %path, "file source: skipping a file with no recognised extension");
134 }
135 Ok(found)
136 }
137
138 fn codec_of(&self, path: &str) -> Compression {
139 let codec = faucet_common_file::resolve_compression(self.config.compression, path);
140 faucet_core::compression::warn_mismatch(resolution_name(path), codec);
141 codec
142 }
143
144 async fn candidates(&self, apply_bookmark: bool) -> Result<Vec<Candidate>, FaucetError> {
146 self.candidates_at(&self.config.path, apply_bookmark).await
147 }
148
149 async fn candidates_at(
150 &self,
151 root: &str,
152 apply_bookmark: bool,
153 ) -> Result<Vec<Candidate>, FaucetError> {
154 let mut files = match &self.http {
155 Some(http) => {
156 let needs_mtime = matches!(self.by(), Some(IncrementalBy::Mtime))
157 || self.config.stable_for_secs.is_some();
158 let mtime_ns = if needs_mtime {
159 let meta = http
160 .head_in(root, &self.roundtrips, &self.calls.get())
161 .await?;
162 Some(meta.last_modified_ns.ok_or_else(|| {
163 FaucetError::Source(format!(
164 "file source: {} sends no Last-Modified header, which `incremental: \
165 {{by: mtime}}` / `stable_for_secs` need",
166 root
167 ))
168 })?)
169 } else {
170 None
171 };
172 vec![Candidate {
173 path: root.to_string(),
174 mtime_ns,
175 ctime_ns: None,
176 }]
177 }
178 None => {
179 let path = root.to_string();
180 let recursive = self.config.recursive;
181 tokio::task::spawn_blocking(move || {
182 let files = plan::list_local(&path, recursive)?;
183 for f in &files {
184 faucet_core::path_guard::check(std::path::Path::new(&f.path))?;
185 }
186 Ok::<_, FaucetError>(files)
187 })
188 .await
189 .map_err(|e| {
190 FaucetError::Source(format!("file source: listing task failed: {e}"))
191 })??
192 }
193 };
194 if let Some(member) = *self.shard.lock().expect("shard mutex") {
195 files.retain(|f| member.contains(&f.path));
196 }
197 let bookmark = if apply_bookmark {
198 self.start.lock().expect("bookmark mutex").clone()
199 } else {
200 None
201 };
202 let mut files = plan::select(
203 files,
204 if apply_bookmark { self.by() } else { None },
205 bookmark.as_ref(),
206 self.config.stable_for_secs,
207 now_ns(),
208 )?;
209 if let Some(max) = self.config.max_files {
210 files.truncate(max);
211 }
212 Ok(files)
213 }
214
215 async fn open_reader(&self, path: &str) -> Result<Reader, FaucetError> {
216 let raw = self.open_raw(path).await?;
217 Ok(faucet_core::compression::wrap_async_reader(
218 raw,
219 self.codec_of(path),
220 ))
221 }
222
223 async fn open_raw(&self, path: &str) -> Result<Reader, FaucetError> {
224 Ok(match &self.http {
225 Some(http) => {
226 let resp = http
227 .get_in(path, &self.roundtrips, &self.calls.get())
228 .await?;
229 let body = resp
230 .bytes_stream()
231 .map(|r| r.map_err(std::io::Error::other));
232 Box::pin(tokio_util::io::StreamReader::new(body))
233 }
234 None => Box::pin(tokio::io::BufReader::new(tokio::fs::File::from_std(
235 open_local(path)?,
236 ))),
237 })
238 }
239
240 async fn open(&self, path: &str) -> Result<Opened, FaucetError> {
241 let Some(format) = self.format_of(path)? else {
242 return Ok(Opened::Skip);
243 };
244 #[cfg(feature = "encryption")]
245 if let Some(enc) = &self.encryption {
246 let plain = self.read_decrypted(path, format, enc).await?;
247 return Ok(match format {
248 FileFormat::JsonLines => Opened::Lines(Box::pin(std::io::Cursor::new(plain))),
249 #[cfg(feature = "file-format-csv")]
250 FileFormat::Csv => Opened::Csv(Box::pin(std::io::Cursor::new(plain))),
251 _ => Opened::Bytes(plain),
252 });
253 }
254 if format == FileFormat::JsonLines {
255 return Ok(Opened::Lines(self.open_reader(path).await?));
256 }
257 #[cfg(feature = "file-format-csv")]
258 if format == FileFormat::Csv {
259 return Ok(Opened::Csv(self.open_reader(path).await?));
260 }
261 let binary = format.is_container() || format == FileFormat::Parquet;
262 if binary && self.http.is_none() && self.codec_of(path) == Compression::None {
263 return Ok(Opened::Local(open_local(path)?));
264 }
265 let reader = self.open_reader(path).await?;
266 let bytes = faucet_core::file_format::read_to_end_capped(
267 reader,
268 self.config.max_object_bytes,
269 path,
270 )
271 .await?;
272 Ok(Opened::Bytes(bytes))
273 }
274
275 #[cfg(feature = "encryption")]
276 async fn read_decrypted(
277 &self,
278 path: &str,
279 format: FileFormat,
280 enc: &faucet_core::CompiledEncryption,
281 ) -> Result<Vec<u8>, FaucetError> {
282 let max = self.config.max_object_bytes;
283 let raw =
284 faucet_core::file_format::read_to_end_capped(self.open_raw(path).await?, max, path)
285 .await?;
286 let codec = self.codec_of(path);
287 let fail = |e: FaucetError| FaucetError::Source(format!("file source: '{path}': {e}"));
288 if faucet_core::encryption::is_encrypted(&raw) {
289 let sealed = enc.decrypt(&raw).map_err(fail)?;
290 let mut plain = Vec::new();
291 std::io::Read::read_to_end(
292 &mut std::io::Read::take(
293 faucet_core::compression::wrap_sync_reader(std::io::Cursor::new(sealed), codec),
294 max.saturating_add(1),
295 ),
296 &mut plain,
297 )
298 .map_err(|e| read_err(path, e))?;
299 faucet_core::file_format::check_object_size(plain.len() as u64, max, path)?;
300 return Ok(plain);
301 }
302 if matches!(format, FileFormat::JsonLines | FileFormat::RawText)
303 && codec == Compression::None
304 {
305 return crate::decrypt::lines(&raw, enc).map_err(fail);
306 }
307 Err(FaucetError::Source(format!(
308 "file source: '{path}' is not encrypted, but `encryption` is set — refusing to read \
309 plaintext as if it were authenticated"
310 )))
311 }
312
313 fn root(&self, context: &HashMap<String, Value>) -> String {
314 if context.is_empty() {
315 self.config.path.clone()
316 } else {
317 faucet_core::util::substitute_context(&self.config.path, context)
318 }
319 }
320
321 async fn check_parquet_schemas(&self, files: &[Candidate]) -> Result<(), FaucetError> {
322 #[cfg(feature = "file-format-parquet")]
323 {
324 if self.http.is_some() || self.config.encryption_set() {
325 return Ok(());
326 }
327 let mut local = Vec::new();
328 for f in files {
329 if self.format_of(&f.path)? == Some(FileFormat::Parquet)
330 && self.codec_of(&f.path) == Compression::None
331 {
332 local.push((f.path.clone(), open_local(&f.path)?));
333 }
334 }
335 if local.len() < 2 {
336 return Ok(());
337 }
338 let columns = self.config.parquet.columns.clone();
339 tokio::task::spawn_blocking(move || {
340 crate::parquet::check_schemas(local, columns.as_deref())
341 })
342 .await
343 .map_err(|e| FaucetError::Source(format!("file source: schema check failed: {e}")))??;
344 }
345 #[cfg(not(feature = "file-format-parquet"))]
346 let _ = files;
347 Ok(())
348 }
349
350 fn prefetch<'a>(
351 &'a self,
352 files: &'a [Candidate],
353 ) -> impl futures::Stream<Item = (&'a Candidate, Result<Opened, FaucetError>)> + 'a {
354 futures::stream::iter(files.iter())
355 .map(move |c| async move { (c, self.open(&c.path).await) })
356 .buffered(self.config.concurrency.max(1))
357 }
358}
359
360fn now_ns() -> i64 {
361 chrono::Utc::now().timestamp_nanos_opt().unwrap_or(i64::MAX)
362}
363
364fn open_local(path: &str) -> Result<std::fs::File, FaucetError> {
367 match faucet_core::http::current_reference_policy() {
368 Some(policy) if policy.path_roots().is_some() => {
369 faucet_core::path_guard::open_within(std::path::Path::new(path), Some(&policy))
370 }
371 _ => std::fs::File::open(path).map_err(|e| read_err(path, e)),
372 }
373}
374
375fn read_err(path: &str, e: impl std::fmt::Display) -> FaucetError {
376 FaucetError::Source(format!("file source: read '{path}': {e}"))
377}
378
379async fn decode_whole(
381 path: &str,
382 format: FileFormat,
383 bytes: Vec<u8>,
384 opts: &FormatOptions,
385 max_decoded_bytes: u64,
386) -> Result<Vec<Value>, FaucetError> {
387 if format == FileFormat::RawText {
388 let content = String::from_utf8(bytes)
389 .map_err(|e| FaucetError::Source(format!("file source: '{path}' is not UTF-8: {e}")))?;
390 return Ok(vec![json!({"path": path, "content": content})]);
391 }
392 faucet_core::file_format::decode_owned_bounded(bytes, format, opts, max_decoded_bytes)
393 .await
394 .map_err(|e| FaucetError::Source(format!("file source: '{path}': {e}")))
395}
396
397fn spawn_blocking_decode<T: Send + 'static>(
401 work: impl FnOnce(&mut dyn FnMut(T) -> Result<(), FaucetError>) -> Result<(), FaucetError>
402 + Send
403 + 'static,
404) -> (
405 tokio::sync::mpsc::Receiver<T>,
406 tokio::task::JoinHandle<Result<(), FaucetError>>,
407) {
408 let (tx, rx) = tokio::sync::mpsc::channel(2);
409 let handle = tokio::task::spawn_blocking(move || {
410 work(&mut |item| {
411 tx.blocking_send(item)
412 .map_err(|_| FaucetError::Source("file source: decode consumer went away".into()))
413 })
414 });
415 (rx, handle)
416}
417
418async fn join(handle: tokio::task::JoinHandle<Result<(), FaucetError>>) -> Result<(), FaucetError> {
419 handle
420 .await
421 .map_err(|e| FaucetError::Source(format!("file source: decode task failed: {e}")))?
422}
423
424fn input_of(opened: Opened) -> FileInput {
425 match opened {
426 Opened::Local(f) => FileInput::File(f),
427 Opened::Bytes(b) => FileInput::Bytes(b),
428 _ => unreachable!("only binary formats are decoded here"),
429 }
430}
431
432fn records_task(
434 decoders: Arc<Mutex<Decoders>>,
435 path: String,
436 format: FileFormat,
437 input: FileInput,
438 chunk: usize,
439) -> (
440 tokio::sync::mpsc::Receiver<Vec<Value>>,
441 tokio::task::JoinHandle<Result<(), FaucetError>>,
442) {
443 spawn_blocking_decode(move |send| {
444 let mut d = decoders.lock().expect("decoder mutex");
445 match format {
446 #[cfg(feature = "file-format-parquet")]
447 FileFormat::Parquet => crate::parquet::read(&mut d, &path, input, chunk, &mut |b| {
448 send(faucet_core::columnar::record_batch_to_values(&b)?)
449 }),
450 #[cfg(not(feature = "file-format-parquet"))]
451 FileFormat::Parquet => Err(crate::parquet_missing()),
452 _ => d.container(format)?.records(&path, input, chunk, send),
453 }
454 })
455}
456
457#[cfg(feature = "arrow")]
459fn batches_task(
460 decoders: Arc<Mutex<Decoders>>,
461 path: String,
462 format: FileFormat,
463 input: FileInput,
464 batch_size: usize,
465) -> (
466 tokio::sync::mpsc::Receiver<arrow::array::RecordBatch>,
467 tokio::task::JoinHandle<Result<(), FaucetError>>,
468) {
469 spawn_blocking_decode(move |send| {
470 let mut d = decoders.lock().expect("decoder mutex");
471 match format {
472 #[cfg(feature = "file-format-parquet")]
473 FileFormat::Parquet => crate::parquet::read(&mut d, &path, input, batch_size, send),
474 #[cfg(not(feature = "file-format-parquet"))]
475 FileFormat::Parquet => Err(crate::parquet_missing()),
476 _ => d
477 .container(format)?
478 .batches(&path, input, batch_size, send)
479 .map(|_| ()),
480 }
481 })
482}
483
484#[cfg(feature = "file-format-parquet")]
485impl Decoders {
486 pub(crate) fn parquet_reference(
487 &mut self,
488 ) -> &mut Option<(String, arrow::datatypes::SchemaRef)> {
489 &mut self.parquet
490 }
491
492 pub(crate) fn parquet_columns(&self) -> Option<&[String]> {
493 self.parquet_columns.as_deref()
494 }
495}
496
497#[async_trait]
498impl faucet_core::Source for FileSource {
499 async fn fetch_with_context(
500 &self,
501 context: &HashMap<String, Value>,
502 ) -> Result<Vec<Value>, FaucetError> {
503 let mut out = Vec::new();
504 let mut pages = self.stream_pages(context, self.config.batch_size);
505 while let Some(page) = pages.next().await {
506 out.extend(page?.records);
507 }
508 Ok(out)
509 }
510
511 fn stream_pages<'a>(
516 &'a self,
517 context: &'a HashMap<String, Value>,
518 _batch_size: usize,
519 ) -> Pin<Box<dyn Stream<Item = Result<StreamPage, FaucetError>> + Send + 'a>> {
520 let batch_size = self.config.batch_size;
521 Box::pin(async_stream::try_stream! {
522 let root = self.root(context);
523 let files = self.candidates_at(&root, true).await?;
524 tracing::info!(path = %root, files = files.len(), "file source listed files");
525 self.check_parquet_schemas(&files).await?;
526 let by = self.by();
527 let mut bookmark = self.start.lock().expect("bookmark mutex").clone();
528 let decoders = Decoders::new(self.config.format_options(), self.config.decoded_bytes_limit(), self.config.parquet.columns.clone());
529 let opts = self.config.format_options();
530 let chunk = if batch_size == 0 { usize::MAX } else { batch_size };
531 let mut buffer: Vec<Value> = Vec::new();
532 let mut opened_files = self.prefetch(&files);
533
534 while let Some((file, opened)) = opened_files.next().await {
535 let path = file.path.as_str();
536 let opened = opened?;
537 let format = match &opened {
538 Opened::Skip => None,
539 _ => self.format_of(path)?,
540 };
541 if let Some(format) = format {
542 match opened {
543 Opened::Lines(reader) => {
544 let mut lines = faucet_core::file_format::capped_lines(reader, self.config.max_object_bytes);
548 let mut n = 0usize;
549 while let Some(line) = lines.next_line().await.map_err(|e| read_err(path, e))? {
550 n += 1;
551 let line = line.trim();
552 if line.is_empty() {
553 continue;
554 }
555 buffer.push(faucet_core::fidelity::from_str(line).map_err(|e| {
556 FaucetError::Source(format!("file source: '{path}' line {n}: {e}"))
557 })?);
558 if buffer.len() >= chunk {
559 yield StreamPage { records: std::mem::take(&mut buffer), bookmark: None };
560 }
561 }
562 }
563 #[cfg(feature = "file-format-csv")]
564 Opened::Csv(reader) => {
565 let reader = faucet_core::file_format::line_capped(reader, self.config.max_object_bytes);
566 let mut rows = faucet_core::file_format::csv::CsvRowReader::new(reader, &opts.csv, false)?;
567 while let Some(r) = rows.next_record().await.map_err(|e| {
568 FaucetError::Source(format!("file source: '{path}': {e}"))
569 })? {
570 buffer.push(r);
571 if buffer.len() >= chunk {
572 yield StreamPage { records: std::mem::take(&mut buffer), bookmark: None };
573 }
574 }
575 }
576 other if format.is_container() || format == FileFormat::Parquet => {
577 let (mut rx, handle) = records_task(decoders.clone(), path.to_string(), format, input_of(other), chunk);
578 while let Some(rows) = rx.recv().await {
579 for r in rows {
580 buffer.push(r);
581 if buffer.len() >= chunk {
582 yield StreamPage { records: std::mem::take(&mut buffer), bookmark: None };
583 }
584 }
585 }
586 join(handle).await?;
587 }
588 Opened::Bytes(bytes) => {
589 for r in decode_whole(path, format, bytes, &opts, self.config.decoded_bytes_limit()).await? {
590 buffer.push(r);
591 if buffer.len() >= chunk {
592 yield StreamPage { records: std::mem::take(&mut buffer), bookmark: None };
593 }
594 }
595 }
596 Opened::Local(_) | Opened::Skip => unreachable!("binary and skipped files handled above"),
597 }
598 if batch_size == 0 && !buffer.is_empty() && by.is_none() {
599 yield StreamPage { records: std::mem::take(&mut buffer), bookmark: None };
600 }
601 }
602 if let Some(by) = by {
603 bookmark = Some(plan::advance(bookmark, by, file)?);
604 let mark = bookmark.as_ref().map(Bookmark::to_value);
605 yield StreamPage { records: std::mem::take(&mut buffer), bookmark: mark };
606 }
607 }
608 if !buffer.is_empty() {
609 yield StreamPage { records: buffer, bookmark: None };
610 }
611 })
612 }
613
614 #[cfg(feature = "arrow")]
617 fn supports_columnar(&self) -> bool {
618 self.config
619 .format
620 .explicit()
621 .is_some_and(|f| f.is_container() || f == FileFormat::Parquet)
622 }
623
624 #[cfg(feature = "arrow")]
625 fn stream_batches<'a>(
626 &'a self,
627 context: &'a HashMap<String, Value>,
628 _batch_size: usize,
629 ) -> Pin<
630 Box<
631 dyn Stream<Item = Result<faucet_core::columnar::ColumnarPage, FaucetError>> + Send + 'a,
632 >,
633 > {
634 Box::pin(async_stream::try_stream! {
635 if !self.supports_columnar() {
636 Err(FaucetError::Source(
637 "file source: stream_batches needs format avro, orc or parquet".into(),
638 ))?;
639 }
640 let format = self.config.format.explicit().expect("checked by supports_columnar");
641 let root = self.root(context);
642 let files = self.candidates_at(&root, true).await?;
643 self.check_parquet_schemas(&files).await?;
644 let by = self.by();
645 let mut bookmark = self.start.lock().expect("bookmark mutex").clone();
646 let decoders = Decoders::new(self.config.format_options(), self.config.decoded_bytes_limit(), self.config.parquet.columns.clone());
647 let mut opened_files = self.prefetch(&files);
648 while let Some((file, opened)) = opened_files.next().await {
649 let (mut rx, handle) = batches_task(decoders.clone(), file.path.clone(), format, input_of(opened?), self.config.batch_size);
650 let mut last: Option<arrow::array::RecordBatch> = None;
651 while let Some(batch) = rx.recv().await {
652 if let Some(prev) = last.replace(batch) {
653 yield faucet_core::columnar::ColumnarPage::new(prev, None);
654 }
655 }
656 join(handle).await?;
657 let mark = match by {
658 Some(by) => {
659 bookmark = Some(plan::advance(bookmark, by, file)?);
660 bookmark.as_ref().map(Bookmark::to_value)
661 }
662 None => None,
663 };
664 match last {
665 Some(batch) => yield faucet_core::columnar::ColumnarPage::new(batch, mark),
666 None if mark.is_some() => {
667 let schema = std::sync::Arc::new(arrow::datatypes::Schema::empty());
668 yield faucet_core::columnar::ColumnarPage::new(arrow::array::RecordBatch::new_empty(schema), mark);
669 }
670 None => {}
671 }
672 }
673 })
674 }
675
676 fn config_schema(&self) -> Value {
677 serde_json::to_value(faucet_core::schema_for!(FileSourceConfig))
678 .expect("schema serialization")
679 }
680
681 fn connector_name(&self) -> &'static str {
682 "file"
683 }
684
685 fn set_roundtrip_recorder(&self, recorder: Arc<faucet_core::observability::RoundtripRecorder>) {
686 self.roundtrips.install(recorder);
687 }
688
689 fn set_call_context(&self, ctx: faucet_core::CallContext) {
690 self.calls.install(ctx);
691 }
692
693 fn dataset_uri(&self) -> String {
694 if self.http.is_some() {
695 return faucet_core::util::redact_uri_credentials(&self.config.path);
696 }
697 let p = std::path::Path::new(&self.config.path);
698 let abs = if p.is_absolute() {
699 p.to_path_buf()
700 } else {
701 std::env::current_dir()
702 .map(|d| d.join(p))
703 .unwrap_or_else(|_| p.to_path_buf())
704 };
705 format!("file://{}", abs.display())
706 }
707
708 fn state_key(&self) -> Option<String> {
711 self.config.incremental.map(|_| {
712 format!(
713 "file:{:016x}",
714 faucet_core::shard::shard_hash(&self.config.path)
715 )
716 })
717 }
718
719 async fn apply_start_bookmark(&self, bookmark: Value) -> Result<(), FaucetError> {
720 let Some(by) = self.by() else {
721 return Ok(());
722 };
723 if bookmark.is_null() {
724 return Ok(());
725 }
726 *self.start.lock().expect("bookmark mutex") = Some(Bookmark::from_value(&bookmark, by)?);
727 Ok(())
728 }
729
730 fn is_shardable(&self) -> bool {
731 self.http.is_none()
732 }
733
734 async fn enumerate_shards(&self, target: usize) -> Result<Vec<ShardSpec>, FaucetError> {
735 Ok(if self.http.is_some() {
736 plan_hash_shards(1)
737 } else {
738 plan_hash_shards(target)
739 })
740 }
741
742 async fn apply_shard(&self, shard: &ShardSpec) -> Result<(), FaucetError> {
743 *self.shard.lock().expect("shard mutex") = parse_hash_shard(shard, "file")?;
744 Ok(())
745 }
746
747 fn supports_discover(&self) -> bool {
748 true
749 }
750
751 async fn discover(&self) -> Result<Vec<faucet_core::DatasetDescriptor>, FaucetError> {
753 let files = self.candidates(false).await?;
754 let mut out = Vec::new();
755 for f in files {
756 let Some(format) = self.format_of(&f.path)? else {
757 continue;
758 };
759 let name = if self.http.is_some() {
760 url_file_name(&f.path).to_string()
761 } else {
762 std::path::Path::new(&f.path)
763 .strip_prefix(&self.config.path)
764 .ok()
765 .map(|p| p.to_string_lossy().into_owned())
766 .filter(|s| !s.is_empty())
767 .unwrap_or_else(|| f.path.clone())
768 };
769 out.push(faucet_core::DatasetDescriptor::new(
770 name,
771 "file",
772 json!({"path": f.path, "format": format.as_str()}),
773 ));
774 }
775 Ok(out)
776 }
777
778 async fn check(
781 &self,
782 ctx: &faucet_core::check::CheckContext,
783 ) -> Result<faucet_core::check::CheckReport, FaucetError> {
784 use faucet_core::check::{CheckReport, Probe};
785 let started = std::time::Instant::now();
786 if let Some(http) = &self.http {
787 let probe = match tokio::time::timeout(
788 ctx.timeout,
789 http.head_in(&self.config.path, &self.roundtrips, &self.calls.get()),
790 )
791 .await
792 {
793 Err(_) => Probe::fail("head", started.elapsed(), "timed out"),
794 Ok(Ok(_)) => Probe::pass("head", started.elapsed()),
795 Ok(Err(e)) => Probe::fail("head", started.elapsed(), e.to_string()),
796 };
797 return Ok(CheckReport::single(probe));
798 }
799 let probe = match tokio::time::timeout(ctx.timeout, self.candidates(false)).await {
800 Err(_) => Probe::fail("list", started.elapsed(), "timed out"),
801 Ok(Ok(files)) if files.is_empty() => Probe::fail_hint(
802 "list",
803 started.elapsed(),
804 format!("no files match {}", self.config.path),
805 "check `path` (and `recursive` for nested directories)",
806 ),
807 Ok(Ok(_)) => Probe::pass("list", started.elapsed()),
808 Ok(Err(e)) => Probe::fail("list", started.elapsed(), e.to_string()),
809 };
810 Ok(CheckReport::single(probe))
811 }
812}