Skip to main content

faucet_source_s3/
stream.rs

1//! S3 source stream executor.
2
3use crate::config::{S3FileFormat, S3SourceConfig};
4use async_trait::async_trait;
5use aws_sdk_s3::Client;
6use faucet_core::shard::{HashShard, ShardSpec, parse_hash_shard, plan_hash_shards};
7use faucet_core::{FaucetError, Stream, StreamPage};
8use futures::stream::{self, StreamExt, TryStreamExt};
9use serde_json::Value;
10use std::pin::Pin;
11use std::sync::Mutex;
12use tokio::io::AsyncBufReadExt;
13
14/// An S3 source that lists and reads objects from a bucket.
15pub struct S3Source {
16    config: S3SourceConfig,
17    client: Client,
18    /// Shard applied by the cluster coordinator (Mode B). `None` (or a
19    /// degenerate single-shard set) reads every listed object. Stored behind a
20    /// `Mutex` so `apply_shard(&self, …)` can record it before streaming.
21    applied_shard: Mutex<Option<HashShard>>,
22}
23
24impl S3Source {
25    /// Create a new S3 source from the given configuration.
26    ///
27    /// Builds the S3 client eagerly so it is reused across calls.
28    pub async fn new(config: S3SourceConfig) -> Result<Self, FaucetError> {
29        let client = Self::build_client(&config).await?;
30        Ok(Self {
31            config,
32            client,
33            applied_shard: Mutex::new(None),
34        })
35    }
36
37    /// Retain only the keys belonging to the applied shard (hash-of-key modulo
38    /// `shards`). A no-op when no shard is applied or `shards <= 1`.
39    fn shard_filter(&self, keys: Vec<String>) -> Vec<String> {
40        match *self.applied_shard.lock().expect("shard mutex poisoned") {
41            Some(member) => keys.into_iter().filter(|k| member.contains(k)).collect(),
42            None => keys,
43        }
44    }
45
46    /// Build an S3 client from the configuration.
47    async fn build_client(config: &S3SourceConfig) -> Result<Client, FaucetError> {
48        let mut config_loader = aws_config::defaults(aws_config::BehaviorVersion::latest());
49
50        if let Some(ref region) = config.region {
51            config_loader = config_loader.region(aws_config::Region::new(region.clone()));
52        }
53
54        if let Some(ref endpoint) = config.endpoint_url {
55            config_loader = config_loader.endpoint_url(endpoint);
56        }
57
58        let sdk_config = config_loader.load().await;
59        let client = Client::new(&sdk_config);
60        Ok(client)
61    }
62
63    /// List object keys matching the configured bucket and prefix.
64    ///
65    /// When `prefix_override` is `Some`, it is used instead of `self.config.prefix`
66    /// (used for parent-context substitution).
67    async fn list_object_keys(
68        &self,
69        prefix_override: Option<&str>,
70    ) -> Result<Vec<String>, FaucetError> {
71        let mut keys = Vec::new();
72        let mut continuation_token: Option<String> = None;
73
74        let effective_prefix = prefix_override.or(self.config.prefix.as_deref());
75
76        loop {
77            let mut req = self.client.list_objects_v2().bucket(&self.config.bucket);
78
79            if let Some(prefix) = effective_prefix {
80                req = req.prefix(prefix);
81            }
82
83            if let Some(ref token) = continuation_token {
84                req = req.continuation_token(token);
85            }
86
87            let response = req.send().await.map_err(|e| {
88                FaucetError::Source(format!(
89                    "S3 list objects error for bucket '{}': {e}",
90                    self.config.bucket
91                ))
92            })?;
93
94            for object in response.contents() {
95                let key: &str = object.key().unwrap_or_default();
96                if key.is_empty() {
97                    continue;
98                }
99                keys.push(key.to_string());
100
101                if let Some(max) = self.config.max_objects
102                    && keys.len() >= max
103                {
104                    return Ok(self.shard_filter(keys));
105                }
106            }
107
108            if response.is_truncated() == Some(true) {
109                continuation_token = response.next_continuation_token().map(String::from);
110            } else {
111                break;
112            }
113        }
114
115        Ok(self.shard_filter(keys))
116    }
117
118    /// Read and parse a single S3 object into records.
119    async fn read_object(&self, key: &str) -> Result<Vec<Value>, FaucetError> {
120        let text = self.read_object_text(key).await?;
121        self.parse_content(key, &text)
122    }
123
124    /// Read the full body of a single S3 object into a UTF-8 `String`.
125    ///
126    /// Streams the (optionally decompressed) body straight into one `String`
127    /// via [`open_object_reader`](Self::open_object_reader) rather than
128    /// buffering the raw bytes AND the decompressed bytes AND the `String`
129    /// at once (#78/#25). The whole object is still one unit for
130    /// `JsonArray` / `RawText`, but peak memory is now ~1× the decoded size.
131    async fn read_object_text(&self, key: &str) -> Result<String, FaucetError> {
132        use tokio::io::AsyncReadExt as _;
133        let mut reader = self.open_object_reader(key).await?;
134        let mut text = String::new();
135        reader.read_to_string(&mut text).await.map_err(|e| {
136            FaucetError::Source(format!(
137                "S3 read/decode error for key '{key}' (not valid UTF-8?): {e}"
138            ))
139        })?;
140        Ok(text)
141    }
142
143    /// Open an S3 object as an [`AsyncBufRead`](tokio::io::AsyncBufRead) over
144    /// its body. Used by [`Source::stream_pages`](faucet_core::Source::stream_pages)
145    /// to decode `JsonLines` objects line-by-line without buffering the
146    /// whole file.
147    async fn open_object_reader(
148        &self,
149        key: &str,
150    ) -> Result<std::pin::Pin<Box<dyn tokio::io::AsyncBufRead + Send + Unpin>>, FaucetError> {
151        let mut request = self
152            .client
153            .get_object()
154            .bucket(&self.config.bucket)
155            .key(key);
156        // Ask S3 to return its stored checksum so we can verify the body (#161).
157        if self.config.verify_checksum {
158            request = request.checksum_mode(aws_sdk_s3::types::ChecksumMode::Enabled);
159        }
160        let response = request.send().await.map_err(|e| {
161            FaucetError::Source(format!("S3 get object error for key '{key}': {e}"))
162        })?;
163
164        // Read all metadata BEFORE consuming `body` (which partially moves
165        // `response`), so a cleanly-truncated/corrupted transfer is rejected
166        // rather than silently parsed as a complete object (#161).
167        let mut checks: Vec<Box<dyn faucet_core::IntegrityCheck>> = Vec::new();
168        match crate::verify::length_check(response.content_length(), self.config.verify_length) {
169            Some(check) => checks.push(check),
170            None if self.config.verify_length => tracing::debug!(
171                key = %key,
172                "S3 object reports no Content-Length; length verification skipped"
173            ),
174            None => {}
175        }
176        if self.config.verify_checksum {
177            let advertised = crate::verify::S3Checksums {
178                crc32: response.checksum_crc32().map(str::to_string),
179                crc32c: response.checksum_crc32_c().map(str::to_string),
180                crc64nvme: response.checksum_crc64_nvme().map(str::to_string),
181                sha256: response.checksum_sha256().map(str::to_string),
182                etag: response.e_tag().map(str::to_string),
183            };
184            match crate::verify::checksum_check(&advertised) {
185                Some(check) => checks.push(check),
186                None => tracing::warn!(
187                    key = %key,
188                    "verify_checksum is enabled but S3 advertised no verifiable checksum for \
189                     this object; relying on the length check only"
190                ),
191            }
192        }
193
194        // `ByteStream::into_async_read` returns `impl AsyncRead`. Wrap the RAW
195        // body in the verifier first so length/checksum cover the stored bytes
196        // (below any decompression), then `BufReader` so `.lines()` is usable
197        // and ownership is `Unpin`.
198        let verified = faucet_core::VerifyingReader::new(response.body.into_async_read(), checks);
199        let buffered = tokio::io::BufReader::new(verified);
200        #[cfg(feature = "compression")]
201        {
202            let codec = self.config.compression.resolve(key);
203            faucet_core::compression::warn_mismatch(key, codec);
204            Ok(faucet_core::compression::wrap_async_reader(buffered, codec))
205        }
206        #[cfg(not(feature = "compression"))]
207        {
208            Ok(Box::pin(buffered))
209        }
210    }
211
212    /// Parse file content into records based on the configured file format.
213    fn parse_content(&self, key: &str, text: &str) -> Result<Vec<Value>, FaucetError> {
214        match self.config.file_format {
215            S3FileFormat::JsonLines => {
216                let mut records = Vec::new();
217                for (line_num, line) in text.lines().enumerate() {
218                    let trimmed = line.trim();
219                    if trimmed.is_empty() {
220                        continue;
221                    }
222                    let value: Value = serde_json::from_str(trimmed).map_err(|e| {
223                        FaucetError::Source(format!(
224                            "S3 JSON parse error in '{key}' at line {}: {e}",
225                            line_num + 1
226                        ))
227                    })?;
228                    records.push(value);
229                }
230                Ok(records)
231            }
232            S3FileFormat::JsonArray => {
233                let value: Value = serde_json::from_str(text).map_err(|e| {
234                    FaucetError::Source(format!("S3 JSON parse error in '{key}': {e}"))
235                })?;
236                match value {
237                    Value::Array(arr) => Ok(arr),
238                    _ => Err(FaucetError::Source(format!(
239                        "S3 expected JSON array in '{key}', got {}",
240                        value_type_name(&value)
241                    ))),
242                }
243            }
244            S3FileFormat::RawText => {
245                let record = serde_json::json!({
246                    "key": key,
247                    "content": text,
248                });
249                Ok(vec![record])
250            }
251        }
252    }
253}
254
255#[async_trait]
256impl faucet_core::Source for S3Source {
257    async fn fetch_with_context(
258        &self,
259        context: &std::collections::HashMap<String, serde_json::Value>,
260    ) -> Result<Vec<Value>, FaucetError> {
261        // Substitute context into prefix when parent context is provided.
262        let substituted_prefix: Option<String> = if !context.is_empty() {
263            self.config
264                .prefix
265                .as_ref()
266                .map(|p| faucet_core::util::substitute_context(p, context))
267        } else {
268            None
269        };
270
271        let keys = self.list_object_keys(substituted_prefix.as_deref()).await?;
272
273        tracing::info!(
274            bucket = %self.config.bucket,
275            objects = keys.len(),
276            "Listed S3 objects"
277        );
278
279        let concurrency = self.config.concurrency.max(1);
280
281        let results: Vec<Vec<Value>> = stream::iter(keys)
282            .map(|key| async move {
283                let records = self.read_object(&key).await?;
284                tracing::debug!(key = %key, records = records.len(), "Read S3 object");
285                Ok::<Vec<Value>, FaucetError>(records)
286            })
287            .buffer_unordered(concurrency)
288            .try_collect()
289            .await?;
290
291        let all_records: Vec<Value> = results.into_iter().flatten().collect();
292
293        tracing::info!(total_records = all_records.len(), "S3 fetch complete");
294        Ok(all_records)
295    }
296
297    /// Stream records from listed S3 objects without buffering the full
298    /// scan. Each emitted [`StreamPage`] holds up to
299    /// [`S3SourceConfig::batch_size`] records.
300    ///
301    /// The trait-level `batch_size` argument is ignored in favour of the
302    /// config field — the config is the user-facing knob the README
303    /// documents, and routing the pipeline-supplied hint through it would
304    /// silently override an explicit config value.
305    ///
306    /// Behaviour by format:
307    ///
308    /// - `JsonLines` / `RawText`: the object body is decoded line-by-line
309    ///   via [`tokio::io::AsyncBufReadExt::lines`] so client-side memory is
310    ///   bounded at `O(batch_size)` per object. Multi-object scans are
311    ///   flattened — a single page may carry lines drawn from any object.
312    /// - `JsonArray`: each object is buffered fully (the JSON value can
313    ///   only be parsed once the array is complete) and then its records
314    ///   are chunked into pages of `batch_size`. See the README "Streaming
315    ///   and batching" section for the caveat.
316    ///
317    /// `batch_size = 0` is the "no batching" sentinel: one [`StreamPage`]
318    /// is emitted per S3 object (no within-object chunking and no
319    /// cross-object accumulation). The S3 source has no
320    /// incremental-replication mode today, so every emitted page carries
321    /// `bookmark: None`.
322    fn stream_pages<'a>(
323        &'a self,
324        context: &'a std::collections::HashMap<String, Value>,
325        _batch_size: usize,
326    ) -> Pin<Box<dyn Stream<Item = Result<StreamPage, FaucetError>> + Send + 'a>> {
327        let batch_size = self.config.batch_size;
328
329        Box::pin(async_stream::try_stream! {
330            // Substitute context into prefix when parent context is provided.
331            let substituted_prefix: Option<String> = if !context.is_empty() {
332                self.config
333                    .prefix
334                    .as_ref()
335                    .map(|p| faucet_core::util::substitute_context(p, context))
336            } else {
337                None
338            };
339
340            let keys = self.list_object_keys(substituted_prefix.as_deref()).await?;
341            tracing::info!(
342                bucket = %self.config.bucket,
343                objects = keys.len(),
344                "Listed S3 objects (stream)",
345            );
346
347            let chunk = if batch_size == 0 { usize::MAX } else { batch_size };
348            let initial_capacity = if batch_size == 0 { 1024 } else { batch_size };
349            let mut buffer: Vec<Value> = Vec::with_capacity(initial_capacity);
350            let mut total = 0usize;
351
352            for key in &keys {
353                match self.config.file_format {
354                    S3FileFormat::JsonLines => {
355                        let reader = self.open_object_reader(key).await?;
356                        let mut lines = reader.lines();
357                        let mut line_num: usize = 0;
358                        while let Some(line) = lines
359                            .next_line()
360                            .await
361                            .map_err(|e| FaucetError::Source(format!(
362                                "S3 read body error for key '{key}': {e}"
363                            )))?
364                        {
365                            line_num += 1;
366                            let trimmed = line.trim();
367                            if trimmed.is_empty() {
368                                continue;
369                            }
370                            let value: Value =
371                                serde_json::from_str(trimmed).map_err(|e| {
372                                    FaucetError::Source(format!(
373                                        "S3 JSON parse error in '{key}' at line {line_num}: {e}",
374                                    ))
375                                })?;
376                            buffer.push(value);
377                            if batch_size != 0 && buffer.len() >= chunk {
378                                let page = std::mem::replace(
379                                    &mut buffer,
380                                    Vec::with_capacity(initial_capacity),
381                                );
382                                total += page.len();
383                                yield StreamPage { records: page, bookmark: None };
384                            }
385                        }
386                        if batch_size == 0 && !buffer.is_empty() {
387                            let page = std::mem::take(&mut buffer);
388                            total += page.len();
389                            yield StreamPage { records: page, bookmark: None };
390                        }
391                    }
392                    S3FileFormat::RawText => {
393                        // RawText emits a single record per object; the
394                        // `key` + `content` shape is unchanged so we
395                        // continue to buffer the body fully. This still
396                        // streams *across* objects.
397                        let text = self.read_object_text(key).await?;
398                        let record = serde_json::json!({
399                            "key": key,
400                            "content": text,
401                        });
402                        buffer.push(record);
403                        if batch_size == 0 {
404                            let page = std::mem::take(&mut buffer);
405                            total += page.len();
406                            yield StreamPage { records: page, bookmark: None };
407                        } else if buffer.len() >= chunk {
408                            let page = std::mem::replace(
409                                &mut buffer,
410                                Vec::with_capacity(initial_capacity),
411                            );
412                            total += page.len();
413                            yield StreamPage { records: page, bookmark: None };
414                        }
415                    }
416                    S3FileFormat::JsonArray => {
417                        // JSON-array files cannot be parsed incrementally
418                        // (the closing `]` is required to validate the
419                        // structure), so each object is buffered fully and
420                        // then chunked. The caveat is documented in the
421                        // crate README.
422                        let text = self.read_object_text(key).await?;
423                        let value: Value = serde_json::from_str(&text).map_err(|e| {
424                            FaucetError::Source(format!("S3 JSON parse error in '{key}': {e}"))
425                        })?;
426                        let array = match value {
427                            Value::Array(arr) => arr,
428                            other => Err(FaucetError::Source(format!(
429                                "S3 expected JSON array in '{key}', got {}",
430                                value_type_name(&other)
431                            )))?,
432                        };
433                        if batch_size == 0 {
434                            // Flush any cross-object buffer first (none
435                            // here because each iteration completes its
436                            // own object — but keep symmetric with the
437                            // line-shaped branches).
438                            if !buffer.is_empty() {
439                                let page = std::mem::take(&mut buffer);
440                                total += page.len();
441                                yield StreamPage { records: page, bookmark: None };
442                            }
443                            total += array.len();
444                            yield StreamPage { records: array, bookmark: None };
445                        } else {
446                            for record in array {
447                                buffer.push(record);
448                                if buffer.len() >= chunk {
449                                    let page = std::mem::replace(
450                                        &mut buffer,
451                                        Vec::with_capacity(initial_capacity),
452                                    );
453                                    total += page.len();
454                                    yield StreamPage { records: page, bookmark: None };
455                                }
456                            }
457                        }
458                    }
459                }
460            }
461
462            if !buffer.is_empty() {
463                let page = std::mem::take(&mut buffer);
464                total += page.len();
465                yield StreamPage { records: page, bookmark: None };
466            }
467
468            tracing::info!(
469                total_records = total,
470                batch_size,
471                objects = keys.len(),
472                "S3 source stream complete",
473            );
474        })
475    }
476
477    fn config_schema(&self) -> serde_json::Value {
478        serde_json::to_value(faucet_core::schema_for!(S3SourceConfig))
479            .expect("schema serialization")
480    }
481
482    fn dataset_uri(&self) -> String {
483        match &self.config.prefix {
484            Some(p) => format!("s3://{}/{}", self.config.bucket, p),
485            None => format!("s3://{}", self.config.bucket),
486        }
487    }
488
489    /// The S3 source is always shardable: any object set can be split by
490    /// hash-of-key. Sharding only takes effect when the cluster coordinator
491    /// calls `apply_shard`; a plain `faucet run` reads
492    /// every object.
493    fn is_shardable(&self) -> bool {
494        true
495    }
496
497    /// Enumerate `target` hash-modulo shards. Each shard `i` will read the
498    /// objects whose key hashes to `i (mod target)`. No I/O: the partition is
499    /// defined by the hash function, so enumeration is cheap and stable as new
500    /// objects appear. `target <= 1` yields a single whole-dataset shard.
501    async fn enumerate_shards(&self, target: usize) -> Result<Vec<ShardSpec>, FaucetError> {
502        Ok(plan_hash_shards(target))
503    }
504
505    /// Narrow this source to one hash-modulo shard. The whole-dataset shard
506    /// clears any filter (reads every object).
507    async fn apply_shard(&self, shard: &ShardSpec) -> Result<(), FaucetError> {
508        *self.applied_shard.lock().expect("shard mutex poisoned") = parse_hash_shard(shard, "s3")?;
509        Ok(())
510    }
511
512    fn supports_discover(&self) -> bool {
513        true
514    }
515
516    /// Enumerate the "directories" directly under the configured prefix via
517    /// **one** `ListObjectsV2` delimiter (`/`) listing — each common prefix
518    /// becomes a `prefix` dataset. When the listing returns no common
519    /// prefixes but does return objects directly under the prefix, each
520    /// object (first page only, capped at `DISCOVER_MAX_OBJECTS` = 1000) becomes an
521    /// `object` dataset instead. No recursion and no data scan — object
522    /// counts would require paging the whole listing, so `estimated_rows`
523    /// is never set.
524    async fn discover(&self) -> Result<Vec<faucet_core::DatasetDescriptor>, FaucetError> {
525        let mut req = self
526            .client
527            .list_objects_v2()
528            .bucket(&self.config.bucket)
529            .delimiter("/")
530            .max_keys(DISCOVER_MAX_OBJECTS as i32);
531        if let Some(prefix) = self.config.prefix.as_deref() {
532            req = req.prefix(prefix);
533        }
534        let response = req
535            .send()
536            .await
537            .map_err(|e| FaucetError::Source(format!("s3: catalog discovery failed: {e}")))?;
538
539        let prefixes: Vec<String> = response
540            .common_prefixes()
541            .iter()
542            .filter_map(|p| p.prefix())
543            .filter(|p| !p.is_empty())
544            .map(str::to_string)
545            .collect();
546        let objects: Vec<String> = response
547            .contents()
548            .iter()
549            .filter_map(|o| o.key())
550            .filter(|k| !k.is_empty())
551            .map(str::to_string)
552            .collect();
553
554        Ok(descriptors_from_listing(prefixes, objects))
555    }
556}
557
558/// Cap on object-fallback descriptors — one delimiter-listing page, matching
559/// the `max_keys` requested from S3.
560const DISCOVER_MAX_OBJECTS: usize = 1000;
561
562/// Build one [`DatasetDescriptor`](faucet_core::DatasetDescriptor) per common
563/// prefix from a single delimiter listing; when the listing yielded no common
564/// prefixes, fall back to one descriptor per object (capped at
565/// `DISCOVER_MAX_OBJECTS`). Each patch selects the dataset via the source's
566/// `prefix` config field — a full object key used as a prefix selects exactly
567/// that object. Pure — unit-testable without an S3 client.
568fn descriptors_from_listing(
569    prefixes: Vec<String>,
570    objects: Vec<String>,
571) -> Vec<faucet_core::DatasetDescriptor> {
572    if !prefixes.is_empty() {
573        return prefixes
574            .into_iter()
575            .map(|p| {
576                let patch = serde_json::json!({ "prefix": p });
577                faucet_core::DatasetDescriptor::new(p, "prefix", patch)
578            })
579            .collect();
580    }
581    objects
582        .into_iter()
583        .take(DISCOVER_MAX_OBJECTS)
584        .map(|k| {
585            let patch = serde_json::json!({ "prefix": k });
586            faucet_core::DatasetDescriptor::new(k, "object", patch)
587        })
588        .collect()
589}
590
591/// Return a human-readable name for a JSON value type.
592fn value_type_name(v: &Value) -> &'static str {
593    match v {
594        Value::Null => "null",
595        Value::Bool(_) => "boolean",
596        Value::Number(_) => "number",
597        Value::String(_) => "string",
598        Value::Array(_) => "array",
599        Value::Object(_) => "object",
600    }
601}
602
603#[cfg(test)]
604mod tests {
605    use super::*;
606    use crate::config::S3SourceConfig;
607    use faucet_core::Source;
608    use serde_json::json;
609
610    /// Helper to build an S3Source synchronously for parse-only tests.
611    /// We construct it directly to avoid needing an async runtime for unit tests
612    /// that only exercise `parse_content`.
613    fn test_source(config: S3SourceConfig) -> S3Source {
614        // Build a dummy client — these tests never make network calls.
615        let sdk_config = aws_config::SdkConfig::builder()
616            .behavior_version(aws_config::BehaviorVersion::latest())
617            .build();
618        let client = Client::new(&sdk_config);
619        S3Source {
620            config,
621            client,
622            applied_shard: Mutex::new(None),
623        }
624    }
625
626    #[test]
627    fn parse_json_lines() {
628        let source = test_source(S3SourceConfig::new("test"));
629        let text = r#"{"id":1,"name":"Alice"}
630{"id":2,"name":"Bob"}
631"#;
632        let records = source.parse_content("test.jsonl", text).unwrap();
633        assert_eq!(records.len(), 2);
634        assert_eq!(records[0]["id"], 1);
635        assert_eq!(records[1]["name"], "Bob");
636    }
637
638    #[test]
639    fn parse_json_lines_skips_empty() {
640        let source = test_source(S3SourceConfig::new("test"));
641        let text = r#"{"id":1}
642
643{"id":2}
644
645"#;
646        let records = source.parse_content("test.jsonl", text).unwrap();
647        assert_eq!(records.len(), 2);
648    }
649
650    #[test]
651    fn parse_json_lines_invalid() {
652        let source = test_source(S3SourceConfig::new("test"));
653        let text = "not json\n";
654        let result = source.parse_content("test.jsonl", text);
655        assert!(result.is_err());
656        let err = result.unwrap_err().to_string();
657        assert!(err.contains("JSON parse error"));
658        assert!(err.contains("line 1"));
659    }
660
661    #[test]
662    fn parse_json_array() {
663        let source = test_source(S3SourceConfig::new("test").file_format(S3FileFormat::JsonArray));
664        let text = r#"[{"id":1},{"id":2}]"#;
665        let records = source.parse_content("test.json", text).unwrap();
666        assert_eq!(records.len(), 2);
667        assert_eq!(records[0]["id"], 1);
668    }
669
670    #[test]
671    fn parse_json_array_not_array() {
672        let source = test_source(S3SourceConfig::new("test").file_format(S3FileFormat::JsonArray));
673        let text = r#"{"id":1}"#;
674        let result = source.parse_content("test.json", text);
675        assert!(result.is_err());
676        let err = result.unwrap_err().to_string();
677        assert!(err.contains("expected JSON array"));
678    }
679
680    #[test]
681    fn parse_raw_text() {
682        let source = test_source(S3SourceConfig::new("test").file_format(S3FileFormat::RawText));
683        let text = "hello world\nline two";
684        let records = source.parse_content("data/file.txt", text).unwrap();
685        assert_eq!(records.len(), 1);
686        assert_eq!(
687            records[0],
688            json!({"key": "data/file.txt", "content": "hello world\nline two"})
689        );
690    }
691
692    #[cfg(feature = "compression")]
693    #[test]
694    fn compression_default_is_auto() {
695        let cfg = S3SourceConfig::new("bucket");
696        assert_eq!(cfg.compression, faucet_core::CompressionConfig::Auto);
697    }
698
699    // ── Hash-modulo sharding ────────────────────────────────────────────────
700
701    #[test]
702    fn shard_hash_is_deterministic() {
703        use faucet_core::shard::shard_hash;
704        assert_eq!(
705            shard_hash("data/part-001.jsonl"),
706            shard_hash("data/part-001.jsonl")
707        );
708        assert_ne!(shard_hash("a"), shard_hash("b"));
709    }
710
711    #[tokio::test]
712    async fn enumerate_shards_returns_target_disjoint_shards() {
713        let source = test_source(S3SourceConfig::new("b"));
714        assert!(source.is_shardable());
715        let shards = source.enumerate_shards(3).await.unwrap();
716        assert_eq!(shards.len(), 3);
717        for (i, s) in shards.iter().enumerate() {
718            assert_eq!(s.descriptor["shards"], 3);
719            assert_eq!(s.descriptor["index"], i);
720        }
721    }
722
723    #[tokio::test]
724    async fn enumerate_shards_target_one_is_whole() {
725        let source = test_source(S3SourceConfig::new("b"));
726        let shards = source.enumerate_shards(1).await.unwrap();
727        assert_eq!(shards.len(), 1);
728        assert!(shards[0].is_whole());
729    }
730
731    // The union of every shard's filtered key set equals the full set, with no
732    // key in two shards — the core no-dup / no-loss guarantee.
733    #[tokio::test]
734    async fn shard_filter_partitions_keys_disjointly_and_completely() {
735        let keys: Vec<String> = (0..200).map(|i| format!("data/obj-{i}.jsonl")).collect();
736        let n = 4;
737        let mut union: Vec<String> = Vec::new();
738        for index in 0..n {
739            let source = test_source(S3SourceConfig::new("b"));
740            source
741                .apply_shard(&ShardSpec::new(
742                    index.to_string(),
743                    serde_json::json!({ "shards": n, "index": index }),
744                ))
745                .await
746                .unwrap();
747            let got = source.shard_filter(keys.clone());
748            union.extend(got);
749        }
750        union.sort();
751        let mut expected = keys.clone();
752        expected.sort();
753        assert_eq!(
754            union, expected,
755            "shards must union to the full key set, disjointly"
756        );
757    }
758
759    #[tokio::test]
760    async fn apply_whole_shard_reads_everything() {
761        let keys: Vec<String> = (0..20).map(|i| format!("k{i}")).collect();
762        let source = test_source(S3SourceConfig::new("b"));
763        source.apply_shard(&ShardSpec::whole()).await.unwrap();
764        assert_eq!(source.shard_filter(keys.clone()).len(), keys.len());
765    }
766
767    #[tokio::test]
768    async fn apply_shard_rejects_malformed_descriptor() {
769        let source = test_source(S3SourceConfig::new("b"));
770        let err = source
771            .apply_shard(&ShardSpec::new("0", serde_json::json!({ "index": 0 })))
772            .await
773            .unwrap_err();
774        assert!(matches!(err, FaucetError::Source(_)));
775    }
776
777    // ── discover: pure listing → descriptor mapping ─────────────────────────
778
779    #[test]
780    fn descriptors_from_listing_maps_common_prefixes() {
781        let out = descriptors_from_listing(
782            vec!["raw/orders/".to_string(), "raw/users/".to_string()],
783            vec![],
784        );
785        assert_eq!(out.len(), 2);
786        assert_eq!(out[0].name, "raw/orders/");
787        assert_eq!(out[0].kind, "prefix");
788        assert_eq!(out[0].config_patch, json!({ "prefix": "raw/orders/" }));
789        assert!(out[0].schema.is_none());
790        assert!(out[0].estimated_rows.is_none());
791        assert_eq!(out[1].name, "raw/users/");
792        assert_eq!(out[1].config_patch, json!({ "prefix": "raw/users/" }));
793    }
794
795    // Prefixes win: objects sitting alongside common prefixes are not
796    // enumerated as datasets (they'd be a mixed listing at the same level).
797    #[test]
798    fn descriptors_from_listing_prefers_prefixes_over_objects() {
799        let out = descriptors_from_listing(
800            vec!["raw/orders/".to_string()],
801            vec!["raw/readme.txt".to_string()],
802        );
803        assert_eq!(out.len(), 1);
804        assert_eq!(out[0].kind, "prefix");
805        assert_eq!(out[0].name, "raw/orders/");
806    }
807
808    #[test]
809    fn descriptors_from_listing_falls_back_to_objects() {
810        let out = descriptors_from_listing(
811            vec![],
812            vec!["raw/a.jsonl".to_string(), "raw/b.jsonl".to_string()],
813        );
814        assert_eq!(out.len(), 2);
815        assert_eq!(out[0].name, "raw/a.jsonl");
816        assert_eq!(out[0].kind, "object");
817        assert_eq!(out[0].config_patch, json!({ "prefix": "raw/a.jsonl" }));
818        assert!(out[0].schema.is_none());
819        assert!(out[0].estimated_rows.is_none());
820    }
821
822    #[test]
823    fn descriptors_from_listing_empty_listing_yields_no_datasets() {
824        assert!(descriptors_from_listing(vec![], vec![]).is_empty());
825    }
826
827    #[test]
828    fn descriptors_from_listing_caps_object_fallback() {
829        let objects: Vec<String> = (0..DISCOVER_MAX_OBJECTS + 500)
830            .map(|i| format!("obj-{i}.jsonl"))
831            .collect();
832        let out = descriptors_from_listing(vec![], objects);
833        assert_eq!(out.len(), DISCOVER_MAX_OBJECTS);
834    }
835
836    #[test]
837    fn source_advertises_discover() {
838        let source = test_source(S3SourceConfig::new("my-bucket"));
839        assert!(source.supports_discover());
840    }
841
842    #[test]
843    fn dataset_uri_no_prefix() {
844        let source = test_source(S3SourceConfig::new("my-bucket"));
845        assert_eq!(source.dataset_uri(), "s3://my-bucket");
846    }
847
848    #[test]
849    fn dataset_uri_with_prefix() {
850        let source = test_source(S3SourceConfig::new("my-bucket").prefix("data/2026/"));
851        assert_eq!(source.dataset_uri(), "s3://my-bucket/data/2026/");
852    }
853}