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-duckdb")]
359        "duckdb" => {
360            let cfg =
361                decode::<faucet_source_duckdb::DuckdbSourceConfig>("source", "duckdb", config)?;
362            Ok(Box::new(
363                faucet_source_duckdb::DuckdbSource::new(cfg).await?,
364            ))
365        }
366        #[cfg(feature = "source-sqs")]
367        "sqs" => {
368            let cfg = decode::<faucet_source_sqs::SqsSourceConfig>("source", "sqs", config)?;
369            Ok(Box::new(faucet_source_sqs::SqsSource::new(cfg).await?))
370        }
371        #[cfg(feature = "source-nats")]
372        "nats" => {
373            let cfg = decode::<faucet_source_nats::NatsSourceConfig>("source", "nats", config)?;
374            Ok(Box::new(faucet_source_nats::NatsSource::new(cfg).await?))
375        }
376        #[cfg(feature = "source-sftp")]
377        "sftp" => {
378            let cfg = decode::<faucet_source_sftp::SftpSourceConfig>("source", "sftp", config)?;
379            Ok(Box::new(faucet_source_sftp::SftpSource::new(cfg)?))
380        }
381        #[cfg(feature = "source-s3")]
382        "s3" => {
383            let cfg = decode::<faucet_source_s3::S3SourceConfig>("source", "s3", config)?;
384            Ok(Box::new(faucet_source_s3::S3Source::new(cfg).await?))
385        }
386        #[cfg(feature = "source-mongodb")]
387        "mongodb" => {
388            let cfg =
389                decode::<faucet_source_mongodb::MongoSourceConfig>("source", "mongodb", config)?;
390            Ok(Box::new(
391                faucet_source_mongodb::MongoSource::new(cfg).await?,
392            ))
393        }
394        #[cfg(feature = "source-mongodb-cdc")]
395        "mongodb-cdc" => {
396            let cfg = decode::<faucet_source_mongodb_cdc::MongoCdcSourceConfig>(
397                "source",
398                "mongodb-cdc",
399                config,
400            )?;
401            Ok(Box::new(
402                faucet_source_mongodb_cdc::MongoCdcSource::new(cfg).await?,
403            ))
404        }
405        #[cfg(feature = "source-mysql-cdc")]
406        "mysql-cdc" => {
407            let cfg = decode::<faucet_source_mysql_cdc::MysqlCdcSourceConfig>(
408                "source",
409                "mysql-cdc",
410                config,
411            )?;
412            Ok(Box::new(
413                faucet_source_mysql_cdc::MysqlCdcSource::new(cfg).await?,
414            ))
415        }
416        #[cfg(feature = "source-redis")]
417        "redis" => {
418            let cfg = decode::<faucet_source_redis::RedisSourceConfig>("source", "redis", config)?;
419            Ok(Box::new(faucet_source_redis::RedisSource::new(cfg)?))
420        }
421        #[cfg(feature = "source-webhook")]
422        "webhook" => {
423            let cfg =
424                decode::<faucet_source_webhook::WebhookSourceConfig>("source", "webhook", config)?;
425            Ok(Box::new(faucet_source_webhook::WebhookSource::new(cfg)))
426        }
427        #[cfg(feature = "source-websocket")]
428        "websocket" => {
429            let cfg = decode::<faucet_source_websocket::WebsocketSourceConfig>(
430                "source",
431                "websocket",
432                config,
433            )?;
434            let mut s = faucet_source_websocket::WebsocketSource::new(cfg)?;
435            if let Some(name) = &auth_ref {
436                s = s.with_auth_provider(auth_catalog::resolve(auth, name)?);
437            }
438            Ok(Box::new(s))
439        }
440        #[cfg(feature = "source-csv")]
441        "csv" => {
442            let cfg = decode::<faucet_source_csv::CsvSourceConfig>("source", "csv", config)?;
443            Ok(Box::new(faucet_source_csv::CsvSource::new(cfg)))
444        }
445        #[cfg(feature = "source-singer")]
446        "singer" => {
447            let cfg =
448                decode::<faucet_source_singer::SingerSourceConfig>("source", "singer", config)?;
449            Ok(Box::new(faucet_source_singer::SingerSource::new(cfg)))
450        }
451        #[cfg(feature = "source-elasticsearch")]
452        "elasticsearch" => {
453            let cfg = decode::<faucet_source_elasticsearch::ElasticsearchSourceConfig>(
454                "source",
455                "elasticsearch",
456                config,
457            )?;
458            let mut s = faucet_source_elasticsearch::ElasticsearchSource::new(cfg)?;
459            if let Some(name) = &auth_ref {
460                s = s.with_auth_provider(auth_catalog::resolve(auth, name)?);
461            }
462            Ok(Box::new(s))
463        }
464        #[cfg(feature = "source-kafka")]
465        "kafka" => {
466            let cfg = decode::<faucet_source_kafka::KafkaSourceConfig>("source", "kafka", config)?;
467            Ok(Box::new(faucet_source_kafka::KafkaSource::new(cfg).await?))
468        }
469        #[cfg(feature = "source-kinesis")]
470        "kinesis" => {
471            let cfg =
472                decode::<faucet_source_kinesis::KinesisSourceConfig>("source", "kinesis", config)?;
473            Ok(Box::new(
474                faucet_source_kinesis::KinesisSource::new(cfg).await?,
475            ))
476        }
477        #[cfg(feature = "source-spanner")]
478        "spanner" => {
479            let cfg =
480                decode::<faucet_source_spanner::SpannerSourceConfig>("source", "spanner", config)?;
481            Ok(Box::new(
482                faucet_source_spanner::SpannerSource::new(cfg).await?,
483            ))
484        }
485        #[cfg(feature = "source-parquet")]
486        "parquet" => {
487            let cfg =
488                decode::<faucet_source_parquet::ParquetSourceConfig>("source", "parquet", config)?;
489            Ok(Box::new(
490                faucet_source_parquet::ParquetSource::new(cfg).await?,
491            ))
492        }
493        #[cfg(feature = "source-delta")]
494        "delta" => {
495            let cfg = decode::<faucet_source_delta::DeltaSourceConfig>("source", "delta", config)?;
496            Ok(Box::new(faucet_source_delta::DeltaSource::new(cfg).await?))
497        }
498        #[cfg(feature = "source-databricks")]
499        "databricks" => {
500            let cfg = decode::<faucet_source_databricks::DatabricksSourceConfig>(
501                "source",
502                "databricks",
503                config,
504            )?;
505            let mut s = faucet_source_databricks::DatabricksSource::new(cfg)?;
506            if let Some(name) = &auth_ref {
507                s = s.with_auth_provider(auth_catalog::resolve(auth, name)?);
508            }
509            Ok(Box::new(s))
510        }
511        #[cfg(feature = "source-gcs")]
512        "gcs" => {
513            let cfg = decode::<faucet_source_gcs::GcsSourceConfig>("source", "gcs", config)?;
514            Ok(Box::new(faucet_source_gcs::GcsSource::new(cfg).await?))
515        }
516        #[cfg(feature = "source-bigquery")]
517        "bigquery" => {
518            let cfg = decode::<faucet_source_bigquery::BigQuerySourceConfig>(
519                "source", "bigquery", config,
520            )?;
521            Ok(Box::new(
522                faucet_source_bigquery::BigQuerySource::new(cfg).await?,
523            ))
524        }
525        #[cfg(feature = "source-snowflake")]
526        "snowflake" => {
527            let cfg = decode::<faucet_source_snowflake::SnowflakeSourceConfig>(
528                "source",
529                "snowflake",
530                config,
531            )?;
532            let mut s = faucet_source_snowflake::SnowflakeSource::new(cfg)?;
533            if let Some(name) = &auth_ref {
534                s = s.with_auth_provider(auth_catalog::resolve(auth, name)?);
535            }
536            Ok(Box::new(s))
537        }
538        #[cfg(feature = "source-mssql-cdc")]
539        "mssql-cdc" => {
540            let cfg = decode::<faucet_source_mssql_cdc::MssqlCdcSourceConfig>(
541                "source",
542                "mssql-cdc",
543                config,
544            )?;
545            Ok(Box::new(
546                faucet_source_mssql_cdc::MssqlCdcSource::new(cfg).await?,
547            ))
548        }
549        #[cfg(feature = "source-redshift")]
550        "redshift" => {
551            let cfg = decode::<faucet_source_redshift::RedshiftSourceConfig>(
552                "source", "redshift", config,
553            )?;
554            Ok(Box::new(faucet_source_redshift::RedshiftSource::new(cfg)?))
555        }
556        #[cfg(feature = "source-pubsub")]
557        "pubsub" => {
558            let cfg =
559                decode::<faucet_source_pubsub::PubsubSourceConfig>("source", "pubsub", config)?;
560            Ok(Box::new(
561                faucet_source_pubsub::PubsubSource::new(cfg).await?,
562            ))
563        }
564        #[cfg(feature = "source-clickhouse")]
565        "clickhouse" => {
566            let cfg = decode::<faucet_source_clickhouse::ClickHouseSourceConfig>(
567                "source",
568                "clickhouse",
569                config,
570            )?;
571            Ok(Box::new(faucet_source_clickhouse::ClickHouseSource::new(
572                cfg,
573            )?))
574        }
575        #[cfg(feature = "source-azure-blob")]
576        "azure-blob" => {
577            let cfg = decode::<faucet_source_azure_blob::AzureBlobSourceConfig>(
578                "source",
579                "azure-blob",
580                config,
581            )?;
582            Ok(Box::new(
583                faucet_source_azure_blob::AzureBlobSource::new(cfg).await?,
584            ))
585        }
586        other => Err(unknown(other, "source", source_kinds())),
587    }
588}
589
590/// Build a [`Sink`] trait object from a `(kind, config)` pair. When the config
591/// carries `auth: { ref: <name> }`, the named provider is resolved from `auth`
592/// (the catalog) and injected into the connector.
593pub async fn build_sink(kind: &str, config: Value, auth: &AuthCatalog) -> CliResult<Box<dyn Sink>> {
594    if let Some(entry) = global().sinks.get(kind) {
595        return (entry.factory)(config);
596    }
597    let auth_ref = auth_catalog::auth_ref(&config);
598    match kind {
599        #[cfg(feature = "sink-bigquery")]
600        "bigquery" => {
601            let cfg =
602                decode::<faucet_sink_bigquery::BigQuerySinkConfig>("sink", "bigquery", config)?;
603            Ok(Box::new(
604                faucet_sink_bigquery::BigQuerySink::new(cfg).await?,
605            ))
606        }
607        #[cfg(feature = "sink-iceberg")]
608        "iceberg" => {
609            let cfg = decode::<faucet_sink_iceberg::IcebergSinkConfig>("sink", "iceberg", config)?;
610            Ok(Box::new(faucet_sink_iceberg::IcebergSink::new(cfg).await?))
611        }
612        #[cfg(feature = "sink-delta")]
613        "delta" => {
614            let cfg = decode::<faucet_sink_delta::DeltaSinkConfig>("sink", "delta", config)?;
615            Ok(Box::new(faucet_sink_delta::DeltaSink::new(cfg).await?))
616        }
617        #[cfg(feature = "sink-postgres")]
618        "postgres" => {
619            let cfg =
620                decode::<faucet_sink_postgres::PostgresSinkConfig>("sink", "postgres", config)?;
621            Ok(Box::new(
622                faucet_sink_postgres::PostgresSink::new(cfg).await?,
623            ))
624        }
625        #[cfg(feature = "sink-jsonl")]
626        "jsonl" => {
627            let cfg = decode::<faucet_sink_jsonl::JsonlSinkConfig>("sink", "jsonl", config)?;
628            Ok(Box::new(faucet_sink_jsonl::JsonlSink::new(cfg)))
629        }
630        #[cfg(feature = "sink-snowflake")]
631        "snowflake" => {
632            let cfg =
633                decode::<faucet_sink_snowflake::SnowflakeSinkConfig>("sink", "snowflake", config)?;
634            let mut s = faucet_sink_snowflake::SnowflakeSink::new(cfg)?;
635            if let Some(name) = &auth_ref {
636                s = s.with_auth_provider(auth_catalog::resolve(auth, name)?);
637            }
638            Ok(Box::new(s))
639        }
640        #[cfg(feature = "sink-mysql")]
641        "mysql" => {
642            let cfg = decode::<faucet_sink_mysql::MysqlSinkConfig>("sink", "mysql", config)?;
643            Ok(Box::new(faucet_sink_mysql::MysqlSink::new(cfg).await?))
644        }
645        #[cfg(feature = "sink-mssql")]
646        "mssql" => {
647            let cfg = decode::<faucet_sink_mssql::MssqlSinkConfig>("sink", "mssql", config)?;
648            Ok(Box::new(faucet_sink_mssql::MssqlSink::new(cfg).await?))
649        }
650        #[cfg(feature = "sink-sqlite")]
651        "sqlite" => {
652            let cfg = decode::<faucet_sink_sqlite::SqliteSinkConfig>("sink", "sqlite", config)?;
653            Ok(Box::new(faucet_sink_sqlite::SqliteSink::new(cfg).await?))
654        }
655        #[cfg(feature = "sink-duckdb")]
656        "duckdb" => {
657            let cfg = decode::<faucet_sink_duckdb::DuckdbSinkConfig>("sink", "duckdb", config)?;
658            Ok(Box::new(faucet_sink_duckdb::DuckdbSink::new(cfg).await?))
659        }
660        #[cfg(feature = "sink-sqs")]
661        "sqs" => {
662            let cfg = decode::<faucet_sink_sqs::SqsSinkConfig>("sink", "sqs", config)?;
663            Ok(Box::new(faucet_sink_sqs::SqsSink::new(cfg).await?))
664        }
665        #[cfg(feature = "sink-nats")]
666        "nats" => {
667            let cfg = decode::<faucet_sink_nats::NatsSinkConfig>("sink", "nats", config)?;
668            Ok(Box::new(faucet_sink_nats::NatsSink::new(cfg).await?))
669        }
670        #[cfg(feature = "sink-sftp")]
671        "sftp" => {
672            let cfg = decode::<faucet_sink_sftp::SftpSinkConfig>("sink", "sftp", config)?;
673            Ok(Box::new(faucet_sink_sftp::SftpSink::new(cfg)?))
674        }
675        #[cfg(feature = "sink-s3")]
676        "s3" => {
677            let cfg = decode::<faucet_sink_s3::S3SinkConfig>("sink", "s3", config)?;
678            Ok(Box::new(faucet_sink_s3::S3Sink::new(cfg).await?))
679        }
680        #[cfg(feature = "sink-mongodb")]
681        "mongodb" => {
682            let cfg = decode::<faucet_sink_mongodb::MongoSinkConfig>("sink", "mongodb", config)?;
683            Ok(Box::new(faucet_sink_mongodb::MongoSink::new(cfg).await?))
684        }
685        #[cfg(feature = "sink-redis")]
686        "redis" => {
687            let cfg = decode::<faucet_sink_redis::RedisSinkConfig>("sink", "redis", config)?;
688            Ok(Box::new(faucet_sink_redis::RedisSink::new(cfg).await?))
689        }
690        #[cfg(feature = "sink-csv")]
691        "csv" => {
692            let cfg = decode::<faucet_sink_csv::CsvSinkConfig>("sink", "csv", config)?;
693            Ok(Box::new(faucet_sink_csv::CsvSink::new(cfg)))
694        }
695        #[cfg(feature = "sink-elasticsearch")]
696        "elasticsearch" => {
697            let cfg = decode::<faucet_sink_elasticsearch::ElasticsearchSinkConfig>(
698                "sink",
699                "elasticsearch",
700                config,
701            )?;
702            let mut s = faucet_sink_elasticsearch::ElasticsearchSink::new(cfg)?;
703            if let Some(name) = &auth_ref {
704                s = s.with_auth_provider(auth_catalog::resolve(auth, name)?);
705            }
706            Ok(Box::new(s))
707        }
708        #[cfg(feature = "sink-kafka")]
709        "kafka" => {
710            let cfg = decode::<faucet_sink_kafka::KafkaSinkConfig>("sink", "kafka", config)?;
711            Ok(Box::new(faucet_sink_kafka::KafkaSink::new(cfg).await?))
712        }
713        #[cfg(feature = "sink-kinesis")]
714        "kinesis" => {
715            let cfg = decode::<faucet_sink_kinesis::KinesisSinkConfig>("sink", "kinesis", config)?;
716            Ok(Box::new(faucet_sink_kinesis::KinesisSink::new(cfg).await?))
717        }
718        #[cfg(feature = "sink-spanner")]
719        "spanner" => {
720            let cfg = decode::<faucet_sink_spanner::SpannerSinkConfig>("sink", "spanner", config)?;
721            Ok(Box::new(faucet_sink_spanner::SpannerSink::new(cfg).await?))
722        }
723        #[cfg(feature = "sink-http")]
724        "http" => {
725            let cfg = decode::<faucet_sink_http::HttpSinkConfig>("sink", "http", config)?;
726            let mut s = faucet_sink_http::HttpSink::new(cfg);
727            if let Some(name) = &auth_ref {
728                s = s.with_auth_provider(auth_catalog::resolve(auth, name)?);
729            }
730            Ok(Box::new(s))
731        }
732        #[cfg(feature = "sink-stdout")]
733        "stdout" => {
734            let cfg = decode::<faucet_sink_stdout::StdoutSinkConfig>("sink", "stdout", config)?;
735            Ok(Box::new(faucet_sink_stdout::StdoutSink::new(cfg)))
736        }
737        #[cfg(feature = "sink-parquet")]
738        "parquet" => {
739            let cfg = decode::<faucet_sink_parquet::ParquetSinkConfig>("sink", "parquet", config)?;
740            Ok(Box::new(faucet_sink_parquet::ParquetSink::new(cfg).await?))
741        }
742        #[cfg(feature = "sink-gcs")]
743        "gcs" => {
744            let cfg = decode::<faucet_sink_gcs::GcsSinkConfig>("sink", "gcs", config)?;
745            Ok(Box::new(faucet_sink_gcs::GcsSink::new(cfg).await?))
746        }
747        #[cfg(feature = "sink-redshift")]
748        "redshift" => {
749            let cfg =
750                decode::<faucet_sink_redshift::RedshiftSinkConfig>("sink", "redshift", config)?;
751            Ok(Box::new(
752                faucet_sink_redshift::RedshiftSink::new(cfg).await?,
753            ))
754        }
755        #[cfg(feature = "sink-pubsub")]
756        "pubsub" => {
757            let cfg = decode::<faucet_sink_pubsub::PubsubSinkConfig>("sink", "pubsub", config)?;
758            Ok(Box::new(faucet_sink_pubsub::PubsubSink::new(cfg).await?))
759        }
760        #[cfg(feature = "sink-clickhouse")]
761        "clickhouse" => {
762            let cfg = decode::<faucet_sink_clickhouse::ClickHouseSinkConfig>(
763                "sink",
764                "clickhouse",
765                config,
766            )?;
767            Ok(Box::new(faucet_sink_clickhouse::ClickHouseSink::new(cfg)?))
768        }
769        #[cfg(feature = "sink-azure-blob")]
770        "azure-blob" => {
771            let cfg = decode::<faucet_sink_azure_blob::AzureBlobSinkConfig>(
772                "sink",
773                "azure-blob",
774                config,
775            )?;
776            Ok(Box::new(
777                faucet_sink_azure_blob::AzureBlobSink::new(cfg).await?,
778            ))
779        }
780        other => Err(unknown(other, "sink", sink_kinds())),
781    }
782}
783
784/// Source connector kinds that deterministically replay (exactly-once-capable).
785/// Mirrors `Source::supports_exactly_once` overrides — keep in sync when a new
786/// source opts in. The single source of truth for both the boolean gate and the
787/// human-readable list shown in error messages (F44). `kafka` qualifies because
788/// partitions are immutable logs and every page carries a complete offsets
789/// bookmark (#291).
790pub const EXACTLY_ONCE_SOURCE_KINDS: &[&str] = &[
791    "postgres-cdc",
792    "mysql-cdc",
793    "mssql-cdc",
794    "mongodb-cdc",
795    "kafka",
796];
797
798/// Sink connector kinds that can durably commit a token atomically with data.
799/// Mirrors `Sink::supports_idempotent_writes` overrides — keep in sync when a
800/// new sink opts in. Single source of truth for the gate + the error-message
801/// list (F44).
802pub const IDEMPOTENT_SINK_KINDS: &[&str] = &[
803    "sqlite",
804    "postgres",
805    "mysql",
806    "mssql",
807    "iceberg",
808    "bigquery",
809    "kafka",
810    "snowflake",
811    "redis",
812    "mongodb",
813    "spanner",
814];
815
816/// Sink kinds that can apply additive/widening DDL via `Sink::evolve_schema`.
817/// Mirrors each sink's `supports_schema_evolution()` override. Iceberg is
818/// additive-only (new columns) via iceberg-rust 0.10.0's `update_schema`
819/// action (#255).
820pub const SCHEMA_EVOLUTION_SINK_KINDS: &[&str] = &[
821    "postgres",
822    "mysql",
823    "mssql",
824    "sqlite",
825    "bigquery",
826    "elasticsearch",
827    "spanner",
828    "iceberg",
829];
830
831/// Sink kinds that support `write_mode: upsert|delete`. Mirrors each sink's
832/// `Sink::supported_write_modes()` override. Single source of truth for the gate
833/// + the error-message list (F44).
834pub const UPSERT_SINK_KINDS: &[&str] = &[
835    "postgres",
836    "sqlite",
837    "mysql",
838    "mssql",
839    "mongodb",
840    "elasticsearch",
841    "bigquery",
842    "spanner",
843];
844
845/// Source kinds that implement live dataset discovery (`Source::discover`,
846/// issue #211) — mirrors the discoverable-source list in the connector docs.
847/// Single source of truth for the conformance scorecard (#330).
848pub const DISCOVER_SOURCE_KINDS: &[&str] = &[
849    "postgres",
850    "mysql",
851    "mssql",
852    "sqlite",
853    "mongodb",
854    "elasticsearch",
855    "bigquery",
856    "snowflake",
857    "spanner",
858    "s3",
859    "gcs",
860];
861
862/// Whether a source kind supports `faucet discover` (dataset introspection).
863pub fn source_supports_discover(kind: &str) -> bool {
864    DISCOVER_SOURCE_KINDS.contains(&kind)
865}
866
867/// The typed replay capability a source kind advertises
868/// (`Source::replay_guarantee`, issue #292). Derived from
869/// [`EXACTLY_ONCE_SOURCE_KINDS`] — the kind table stays the single source of
870/// truth; this is the typed view the delivery-guarantee derivation consumes.
871pub fn source_replay_guarantee(kind: &str) -> faucet_core::ReplayGuarantee {
872    if EXACTLY_ONCE_SOURCE_KINDS.contains(&kind) {
873        faucet_core::ReplayGuarantee::Deterministic
874    } else {
875        faucet_core::ReplayGuarantee::NonDeterministic
876    }
877}
878
879/// The strongest delivery guarantee a sink kind can uphold
880/// (`Sink::sink_guarantee`, issue #292). Derived from
881/// [`IDEMPOTENT_SINK_KINDS`] / [`UPSERT_SINK_KINDS`].
882pub fn sink_guarantee(kind: &str) -> faucet_core::SinkGuarantee {
883    if IDEMPOTENT_SINK_KINDS.contains(&kind) {
884        faucet_core::SinkGuarantee::AtomicWatermark
885    } else if UPSERT_SINK_KINDS.contains(&kind) {
886        faucet_core::SinkGuarantee::KeyedUpsert
887    } else {
888        faucet_core::SinkGuarantee::AtLeastOnce
889    }
890}
891
892/// See [`EXACTLY_ONCE_SOURCE_KINDS`].
893pub fn source_supports_exactly_once(kind: &str) -> bool {
894    source_replay_guarantee(kind) == faucet_core::ReplayGuarantee::Deterministic
895}
896
897/// See [`IDEMPOTENT_SINK_KINDS`].
898pub fn sink_supports_idempotent_writes(kind: &str) -> bool {
899    sink_guarantee(kind) == faucet_core::SinkGuarantee::AtomicWatermark
900}
901
902/// See [`SCHEMA_EVOLUTION_SINK_KINDS`].
903pub fn sink_supports_schema_evolution(kind: &str) -> bool {
904    SCHEMA_EVOLUTION_SINK_KINDS.contains(&kind)
905}
906
907/// Write modes each sink kind supports. Kept in sync with each sink's
908/// `Sink::supported_write_modes()` override via [`UPSERT_SINK_KINDS`].
909pub fn sink_supported_write_modes(kind: &str) -> &'static [faucet_core::WriteMode] {
910    use faucet_core::WriteMode;
911    if UPSERT_SINK_KINDS.contains(&kind) {
912        &[WriteMode::Append, WriteMode::Upsert, WriteMode::Delete]
913    } else {
914        &[WriteMode::Append]
915    }
916}
917
918/// Return the JSON Schema for the named source's config struct.
919pub fn source_schema(kind: &str) -> CliResult<Value> {
920    if let Some(entry) = global().sources.get(kind) {
921        return Ok((entry.schema)());
922    }
923    match kind {
924        #[cfg(feature = "source-rest")]
925        "rest" => Ok(schema::<faucet_source_rest::RestStreamConfig>()),
926        #[cfg(feature = "source-graphql")]
927        "graphql" => Ok(schema::<faucet_source_graphql::GraphqlStreamConfig>()),
928        #[cfg(feature = "source-xml")]
929        "xml" => Ok(schema::<faucet_source_xml::XmlStreamConfig>()),
930        #[cfg(feature = "source-grpc")]
931        "grpc" => Ok(schema::<faucet_source_grpc::GrpcStreamConfig>()),
932        #[cfg(feature = "source-postgres")]
933        "postgres" => Ok(schema::<faucet_source_postgres::PostgresSourceConfig>()),
934        #[cfg(feature = "source-postgres-cdc")]
935        "postgres-cdc" => Ok(schema::<faucet_source_postgres_cdc::PostgresCdcSourceConfig>()),
936        #[cfg(feature = "source-mysql")]
937        "mysql" => Ok(schema::<faucet_source_mysql::MysqlSourceConfig>()),
938        #[cfg(feature = "source-mssql")]
939        "mssql" => Ok(schema::<faucet_source_mssql::MssqlSourceConfig>()),
940        #[cfg(feature = "source-sqlite")]
941        "sqlite" => Ok(schema::<faucet_source_sqlite::SqliteSourceConfig>()),
942        #[cfg(feature = "source-duckdb")]
943        "duckdb" => Ok(schema::<faucet_source_duckdb::DuckdbSourceConfig>()),
944        #[cfg(feature = "source-sqs")]
945        "sqs" => Ok(schema::<faucet_source_sqs::SqsSourceConfig>()),
946        #[cfg(feature = "source-nats")]
947        "nats" => Ok(schema::<faucet_source_nats::NatsSourceConfig>()),
948        #[cfg(feature = "source-sftp")]
949        "sftp" => Ok(schema::<faucet_source_sftp::SftpSourceConfig>()),
950        #[cfg(feature = "source-s3")]
951        "s3" => Ok(schema::<faucet_source_s3::S3SourceConfig>()),
952        #[cfg(feature = "source-mongodb")]
953        "mongodb" => Ok(schema::<faucet_source_mongodb::MongoSourceConfig>()),
954        #[cfg(feature = "source-mongodb-cdc")]
955        "mongodb-cdc" => Ok(schema::<faucet_source_mongodb_cdc::MongoCdcSourceConfig>()),
956        #[cfg(feature = "source-mysql-cdc")]
957        "mysql-cdc" => Ok(schema::<faucet_source_mysql_cdc::MysqlCdcSourceConfig>()),
958        #[cfg(feature = "source-redis")]
959        "redis" => Ok(schema::<faucet_source_redis::RedisSourceConfig>()),
960        #[cfg(feature = "source-webhook")]
961        "webhook" => Ok(schema::<faucet_source_webhook::WebhookSourceConfig>()),
962        #[cfg(feature = "source-websocket")]
963        "websocket" => Ok(schema::<faucet_source_websocket::WebsocketSourceConfig>()),
964        #[cfg(feature = "source-csv")]
965        "csv" => Ok(schema::<faucet_source_csv::CsvSourceConfig>()),
966        #[cfg(feature = "source-singer")]
967        "singer" => Ok(schema::<faucet_source_singer::SingerSourceConfig>()),
968        #[cfg(feature = "source-elasticsearch")]
969        "elasticsearch" => Ok(schema::<
970            faucet_source_elasticsearch::ElasticsearchSourceConfig,
971        >()),
972        #[cfg(feature = "source-kafka")]
973        "kafka" => Ok(schema::<faucet_source_kafka::KafkaSourceConfig>()),
974        #[cfg(feature = "source-kinesis")]
975        "kinesis" => Ok(schema::<faucet_source_kinesis::KinesisSourceConfig>()),
976        #[cfg(feature = "source-spanner")]
977        "spanner" => Ok(schema::<faucet_source_spanner::SpannerSourceConfig>()),
978        #[cfg(feature = "source-parquet")]
979        "parquet" => Ok(schema::<faucet_source_parquet::ParquetSourceConfig>()),
980        #[cfg(feature = "source-delta")]
981        "delta" => Ok(schema::<faucet_source_delta::DeltaSourceConfig>()),
982        #[cfg(feature = "source-databricks")]
983        "databricks" => Ok(schema::<faucet_source_databricks::DatabricksSourceConfig>()),
984        #[cfg(feature = "source-gcs")]
985        "gcs" => Ok(schema::<faucet_source_gcs::GcsSourceConfig>()),
986        #[cfg(feature = "source-bigquery")]
987        "bigquery" => Ok(schema::<faucet_source_bigquery::BigQuerySourceConfig>()),
988        #[cfg(feature = "source-snowflake")]
989        "snowflake" => Ok(schema::<faucet_source_snowflake::SnowflakeSourceConfig>()),
990        #[cfg(feature = "source-mssql-cdc")]
991        "mssql-cdc" => Ok(schema::<faucet_source_mssql_cdc::MssqlCdcSourceConfig>()),
992        #[cfg(feature = "source-redshift")]
993        "redshift" => Ok(schema::<faucet_source_redshift::RedshiftSourceConfig>()),
994        #[cfg(feature = "source-pubsub")]
995        "pubsub" => Ok(schema::<faucet_source_pubsub::PubsubSourceConfig>()),
996        #[cfg(feature = "source-clickhouse")]
997        "clickhouse" => Ok(schema::<faucet_source_clickhouse::ClickHouseSourceConfig>()),
998        #[cfg(feature = "source-azure-blob")]
999        "azure-blob" => Ok(schema::<faucet_source_azure_blob::AzureBlobSourceConfig>()),
1000        other => Err(unknown(other, "source", source_kinds())),
1001    }
1002}
1003
1004/// Check if a source kind is registered (not unknown or disabled by feature gate).
1005pub fn source_exists(kind: &str) -> bool {
1006    source_schema(kind).is_ok()
1007}
1008
1009/// Check if a sink kind is registered (not unknown or disabled by feature gate).
1010pub fn sink_exists(kind: &str) -> bool {
1011    sink_schema(kind).is_ok()
1012}
1013
1014/// Return the JSON Schema for the named sink's config struct.
1015pub fn sink_schema(kind: &str) -> CliResult<Value> {
1016    if let Some(entry) = global().sinks.get(kind) {
1017        return Ok((entry.schema)());
1018    }
1019    match kind {
1020        #[cfg(feature = "sink-bigquery")]
1021        "bigquery" => Ok(schema::<faucet_sink_bigquery::BigQuerySinkConfig>()),
1022        #[cfg(feature = "sink-iceberg")]
1023        "iceberg" => Ok(schema::<faucet_sink_iceberg::IcebergSinkConfig>()),
1024        #[cfg(feature = "sink-delta")]
1025        "delta" => Ok(schema::<faucet_sink_delta::DeltaSinkConfig>()),
1026        #[cfg(feature = "sink-postgres")]
1027        "postgres" => Ok(schema::<faucet_sink_postgres::PostgresSinkConfig>()),
1028        #[cfg(feature = "sink-jsonl")]
1029        "jsonl" => Ok(schema::<faucet_sink_jsonl::JsonlSinkConfig>()),
1030        #[cfg(feature = "sink-snowflake")]
1031        "snowflake" => Ok(schema::<faucet_sink_snowflake::SnowflakeSinkConfig>()),
1032        #[cfg(feature = "sink-mysql")]
1033        "mysql" => Ok(schema::<faucet_sink_mysql::MysqlSinkConfig>()),
1034        #[cfg(feature = "sink-mssql")]
1035        "mssql" => Ok(schema::<faucet_sink_mssql::MssqlSinkConfig>()),
1036        #[cfg(feature = "sink-sqlite")]
1037        "sqlite" => Ok(schema::<faucet_sink_sqlite::SqliteSinkConfig>()),
1038        #[cfg(feature = "sink-duckdb")]
1039        "duckdb" => Ok(schema::<faucet_sink_duckdb::DuckdbSinkConfig>()),
1040        #[cfg(feature = "sink-sqs")]
1041        "sqs" => Ok(schema::<faucet_sink_sqs::SqsSinkConfig>()),
1042        #[cfg(feature = "sink-nats")]
1043        "nats" => Ok(schema::<faucet_sink_nats::NatsSinkConfig>()),
1044        #[cfg(feature = "sink-sftp")]
1045        "sftp" => Ok(schema::<faucet_sink_sftp::SftpSinkConfig>()),
1046        #[cfg(feature = "sink-s3")]
1047        "s3" => Ok(schema::<faucet_sink_s3::S3SinkConfig>()),
1048        #[cfg(feature = "sink-mongodb")]
1049        "mongodb" => Ok(schema::<faucet_sink_mongodb::MongoSinkConfig>()),
1050        #[cfg(feature = "sink-redis")]
1051        "redis" => Ok(schema::<faucet_sink_redis::RedisSinkConfig>()),
1052        #[cfg(feature = "sink-csv")]
1053        "csv" => Ok(schema::<faucet_sink_csv::CsvSinkConfig>()),
1054        #[cfg(feature = "sink-elasticsearch")]
1055        "elasticsearch" => Ok(schema::<faucet_sink_elasticsearch::ElasticsearchSinkConfig>()),
1056        #[cfg(feature = "sink-kafka")]
1057        "kafka" => Ok(schema::<faucet_sink_kafka::KafkaSinkConfig>()),
1058        #[cfg(feature = "sink-kinesis")]
1059        "kinesis" => Ok(schema::<faucet_sink_kinesis::KinesisSinkConfig>()),
1060        #[cfg(feature = "sink-spanner")]
1061        "spanner" => Ok(schema::<faucet_sink_spanner::SpannerSinkConfig>()),
1062        #[cfg(feature = "sink-http")]
1063        "http" => Ok(schema::<faucet_sink_http::HttpSinkConfig>()),
1064        #[cfg(feature = "sink-stdout")]
1065        "stdout" => Ok(schema::<faucet_sink_stdout::StdoutSinkConfig>()),
1066        #[cfg(feature = "sink-parquet")]
1067        "parquet" => Ok(schema::<faucet_sink_parquet::ParquetSinkConfig>()),
1068        #[cfg(feature = "sink-gcs")]
1069        "gcs" => Ok(schema::<faucet_sink_gcs::GcsSinkConfig>()),
1070        #[cfg(feature = "sink-redshift")]
1071        "redshift" => Ok(schema::<faucet_sink_redshift::RedshiftSinkConfig>()),
1072        #[cfg(feature = "sink-pubsub")]
1073        "pubsub" => Ok(schema::<faucet_sink_pubsub::PubsubSinkConfig>()),
1074        #[cfg(feature = "sink-clickhouse")]
1075        "clickhouse" => Ok(schema::<faucet_sink_clickhouse::ClickHouseSinkConfig>()),
1076        #[cfg(feature = "sink-azure-blob")]
1077        "azure-blob" => Ok(schema::<faucet_sink_azure_blob::AzureBlobSinkConfig>()),
1078        other => Err(unknown(other, "sink", sink_kinds())),
1079    }
1080}
1081
1082/// One-line summary of every source connector — the compiled-in built-ins plus
1083/// any third-party connectors registered via [`PluginRegistry`]. Used by
1084/// `faucet list`.
1085pub fn source_descriptions() -> Vec<(&'static str, &'static str)> {
1086    let mut v = builtin_source_descriptions();
1087    v.extend(global().custom_source_descriptions());
1088    v
1089}
1090
1091/// One-line summary of every compiled-in built-in source connector (no customs).
1092#[allow(clippy::vec_init_then_push)]
1093fn builtin_source_descriptions() -> Vec<(&'static str, &'static str)> {
1094    let mut v: Vec<(&'static str, &'static str)> = Vec::new();
1095    #[cfg(feature = "source-rest")]
1096    v.push(("rest", "REST API source with pagination, auth, transforms"));
1097    #[cfg(feature = "source-graphql")]
1098    v.push(("graphql", "GraphQL API source with cursor pagination"));
1099    #[cfg(feature = "source-xml")]
1100    v.push(("xml", "XML / SOAP API source with XML→JSON conversion"));
1101    #[cfg(feature = "source-grpc")]
1102    v.push(("grpc", "gRPC source with dynamic protobuf"));
1103    #[cfg(feature = "source-postgres")]
1104    v.push(("postgres", "PostgreSQL query source"));
1105    #[cfg(feature = "source-postgres-cdc")]
1106    v.push((
1107        "postgres-cdc",
1108        "PostgreSQL CDC source (logical replication)",
1109    ));
1110    #[cfg(feature = "source-mysql")]
1111    v.push(("mysql", "MySQL query source"));
1112    #[cfg(feature = "source-mssql")]
1113    v.push(("mssql", "Microsoft SQL Server query source"));
1114    #[cfg(feature = "source-sqlite")]
1115    v.push(("sqlite", "SQLite query source"));
1116    #[cfg(feature = "source-duckdb")]
1117    v.push((
1118        "duckdb",
1119        "DuckDB query source. Runs SQL against a DuckDB file or in-memory database and streams rows as JSON with bounded memory.",
1120    ));
1121    #[cfg(feature = "source-sqs")]
1122    v.push((
1123        "sqs",
1124        "AWS SQS source. Long-polls ReceiveMessage, deletes after the batch is emitted (at-least-once), with idle/max-messages termination.",
1125    ));
1126    #[cfg(feature = "source-nats")]
1127    v.push((
1128        "nats",
1129        "NATS source. Subscribes to a subject (or a JetStream durable consumer) and drains with idle/max-messages termination.",
1130    ));
1131    #[cfg(feature = "source-sftp")]
1132    v.push((
1133        "sftp",
1134        "SFTP source. Lists/globs a remote directory and streams JSONL / JSON-array / raw-text files over SSH.",
1135    ));
1136    #[cfg(feature = "source-s3")]
1137    v.push(("s3", "AWS S3 object source"));
1138    #[cfg(feature = "source-mongodb")]
1139    v.push(("mongodb", "MongoDB query source"));
1140    #[cfg(feature = "source-mongodb-cdc")]
1141    v.push(("mongodb-cdc", "MongoDB CDC source (Change Streams)"));
1142    #[cfg(feature = "source-mysql-cdc")]
1143    v.push(("mysql-cdc", "MySQL CDC source (binlog replication)"));
1144    #[cfg(feature = "source-mssql-cdc")]
1145    v.push((
1146        "mssql-cdc",
1147        "Microsoft SQL Server CDC source (change data capture, exactly-once capable)",
1148    ));
1149    #[cfg(feature = "source-redshift")]
1150    v.push((
1151        "redshift",
1152        "Amazon Redshift query source (PostgreSQL wire; streaming rows, incremental replication)",
1153    ));
1154    #[cfg(feature = "source-pubsub")]
1155    v.push((
1156        "pubsub",
1157        "Google Cloud Pub/Sub consumer — streaming pull with per-message records, attribute mapping, and ack at durable page boundaries (at-least-once)",
1158    ));
1159    #[cfg(feature = "source-clickhouse")]
1160    v.push((
1161        "clickhouse",
1162        "ClickHouse query source (HTTP interface, JSONEachRow streaming)",
1163    ));
1164    #[cfg(feature = "source-azure-blob")]
1165    v.push((
1166        "azure-blob",
1167        "Azure Blob Storage / ADLS Gen2 source — JSONL, JSON array, or raw text",
1168    ));
1169    #[cfg(feature = "source-redis")]
1170    v.push(("redis", "Redis (streams, lists, keys) source"));
1171    #[cfg(feature = "source-webhook")]
1172    v.push(("webhook", "Webhook HTTP receiver source"));
1173    #[cfg(feature = "source-websocket")]
1174    v.push((
1175        "websocket",
1176        "WebSocket streaming source — connects, subscribes, streams each message as a record",
1177    ));
1178    #[cfg(feature = "source-csv")]
1179    v.push(("csv", "CSV file source"));
1180    #[cfg(feature = "source-singer")]
1181    v.push((
1182        "singer",
1183        "Singer tap bridge (runs an external Singer tap; single-stream v0, Tier-2/experimental)",
1184    ));
1185    #[cfg(feature = "source-elasticsearch")]
1186    v.push(("elasticsearch", "Elasticsearch search / scroll source"));
1187    #[cfg(feature = "source-kafka")]
1188    v.push(("kafka", "Apache Kafka consumer (rdkafka). Subscribes to topics and drains messages with idle/max-messages termination."));
1189    #[cfg(feature = "source-kinesis")]
1190    v.push(("kinesis", "AWS Kinesis Data Streams consumer. Per-shard workers with resumable sequence-number checkpoints and idle/max-messages termination."));
1191    #[cfg(feature = "source-spanner")]
1192    v.push(("spanner", "Google Cloud Spanner query source. Streaming SQL reads with incremental replication bookmarks, stale reads, and PK-range sharding."));
1193    #[cfg(feature = "source-parquet")]
1194    v.push(("parquet", "Apache Parquet file source (local path, glob, or S3). Streams record batches via the Arrow async reader."));
1195    #[cfg(feature = "source-delta")]
1196    v.push(("delta", "Apache Delta Lake source (local FS or S3/Azure/GCS). Streams active data files with time travel and projection pushdown."));
1197    #[cfg(feature = "source-databricks")]
1198    v.push(("databricks", "Databricks SQL query source (Statement Execution API). Streams typed query results with chunk pagination and incremental replication."));
1199    #[cfg(feature = "source-gcs")]
1200    v.push((
1201        "gcs",
1202        "Google Cloud Storage source — JSONL, JSON array, or raw text",
1203    ));
1204    #[cfg(feature = "source-bigquery")]
1205    v.push((
1206        "bigquery",
1207        "Google BigQuery query source (jobs.query + jobs.getQueryResults)",
1208    ));
1209    #[cfg(feature = "source-snowflake")]
1210    v.push((
1211        "snowflake",
1212        "Snowflake query source (SQL REST API with partition paging)",
1213    ));
1214    v
1215}
1216
1217/// One-line summary of every sink connector — the compiled-in built-ins plus
1218/// any third-party connectors registered via [`PluginRegistry`]. Used by
1219/// `faucet list`.
1220pub fn sink_descriptions() -> Vec<(&'static str, &'static str)> {
1221    let mut v = builtin_sink_descriptions();
1222    v.extend(global().custom_sink_descriptions());
1223    v
1224}
1225
1226/// One-line summary of every compiled-in built-in sink connector (no customs).
1227#[allow(clippy::vec_init_then_push)]
1228fn builtin_sink_descriptions() -> Vec<(&'static str, &'static str)> {
1229    let mut v: Vec<(&'static str, &'static str)> = Vec::new();
1230    #[cfg(feature = "sink-bigquery")]
1231    v.push(("bigquery", "Google BigQuery streaming-insert sink"));
1232    #[cfg(feature = "sink-iceberg")]
1233    v.push((
1234        "iceberg",
1235        "Apache Iceberg sink (append, REST/Glue/SQL/HMS catalogs)",
1236    ));
1237    #[cfg(feature = "sink-postgres")]
1238    v.push(("postgres", "PostgreSQL sink (JSONB or auto-mapped columns)"));
1239    #[cfg(feature = "sink-jsonl")]
1240    v.push(("jsonl", "JSON Lines file sink"));
1241    #[cfg(feature = "sink-snowflake")]
1242    v.push(("snowflake", "Snowflake SQL REST API sink"));
1243    #[cfg(feature = "sink-mysql")]
1244    v.push(("mysql", "MySQL sink"));
1245    #[cfg(feature = "sink-mssql")]
1246    v.push((
1247        "mssql",
1248        "Microsoft SQL Server sink (auto-mapped columns or JSON column)",
1249    ));
1250    #[cfg(feature = "sink-sqlite")]
1251    v.push(("sqlite", "SQLite sink"));
1252    #[cfg(feature = "sink-duckdb")]
1253    v.push((
1254        "duckdb",
1255        "DuckDB sink. Transaction-wrapped multi-row INSERT (JSON column or auto-mapped columns).",
1256    ));
1257    #[cfg(feature = "sink-sqs")]
1258    v.push((
1259        "sqs",
1260        "AWS SQS sink. Batched SendMessageBatch (10-message chunks) with per-entry partial-failure retry; FIFO group/dedup support.",
1261    ));
1262    #[cfg(feature = "sink-nats")]
1263    v.push((
1264        "nats",
1265        "NATS sink. Publishes records to a subject (optionally subject-per-record) and flushes per batch.",
1266    ));
1267    #[cfg(feature = "sink-sftp")]
1268    v.push((
1269        "sftp",
1270        "SFTP sink. Writes JSONL files over SSH with atomic temp-then-rename uploads.",
1271    ));
1272    #[cfg(feature = "sink-s3")]
1273    v.push(("s3", "AWS S3 object sink"));
1274    #[cfg(feature = "sink-mongodb")]
1275    v.push(("mongodb", "MongoDB insert sink"));
1276    #[cfg(feature = "sink-redis")]
1277    v.push(("redis", "Redis (streams, lists, key-value) sink"));
1278    #[cfg(feature = "sink-csv")]
1279    v.push(("csv", "CSV file sink"));
1280    #[cfg(feature = "sink-elasticsearch")]
1281    v.push(("elasticsearch", "Elasticsearch bulk index sink"));
1282    #[cfg(feature = "sink-kafka")]
1283    v.push(("kafka", "Apache Kafka producer (rdkafka). FuturesUnordered batched sends with QueueFull retry; supports fixed or per-record topic routing."));
1284    #[cfg(feature = "sink-kinesis")]
1285    v.push(("kinesis", "AWS Kinesis Data Streams producer. Batched PutRecords with partition-key routing and partial-failure retry (DLQ-routable)."));
1286    #[cfg(feature = "sink-spanner")]
1287    v.push(("spanner", "Google Cloud Spanner sink. Batched mutations with upsert/delete write modes, exactly-once commit tokens, and schema evolution."));
1288    #[cfg(feature = "sink-http")]
1289    v.push(("http", "HTTP POST sink (individual or array batch)"));
1290    #[cfg(feature = "sink-stdout")]
1291    v.push(("stdout", "Stdout / stderr sink (JSON Lines, pretty, TSV)"));
1292    #[cfg(feature = "sink-parquet")]
1293    v.push(("parquet", "Apache Parquet file sink (local path or S3). Schema-inferred, configurable compression, row/byte rollover."));
1294    #[cfg(feature = "sink-delta")]
1295    v.push(("delta", "Apache Delta Lake sink (local FS or S3/Azure/GCS). Append-only, schema-inferred table creation, one commit per flush."));
1296    #[cfg(feature = "sink-gcs")]
1297    v.push(("gcs", "Google Cloud Storage sink — JSONL files"));
1298    #[cfg(feature = "sink-redshift")]
1299    v.push((
1300        "redshift",
1301        "Amazon Redshift sink (COPY-from-S3 or multi-row INSERT)",
1302    ));
1303    #[cfg(feature = "sink-pubsub")]
1304    v.push((
1305        "pubsub",
1306        "Google Cloud Pub/Sub producer — batched publish with optional ordering keys, bounded concurrency, and partial-failure retry (DLQ-routable)",
1307    ));
1308    #[cfg(feature = "sink-clickhouse")]
1309    v.push((
1310        "clickhouse",
1311        "ClickHouse sink (HTTP INSERT … FORMAT JSONEachRow; optional async inserts)",
1312    ));
1313    #[cfg(feature = "sink-azure-blob")]
1314    v.push((
1315        "azure-blob",
1316        "Azure Blob Storage / ADLS Gen2 sink — JSONL files",
1317    ));
1318    v
1319}
1320
1321/// Names of every compiled-in source connector.
1322pub fn source_kinds() -> Vec<&'static str> {
1323    source_descriptions().into_iter().map(|(k, _)| k).collect()
1324}
1325
1326/// Names of every compiled-in sink connector.
1327pub fn sink_kinds() -> Vec<&'static str> {
1328    sink_descriptions().into_iter().map(|(k, _)| k).collect()
1329}
1330
1331fn decode<T: DeserializeOwned>(kind: &'static str, name: &str, config: Value) -> CliResult<T> {
1332    serde_json::from_value(config).map_err(|e| CliError::InvalidConnectorConfig {
1333        kind,
1334        name: name.to_owned(),
1335        message: scrub_config_error(&e.to_string()),
1336    })
1337}
1338
1339/// Sanitise a serde deserialization error before it reaches stderr/logs.
1340///
1341/// serde_json's `invalid type:` errors echo the offending value as a
1342/// double-quoted literal — which can be a secret injected via
1343/// `${secret:...}` / `${env:...}`. Replace every double-quoted run with a
1344/// placeholder (field/type names use backticks and are preserved for
1345/// diagnostics) and cap the length so a huge value can't flood the log
1346/// (#78/#38). Note: `${secret:}` is currently an `${env:}` alias with no
1347/// at-rest redaction — this only scrubs error *output*.
1348fn scrub_config_error(msg: &str) -> String {
1349    const MAX_CHARS: usize = 200;
1350    let mut out = String::with_capacity(msg.len());
1351    let mut in_quote = false;
1352    for c in msg.chars() {
1353        if c == '"' {
1354            if !in_quote {
1355                out.push_str("\"<redacted>\"");
1356            }
1357            in_quote = !in_quote;
1358            continue;
1359        }
1360        if !in_quote {
1361            out.push(c);
1362        }
1363    }
1364    if out.chars().count() > MAX_CHARS {
1365        let truncated: String = out.chars().take(MAX_CHARS).collect();
1366        return format!("{truncated}…");
1367    }
1368    out
1369}
1370
1371fn schema<T: faucet_core::JsonSchema>() -> Value {
1372    serde_json::to_value(faucet_core::schema_for!(T))
1373        .unwrap_or_else(|_| serde_json::json!({"type": "object"}))
1374}
1375
1376fn unknown(name: &str, kind: &'static str, available: Vec<&'static str>) -> CliError {
1377    CliError::UnknownConnector {
1378        kind,
1379        name: name.to_owned(),
1380        available: if available.is_empty() {
1381            "(none — rebuild faucet-cli with the relevant feature enabled)".to_owned()
1382        } else {
1383            available.join(", ")
1384        },
1385    }
1386}
1387
1388#[cfg(test)]
1389mod tests {
1390    use super::*;
1391
1392    // A trivial in-memory source used to exercise custom registration without
1393    // any I/O.
1394    #[derive(Clone)]
1395    struct DummySource;
1396    #[faucet_core::async_trait]
1397    impl Source for DummySource {
1398        async fn fetch_with_context(
1399            &self,
1400            _ctx: &std::collections::HashMap<String, Value>,
1401        ) -> Result<Vec<Value>, faucet_core::FaucetError> {
1402            Ok(vec![serde_json::json!({"ok": true})])
1403        }
1404        fn config_schema(&self) -> Value {
1405            serde_json::json!({"type": "object"})
1406        }
1407    }
1408
1409    #[test]
1410    fn register_source_rejects_builtin_collision() {
1411        // `csv` is a built-in whenever that feature is on; use a name we know is
1412        // built-in under --all-features to assert the collision guard fires.
1413        let reg = PluginRegistry::with_builtins()
1414            .register_source("csv", |_| Ok(Box::new(DummySource) as Box<dyn Source>));
1415        // install() surfaces the stashed error WITHOUT touching the global
1416        // (errors are checked before the OnceLock is set), so this is race-free.
1417        let err = reg
1418            .install()
1419            .expect_err("built-in collision must be rejected");
1420        match err {
1421            CliError::Config(msg) => assert!(msg.contains("built-in source"), "{msg}"),
1422            other => panic!("expected Config error, got {other:?}"),
1423        }
1424    }
1425
1426    #[test]
1427    fn register_source_rejects_duplicate() {
1428        let reg = PluginRegistry::new()
1429            .register_source("dup", |_| Ok(Box::new(DummySource) as Box<dyn Source>))
1430            .register_source("dup", |_| Ok(Box::new(DummySource) as Box<dyn Source>));
1431        let err = reg
1432            .install()
1433            .expect_err("duplicate registration must be rejected");
1434        match err {
1435            CliError::Config(msg) => assert!(msg.contains("more than once"), "{msg}"),
1436            other => panic!("expected Config error, got {other:?}"),
1437        }
1438    }
1439
1440    #[test]
1441    fn register_sink_rejects_duplicate() {
1442        // Build a registry with a duplicate sink and confirm the error is
1443        // stashed; we inspect it via the private field rather than install()
1444        // so no global state is touched even indirectly.
1445        let reg = PluginRegistry::new()
1446            .register_sink("dupsink", |_| Err(CliError::Config("unused".into())))
1447            .register_sink("dupsink", |_| Err(CliError::Config("unused".into())));
1448        assert!(
1449            reg.errors.iter().any(|e| e.contains("more than once")),
1450            "{:?}",
1451            reg.errors
1452        );
1453    }
1454
1455    #[test]
1456    fn custom_descriptions_use_default_when_blank() {
1457        let reg = PluginRegistry::new()
1458            .register_source("acme", |_| Ok(Box::new(DummySource) as Box<dyn Source>));
1459        let descs = reg.custom_source_descriptions();
1460        assert_eq!(descs.len(), 1);
1461        assert_eq!(descs[0].0, "acme");
1462        assert_eq!(descs[0].1, "custom source connector");
1463    }
1464
1465    #[test]
1466    fn custom_descriptions_carry_explicit_summary() {
1467        let reg = PluginRegistry::new().register_source_with(
1468            "acme",
1469            |_| Ok(Box::new(DummySource) as Box<dyn Source>),
1470            || serde_json::json!({"type": "object", "title": "acme"}),
1471            "Acme widget source",
1472        );
1473        let descs = reg.custom_source_descriptions();
1474        assert_eq!(descs[0], ("acme", "Acme widget source"));
1475        // The schema closure is what `faucet schema source acme` would print.
1476        assert_eq!(
1477            (reg.sources.get("acme").unwrap().schema)()["title"],
1478            serde_json::json!("acme")
1479        );
1480    }
1481
1482    #[test]
1483    fn capability_constants_match_their_predicates() {
1484        // F44: the human-readable lists in error messages derive from these
1485        // constants, which must stay in lockstep with the boolean gates. In
1486        // particular the idempotent-sink list must include bigquery AND kafka,
1487        // and the upsert-sink list must include bigquery — the values the old
1488        // hand-maintained message strings had drifted away from.
1489        for &k in EXACTLY_ONCE_SOURCE_KINDS {
1490            assert!(
1491                source_supports_exactly_once(k),
1492                "{k} should be exactly-once"
1493            );
1494        }
1495        for &k in IDEMPOTENT_SINK_KINDS {
1496            assert!(
1497                sink_supports_idempotent_writes(k),
1498                "{k} should be idempotent"
1499            );
1500        }
1501        for &k in UPSERT_SINK_KINDS {
1502            use faucet_core::WriteMode;
1503            assert!(
1504                sink_supported_write_modes(k).contains(&WriteMode::Upsert),
1505                "{k} should support upsert"
1506            );
1507        }
1508        assert!(IDEMPOTENT_SINK_KINDS.contains(&"bigquery"));
1509        assert!(IDEMPOTENT_SINK_KINDS.contains(&"kafka"));
1510        assert!(UPSERT_SINK_KINDS.contains(&"bigquery"));
1511    }
1512
1513    #[cfg(feature = "source-rest")]
1514    #[test]
1515    fn rest_source_appears_in_listings() {
1516        assert!(source_kinds().contains(&"rest"));
1517    }
1518
1519    #[cfg(feature = "sink-jsonl")]
1520    #[test]
1521    fn jsonl_sink_appears_in_listings() {
1522        assert!(sink_kinds().contains(&"jsonl"));
1523    }
1524
1525    #[tokio::test]
1526    async fn unknown_source_kind_errors() {
1527        let err = build_source("nope", serde_json::json!({}), &AuthCatalog::new(), None)
1528            .await
1529            .err()
1530            .expect("should fail");
1531        match err {
1532            CliError::UnknownConnector { kind, name, .. } => {
1533                assert_eq!(kind, "source");
1534                assert_eq!(name, "nope");
1535            }
1536            other => panic!("expected UnknownConnector, got {other:?}"),
1537        }
1538    }
1539
1540    #[tokio::test]
1541    async fn unknown_sink_kind_errors() {
1542        let err = build_sink("nope", serde_json::json!({}), &AuthCatalog::new())
1543            .await
1544            .err()
1545            .expect("should fail");
1546        assert!(matches!(
1547            err,
1548            CliError::UnknownConnector { kind: "sink", .. }
1549        ));
1550    }
1551
1552    #[cfg(feature = "source-rest")]
1553    #[test]
1554    fn rest_schema_is_object() {
1555        let s = source_schema("rest").unwrap();
1556        assert!(s.is_object());
1557    }
1558
1559    #[cfg(feature = "sink-jsonl")]
1560    #[test]
1561    fn jsonl_schema_is_object() {
1562        let s = sink_schema("jsonl").unwrap();
1563        assert!(s.is_object());
1564    }
1565
1566    #[test]
1567    fn scrub_config_error_redacts_quoted_values() {
1568        // A serde "invalid type" error echoes the offending value in double
1569        // quotes — must be redacted so a secret can't reach the log (#78/#38).
1570        let msg =
1571            r#"invalid type: string "sk-super-secret-123", expected a sequence at line 1 column 9"#;
1572        let scrubbed = scrub_config_error(msg);
1573        assert!(!scrubbed.contains("sk-super-secret-123"), "{scrubbed}");
1574        assert!(scrubbed.contains("<redacted>"), "{scrubbed}");
1575        // Structural context outside the quotes is preserved.
1576        assert!(scrubbed.contains("invalid type"), "{scrubbed}");
1577        assert!(scrubbed.contains("expected a sequence"), "{scrubbed}");
1578    }
1579
1580    #[test]
1581    fn scrub_config_error_truncates_long_messages() {
1582        let msg = "x".repeat(500);
1583        let scrubbed = scrub_config_error(&msg);
1584        assert!(
1585            scrubbed.chars().count() <= 201,
1586            "len {}",
1587            scrubbed.chars().count()
1588        );
1589        assert!(scrubbed.ends_with('…'));
1590    }
1591
1592    // A `(kind, config)` pair that builds without performing any network/disk
1593    // I/O — the CSV source's `new()` only stores config, so we can drive the
1594    // real `build_source` dispatch arm and inspect the resulting trait object.
1595    #[cfg(feature = "source-csv")]
1596    #[tokio::test]
1597    async fn build_source_csv_succeeds_without_io() {
1598        let src = build_source(
1599            "csv",
1600            serde_json::json!({ "path": "/tmp/does-not-need-to-exist.csv" }),
1601            &AuthCatalog::new(),
1602            None,
1603        )
1604        .await
1605        .expect("csv source should build without I/O");
1606        // The CSV source uses the default `connector_name()` (stripped type
1607        // name) rather than overriding it with a friendly label.
1608        assert_eq!(src.connector_name(), "CsvSource");
1609    }
1610
1611    // The JSONL sink's `new()` is also pure (it opens the file lazily on first
1612    // write), so building it exercises the sink dispatch arm with no I/O.
1613    #[cfg(feature = "sink-jsonl")]
1614    #[tokio::test]
1615    async fn build_sink_jsonl_succeeds_without_io() {
1616        let sink = build_sink(
1617            "jsonl",
1618            serde_json::json!({ "path": "/tmp/does-not-need-to-exist.jsonl" }),
1619            &AuthCatalog::new(),
1620        )
1621        .await
1622        .expect("jsonl sink should build without I/O");
1623        assert_eq!(sink.connector_name(), "jsonl");
1624    }
1625
1626    // The stdout sink builds without any config fields and without I/O.
1627    #[cfg(feature = "sink-stdout")]
1628    #[tokio::test]
1629    async fn build_sink_stdout_succeeds_without_io() {
1630        let sink = build_sink("stdout", serde_json::json!({}), &AuthCatalog::new())
1631            .await
1632            .expect("stdout sink should build without I/O");
1633        // The stdout sink uses the default `connector_name()` (stripped type
1634        // name) rather than overriding it with a friendly label.
1635        assert_eq!(sink.connector_name(), "StdoutSink");
1636    }
1637
1638    // Exercise the Delta source+sink registry arms end to end: build both via
1639    // the registry, round-trip a page through a real local table, and confirm
1640    // the schema + description arms resolve.
1641    #[cfg(all(feature = "source-delta", feature = "sink-delta"))]
1642    #[tokio::test]
1643    async fn delta_registry_round_trip() {
1644        let dir = tempfile::tempdir().unwrap();
1645        let uri = dir.path().join("reg_delta").to_string_lossy().into_owned();
1646
1647        assert!(source_schema("delta").is_ok());
1648        assert!(sink_schema("delta").is_ok());
1649        assert!(source_descriptions().iter().any(|(n, _)| *n == "delta"));
1650        assert!(sink_descriptions().iter().any(|(n, _)| *n == "delta"));
1651
1652        let sink = build_sink(
1653            "delta",
1654            serde_json::json!({ "table_uri": uri }),
1655            &AuthCatalog::new(),
1656        )
1657        .await
1658        .expect("delta sink builds");
1659        assert_eq!(sink.connector_name(), "delta");
1660        let n = sink
1661            .write_batch(&[serde_json::json!({"id": 1}), serde_json::json!({"id": 2})])
1662            .await
1663            .expect("write");
1664        assert_eq!(n, 2);
1665        sink.flush().await.expect("flush");
1666
1667        let source = build_source(
1668            "delta",
1669            serde_json::json!({ "table_uri": uri }),
1670            &AuthCatalog::new(),
1671            None,
1672        )
1673        .await
1674        .expect("delta source builds");
1675        assert_eq!(source.connector_name(), "delta");
1676        let rows = source
1677            .fetch_with_context(&std::collections::HashMap::new())
1678            .await
1679            .expect("read");
1680        assert_eq!(rows.len(), 2);
1681    }
1682
1683    // The Databricks source builds from the registry (no I/O in `new`), and its
1684    // schema + description arms resolve.
1685    #[cfg(feature = "source-databricks")]
1686    #[tokio::test]
1687    async fn databricks_registry_source_builds() {
1688        assert!(source_schema("databricks").is_ok());
1689        assert!(
1690            source_descriptions()
1691                .iter()
1692                .any(|(n, _)| *n == "databricks")
1693        );
1694        let cfg = serde_json::json!({
1695            "workspace_url": "https://x.cloud.databricks.com",
1696            "warehouse_id": "wh1",
1697            "sql": "SELECT 1",
1698            "auth": { "type": "pat", "config": { "token": "t" } }
1699        });
1700        let src = build_source("databricks", cfg, &AuthCatalog::new(), None)
1701            .await
1702            .expect("databricks source builds");
1703        assert_eq!(src.connector_name(), "databricks");
1704    }
1705
1706    // A malformed config for a known connector must surface as a typed
1707    // `InvalidConnectorConfig` from the `decode` helper, not a panic.
1708    #[cfg(feature = "source-csv")]
1709    #[tokio::test]
1710    async fn build_source_csv_invalid_config_errors() {
1711        // `path` is a required String; supplying an integer is a type error.
1712        // `Box<dyn Source>` is not `Debug`, so match the Result directly rather
1713        // than using `expect_err`.
1714        let res = build_source(
1715            "csv",
1716            serde_json::json!({ "path": 42 }),
1717            &AuthCatalog::new(),
1718            None,
1719        )
1720        .await;
1721        match res {
1722            Err(CliError::InvalidConnectorConfig { kind, name, .. }) => {
1723                assert_eq!(kind, "source");
1724                assert_eq!(name, "csv");
1725            }
1726            Ok(_) => panic!("expected InvalidConnectorConfig, got Ok"),
1727            Err(other) => panic!("expected InvalidConnectorConfig, got {other:?}"),
1728        }
1729    }
1730
1731    // `source_schema` must return a JSON object that surfaces the connector's
1732    // config fields (here: the required `path`).
1733    #[cfg(feature = "source-csv")]
1734    #[test]
1735    fn source_schema_csv_exposes_path_property() {
1736        let schema = source_schema("csv").expect("csv schema");
1737        let props = schema
1738            .get("properties")
1739            .and_then(Value::as_object)
1740            .expect("schema should have a properties object");
1741        assert!(props.contains_key("path"), "schema props: {props:?}");
1742    }
1743
1744    #[cfg(feature = "sink-jsonl")]
1745    #[test]
1746    fn sink_schema_jsonl_exposes_path_property() {
1747        let schema = sink_schema("jsonl").expect("jsonl schema");
1748        let props = schema
1749            .get("properties")
1750            .and_then(Value::as_object)
1751            .expect("schema should have a properties object");
1752        assert!(props.contains_key("path"), "schema props: {props:?}");
1753    }
1754
1755    #[test]
1756    fn unknown_source_schema_errors_with_available_list() {
1757        let err = source_schema("definitely-not-a-source").expect_err("unknown source");
1758        match err {
1759            CliError::UnknownConnector {
1760                kind,
1761                name,
1762                available,
1763            } => {
1764                assert_eq!(kind, "source");
1765                assert_eq!(name, "definitely-not-a-source");
1766                // Under `--all-features` the available list is non-empty.
1767                assert!(!available.is_empty());
1768            }
1769            other => panic!("expected UnknownConnector, got {other:?}"),
1770        }
1771    }
1772
1773    #[test]
1774    fn unknown_sink_schema_errors() {
1775        let err = sink_schema("definitely-not-a-sink").expect_err("unknown sink");
1776        assert!(matches!(
1777            err,
1778            CliError::UnknownConnector { kind: "sink", .. }
1779        ));
1780    }
1781
1782    #[cfg(feature = "source-csv")]
1783    #[test]
1784    fn source_exists_is_true_for_known_and_false_for_unknown() {
1785        assert!(source_exists("csv"));
1786        assert!(!source_exists("definitely-not-a-source"));
1787    }
1788
1789    #[cfg(feature = "sink-jsonl")]
1790    #[test]
1791    fn sink_exists_is_true_for_known_and_false_for_unknown() {
1792        assert!(sink_exists("jsonl"));
1793        assert!(!sink_exists("definitely-not-a-sink"));
1794    }
1795
1796    // Descriptions back `faucet list`: non-empty, with a one-line summary, and
1797    // each name must resolve to a real schema (no orphan listing).
1798    #[test]
1799    fn source_descriptions_are_non_empty_and_consistent() {
1800        let descs = source_descriptions();
1801        assert!(!descs.is_empty());
1802        for (name, summary) in &descs {
1803            assert!(!name.is_empty(), "empty connector name");
1804            assert!(!summary.is_empty(), "empty summary for {name}");
1805            assert!(
1806                source_schema(name).is_ok(),
1807                "listed source `{name}` has no schema"
1808            );
1809        }
1810    }
1811
1812    #[test]
1813    fn sink_descriptions_are_non_empty_and_consistent() {
1814        let descs = sink_descriptions();
1815        assert!(!descs.is_empty());
1816        for (name, summary) in &descs {
1817            assert!(!name.is_empty(), "empty connector name");
1818            assert!(!summary.is_empty(), "empty summary for {name}");
1819            assert!(
1820                sink_schema(name).is_ok(),
1821                "listed sink `{name}` has no schema"
1822            );
1823        }
1824    }
1825
1826    // `*_kinds()` is derived from `*_descriptions()`; under `--all-features`
1827    // the canonical built-in connectors must be present.
1828    #[cfg(all(feature = "source-csv", feature = "source-rest"))]
1829    #[test]
1830    fn source_kinds_contains_expected_builtins() {
1831        let kinds = source_kinds();
1832        assert!(kinds.contains(&"csv"));
1833        assert!(kinds.contains(&"rest"));
1834    }
1835
1836    #[cfg(all(feature = "sink-jsonl", feature = "sink-stdout"))]
1837    #[test]
1838    fn sink_kinds_contains_expected_builtins() {
1839        let kinds = sink_kinds();
1840        assert!(kinds.contains(&"jsonl"));
1841        assert!(kinds.contains(&"stdout"));
1842    }
1843
1844    // Build a catalog holding one `static` bearer provider, then build a
1845    // connector whose config carries `auth: { ref: "tok" }` — exercising the
1846    // `with_auth_provider` injection branch in the dispatch arm.
1847    #[cfg(feature = "source-rest")]
1848    #[tokio::test]
1849    async fn build_source_injects_referenced_auth_provider() {
1850        let mut specs = std::collections::HashMap::new();
1851        specs.insert(
1852            "tok".to_string(),
1853            serde_json::json!({"type": "static", "config": {"token": "abc"}}),
1854        );
1855        let catalog = auth_catalog::build_auth_catalog(Some(&specs)).expect("catalog");
1856
1857        let src = build_source("rest", rest_config_with_auth_ref("tok"), &catalog, None)
1858            .await
1859            .expect("rest source with a resolvable auth ref should build");
1860        assert_eq!(src.connector_name(), "rest");
1861    }
1862
1863    // A minimal, fully-valid rest config (built from the real constructor so
1864    // every required field is present) carrying an `auth: { ref }` pointer.
1865    #[cfg(feature = "source-rest")]
1866    fn rest_config_with_auth_ref(name: &str) -> Value {
1867        let cfg = faucet_source_rest::RestStreamConfig::new("https://api.example.com", "/v1");
1868        let mut v = serde_json::to_value(cfg).expect("serialize rest config");
1869        v.as_object_mut()
1870            .unwrap()
1871            .insert("auth".to_string(), serde_json::json!({ "ref": name }));
1872        v
1873    }
1874
1875    // An `auth: { ref }` pointing at a name absent from the catalog must surface
1876    // as `UnknownAuthProvider`, not silently build without auth.
1877    #[cfg(feature = "source-rest")]
1878    #[tokio::test]
1879    async fn build_source_unknown_auth_ref_errors() {
1880        let res = build_source(
1881            "rest",
1882            rest_config_with_auth_ref("missing"),
1883            &AuthCatalog::new(),
1884            None,
1885        )
1886        .await;
1887        match res {
1888            Err(CliError::UnknownAuthProvider { name, .. }) => assert_eq!(name, "missing"),
1889            Ok(_) => panic!("expected UnknownAuthProvider, got Ok"),
1890            Err(other) => panic!("expected UnknownAuthProvider, got {other:?}"),
1891        }
1892    }
1893
1894    #[test]
1895    fn exactly_once_capability_allowlists() {
1896        assert!(source_supports_exactly_once("postgres-cdc"));
1897        assert!(source_supports_exactly_once("mysql-cdc"));
1898        assert!(source_supports_exactly_once("mongodb-cdc"));
1899        assert!(source_supports_exactly_once("kafka"));
1900        assert!(!source_supports_exactly_once("rest"));
1901
1902        assert!(sink_supports_idempotent_writes("postgres"));
1903        assert!(sink_supports_idempotent_writes("iceberg"));
1904        assert!(sink_supports_idempotent_writes("bigquery"));
1905        assert!(sink_supports_idempotent_writes("kafka"));
1906        assert!(sink_supports_idempotent_writes("snowflake"));
1907        assert!(sink_supports_idempotent_writes("redis"));
1908        assert!(sink_supports_idempotent_writes("mongodb"));
1909        assert!(!sink_supports_idempotent_writes("jsonl"));
1910    }
1911
1912    #[test]
1913    fn typed_delivery_capabilities_derive_from_kind_tables() {
1914        use faucet_core::{ReplayGuarantee, SinkGuarantee};
1915        assert_eq!(
1916            source_replay_guarantee("kafka"),
1917            ReplayGuarantee::Deterministic
1918        );
1919        assert_eq!(
1920            source_replay_guarantee("rest"),
1921            ReplayGuarantee::NonDeterministic
1922        );
1923        assert_eq!(sink_guarantee("postgres"), SinkGuarantee::AtomicWatermark);
1924        // Upsert-capable but not atomic: elasticsearch dedups by key only.
1925        assert_eq!(sink_guarantee("elasticsearch"), SinkGuarantee::KeyedUpsert);
1926        assert_eq!(sink_guarantee("jsonl"), SinkGuarantee::AtLeastOnce);
1927    }
1928
1929    #[test]
1930    fn sink_supported_write_modes_allowlist() {
1931        use faucet_core::WriteMode;
1932        assert!(sink_supported_write_modes("postgres").contains(&WriteMode::Upsert));
1933        assert!(sink_supported_write_modes("elasticsearch").contains(&WriteMode::Delete));
1934        assert!(sink_supported_write_modes("bigquery").contains(&WriteMode::Upsert));
1935        // a sink without upsert support is append-only
1936        assert_eq!(sink_supported_write_modes("jsonl"), &[WriteMode::Append]);
1937        assert_eq!(sink_supported_write_modes("kafka"), &[WriteMode::Append]);
1938    }
1939
1940    #[test]
1941    fn sink_supports_schema_evolution_allowlist() {
1942        assert!(sink_supports_schema_evolution("postgres"));
1943        assert!(sink_supports_schema_evolution("mysql"));
1944        assert!(sink_supports_schema_evolution("mssql"));
1945        assert!(sink_supports_schema_evolution("sqlite"));
1946        assert!(sink_supports_schema_evolution("bigquery"));
1947        assert!(sink_supports_schema_evolution("elasticsearch"));
1948        assert!(sink_supports_schema_evolution("iceberg"));
1949        assert!(!sink_supports_schema_evolution("jsonl"));
1950        assert!(!sink_supports_schema_evolution("kafka"));
1951    }
1952}