Skip to main content

faucet_sink_iceberg/
config.rs

1//! Configuration types for the Apache Iceberg sink.
2//!
3//! ## Write mode
4//!
5//! The `write_mode` field accepts the shared [`faucet_core::WriteMode`] enum
6//! (`append` | `upsert` | `delete`). Only `append` is supported at runtime in
7//! v1; `upsert` and `delete` deserialise successfully but are rejected by
8//! [`IcebergSink::new`](crate::sink::IcebergSink::new) with a typed
9//! `FaucetError::Config`. Equality-delete upsert is tracked in
10//! [#179](https://github.com/PawanSikawat/faucet-stream/issues/179) and is
11//! blocked on upstream iceberg-rust adding a replace/overwrite transaction action.
12
13use std::collections::HashMap;
14use std::fmt;
15
16use faucet_core::{FaucetError, WriteMode};
17use schemars::JsonSchema;
18use serde::{Deserialize, Serialize};
19
20// ── Warehouse scheme classification ─────────────────────────────────────────
21
22/// Classification of a warehouse URI by scheme, used to select an Iceberg
23/// `StorageFactory` (see `crate::storage_factory`) and to validate configs.
24///
25/// The set of recognised schemes is intentionally small and feature-independent:
26/// it is the set faucet's storage-factory selector understands. REST catalogs
27/// resolve FileIO server-side and are exempt from this classification.
28#[derive(Debug, Clone, PartialEq, Eq)]
29pub(crate) enum WarehouseScheme {
30    /// No scheme, a bare path, or `file://` — local filesystem.
31    Local,
32    /// `s3://` or `s3a://`. Carries the exact scheme string ("s3" / "s3a"),
33    /// which the OpenDAL S3 operator requires to match the warehouse URI.
34    S3(&'static str),
35    /// `gs://` — Google Cloud Storage.
36    Gcs,
37    /// Any other scheme (e.g. `oss`, `abfss`) — no storage factory available.
38    Unsupported(String),
39}
40
41/// Classify a warehouse URI by its scheme.
42///
43/// A URI with no `://` (empty, bare path, or relative path) is treated as a
44/// local-filesystem warehouse. Scheme matching is case-insensitive.
45pub(crate) fn warehouse_scheme(warehouse: &str) -> WarehouseScheme {
46    let scheme = match warehouse.trim().split_once("://") {
47        Some((s, _)) => s.to_ascii_lowercase(),
48        None => return WarehouseScheme::Local,
49    };
50    match scheme.as_str() {
51        "file" => WarehouseScheme::Local,
52        "s3" => WarehouseScheme::S3("s3"),
53        "s3a" => WarehouseScheme::S3("s3a"),
54        "gs" => WarehouseScheme::Gcs,
55        other => WarehouseScheme::Unsupported(other.to_string()),
56    }
57}
58
59// ── Catalog config ────────────────────────────────────────────────────────────
60
61/// Configuration fields shared by every catalog variant.
62///
63/// Individual variants carry the same fields so `CatalogConfig` stays a
64/// well-typed tagged enum without a separate inner struct (which would make
65/// the JSON Schema less readable).
66#[derive(Clone, Serialize, Deserialize, JsonSchema)]
67#[serde(rename_all = "snake_case")]
68pub struct CatalogInner {
69    /// Catalog endpoint URI.
70    ///
71    /// For REST: `https://catalog.example.com`.
72    /// For HMS: `thrift://hms:9083`.
73    /// For SQL: the JDBC/SQLx connection string, e.g. `postgres://…`.
74    #[serde(default)]
75    pub uri: Option<String>,
76
77    /// Object-storage warehouse root, e.g. `s3://lake/warehouse`.
78    #[serde(default)]
79    pub warehouse: Option<String>,
80
81    /// REST bearer token or other catalog-specific credential.
82    ///
83    /// Redacted in `Debug` output — never logged.
84    #[serde(default)]
85    pub credential: Option<String>,
86
87    /// Arbitrary catalog properties passed through to the catalog builder
88    /// (e.g. S3 region, endpoint, access key).
89    #[serde(default)]
90    pub properties: HashMap<String, String>,
91}
92
93/// Iceberg catalog type and its connection settings.
94///
95/// Uses serde's internally-tagged enum: the JSON/YAML `type` key selects the
96/// variant. Each variant carries the same inner fields (`uri`, `warehouse`,
97/// `credential`, `properties`); the relevant set differs per catalog type and
98/// is documented in each variant.
99///
100/// | Variant | Cargo feature required   |
101/// |---------|--------------------------|
102/// | `rest`  | `catalog-rest` (default) |
103/// | `glue`  | `catalog-glue`           |
104/// | `sql`   | `catalog-sql`            |
105/// | `hms`   | `catalog-hms`            |
106#[derive(Clone, Serialize, Deserialize, JsonSchema)]
107#[serde(tag = "type", rename_all = "snake_case")]
108pub enum CatalogConfig {
109    /// Apache Iceberg REST catalog. `uri` is the catalog endpoint; `credential`
110    /// becomes the REST bearer token.
111    Rest(CatalogInner),
112
113    /// AWS Glue catalog. `warehouse` is the S3 root; AWS credentials are
114    /// supplied via `properties` or the default AWS credential chain.
115    Glue(CatalogInner),
116
117    /// SQL-backed catalog (e.g. JDBC/postgres). `uri` is the connection string.
118    Sql(CatalogInner),
119
120    /// Hive Metastore catalog. `uri` is the Thrift endpoint
121    /// (`thrift://hms:9083`).
122    Hms(CatalogInner),
123}
124
125impl CatalogConfig {
126    fn inner(&self) -> &CatalogInner {
127        match self {
128            CatalogConfig::Rest(i)
129            | CatalogConfig::Glue(i)
130            | CatalogConfig::Sql(i)
131            | CatalogConfig::Hms(i) => i,
132        }
133    }
134}
135
136// Redact credential and URI from Debug output.
137impl fmt::Debug for CatalogConfig {
138    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
139        let type_name = match self {
140            CatalogConfig::Rest(_) => "rest",
141            CatalogConfig::Glue(_) => "glue",
142            CatalogConfig::Sql(_) => "sql",
143            CatalogConfig::Hms(_) => "hms",
144        };
145        let inner = self.inner();
146        // Redact the credential field entirely, and the uri (may contain userinfo/token).
147        let uri_display = inner.uri.as_deref().map(|_| "***").unwrap_or("<none>");
148        let cred_display = inner
149            .credential
150            .as_deref()
151            .map(|_| "***")
152            .unwrap_or("<none>");
153        f.debug_struct("CatalogConfig")
154            .field("type", &type_name)
155            .field("uri", &uri_display)
156            .field("warehouse", &inner.warehouse)
157            .field("credential", &cred_display)
158            .field(
159                "properties_keys",
160                &inner.properties.keys().collect::<Vec<_>>(),
161            )
162            .finish()
163    }
164}
165
166// ── Partition spec ────────────────────────────────────────────────────────────
167
168/// A single partition field: source column + transform.
169///
170/// Supported transforms: `identity`, `year`, `month`, `day`, `hour`, `void`,
171/// and parameterized forms `bucket[N]` and `truncate[N]` (e.g. `bucket[16]`,
172/// `truncate[8]`). Used only when `create_if_missing: true` and the table
173/// does not yet exist.
174#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
175pub struct PartitionField {
176    /// Source column name in the table schema.
177    pub source: String,
178
179    /// Iceberg partition transform. One of: `identity`, `year`, `month`,
180    /// `day`, `hour`, `void`, `bucket[N]`, `truncate[N]`.
181    pub transform: String,
182}
183
184// ── Parquet options ───────────────────────────────────────────────────────────
185
186/// Parquet-level compression and encoding options.
187#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
188pub struct ParquetOpts {
189    /// Parquet compression codec. Supported: `snappy` (default), `zstd`,
190    /// `gzip`, `lz4`, `none`.
191    #[serde(default = "default_compression")]
192    pub compression: String,
193}
194
195fn default_compression() -> String {
196    "snappy".to_string()
197}
198
199impl Default for ParquetOpts {
200    fn default() -> Self {
201        ParquetOpts {
202            compression: default_compression(),
203        }
204    }
205}
206
207// ── Top-level sink config ─────────────────────────────────────────────────────
208
209/// Configuration for the Apache Iceberg sink.
210///
211/// Records are buffered into Arrow batches, written as Parquet data files via
212/// the iceberg-rust writer pipeline, and committed as a single snapshot per
213/// `flush()` call using `Transaction::fast_append`.
214///
215/// ## Append-only (v1)
216///
217/// Only `write_mode: append` is supported at runtime. The `write_mode` field
218/// accepts the shared [`faucet_core::WriteMode`] enum, so `upsert` and
219/// `delete` deserialise without error but are rejected by
220/// [`IcebergSink::new`](crate::sink::IcebergSink::new) with a `FaucetError::Config`. Configuring
221/// `write_mode: overwrite` still produces a deserialization error (it is not
222/// a recognised variant). Equality-delete upsert is tracked in #179.
223///
224/// ## Catalog feature gates
225///
226/// The REST catalog is included in the default build (`catalog-rest`). Glue,
227/// SQL, and HMS each require their own Cargo feature (`catalog-glue`,
228/// `catalog-sql`, `catalog-hms`). Configuring a disabled catalog type returns
229/// `FaucetError::Config` at startup.
230#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
231pub struct IcebergSinkConfig {
232    /// Iceberg catalog connection settings.
233    pub catalog: CatalogConfig,
234
235    /// Multi-part namespace that contains the target table, e.g.
236    /// `["analytics", "events"]`. Must be non-empty; no segment may be empty.
237    pub namespace: Vec<String>,
238
239    /// Name of the Iceberg table (without namespace), e.g. `"page_views"`.
240    pub table: String,
241
242    /// Create the table if it does not exist, inferring the schema from the
243    /// first batch. When `false`, `load_table` is called at startup and an
244    /// absent table causes a `FaucetError::Sink` immediately.
245    #[serde(default = "default_create_if_missing")]
246    pub create_if_missing: bool,
247
248    /// Partition fields applied when creating the table. Ignored on writes to
249    /// an existing table (the table's existing spec is used).
250    #[serde(default)]
251    pub partition_spec: Vec<PartitionField>,
252
253    /// Write semantics. Uses the shared [`faucet_core::WriteMode`] enum
254    /// (`append` | `upsert` | `delete`). Only `append` is supported at
255    /// runtime; non-append modes are rejected by [`IcebergSink::new`](crate::sink::IcebergSink::new) with a
256    /// `FaucetError::Config`. Upsert via equality-delete is tracked in #179.
257    #[serde(default)]
258    pub write_mode: WriteMode,
259
260    /// Roll over to a new Parquet data file when the estimated file size
261    /// (uncompressed Arrow bytes × 0.4) exceeds this threshold.
262    #[serde(default = "default_target_file_size_mb")]
263    pub target_file_size_mb: u64,
264
265    /// Parquet codec settings.
266    #[serde(default)]
267    pub parquet: ParquetOpts,
268
269    /// Key-value pairs written into the Iceberg snapshot summary.
270    #[serde(default)]
271    pub snapshot_properties: HashMap<String, String>,
272
273    /// Maximum records buffered in memory before flushing a write to the
274    /// iceberg writer pipeline. `0` = no limit (single batch). Default: 10 000.
275    #[serde(default = "default_batch_size")]
276    pub batch_size: usize,
277
278    /// Delete the Parquet data files this flush already uploaded when the
279    /// snapshot commit *definitively* fails, so they do not accumulate as
280    /// orphans.
281    ///
282    /// Iceberg commits use optimistic concurrency: the data files are written
283    /// to object storage *before* the snapshot commit. iceberg-rust already
284    /// retries retryable commit conflicts internally (reloading table metadata
285    /// and re-applying the append against the latest snapshot — tunable via the
286    /// standard `commit.retry.*` table properties). If those retries are
287    /// exhausted (a competing writer won) the just-written files are orphaned —
288    /// valid Parquet, but referenced by no snapshot.
289    ///
290    /// With this flag set, such orphans are deleted automatically on a
291    /// **definitive** loss (an exhausted commit conflict, or a catalog-rejected
292    /// commit). An **ambiguous** failure — e.g. a network error on the catalog
293    /// update where the commit may have landed server-side — is *never* cleaned
294    /// up regardless of this flag, because deleting then could remove files a
295    /// successful-but-unacknowledged commit references.
296    ///
297    /// Default `false`: leave orphans in place (recoverable later via Iceberg's
298    /// standard `remove_orphan_files` maintenance) so cleanup is an explicit,
299    /// opt-in choice.
300    #[serde(default)]
301    pub cleanup_orphans_on_failure: bool,
302}
303
304fn default_create_if_missing() -> bool {
305    true
306}
307
308fn default_target_file_size_mb() -> u64 {
309    256
310}
311
312fn default_batch_size() -> usize {
313    10_000
314}
315
316// ── Validation ────────────────────────────────────────────────────────────────
317
318/// Known partition transforms (bare names).
319const KNOWN_TRANSFORMS: &[&str] = &["identity", "year", "month", "day", "hour", "void"];
320
321/// Returns `true` if the transform string is a valid Iceberg partition transform.
322///
323/// Accepts:
324/// - Bare names: `identity`, `year`, `month`, `day`, `hour`, `void`
325/// - Parameterized: `bucket[N]`, `truncate[N]` (N must be a positive integer)
326fn is_valid_transform(t: &str) -> bool {
327    if KNOWN_TRANSFORMS.contains(&t) {
328        return true;
329    }
330    // bucket[N] or truncate[N]
331    for prefix in &["bucket[", "truncate["] {
332        if let Some(rest) = t.strip_prefix(prefix)
333            && let Some(n_str) = rest.strip_suffix(']')
334        {
335            return n_str.parse::<u64>().map(|n| n > 0).unwrap_or(false);
336        }
337    }
338    false
339}
340
341impl IcebergSinkConfig {
342    /// Validate the configuration at load time.
343    ///
344    /// Checks:
345    /// - `namespace` is non-empty and contains no empty segment.
346    /// - `table` is non-empty.
347    /// - Each `partition_spec[].transform` is a recognised Iceberg transform.
348    /// - `batch_size` is within bounds (via [`faucet_core::validate_batch_size`]).
349    pub fn validate(&self) -> Result<(), FaucetError> {
350        // Namespace
351        if self.namespace.is_empty() {
352            return Err(FaucetError::Config(
353                "iceberg: `namespace` must contain at least one segment".to_string(),
354            ));
355        }
356        for seg in &self.namespace {
357            if seg.is_empty() {
358                return Err(FaucetError::Config(
359                    "iceberg: `namespace` segments must not be empty".to_string(),
360                ));
361            }
362        }
363
364        // Table name
365        if self.table.is_empty() {
366            return Err(FaucetError::Config(
367                "iceberg: `table` must not be empty".to_string(),
368            ));
369        }
370
371        // Partition transforms
372        for (i, pf) in self.partition_spec.iter().enumerate() {
373            if pf.source.is_empty() {
374                return Err(FaucetError::Config(format!(
375                    "iceberg: partition_spec[{i}].source must not be empty"
376                )));
377            }
378            if !is_valid_transform(&pf.transform) {
379                return Err(FaucetError::Config(format!(
380                    "iceberg: partition_spec[{i}].transform {:?} is not a recognised Iceberg \
381                     transform; expected one of: {} or parameterized bucket[N] / truncate[N]",
382                    pf.transform,
383                    KNOWN_TRANSFORMS.join(", ")
384                )));
385            }
386        }
387
388        // Catalog connection URI. REST / SQL / HMS need an endpoint URI; Glue
389        // resolves its endpoint from AWS config (region/credentials in
390        // `properties` or the default chain), so it has no required URI. Caught
391        // here at config-load time rather than only at connect time.
392        let (uri_required, kind) = match &self.catalog {
393            CatalogConfig::Rest(_) => (true, "rest"),
394            CatalogConfig::Sql(_) => (true, "sql"),
395            CatalogConfig::Hms(_) => (true, "hms"),
396            CatalogConfig::Glue(_) => (false, "glue"),
397        };
398        if uri_required
399            && self
400                .catalog
401                .inner()
402                .uri
403                .as_deref()
404                .map(str::trim)
405                .unwrap_or("")
406                .is_empty()
407        {
408            return Err(FaucetError::Config(format!(
409                "iceberg: catalog '{kind}' requires a non-empty `uri`"
410            )));
411        }
412
413        // Warehouse scheme: the non-REST catalogs build FileIO in-process, so
414        // faucet must have a storage factory for the scheme. REST resolves
415        // FileIO server-side and may use any scheme. (#181)
416        if !matches!(self.catalog, CatalogConfig::Rest(_)) {
417            let warehouse = self.catalog.inner().warehouse.as_deref().unwrap_or("");
418            if let WarehouseScheme::Unsupported(s) = warehouse_scheme(warehouse) {
419                return Err(FaucetError::Config(format!(
420                    "iceberg: warehouse scheme '{s}://' is not supported for the \
421                     '{kind}' catalog; use file://, s3://, s3a://, or gs:// (or the \
422                     REST catalog for other object stores)"
423                )));
424            }
425        }
426
427        // Target file size: 0 would make iceberg's rolling writer roll a new
428        // (tiny) data file on every batch — almost certainly a misconfiguration.
429        if self.target_file_size_mb == 0 {
430            return Err(FaucetError::Config(
431                "iceberg: `target_file_size_mb` must be > 0".to_string(),
432            ));
433        }
434
435        // Batch size
436        faucet_core::validate_batch_size(self.batch_size)?;
437
438        Ok(())
439    }
440}
441
442// ── Tests ─────────────────────────────────────────────────────────────────────
443
444#[cfg(test)]
445mod tests {
446    use super::*;
447
448    fn minimal_config_json() -> serde_json::Value {
449        serde_json::json!({
450            "catalog": { "type": "rest", "uri": "http://localhost:8181" },
451            "namespace": ["analytics"],
452            "table": "events"
453        })
454    }
455
456    fn parse(v: serde_json::Value) -> IcebergSinkConfig {
457        serde_json::from_value(v).expect("parse failed")
458    }
459
460    // ── defaults ──────────────────────────────────────────────────────────────
461
462    #[test]
463    fn defaults_are_applied() {
464        let cfg = parse(minimal_config_json());
465        assert!(cfg.create_if_missing);
466        assert_eq!(cfg.target_file_size_mb, 256);
467        assert_eq!(cfg.parquet.compression, "snappy");
468        assert_eq!(cfg.write_mode, WriteMode::Append);
469        assert_eq!(cfg.batch_size, 10_000);
470        assert!(cfg.partition_spec.is_empty());
471        assert!(cfg.snapshot_properties.is_empty());
472    }
473
474    // ── catalog tagged-enum round-trip ────────────────────────────────────────
475
476    #[test]
477    fn catalog_rest_round_trip() {
478        let v = serde_json::json!({
479            "type": "rest",
480            "uri": "https://catalog.example.com",
481            "warehouse": "s3://lake/wh",
482            "credential": "my-token",
483            "properties": { "region": "us-east-1" }
484        });
485        let cat: CatalogConfig = serde_json::from_value(v).unwrap();
486        assert!(matches!(cat, CatalogConfig::Rest(_)));
487        let inner = cat.inner();
488        assert_eq!(inner.uri.as_deref(), Some("https://catalog.example.com"));
489        assert_eq!(inner.credential.as_deref(), Some("my-token"));
490        assert_eq!(
491            inner.properties.get("region").map(String::as_str),
492            Some("us-east-1")
493        );
494
495        // Re-serialize and re-parse.
496        let json = serde_json::to_value(&cat).unwrap();
497        assert_eq!(json["type"], "rest");
498        let _cat2: CatalogConfig = serde_json::from_value(json).unwrap();
499    }
500
501    #[test]
502    fn catalog_glue_round_trip() {
503        let v = serde_json::json!({ "type": "glue", "warehouse": "s3://lake/wh" });
504        let cat: CatalogConfig = serde_json::from_value(v).unwrap();
505        assert!(matches!(cat, CatalogConfig::Glue(_)));
506    }
507
508    #[test]
509    fn catalog_sql_round_trip() {
510        let v = serde_json::json!({
511            "type": "sql",
512            "uri": "postgres://localhost/meta",
513            "warehouse": "s3://lake/wh"
514        });
515        let cat: CatalogConfig = serde_json::from_value(v).unwrap();
516        assert!(matches!(cat, CatalogConfig::Sql(_)));
517    }
518
519    #[test]
520    fn catalog_hms_round_trip() {
521        let v = serde_json::json!({ "type": "hms", "uri": "thrift://hms:9083" });
522        let cat: CatalogConfig = serde_json::from_value(v).unwrap();
523        assert!(matches!(cat, CatalogConfig::Hms(_)));
524    }
525
526    // ── write_mode ────────────────────────────────────────────────────────────
527
528    #[test]
529    fn write_mode_defaults_to_append() {
530        let cfg = parse(minimal_config_json());
531        assert_eq!(cfg.write_mode, WriteMode::Append);
532    }
533
534    #[test]
535    fn write_mode_append_explicit() {
536        let mut v = minimal_config_json();
537        v["write_mode"] = serde_json::json!("append");
538        let cfg = parse(v);
539        assert_eq!(cfg.write_mode, WriteMode::Append);
540    }
541
542    #[test]
543    fn write_mode_overwrite_is_rejected() {
544        let mut v = minimal_config_json();
545        v["write_mode"] = serde_json::json!("overwrite");
546        let err = serde_json::from_value::<IcebergSinkConfig>(v);
547        assert!(err.is_err(), "expected error for write_mode=overwrite");
548        let msg = err.unwrap_err().to_string();
549        assert!(
550            msg.contains("unknown variant") || msg.contains("overwrite"),
551            "error message should mention unknown variant or overwrite: {msg}"
552        );
553    }
554
555    // ── partition transform validation ────────────────────────────────────────
556
557    #[test]
558    fn valid_transforms_accepted() {
559        let transforms = [
560            "identity",
561            "year",
562            "month",
563            "day",
564            "hour",
565            "void",
566            "bucket[5]",
567            "bucket[16]",
568            "truncate[8]",
569        ];
570        for t in transforms {
571            assert!(is_valid_transform(t), "{t} should be valid");
572        }
573    }
574
575    #[test]
576    fn invalid_transform_rejected_by_validate() {
577        let mut v = minimal_config_json();
578        v["partition_spec"] = serde_json::json!([{ "source": "col", "transform": "frobnicate" }]);
579        let cfg = parse(v);
580        let err = cfg.validate().unwrap_err();
581        assert!(matches!(err, FaucetError::Config(_)));
582        let msg = err.to_string();
583        assert!(
584            msg.contains("frobnicate"),
585            "error should mention the bad transform: {msg}"
586        );
587    }
588
589    #[test]
590    fn bucket_zero_is_rejected() {
591        assert!(!is_valid_transform("bucket[0]"));
592    }
593
594    #[test]
595    fn valid_partition_spec_passes_validate() {
596        let mut v = minimal_config_json();
597        v["partition_spec"] = serde_json::json!([
598            { "source": "event_date", "transform": "day" },
599            { "source": "tenant_id",  "transform": "identity" },
600            { "source": "bucket_col", "transform": "bucket[16]" }
601        ]);
602        let cfg = parse(v);
603        cfg.validate().expect("should pass");
604    }
605
606    // ── namespace / table required ────────────────────────────────────────────
607
608    #[test]
609    fn empty_namespace_is_rejected() {
610        let mut v = minimal_config_json();
611        v["namespace"] = serde_json::json!([]);
612        let cfg = parse(v);
613        let err = cfg.validate().unwrap_err();
614        assert!(matches!(err, FaucetError::Config(_)));
615        let msg = err.to_string();
616        assert!(msg.contains("namespace"), "should mention namespace: {msg}");
617    }
618
619    #[test]
620    fn namespace_with_empty_segment_is_rejected() {
621        let mut v = minimal_config_json();
622        v["namespace"] = serde_json::json!(["analytics", "", "events"]);
623        let cfg = parse(v);
624        let err = cfg.validate().unwrap_err();
625        assert!(matches!(err, FaucetError::Config(_)));
626    }
627
628    #[test]
629    fn empty_table_is_rejected() {
630        let mut v = minimal_config_json();
631        v["table"] = serde_json::json!("");
632        let cfg = parse(v);
633        let err = cfg.validate().unwrap_err();
634        assert!(matches!(err, FaucetError::Config(_)));
635        let msg = err.to_string();
636        assert!(msg.contains("table"), "should mention table: {msg}");
637    }
638
639    // ── catalog uri requirement ───────────────────────────────────────────────
640
641    #[test]
642    fn rest_catalog_without_uri_is_rejected() {
643        let cfg = parse(serde_json::json!({
644            "catalog": { "type": "rest" },
645            "namespace": ["analytics"],
646            "table": "events"
647        }));
648        let err = cfg.validate().unwrap_err();
649        assert!(matches!(err, FaucetError::Config(_)));
650        assert!(err.to_string().contains("uri"), "should mention uri: {err}");
651    }
652
653    #[test]
654    fn sql_and_hms_without_uri_are_rejected() {
655        for ty in ["sql", "hms"] {
656            let cfg = parse(serde_json::json!({
657                "catalog": { "type": ty },
658                "namespace": ["analytics"],
659                "table": "events"
660            }));
661            assert!(cfg.validate().is_err(), "{ty} without uri should fail");
662        }
663    }
664
665    #[test]
666    fn rest_catalog_with_whitespace_uri_is_rejected() {
667        let cfg = parse(serde_json::json!({
668            "catalog": { "type": "rest", "uri": "   " },
669            "namespace": ["analytics"],
670            "table": "events"
671        }));
672        assert!(cfg.validate().is_err(), "whitespace-only uri should fail");
673    }
674
675    #[test]
676    fn zero_target_file_size_is_rejected() {
677        let mut v = minimal_config_json();
678        v["target_file_size_mb"] = serde_json::json!(0);
679        assert!(parse(v).validate().is_err());
680    }
681
682    #[test]
683    fn glue_catalog_without_uri_is_allowed() {
684        // Glue resolves its endpoint from AWS config, so no uri is required.
685        let cfg = parse(serde_json::json!({
686            "catalog": { "type": "glue", "warehouse": "s3://lake/wh" },
687            "namespace": ["analytics"],
688            "table": "events"
689        }));
690        assert!(cfg.validate().is_ok());
691    }
692
693    // ── warehouse scheme validation ───────────────────────────────────────────
694
695    #[test]
696    fn sql_catalog_rejects_unsupported_warehouse_scheme() {
697        let cfg = parse(serde_json::json!({
698            "catalog": { "type": "sql", "uri": "sqlite::memory:", "warehouse": "oss://bucket/wh" },
699            "namespace": ["analytics"],
700            "table": "events"
701        }));
702        let err = cfg.validate().unwrap_err();
703        assert!(matches!(err, FaucetError::Config(_)));
704        let msg = err.to_string();
705        assert!(msg.contains("oss"), "should name the bad scheme: {msg}");
706    }
707
708    #[test]
709    fn sql_catalog_accepts_cloud_and_local_warehouses() {
710        for w in [
711            "s3://bucket/wh",
712            "s3a://bucket/wh",
713            "gs://bucket/wh",
714            "file:///tmp/wh",
715            "/tmp/wh",
716        ] {
717            let cfg = parse(serde_json::json!({
718                "catalog": { "type": "sql", "uri": "sqlite::memory:", "warehouse": w },
719                "namespace": ["analytics"],
720                "table": "events"
721            }));
722            cfg.validate()
723                .unwrap_or_else(|e| panic!("{w} should validate: {e}"));
724        }
725    }
726
727    #[test]
728    fn rest_catalog_allows_any_warehouse_scheme() {
729        let cfg = parse(serde_json::json!({
730            "catalog": { "type": "rest", "uri": "http://localhost:8181", "warehouse": "oss://bucket/wh" },
731            "namespace": ["analytics"],
732            "table": "events"
733        }));
734        cfg.validate()
735            .expect("REST should accept any warehouse scheme");
736    }
737
738    // ── write_mode core-enum ──────────────────────────────────────────────────
739
740    #[test]
741    fn iceberg_config_parses_upsert_against_core_enum() {
742        // Once the local WriteMode is replaced by faucet_core::WriteMode,
743        // "upsert" must deserialise (the local enum only had Append, so this
744        // would fail with "unknown variant 'upsert'" before the change).
745        let cfg: IcebergSinkConfig = serde_json::from_value(serde_json::json!({
746            "catalog": { "type": "rest", "uri": "http://localhost:8181" },
747            "namespace": ["analytics"],
748            "table": "events",
749            "write_mode": "upsert"
750        }))
751        .expect("upsert parses against the core enum");
752        assert_eq!(cfg.write_mode, faucet_core::WriteMode::Upsert);
753    }
754
755    // ── batch_size bounds ─────────────────────────────────────────────────────
756
757    #[test]
758    fn batch_size_above_max_is_rejected() {
759        let mut v = minimal_config_json();
760        v["batch_size"] = serde_json::json!(1_000_001usize);
761        let cfg = parse(v);
762        let err = cfg.validate().unwrap_err();
763        assert!(matches!(err, FaucetError::Config(_)));
764    }
765
766    #[test]
767    fn batch_size_at_max_is_accepted() {
768        let mut v = minimal_config_json();
769        v["batch_size"] = serde_json::json!(1_000_000usize);
770        let cfg = parse(v);
771        cfg.validate().expect("max batch_size should be accepted");
772    }
773
774    #[test]
775    fn batch_size_zero_is_accepted() {
776        let mut v = minimal_config_json();
777        v["batch_size"] = serde_json::json!(0usize);
778        let cfg = parse(v);
779        cfg.validate()
780            .expect("batch_size=0 sentinel should be accepted");
781    }
782
783    // ── Debug redacts credential ──────────────────────────────────────────────
784
785    #[test]
786    fn debug_redacts_credential() {
787        let v = serde_json::json!({
788            "type": "rest",
789            "uri": "https://catalog.example.com/api",
790            "credential": "super-secret-token"
791        });
792        let cat: CatalogConfig = serde_json::from_value(v).unwrap();
793        let debug_str = format!("{cat:?}");
794        assert!(
795            !debug_str.contains("super-secret-token"),
796            "credential must be redacted: {debug_str}"
797        );
798        assert!(
799            debug_str.contains("***"),
800            "should show *** placeholder: {debug_str}"
801        );
802    }
803
804    #[test]
805    fn debug_redacts_uri() {
806        let v = serde_json::json!({
807            "type": "rest",
808            "uri": "https://user:pass@catalog.example.com"
809        });
810        let cat: CatalogConfig = serde_json::from_value(v).unwrap();
811        let debug_str = format!("{cat:?}");
812        // The URI field is redacted to "***" when present.
813        assert!(
814            !debug_str.contains("user:pass"),
815            "uri userinfo must be redacted: {debug_str}"
816        );
817    }
818
819    // ── warehouse scheme classification ───────────────────────────────────────
820
821    #[test]
822    fn warehouse_scheme_local_variants() {
823        use super::{WarehouseScheme, warehouse_scheme};
824        for w in [
825            "",
826            "/tmp/warehouse",
827            "./wh",
828            "relative/dir",
829            "file:///tmp/wh",
830        ] {
831            assert!(
832                matches!(warehouse_scheme(w), WarehouseScheme::Local),
833                "{w:?} should be Local"
834            );
835        }
836    }
837
838    #[test]
839    fn warehouse_scheme_s3_preserves_scheme() {
840        use super::{WarehouseScheme, warehouse_scheme};
841        assert!(matches!(
842            warehouse_scheme("s3://bucket/wh"),
843            WarehouseScheme::S3("s3")
844        ));
845        assert!(matches!(
846            warehouse_scheme("s3a://bucket/wh"),
847            WarehouseScheme::S3("s3a")
848        ));
849        assert!(matches!(
850            warehouse_scheme("S3://bucket/wh"),
851            WarehouseScheme::S3("s3")
852        ));
853    }
854
855    #[test]
856    fn warehouse_scheme_gcs() {
857        use super::{WarehouseScheme, warehouse_scheme};
858        assert!(matches!(
859            warehouse_scheme("gs://bucket/wh"),
860            WarehouseScheme::Gcs
861        ));
862    }
863
864    #[test]
865    fn warehouse_scheme_unsupported() {
866        use super::{WarehouseScheme, warehouse_scheme};
867        match warehouse_scheme("oss://bucket/wh") {
868            WarehouseScheme::Unsupported(s) => assert_eq!(s, "oss"),
869            other => panic!("expected Unsupported, got {other:?}"),
870        }
871        assert!(matches!(
872            warehouse_scheme("abfss://x/y"),
873            WarehouseScheme::Unsupported(_)
874        ));
875    }
876}