Skip to main content

faucet_cli/
registry.rs

1//! Feature-gated dispatch from a string `type` to a concrete connector.
2//!
3//! Every arm in this file is guarded by the matching `source-*` / `sink-*`
4//! Cargo feature so users can build a slim binary with just the connectors
5//! they need. The string keys here are the public contract of the CLI's
6//! `type:` field in YAML/JSON pipeline configs.
7
8use crate::auth_catalog::{self, AuthCatalog};
9use crate::error::{CliError, CliResult};
10use faucet_core::{Sink, Source};
11use serde::de::DeserializeOwned;
12use serde_json::Value;
13use std::collections::BTreeMap;
14use std::sync::{Arc, OnceLock};
15
16/// A factory that builds a [`Source`] trait object from its JSON/YAML config
17/// (`source.config`). This is the extension point third-party connectors plug
18/// into: a custom-CLI author registers one per connector `type:` they want
19/// their `faucet` binary to understand.
20///
21/// The factory is **synchronous** — a connector that needs to connect eagerly
22/// should do so lazily on first use (the pattern every built-in file/DB
23/// connector already follows). Return a typed error (wrap your own error in
24/// [`faucet_core::FaucetError::Custom`]) on invalid config.
25pub type SourceFactory = Arc<dyn Fn(Value) -> CliResult<Box<dyn Source>> + Send + Sync>;
26
27/// A factory that builds a [`Sink`] trait object from its `sink.config`. See
28/// [`SourceFactory`] for the contract.
29pub type SinkFactory = Arc<dyn Fn(Value) -> CliResult<Box<dyn Sink>> + Send + Sync>;
30
31/// A closure returning the JSON Schema for a custom connector's config. Powers
32/// `faucet schema source <name>` / `faucet schema sink <name>` for third-party
33/// connectors without instantiating one. Typically `|| schema_for!(MyConfig)`.
34pub type SchemaFn = Arc<dyn Fn() -> Value + Send + Sync>;
35
36struct SourceEntry {
37    factory: SourceFactory,
38    schema: SchemaFn,
39    description: &'static str,
40}
41
42struct SinkEntry {
43    factory: SinkFactory,
44    schema: SchemaFn,
45    description: &'static str,
46}
47
48/// A registry of connector factories keyed by their YAML `type:` string.
49///
50/// The built-in connectors are dispatched by a compile-time `match` (gated by
51/// the `source-*` / `sink-*` Cargo features); this registry holds **only the
52/// third-party connectors** a custom-CLI author registers on top. The two are
53/// merged transparently — [`build_source`] / [`source_schema`] /
54/// [`source_descriptions`] (and their sink counterparts) consult the registered
55/// customs first and fall back to the built-in `match`, so a custom connector is
56/// usable from `faucet.yaml` exactly like a built-in one, across every command
57/// (`run`, `validate`, `schema`, `list`, `preview`, `serve`, …).
58///
59/// # Example
60///
61/// ```no_run
62/// use faucet_cli::registry::PluginRegistry;
63/// # use faucet_core::{Source, async_trait, serde_json::Value};
64/// # use std::collections::HashMap;
65/// # struct MySource;
66/// # impl MySource { fn from_value(_: Value) -> Result<Self, faucet_core::FaucetError> { Ok(MySource) } }
67/// # #[async_trait]
68/// # impl Source for MySource {
69/// #     async fn fetch_with_context(&self, _: &HashMap<String, Value>) -> Result<Vec<Value>, faucet_core::FaucetError> { Ok(vec![]) }
70/// #     fn config_schema(&self) -> Value { Value::Null }
71/// # }
72/// let registry = PluginRegistry::with_builtins()
73///     .register_source("my", |cfg| Ok(Box::new(MySource::from_value(cfg)?)));
74/// faucet_cli::run_main(registry);
75/// ```
76#[derive(Default)]
77pub struct PluginRegistry {
78    sources: BTreeMap<&'static str, SourceEntry>,
79    sinks: BTreeMap<&'static str, SinkEntry>,
80    /// Registration errors (name collisions) stashed during the builder chain
81    /// and surfaced by [`PluginRegistry::install`] so `register_*` can stay
82    /// chainable.
83    errors: Vec<String>,
84}
85
86impl PluginRegistry {
87    /// An empty registry. Custom connectors registered on top of the built-ins.
88    pub fn new() -> Self {
89        Self::default()
90    }
91
92    /// A registry seeded with the built-in connectors. The built-ins are
93    /// dispatched by the compile-time `match`, so this is currently equivalent
94    /// to [`PluginRegistry::new`] — but it is the canonical constructor a
95    /// custom `main.rs` should call so that future changes to how built-ins are
96    /// registered are picked up automatically.
97    pub fn with_builtins() -> Self {
98        Self::default()
99    }
100
101    /// Register a custom source connector under `name` (its YAML `type:`).
102    /// Chainable. A collision with a built-in or a previously-registered custom
103    /// name is recorded and surfaced by [`install`](Self::install).
104    #[must_use]
105    pub fn register_source<F>(self, name: &str, factory: F) -> Self
106    where
107        F: Fn(Value) -> CliResult<Box<dyn Source>> + Send + Sync + 'static,
108    {
109        self.register_source_with(name, factory, || serde_json::json!({"type": "object"}), "")
110    }
111
112    /// Register a custom source with an explicit schema closure and one-line
113    /// description (shown by `faucet list` and `faucet schema source <name>`).
114    #[must_use]
115    pub fn register_source_with<F, S>(
116        mut self,
117        name: &str,
118        factory: F,
119        schema: S,
120        description: &str,
121    ) -> Self
122    where
123        F: Fn(Value) -> CliResult<Box<dyn Source>> + Send + Sync + 'static,
124        S: Fn() -> Value + Send + Sync + 'static,
125    {
126        let key = leak_str(name);
127        if builtin_source_descriptions().iter().any(|(n, _)| *n == key) {
128            self.errors.push(format!(
129                "cannot register source `{name}`: a built-in source already uses that name"
130            ));
131            return self;
132        }
133        if self.sources.contains_key(key) {
134            self.errors
135                .push(format!("source `{name}` is registered more than once"));
136            return self;
137        }
138        self.sources.insert(
139            key,
140            SourceEntry {
141                factory: Arc::new(factory),
142                schema: Arc::new(schema),
143                description: leak_str(description),
144            },
145        );
146        self
147    }
148
149    /// Register a custom sink connector under `name` (its YAML `type:`).
150    #[must_use]
151    pub fn register_sink<F>(self, name: &str, factory: F) -> Self
152    where
153        F: Fn(Value) -> CliResult<Box<dyn Sink>> + Send + Sync + 'static,
154    {
155        self.register_sink_with(name, factory, || serde_json::json!({"type": "object"}), "")
156    }
157
158    /// Register a custom sink with an explicit schema closure and description.
159    #[must_use]
160    pub fn register_sink_with<F, S>(
161        mut self,
162        name: &str,
163        factory: F,
164        schema: S,
165        description: &str,
166    ) -> Self
167    where
168        F: Fn(Value) -> CliResult<Box<dyn Sink>> + Send + Sync + 'static,
169        S: Fn() -> Value + Send + Sync + 'static,
170    {
171        let key = leak_str(name);
172        if builtin_sink_descriptions().iter().any(|(n, _)| *n == key) {
173            self.errors.push(format!(
174                "cannot register sink `{name}`: a built-in sink already uses that name"
175            ));
176            return self;
177        }
178        if self.sinks.contains_key(key) {
179            self.errors
180                .push(format!("sink `{name}` is registered more than once"));
181            return self;
182        }
183        self.sinks.insert(
184            key,
185            SinkEntry {
186                factory: Arc::new(factory),
187                schema: Arc::new(schema),
188                description: leak_str(description),
189            },
190        );
191        self
192    }
193
194    /// Install this registry as the process-global custom-connector registry.
195    /// Called once by [`crate::run_main`]. Returns an error if any `register_*`
196    /// call collided, or if a registry was already installed.
197    pub fn install(self) -> CliResult<()> {
198        if !self.errors.is_empty() {
199            return Err(CliError::Config(self.errors.join("; ")));
200        }
201        GLOBAL_REGISTRY
202            .set(self)
203            .map_err(|_| CliError::Config("connector registry already installed".to_owned()))
204    }
205
206    fn custom_source_descriptions(&self) -> Vec<(&'static str, &'static str)> {
207        self.sources
208            .iter()
209            .map(|(name, e)| {
210                (
211                    *name,
212                    if e.description.is_empty() {
213                        "custom source connector"
214                    } else {
215                        e.description
216                    },
217                )
218            })
219            .collect()
220    }
221
222    fn custom_sink_descriptions(&self) -> Vec<(&'static str, &'static str)> {
223        self.sinks
224            .iter()
225            .map(|(name, e)| {
226                (
227                    *name,
228                    if e.description.is_empty() {
229                        "custom sink connector"
230                    } else {
231                        e.description
232                    },
233                )
234            })
235            .collect()
236    }
237}
238
239/// Leak a string into a `&'static str`. Connector names/descriptions are
240/// registered once at process start and live for the whole run, so leaking a
241/// handful of small strings is the right tradeoff to keep the `&'static str`
242/// listing signatures (`source_kinds`, `source_descriptions`) unchanged.
243fn leak_str(s: &str) -> &'static str {
244    Box::leak(s.to_owned().into_boxed_str())
245}
246
247static GLOBAL_REGISTRY: OnceLock<PluginRegistry> = OnceLock::new();
248
249/// The process-global custom-connector registry, or an empty one if none was
250/// installed (the default `faucet` binary registers no customs).
251fn global() -> &'static PluginRegistry {
252    GLOBAL_REGISTRY.get_or_init(PluginRegistry::default)
253}
254
255/// Build a [`Source`] trait object from a `(kind, config)` pair. When the
256/// config carries `auth: { ref: <name> }`, the named provider is resolved from
257/// `auth` (the catalog) and injected into the connector.
258pub async fn build_source(
259    kind: &str,
260    config: Value,
261    auth: &AuthCatalog,
262    retry_policy: Option<&faucet_core::RetryPolicy>,
263) -> CliResult<Box<dyn Source>> {
264    // Third-party connectors registered via `PluginRegistry` win first. Names
265    // can never collide with a built-in (registration rejects that), so this is
266    // safe to check ahead of the built-in `match`. Custom factories receive the
267    // raw config and manage their own auth (the shared `auth:` catalog is not
268    // injected into custom connectors).
269    if let Some(entry) = global().sources.get(kind) {
270        return (entry.factory)(config);
271    }
272    let auth_ref = auth_catalog::auth_ref(&config);
273    match kind {
274        #[cfg(feature = "source-rest")]
275        "rest" => {
276            let cfg = decode::<faucet_source_rest::RestStreamConfig>("source", "rest", config)?;
277            let mut s = faucet_source_rest::RestStream::new(cfg)?;
278            if let Some(name) = &auth_ref {
279                s = s.with_auth_provider(auth_catalog::resolve(auth, name)?);
280            }
281            if let Some(rp) = retry_policy {
282                s = s.with_retry_policy(rp.clone());
283            }
284            Ok(Box::new(s))
285        }
286        #[cfg(feature = "source-graphql")]
287        "graphql" => {
288            let cfg =
289                decode::<faucet_source_graphql::GraphqlStreamConfig>("source", "graphql", config)?;
290            let mut s = faucet_source_graphql::GraphqlStream::new(cfg);
291            if let Some(name) = &auth_ref {
292                s = s.with_auth_provider(auth_catalog::resolve(auth, name)?);
293            }
294            if let Some(rp) = retry_policy {
295                s = s.with_retry_policy(rp.clone());
296            }
297            Ok(Box::new(s))
298        }
299        #[cfg(feature = "source-xml")]
300        "xml" => {
301            let cfg = decode::<faucet_source_xml::XmlStreamConfig>("source", "xml", config)?;
302            let mut s = faucet_source_xml::XmlStream::new(cfg);
303            if let Some(name) = &auth_ref {
304                s = s.with_auth_provider(auth_catalog::resolve(auth, name)?);
305            }
306            if let Some(rp) = retry_policy {
307                s = s.with_retry_policy(rp.clone());
308            }
309            Ok(Box::new(s))
310        }
311        #[cfg(feature = "source-grpc")]
312        "grpc" => {
313            let cfg = decode::<faucet_source_grpc::GrpcStreamConfig>("source", "grpc", config)?;
314            let mut s = faucet_source_grpc::GrpcStream::new(cfg)?;
315            if let Some(name) = &auth_ref {
316                s = s.with_auth_provider(auth_catalog::resolve(auth, name)?);
317            }
318            Ok(Box::new(s))
319        }
320        #[cfg(feature = "source-postgres")]
321        "postgres" => {
322            let cfg = decode::<faucet_source_postgres::PostgresSourceConfig>(
323                "source", "postgres", config,
324            )?;
325            Ok(Box::new(
326                faucet_source_postgres::PostgresSource::new(cfg).await?,
327            ))
328        }
329        #[cfg(feature = "source-postgres-cdc")]
330        "postgres-cdc" => {
331            let cfg = decode::<faucet_source_postgres_cdc::PostgresCdcSourceConfig>(
332                "source",
333                "postgres-cdc",
334                config,
335            )?;
336            Ok(Box::new(
337                faucet_source_postgres_cdc::PostgresCdcSource::new(cfg).await?,
338            ))
339        }
340        #[cfg(feature = "source-mysql")]
341        "mysql" => {
342            let cfg = decode::<faucet_source_mysql::MysqlSourceConfig>("source", "mysql", config)?;
343            Ok(Box::new(faucet_source_mysql::MysqlSource::new(cfg).await?))
344        }
345        #[cfg(feature = "source-mssql")]
346        "mssql" => {
347            let cfg = decode::<faucet_source_mssql::MssqlSourceConfig>("source", "mssql", config)?;
348            Ok(Box::new(faucet_source_mssql::MssqlSource::new(cfg).await?))
349        }
350        #[cfg(feature = "source-sqlite")]
351        "sqlite" => {
352            let cfg =
353                decode::<faucet_source_sqlite::SqliteSourceConfig>("source", "sqlite", config)?;
354            Ok(Box::new(
355                faucet_source_sqlite::SqliteSource::new(cfg).await?,
356            ))
357        }
358        #[cfg(feature = "source-s3")]
359        "s3" => {
360            let cfg = decode::<faucet_source_s3::S3SourceConfig>("source", "s3", config)?;
361            Ok(Box::new(faucet_source_s3::S3Source::new(cfg).await?))
362        }
363        #[cfg(feature = "source-mongodb")]
364        "mongodb" => {
365            let cfg =
366                decode::<faucet_source_mongodb::MongoSourceConfig>("source", "mongodb", config)?;
367            Ok(Box::new(
368                faucet_source_mongodb::MongoSource::new(cfg).await?,
369            ))
370        }
371        #[cfg(feature = "source-mongodb-cdc")]
372        "mongodb-cdc" => {
373            let cfg = decode::<faucet_source_mongodb_cdc::MongoCdcSourceConfig>(
374                "source",
375                "mongodb-cdc",
376                config,
377            )?;
378            Ok(Box::new(
379                faucet_source_mongodb_cdc::MongoCdcSource::new(cfg).await?,
380            ))
381        }
382        #[cfg(feature = "source-mysql-cdc")]
383        "mysql-cdc" => {
384            let cfg = decode::<faucet_source_mysql_cdc::MysqlCdcSourceConfig>(
385                "source",
386                "mysql-cdc",
387                config,
388            )?;
389            Ok(Box::new(
390                faucet_source_mysql_cdc::MysqlCdcSource::new(cfg).await?,
391            ))
392        }
393        #[cfg(feature = "source-redis")]
394        "redis" => {
395            let cfg = decode::<faucet_source_redis::RedisSourceConfig>("source", "redis", config)?;
396            Ok(Box::new(faucet_source_redis::RedisSource::new(cfg)?))
397        }
398        #[cfg(feature = "source-webhook")]
399        "webhook" => {
400            let cfg =
401                decode::<faucet_source_webhook::WebhookSourceConfig>("source", "webhook", config)?;
402            Ok(Box::new(faucet_source_webhook::WebhookSource::new(cfg)))
403        }
404        #[cfg(feature = "source-websocket")]
405        "websocket" => {
406            let cfg = decode::<faucet_source_websocket::WebsocketSourceConfig>(
407                "source",
408                "websocket",
409                config,
410            )?;
411            let mut s = faucet_source_websocket::WebsocketSource::new(cfg)?;
412            if let Some(name) = &auth_ref {
413                s = s.with_auth_provider(auth_catalog::resolve(auth, name)?);
414            }
415            Ok(Box::new(s))
416        }
417        #[cfg(feature = "source-csv")]
418        "csv" => {
419            let cfg = decode::<faucet_source_csv::CsvSourceConfig>("source", "csv", config)?;
420            Ok(Box::new(faucet_source_csv::CsvSource::new(cfg)))
421        }
422        #[cfg(feature = "source-singer")]
423        "singer" => {
424            let cfg =
425                decode::<faucet_source_singer::SingerSourceConfig>("source", "singer", config)?;
426            Ok(Box::new(faucet_source_singer::SingerSource::new(cfg)))
427        }
428        #[cfg(feature = "source-elasticsearch")]
429        "elasticsearch" => {
430            let cfg = decode::<faucet_source_elasticsearch::ElasticsearchSourceConfig>(
431                "source",
432                "elasticsearch",
433                config,
434            )?;
435            let mut s = faucet_source_elasticsearch::ElasticsearchSource::new(cfg)?;
436            if let Some(name) = &auth_ref {
437                s = s.with_auth_provider(auth_catalog::resolve(auth, name)?);
438            }
439            Ok(Box::new(s))
440        }
441        #[cfg(feature = "source-kafka")]
442        "kafka" => {
443            let cfg = decode::<faucet_source_kafka::KafkaSourceConfig>("source", "kafka", config)?;
444            Ok(Box::new(faucet_source_kafka::KafkaSource::new(cfg).await?))
445        }
446        #[cfg(feature = "source-kinesis")]
447        "kinesis" => {
448            let cfg =
449                decode::<faucet_source_kinesis::KinesisSourceConfig>("source", "kinesis", config)?;
450            Ok(Box::new(
451                faucet_source_kinesis::KinesisSource::new(cfg).await?,
452            ))
453        }
454        #[cfg(feature = "source-spanner")]
455        "spanner" => {
456            let cfg =
457                decode::<faucet_source_spanner::SpannerSourceConfig>("source", "spanner", config)?;
458            Ok(Box::new(
459                faucet_source_spanner::SpannerSource::new(cfg).await?,
460            ))
461        }
462        #[cfg(feature = "source-parquet")]
463        "parquet" => {
464            let cfg =
465                decode::<faucet_source_parquet::ParquetSourceConfig>("source", "parquet", config)?;
466            Ok(Box::new(
467                faucet_source_parquet::ParquetSource::new(cfg).await?,
468            ))
469        }
470        #[cfg(feature = "source-delta")]
471        "delta" => {
472            let cfg = decode::<faucet_source_delta::DeltaSourceConfig>("source", "delta", config)?;
473            Ok(Box::new(faucet_source_delta::DeltaSource::new(cfg).await?))
474        }
475        #[cfg(feature = "source-databricks")]
476        "databricks" => {
477            let cfg = decode::<faucet_source_databricks::DatabricksSourceConfig>(
478                "source",
479                "databricks",
480                config,
481            )?;
482            let mut s = faucet_source_databricks::DatabricksSource::new(cfg)?;
483            if let Some(name) = &auth_ref {
484                s = s.with_auth_provider(auth_catalog::resolve(auth, name)?);
485            }
486            Ok(Box::new(s))
487        }
488        #[cfg(feature = "source-gcs")]
489        "gcs" => {
490            let cfg = decode::<faucet_source_gcs::GcsSourceConfig>("source", "gcs", config)?;
491            Ok(Box::new(faucet_source_gcs::GcsSource::new(cfg).await?))
492        }
493        #[cfg(feature = "source-bigquery")]
494        "bigquery" => {
495            let cfg = decode::<faucet_source_bigquery::BigQuerySourceConfig>(
496                "source", "bigquery", config,
497            )?;
498            Ok(Box::new(
499                faucet_source_bigquery::BigQuerySource::new(cfg).await?,
500            ))
501        }
502        #[cfg(feature = "source-snowflake")]
503        "snowflake" => {
504            let cfg = decode::<faucet_source_snowflake::SnowflakeSourceConfig>(
505                "source",
506                "snowflake",
507                config,
508            )?;
509            let mut s = faucet_source_snowflake::SnowflakeSource::new(cfg)?;
510            if let Some(name) = &auth_ref {
511                s = s.with_auth_provider(auth_catalog::resolve(auth, name)?);
512            }
513            Ok(Box::new(s))
514        }
515        other => Err(unknown(other, "source", source_kinds())),
516    }
517}
518
519/// Build a [`Sink`] trait object from a `(kind, config)` pair. When the config
520/// carries `auth: { ref: <name> }`, the named provider is resolved from `auth`
521/// (the catalog) and injected into the connector.
522pub async fn build_sink(kind: &str, config: Value, auth: &AuthCatalog) -> CliResult<Box<dyn Sink>> {
523    if let Some(entry) = global().sinks.get(kind) {
524        return (entry.factory)(config);
525    }
526    let auth_ref = auth_catalog::auth_ref(&config);
527    match kind {
528        #[cfg(feature = "sink-bigquery")]
529        "bigquery" => {
530            let cfg =
531                decode::<faucet_sink_bigquery::BigQuerySinkConfig>("sink", "bigquery", config)?;
532            Ok(Box::new(
533                faucet_sink_bigquery::BigQuerySink::new(cfg).await?,
534            ))
535        }
536        #[cfg(feature = "sink-iceberg")]
537        "iceberg" => {
538            let cfg = decode::<faucet_sink_iceberg::IcebergSinkConfig>("sink", "iceberg", config)?;
539            Ok(Box::new(faucet_sink_iceberg::IcebergSink::new(cfg).await?))
540        }
541        #[cfg(feature = "sink-delta")]
542        "delta" => {
543            let cfg = decode::<faucet_sink_delta::DeltaSinkConfig>("sink", "delta", config)?;
544            Ok(Box::new(faucet_sink_delta::DeltaSink::new(cfg).await?))
545        }
546        #[cfg(feature = "sink-postgres")]
547        "postgres" => {
548            let cfg =
549                decode::<faucet_sink_postgres::PostgresSinkConfig>("sink", "postgres", config)?;
550            Ok(Box::new(
551                faucet_sink_postgres::PostgresSink::new(cfg).await?,
552            ))
553        }
554        #[cfg(feature = "sink-jsonl")]
555        "jsonl" => {
556            let cfg = decode::<faucet_sink_jsonl::JsonlSinkConfig>("sink", "jsonl", config)?;
557            Ok(Box::new(faucet_sink_jsonl::JsonlSink::new(cfg)))
558        }
559        #[cfg(feature = "sink-snowflake")]
560        "snowflake" => {
561            let cfg =
562                decode::<faucet_sink_snowflake::SnowflakeSinkConfig>("sink", "snowflake", config)?;
563            let mut s = faucet_sink_snowflake::SnowflakeSink::new(cfg)?;
564            if let Some(name) = &auth_ref {
565                s = s.with_auth_provider(auth_catalog::resolve(auth, name)?);
566            }
567            Ok(Box::new(s))
568        }
569        #[cfg(feature = "sink-mysql")]
570        "mysql" => {
571            let cfg = decode::<faucet_sink_mysql::MysqlSinkConfig>("sink", "mysql", config)?;
572            Ok(Box::new(faucet_sink_mysql::MysqlSink::new(cfg).await?))
573        }
574        #[cfg(feature = "sink-mssql")]
575        "mssql" => {
576            let cfg = decode::<faucet_sink_mssql::MssqlSinkConfig>("sink", "mssql", config)?;
577            Ok(Box::new(faucet_sink_mssql::MssqlSink::new(cfg).await?))
578        }
579        #[cfg(feature = "sink-sqlite")]
580        "sqlite" => {
581            let cfg = decode::<faucet_sink_sqlite::SqliteSinkConfig>("sink", "sqlite", config)?;
582            Ok(Box::new(faucet_sink_sqlite::SqliteSink::new(cfg).await?))
583        }
584        #[cfg(feature = "sink-s3")]
585        "s3" => {
586            let cfg = decode::<faucet_sink_s3::S3SinkConfig>("sink", "s3", config)?;
587            Ok(Box::new(faucet_sink_s3::S3Sink::new(cfg).await?))
588        }
589        #[cfg(feature = "sink-mongodb")]
590        "mongodb" => {
591            let cfg = decode::<faucet_sink_mongodb::MongoSinkConfig>("sink", "mongodb", config)?;
592            Ok(Box::new(faucet_sink_mongodb::MongoSink::new(cfg).await?))
593        }
594        #[cfg(feature = "sink-redis")]
595        "redis" => {
596            let cfg = decode::<faucet_sink_redis::RedisSinkConfig>("sink", "redis", config)?;
597            Ok(Box::new(faucet_sink_redis::RedisSink::new(cfg).await?))
598        }
599        #[cfg(feature = "sink-csv")]
600        "csv" => {
601            let cfg = decode::<faucet_sink_csv::CsvSinkConfig>("sink", "csv", config)?;
602            Ok(Box::new(faucet_sink_csv::CsvSink::new(cfg)))
603        }
604        #[cfg(feature = "sink-elasticsearch")]
605        "elasticsearch" => {
606            let cfg = decode::<faucet_sink_elasticsearch::ElasticsearchSinkConfig>(
607                "sink",
608                "elasticsearch",
609                config,
610            )?;
611            let mut s = faucet_sink_elasticsearch::ElasticsearchSink::new(cfg)?;
612            if let Some(name) = &auth_ref {
613                s = s.with_auth_provider(auth_catalog::resolve(auth, name)?);
614            }
615            Ok(Box::new(s))
616        }
617        #[cfg(feature = "sink-kafka")]
618        "kafka" => {
619            let cfg = decode::<faucet_sink_kafka::KafkaSinkConfig>("sink", "kafka", config)?;
620            Ok(Box::new(faucet_sink_kafka::KafkaSink::new(cfg).await?))
621        }
622        #[cfg(feature = "sink-kinesis")]
623        "kinesis" => {
624            let cfg = decode::<faucet_sink_kinesis::KinesisSinkConfig>("sink", "kinesis", config)?;
625            Ok(Box::new(faucet_sink_kinesis::KinesisSink::new(cfg).await?))
626        }
627        #[cfg(feature = "sink-spanner")]
628        "spanner" => {
629            let cfg = decode::<faucet_sink_spanner::SpannerSinkConfig>("sink", "spanner", config)?;
630            Ok(Box::new(faucet_sink_spanner::SpannerSink::new(cfg).await?))
631        }
632        #[cfg(feature = "sink-http")]
633        "http" => {
634            let cfg = decode::<faucet_sink_http::HttpSinkConfig>("sink", "http", config)?;
635            let mut s = faucet_sink_http::HttpSink::new(cfg);
636            if let Some(name) = &auth_ref {
637                s = s.with_auth_provider(auth_catalog::resolve(auth, name)?);
638            }
639            Ok(Box::new(s))
640        }
641        #[cfg(feature = "sink-stdout")]
642        "stdout" => {
643            let cfg = decode::<faucet_sink_stdout::StdoutSinkConfig>("sink", "stdout", config)?;
644            Ok(Box::new(faucet_sink_stdout::StdoutSink::new(cfg)))
645        }
646        #[cfg(feature = "sink-parquet")]
647        "parquet" => {
648            let cfg = decode::<faucet_sink_parquet::ParquetSinkConfig>("sink", "parquet", config)?;
649            Ok(Box::new(faucet_sink_parquet::ParquetSink::new(cfg).await?))
650        }
651        #[cfg(feature = "sink-gcs")]
652        "gcs" => {
653            let cfg = decode::<faucet_sink_gcs::GcsSinkConfig>("sink", "gcs", config)?;
654            Ok(Box::new(faucet_sink_gcs::GcsSink::new(cfg).await?))
655        }
656        other => Err(unknown(other, "sink", sink_kinds())),
657    }
658}
659
660/// Source connector kinds that deterministically replay (exactly-once-capable).
661/// Mirrors `Source::supports_exactly_once` overrides — keep in sync when a new
662/// source opts in. The single source of truth for both the boolean gate and the
663/// human-readable list shown in error messages (F44). `kafka` qualifies because
664/// partitions are immutable logs and every page carries a complete offsets
665/// bookmark (#291).
666pub const EXACTLY_ONCE_SOURCE_KINDS: &[&str] =
667    &["postgres-cdc", "mysql-cdc", "mongodb-cdc", "kafka"];
668
669/// Sink connector kinds that can durably commit a token atomically with data.
670/// Mirrors `Sink::supports_idempotent_writes` overrides — keep in sync when a
671/// new sink opts in. Single source of truth for the gate + the error-message
672/// list (F44).
673pub const IDEMPOTENT_SINK_KINDS: &[&str] = &[
674    "sqlite",
675    "postgres",
676    "mysql",
677    "mssql",
678    "iceberg",
679    "bigquery",
680    "kafka",
681    "snowflake",
682    "redis",
683    "mongodb",
684    "spanner",
685];
686
687/// Sink kinds that can apply additive/widening DDL via `Sink::evolve_schema`.
688/// Mirrors each sink's `supports_schema_evolution()` override. Iceberg is
689/// intentionally excluded — iceberg-rust 0.9.1 exposes no schema-evolution API (#255).
690pub const SCHEMA_EVOLUTION_SINK_KINDS: &[&str] = &[
691    "postgres",
692    "mysql",
693    "mssql",
694    "sqlite",
695    "bigquery",
696    "elasticsearch",
697    "spanner",
698];
699
700/// Sink kinds that support `write_mode: upsert|delete`. Mirrors each sink's
701/// `Sink::supported_write_modes()` override. Single source of truth for the gate
702/// + the error-message list (F44).
703pub const UPSERT_SINK_KINDS: &[&str] = &[
704    "postgres",
705    "sqlite",
706    "mysql",
707    "mssql",
708    "mongodb",
709    "elasticsearch",
710    "bigquery",
711    "spanner",
712];
713
714/// Source kinds that implement live dataset discovery (`Source::discover`,
715/// issue #211) — mirrors the discoverable-source list in the connector docs.
716/// Single source of truth for the conformance scorecard (#330).
717pub const DISCOVER_SOURCE_KINDS: &[&str] = &[
718    "postgres",
719    "mysql",
720    "mssql",
721    "sqlite",
722    "mongodb",
723    "elasticsearch",
724    "bigquery",
725    "snowflake",
726    "spanner",
727    "s3",
728    "gcs",
729];
730
731/// Whether a source kind supports `faucet discover` (dataset introspection).
732pub fn source_supports_discover(kind: &str) -> bool {
733    DISCOVER_SOURCE_KINDS.contains(&kind)
734}
735
736/// The typed replay capability a source kind advertises
737/// (`Source::replay_guarantee`, issue #292). Derived from
738/// [`EXACTLY_ONCE_SOURCE_KINDS`] — the kind table stays the single source of
739/// truth; this is the typed view the delivery-guarantee derivation consumes.
740pub fn source_replay_guarantee(kind: &str) -> faucet_core::ReplayGuarantee {
741    if EXACTLY_ONCE_SOURCE_KINDS.contains(&kind) {
742        faucet_core::ReplayGuarantee::Deterministic
743    } else {
744        faucet_core::ReplayGuarantee::NonDeterministic
745    }
746}
747
748/// The strongest delivery guarantee a sink kind can uphold
749/// (`Sink::sink_guarantee`, issue #292). Derived from
750/// [`IDEMPOTENT_SINK_KINDS`] / [`UPSERT_SINK_KINDS`].
751pub fn sink_guarantee(kind: &str) -> faucet_core::SinkGuarantee {
752    if IDEMPOTENT_SINK_KINDS.contains(&kind) {
753        faucet_core::SinkGuarantee::AtomicWatermark
754    } else if UPSERT_SINK_KINDS.contains(&kind) {
755        faucet_core::SinkGuarantee::KeyedUpsert
756    } else {
757        faucet_core::SinkGuarantee::AtLeastOnce
758    }
759}
760
761/// See [`EXACTLY_ONCE_SOURCE_KINDS`].
762pub fn source_supports_exactly_once(kind: &str) -> bool {
763    source_replay_guarantee(kind) == faucet_core::ReplayGuarantee::Deterministic
764}
765
766/// See [`IDEMPOTENT_SINK_KINDS`].
767pub fn sink_supports_idempotent_writes(kind: &str) -> bool {
768    sink_guarantee(kind) == faucet_core::SinkGuarantee::AtomicWatermark
769}
770
771/// See [`SCHEMA_EVOLUTION_SINK_KINDS`].
772pub fn sink_supports_schema_evolution(kind: &str) -> bool {
773    SCHEMA_EVOLUTION_SINK_KINDS.contains(&kind)
774}
775
776/// Write modes each sink kind supports. Kept in sync with each sink's
777/// `Sink::supported_write_modes()` override via [`UPSERT_SINK_KINDS`].
778pub fn sink_supported_write_modes(kind: &str) -> &'static [faucet_core::WriteMode] {
779    use faucet_core::WriteMode;
780    if UPSERT_SINK_KINDS.contains(&kind) {
781        &[WriteMode::Append, WriteMode::Upsert, WriteMode::Delete]
782    } else {
783        &[WriteMode::Append]
784    }
785}
786
787/// Return the JSON Schema for the named source's config struct.
788pub fn source_schema(kind: &str) -> CliResult<Value> {
789    if let Some(entry) = global().sources.get(kind) {
790        return Ok((entry.schema)());
791    }
792    match kind {
793        #[cfg(feature = "source-rest")]
794        "rest" => Ok(schema::<faucet_source_rest::RestStreamConfig>()),
795        #[cfg(feature = "source-graphql")]
796        "graphql" => Ok(schema::<faucet_source_graphql::GraphqlStreamConfig>()),
797        #[cfg(feature = "source-xml")]
798        "xml" => Ok(schema::<faucet_source_xml::XmlStreamConfig>()),
799        #[cfg(feature = "source-grpc")]
800        "grpc" => Ok(schema::<faucet_source_grpc::GrpcStreamConfig>()),
801        #[cfg(feature = "source-postgres")]
802        "postgres" => Ok(schema::<faucet_source_postgres::PostgresSourceConfig>()),
803        #[cfg(feature = "source-postgres-cdc")]
804        "postgres-cdc" => Ok(schema::<faucet_source_postgres_cdc::PostgresCdcSourceConfig>()),
805        #[cfg(feature = "source-mysql")]
806        "mysql" => Ok(schema::<faucet_source_mysql::MysqlSourceConfig>()),
807        #[cfg(feature = "source-mssql")]
808        "mssql" => Ok(schema::<faucet_source_mssql::MssqlSourceConfig>()),
809        #[cfg(feature = "source-sqlite")]
810        "sqlite" => Ok(schema::<faucet_source_sqlite::SqliteSourceConfig>()),
811        #[cfg(feature = "source-s3")]
812        "s3" => Ok(schema::<faucet_source_s3::S3SourceConfig>()),
813        #[cfg(feature = "source-mongodb")]
814        "mongodb" => Ok(schema::<faucet_source_mongodb::MongoSourceConfig>()),
815        #[cfg(feature = "source-mongodb-cdc")]
816        "mongodb-cdc" => Ok(schema::<faucet_source_mongodb_cdc::MongoCdcSourceConfig>()),
817        #[cfg(feature = "source-mysql-cdc")]
818        "mysql-cdc" => Ok(schema::<faucet_source_mysql_cdc::MysqlCdcSourceConfig>()),
819        #[cfg(feature = "source-redis")]
820        "redis" => Ok(schema::<faucet_source_redis::RedisSourceConfig>()),
821        #[cfg(feature = "source-webhook")]
822        "webhook" => Ok(schema::<faucet_source_webhook::WebhookSourceConfig>()),
823        #[cfg(feature = "source-websocket")]
824        "websocket" => Ok(schema::<faucet_source_websocket::WebsocketSourceConfig>()),
825        #[cfg(feature = "source-csv")]
826        "csv" => Ok(schema::<faucet_source_csv::CsvSourceConfig>()),
827        #[cfg(feature = "source-singer")]
828        "singer" => Ok(schema::<faucet_source_singer::SingerSourceConfig>()),
829        #[cfg(feature = "source-elasticsearch")]
830        "elasticsearch" => Ok(schema::<
831            faucet_source_elasticsearch::ElasticsearchSourceConfig,
832        >()),
833        #[cfg(feature = "source-kafka")]
834        "kafka" => Ok(schema::<faucet_source_kafka::KafkaSourceConfig>()),
835        #[cfg(feature = "source-kinesis")]
836        "kinesis" => Ok(schema::<faucet_source_kinesis::KinesisSourceConfig>()),
837        #[cfg(feature = "source-spanner")]
838        "spanner" => Ok(schema::<faucet_source_spanner::SpannerSourceConfig>()),
839        #[cfg(feature = "source-parquet")]
840        "parquet" => Ok(schema::<faucet_source_parquet::ParquetSourceConfig>()),
841        #[cfg(feature = "source-delta")]
842        "delta" => Ok(schema::<faucet_source_delta::DeltaSourceConfig>()),
843        #[cfg(feature = "source-databricks")]
844        "databricks" => Ok(schema::<faucet_source_databricks::DatabricksSourceConfig>()),
845        #[cfg(feature = "source-gcs")]
846        "gcs" => Ok(schema::<faucet_source_gcs::GcsSourceConfig>()),
847        #[cfg(feature = "source-bigquery")]
848        "bigquery" => Ok(schema::<faucet_source_bigquery::BigQuerySourceConfig>()),
849        #[cfg(feature = "source-snowflake")]
850        "snowflake" => Ok(schema::<faucet_source_snowflake::SnowflakeSourceConfig>()),
851        other => Err(unknown(other, "source", source_kinds())),
852    }
853}
854
855/// Check if a source kind is registered (not unknown or disabled by feature gate).
856pub fn source_exists(kind: &str) -> bool {
857    source_schema(kind).is_ok()
858}
859
860/// Check if a sink kind is registered (not unknown or disabled by feature gate).
861pub fn sink_exists(kind: &str) -> bool {
862    sink_schema(kind).is_ok()
863}
864
865/// Return the JSON Schema for the named sink's config struct.
866pub fn sink_schema(kind: &str) -> CliResult<Value> {
867    if let Some(entry) = global().sinks.get(kind) {
868        return Ok((entry.schema)());
869    }
870    match kind {
871        #[cfg(feature = "sink-bigquery")]
872        "bigquery" => Ok(schema::<faucet_sink_bigquery::BigQuerySinkConfig>()),
873        #[cfg(feature = "sink-iceberg")]
874        "iceberg" => Ok(schema::<faucet_sink_iceberg::IcebergSinkConfig>()),
875        #[cfg(feature = "sink-delta")]
876        "delta" => Ok(schema::<faucet_sink_delta::DeltaSinkConfig>()),
877        #[cfg(feature = "sink-postgres")]
878        "postgres" => Ok(schema::<faucet_sink_postgres::PostgresSinkConfig>()),
879        #[cfg(feature = "sink-jsonl")]
880        "jsonl" => Ok(schema::<faucet_sink_jsonl::JsonlSinkConfig>()),
881        #[cfg(feature = "sink-snowflake")]
882        "snowflake" => Ok(schema::<faucet_sink_snowflake::SnowflakeSinkConfig>()),
883        #[cfg(feature = "sink-mysql")]
884        "mysql" => Ok(schema::<faucet_sink_mysql::MysqlSinkConfig>()),
885        #[cfg(feature = "sink-mssql")]
886        "mssql" => Ok(schema::<faucet_sink_mssql::MssqlSinkConfig>()),
887        #[cfg(feature = "sink-sqlite")]
888        "sqlite" => Ok(schema::<faucet_sink_sqlite::SqliteSinkConfig>()),
889        #[cfg(feature = "sink-s3")]
890        "s3" => Ok(schema::<faucet_sink_s3::S3SinkConfig>()),
891        #[cfg(feature = "sink-mongodb")]
892        "mongodb" => Ok(schema::<faucet_sink_mongodb::MongoSinkConfig>()),
893        #[cfg(feature = "sink-redis")]
894        "redis" => Ok(schema::<faucet_sink_redis::RedisSinkConfig>()),
895        #[cfg(feature = "sink-csv")]
896        "csv" => Ok(schema::<faucet_sink_csv::CsvSinkConfig>()),
897        #[cfg(feature = "sink-elasticsearch")]
898        "elasticsearch" => Ok(schema::<faucet_sink_elasticsearch::ElasticsearchSinkConfig>()),
899        #[cfg(feature = "sink-kafka")]
900        "kafka" => Ok(schema::<faucet_sink_kafka::KafkaSinkConfig>()),
901        #[cfg(feature = "sink-kinesis")]
902        "kinesis" => Ok(schema::<faucet_sink_kinesis::KinesisSinkConfig>()),
903        #[cfg(feature = "sink-spanner")]
904        "spanner" => Ok(schema::<faucet_sink_spanner::SpannerSinkConfig>()),
905        #[cfg(feature = "sink-http")]
906        "http" => Ok(schema::<faucet_sink_http::HttpSinkConfig>()),
907        #[cfg(feature = "sink-stdout")]
908        "stdout" => Ok(schema::<faucet_sink_stdout::StdoutSinkConfig>()),
909        #[cfg(feature = "sink-parquet")]
910        "parquet" => Ok(schema::<faucet_sink_parquet::ParquetSinkConfig>()),
911        #[cfg(feature = "sink-gcs")]
912        "gcs" => Ok(schema::<faucet_sink_gcs::GcsSinkConfig>()),
913        other => Err(unknown(other, "sink", sink_kinds())),
914    }
915}
916
917/// One-line summary of every source connector — the compiled-in built-ins plus
918/// any third-party connectors registered via [`PluginRegistry`]. Used by
919/// `faucet list`.
920pub fn source_descriptions() -> Vec<(&'static str, &'static str)> {
921    let mut v = builtin_source_descriptions();
922    v.extend(global().custom_source_descriptions());
923    v
924}
925
926/// One-line summary of every compiled-in built-in source connector (no customs).
927#[allow(clippy::vec_init_then_push)]
928fn builtin_source_descriptions() -> Vec<(&'static str, &'static str)> {
929    let mut v: Vec<(&'static str, &'static str)> = Vec::new();
930    #[cfg(feature = "source-rest")]
931    v.push(("rest", "REST API source with pagination, auth, transforms"));
932    #[cfg(feature = "source-graphql")]
933    v.push(("graphql", "GraphQL API source with cursor pagination"));
934    #[cfg(feature = "source-xml")]
935    v.push(("xml", "XML / SOAP API source with XML→JSON conversion"));
936    #[cfg(feature = "source-grpc")]
937    v.push(("grpc", "gRPC source with dynamic protobuf"));
938    #[cfg(feature = "source-postgres")]
939    v.push(("postgres", "PostgreSQL query source"));
940    #[cfg(feature = "source-postgres-cdc")]
941    v.push((
942        "postgres-cdc",
943        "PostgreSQL CDC source (logical replication)",
944    ));
945    #[cfg(feature = "source-mysql")]
946    v.push(("mysql", "MySQL query source"));
947    #[cfg(feature = "source-mssql")]
948    v.push(("mssql", "Microsoft SQL Server query source"));
949    #[cfg(feature = "source-sqlite")]
950    v.push(("sqlite", "SQLite query source"));
951    #[cfg(feature = "source-s3")]
952    v.push(("s3", "AWS S3 object source"));
953    #[cfg(feature = "source-mongodb")]
954    v.push(("mongodb", "MongoDB query source"));
955    #[cfg(feature = "source-mongodb-cdc")]
956    v.push(("mongodb-cdc", "MongoDB CDC source (Change Streams)"));
957    #[cfg(feature = "source-mysql-cdc")]
958    v.push(("mysql-cdc", "MySQL CDC source (binlog replication)"));
959    #[cfg(feature = "source-redis")]
960    v.push(("redis", "Redis (streams, lists, keys) source"));
961    #[cfg(feature = "source-webhook")]
962    v.push(("webhook", "Webhook HTTP receiver source"));
963    #[cfg(feature = "source-websocket")]
964    v.push((
965        "websocket",
966        "WebSocket streaming source — connects, subscribes, streams each message as a record",
967    ));
968    #[cfg(feature = "source-csv")]
969    v.push(("csv", "CSV file source"));
970    #[cfg(feature = "source-singer")]
971    v.push((
972        "singer",
973        "Singer tap bridge (runs an external Singer tap; single-stream v0, Tier-2/experimental)",
974    ));
975    #[cfg(feature = "source-elasticsearch")]
976    v.push(("elasticsearch", "Elasticsearch search / scroll source"));
977    #[cfg(feature = "source-kafka")]
978    v.push(("kafka", "Apache Kafka consumer (rdkafka). Subscribes to topics and drains messages with idle/max-messages termination."));
979    #[cfg(feature = "source-kinesis")]
980    v.push(("kinesis", "AWS Kinesis Data Streams consumer. Per-shard workers with resumable sequence-number checkpoints and idle/max-messages termination."));
981    #[cfg(feature = "source-spanner")]
982    v.push(("spanner", "Google Cloud Spanner query source. Streaming SQL reads with incremental replication bookmarks, stale reads, and PK-range sharding."));
983    #[cfg(feature = "source-parquet")]
984    v.push(("parquet", "Apache Parquet file source (local path, glob, or S3). Streams record batches via the Arrow async reader."));
985    #[cfg(feature = "source-delta")]
986    v.push(("delta", "Apache Delta Lake source (local FS or S3/Azure/GCS). Streams active data files with time travel and projection pushdown."));
987    #[cfg(feature = "source-databricks")]
988    v.push(("databricks", "Databricks SQL query source (Statement Execution API). Streams typed query results with chunk pagination and incremental replication."));
989    #[cfg(feature = "source-gcs")]
990    v.push((
991        "gcs",
992        "Google Cloud Storage source — JSONL, JSON array, or raw text",
993    ));
994    #[cfg(feature = "source-bigquery")]
995    v.push((
996        "bigquery",
997        "Google BigQuery query source (jobs.query + jobs.getQueryResults)",
998    ));
999    #[cfg(feature = "source-snowflake")]
1000    v.push((
1001        "snowflake",
1002        "Snowflake query source (SQL REST API with partition paging)",
1003    ));
1004    v
1005}
1006
1007/// One-line summary of every sink connector — the compiled-in built-ins plus
1008/// any third-party connectors registered via [`PluginRegistry`]. Used by
1009/// `faucet list`.
1010pub fn sink_descriptions() -> Vec<(&'static str, &'static str)> {
1011    let mut v = builtin_sink_descriptions();
1012    v.extend(global().custom_sink_descriptions());
1013    v
1014}
1015
1016/// One-line summary of every compiled-in built-in sink connector (no customs).
1017#[allow(clippy::vec_init_then_push)]
1018fn builtin_sink_descriptions() -> Vec<(&'static str, &'static str)> {
1019    let mut v: Vec<(&'static str, &'static str)> = Vec::new();
1020    #[cfg(feature = "sink-bigquery")]
1021    v.push(("bigquery", "Google BigQuery streaming-insert sink"));
1022    #[cfg(feature = "sink-iceberg")]
1023    v.push((
1024        "iceberg",
1025        "Apache Iceberg sink (append, REST/Glue/SQL/HMS catalogs)",
1026    ));
1027    #[cfg(feature = "sink-postgres")]
1028    v.push(("postgres", "PostgreSQL sink (JSONB or auto-mapped columns)"));
1029    #[cfg(feature = "sink-jsonl")]
1030    v.push(("jsonl", "JSON Lines file sink"));
1031    #[cfg(feature = "sink-snowflake")]
1032    v.push(("snowflake", "Snowflake SQL REST API sink"));
1033    #[cfg(feature = "sink-mysql")]
1034    v.push(("mysql", "MySQL sink"));
1035    #[cfg(feature = "sink-mssql")]
1036    v.push((
1037        "mssql",
1038        "Microsoft SQL Server sink (auto-mapped columns or JSON column)",
1039    ));
1040    #[cfg(feature = "sink-sqlite")]
1041    v.push(("sqlite", "SQLite sink"));
1042    #[cfg(feature = "sink-s3")]
1043    v.push(("s3", "AWS S3 object sink"));
1044    #[cfg(feature = "sink-mongodb")]
1045    v.push(("mongodb", "MongoDB insert sink"));
1046    #[cfg(feature = "sink-redis")]
1047    v.push(("redis", "Redis (streams, lists, key-value) sink"));
1048    #[cfg(feature = "sink-csv")]
1049    v.push(("csv", "CSV file sink"));
1050    #[cfg(feature = "sink-elasticsearch")]
1051    v.push(("elasticsearch", "Elasticsearch bulk index sink"));
1052    #[cfg(feature = "sink-kafka")]
1053    v.push(("kafka", "Apache Kafka producer (rdkafka). FuturesUnordered batched sends with QueueFull retry; supports fixed or per-record topic routing."));
1054    #[cfg(feature = "sink-kinesis")]
1055    v.push(("kinesis", "AWS Kinesis Data Streams producer. Batched PutRecords with partition-key routing and partial-failure retry (DLQ-routable)."));
1056    #[cfg(feature = "sink-spanner")]
1057    v.push(("spanner", "Google Cloud Spanner sink. Batched mutations with upsert/delete write modes, exactly-once commit tokens, and schema evolution."));
1058    #[cfg(feature = "sink-http")]
1059    v.push(("http", "HTTP POST sink (individual or array batch)"));
1060    #[cfg(feature = "sink-stdout")]
1061    v.push(("stdout", "Stdout / stderr sink (JSON Lines, pretty, TSV)"));
1062    #[cfg(feature = "sink-parquet")]
1063    v.push(("parquet", "Apache Parquet file sink (local path or S3). Schema-inferred, configurable compression, row/byte rollover."));
1064    #[cfg(feature = "sink-delta")]
1065    v.push(("delta", "Apache Delta Lake sink (local FS or S3/Azure/GCS). Append-only, schema-inferred table creation, one commit per flush."));
1066    #[cfg(feature = "sink-gcs")]
1067    v.push(("gcs", "Google Cloud Storage sink — JSONL files"));
1068    v
1069}
1070
1071/// Names of every compiled-in source connector.
1072pub fn source_kinds() -> Vec<&'static str> {
1073    source_descriptions().into_iter().map(|(k, _)| k).collect()
1074}
1075
1076/// Names of every compiled-in sink connector.
1077pub fn sink_kinds() -> Vec<&'static str> {
1078    sink_descriptions().into_iter().map(|(k, _)| k).collect()
1079}
1080
1081fn decode<T: DeserializeOwned>(kind: &'static str, name: &str, config: Value) -> CliResult<T> {
1082    serde_json::from_value(config).map_err(|e| CliError::InvalidConnectorConfig {
1083        kind,
1084        name: name.to_owned(),
1085        message: scrub_config_error(&e.to_string()),
1086    })
1087}
1088
1089/// Sanitise a serde deserialization error before it reaches stderr/logs.
1090///
1091/// serde_json's `invalid type:` errors echo the offending value as a
1092/// double-quoted literal — which can be a secret injected via
1093/// `${secret:...}` / `${env:...}`. Replace every double-quoted run with a
1094/// placeholder (field/type names use backticks and are preserved for
1095/// diagnostics) and cap the length so a huge value can't flood the log
1096/// (#78/#38). Note: `${secret:}` is currently an `${env:}` alias with no
1097/// at-rest redaction — this only scrubs error *output*.
1098fn scrub_config_error(msg: &str) -> String {
1099    const MAX_CHARS: usize = 200;
1100    let mut out = String::with_capacity(msg.len());
1101    let mut in_quote = false;
1102    for c in msg.chars() {
1103        if c == '"' {
1104            if !in_quote {
1105                out.push_str("\"<redacted>\"");
1106            }
1107            in_quote = !in_quote;
1108            continue;
1109        }
1110        if !in_quote {
1111            out.push(c);
1112        }
1113    }
1114    if out.chars().count() > MAX_CHARS {
1115        let truncated: String = out.chars().take(MAX_CHARS).collect();
1116        return format!("{truncated}…");
1117    }
1118    out
1119}
1120
1121fn schema<T: faucet_core::JsonSchema>() -> Value {
1122    serde_json::to_value(faucet_core::schema_for!(T))
1123        .unwrap_or_else(|_| serde_json::json!({"type": "object"}))
1124}
1125
1126fn unknown(name: &str, kind: &'static str, available: Vec<&'static str>) -> CliError {
1127    CliError::UnknownConnector {
1128        kind,
1129        name: name.to_owned(),
1130        available: if available.is_empty() {
1131            "(none — rebuild faucet-cli with the relevant feature enabled)".to_owned()
1132        } else {
1133            available.join(", ")
1134        },
1135    }
1136}
1137
1138#[cfg(test)]
1139mod tests {
1140    use super::*;
1141
1142    // A trivial in-memory source used to exercise custom registration without
1143    // any I/O.
1144    #[derive(Clone)]
1145    struct DummySource;
1146    #[faucet_core::async_trait]
1147    impl Source for DummySource {
1148        async fn fetch_with_context(
1149            &self,
1150            _ctx: &std::collections::HashMap<String, Value>,
1151        ) -> Result<Vec<Value>, faucet_core::FaucetError> {
1152            Ok(vec![serde_json::json!({"ok": true})])
1153        }
1154        fn config_schema(&self) -> Value {
1155            serde_json::json!({"type": "object"})
1156        }
1157    }
1158
1159    #[test]
1160    fn register_source_rejects_builtin_collision() {
1161        // `csv` is a built-in whenever that feature is on; use a name we know is
1162        // built-in under --all-features to assert the collision guard fires.
1163        let reg = PluginRegistry::with_builtins()
1164            .register_source("csv", |_| Ok(Box::new(DummySource) as Box<dyn Source>));
1165        // install() surfaces the stashed error WITHOUT touching the global
1166        // (errors are checked before the OnceLock is set), so this is race-free.
1167        let err = reg
1168            .install()
1169            .expect_err("built-in collision must be rejected");
1170        match err {
1171            CliError::Config(msg) => assert!(msg.contains("built-in source"), "{msg}"),
1172            other => panic!("expected Config error, got {other:?}"),
1173        }
1174    }
1175
1176    #[test]
1177    fn register_source_rejects_duplicate() {
1178        let reg = PluginRegistry::new()
1179            .register_source("dup", |_| Ok(Box::new(DummySource) as Box<dyn Source>))
1180            .register_source("dup", |_| Ok(Box::new(DummySource) as Box<dyn Source>));
1181        let err = reg
1182            .install()
1183            .expect_err("duplicate registration must be rejected");
1184        match err {
1185            CliError::Config(msg) => assert!(msg.contains("more than once"), "{msg}"),
1186            other => panic!("expected Config error, got {other:?}"),
1187        }
1188    }
1189
1190    #[test]
1191    fn register_sink_rejects_duplicate() {
1192        // Build a registry with a duplicate sink and confirm the error is
1193        // stashed; we inspect it via the private field rather than install()
1194        // so no global state is touched even indirectly.
1195        let reg = PluginRegistry::new()
1196            .register_sink("dupsink", |_| Err(CliError::Config("unused".into())))
1197            .register_sink("dupsink", |_| Err(CliError::Config("unused".into())));
1198        assert!(
1199            reg.errors.iter().any(|e| e.contains("more than once")),
1200            "{:?}",
1201            reg.errors
1202        );
1203    }
1204
1205    #[test]
1206    fn custom_descriptions_use_default_when_blank() {
1207        let reg = PluginRegistry::new()
1208            .register_source("acme", |_| Ok(Box::new(DummySource) as Box<dyn Source>));
1209        let descs = reg.custom_source_descriptions();
1210        assert_eq!(descs.len(), 1);
1211        assert_eq!(descs[0].0, "acme");
1212        assert_eq!(descs[0].1, "custom source connector");
1213    }
1214
1215    #[test]
1216    fn custom_descriptions_carry_explicit_summary() {
1217        let reg = PluginRegistry::new().register_source_with(
1218            "acme",
1219            |_| Ok(Box::new(DummySource) as Box<dyn Source>),
1220            || serde_json::json!({"type": "object", "title": "acme"}),
1221            "Acme widget source",
1222        );
1223        let descs = reg.custom_source_descriptions();
1224        assert_eq!(descs[0], ("acme", "Acme widget source"));
1225        // The schema closure is what `faucet schema source acme` would print.
1226        assert_eq!(
1227            (reg.sources.get("acme").unwrap().schema)()["title"],
1228            serde_json::json!("acme")
1229        );
1230    }
1231
1232    #[test]
1233    fn capability_constants_match_their_predicates() {
1234        // F44: the human-readable lists in error messages derive from these
1235        // constants, which must stay in lockstep with the boolean gates. In
1236        // particular the idempotent-sink list must include bigquery AND kafka,
1237        // and the upsert-sink list must include bigquery — the values the old
1238        // hand-maintained message strings had drifted away from.
1239        for &k in EXACTLY_ONCE_SOURCE_KINDS {
1240            assert!(
1241                source_supports_exactly_once(k),
1242                "{k} should be exactly-once"
1243            );
1244        }
1245        for &k in IDEMPOTENT_SINK_KINDS {
1246            assert!(
1247                sink_supports_idempotent_writes(k),
1248                "{k} should be idempotent"
1249            );
1250        }
1251        for &k in UPSERT_SINK_KINDS {
1252            use faucet_core::WriteMode;
1253            assert!(
1254                sink_supported_write_modes(k).contains(&WriteMode::Upsert),
1255                "{k} should support upsert"
1256            );
1257        }
1258        assert!(IDEMPOTENT_SINK_KINDS.contains(&"bigquery"));
1259        assert!(IDEMPOTENT_SINK_KINDS.contains(&"kafka"));
1260        assert!(UPSERT_SINK_KINDS.contains(&"bigquery"));
1261    }
1262
1263    #[cfg(feature = "source-rest")]
1264    #[test]
1265    fn rest_source_appears_in_listings() {
1266        assert!(source_kinds().contains(&"rest"));
1267    }
1268
1269    #[cfg(feature = "sink-jsonl")]
1270    #[test]
1271    fn jsonl_sink_appears_in_listings() {
1272        assert!(sink_kinds().contains(&"jsonl"));
1273    }
1274
1275    #[tokio::test]
1276    async fn unknown_source_kind_errors() {
1277        let err = build_source("nope", serde_json::json!({}), &AuthCatalog::new(), None)
1278            .await
1279            .err()
1280            .expect("should fail");
1281        match err {
1282            CliError::UnknownConnector { kind, name, .. } => {
1283                assert_eq!(kind, "source");
1284                assert_eq!(name, "nope");
1285            }
1286            other => panic!("expected UnknownConnector, got {other:?}"),
1287        }
1288    }
1289
1290    #[tokio::test]
1291    async fn unknown_sink_kind_errors() {
1292        let err = build_sink("nope", serde_json::json!({}), &AuthCatalog::new())
1293            .await
1294            .err()
1295            .expect("should fail");
1296        assert!(matches!(
1297            err,
1298            CliError::UnknownConnector { kind: "sink", .. }
1299        ));
1300    }
1301
1302    #[cfg(feature = "source-rest")]
1303    #[test]
1304    fn rest_schema_is_object() {
1305        let s = source_schema("rest").unwrap();
1306        assert!(s.is_object());
1307    }
1308
1309    #[cfg(feature = "sink-jsonl")]
1310    #[test]
1311    fn jsonl_schema_is_object() {
1312        let s = sink_schema("jsonl").unwrap();
1313        assert!(s.is_object());
1314    }
1315
1316    #[test]
1317    fn scrub_config_error_redacts_quoted_values() {
1318        // A serde "invalid type" error echoes the offending value in double
1319        // quotes — must be redacted so a secret can't reach the log (#78/#38).
1320        let msg =
1321            r#"invalid type: string "sk-super-secret-123", expected a sequence at line 1 column 9"#;
1322        let scrubbed = scrub_config_error(msg);
1323        assert!(!scrubbed.contains("sk-super-secret-123"), "{scrubbed}");
1324        assert!(scrubbed.contains("<redacted>"), "{scrubbed}");
1325        // Structural context outside the quotes is preserved.
1326        assert!(scrubbed.contains("invalid type"), "{scrubbed}");
1327        assert!(scrubbed.contains("expected a sequence"), "{scrubbed}");
1328    }
1329
1330    #[test]
1331    fn scrub_config_error_truncates_long_messages() {
1332        let msg = "x".repeat(500);
1333        let scrubbed = scrub_config_error(&msg);
1334        assert!(
1335            scrubbed.chars().count() <= 201,
1336            "len {}",
1337            scrubbed.chars().count()
1338        );
1339        assert!(scrubbed.ends_with('…'));
1340    }
1341
1342    // A `(kind, config)` pair that builds without performing any network/disk
1343    // I/O — the CSV source's `new()` only stores config, so we can drive the
1344    // real `build_source` dispatch arm and inspect the resulting trait object.
1345    #[cfg(feature = "source-csv")]
1346    #[tokio::test]
1347    async fn build_source_csv_succeeds_without_io() {
1348        let src = build_source(
1349            "csv",
1350            serde_json::json!({ "path": "/tmp/does-not-need-to-exist.csv" }),
1351            &AuthCatalog::new(),
1352            None,
1353        )
1354        .await
1355        .expect("csv source should build without I/O");
1356        // The CSV source uses the default `connector_name()` (stripped type
1357        // name) rather than overriding it with a friendly label.
1358        assert_eq!(src.connector_name(), "CsvSource");
1359    }
1360
1361    // The JSONL sink's `new()` is also pure (it opens the file lazily on first
1362    // write), so building it exercises the sink dispatch arm with no I/O.
1363    #[cfg(feature = "sink-jsonl")]
1364    #[tokio::test]
1365    async fn build_sink_jsonl_succeeds_without_io() {
1366        let sink = build_sink(
1367            "jsonl",
1368            serde_json::json!({ "path": "/tmp/does-not-need-to-exist.jsonl" }),
1369            &AuthCatalog::new(),
1370        )
1371        .await
1372        .expect("jsonl sink should build without I/O");
1373        assert_eq!(sink.connector_name(), "jsonl");
1374    }
1375
1376    // The stdout sink builds without any config fields and without I/O.
1377    #[cfg(feature = "sink-stdout")]
1378    #[tokio::test]
1379    async fn build_sink_stdout_succeeds_without_io() {
1380        let sink = build_sink("stdout", serde_json::json!({}), &AuthCatalog::new())
1381            .await
1382            .expect("stdout sink should build without I/O");
1383        // The stdout sink uses the default `connector_name()` (stripped type
1384        // name) rather than overriding it with a friendly label.
1385        assert_eq!(sink.connector_name(), "StdoutSink");
1386    }
1387
1388    // Exercise the Delta source+sink registry arms end to end: build both via
1389    // the registry, round-trip a page through a real local table, and confirm
1390    // the schema + description arms resolve.
1391    #[cfg(all(feature = "source-delta", feature = "sink-delta"))]
1392    #[tokio::test]
1393    async fn delta_registry_round_trip() {
1394        let dir = tempfile::tempdir().unwrap();
1395        let uri = dir.path().join("reg_delta").to_string_lossy().into_owned();
1396
1397        assert!(source_schema("delta").is_ok());
1398        assert!(sink_schema("delta").is_ok());
1399        assert!(source_descriptions().iter().any(|(n, _)| *n == "delta"));
1400        assert!(sink_descriptions().iter().any(|(n, _)| *n == "delta"));
1401
1402        let sink = build_sink(
1403            "delta",
1404            serde_json::json!({ "table_uri": uri }),
1405            &AuthCatalog::new(),
1406        )
1407        .await
1408        .expect("delta sink builds");
1409        assert_eq!(sink.connector_name(), "delta");
1410        let n = sink
1411            .write_batch(&[serde_json::json!({"id": 1}), serde_json::json!({"id": 2})])
1412            .await
1413            .expect("write");
1414        assert_eq!(n, 2);
1415        sink.flush().await.expect("flush");
1416
1417        let source = build_source(
1418            "delta",
1419            serde_json::json!({ "table_uri": uri }),
1420            &AuthCatalog::new(),
1421            None,
1422        )
1423        .await
1424        .expect("delta source builds");
1425        assert_eq!(source.connector_name(), "delta");
1426        let rows = source
1427            .fetch_with_context(&std::collections::HashMap::new())
1428            .await
1429            .expect("read");
1430        assert_eq!(rows.len(), 2);
1431    }
1432
1433    // The Databricks source builds from the registry (no I/O in `new`), and its
1434    // schema + description arms resolve.
1435    #[cfg(feature = "source-databricks")]
1436    #[tokio::test]
1437    async fn databricks_registry_source_builds() {
1438        assert!(source_schema("databricks").is_ok());
1439        assert!(
1440            source_descriptions()
1441                .iter()
1442                .any(|(n, _)| *n == "databricks")
1443        );
1444        let cfg = serde_json::json!({
1445            "workspace_url": "https://x.cloud.databricks.com",
1446            "warehouse_id": "wh1",
1447            "sql": "SELECT 1",
1448            "auth": { "type": "pat", "config": { "token": "t" } }
1449        });
1450        let src = build_source("databricks", cfg, &AuthCatalog::new(), None)
1451            .await
1452            .expect("databricks source builds");
1453        assert_eq!(src.connector_name(), "databricks");
1454    }
1455
1456    // A malformed config for a known connector must surface as a typed
1457    // `InvalidConnectorConfig` from the `decode` helper, not a panic.
1458    #[cfg(feature = "source-csv")]
1459    #[tokio::test]
1460    async fn build_source_csv_invalid_config_errors() {
1461        // `path` is a required String; supplying an integer is a type error.
1462        // `Box<dyn Source>` is not `Debug`, so match the Result directly rather
1463        // than using `expect_err`.
1464        let res = build_source(
1465            "csv",
1466            serde_json::json!({ "path": 42 }),
1467            &AuthCatalog::new(),
1468            None,
1469        )
1470        .await;
1471        match res {
1472            Err(CliError::InvalidConnectorConfig { kind, name, .. }) => {
1473                assert_eq!(kind, "source");
1474                assert_eq!(name, "csv");
1475            }
1476            Ok(_) => panic!("expected InvalidConnectorConfig, got Ok"),
1477            Err(other) => panic!("expected InvalidConnectorConfig, got {other:?}"),
1478        }
1479    }
1480
1481    // `source_schema` must return a JSON object that surfaces the connector's
1482    // config fields (here: the required `path`).
1483    #[cfg(feature = "source-csv")]
1484    #[test]
1485    fn source_schema_csv_exposes_path_property() {
1486        let schema = source_schema("csv").expect("csv schema");
1487        let props = schema
1488            .get("properties")
1489            .and_then(Value::as_object)
1490            .expect("schema should have a properties object");
1491        assert!(props.contains_key("path"), "schema props: {props:?}");
1492    }
1493
1494    #[cfg(feature = "sink-jsonl")]
1495    #[test]
1496    fn sink_schema_jsonl_exposes_path_property() {
1497        let schema = sink_schema("jsonl").expect("jsonl schema");
1498        let props = schema
1499            .get("properties")
1500            .and_then(Value::as_object)
1501            .expect("schema should have a properties object");
1502        assert!(props.contains_key("path"), "schema props: {props:?}");
1503    }
1504
1505    #[test]
1506    fn unknown_source_schema_errors_with_available_list() {
1507        let err = source_schema("definitely-not-a-source").expect_err("unknown source");
1508        match err {
1509            CliError::UnknownConnector {
1510                kind,
1511                name,
1512                available,
1513            } => {
1514                assert_eq!(kind, "source");
1515                assert_eq!(name, "definitely-not-a-source");
1516                // Under `--all-features` the available list is non-empty.
1517                assert!(!available.is_empty());
1518            }
1519            other => panic!("expected UnknownConnector, got {other:?}"),
1520        }
1521    }
1522
1523    #[test]
1524    fn unknown_sink_schema_errors() {
1525        let err = sink_schema("definitely-not-a-sink").expect_err("unknown sink");
1526        assert!(matches!(
1527            err,
1528            CliError::UnknownConnector { kind: "sink", .. }
1529        ));
1530    }
1531
1532    #[cfg(feature = "source-csv")]
1533    #[test]
1534    fn source_exists_is_true_for_known_and_false_for_unknown() {
1535        assert!(source_exists("csv"));
1536        assert!(!source_exists("definitely-not-a-source"));
1537    }
1538
1539    #[cfg(feature = "sink-jsonl")]
1540    #[test]
1541    fn sink_exists_is_true_for_known_and_false_for_unknown() {
1542        assert!(sink_exists("jsonl"));
1543        assert!(!sink_exists("definitely-not-a-sink"));
1544    }
1545
1546    // Descriptions back `faucet list`: non-empty, with a one-line summary, and
1547    // each name must resolve to a real schema (no orphan listing).
1548    #[test]
1549    fn source_descriptions_are_non_empty_and_consistent() {
1550        let descs = source_descriptions();
1551        assert!(!descs.is_empty());
1552        for (name, summary) in &descs {
1553            assert!(!name.is_empty(), "empty connector name");
1554            assert!(!summary.is_empty(), "empty summary for {name}");
1555            assert!(
1556                source_schema(name).is_ok(),
1557                "listed source `{name}` has no schema"
1558            );
1559        }
1560    }
1561
1562    #[test]
1563    fn sink_descriptions_are_non_empty_and_consistent() {
1564        let descs = sink_descriptions();
1565        assert!(!descs.is_empty());
1566        for (name, summary) in &descs {
1567            assert!(!name.is_empty(), "empty connector name");
1568            assert!(!summary.is_empty(), "empty summary for {name}");
1569            assert!(
1570                sink_schema(name).is_ok(),
1571                "listed sink `{name}` has no schema"
1572            );
1573        }
1574    }
1575
1576    // `*_kinds()` is derived from `*_descriptions()`; under `--all-features`
1577    // the canonical built-in connectors must be present.
1578    #[cfg(all(feature = "source-csv", feature = "source-rest"))]
1579    #[test]
1580    fn source_kinds_contains_expected_builtins() {
1581        let kinds = source_kinds();
1582        assert!(kinds.contains(&"csv"));
1583        assert!(kinds.contains(&"rest"));
1584    }
1585
1586    #[cfg(all(feature = "sink-jsonl", feature = "sink-stdout"))]
1587    #[test]
1588    fn sink_kinds_contains_expected_builtins() {
1589        let kinds = sink_kinds();
1590        assert!(kinds.contains(&"jsonl"));
1591        assert!(kinds.contains(&"stdout"));
1592    }
1593
1594    // Build a catalog holding one `static` bearer provider, then build a
1595    // connector whose config carries `auth: { ref: "tok" }` — exercising the
1596    // `with_auth_provider` injection branch in the dispatch arm.
1597    #[cfg(feature = "source-rest")]
1598    #[tokio::test]
1599    async fn build_source_injects_referenced_auth_provider() {
1600        let mut specs = std::collections::HashMap::new();
1601        specs.insert(
1602            "tok".to_string(),
1603            serde_json::json!({"type": "static", "config": {"token": "abc"}}),
1604        );
1605        let catalog = auth_catalog::build_auth_catalog(Some(&specs)).expect("catalog");
1606
1607        let src = build_source("rest", rest_config_with_auth_ref("tok"), &catalog, None)
1608            .await
1609            .expect("rest source with a resolvable auth ref should build");
1610        assert_eq!(src.connector_name(), "rest");
1611    }
1612
1613    // A minimal, fully-valid rest config (built from the real constructor so
1614    // every required field is present) carrying an `auth: { ref }` pointer.
1615    #[cfg(feature = "source-rest")]
1616    fn rest_config_with_auth_ref(name: &str) -> Value {
1617        let cfg = faucet_source_rest::RestStreamConfig::new("https://api.example.com", "/v1");
1618        let mut v = serde_json::to_value(cfg).expect("serialize rest config");
1619        v.as_object_mut()
1620            .unwrap()
1621            .insert("auth".to_string(), serde_json::json!({ "ref": name }));
1622        v
1623    }
1624
1625    // An `auth: { ref }` pointing at a name absent from the catalog must surface
1626    // as `UnknownAuthProvider`, not silently build without auth.
1627    #[cfg(feature = "source-rest")]
1628    #[tokio::test]
1629    async fn build_source_unknown_auth_ref_errors() {
1630        let res = build_source(
1631            "rest",
1632            rest_config_with_auth_ref("missing"),
1633            &AuthCatalog::new(),
1634            None,
1635        )
1636        .await;
1637        match res {
1638            Err(CliError::UnknownAuthProvider { name, .. }) => assert_eq!(name, "missing"),
1639            Ok(_) => panic!("expected UnknownAuthProvider, got Ok"),
1640            Err(other) => panic!("expected UnknownAuthProvider, got {other:?}"),
1641        }
1642    }
1643
1644    #[test]
1645    fn exactly_once_capability_allowlists() {
1646        assert!(source_supports_exactly_once("postgres-cdc"));
1647        assert!(source_supports_exactly_once("mysql-cdc"));
1648        assert!(source_supports_exactly_once("mongodb-cdc"));
1649        assert!(source_supports_exactly_once("kafka"));
1650        assert!(!source_supports_exactly_once("rest"));
1651
1652        assert!(sink_supports_idempotent_writes("postgres"));
1653        assert!(sink_supports_idempotent_writes("iceberg"));
1654        assert!(sink_supports_idempotent_writes("bigquery"));
1655        assert!(sink_supports_idempotent_writes("kafka"));
1656        assert!(sink_supports_idempotent_writes("snowflake"));
1657        assert!(sink_supports_idempotent_writes("redis"));
1658        assert!(sink_supports_idempotent_writes("mongodb"));
1659        assert!(!sink_supports_idempotent_writes("jsonl"));
1660    }
1661
1662    #[test]
1663    fn typed_delivery_capabilities_derive_from_kind_tables() {
1664        use faucet_core::{ReplayGuarantee, SinkGuarantee};
1665        assert_eq!(
1666            source_replay_guarantee("kafka"),
1667            ReplayGuarantee::Deterministic
1668        );
1669        assert_eq!(
1670            source_replay_guarantee("rest"),
1671            ReplayGuarantee::NonDeterministic
1672        );
1673        assert_eq!(sink_guarantee("postgres"), SinkGuarantee::AtomicWatermark);
1674        // Upsert-capable but not atomic: elasticsearch dedups by key only.
1675        assert_eq!(sink_guarantee("elasticsearch"), SinkGuarantee::KeyedUpsert);
1676        assert_eq!(sink_guarantee("jsonl"), SinkGuarantee::AtLeastOnce);
1677    }
1678
1679    #[test]
1680    fn sink_supported_write_modes_allowlist() {
1681        use faucet_core::WriteMode;
1682        assert!(sink_supported_write_modes("postgres").contains(&WriteMode::Upsert));
1683        assert!(sink_supported_write_modes("elasticsearch").contains(&WriteMode::Delete));
1684        assert!(sink_supported_write_modes("bigquery").contains(&WriteMode::Upsert));
1685        // a sink without upsert support is append-only
1686        assert_eq!(sink_supported_write_modes("jsonl"), &[WriteMode::Append]);
1687        assert_eq!(sink_supported_write_modes("kafka"), &[WriteMode::Append]);
1688    }
1689
1690    #[test]
1691    fn sink_supports_schema_evolution_allowlist() {
1692        assert!(sink_supports_schema_evolution("postgres"));
1693        assert!(sink_supports_schema_evolution("mysql"));
1694        assert!(sink_supports_schema_evolution("mssql"));
1695        assert!(sink_supports_schema_evolution("sqlite"));
1696        assert!(sink_supports_schema_evolution("bigquery"));
1697        assert!(sink_supports_schema_evolution("elasticsearch"));
1698        assert!(!sink_supports_schema_evolution("iceberg"));
1699        assert!(!sink_supports_schema_evolution("jsonl"));
1700        assert!(!sink_supports_schema_evolution("kafka"));
1701    }
1702}