Skip to main content

faucet_cli/commands/
new.rs

1//! `faucet new connector` — scaffold a ready-to-build connector crate (#209).
2
3use crate::cli::{NewArgs, NewConnectorArgs, NewTarget};
4use crate::error::{CliError, CliResult};
5use crate::scaffold::{ConnectorKind, ConnectorScaffold};
6
7/// Execute the `new` command.
8pub async fn run(args: NewArgs) -> CliResult<()> {
9    match args.target {
10        NewTarget::Connector(a) => run_connector(a).await,
11    }
12}
13
14/// Scaffold a connector crate.
15async fn run_connector(args: NewConnectorArgs) -> CliResult<()> {
16    let kind = ConnectorKind::parse(&args.kind).map_err(CliError::Config)?;
17    let scaffold =
18        ConnectorScaffold::new(&args.name, kind, args.common).map_err(CliError::Config)?;
19    let files = scaffold.files();
20    let root = &args.output;
21
22    // Fail before writing anything if any target already exists (unless --force).
23    if !args.force {
24        for f in &files {
25            let path = root.join(&f.path);
26            if path.exists() {
27                return Err(CliError::ScaffoldExists { path });
28            }
29        }
30    }
31
32    for f in &files {
33        let path = root.join(&f.path);
34        if let Some(parent) = path.parent() {
35            std::fs::create_dir_all(parent)?;
36        }
37        std::fs::write(&path, &f.contents)?;
38    }
39
40    println!(
41        "Scaffolded {} ({} files):",
42        scaffold.crate_name(),
43        files.len()
44    );
45    for f in &files {
46        println!("  {}", root.join(&f.path).display());
47    }
48    println!(
49        "\nNext:\n  cd {}\n  cargo test          # the generated passthrough compiles & tests green\n  # then implement the TODOs in src/config.rs and src/{}",
50        root.join(scaffold.crate_name()).display(),
51        match kind {
52            ConnectorKind::Source => "stream.rs",
53            ConnectorKind::Sink => "sink.rs",
54        }
55    );
56    // Conformance tier: a fresh scaffold has a real config schema but is not yet
57    // registered/documented, so it starts at ⚪ Draft. Show the path to Stable.
58    let cap = match kind {
59        ConnectorKind::Source => "native streaming (override stream_pages) + resumable bookmarks",
60        ConnectorKind::Sink => "idempotent writes + upsert / schema evolution",
61    };
62    println!(
63        "\nConformance tier: ⚪ Draft → reach 🟢 Stable by\n  \
64         [ ] adding a verified entry to cli/connectors/registry.json\n  \
65         [ ] a complete config_schema() (already scaffolded)\n  \
66         [ ] a one-line description in the connector catalog\n  \
67         [ ] {cap}\n  \
68         [ ] unit + integration tests (run the faucet-conformance battery)\n\
69         Check your score any time with `faucet conformance {}`.",
70        scaffold.crate_name(),
71    );
72    Ok(())
73}
74
75#[cfg(test)]
76mod tests {
77    use super::*;
78
79    #[tokio::test]
80    async fn scaffolds_a_source_crate_to_disk() {
81        let dir = tempfile::tempdir().unwrap();
82        let args = NewConnectorArgs {
83            name: "acme".into(),
84            kind: "source".into(),
85            common: false,
86            output: dir.path().to_path_buf(),
87            force: false,
88        };
89        run_connector(args).await.expect("scaffold succeeds");
90        let base = dir.path().join("faucet-source-acme");
91        assert!(base.join("Cargo.toml").is_file());
92        assert!(base.join("src/lib.rs").is_file());
93        assert!(base.join("src/config.rs").is_file());
94        assert!(base.join("src/stream.rs").is_file());
95        assert!(base.join("README.md").is_file());
96        let lib = std::fs::read_to_string(base.join("src/lib.rs")).unwrap();
97        assert!(lib.contains("pub use stream::AcmeSource;"));
98    }
99
100    #[tokio::test]
101    async fn refuses_to_overwrite_without_force() {
102        let dir = tempfile::tempdir().unwrap();
103        let mk = || NewConnectorArgs {
104            name: "acme".into(),
105            kind: "sink".into(),
106            common: false,
107            output: dir.path().to_path_buf(),
108            force: false,
109        };
110        run_connector(mk()).await.expect("first scaffold");
111        let err = run_connector(mk()).await.expect_err("second must refuse");
112        assert!(matches!(err, CliError::ScaffoldExists { .. }));
113    }
114
115    #[tokio::test]
116    async fn force_overwrites() {
117        let dir = tempfile::tempdir().unwrap();
118        let mk = |force| NewConnectorArgs {
119            name: "acme".into(),
120            kind: "sink".into(),
121            common: true,
122            output: dir.path().to_path_buf(),
123            force,
124        };
125        run_connector(mk(false)).await.expect("first scaffold");
126        run_connector(mk(true)).await.expect("force overwrite");
127        assert!(dir.path().join("faucet-common-acme/Cargo.toml").is_file());
128    }
129
130    #[tokio::test]
131    async fn rejects_bad_kind() {
132        let dir = tempfile::tempdir().unwrap();
133        let args = NewConnectorArgs {
134            name: "acme".into(),
135            kind: "middleware".into(),
136            common: false,
137            output: dir.path().to_path_buf(),
138            force: false,
139        };
140        assert!(matches!(
141            run_connector(args).await,
142            Err(CliError::Config(_))
143        ));
144    }
145
146    #[tokio::test]
147    async fn rejects_bad_name() {
148        let dir = tempfile::tempdir().unwrap();
149        let args = NewConnectorArgs {
150            name: "Acme_Bad".into(),
151            kind: "source".into(),
152            common: false,
153            output: dir.path().to_path_buf(),
154            force: false,
155        };
156        assert!(matches!(
157            run_connector(args).await,
158            Err(CliError::Config(_))
159        ));
160    }
161}