1#[derive(Debug, Clone, Copy, PartialEq, Eq)]
17pub enum ConnectorKind {
18 Source,
19 Sink,
20}
21
22impl ConnectorKind {
23 pub fn as_str(self) -> &'static str {
25 match self {
26 ConnectorKind::Source => "source",
27 ConnectorKind::Sink => "sink",
28 }
29 }
30
31 pub fn parse(s: &str) -> Result<Self, String> {
33 match s {
34 "source" => Ok(ConnectorKind::Source),
35 "sink" => Ok(ConnectorKind::Sink),
36 other => Err(format!(
37 "unknown connector kind `{other}` (expected `source` or `sink`)"
38 )),
39 }
40 }
41}
42
43#[derive(Debug, Clone, PartialEq, Eq)]
46pub struct GeneratedFile {
47 pub path: String,
48 pub contents: String,
49}
50
51#[derive(Debug, Clone)]
53pub struct ConnectorScaffold {
54 pub name: String,
56 pub kind: ConnectorKind,
57 pub with_common: bool,
59}
60
61impl ConnectorScaffold {
62 pub fn new(name: &str, kind: ConnectorKind, with_common: bool) -> Result<Self, String> {
64 validate_name(name)?;
65 Ok(Self {
66 name: name.to_owned(),
67 kind,
68 with_common,
69 })
70 }
71
72 pub fn crate_name(&self) -> String {
74 format!("faucet-{}-{}", self.kind.as_str(), self.name)
75 }
76
77 pub fn common_crate_name(&self) -> String {
79 format!("faucet-common-{}", self.name)
80 }
81
82 pub fn type_prefix(&self) -> String {
84 to_pascal(&self.name)
85 }
86
87 pub fn connector_type(&self) -> String {
89 format!(
90 "{}{}",
91 self.type_prefix(),
92 match self.kind {
93 ConnectorKind::Source => "Source",
94 ConnectorKind::Sink => "Sink",
95 }
96 )
97 }
98
99 pub fn config_type(&self) -> String {
101 format!("{}Config", self.connector_type())
102 }
103
104 pub fn files(&self) -> Vec<GeneratedFile> {
106 let base = self.crate_name();
107 let impl_file = match self.kind {
108 ConnectorKind::Source => "stream.rs",
109 ConnectorKind::Sink => "sink.rs",
110 };
111 let mut files = vec![
112 GeneratedFile {
113 path: format!("{base}/Cargo.toml"),
114 contents: self.cargo_toml(),
115 },
116 GeneratedFile {
117 path: format!("{base}/README.md"),
118 contents: self.readme(),
119 },
120 GeneratedFile {
121 path: format!("{base}/src/lib.rs"),
122 contents: self.lib_rs(),
123 },
124 GeneratedFile {
125 path: format!("{base}/src/config.rs"),
126 contents: self.config_rs(),
127 },
128 GeneratedFile {
129 path: format!("{base}/src/{impl_file}"),
130 contents: self.impl_rs(),
131 },
132 ];
133 if self.with_common {
134 let cbase = self.common_crate_name();
135 files.push(GeneratedFile {
136 path: format!("{cbase}/Cargo.toml"),
137 contents: self.common_cargo_toml(),
138 });
139 files.push(GeneratedFile {
140 path: format!("{cbase}/src/lib.rs"),
141 contents: self.common_lib_rs(),
142 });
143 }
144 files
145 }
146
147 fn cargo_toml(&self) -> String {
148 let name = &self.name;
149 let crate_name = self.crate_name();
150 let role = self.kind.as_str();
151 let common_dep = if self.with_common {
152 format!(
153 "{cn} = {{ path = \"../{cn}\", version = \"1.0.0\" }}\n",
154 cn = self.common_crate_name()
155 )
156 } else {
157 String::new()
158 };
159 format!(
160 r#"[package]
161name = "{crate_name}"
162version = "1.0.0"
163edition = "2024"
164rust-version = "1.96"
165license = "MIT OR Apache-2.0"
166repository = "https://github.com/your-org/{crate_name}"
167description = "{name} {role} connector for the faucet-stream ecosystem"
168readme = "README.md"
169# System name first so the crate ranks on crates.io for `{name}`.
170keywords = ["{name}", "etl", "pipeline", "connector", "data"]
171categories = ["database", "asynchronous"]
172
173[dependencies]
174# faucet-core carries the Source/Sink traits and re-exports async_trait +
175# serde_json. serde + schemars are pulled directly for the derive macros
176# (matching every built-in connector crate).
177faucet-core = "1"
178{common_dep}serde = {{ version = "1", features = ["derive"] }}
179schemars = "1"
180
181[dev-dependencies]
182tokio = {{ version = "1", features = ["macros", "rt-multi-thread"] }}
183
184# Renders the complete feature-gated API (with per-item feature badges) on
185# docs.rs — mirror this in every connector crate.
186[package.metadata.docs.rs]
187all-features = true
188rustdoc-args = ["--cfg", "docsrs"]
189"#
190 )
191 }
192
193 fn lib_rs(&self) -> String {
194 let crate_name = self.crate_name();
195 let name = &self.name;
196 let role = self.kind.as_str();
197 let config_ty = self.config_type();
198 let conn_ty = self.connector_type();
199 let (trait_reexport, impl_mod) = match self.kind {
200 ConnectorKind::Source => ("Source", "stream"),
201 ConnectorKind::Sink => ("Sink", "sink"),
202 };
203 format!(
204 r#"#![cfg_attr(docsrs, feature(doc_cfg))]
205
206//! # {crate_name}
207//!
208//! {name} {role} connector for the [faucet-stream](https://docs.rs/faucet-stream)
209//! ecosystem. Generated by `faucet new connector`.
210
211pub mod config;
212pub mod {impl_mod};
213
214pub use faucet_core::{{FaucetError, {trait_reexport}}};
215
216pub use config::{config_ty};
217pub use {impl_mod}::{conn_ty};
218"#
219 )
220 }
221
222 fn config_rs(&self) -> String {
223 let config_ty = self.config_type();
224 let name = &self.name;
225 format!(
226 r#"//! Configuration for the {name} connector.
227//!
228//! No I/O or protocol logic lives here — just the serde/schemars-deriving
229//! config struct (and any sub-enums it needs).
230
231use faucet_core::JsonSchema;
232use serde::{{Deserialize, Serialize}};
233
234/// Config for the {name} connector, deserialized from the `config:` block of a
235/// `faucet.yaml` pipeline.
236#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
237#[serde(deny_unknown_fields)]
238pub struct {config_ty} {{
239 /// TODO: replace with your connector's real settings (endpoint, table,
240 /// bucket, credentials-via-`${{env:VAR}}`, …). This placeholder keeps the
241 /// generated crate compiling and testable out of the box.
242 #[serde(default)]
243 pub example_setting: Option<String>,
244}}
245"#
246 )
247 }
248
249 fn impl_rs(&self) -> String {
250 match self.kind {
251 ConnectorKind::Source => self.source_impl(),
252 ConnectorKind::Sink => self.sink_impl(),
253 }
254 }
255
256 fn source_impl(&self) -> String {
257 let name = &self.name;
258 let config_ty = self.config_type();
259 let conn_ty = self.connector_type();
260 format!(
261 r#"//! The {name} source — the one module that performs I/O.
262
263use crate::config::{config_ty};
264use faucet_core::{{async_trait, serde_json::Value, FaucetError, Source}};
265use std::collections::HashMap;
266
267/// {name} source connector.
268pub struct {conn_ty} {{
269 #[allow(dead_code)]
270 config: {config_ty},
271}}
272
273impl {conn_ty} {{
274 /// Construct the source from its config. Store reusable clients/pools here;
275 /// never recreate them per fetch (see the faucet performance guidelines).
276 pub fn new(config: {config_ty}) -> Self {{
277 Self {{ config }}
278 }}
279}}
280
281#[async_trait]
282impl Source for {conn_ty} {{
283 async fn fetch_with_context(
284 &self,
285 _context: &HashMap<String, Value>,
286 ) -> Result<Vec<Value>, FaucetError> {{
287 // TODO: fetch real records from your source. The passthrough below
288 // returns an empty page so the generated crate compiles and tests green.
289 Ok(Vec::new())
290 }}
291
292 fn config_schema(&self) -> Value {{
293 faucet_core::serde_json::to_value(faucet_core::schema_for!({config_ty}))
294 .unwrap_or(Value::Null)
295 }}
296
297 fn connector_name(&self) -> &'static str {{
298 "{name}"
299 }}
300}}
301
302#[cfg(test)]
303mod tests {{
304 use super::*;
305
306 #[tokio::test]
307 async fn fetches_without_error() {{
308 let source = {conn_ty}::new({config_ty} {{ example_setting: None }});
309 let records = source.fetch_all().await.expect("fetch");
310 assert!(records.is_empty());
311 assert_eq!(source.connector_name(), "{name}");
312 }}
313}}
314"#
315 )
316 }
317
318 fn sink_impl(&self) -> String {
319 let name = &self.name;
320 let config_ty = self.config_type();
321 let conn_ty = self.connector_type();
322 format!(
323 r#"//! The {name} sink — the one module that performs I/O.
324
325use crate::config::{config_ty};
326use faucet_core::{{async_trait, serde_json::Value, FaucetError, Sink}};
327
328/// {name} sink connector.
329pub struct {conn_ty} {{
330 #[allow(dead_code)]
331 config: {config_ty},
332}}
333
334impl {conn_ty} {{
335 /// Construct the sink from its config. Store reusable clients/pools here.
336 pub fn new(config: {config_ty}) -> Self {{
337 Self {{ config }}
338 }}
339}}
340
341#[async_trait]
342impl Sink for {conn_ty} {{
343 async fn write_batch(&self, records: &[Value]) -> Result<usize, FaucetError> {{
344 // TODO: write `records` to your destination (prefer a bulk/batch API).
345 // The passthrough below just counts them so the crate tests green.
346 Ok(records.len())
347 }}
348
349 fn config_schema(&self) -> Value {{
350 faucet_core::serde_json::to_value(faucet_core::schema_for!({config_ty}))
351 .unwrap_or(Value::Null)
352 }}
353
354 fn connector_name(&self) -> &'static str {{
355 "{name}"
356 }}
357}}
358
359#[cfg(test)]
360mod tests {{
361 use super::*;
362 use faucet_core::serde_json::json;
363
364 #[tokio::test]
365 async fn writes_batch() {{
366 let sink = {conn_ty}::new({config_ty} {{ example_setting: None }});
367 let n = sink.write_batch(&[json!({{"a": 1}})]).await.expect("write");
368 assert_eq!(n, 1);
369 assert_eq!(sink.connector_name(), "{name}");
370 }}
371}}
372"#
373 )
374 }
375
376 fn readme(&self) -> String {
377 let crate_name = self.crate_name();
378 let name = &self.name;
379 let role = self.kind.as_str();
380 let type_kw = if self.kind == ConnectorKind::Source {
381 "source"
382 } else {
383 "sink"
384 };
385 format!(
386 r#"# {crate_name}
387
388{name} {role} connector for the [faucet-stream](https://github.com/PawanSikawat/faucet-stream)
389ecosystem. Generated by `faucet new connector`.
390
391## Usage from a pipeline config
392
393```yaml
394pipeline:
395 {type_kw}:
396 type: {name}
397 config:
398 example_setting: replace-me
399```
400
401To use it from the `faucet` CLI, build a custom binary that registers it — see
402[Custom binaries with third-party connectors](https://github.com/PawanSikawat/faucet-stream/blob/main/cli/README.md#custom-binaries-with-third-party-connectors).
403
404## Next steps
405
4061. Replace the fields in `src/config.rs` with your connector's real settings.
4072. Implement the I/O in `src/{impl_file}` (reuse clients/pools created in `new()`).
4083. Flesh out the tests, then publish with `cargo publish`.
409"#,
410 impl_file = match self.kind {
411 ConnectorKind::Source => "stream.rs",
412 ConnectorKind::Sink => "sink.rs",
413 }
414 )
415 }
416
417 fn common_cargo_toml(&self) -> String {
418 let common = self.common_crate_name();
419 let name = &self.name;
420 format!(
421 r#"[package]
422name = "{common}"
423version = "1.0.0"
424edition = "2024"
425rust-version = "1.96"
426license = "MIT OR Apache-2.0"
427repository = "https://github.com/your-org/{common}"
428description = "Shared config types for the {name} faucet-stream source/sink pair"
429readme = "README.md"
430keywords = ["{name}", "etl", "pipeline", "connector", "data"]
431categories = ["database", "asynchronous"]
432
433[dependencies]
434faucet-core = "1"
435serde = {{ version = "1", features = ["derive"] }}
436schemars = "1"
437
438[package.metadata.docs.rs]
439all-features = true
440rustdoc-args = ["--cfg", "docsrs"]
441"#
442 )
443 }
444
445 fn common_lib_rs(&self) -> String {
446 let name = &self.name;
447 format!(
448 r#"#![cfg_attr(docsrs, feature(doc_cfg))]
449
450//! Shared config types for the {name} source/sink pair.
451//!
452//! Put auth enums, value-format enums, TLS settings, and any other types both
453//! the source and the sink crates need here; re-export them from each so
454//! end-user imports don't change.
455
456use faucet_core::JsonSchema;
457use serde::{{Deserialize, Serialize}};
458
459/// Shared connection settings for {name}.
460#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
461#[serde(deny_unknown_fields)]
462pub struct {prefix}Connection {{
463 /// TODO: shared connection fields (endpoint, credentials, …).
464 #[serde(default)]
465 pub endpoint: Option<String>,
466}}
467"#,
468 prefix = self.type_prefix()
469 )
470 }
471}
472
473pub fn validate_name(name: &str) -> Result<(), String> {
476 if name.is_empty() {
477 return Err("connector name must not be empty".to_owned());
478 }
479 let mut chars = name.chars();
480 let first = chars.next().unwrap();
481 if !first.is_ascii_lowercase() {
482 return Err(format!(
483 "connector name `{name}` must start with a lowercase ASCII letter"
484 ));
485 }
486 if !name
487 .chars()
488 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
489 {
490 return Err(format!(
491 "connector name `{name}` may only contain lowercase letters, digits, and hyphens"
492 ));
493 }
494 if name.ends_with('-') || name.contains("--") {
495 return Err(format!(
496 "connector name `{name}` has a stray/doubled hyphen"
497 ));
498 }
499 Ok(())
500}
501
502fn to_pascal(name: &str) -> String {
504 name.split('-')
505 .filter(|s| !s.is_empty())
506 .map(|word| {
507 let mut c = word.chars();
508 match c.next() {
509 Some(first) => first.to_ascii_uppercase().to_string() + c.as_str(),
510 None => String::new(),
511 }
512 })
513 .collect()
514}
515
516#[cfg(test)]
517mod tests {
518 use super::*;
519
520 #[test]
521 fn validates_names() {
522 assert!(validate_name("acme").is_ok());
523 assert!(validate_name("acme-widgets").is_ok());
524 assert!(validate_name("s3").is_ok());
525 assert!(validate_name("").is_err());
526 assert!(validate_name("Acme").is_err());
527 assert!(validate_name("1acme").is_err());
528 assert!(validate_name("acme_widgets").is_err());
529 assert!(validate_name("acme-").is_err());
530 assert!(validate_name("acme--x").is_err());
531 }
532
533 #[test]
534 fn pascal_case() {
535 assert_eq!(to_pascal("acme"), "Acme");
536 assert_eq!(to_pascal("acme-widgets"), "AcmeWidgets");
537 assert_eq!(to_pascal("s3"), "S3");
538 }
539
540 #[test]
541 fn source_scaffold_shape() {
542 let s = ConnectorScaffold::new("acme", ConnectorKind::Source, false).unwrap();
543 assert_eq!(s.crate_name(), "faucet-source-acme");
544 assert_eq!(s.connector_type(), "AcmeSource");
545 assert_eq!(s.config_type(), "AcmeSourceConfig");
546 let files = s.files();
547 let paths: Vec<&str> = files.iter().map(|f| f.path.as_str()).collect();
548 assert!(paths.contains(&"faucet-source-acme/Cargo.toml"));
549 assert!(paths.contains(&"faucet-source-acme/src/lib.rs"));
550 assert!(paths.contains(&"faucet-source-acme/src/config.rs"));
551 assert!(paths.contains(&"faucet-source-acme/src/stream.rs"));
552 assert!(paths.contains(&"faucet-source-acme/README.md"));
553 assert!(!paths.iter().any(|p| p.contains("faucet-common-acme")));
555 }
556
557 #[test]
558 fn cargo_toml_follows_conventions() {
559 let s = ConnectorScaffold::new("acme", ConnectorKind::Source, false).unwrap();
560 let cargo = s
561 .files()
562 .into_iter()
563 .find(|f| f.path.ends_with("Cargo.toml"))
564 .unwrap()
565 .contents;
566 assert!(cargo.contains("version = \"1.0.0\""), "must start at 1.0.0");
567 assert!(
568 cargo.contains("keywords = [\"acme\""),
569 "system name keyword first"
570 );
571 assert!(cargo.contains("[package.metadata.docs.rs]"));
572 assert!(cargo.contains("all-features = true"));
573 assert!(cargo.contains("faucet-core = \"1\""));
574 }
575
576 #[test]
577 fn lib_rs_has_docsrs_line_and_reexports() {
578 let s = ConnectorScaffold::new("acme", ConnectorKind::Sink, false).unwrap();
579 let lib = s
580 .files()
581 .into_iter()
582 .find(|f| f.path.ends_with("src/lib.rs"))
583 .unwrap()
584 .contents;
585 assert!(lib.starts_with("#![cfg_attr(docsrs, feature(doc_cfg))]"));
586 assert!(lib.contains("pub use sink::AcmeSink"));
587 assert!(lib.contains("pub mod sink;"));
588 }
589
590 #[test]
591 fn sink_impl_implements_trait() {
592 let s = ConnectorScaffold::new("acme", ConnectorKind::Sink, false).unwrap();
593 let sink = s
594 .files()
595 .into_iter()
596 .find(|f| f.path.ends_with("sink.rs"))
597 .unwrap()
598 .contents;
599 assert!(sink.contains("impl Sink for AcmeSink"));
600 assert!(sink.contains("async fn write_batch"));
601 assert!(sink.contains("fn connector_name(&self) -> &'static str"));
602 }
603
604 #[test]
605 fn common_crate_emitted_when_requested() {
606 let s = ConnectorScaffold::new("acme", ConnectorKind::Source, true).unwrap();
607 let paths: Vec<String> = s.files().into_iter().map(|f| f.path).collect();
608 assert!(paths.iter().any(|p| p == "faucet-common-acme/Cargo.toml"));
609 assert!(paths.iter().any(|p| p == "faucet-common-acme/src/lib.rs"));
610 }
611}