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