Skip to main content

faucet_cli/
registry.rs

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