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