1use 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
16pub type SourceFactory = Arc<dyn Fn(Value) -> CliResult<Box<dyn Source>> + Send + Sync>;
26
27pub type SinkFactory = Arc<dyn Fn(Value) -> CliResult<Box<dyn Sink>> + Send + Sync>;
30
31pub 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#[derive(Default)]
77pub struct PluginRegistry {
78 sources: BTreeMap<&'static str, SourceEntry>,
79 sinks: BTreeMap<&'static str, SinkEntry>,
80 errors: Vec<String>,
84}
85
86impl PluginRegistry {
87 pub fn new() -> Self {
89 Self::default()
90 }
91
92 pub fn with_builtins() -> Self {
98 Self::default()
99 }
100
101 #[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 #[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 #[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 #[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 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
239fn 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
249fn global() -> &'static PluginRegistry {
252 GLOBAL_REGISTRY.get_or_init(PluginRegistry::default)
253}
254
255pub 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 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 other => Err(unknown(other, "source", source_kinds())),
516 }
517}
518
519pub async fn build_sink(kind: &str, config: Value, auth: &AuthCatalog) -> CliResult<Box<dyn Sink>> {
523 if let Some(entry) = global().sinks.get(kind) {
524 return (entry.factory)(config);
525 }
526 let auth_ref = auth_catalog::auth_ref(&config);
527 match kind {
528 #[cfg(feature = "sink-bigquery")]
529 "bigquery" => {
530 let cfg =
531 decode::<faucet_sink_bigquery::BigQuerySinkConfig>("sink", "bigquery", config)?;
532 Ok(Box::new(
533 faucet_sink_bigquery::BigQuerySink::new(cfg).await?,
534 ))
535 }
536 #[cfg(feature = "sink-iceberg")]
537 "iceberg" => {
538 let cfg = decode::<faucet_sink_iceberg::IcebergSinkConfig>("sink", "iceberg", config)?;
539 Ok(Box::new(faucet_sink_iceberg::IcebergSink::new(cfg).await?))
540 }
541 #[cfg(feature = "sink-delta")]
542 "delta" => {
543 let cfg = decode::<faucet_sink_delta::DeltaSinkConfig>("sink", "delta", config)?;
544 Ok(Box::new(faucet_sink_delta::DeltaSink::new(cfg).await?))
545 }
546 #[cfg(feature = "sink-postgres")]
547 "postgres" => {
548 let cfg =
549 decode::<faucet_sink_postgres::PostgresSinkConfig>("sink", "postgres", config)?;
550 Ok(Box::new(
551 faucet_sink_postgres::PostgresSink::new(cfg).await?,
552 ))
553 }
554 #[cfg(feature = "sink-jsonl")]
555 "jsonl" => {
556 let cfg = decode::<faucet_sink_jsonl::JsonlSinkConfig>("sink", "jsonl", config)?;
557 Ok(Box::new(faucet_sink_jsonl::JsonlSink::new(cfg)))
558 }
559 #[cfg(feature = "sink-snowflake")]
560 "snowflake" => {
561 let cfg =
562 decode::<faucet_sink_snowflake::SnowflakeSinkConfig>("sink", "snowflake", config)?;
563 let mut s = faucet_sink_snowflake::SnowflakeSink::new(cfg)?;
564 if let Some(name) = &auth_ref {
565 s = s.with_auth_provider(auth_catalog::resolve(auth, name)?);
566 }
567 Ok(Box::new(s))
568 }
569 #[cfg(feature = "sink-mysql")]
570 "mysql" => {
571 let cfg = decode::<faucet_sink_mysql::MysqlSinkConfig>("sink", "mysql", config)?;
572 Ok(Box::new(faucet_sink_mysql::MysqlSink::new(cfg).await?))
573 }
574 #[cfg(feature = "sink-mssql")]
575 "mssql" => {
576 let cfg = decode::<faucet_sink_mssql::MssqlSinkConfig>("sink", "mssql", config)?;
577 Ok(Box::new(faucet_sink_mssql::MssqlSink::new(cfg).await?))
578 }
579 #[cfg(feature = "sink-sqlite")]
580 "sqlite" => {
581 let cfg = decode::<faucet_sink_sqlite::SqliteSinkConfig>("sink", "sqlite", config)?;
582 Ok(Box::new(faucet_sink_sqlite::SqliteSink::new(cfg).await?))
583 }
584 #[cfg(feature = "sink-s3")]
585 "s3" => {
586 let cfg = decode::<faucet_sink_s3::S3SinkConfig>("sink", "s3", config)?;
587 Ok(Box::new(faucet_sink_s3::S3Sink::new(cfg).await?))
588 }
589 #[cfg(feature = "sink-mongodb")]
590 "mongodb" => {
591 let cfg = decode::<faucet_sink_mongodb::MongoSinkConfig>("sink", "mongodb", config)?;
592 Ok(Box::new(faucet_sink_mongodb::MongoSink::new(cfg).await?))
593 }
594 #[cfg(feature = "sink-redis")]
595 "redis" => {
596 let cfg = decode::<faucet_sink_redis::RedisSinkConfig>("sink", "redis", config)?;
597 Ok(Box::new(faucet_sink_redis::RedisSink::new(cfg).await?))
598 }
599 #[cfg(feature = "sink-csv")]
600 "csv" => {
601 let cfg = decode::<faucet_sink_csv::CsvSinkConfig>("sink", "csv", config)?;
602 Ok(Box::new(faucet_sink_csv::CsvSink::new(cfg)))
603 }
604 #[cfg(feature = "sink-elasticsearch")]
605 "elasticsearch" => {
606 let cfg = decode::<faucet_sink_elasticsearch::ElasticsearchSinkConfig>(
607 "sink",
608 "elasticsearch",
609 config,
610 )?;
611 let mut s = faucet_sink_elasticsearch::ElasticsearchSink::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-kafka")]
618 "kafka" => {
619 let cfg = decode::<faucet_sink_kafka::KafkaSinkConfig>("sink", "kafka", config)?;
620 Ok(Box::new(faucet_sink_kafka::KafkaSink::new(cfg).await?))
621 }
622 #[cfg(feature = "sink-kinesis")]
623 "kinesis" => {
624 let cfg = decode::<faucet_sink_kinesis::KinesisSinkConfig>("sink", "kinesis", config)?;
625 Ok(Box::new(faucet_sink_kinesis::KinesisSink::new(cfg).await?))
626 }
627 #[cfg(feature = "sink-spanner")]
628 "spanner" => {
629 let cfg = decode::<faucet_sink_spanner::SpannerSinkConfig>("sink", "spanner", config)?;
630 Ok(Box::new(faucet_sink_spanner::SpannerSink::new(cfg).await?))
631 }
632 #[cfg(feature = "sink-http")]
633 "http" => {
634 let cfg = decode::<faucet_sink_http::HttpSinkConfig>("sink", "http", config)?;
635 let mut s = faucet_sink_http::HttpSink::new(cfg);
636 if let Some(name) = &auth_ref {
637 s = s.with_auth_provider(auth_catalog::resolve(auth, name)?);
638 }
639 Ok(Box::new(s))
640 }
641 #[cfg(feature = "sink-stdout")]
642 "stdout" => {
643 let cfg = decode::<faucet_sink_stdout::StdoutSinkConfig>("sink", "stdout", config)?;
644 Ok(Box::new(faucet_sink_stdout::StdoutSink::new(cfg)))
645 }
646 #[cfg(feature = "sink-parquet")]
647 "parquet" => {
648 let cfg = decode::<faucet_sink_parquet::ParquetSinkConfig>("sink", "parquet", config)?;
649 Ok(Box::new(faucet_sink_parquet::ParquetSink::new(cfg).await?))
650 }
651 #[cfg(feature = "sink-gcs")]
652 "gcs" => {
653 let cfg = decode::<faucet_sink_gcs::GcsSinkConfig>("sink", "gcs", config)?;
654 Ok(Box::new(faucet_sink_gcs::GcsSink::new(cfg).await?))
655 }
656 other => Err(unknown(other, "sink", sink_kinds())),
657 }
658}
659
660pub const EXACTLY_ONCE_SOURCE_KINDS: &[&str] =
667 &["postgres-cdc", "mysql-cdc", "mongodb-cdc", "kafka"];
668
669pub const IDEMPOTENT_SINK_KINDS: &[&str] = &[
674 "sqlite",
675 "postgres",
676 "mysql",
677 "mssql",
678 "iceberg",
679 "bigquery",
680 "kafka",
681 "snowflake",
682 "redis",
683 "mongodb",
684 "spanner",
685];
686
687pub const SCHEMA_EVOLUTION_SINK_KINDS: &[&str] = &[
691 "postgres",
692 "mysql",
693 "mssql",
694 "sqlite",
695 "bigquery",
696 "elasticsearch",
697 "spanner",
698];
699
700pub const UPSERT_SINK_KINDS: &[&str] = &[
704 "postgres",
705 "sqlite",
706 "mysql",
707 "mssql",
708 "mongodb",
709 "elasticsearch",
710 "bigquery",
711 "spanner",
712];
713
714pub const DISCOVER_SOURCE_KINDS: &[&str] = &[
718 "postgres",
719 "mysql",
720 "mssql",
721 "sqlite",
722 "mongodb",
723 "elasticsearch",
724 "bigquery",
725 "snowflake",
726 "spanner",
727 "s3",
728 "gcs",
729];
730
731pub fn source_supports_discover(kind: &str) -> bool {
733 DISCOVER_SOURCE_KINDS.contains(&kind)
734}
735
736pub fn source_replay_guarantee(kind: &str) -> faucet_core::ReplayGuarantee {
741 if EXACTLY_ONCE_SOURCE_KINDS.contains(&kind) {
742 faucet_core::ReplayGuarantee::Deterministic
743 } else {
744 faucet_core::ReplayGuarantee::NonDeterministic
745 }
746}
747
748pub fn sink_guarantee(kind: &str) -> faucet_core::SinkGuarantee {
752 if IDEMPOTENT_SINK_KINDS.contains(&kind) {
753 faucet_core::SinkGuarantee::AtomicWatermark
754 } else if UPSERT_SINK_KINDS.contains(&kind) {
755 faucet_core::SinkGuarantee::KeyedUpsert
756 } else {
757 faucet_core::SinkGuarantee::AtLeastOnce
758 }
759}
760
761pub fn source_supports_exactly_once(kind: &str) -> bool {
763 source_replay_guarantee(kind) == faucet_core::ReplayGuarantee::Deterministic
764}
765
766pub fn sink_supports_idempotent_writes(kind: &str) -> bool {
768 sink_guarantee(kind) == faucet_core::SinkGuarantee::AtomicWatermark
769}
770
771pub fn sink_supports_schema_evolution(kind: &str) -> bool {
773 SCHEMA_EVOLUTION_SINK_KINDS.contains(&kind)
774}
775
776pub fn sink_supported_write_modes(kind: &str) -> &'static [faucet_core::WriteMode] {
779 use faucet_core::WriteMode;
780 if UPSERT_SINK_KINDS.contains(&kind) {
781 &[WriteMode::Append, WriteMode::Upsert, WriteMode::Delete]
782 } else {
783 &[WriteMode::Append]
784 }
785}
786
787pub fn source_schema(kind: &str) -> CliResult<Value> {
789 if let Some(entry) = global().sources.get(kind) {
790 return Ok((entry.schema)());
791 }
792 match kind {
793 #[cfg(feature = "source-rest")]
794 "rest" => Ok(schema::<faucet_source_rest::RestStreamConfig>()),
795 #[cfg(feature = "source-graphql")]
796 "graphql" => Ok(schema::<faucet_source_graphql::GraphqlStreamConfig>()),
797 #[cfg(feature = "source-xml")]
798 "xml" => Ok(schema::<faucet_source_xml::XmlStreamConfig>()),
799 #[cfg(feature = "source-grpc")]
800 "grpc" => Ok(schema::<faucet_source_grpc::GrpcStreamConfig>()),
801 #[cfg(feature = "source-postgres")]
802 "postgres" => Ok(schema::<faucet_source_postgres::PostgresSourceConfig>()),
803 #[cfg(feature = "source-postgres-cdc")]
804 "postgres-cdc" => Ok(schema::<faucet_source_postgres_cdc::PostgresCdcSourceConfig>()),
805 #[cfg(feature = "source-mysql")]
806 "mysql" => Ok(schema::<faucet_source_mysql::MysqlSourceConfig>()),
807 #[cfg(feature = "source-mssql")]
808 "mssql" => Ok(schema::<faucet_source_mssql::MssqlSourceConfig>()),
809 #[cfg(feature = "source-sqlite")]
810 "sqlite" => Ok(schema::<faucet_source_sqlite::SqliteSourceConfig>()),
811 #[cfg(feature = "source-s3")]
812 "s3" => Ok(schema::<faucet_source_s3::S3SourceConfig>()),
813 #[cfg(feature = "source-mongodb")]
814 "mongodb" => Ok(schema::<faucet_source_mongodb::MongoSourceConfig>()),
815 #[cfg(feature = "source-mongodb-cdc")]
816 "mongodb-cdc" => Ok(schema::<faucet_source_mongodb_cdc::MongoCdcSourceConfig>()),
817 #[cfg(feature = "source-mysql-cdc")]
818 "mysql-cdc" => Ok(schema::<faucet_source_mysql_cdc::MysqlCdcSourceConfig>()),
819 #[cfg(feature = "source-redis")]
820 "redis" => Ok(schema::<faucet_source_redis::RedisSourceConfig>()),
821 #[cfg(feature = "source-webhook")]
822 "webhook" => Ok(schema::<faucet_source_webhook::WebhookSourceConfig>()),
823 #[cfg(feature = "source-websocket")]
824 "websocket" => Ok(schema::<faucet_source_websocket::WebsocketSourceConfig>()),
825 #[cfg(feature = "source-csv")]
826 "csv" => Ok(schema::<faucet_source_csv::CsvSourceConfig>()),
827 #[cfg(feature = "source-singer")]
828 "singer" => Ok(schema::<faucet_source_singer::SingerSourceConfig>()),
829 #[cfg(feature = "source-elasticsearch")]
830 "elasticsearch" => Ok(schema::<
831 faucet_source_elasticsearch::ElasticsearchSourceConfig,
832 >()),
833 #[cfg(feature = "source-kafka")]
834 "kafka" => Ok(schema::<faucet_source_kafka::KafkaSourceConfig>()),
835 #[cfg(feature = "source-kinesis")]
836 "kinesis" => Ok(schema::<faucet_source_kinesis::KinesisSourceConfig>()),
837 #[cfg(feature = "source-spanner")]
838 "spanner" => Ok(schema::<faucet_source_spanner::SpannerSourceConfig>()),
839 #[cfg(feature = "source-parquet")]
840 "parquet" => Ok(schema::<faucet_source_parquet::ParquetSourceConfig>()),
841 #[cfg(feature = "source-delta")]
842 "delta" => Ok(schema::<faucet_source_delta::DeltaSourceConfig>()),
843 #[cfg(feature = "source-databricks")]
844 "databricks" => Ok(schema::<faucet_source_databricks::DatabricksSourceConfig>()),
845 #[cfg(feature = "source-gcs")]
846 "gcs" => Ok(schema::<faucet_source_gcs::GcsSourceConfig>()),
847 #[cfg(feature = "source-bigquery")]
848 "bigquery" => Ok(schema::<faucet_source_bigquery::BigQuerySourceConfig>()),
849 #[cfg(feature = "source-snowflake")]
850 "snowflake" => Ok(schema::<faucet_source_snowflake::SnowflakeSourceConfig>()),
851 other => Err(unknown(other, "source", source_kinds())),
852 }
853}
854
855pub fn source_exists(kind: &str) -> bool {
857 source_schema(kind).is_ok()
858}
859
860pub fn sink_exists(kind: &str) -> bool {
862 sink_schema(kind).is_ok()
863}
864
865pub fn sink_schema(kind: &str) -> CliResult<Value> {
867 if let Some(entry) = global().sinks.get(kind) {
868 return Ok((entry.schema)());
869 }
870 match kind {
871 #[cfg(feature = "sink-bigquery")]
872 "bigquery" => Ok(schema::<faucet_sink_bigquery::BigQuerySinkConfig>()),
873 #[cfg(feature = "sink-iceberg")]
874 "iceberg" => Ok(schema::<faucet_sink_iceberg::IcebergSinkConfig>()),
875 #[cfg(feature = "sink-delta")]
876 "delta" => Ok(schema::<faucet_sink_delta::DeltaSinkConfig>()),
877 #[cfg(feature = "sink-postgres")]
878 "postgres" => Ok(schema::<faucet_sink_postgres::PostgresSinkConfig>()),
879 #[cfg(feature = "sink-jsonl")]
880 "jsonl" => Ok(schema::<faucet_sink_jsonl::JsonlSinkConfig>()),
881 #[cfg(feature = "sink-snowflake")]
882 "snowflake" => Ok(schema::<faucet_sink_snowflake::SnowflakeSinkConfig>()),
883 #[cfg(feature = "sink-mysql")]
884 "mysql" => Ok(schema::<faucet_sink_mysql::MysqlSinkConfig>()),
885 #[cfg(feature = "sink-mssql")]
886 "mssql" => Ok(schema::<faucet_sink_mssql::MssqlSinkConfig>()),
887 #[cfg(feature = "sink-sqlite")]
888 "sqlite" => Ok(schema::<faucet_sink_sqlite::SqliteSinkConfig>()),
889 #[cfg(feature = "sink-s3")]
890 "s3" => Ok(schema::<faucet_sink_s3::S3SinkConfig>()),
891 #[cfg(feature = "sink-mongodb")]
892 "mongodb" => Ok(schema::<faucet_sink_mongodb::MongoSinkConfig>()),
893 #[cfg(feature = "sink-redis")]
894 "redis" => Ok(schema::<faucet_sink_redis::RedisSinkConfig>()),
895 #[cfg(feature = "sink-csv")]
896 "csv" => Ok(schema::<faucet_sink_csv::CsvSinkConfig>()),
897 #[cfg(feature = "sink-elasticsearch")]
898 "elasticsearch" => Ok(schema::<faucet_sink_elasticsearch::ElasticsearchSinkConfig>()),
899 #[cfg(feature = "sink-kafka")]
900 "kafka" => Ok(schema::<faucet_sink_kafka::KafkaSinkConfig>()),
901 #[cfg(feature = "sink-kinesis")]
902 "kinesis" => Ok(schema::<faucet_sink_kinesis::KinesisSinkConfig>()),
903 #[cfg(feature = "sink-spanner")]
904 "spanner" => Ok(schema::<faucet_sink_spanner::SpannerSinkConfig>()),
905 #[cfg(feature = "sink-http")]
906 "http" => Ok(schema::<faucet_sink_http::HttpSinkConfig>()),
907 #[cfg(feature = "sink-stdout")]
908 "stdout" => Ok(schema::<faucet_sink_stdout::StdoutSinkConfig>()),
909 #[cfg(feature = "sink-parquet")]
910 "parquet" => Ok(schema::<faucet_sink_parquet::ParquetSinkConfig>()),
911 #[cfg(feature = "sink-gcs")]
912 "gcs" => Ok(schema::<faucet_sink_gcs::GcsSinkConfig>()),
913 other => Err(unknown(other, "sink", sink_kinds())),
914 }
915}
916
917pub fn source_descriptions() -> Vec<(&'static str, &'static str)> {
921 let mut v = builtin_source_descriptions();
922 v.extend(global().custom_source_descriptions());
923 v
924}
925
926#[allow(clippy::vec_init_then_push)]
928fn builtin_source_descriptions() -> Vec<(&'static str, &'static str)> {
929 let mut v: Vec<(&'static str, &'static str)> = Vec::new();
930 #[cfg(feature = "source-rest")]
931 v.push(("rest", "REST API source with pagination, auth, transforms"));
932 #[cfg(feature = "source-graphql")]
933 v.push(("graphql", "GraphQL API source with cursor pagination"));
934 #[cfg(feature = "source-xml")]
935 v.push(("xml", "XML / SOAP API source with XML→JSON conversion"));
936 #[cfg(feature = "source-grpc")]
937 v.push(("grpc", "gRPC source with dynamic protobuf"));
938 #[cfg(feature = "source-postgres")]
939 v.push(("postgres", "PostgreSQL query source"));
940 #[cfg(feature = "source-postgres-cdc")]
941 v.push((
942 "postgres-cdc",
943 "PostgreSQL CDC source (logical replication)",
944 ));
945 #[cfg(feature = "source-mysql")]
946 v.push(("mysql", "MySQL query source"));
947 #[cfg(feature = "source-mssql")]
948 v.push(("mssql", "Microsoft SQL Server query source"));
949 #[cfg(feature = "source-sqlite")]
950 v.push(("sqlite", "SQLite query source"));
951 #[cfg(feature = "source-s3")]
952 v.push(("s3", "AWS S3 object source"));
953 #[cfg(feature = "source-mongodb")]
954 v.push(("mongodb", "MongoDB query source"));
955 #[cfg(feature = "source-mongodb-cdc")]
956 v.push(("mongodb-cdc", "MongoDB CDC source (Change Streams)"));
957 #[cfg(feature = "source-mysql-cdc")]
958 v.push(("mysql-cdc", "MySQL CDC source (binlog replication)"));
959 #[cfg(feature = "source-redis")]
960 v.push(("redis", "Redis (streams, lists, keys) source"));
961 #[cfg(feature = "source-webhook")]
962 v.push(("webhook", "Webhook HTTP receiver source"));
963 #[cfg(feature = "source-websocket")]
964 v.push((
965 "websocket",
966 "WebSocket streaming source — connects, subscribes, streams each message as a record",
967 ));
968 #[cfg(feature = "source-csv")]
969 v.push(("csv", "CSV file source"));
970 #[cfg(feature = "source-singer")]
971 v.push((
972 "singer",
973 "Singer tap bridge (runs an external Singer tap; single-stream v0, Tier-2/experimental)",
974 ));
975 #[cfg(feature = "source-elasticsearch")]
976 v.push(("elasticsearch", "Elasticsearch search / scroll source"));
977 #[cfg(feature = "source-kafka")]
978 v.push(("kafka", "Apache Kafka consumer (rdkafka). Subscribes to topics and drains messages with idle/max-messages termination."));
979 #[cfg(feature = "source-kinesis")]
980 v.push(("kinesis", "AWS Kinesis Data Streams consumer. Per-shard workers with resumable sequence-number checkpoints and idle/max-messages termination."));
981 #[cfg(feature = "source-spanner")]
982 v.push(("spanner", "Google Cloud Spanner query source. Streaming SQL reads with incremental replication bookmarks, stale reads, and PK-range sharding."));
983 #[cfg(feature = "source-parquet")]
984 v.push(("parquet", "Apache Parquet file source (local path, glob, or S3). Streams record batches via the Arrow async reader."));
985 #[cfg(feature = "source-delta")]
986 v.push(("delta", "Apache Delta Lake source (local FS or S3/Azure/GCS). Streams active data files with time travel and projection pushdown."));
987 #[cfg(feature = "source-databricks")]
988 v.push(("databricks", "Databricks SQL query source (Statement Execution API). Streams typed query results with chunk pagination and incremental replication."));
989 #[cfg(feature = "source-gcs")]
990 v.push((
991 "gcs",
992 "Google Cloud Storage source — JSONL, JSON array, or raw text",
993 ));
994 #[cfg(feature = "source-bigquery")]
995 v.push((
996 "bigquery",
997 "Google BigQuery query source (jobs.query + jobs.getQueryResults)",
998 ));
999 #[cfg(feature = "source-snowflake")]
1000 v.push((
1001 "snowflake",
1002 "Snowflake query source (SQL REST API with partition paging)",
1003 ));
1004 v
1005}
1006
1007pub fn sink_descriptions() -> Vec<(&'static str, &'static str)> {
1011 let mut v = builtin_sink_descriptions();
1012 v.extend(global().custom_sink_descriptions());
1013 v
1014}
1015
1016#[allow(clippy::vec_init_then_push)]
1018fn builtin_sink_descriptions() -> Vec<(&'static str, &'static str)> {
1019 let mut v: Vec<(&'static str, &'static str)> = Vec::new();
1020 #[cfg(feature = "sink-bigquery")]
1021 v.push(("bigquery", "Google BigQuery streaming-insert sink"));
1022 #[cfg(feature = "sink-iceberg")]
1023 v.push((
1024 "iceberg",
1025 "Apache Iceberg sink (append, REST/Glue/SQL/HMS catalogs)",
1026 ));
1027 #[cfg(feature = "sink-postgres")]
1028 v.push(("postgres", "PostgreSQL sink (JSONB or auto-mapped columns)"));
1029 #[cfg(feature = "sink-jsonl")]
1030 v.push(("jsonl", "JSON Lines file sink"));
1031 #[cfg(feature = "sink-snowflake")]
1032 v.push(("snowflake", "Snowflake SQL REST API sink"));
1033 #[cfg(feature = "sink-mysql")]
1034 v.push(("mysql", "MySQL sink"));
1035 #[cfg(feature = "sink-mssql")]
1036 v.push((
1037 "mssql",
1038 "Microsoft SQL Server sink (auto-mapped columns or JSON column)",
1039 ));
1040 #[cfg(feature = "sink-sqlite")]
1041 v.push(("sqlite", "SQLite sink"));
1042 #[cfg(feature = "sink-s3")]
1043 v.push(("s3", "AWS S3 object sink"));
1044 #[cfg(feature = "sink-mongodb")]
1045 v.push(("mongodb", "MongoDB insert sink"));
1046 #[cfg(feature = "sink-redis")]
1047 v.push(("redis", "Redis (streams, lists, key-value) sink"));
1048 #[cfg(feature = "sink-csv")]
1049 v.push(("csv", "CSV file sink"));
1050 #[cfg(feature = "sink-elasticsearch")]
1051 v.push(("elasticsearch", "Elasticsearch bulk index sink"));
1052 #[cfg(feature = "sink-kafka")]
1053 v.push(("kafka", "Apache Kafka producer (rdkafka). FuturesUnordered batched sends with QueueFull retry; supports fixed or per-record topic routing."));
1054 #[cfg(feature = "sink-kinesis")]
1055 v.push(("kinesis", "AWS Kinesis Data Streams producer. Batched PutRecords with partition-key routing and partial-failure retry (DLQ-routable)."));
1056 #[cfg(feature = "sink-spanner")]
1057 v.push(("spanner", "Google Cloud Spanner sink. Batched mutations with upsert/delete write modes, exactly-once commit tokens, and schema evolution."));
1058 #[cfg(feature = "sink-http")]
1059 v.push(("http", "HTTP POST sink (individual or array batch)"));
1060 #[cfg(feature = "sink-stdout")]
1061 v.push(("stdout", "Stdout / stderr sink (JSON Lines, pretty, TSV)"));
1062 #[cfg(feature = "sink-parquet")]
1063 v.push(("parquet", "Apache Parquet file sink (local path or S3). Schema-inferred, configurable compression, row/byte rollover."));
1064 #[cfg(feature = "sink-delta")]
1065 v.push(("delta", "Apache Delta Lake sink (local FS or S3/Azure/GCS). Append-only, schema-inferred table creation, one commit per flush."));
1066 #[cfg(feature = "sink-gcs")]
1067 v.push(("gcs", "Google Cloud Storage sink — JSONL files"));
1068 v
1069}
1070
1071pub fn source_kinds() -> Vec<&'static str> {
1073 source_descriptions().into_iter().map(|(k, _)| k).collect()
1074}
1075
1076pub fn sink_kinds() -> Vec<&'static str> {
1078 sink_descriptions().into_iter().map(|(k, _)| k).collect()
1079}
1080
1081fn decode<T: DeserializeOwned>(kind: &'static str, name: &str, config: Value) -> CliResult<T> {
1082 serde_json::from_value(config).map_err(|e| CliError::InvalidConnectorConfig {
1083 kind,
1084 name: name.to_owned(),
1085 message: scrub_config_error(&e.to_string()),
1086 })
1087}
1088
1089fn scrub_config_error(msg: &str) -> String {
1099 const MAX_CHARS: usize = 200;
1100 let mut out = String::with_capacity(msg.len());
1101 let mut in_quote = false;
1102 for c in msg.chars() {
1103 if c == '"' {
1104 if !in_quote {
1105 out.push_str("\"<redacted>\"");
1106 }
1107 in_quote = !in_quote;
1108 continue;
1109 }
1110 if !in_quote {
1111 out.push(c);
1112 }
1113 }
1114 if out.chars().count() > MAX_CHARS {
1115 let truncated: String = out.chars().take(MAX_CHARS).collect();
1116 return format!("{truncated}…");
1117 }
1118 out
1119}
1120
1121fn schema<T: faucet_core::JsonSchema>() -> Value {
1122 serde_json::to_value(faucet_core::schema_for!(T))
1123 .unwrap_or_else(|_| serde_json::json!({"type": "object"}))
1124}
1125
1126fn unknown(name: &str, kind: &'static str, available: Vec<&'static str>) -> CliError {
1127 CliError::UnknownConnector {
1128 kind,
1129 name: name.to_owned(),
1130 available: if available.is_empty() {
1131 "(none — rebuild faucet-cli with the relevant feature enabled)".to_owned()
1132 } else {
1133 available.join(", ")
1134 },
1135 }
1136}
1137
1138#[cfg(test)]
1139mod tests {
1140 use super::*;
1141
1142 #[derive(Clone)]
1145 struct DummySource;
1146 #[faucet_core::async_trait]
1147 impl Source for DummySource {
1148 async fn fetch_with_context(
1149 &self,
1150 _ctx: &std::collections::HashMap<String, Value>,
1151 ) -> Result<Vec<Value>, faucet_core::FaucetError> {
1152 Ok(vec![serde_json::json!({"ok": true})])
1153 }
1154 fn config_schema(&self) -> Value {
1155 serde_json::json!({"type": "object"})
1156 }
1157 }
1158
1159 #[test]
1160 fn register_source_rejects_builtin_collision() {
1161 let reg = PluginRegistry::with_builtins()
1164 .register_source("csv", |_| Ok(Box::new(DummySource) as Box<dyn Source>));
1165 let err = reg
1168 .install()
1169 .expect_err("built-in collision must be rejected");
1170 match err {
1171 CliError::Config(msg) => assert!(msg.contains("built-in source"), "{msg}"),
1172 other => panic!("expected Config error, got {other:?}"),
1173 }
1174 }
1175
1176 #[test]
1177 fn register_source_rejects_duplicate() {
1178 let reg = PluginRegistry::new()
1179 .register_source("dup", |_| Ok(Box::new(DummySource) as Box<dyn Source>))
1180 .register_source("dup", |_| Ok(Box::new(DummySource) as Box<dyn Source>));
1181 let err = reg
1182 .install()
1183 .expect_err("duplicate registration must be rejected");
1184 match err {
1185 CliError::Config(msg) => assert!(msg.contains("more than once"), "{msg}"),
1186 other => panic!("expected Config error, got {other:?}"),
1187 }
1188 }
1189
1190 #[test]
1191 fn register_sink_rejects_duplicate() {
1192 let reg = PluginRegistry::new()
1196 .register_sink("dupsink", |_| Err(CliError::Config("unused".into())))
1197 .register_sink("dupsink", |_| Err(CliError::Config("unused".into())));
1198 assert!(
1199 reg.errors.iter().any(|e| e.contains("more than once")),
1200 "{:?}",
1201 reg.errors
1202 );
1203 }
1204
1205 #[test]
1206 fn custom_descriptions_use_default_when_blank() {
1207 let reg = PluginRegistry::new()
1208 .register_source("acme", |_| Ok(Box::new(DummySource) as Box<dyn Source>));
1209 let descs = reg.custom_source_descriptions();
1210 assert_eq!(descs.len(), 1);
1211 assert_eq!(descs[0].0, "acme");
1212 assert_eq!(descs[0].1, "custom source connector");
1213 }
1214
1215 #[test]
1216 fn custom_descriptions_carry_explicit_summary() {
1217 let reg = PluginRegistry::new().register_source_with(
1218 "acme",
1219 |_| Ok(Box::new(DummySource) as Box<dyn Source>),
1220 || serde_json::json!({"type": "object", "title": "acme"}),
1221 "Acme widget source",
1222 );
1223 let descs = reg.custom_source_descriptions();
1224 assert_eq!(descs[0], ("acme", "Acme widget source"));
1225 assert_eq!(
1227 (reg.sources.get("acme").unwrap().schema)()["title"],
1228 serde_json::json!("acme")
1229 );
1230 }
1231
1232 #[test]
1233 fn capability_constants_match_their_predicates() {
1234 for &k in EXACTLY_ONCE_SOURCE_KINDS {
1240 assert!(
1241 source_supports_exactly_once(k),
1242 "{k} should be exactly-once"
1243 );
1244 }
1245 for &k in IDEMPOTENT_SINK_KINDS {
1246 assert!(
1247 sink_supports_idempotent_writes(k),
1248 "{k} should be idempotent"
1249 );
1250 }
1251 for &k in UPSERT_SINK_KINDS {
1252 use faucet_core::WriteMode;
1253 assert!(
1254 sink_supported_write_modes(k).contains(&WriteMode::Upsert),
1255 "{k} should support upsert"
1256 );
1257 }
1258 assert!(IDEMPOTENT_SINK_KINDS.contains(&"bigquery"));
1259 assert!(IDEMPOTENT_SINK_KINDS.contains(&"kafka"));
1260 assert!(UPSERT_SINK_KINDS.contains(&"bigquery"));
1261 }
1262
1263 #[cfg(feature = "source-rest")]
1264 #[test]
1265 fn rest_source_appears_in_listings() {
1266 assert!(source_kinds().contains(&"rest"));
1267 }
1268
1269 #[cfg(feature = "sink-jsonl")]
1270 #[test]
1271 fn jsonl_sink_appears_in_listings() {
1272 assert!(sink_kinds().contains(&"jsonl"));
1273 }
1274
1275 #[tokio::test]
1276 async fn unknown_source_kind_errors() {
1277 let err = build_source("nope", serde_json::json!({}), &AuthCatalog::new(), None)
1278 .await
1279 .err()
1280 .expect("should fail");
1281 match err {
1282 CliError::UnknownConnector { kind, name, .. } => {
1283 assert_eq!(kind, "source");
1284 assert_eq!(name, "nope");
1285 }
1286 other => panic!("expected UnknownConnector, got {other:?}"),
1287 }
1288 }
1289
1290 #[tokio::test]
1291 async fn unknown_sink_kind_errors() {
1292 let err = build_sink("nope", serde_json::json!({}), &AuthCatalog::new())
1293 .await
1294 .err()
1295 .expect("should fail");
1296 assert!(matches!(
1297 err,
1298 CliError::UnknownConnector { kind: "sink", .. }
1299 ));
1300 }
1301
1302 #[cfg(feature = "source-rest")]
1303 #[test]
1304 fn rest_schema_is_object() {
1305 let s = source_schema("rest").unwrap();
1306 assert!(s.is_object());
1307 }
1308
1309 #[cfg(feature = "sink-jsonl")]
1310 #[test]
1311 fn jsonl_schema_is_object() {
1312 let s = sink_schema("jsonl").unwrap();
1313 assert!(s.is_object());
1314 }
1315
1316 #[test]
1317 fn scrub_config_error_redacts_quoted_values() {
1318 let msg =
1321 r#"invalid type: string "sk-super-secret-123", expected a sequence at line 1 column 9"#;
1322 let scrubbed = scrub_config_error(msg);
1323 assert!(!scrubbed.contains("sk-super-secret-123"), "{scrubbed}");
1324 assert!(scrubbed.contains("<redacted>"), "{scrubbed}");
1325 assert!(scrubbed.contains("invalid type"), "{scrubbed}");
1327 assert!(scrubbed.contains("expected a sequence"), "{scrubbed}");
1328 }
1329
1330 #[test]
1331 fn scrub_config_error_truncates_long_messages() {
1332 let msg = "x".repeat(500);
1333 let scrubbed = scrub_config_error(&msg);
1334 assert!(
1335 scrubbed.chars().count() <= 201,
1336 "len {}",
1337 scrubbed.chars().count()
1338 );
1339 assert!(scrubbed.ends_with('…'));
1340 }
1341
1342 #[cfg(feature = "source-csv")]
1346 #[tokio::test]
1347 async fn build_source_csv_succeeds_without_io() {
1348 let src = build_source(
1349 "csv",
1350 serde_json::json!({ "path": "/tmp/does-not-need-to-exist.csv" }),
1351 &AuthCatalog::new(),
1352 None,
1353 )
1354 .await
1355 .expect("csv source should build without I/O");
1356 assert_eq!(src.connector_name(), "CsvSource");
1359 }
1360
1361 #[cfg(feature = "sink-jsonl")]
1364 #[tokio::test]
1365 async fn build_sink_jsonl_succeeds_without_io() {
1366 let sink = build_sink(
1367 "jsonl",
1368 serde_json::json!({ "path": "/tmp/does-not-need-to-exist.jsonl" }),
1369 &AuthCatalog::new(),
1370 )
1371 .await
1372 .expect("jsonl sink should build without I/O");
1373 assert_eq!(sink.connector_name(), "jsonl");
1374 }
1375
1376 #[cfg(feature = "sink-stdout")]
1378 #[tokio::test]
1379 async fn build_sink_stdout_succeeds_without_io() {
1380 let sink = build_sink("stdout", serde_json::json!({}), &AuthCatalog::new())
1381 .await
1382 .expect("stdout sink should build without I/O");
1383 assert_eq!(sink.connector_name(), "StdoutSink");
1386 }
1387
1388 #[cfg(all(feature = "source-delta", feature = "sink-delta"))]
1392 #[tokio::test]
1393 async fn delta_registry_round_trip() {
1394 let dir = tempfile::tempdir().unwrap();
1395 let uri = dir.path().join("reg_delta").to_string_lossy().into_owned();
1396
1397 assert!(source_schema("delta").is_ok());
1398 assert!(sink_schema("delta").is_ok());
1399 assert!(source_descriptions().iter().any(|(n, _)| *n == "delta"));
1400 assert!(sink_descriptions().iter().any(|(n, _)| *n == "delta"));
1401
1402 let sink = build_sink(
1403 "delta",
1404 serde_json::json!({ "table_uri": uri }),
1405 &AuthCatalog::new(),
1406 )
1407 .await
1408 .expect("delta sink builds");
1409 assert_eq!(sink.connector_name(), "delta");
1410 let n = sink
1411 .write_batch(&[serde_json::json!({"id": 1}), serde_json::json!({"id": 2})])
1412 .await
1413 .expect("write");
1414 assert_eq!(n, 2);
1415 sink.flush().await.expect("flush");
1416
1417 let source = build_source(
1418 "delta",
1419 serde_json::json!({ "table_uri": uri }),
1420 &AuthCatalog::new(),
1421 None,
1422 )
1423 .await
1424 .expect("delta source builds");
1425 assert_eq!(source.connector_name(), "delta");
1426 let rows = source
1427 .fetch_with_context(&std::collections::HashMap::new())
1428 .await
1429 .expect("read");
1430 assert_eq!(rows.len(), 2);
1431 }
1432
1433 #[cfg(feature = "source-databricks")]
1436 #[tokio::test]
1437 async fn databricks_registry_source_builds() {
1438 assert!(source_schema("databricks").is_ok());
1439 assert!(
1440 source_descriptions()
1441 .iter()
1442 .any(|(n, _)| *n == "databricks")
1443 );
1444 let cfg = serde_json::json!({
1445 "workspace_url": "https://x.cloud.databricks.com",
1446 "warehouse_id": "wh1",
1447 "sql": "SELECT 1",
1448 "auth": { "type": "pat", "config": { "token": "t" } }
1449 });
1450 let src = build_source("databricks", cfg, &AuthCatalog::new(), None)
1451 .await
1452 .expect("databricks source builds");
1453 assert_eq!(src.connector_name(), "databricks");
1454 }
1455
1456 #[cfg(feature = "source-csv")]
1459 #[tokio::test]
1460 async fn build_source_csv_invalid_config_errors() {
1461 let res = build_source(
1465 "csv",
1466 serde_json::json!({ "path": 42 }),
1467 &AuthCatalog::new(),
1468 None,
1469 )
1470 .await;
1471 match res {
1472 Err(CliError::InvalidConnectorConfig { kind, name, .. }) => {
1473 assert_eq!(kind, "source");
1474 assert_eq!(name, "csv");
1475 }
1476 Ok(_) => panic!("expected InvalidConnectorConfig, got Ok"),
1477 Err(other) => panic!("expected InvalidConnectorConfig, got {other:?}"),
1478 }
1479 }
1480
1481 #[cfg(feature = "source-csv")]
1484 #[test]
1485 fn source_schema_csv_exposes_path_property() {
1486 let schema = source_schema("csv").expect("csv schema");
1487 let props = schema
1488 .get("properties")
1489 .and_then(Value::as_object)
1490 .expect("schema should have a properties object");
1491 assert!(props.contains_key("path"), "schema props: {props:?}");
1492 }
1493
1494 #[cfg(feature = "sink-jsonl")]
1495 #[test]
1496 fn sink_schema_jsonl_exposes_path_property() {
1497 let schema = sink_schema("jsonl").expect("jsonl schema");
1498 let props = schema
1499 .get("properties")
1500 .and_then(Value::as_object)
1501 .expect("schema should have a properties object");
1502 assert!(props.contains_key("path"), "schema props: {props:?}");
1503 }
1504
1505 #[test]
1506 fn unknown_source_schema_errors_with_available_list() {
1507 let err = source_schema("definitely-not-a-source").expect_err("unknown source");
1508 match err {
1509 CliError::UnknownConnector {
1510 kind,
1511 name,
1512 available,
1513 } => {
1514 assert_eq!(kind, "source");
1515 assert_eq!(name, "definitely-not-a-source");
1516 assert!(!available.is_empty());
1518 }
1519 other => panic!("expected UnknownConnector, got {other:?}"),
1520 }
1521 }
1522
1523 #[test]
1524 fn unknown_sink_schema_errors() {
1525 let err = sink_schema("definitely-not-a-sink").expect_err("unknown sink");
1526 assert!(matches!(
1527 err,
1528 CliError::UnknownConnector { kind: "sink", .. }
1529 ));
1530 }
1531
1532 #[cfg(feature = "source-csv")]
1533 #[test]
1534 fn source_exists_is_true_for_known_and_false_for_unknown() {
1535 assert!(source_exists("csv"));
1536 assert!(!source_exists("definitely-not-a-source"));
1537 }
1538
1539 #[cfg(feature = "sink-jsonl")]
1540 #[test]
1541 fn sink_exists_is_true_for_known_and_false_for_unknown() {
1542 assert!(sink_exists("jsonl"));
1543 assert!(!sink_exists("definitely-not-a-sink"));
1544 }
1545
1546 #[test]
1549 fn source_descriptions_are_non_empty_and_consistent() {
1550 let descs = source_descriptions();
1551 assert!(!descs.is_empty());
1552 for (name, summary) in &descs {
1553 assert!(!name.is_empty(), "empty connector name");
1554 assert!(!summary.is_empty(), "empty summary for {name}");
1555 assert!(
1556 source_schema(name).is_ok(),
1557 "listed source `{name}` has no schema"
1558 );
1559 }
1560 }
1561
1562 #[test]
1563 fn sink_descriptions_are_non_empty_and_consistent() {
1564 let descs = sink_descriptions();
1565 assert!(!descs.is_empty());
1566 for (name, summary) in &descs {
1567 assert!(!name.is_empty(), "empty connector name");
1568 assert!(!summary.is_empty(), "empty summary for {name}");
1569 assert!(
1570 sink_schema(name).is_ok(),
1571 "listed sink `{name}` has no schema"
1572 );
1573 }
1574 }
1575
1576 #[cfg(all(feature = "source-csv", feature = "source-rest"))]
1579 #[test]
1580 fn source_kinds_contains_expected_builtins() {
1581 let kinds = source_kinds();
1582 assert!(kinds.contains(&"csv"));
1583 assert!(kinds.contains(&"rest"));
1584 }
1585
1586 #[cfg(all(feature = "sink-jsonl", feature = "sink-stdout"))]
1587 #[test]
1588 fn sink_kinds_contains_expected_builtins() {
1589 let kinds = sink_kinds();
1590 assert!(kinds.contains(&"jsonl"));
1591 assert!(kinds.contains(&"stdout"));
1592 }
1593
1594 #[cfg(feature = "source-rest")]
1598 #[tokio::test]
1599 async fn build_source_injects_referenced_auth_provider() {
1600 let mut specs = std::collections::HashMap::new();
1601 specs.insert(
1602 "tok".to_string(),
1603 serde_json::json!({"type": "static", "config": {"token": "abc"}}),
1604 );
1605 let catalog = auth_catalog::build_auth_catalog(Some(&specs)).expect("catalog");
1606
1607 let src = build_source("rest", rest_config_with_auth_ref("tok"), &catalog, None)
1608 .await
1609 .expect("rest source with a resolvable auth ref should build");
1610 assert_eq!(src.connector_name(), "rest");
1611 }
1612
1613 #[cfg(feature = "source-rest")]
1616 fn rest_config_with_auth_ref(name: &str) -> Value {
1617 let cfg = faucet_source_rest::RestStreamConfig::new("https://api.example.com", "/v1");
1618 let mut v = serde_json::to_value(cfg).expect("serialize rest config");
1619 v.as_object_mut()
1620 .unwrap()
1621 .insert("auth".to_string(), serde_json::json!({ "ref": name }));
1622 v
1623 }
1624
1625 #[cfg(feature = "source-rest")]
1628 #[tokio::test]
1629 async fn build_source_unknown_auth_ref_errors() {
1630 let res = build_source(
1631 "rest",
1632 rest_config_with_auth_ref("missing"),
1633 &AuthCatalog::new(),
1634 None,
1635 )
1636 .await;
1637 match res {
1638 Err(CliError::UnknownAuthProvider { name, .. }) => assert_eq!(name, "missing"),
1639 Ok(_) => panic!("expected UnknownAuthProvider, got Ok"),
1640 Err(other) => panic!("expected UnknownAuthProvider, got {other:?}"),
1641 }
1642 }
1643
1644 #[test]
1645 fn exactly_once_capability_allowlists() {
1646 assert!(source_supports_exactly_once("postgres-cdc"));
1647 assert!(source_supports_exactly_once("mysql-cdc"));
1648 assert!(source_supports_exactly_once("mongodb-cdc"));
1649 assert!(source_supports_exactly_once("kafka"));
1650 assert!(!source_supports_exactly_once("rest"));
1651
1652 assert!(sink_supports_idempotent_writes("postgres"));
1653 assert!(sink_supports_idempotent_writes("iceberg"));
1654 assert!(sink_supports_idempotent_writes("bigquery"));
1655 assert!(sink_supports_idempotent_writes("kafka"));
1656 assert!(sink_supports_idempotent_writes("snowflake"));
1657 assert!(sink_supports_idempotent_writes("redis"));
1658 assert!(sink_supports_idempotent_writes("mongodb"));
1659 assert!(!sink_supports_idempotent_writes("jsonl"));
1660 }
1661
1662 #[test]
1663 fn typed_delivery_capabilities_derive_from_kind_tables() {
1664 use faucet_core::{ReplayGuarantee, SinkGuarantee};
1665 assert_eq!(
1666 source_replay_guarantee("kafka"),
1667 ReplayGuarantee::Deterministic
1668 );
1669 assert_eq!(
1670 source_replay_guarantee("rest"),
1671 ReplayGuarantee::NonDeterministic
1672 );
1673 assert_eq!(sink_guarantee("postgres"), SinkGuarantee::AtomicWatermark);
1674 assert_eq!(sink_guarantee("elasticsearch"), SinkGuarantee::KeyedUpsert);
1676 assert_eq!(sink_guarantee("jsonl"), SinkGuarantee::AtLeastOnce);
1677 }
1678
1679 #[test]
1680 fn sink_supported_write_modes_allowlist() {
1681 use faucet_core::WriteMode;
1682 assert!(sink_supported_write_modes("postgres").contains(&WriteMode::Upsert));
1683 assert!(sink_supported_write_modes("elasticsearch").contains(&WriteMode::Delete));
1684 assert!(sink_supported_write_modes("bigquery").contains(&WriteMode::Upsert));
1685 assert_eq!(sink_supported_write_modes("jsonl"), &[WriteMode::Append]);
1687 assert_eq!(sink_supported_write_modes("kafka"), &[WriteMode::Append]);
1688 }
1689
1690 #[test]
1691 fn sink_supports_schema_evolution_allowlist() {
1692 assert!(sink_supports_schema_evolution("postgres"));
1693 assert!(sink_supports_schema_evolution("mysql"));
1694 assert!(sink_supports_schema_evolution("mssql"));
1695 assert!(sink_supports_schema_evolution("sqlite"));
1696 assert!(sink_supports_schema_evolution("bigquery"));
1697 assert!(sink_supports_schema_evolution("elasticsearch"));
1698 assert!(!sink_supports_schema_evolution("iceberg"));
1699 assert!(!sink_supports_schema_evolution("jsonl"));
1700 assert!(!sink_supports_schema_evolution("kafka"));
1701 }
1702}