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/// Sink kinds that implement scoped cleanup (`Sink::cleanup_scope`, #478) —
846/// deleting destination rows inside a source's declared completeness scope that
847/// the run did not write. Kept in sync with each sink's `supports_cleanup()`
848/// override; `cli/tests/registry_capability_parity.rs` asserts they agree.
849///
850/// Currently the same set as [`UPSERT_SINK_KINDS`]: cleanup is only meaningful
851/// alongside `write_mode: upsert`, which is exactly what those sinks support.
852/// They are separate constants because that coincidence is not a guarantee — a
853/// future upsert-capable sink whose backend cannot express a scoped delete would
854/// belong in one list and not the other.
855pub const CLEANUP_SINK_KINDS: &[&str] = &[
856    "postgres",
857    "sqlite",
858    "mysql",
859    "mssql",
860    "mongodb",
861    "elasticsearch",
862    "bigquery",
863    "spanner",
864];
865
866/// Source kinds that implement live dataset discovery (`Source::discover`,
867/// issue #211) — mirrors the discoverable-source list in the connector docs.
868/// Single source of truth for the conformance scorecard (#330).
869pub const DISCOVER_SOURCE_KINDS: &[&str] = &[
870    "postgres",
871    "mysql",
872    "mssql",
873    "sqlite",
874    "mongodb",
875    "elasticsearch",
876    "bigquery",
877    "snowflake",
878    "spanner",
879    "s3",
880    "gcs",
881];
882
883/// Whether a source kind supports `faucet discover` (dataset introspection).
884pub fn source_supports_discover(kind: &str) -> bool {
885    DISCOVER_SOURCE_KINDS.contains(&kind)
886}
887
888/// The typed replay capability a source kind advertises
889/// (`Source::replay_guarantee`, issue #292). Derived from
890/// [`EXACTLY_ONCE_SOURCE_KINDS`] — the kind table stays the single source of
891/// truth; this is the typed view the delivery-guarantee derivation consumes.
892pub fn source_replay_guarantee(kind: &str) -> faucet_core::ReplayGuarantee {
893    if EXACTLY_ONCE_SOURCE_KINDS.contains(&kind) {
894        faucet_core::ReplayGuarantee::Deterministic
895    } else {
896        faucet_core::ReplayGuarantee::NonDeterministic
897    }
898}
899
900/// The strongest delivery guarantee a sink kind can uphold
901/// (`Sink::sink_guarantee`, issue #292). Derived from
902/// [`IDEMPOTENT_SINK_KINDS`] / [`UPSERT_SINK_KINDS`].
903pub fn sink_guarantee(kind: &str) -> faucet_core::SinkGuarantee {
904    if IDEMPOTENT_SINK_KINDS.contains(&kind) {
905        faucet_core::SinkGuarantee::AtomicWatermark
906    } else if UPSERT_SINK_KINDS.contains(&kind) {
907        faucet_core::SinkGuarantee::KeyedUpsert
908    } else {
909        faucet_core::SinkGuarantee::AtLeastOnce
910    }
911}
912
913/// See [`EXACTLY_ONCE_SOURCE_KINDS`].
914pub fn source_supports_exactly_once(kind: &str) -> bool {
915    source_replay_guarantee(kind) == faucet_core::ReplayGuarantee::Deterministic
916}
917
918/// See [`IDEMPOTENT_SINK_KINDS`].
919pub fn sink_supports_idempotent_writes(kind: &str) -> bool {
920    sink_guarantee(kind) == faucet_core::SinkGuarantee::AtomicWatermark
921}
922
923/// See [`SCHEMA_EVOLUTION_SINK_KINDS`].
924pub fn sink_supports_schema_evolution(kind: &str) -> bool {
925    SCHEMA_EVOLUTION_SINK_KINDS.contains(&kind)
926}
927
928/// See [`CLEANUP_SINK_KINDS`].
929pub fn sink_supports_cleanup(kind: &str) -> bool {
930    CLEANUP_SINK_KINDS.contains(&kind)
931}
932
933/// Write modes each sink kind supports. Kept in sync with each sink's
934/// `Sink::supported_write_modes()` override via [`UPSERT_SINK_KINDS`].
935pub fn sink_supported_write_modes(kind: &str) -> &'static [faucet_core::WriteMode] {
936    use faucet_core::WriteMode;
937    if UPSERT_SINK_KINDS.contains(&kind) {
938        &[WriteMode::Append, WriteMode::Upsert, WriteMode::Delete]
939    } else {
940        &[WriteMode::Append]
941    }
942}
943
944/// Return the JSON Schema for the named source's config struct.
945pub fn source_schema(kind: &str) -> CliResult<Value> {
946    if let Some(entry) = global().sources.get(kind) {
947        return Ok((entry.schema)());
948    }
949    match kind {
950        #[cfg(feature = "source-rest")]
951        "rest" => Ok(schema::<faucet_source_rest::RestStreamConfig>()),
952        #[cfg(feature = "source-graphql")]
953        "graphql" => Ok(schema::<faucet_source_graphql::GraphqlStreamConfig>()),
954        #[cfg(feature = "source-xml")]
955        "xml" => Ok(schema::<faucet_source_xml::XmlStreamConfig>()),
956        #[cfg(feature = "source-grpc")]
957        "grpc" => Ok(schema::<faucet_source_grpc::GrpcStreamConfig>()),
958        #[cfg(feature = "source-postgres")]
959        "postgres" => Ok(schema::<faucet_source_postgres::PostgresSourceConfig>()),
960        #[cfg(feature = "source-postgres-cdc")]
961        "postgres-cdc" => Ok(schema::<faucet_source_postgres_cdc::PostgresCdcSourceConfig>()),
962        #[cfg(feature = "source-mysql")]
963        "mysql" => Ok(schema::<faucet_source_mysql::MysqlSourceConfig>()),
964        #[cfg(feature = "source-mssql")]
965        "mssql" => Ok(schema::<faucet_source_mssql::MssqlSourceConfig>()),
966        #[cfg(feature = "source-sqlite")]
967        "sqlite" => Ok(schema::<faucet_source_sqlite::SqliteSourceConfig>()),
968        #[cfg(feature = "source-duckdb")]
969        "duckdb" => Ok(schema::<faucet_source_duckdb::DuckdbSourceConfig>()),
970        #[cfg(feature = "source-sqs")]
971        "sqs" => Ok(schema::<faucet_source_sqs::SqsSourceConfig>()),
972        #[cfg(feature = "source-nats")]
973        "nats" => Ok(schema::<faucet_source_nats::NatsSourceConfig>()),
974        #[cfg(feature = "source-sftp")]
975        "sftp" => Ok(schema::<faucet_source_sftp::SftpSourceConfig>()),
976        #[cfg(feature = "source-s3")]
977        "s3" => Ok(schema::<faucet_source_s3::S3SourceConfig>()),
978        #[cfg(feature = "source-mongodb")]
979        "mongodb" => Ok(schema::<faucet_source_mongodb::MongoSourceConfig>()),
980        #[cfg(feature = "source-mongodb-cdc")]
981        "mongodb-cdc" => Ok(schema::<faucet_source_mongodb_cdc::MongoCdcSourceConfig>()),
982        #[cfg(feature = "source-mysql-cdc")]
983        "mysql-cdc" => Ok(schema::<faucet_source_mysql_cdc::MysqlCdcSourceConfig>()),
984        #[cfg(feature = "source-redis")]
985        "redis" => Ok(schema::<faucet_source_redis::RedisSourceConfig>()),
986        #[cfg(feature = "source-webhook")]
987        "webhook" => Ok(schema::<faucet_source_webhook::WebhookSourceConfig>()),
988        #[cfg(feature = "source-websocket")]
989        "websocket" => Ok(schema::<faucet_source_websocket::WebsocketSourceConfig>()),
990        #[cfg(feature = "source-csv")]
991        "csv" => Ok(schema::<faucet_source_csv::CsvSourceConfig>()),
992        #[cfg(feature = "source-singer")]
993        "singer" => Ok(schema::<faucet_source_singer::SingerSourceConfig>()),
994        #[cfg(feature = "source-elasticsearch")]
995        "elasticsearch" => Ok(schema::<
996            faucet_source_elasticsearch::ElasticsearchSourceConfig,
997        >()),
998        #[cfg(feature = "source-kafka")]
999        "kafka" => Ok(schema::<faucet_source_kafka::KafkaSourceConfig>()),
1000        #[cfg(feature = "source-kinesis")]
1001        "kinesis" => Ok(schema::<faucet_source_kinesis::KinesisSourceConfig>()),
1002        #[cfg(feature = "source-spanner")]
1003        "spanner" => Ok(schema::<faucet_source_spanner::SpannerSourceConfig>()),
1004        #[cfg(feature = "source-parquet")]
1005        "parquet" => Ok(schema::<faucet_source_parquet::ParquetSourceConfig>()),
1006        #[cfg(feature = "source-delta")]
1007        "delta" => Ok(schema::<faucet_source_delta::DeltaSourceConfig>()),
1008        #[cfg(feature = "source-databricks")]
1009        "databricks" => Ok(schema::<faucet_source_databricks::DatabricksSourceConfig>()),
1010        #[cfg(feature = "source-gcs")]
1011        "gcs" => Ok(schema::<faucet_source_gcs::GcsSourceConfig>()),
1012        #[cfg(feature = "source-bigquery")]
1013        "bigquery" => Ok(schema::<faucet_source_bigquery::BigQuerySourceConfig>()),
1014        #[cfg(feature = "source-snowflake")]
1015        "snowflake" => Ok(schema::<faucet_source_snowflake::SnowflakeSourceConfig>()),
1016        #[cfg(feature = "source-mssql-cdc")]
1017        "mssql-cdc" => Ok(schema::<faucet_source_mssql_cdc::MssqlCdcSourceConfig>()),
1018        #[cfg(feature = "source-redshift")]
1019        "redshift" => Ok(schema::<faucet_source_redshift::RedshiftSourceConfig>()),
1020        #[cfg(feature = "source-pubsub")]
1021        "pubsub" => Ok(schema::<faucet_source_pubsub::PubsubSourceConfig>()),
1022        #[cfg(feature = "source-clickhouse")]
1023        "clickhouse" => Ok(schema::<faucet_source_clickhouse::ClickHouseSourceConfig>()),
1024        #[cfg(feature = "source-azure-blob")]
1025        "azure-blob" => Ok(schema::<faucet_source_azure_blob::AzureBlobSourceConfig>()),
1026        other => Err(unknown(other, "source", source_kinds())),
1027    }
1028}
1029
1030/// Check if a source kind is registered (not unknown or disabled by feature gate).
1031pub fn source_exists(kind: &str) -> bool {
1032    source_schema(kind).is_ok()
1033}
1034
1035/// Check if a sink kind is registered (not unknown or disabled by feature gate).
1036pub fn sink_exists(kind: &str) -> bool {
1037    sink_schema(kind).is_ok()
1038}
1039
1040/// Return the JSON Schema for the named sink's config struct.
1041pub fn sink_schema(kind: &str) -> CliResult<Value> {
1042    if let Some(entry) = global().sinks.get(kind) {
1043        return Ok((entry.schema)());
1044    }
1045    match kind {
1046        #[cfg(feature = "sink-bigquery")]
1047        "bigquery" => Ok(schema::<faucet_sink_bigquery::BigQuerySinkConfig>()),
1048        #[cfg(feature = "sink-iceberg")]
1049        "iceberg" => Ok(schema::<faucet_sink_iceberg::IcebergSinkConfig>()),
1050        #[cfg(feature = "sink-delta")]
1051        "delta" => Ok(schema::<faucet_sink_delta::DeltaSinkConfig>()),
1052        #[cfg(feature = "sink-postgres")]
1053        "postgres" => Ok(schema::<faucet_sink_postgres::PostgresSinkConfig>()),
1054        #[cfg(feature = "sink-jsonl")]
1055        "jsonl" => Ok(schema::<faucet_sink_jsonl::JsonlSinkConfig>()),
1056        #[cfg(feature = "sink-snowflake")]
1057        "snowflake" => Ok(schema::<faucet_sink_snowflake::SnowflakeSinkConfig>()),
1058        #[cfg(feature = "sink-mysql")]
1059        "mysql" => Ok(schema::<faucet_sink_mysql::MysqlSinkConfig>()),
1060        #[cfg(feature = "sink-mssql")]
1061        "mssql" => Ok(schema::<faucet_sink_mssql::MssqlSinkConfig>()),
1062        #[cfg(feature = "sink-sqlite")]
1063        "sqlite" => Ok(schema::<faucet_sink_sqlite::SqliteSinkConfig>()),
1064        #[cfg(feature = "sink-duckdb")]
1065        "duckdb" => Ok(schema::<faucet_sink_duckdb::DuckdbSinkConfig>()),
1066        #[cfg(feature = "sink-sqs")]
1067        "sqs" => Ok(schema::<faucet_sink_sqs::SqsSinkConfig>()),
1068        #[cfg(feature = "sink-nats")]
1069        "nats" => Ok(schema::<faucet_sink_nats::NatsSinkConfig>()),
1070        #[cfg(feature = "sink-sftp")]
1071        "sftp" => Ok(schema::<faucet_sink_sftp::SftpSinkConfig>()),
1072        #[cfg(feature = "sink-s3")]
1073        "s3" => Ok(schema::<faucet_sink_s3::S3SinkConfig>()),
1074        #[cfg(feature = "sink-mongodb")]
1075        "mongodb" => Ok(schema::<faucet_sink_mongodb::MongoSinkConfig>()),
1076        #[cfg(feature = "sink-redis")]
1077        "redis" => Ok(schema::<faucet_sink_redis::RedisSinkConfig>()),
1078        #[cfg(feature = "sink-csv")]
1079        "csv" => Ok(schema::<faucet_sink_csv::CsvSinkConfig>()),
1080        #[cfg(feature = "sink-elasticsearch")]
1081        "elasticsearch" => Ok(schema::<faucet_sink_elasticsearch::ElasticsearchSinkConfig>()),
1082        #[cfg(feature = "sink-kafka")]
1083        "kafka" => Ok(schema::<faucet_sink_kafka::KafkaSinkConfig>()),
1084        #[cfg(feature = "sink-kinesis")]
1085        "kinesis" => Ok(schema::<faucet_sink_kinesis::KinesisSinkConfig>()),
1086        #[cfg(feature = "sink-spanner")]
1087        "spanner" => Ok(schema::<faucet_sink_spanner::SpannerSinkConfig>()),
1088        #[cfg(feature = "sink-http")]
1089        "http" => Ok(schema::<faucet_sink_http::HttpSinkConfig>()),
1090        #[cfg(feature = "sink-stdout")]
1091        "stdout" => Ok(schema::<faucet_sink_stdout::StdoutSinkConfig>()),
1092        #[cfg(feature = "sink-parquet")]
1093        "parquet" => Ok(schema::<faucet_sink_parquet::ParquetSinkConfig>()),
1094        #[cfg(feature = "sink-gcs")]
1095        "gcs" => Ok(schema::<faucet_sink_gcs::GcsSinkConfig>()),
1096        #[cfg(feature = "sink-redshift")]
1097        "redshift" => Ok(schema::<faucet_sink_redshift::RedshiftSinkConfig>()),
1098        #[cfg(feature = "sink-pubsub")]
1099        "pubsub" => Ok(schema::<faucet_sink_pubsub::PubsubSinkConfig>()),
1100        #[cfg(feature = "sink-clickhouse")]
1101        "clickhouse" => Ok(schema::<faucet_sink_clickhouse::ClickHouseSinkConfig>()),
1102        #[cfg(feature = "sink-azure-blob")]
1103        "azure-blob" => Ok(schema::<faucet_sink_azure_blob::AzureBlobSinkConfig>()),
1104        other => Err(unknown(other, "sink", sink_kinds())),
1105    }
1106}
1107
1108/// One-line summary of every source connector — the compiled-in built-ins plus
1109/// any third-party connectors registered via [`PluginRegistry`]. Used by
1110/// `faucet list`.
1111pub fn source_descriptions() -> Vec<(&'static str, &'static str)> {
1112    let mut v = builtin_source_descriptions();
1113    v.extend(global().custom_source_descriptions());
1114    v
1115}
1116
1117/// One-line summary of every compiled-in built-in source connector (no customs).
1118#[allow(clippy::vec_init_then_push)]
1119fn builtin_source_descriptions() -> Vec<(&'static str, &'static str)> {
1120    let mut v: Vec<(&'static str, &'static str)> = Vec::new();
1121    #[cfg(feature = "source-rest")]
1122    v.push(("rest", "REST API source with pagination, auth, transforms"));
1123    #[cfg(feature = "source-graphql")]
1124    v.push(("graphql", "GraphQL API source with cursor pagination"));
1125    #[cfg(feature = "source-xml")]
1126    v.push(("xml", "XML / SOAP API source with XML→JSON conversion"));
1127    #[cfg(feature = "source-grpc")]
1128    v.push(("grpc", "gRPC source with dynamic protobuf"));
1129    #[cfg(feature = "source-postgres")]
1130    v.push(("postgres", "PostgreSQL query source"));
1131    #[cfg(feature = "source-postgres-cdc")]
1132    v.push((
1133        "postgres-cdc",
1134        "PostgreSQL CDC source (logical replication)",
1135    ));
1136    #[cfg(feature = "source-mysql")]
1137    v.push(("mysql", "MySQL query source"));
1138    #[cfg(feature = "source-mssql")]
1139    v.push(("mssql", "Microsoft SQL Server query source"));
1140    #[cfg(feature = "source-sqlite")]
1141    v.push(("sqlite", "SQLite query source"));
1142    #[cfg(feature = "source-duckdb")]
1143    v.push((
1144        "duckdb",
1145        "DuckDB query source. Runs SQL against a DuckDB file or in-memory database and streams rows as JSON with bounded memory.",
1146    ));
1147    #[cfg(feature = "source-sqs")]
1148    v.push((
1149        "sqs",
1150        "AWS SQS source. Long-polls ReceiveMessage, deletes after the batch is emitted (at-least-once), with idle/max-messages termination.",
1151    ));
1152    #[cfg(feature = "source-nats")]
1153    v.push((
1154        "nats",
1155        "NATS source. Subscribes to a subject (or a JetStream durable consumer) and drains with idle/max-messages termination.",
1156    ));
1157    #[cfg(feature = "source-sftp")]
1158    v.push((
1159        "sftp",
1160        "SFTP source. Lists/globs a remote directory and streams JSONL / JSON-array / raw-text files over SSH.",
1161    ));
1162    #[cfg(feature = "source-s3")]
1163    v.push(("s3", "AWS S3 object source"));
1164    #[cfg(feature = "source-mongodb")]
1165    v.push(("mongodb", "MongoDB query source"));
1166    #[cfg(feature = "source-mongodb-cdc")]
1167    v.push(("mongodb-cdc", "MongoDB CDC source (Change Streams)"));
1168    #[cfg(feature = "source-mysql-cdc")]
1169    v.push(("mysql-cdc", "MySQL CDC source (binlog replication)"));
1170    #[cfg(feature = "source-mssql-cdc")]
1171    v.push((
1172        "mssql-cdc",
1173        "Microsoft SQL Server CDC source (change data capture, exactly-once capable)",
1174    ));
1175    #[cfg(feature = "source-redshift")]
1176    v.push((
1177        "redshift",
1178        "Amazon Redshift query source (PostgreSQL wire; streaming rows, incremental replication)",
1179    ));
1180    #[cfg(feature = "source-pubsub")]
1181    v.push((
1182        "pubsub",
1183        "Google Cloud Pub/Sub consumer — streaming pull with per-message records, attribute mapping, and ack at durable page boundaries (at-least-once)",
1184    ));
1185    #[cfg(feature = "source-clickhouse")]
1186    v.push((
1187        "clickhouse",
1188        "ClickHouse query source (HTTP interface, JSONEachRow streaming)",
1189    ));
1190    #[cfg(feature = "source-azure-blob")]
1191    v.push((
1192        "azure-blob",
1193        "Azure Blob Storage / ADLS Gen2 source — JSONL, JSON array, or raw text",
1194    ));
1195    #[cfg(feature = "source-redis")]
1196    v.push(("redis", "Redis (streams, lists, keys) source"));
1197    #[cfg(feature = "source-webhook")]
1198    v.push(("webhook", "Webhook HTTP receiver source"));
1199    #[cfg(feature = "source-websocket")]
1200    v.push((
1201        "websocket",
1202        "WebSocket streaming source — connects, subscribes, streams each message as a record",
1203    ));
1204    #[cfg(feature = "source-csv")]
1205    v.push(("csv", "CSV file source"));
1206    #[cfg(feature = "source-singer")]
1207    v.push((
1208        "singer",
1209        "Singer tap bridge (runs an external Singer tap; single-stream v0, Tier-2/experimental)",
1210    ));
1211    #[cfg(feature = "source-elasticsearch")]
1212    v.push(("elasticsearch", "Elasticsearch search / scroll source"));
1213    #[cfg(feature = "source-kafka")]
1214    v.push(("kafka", "Apache Kafka consumer (rdkafka). Subscribes to topics and drains messages with idle/max-messages termination."));
1215    #[cfg(feature = "source-kinesis")]
1216    v.push(("kinesis", "AWS Kinesis Data Streams consumer. Per-shard workers with resumable sequence-number checkpoints and idle/max-messages termination."));
1217    #[cfg(feature = "source-spanner")]
1218    v.push(("spanner", "Google Cloud Spanner query source. Streaming SQL reads with incremental replication bookmarks, stale reads, and PK-range sharding."));
1219    #[cfg(feature = "source-parquet")]
1220    v.push(("parquet", "Apache Parquet file source (local path, glob, or S3). Streams record batches via the Arrow async reader."));
1221    #[cfg(feature = "source-delta")]
1222    v.push(("delta", "Apache Delta Lake source (local FS or S3/Azure/GCS). Streams active data files with time travel and projection pushdown."));
1223    #[cfg(feature = "source-databricks")]
1224    v.push(("databricks", "Databricks SQL query source (Statement Execution API). Streams typed query results with chunk pagination and incremental replication."));
1225    #[cfg(feature = "source-gcs")]
1226    v.push((
1227        "gcs",
1228        "Google Cloud Storage source — JSONL, JSON array, or raw text",
1229    ));
1230    #[cfg(feature = "source-bigquery")]
1231    v.push((
1232        "bigquery",
1233        "Google BigQuery query source (jobs.query + jobs.getQueryResults)",
1234    ));
1235    #[cfg(feature = "source-snowflake")]
1236    v.push((
1237        "snowflake",
1238        "Snowflake query source (SQL REST API with partition paging)",
1239    ));
1240    v
1241}
1242
1243/// One-line summary of every sink connector — the compiled-in built-ins plus
1244/// any third-party connectors registered via [`PluginRegistry`]. Used by
1245/// `faucet list`.
1246pub fn sink_descriptions() -> Vec<(&'static str, &'static str)> {
1247    let mut v = builtin_sink_descriptions();
1248    v.extend(global().custom_sink_descriptions());
1249    v
1250}
1251
1252/// One-line summary of every compiled-in built-in sink connector (no customs).
1253#[allow(clippy::vec_init_then_push)]
1254fn builtin_sink_descriptions() -> Vec<(&'static str, &'static str)> {
1255    let mut v: Vec<(&'static str, &'static str)> = Vec::new();
1256    #[cfg(feature = "sink-bigquery")]
1257    v.push(("bigquery", "Google BigQuery streaming-insert sink"));
1258    #[cfg(feature = "sink-iceberg")]
1259    v.push((
1260        "iceberg",
1261        "Apache Iceberg sink (append, REST/Glue/SQL/HMS catalogs)",
1262    ));
1263    #[cfg(feature = "sink-postgres")]
1264    v.push(("postgres", "PostgreSQL sink (JSONB or auto-mapped columns)"));
1265    #[cfg(feature = "sink-jsonl")]
1266    v.push(("jsonl", "JSON Lines file sink"));
1267    #[cfg(feature = "sink-snowflake")]
1268    v.push(("snowflake", "Snowflake SQL REST API sink"));
1269    #[cfg(feature = "sink-mysql")]
1270    v.push(("mysql", "MySQL sink"));
1271    #[cfg(feature = "sink-mssql")]
1272    v.push((
1273        "mssql",
1274        "Microsoft SQL Server sink (auto-mapped columns or JSON column)",
1275    ));
1276    #[cfg(feature = "sink-sqlite")]
1277    v.push(("sqlite", "SQLite sink"));
1278    #[cfg(feature = "sink-duckdb")]
1279    v.push((
1280        "duckdb",
1281        "DuckDB sink. Transaction-wrapped multi-row INSERT (JSON column or auto-mapped columns).",
1282    ));
1283    #[cfg(feature = "sink-sqs")]
1284    v.push((
1285        "sqs",
1286        "AWS SQS sink. Batched SendMessageBatch (10-message chunks) with per-entry partial-failure retry; FIFO group/dedup support.",
1287    ));
1288    #[cfg(feature = "sink-nats")]
1289    v.push((
1290        "nats",
1291        "NATS sink. Publishes records to a subject (optionally subject-per-record) and flushes per batch.",
1292    ));
1293    #[cfg(feature = "sink-sftp")]
1294    v.push((
1295        "sftp",
1296        "SFTP sink. Writes JSONL files over SSH with atomic temp-then-rename uploads.",
1297    ));
1298    #[cfg(feature = "sink-s3")]
1299    v.push(("s3", "AWS S3 object sink"));
1300    #[cfg(feature = "sink-mongodb")]
1301    v.push(("mongodb", "MongoDB insert sink"));
1302    #[cfg(feature = "sink-redis")]
1303    v.push(("redis", "Redis (streams, lists, key-value) sink"));
1304    #[cfg(feature = "sink-csv")]
1305    v.push(("csv", "CSV file sink"));
1306    #[cfg(feature = "sink-elasticsearch")]
1307    v.push(("elasticsearch", "Elasticsearch bulk index sink"));
1308    #[cfg(feature = "sink-kafka")]
1309    v.push(("kafka", "Apache Kafka producer (rdkafka). FuturesUnordered batched sends with QueueFull retry; supports fixed or per-record topic routing."));
1310    #[cfg(feature = "sink-kinesis")]
1311    v.push(("kinesis", "AWS Kinesis Data Streams producer. Batched PutRecords with partition-key routing and partial-failure retry (DLQ-routable)."));
1312    #[cfg(feature = "sink-spanner")]
1313    v.push(("spanner", "Google Cloud Spanner sink. Batched mutations with upsert/delete write modes, exactly-once commit tokens, and schema evolution."));
1314    #[cfg(feature = "sink-http")]
1315    v.push(("http", "HTTP POST sink (individual or array batch)"));
1316    #[cfg(feature = "sink-stdout")]
1317    v.push(("stdout", "Stdout / stderr sink (JSON Lines, pretty, TSV)"));
1318    #[cfg(feature = "sink-parquet")]
1319    v.push(("parquet", "Apache Parquet file sink (local path or S3). Schema-inferred, configurable compression, row/byte rollover."));
1320    #[cfg(feature = "sink-delta")]
1321    v.push(("delta", "Apache Delta Lake sink (local FS or S3/Azure/GCS). Append-only, schema-inferred table creation, one commit per flush."));
1322    #[cfg(feature = "sink-gcs")]
1323    v.push(("gcs", "Google Cloud Storage sink — JSONL files"));
1324    #[cfg(feature = "sink-redshift")]
1325    v.push((
1326        "redshift",
1327        "Amazon Redshift sink (COPY-from-S3 or multi-row INSERT)",
1328    ));
1329    #[cfg(feature = "sink-pubsub")]
1330    v.push((
1331        "pubsub",
1332        "Google Cloud Pub/Sub producer — batched publish with optional ordering keys, bounded concurrency, and partial-failure retry (DLQ-routable)",
1333    ));
1334    #[cfg(feature = "sink-clickhouse")]
1335    v.push((
1336        "clickhouse",
1337        "ClickHouse sink (HTTP INSERT … FORMAT JSONEachRow; optional async inserts)",
1338    ));
1339    #[cfg(feature = "sink-azure-blob")]
1340    v.push((
1341        "azure-blob",
1342        "Azure Blob Storage / ADLS Gen2 sink — JSONL files",
1343    ));
1344    v
1345}
1346
1347/// Names of every compiled-in source connector.
1348pub fn source_kinds() -> Vec<&'static str> {
1349    source_descriptions().into_iter().map(|(k, _)| k).collect()
1350}
1351
1352/// Names of every compiled-in sink connector.
1353pub fn sink_kinds() -> Vec<&'static str> {
1354    sink_descriptions().into_iter().map(|(k, _)| k).collect()
1355}
1356
1357fn decode<T: DeserializeOwned>(kind: &'static str, name: &str, config: Value) -> CliResult<T> {
1358    serde_json::from_value(config).map_err(|e| CliError::InvalidConnectorConfig {
1359        kind,
1360        name: name.to_owned(),
1361        message: scrub_config_error(&e.to_string()),
1362    })
1363}
1364
1365/// Sanitise a serde deserialization error before it reaches stderr/logs.
1366///
1367/// serde_json's `invalid type:` errors echo the offending value as a
1368/// double-quoted literal — which can be a secret injected via
1369/// `${secret:...}` / `${env:...}`. Replace every double-quoted run with a
1370/// placeholder (field/type names use backticks and are preserved for
1371/// diagnostics) and cap the length so a huge value can't flood the log
1372/// (#78/#38). Note: `${secret:}` is currently an `${env:}` alias with no
1373/// at-rest redaction — this only scrubs error *output*.
1374fn scrub_config_error(msg: &str) -> String {
1375    const MAX_CHARS: usize = 200;
1376    let mut out = String::with_capacity(msg.len());
1377    let mut in_quote = false;
1378    for c in msg.chars() {
1379        if c == '"' {
1380            if !in_quote {
1381                out.push_str("\"<redacted>\"");
1382            }
1383            in_quote = !in_quote;
1384            continue;
1385        }
1386        if !in_quote {
1387            out.push(c);
1388        }
1389    }
1390    if out.chars().count() > MAX_CHARS {
1391        let truncated: String = out.chars().take(MAX_CHARS).collect();
1392        return format!("{truncated}…");
1393    }
1394    out
1395}
1396
1397fn schema<T: faucet_core::JsonSchema>() -> Value {
1398    serde_json::to_value(faucet_core::schema_for!(T))
1399        .unwrap_or_else(|_| serde_json::json!({"type": "object"}))
1400}
1401
1402fn unknown(name: &str, kind: &'static str, available: Vec<&'static str>) -> CliError {
1403    CliError::UnknownConnector {
1404        kind,
1405        name: name.to_owned(),
1406        available: if available.is_empty() {
1407            "(none — rebuild faucet-cli with the relevant feature enabled)".to_owned()
1408        } else {
1409            available.join(", ")
1410        },
1411    }
1412}
1413
1414#[cfg(test)]
1415mod tests {
1416    use super::*;
1417
1418    // A trivial in-memory source used to exercise custom registration without
1419    // any I/O.
1420    #[derive(Clone)]
1421    struct DummySource;
1422    #[faucet_core::async_trait]
1423    impl Source for DummySource {
1424        async fn fetch_with_context(
1425            &self,
1426            _ctx: &std::collections::HashMap<String, Value>,
1427        ) -> Result<Vec<Value>, faucet_core::FaucetError> {
1428            Ok(vec![serde_json::json!({"ok": true})])
1429        }
1430        fn config_schema(&self) -> Value {
1431            serde_json::json!({"type": "object"})
1432        }
1433    }
1434
1435    #[test]
1436    fn register_source_rejects_builtin_collision() {
1437        // `csv` is a built-in whenever that feature is on; use a name we know is
1438        // built-in under --all-features to assert the collision guard fires.
1439        let reg = PluginRegistry::with_builtins()
1440            .register_source("csv", |_| Ok(Box::new(DummySource) as Box<dyn Source>));
1441        // install() surfaces the stashed error WITHOUT touching the global
1442        // (errors are checked before the OnceLock is set), so this is race-free.
1443        let err = reg
1444            .install()
1445            .expect_err("built-in collision must be rejected");
1446        match err {
1447            CliError::Config(msg) => assert!(msg.contains("built-in source"), "{msg}"),
1448            other => panic!("expected Config error, got {other:?}"),
1449        }
1450    }
1451
1452    #[test]
1453    fn register_source_rejects_duplicate() {
1454        let reg = PluginRegistry::new()
1455            .register_source("dup", |_| Ok(Box::new(DummySource) as Box<dyn Source>))
1456            .register_source("dup", |_| Ok(Box::new(DummySource) as Box<dyn Source>));
1457        let err = reg
1458            .install()
1459            .expect_err("duplicate registration must be rejected");
1460        match err {
1461            CliError::Config(msg) => assert!(msg.contains("more than once"), "{msg}"),
1462            other => panic!("expected Config error, got {other:?}"),
1463        }
1464    }
1465
1466    #[test]
1467    fn register_sink_rejects_duplicate() {
1468        // Build a registry with a duplicate sink and confirm the error is
1469        // stashed; we inspect it via the private field rather than install()
1470        // so no global state is touched even indirectly.
1471        let reg = PluginRegistry::new()
1472            .register_sink("dupsink", |_| Err(CliError::Config("unused".into())))
1473            .register_sink("dupsink", |_| Err(CliError::Config("unused".into())));
1474        assert!(
1475            reg.errors.iter().any(|e| e.contains("more than once")),
1476            "{:?}",
1477            reg.errors
1478        );
1479    }
1480
1481    #[test]
1482    fn custom_descriptions_use_default_when_blank() {
1483        let reg = PluginRegistry::new()
1484            .register_source("acme", |_| Ok(Box::new(DummySource) as Box<dyn Source>));
1485        let descs = reg.custom_source_descriptions();
1486        assert_eq!(descs.len(), 1);
1487        assert_eq!(descs[0].0, "acme");
1488        assert_eq!(descs[0].1, "custom source connector");
1489    }
1490
1491    #[test]
1492    fn custom_descriptions_carry_explicit_summary() {
1493        let reg = PluginRegistry::new().register_source_with(
1494            "acme",
1495            |_| Ok(Box::new(DummySource) as Box<dyn Source>),
1496            || serde_json::json!({"type": "object", "title": "acme"}),
1497            "Acme widget source",
1498        );
1499        let descs = reg.custom_source_descriptions();
1500        assert_eq!(descs[0], ("acme", "Acme widget source"));
1501        // The schema closure is what `faucet schema source acme` would print.
1502        assert_eq!(
1503            (reg.sources.get("acme").unwrap().schema)()["title"],
1504            serde_json::json!("acme")
1505        );
1506    }
1507
1508    #[test]
1509    fn capability_constants_match_their_predicates() {
1510        // F44: the human-readable lists in error messages derive from these
1511        // constants, which must stay in lockstep with the boolean gates. In
1512        // particular the idempotent-sink list must include bigquery AND kafka,
1513        // and the upsert-sink list must include bigquery — the values the old
1514        // hand-maintained message strings had drifted away from.
1515        for &k in EXACTLY_ONCE_SOURCE_KINDS {
1516            assert!(
1517                source_supports_exactly_once(k),
1518                "{k} should be exactly-once"
1519            );
1520        }
1521        for &k in IDEMPOTENT_SINK_KINDS {
1522            assert!(
1523                sink_supports_idempotent_writes(k),
1524                "{k} should be idempotent"
1525            );
1526        }
1527        for &k in UPSERT_SINK_KINDS {
1528            use faucet_core::WriteMode;
1529            assert!(
1530                sink_supported_write_modes(k).contains(&WriteMode::Upsert),
1531                "{k} should support upsert"
1532            );
1533        }
1534        assert!(IDEMPOTENT_SINK_KINDS.contains(&"bigquery"));
1535        assert!(IDEMPOTENT_SINK_KINDS.contains(&"kafka"));
1536        assert!(UPSERT_SINK_KINDS.contains(&"bigquery"));
1537    }
1538
1539    #[cfg(feature = "source-rest")]
1540    #[test]
1541    fn rest_source_appears_in_listings() {
1542        assert!(source_kinds().contains(&"rest"));
1543    }
1544
1545    #[cfg(feature = "sink-jsonl")]
1546    #[test]
1547    fn jsonl_sink_appears_in_listings() {
1548        assert!(sink_kinds().contains(&"jsonl"));
1549    }
1550
1551    #[tokio::test]
1552    async fn unknown_source_kind_errors() {
1553        let err = build_source("nope", serde_json::json!({}), &AuthCatalog::new(), None)
1554            .await
1555            .err()
1556            .expect("should fail");
1557        match err {
1558            CliError::UnknownConnector { kind, name, .. } => {
1559                assert_eq!(kind, "source");
1560                assert_eq!(name, "nope");
1561            }
1562            other => panic!("expected UnknownConnector, got {other:?}"),
1563        }
1564    }
1565
1566    #[tokio::test]
1567    async fn unknown_sink_kind_errors() {
1568        let err = build_sink("nope", serde_json::json!({}), &AuthCatalog::new())
1569            .await
1570            .err()
1571            .expect("should fail");
1572        assert!(matches!(
1573            err,
1574            CliError::UnknownConnector { kind: "sink", .. }
1575        ));
1576    }
1577
1578    #[cfg(feature = "source-rest")]
1579    #[test]
1580    fn rest_schema_is_object() {
1581        let s = source_schema("rest").unwrap();
1582        assert!(s.is_object());
1583    }
1584
1585    #[cfg(feature = "sink-jsonl")]
1586    #[test]
1587    fn jsonl_schema_is_object() {
1588        let s = sink_schema("jsonl").unwrap();
1589        assert!(s.is_object());
1590    }
1591
1592    #[test]
1593    fn scrub_config_error_redacts_quoted_values() {
1594        // A serde "invalid type" error echoes the offending value in double
1595        // quotes — must be redacted so a secret can't reach the log (#78/#38).
1596        let msg =
1597            r#"invalid type: string "sk-super-secret-123", expected a sequence at line 1 column 9"#;
1598        let scrubbed = scrub_config_error(msg);
1599        assert!(!scrubbed.contains("sk-super-secret-123"), "{scrubbed}");
1600        assert!(scrubbed.contains("<redacted>"), "{scrubbed}");
1601        // Structural context outside the quotes is preserved.
1602        assert!(scrubbed.contains("invalid type"), "{scrubbed}");
1603        assert!(scrubbed.contains("expected a sequence"), "{scrubbed}");
1604    }
1605
1606    #[test]
1607    fn scrub_config_error_truncates_long_messages() {
1608        let msg = "x".repeat(500);
1609        let scrubbed = scrub_config_error(&msg);
1610        assert!(
1611            scrubbed.chars().count() <= 201,
1612            "len {}",
1613            scrubbed.chars().count()
1614        );
1615        assert!(scrubbed.ends_with('…'));
1616    }
1617
1618    // A `(kind, config)` pair that builds without performing any network/disk
1619    // I/O — the CSV source's `new()` only stores config, so we can drive the
1620    // real `build_source` dispatch arm and inspect the resulting trait object.
1621    #[cfg(feature = "source-csv")]
1622    #[tokio::test]
1623    async fn build_source_csv_succeeds_without_io() {
1624        let src = build_source(
1625            "csv",
1626            serde_json::json!({ "path": "/tmp/does-not-need-to-exist.csv" }),
1627            &AuthCatalog::new(),
1628            None,
1629        )
1630        .await
1631        .expect("csv source should build without I/O");
1632        // The CSV source uses the default `connector_name()` (stripped type
1633        // name) rather than overriding it with a friendly label.
1634        assert_eq!(src.connector_name(), "CsvSource");
1635    }
1636
1637    // The JSONL sink's `new()` is also pure (it opens the file lazily on first
1638    // write), so building it exercises the sink dispatch arm with no I/O.
1639    #[cfg(feature = "sink-jsonl")]
1640    #[tokio::test]
1641    async fn build_sink_jsonl_succeeds_without_io() {
1642        let sink = build_sink(
1643            "jsonl",
1644            serde_json::json!({ "path": "/tmp/does-not-need-to-exist.jsonl" }),
1645            &AuthCatalog::new(),
1646        )
1647        .await
1648        .expect("jsonl sink should build without I/O");
1649        assert_eq!(sink.connector_name(), "jsonl");
1650    }
1651
1652    // The stdout sink builds without any config fields and without I/O.
1653    #[cfg(feature = "sink-stdout")]
1654    #[tokio::test]
1655    async fn build_sink_stdout_succeeds_without_io() {
1656        let sink = build_sink("stdout", serde_json::json!({}), &AuthCatalog::new())
1657            .await
1658            .expect("stdout sink should build without I/O");
1659        // The stdout sink uses the default `connector_name()` (stripped type
1660        // name) rather than overriding it with a friendly label.
1661        assert_eq!(sink.connector_name(), "StdoutSink");
1662    }
1663
1664    // Exercise the Delta source+sink registry arms end to end: build both via
1665    // the registry, round-trip a page through a real local table, and confirm
1666    // the schema + description arms resolve.
1667    #[cfg(all(feature = "source-delta", feature = "sink-delta"))]
1668    #[tokio::test]
1669    async fn delta_registry_round_trip() {
1670        let dir = tempfile::tempdir().unwrap();
1671        let uri = dir.path().join("reg_delta").to_string_lossy().into_owned();
1672
1673        assert!(source_schema("delta").is_ok());
1674        assert!(sink_schema("delta").is_ok());
1675        assert!(source_descriptions().iter().any(|(n, _)| *n == "delta"));
1676        assert!(sink_descriptions().iter().any(|(n, _)| *n == "delta"));
1677
1678        let sink = build_sink(
1679            "delta",
1680            serde_json::json!({ "table_uri": uri }),
1681            &AuthCatalog::new(),
1682        )
1683        .await
1684        .expect("delta sink builds");
1685        assert_eq!(sink.connector_name(), "delta");
1686        let n = sink
1687            .write_batch(&[serde_json::json!({"id": 1}), serde_json::json!({"id": 2})])
1688            .await
1689            .expect("write");
1690        assert_eq!(n, 2);
1691        sink.flush().await.expect("flush");
1692
1693        let source = build_source(
1694            "delta",
1695            serde_json::json!({ "table_uri": uri }),
1696            &AuthCatalog::new(),
1697            None,
1698        )
1699        .await
1700        .expect("delta source builds");
1701        assert_eq!(source.connector_name(), "delta");
1702        let rows = source
1703            .fetch_with_context(&std::collections::HashMap::new())
1704            .await
1705            .expect("read");
1706        assert_eq!(rows.len(), 2);
1707    }
1708
1709    // The Databricks source builds from the registry (no I/O in `new`), and its
1710    // schema + description arms resolve.
1711    #[cfg(feature = "source-databricks")]
1712    #[tokio::test]
1713    async fn databricks_registry_source_builds() {
1714        assert!(source_schema("databricks").is_ok());
1715        assert!(
1716            source_descriptions()
1717                .iter()
1718                .any(|(n, _)| *n == "databricks")
1719        );
1720        let cfg = serde_json::json!({
1721            "workspace_url": "https://x.cloud.databricks.com",
1722            "warehouse_id": "wh1",
1723            "sql": "SELECT 1",
1724            "auth": { "type": "pat", "config": { "token": "t" } }
1725        });
1726        let src = build_source("databricks", cfg, &AuthCatalog::new(), None)
1727            .await
1728            .expect("databricks source builds");
1729        assert_eq!(src.connector_name(), "databricks");
1730    }
1731
1732    // A malformed config for a known connector must surface as a typed
1733    // `InvalidConnectorConfig` from the `decode` helper, not a panic.
1734    #[cfg(feature = "source-csv")]
1735    #[tokio::test]
1736    async fn build_source_csv_invalid_config_errors() {
1737        // `path` is a required String; supplying an integer is a type error.
1738        // `Box<dyn Source>` is not `Debug`, so match the Result directly rather
1739        // than using `expect_err`.
1740        let res = build_source(
1741            "csv",
1742            serde_json::json!({ "path": 42 }),
1743            &AuthCatalog::new(),
1744            None,
1745        )
1746        .await;
1747        match res {
1748            Err(CliError::InvalidConnectorConfig { kind, name, .. }) => {
1749                assert_eq!(kind, "source");
1750                assert_eq!(name, "csv");
1751            }
1752            Ok(_) => panic!("expected InvalidConnectorConfig, got Ok"),
1753            Err(other) => panic!("expected InvalidConnectorConfig, got {other:?}"),
1754        }
1755    }
1756
1757    // `source_schema` must return a JSON object that surfaces the connector's
1758    // config fields (here: the required `path`).
1759    #[cfg(feature = "source-csv")]
1760    #[test]
1761    fn source_schema_csv_exposes_path_property() {
1762        let schema = source_schema("csv").expect("csv schema");
1763        let props = schema
1764            .get("properties")
1765            .and_then(Value::as_object)
1766            .expect("schema should have a properties object");
1767        assert!(props.contains_key("path"), "schema props: {props:?}");
1768    }
1769
1770    #[cfg(feature = "sink-jsonl")]
1771    #[test]
1772    fn sink_schema_jsonl_exposes_path_property() {
1773        let schema = sink_schema("jsonl").expect("jsonl schema");
1774        let props = schema
1775            .get("properties")
1776            .and_then(Value::as_object)
1777            .expect("schema should have a properties object");
1778        assert!(props.contains_key("path"), "schema props: {props:?}");
1779    }
1780
1781    #[test]
1782    fn unknown_source_schema_errors_with_available_list() {
1783        let err = source_schema("definitely-not-a-source").expect_err("unknown source");
1784        match err {
1785            CliError::UnknownConnector {
1786                kind,
1787                name,
1788                available,
1789            } => {
1790                assert_eq!(kind, "source");
1791                assert_eq!(name, "definitely-not-a-source");
1792                // Under `--all-features` the available list is non-empty.
1793                assert!(!available.is_empty());
1794            }
1795            other => panic!("expected UnknownConnector, got {other:?}"),
1796        }
1797    }
1798
1799    #[test]
1800    fn unknown_sink_schema_errors() {
1801        let err = sink_schema("definitely-not-a-sink").expect_err("unknown sink");
1802        assert!(matches!(
1803            err,
1804            CliError::UnknownConnector { kind: "sink", .. }
1805        ));
1806    }
1807
1808    #[cfg(feature = "source-csv")]
1809    #[test]
1810    fn source_exists_is_true_for_known_and_false_for_unknown() {
1811        assert!(source_exists("csv"));
1812        assert!(!source_exists("definitely-not-a-source"));
1813    }
1814
1815    #[cfg(feature = "sink-jsonl")]
1816    #[test]
1817    fn sink_exists_is_true_for_known_and_false_for_unknown() {
1818        assert!(sink_exists("jsonl"));
1819        assert!(!sink_exists("definitely-not-a-sink"));
1820    }
1821
1822    // Descriptions back `faucet list`: non-empty, with a one-line summary, and
1823    // each name must resolve to a real schema (no orphan listing).
1824    #[test]
1825    fn source_descriptions_are_non_empty_and_consistent() {
1826        let descs = source_descriptions();
1827        assert!(!descs.is_empty());
1828        for (name, summary) in &descs {
1829            assert!(!name.is_empty(), "empty connector name");
1830            assert!(!summary.is_empty(), "empty summary for {name}");
1831            assert!(
1832                source_schema(name).is_ok(),
1833                "listed source `{name}` has no schema"
1834            );
1835        }
1836    }
1837
1838    #[test]
1839    fn sink_descriptions_are_non_empty_and_consistent() {
1840        let descs = sink_descriptions();
1841        assert!(!descs.is_empty());
1842        for (name, summary) in &descs {
1843            assert!(!name.is_empty(), "empty connector name");
1844            assert!(!summary.is_empty(), "empty summary for {name}");
1845            assert!(
1846                sink_schema(name).is_ok(),
1847                "listed sink `{name}` has no schema"
1848            );
1849        }
1850    }
1851
1852    // `*_kinds()` is derived from `*_descriptions()`; under `--all-features`
1853    // the canonical built-in connectors must be present.
1854    #[cfg(all(feature = "source-csv", feature = "source-rest"))]
1855    #[test]
1856    fn source_kinds_contains_expected_builtins() {
1857        let kinds = source_kinds();
1858        assert!(kinds.contains(&"csv"));
1859        assert!(kinds.contains(&"rest"));
1860    }
1861
1862    #[cfg(all(feature = "sink-jsonl", feature = "sink-stdout"))]
1863    #[test]
1864    fn sink_kinds_contains_expected_builtins() {
1865        let kinds = sink_kinds();
1866        assert!(kinds.contains(&"jsonl"));
1867        assert!(kinds.contains(&"stdout"));
1868    }
1869
1870    // Build a catalog holding one `static` bearer provider, then build a
1871    // connector whose config carries `auth: { ref: "tok" }` — exercising the
1872    // `with_auth_provider` injection branch in the dispatch arm.
1873    #[cfg(feature = "source-rest")]
1874    #[tokio::test]
1875    async fn build_source_injects_referenced_auth_provider() {
1876        let mut specs = std::collections::HashMap::new();
1877        specs.insert(
1878            "tok".to_string(),
1879            serde_json::json!({"type": "static", "config": {"token": "abc"}}),
1880        );
1881        let catalog = auth_catalog::build_auth_catalog(Some(&specs)).expect("catalog");
1882
1883        let src = build_source("rest", rest_config_with_auth_ref("tok"), &catalog, None)
1884            .await
1885            .expect("rest source with a resolvable auth ref should build");
1886        assert_eq!(src.connector_name(), "rest");
1887    }
1888
1889    // A minimal, fully-valid rest config (built from the real constructor so
1890    // every required field is present) carrying an `auth: { ref }` pointer.
1891    #[cfg(feature = "source-rest")]
1892    fn rest_config_with_auth_ref(name: &str) -> Value {
1893        let cfg = faucet_source_rest::RestStreamConfig::new("https://api.example.com", "/v1");
1894        let mut v = serde_json::to_value(cfg).expect("serialize rest config");
1895        v.as_object_mut()
1896            .unwrap()
1897            .insert("auth".to_string(), serde_json::json!({ "ref": name }));
1898        v
1899    }
1900
1901    // An `auth: { ref }` pointing at a name absent from the catalog must surface
1902    // as `UnknownAuthProvider`, not silently build without auth.
1903    #[cfg(feature = "source-rest")]
1904    #[tokio::test]
1905    async fn build_source_unknown_auth_ref_errors() {
1906        let res = build_source(
1907            "rest",
1908            rest_config_with_auth_ref("missing"),
1909            &AuthCatalog::new(),
1910            None,
1911        )
1912        .await;
1913        match res {
1914            Err(CliError::UnknownAuthProvider { name, .. }) => assert_eq!(name, "missing"),
1915            Ok(_) => panic!("expected UnknownAuthProvider, got Ok"),
1916            Err(other) => panic!("expected UnknownAuthProvider, got {other:?}"),
1917        }
1918    }
1919
1920    #[test]
1921    fn exactly_once_capability_allowlists() {
1922        assert!(source_supports_exactly_once("postgres-cdc"));
1923        assert!(source_supports_exactly_once("mysql-cdc"));
1924        assert!(source_supports_exactly_once("mongodb-cdc"));
1925        assert!(source_supports_exactly_once("kafka"));
1926        assert!(!source_supports_exactly_once("rest"));
1927
1928        assert!(sink_supports_idempotent_writes("postgres"));
1929        assert!(sink_supports_idempotent_writes("iceberg"));
1930        assert!(sink_supports_idempotent_writes("bigquery"));
1931        assert!(sink_supports_idempotent_writes("kafka"));
1932        assert!(sink_supports_idempotent_writes("snowflake"));
1933        assert!(sink_supports_idempotent_writes("redis"));
1934        assert!(sink_supports_idempotent_writes("mongodb"));
1935        assert!(!sink_supports_idempotent_writes("jsonl"));
1936    }
1937
1938    #[test]
1939    fn typed_delivery_capabilities_derive_from_kind_tables() {
1940        use faucet_core::{ReplayGuarantee, SinkGuarantee};
1941        assert_eq!(
1942            source_replay_guarantee("kafka"),
1943            ReplayGuarantee::Deterministic
1944        );
1945        assert_eq!(
1946            source_replay_guarantee("rest"),
1947            ReplayGuarantee::NonDeterministic
1948        );
1949        assert_eq!(sink_guarantee("postgres"), SinkGuarantee::AtomicWatermark);
1950        // Upsert-capable but not atomic: elasticsearch dedups by key only.
1951        assert_eq!(sink_guarantee("elasticsearch"), SinkGuarantee::KeyedUpsert);
1952        assert_eq!(sink_guarantee("jsonl"), SinkGuarantee::AtLeastOnce);
1953    }
1954
1955    #[test]
1956    fn sink_supported_write_modes_allowlist() {
1957        use faucet_core::WriteMode;
1958        assert!(sink_supported_write_modes("postgres").contains(&WriteMode::Upsert));
1959        assert!(sink_supported_write_modes("elasticsearch").contains(&WriteMode::Delete));
1960        assert!(sink_supported_write_modes("bigquery").contains(&WriteMode::Upsert));
1961        // a sink without upsert support is append-only
1962        assert_eq!(sink_supported_write_modes("jsonl"), &[WriteMode::Append]);
1963        assert_eq!(sink_supported_write_modes("kafka"), &[WriteMode::Append]);
1964    }
1965
1966    #[test]
1967    fn sink_supports_schema_evolution_allowlist() {
1968        assert!(sink_supports_schema_evolution("postgres"));
1969        assert!(sink_supports_schema_evolution("mysql"));
1970        assert!(sink_supports_schema_evolution("mssql"));
1971        assert!(sink_supports_schema_evolution("sqlite"));
1972        assert!(sink_supports_schema_evolution("bigquery"));
1973        assert!(sink_supports_schema_evolution("elasticsearch"));
1974        assert!(sink_supports_schema_evolution("iceberg"));
1975        assert!(!sink_supports_schema_evolution("jsonl"));
1976        assert!(!sink_supports_schema_evolution("kafka"));
1977    }
1978}