Skip to main content

faucet_cli/
scaffold.rs

1//! Connector SDK scaffolding (#209): pure templates for `faucet new connector`.
2//!
3//! Emits a ready-to-build `faucet-source-<name>` / `faucet-sink-<name>` crate
4//! that follows every repo convention — the standard module layout,
5//! `JsonSchema`-deriving config, `config_schema()` / `connector_name()`
6//! overrides, the `#![cfg_attr(docsrs, feature(doc_cfg))]` crate-root line, the
7//! `[package.metadata.docs.rs]` block, system-name-first crates.io keywords, a
8//! README, and a passing unit test — so a third-party author starts from a
9//! compiling, publish-ready crate instead of hand-assembling one.
10//!
11//! Everything here is a pure `&self -> String` / `-> Vec<GeneratedFile>`
12//! function so it is fully unit-testable without touching the filesystem; the
13//! [`crate::commands::new`] command writes the returned files to disk.
14
15/// Which half of a connector pair to scaffold.
16#[derive(Debug, Clone, Copy, PartialEq, Eq)]
17pub enum ConnectorKind {
18    Source,
19    Sink,
20}
21
22impl ConnectorKind {
23    /// `"source"` / `"sink"` — the crate-name infix and role word.
24    pub fn as_str(self) -> &'static str {
25        match self {
26            ConnectorKind::Source => "source",
27            ConnectorKind::Sink => "sink",
28        }
29    }
30
31    /// Parse from the `--kind` argument.
32    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/// A single generated file: a path relative to the new crate's parent
44/// directory, and its full contents.
45#[derive(Debug, Clone, PartialEq, Eq)]
46pub struct GeneratedFile {
47    pub path: String,
48    pub contents: String,
49}
50
51/// A parsed, validated scaffold request.
52#[derive(Debug, Clone)]
53pub struct ConnectorScaffold {
54    /// System name, e.g. `acme` (validated: `^[a-z][a-z0-9-]*$`).
55    pub name: String,
56    pub kind: ConnectorKind,
57    /// Also emit a `faucet-common-<name>` crate for shared source/sink config.
58    pub with_common: bool,
59}
60
61impl ConnectorScaffold {
62    /// Validate the connector name and build a scaffold request.
63    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    /// `faucet-source-acme` / `faucet-sink-acme`.
73    pub fn crate_name(&self) -> String {
74        format!("faucet-{}-{}", self.kind.as_str(), self.name)
75    }
76
77    /// `faucet-common-acme`.
78    pub fn common_crate_name(&self) -> String {
79        format!("faucet-common-{}", self.name)
80    }
81
82    /// PascalCase system name, e.g. `acme-widgets` → `AcmeWidgets`.
83    pub fn type_prefix(&self) -> String {
84        to_pascal(&self.name)
85    }
86
87    /// The connector struct name, e.g. `AcmeSource` / `AcmeSink`.
88    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    /// The config struct name, e.g. `AcmeSourceConfig`.
100    pub fn config_type(&self) -> String {
101        format!("{}Config", self.connector_type())
102    }
103
104    /// All files to write, each path relative to the output parent directory.
105    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
473/// Validate a connector system name: lowercase, starts with a letter, then
474/// letters/digits/hyphens (the `faucet-<kind>-<name>` convention).
475pub 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
502/// PascalCase a hyphenated system name: `acme-widgets` → `AcmeWidgets`.
503fn 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        // No common crate unless requested.
554        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}