Skip to main content

faucet_cli/commands/
init.rs

1//! `faucet init` — scaffold a starter `pipeline.yaml` from each connector's
2//! JSON Schema. Defaults to a `rest` → `jsonl` pipeline so `faucet init` with
3//! no flags continues to produce the same shape it did before this command
4//! grew schema-driven scaffolding.
5
6use std::collections::HashMap;
7
8use crate::cli::InitArgs;
9use crate::error::{CliError, CliResult};
10#[cfg(feature = "cli-interactive")]
11use crate::init_template::discover_tagged_enum_fields;
12use crate::init_template::schema_to_yaml_template_with_choices;
13use crate::registry;
14
15const DEFAULT_SOURCE: &str = "rest";
16const DEFAULT_SINK: &str = "jsonl";
17const DEFAULT_NAME: &str = "my-pipeline";
18const CONFIG_INDENT: usize = 8;
19
20/// Execute the `init` subcommand.
21pub async fn run(args: InitArgs) -> CliResult<()> {
22    if args.output.exists() && !args.force {
23        return Err(CliError::ScaffoldExists {
24            path: args.output.clone(),
25        });
26    }
27
28    if args.discover {
29        return run_singer_discover(&args).await;
30    }
31
32    let (source_kind, sink_kind) = resolve_kinds(&args)?;
33    let name = args.name.as_deref().unwrap_or(DEFAULT_NAME);
34    let template = &args.template;
35
36    let source_schema = registry::source_schema(&source_kind)?;
37    let sink_schema = registry::sink_schema(&sink_kind)?;
38    let (source_choices, sink_choices) = if args.interactive {
39        interactive_variant_choices(&source_schema, &sink_schema)?
40    } else {
41        (HashMap::new(), HashMap::new())
42    };
43
44    let body = render_pipeline(
45        name,
46        template,
47        &source_kind,
48        &source_schema,
49        &source_choices,
50        &sink_kind,
51        &sink_schema,
52        &sink_choices,
53    );
54    std::fs::write(&args.output, body)?;
55    println!("wrote {}", args.output.display());
56    Ok(())
57}
58
59/// `faucet init --source singer --discover --executable <tap>`: run the tap's
60/// discovery, write the catalog next to the output, and scaffold a config that
61/// references it and lists the discovered streams.
62#[cfg(feature = "source-singer")]
63async fn run_singer_discover(args: &InitArgs) -> CliResult<()> {
64    let source_kind = args.source.as_deref().unwrap_or("singer");
65    if source_kind != "singer" {
66        return Err(CliError::Config(format!(
67            "`--discover` is only supported for `--source singer` (got `{source_kind}`)"
68        )));
69    }
70    let executable = args.executable.as_deref().ok_or_else(|| {
71        CliError::Config("`--discover` requires `--executable <tap>`".to_string())
72    })?;
73
74    let cfg = faucet_source_singer::SingerSourceConfig::new(executable, "");
75    let raw_catalog = faucet_source_singer::discover(&cfg).await?; // FaucetError -> CliError
76    let streams = faucet_source_singer::catalog_stream_ids(&raw_catalog);
77
78    // When a target stream is given, mark it (and any inferable parent streams)
79    // `selected` — most DB / SDK taps sync nothing from an unselected catalog.
80    let target = args.stream.as_deref().unwrap_or("");
81    let (catalog, selected, warnings) = if target.is_empty() {
82        (raw_catalog, Vec::new(), Vec::new())
83    } else {
84        let sel = faucet_source_singer::select_streams(&raw_catalog, target);
85        (sel.catalog, sel.selected, sel.warnings)
86    };
87
88    // Write the catalog next to the output file.
89    let catalog_path = match args.output.parent() {
90        Some(dir) if !dir.as_os_str().is_empty() => dir.join("catalog.json"),
91        _ => std::path::PathBuf::from("catalog.json"),
92    };
93    let catalog_json = serde_json::to_string_pretty(&catalog)
94        .map_err(|e| CliError::Config(format!("failed to serialize catalog: {e}")))?;
95    std::fs::write(&catalog_path, catalog_json)?;
96
97    // Inline the catalog as compact JSON (YAML is a JSON superset, so a
98    // single-line flow mapping parses correctly — unlike `${file:…}`, which
99    // would insert the file's contents as a *string*, not an object).
100    let catalog_inline = serde_json::to_string(&catalog)
101        .map_err(|e| CliError::Config(format!("failed to serialize catalog: {e}")))?;
102    let name = args.name.as_deref().unwrap_or(DEFAULT_NAME);
103    let body = render_singer_config(name, executable, &catalog_inline, &streams, target);
104    std::fs::write(&args.output, body)?;
105
106    println!(
107        "discovered {} stream(s): {}",
108        streams.len(),
109        if streams.is_empty() {
110            "(none)".to_string()
111        } else {
112            streams.join(", ")
113        }
114    );
115    if !selected.is_empty() {
116        println!(
117            "selected {} stream(s): {}",
118            selected.len(),
119            selected.join(", ")
120        );
121    }
122    for w in &warnings {
123        eprintln!("warning: {w}");
124    }
125    println!(
126        "wrote {} and {}",
127        catalog_path.display(),
128        args.output.display()
129    );
130    Ok(())
131}
132
133#[cfg(not(feature = "source-singer"))]
134async fn run_singer_discover(_args: &InitArgs) -> CliResult<()> {
135    Err(CliError::Config(
136        "`--discover` requires the `source-singer` build feature".to_string(),
137    ))
138}
139
140/// Render a Singer scaffold that inlines the discovered catalog and lists the
141/// streams. `stream:` is left empty on purpose (the user must pick one; leaving
142/// it empty is flagged by `faucet doctor`). The same catalog is also written to
143/// `catalog.json` for reference / use as the tap's `--catalog`.
144#[cfg(feature = "source-singer")]
145fn render_singer_config(
146    name: &str,
147    executable: &str,
148    catalog_inline: &str,
149    streams: &[String],
150    stream: &str,
151) -> String {
152    let discovered = if streams.is_empty() {
153        "(none discovered)".to_string()
154    } else {
155        streams.join(", ")
156    };
157    format!(
158        "version: 1\n\
159         name: {name}\n\
160         pipeline:\n\
161         \x20 source:\n\
162         \x20   type: singer\n\
163         \x20   config:\n\
164         \x20     executable: {executable}\n\
165         \x20     # Discovered catalog, inlined as compact JSON (also saved to catalog.json).\n\
166         \x20     # With --stream, the target stream (and any parents) are marked selected.\n\
167         \x20     catalog: {catalog_inline}\n\
168         \x20     # stream is REQUIRED. Discovered streams: {discovered}\n\
169         \x20     # Set it to one of the above; leaving it empty fails `faucet doctor`.\n\
170         \x20     stream: \"{stream}\"\n\
171         \x20     # The tap's own config (secret-resolved by faucet). Fill in as the tap needs:\n\
172         \x20     tap_config: {{}}\n\
173         \x20 sink:\n\
174         \x20   type: jsonl\n\
175         \x20   config:\n\
176         \x20     path: ./out/records.jsonl\n"
177    )
178}
179
180fn resolve_kinds(args: &InitArgs) -> CliResult<(String, String)> {
181    let source = args.source.clone();
182    let sink = args.sink.clone();
183
184    let (source, sink) = if args.interactive {
185        interactive_prompt(source, sink)?
186    } else {
187        (
188            source.unwrap_or_else(|| DEFAULT_SOURCE.to_string()),
189            sink.unwrap_or_else(|| DEFAULT_SINK.to_string()),
190        )
191    };
192
193    if !registry::source_exists(&source) {
194        return Err(unknown_kind_err("source", &source));
195    }
196    if !registry::sink_exists(&sink) {
197        return Err(unknown_kind_err("sink", &sink));
198    }
199    Ok((source, sink))
200}
201
202#[cfg(feature = "cli-interactive")]
203fn interactive_prompt(source: Option<String>, sink: Option<String>) -> CliResult<(String, String)> {
204    use std::io::IsTerminal;
205    if !std::io::stdin().is_terminal() {
206        return fallback_kinds(source, sink, "stdin is not a TTY");
207    }
208    let sources = registry::source_kinds();
209    let sinks = registry::sink_kinds();
210    let s = if let Some(s) = source {
211        s
212    } else {
213        prompt_select("source", &sources)?
214    };
215    let k = if let Some(k) = sink {
216        k
217    } else {
218        prompt_select("sink", &sinks)?
219    };
220    Ok((s, k))
221}
222
223#[cfg(not(feature = "cli-interactive"))]
224fn interactive_prompt(source: Option<String>, sink: Option<String>) -> CliResult<(String, String)> {
225    fallback_kinds(
226        source,
227        sink,
228        "the `cli-interactive` build feature is not enabled",
229    )
230}
231
232fn fallback_kinds(
233    source: Option<String>,
234    sink: Option<String>,
235    reason: &str,
236) -> CliResult<(String, String)> {
237    match (source, sink) {
238        (Some(s), Some(k)) => Ok((s, k)),
239        (s, k) => {
240            tracing::warn!(
241                "ignoring --interactive: {reason}; falling back to --source/--sink (or defaults)"
242            );
243            Ok((
244                s.unwrap_or_else(|| DEFAULT_SOURCE.to_string()),
245                k.unwrap_or_else(|| DEFAULT_SINK.to_string()),
246            ))
247        }
248    }
249}
250
251#[cfg(feature = "cli-interactive")]
252fn prompt_select(kind: &str, options: &[&'static str]) -> CliResult<String> {
253    let choice = inquire::Select::new(&format!("Pick a {kind} connector"), options.to_vec())
254        .prompt()
255        .map_err(|e| CliError::Io(std::io::Error::other(e.to_string())))?;
256    Ok(choice.to_string())
257}
258
259#[cfg(feature = "cli-interactive")]
260fn interactive_variant_choices(
261    source_schema: &serde_json::Value,
262    sink_schema: &serde_json::Value,
263) -> CliResult<(HashMap<String, String>, HashMap<String, String>)> {
264    use std::io::IsTerminal;
265    if !std::io::stdin().is_terminal() {
266        return Ok((HashMap::new(), HashMap::new()));
267    }
268    let source = prompt_variants_for("source", source_schema)?;
269    let sink = prompt_variants_for("sink", sink_schema)?;
270    Ok((source, sink))
271}
272
273#[cfg(not(feature = "cli-interactive"))]
274fn interactive_variant_choices(
275    _source_schema: &serde_json::Value,
276    _sink_schema: &serde_json::Value,
277) -> CliResult<(HashMap<String, String>, HashMap<String, String>)> {
278    tracing::warn!("ignoring --interactive: the `cli-interactive` build feature is not enabled");
279    Ok((HashMap::new(), HashMap::new()))
280}
281
282#[cfg(feature = "cli-interactive")]
283fn prompt_variants_for(
284    side: &str,
285    schema: &serde_json::Value,
286) -> CliResult<HashMap<String, String>> {
287    let fields = discover_tagged_enum_fields(schema);
288    let mut choices = HashMap::new();
289    for field in fields {
290        let label = format!("Pick a variant for {side}.{}", field.path);
291        let opts: Vec<String> = field.variants.clone();
292        let chosen = inquire::Select::new(&label, opts)
293            .prompt()
294            .map_err(|e| CliError::Io(std::io::Error::other(e.to_string())))?;
295        choices.insert(field.path, chosen);
296    }
297    Ok(choices)
298}
299
300fn unknown_kind_err(kind: &'static str, name: &str) -> CliError {
301    let available = if kind == "source" {
302        registry::source_kinds()
303    } else {
304        registry::sink_kinds()
305    };
306    CliError::UnknownConnector {
307        kind,
308        name: name.to_owned(),
309        available: if available.is_empty() {
310            "(none — rebuild faucet-cli with the relevant feature enabled)".to_owned()
311        } else {
312            available.join(", ")
313        },
314    }
315}
316
317#[allow(clippy::too_many_arguments)]
318fn render_pipeline(
319    name: &str,
320    template: &str,
321    source_kind: &str,
322    source_schema: &serde_json::Value,
323    source_choices: &HashMap<String, String>,
324    sink_kind: &str,
325    sink_schema: &serde_json::Value,
326    sink_choices: &HashMap<String, String>,
327) -> String {
328    let source_yaml =
329        schema_to_yaml_template_with_choices(source_schema, CONFIG_INDENT, source_choices);
330    let sink_yaml = schema_to_yaml_template_with_choices(sink_schema, CONFIG_INDENT, sink_choices);
331
332    let mut body = String::new();
333    body.push_str("version: 1\n");
334    body.push_str(&format!("name: {name}\n\n"));
335    body.push_str("# Optional shared constants. Reference these anywhere via ${vars.KEY}.\n");
336    body.push_str("# vars:\n");
337    body.push_str("#   api_base: https://api.example.com\n\n");
338    body.push_str("# Named source/sink templates. Matrix rows pick from these via ref:.\n");
339    body.push_str("# A matrix row that omits ref: inherits the `default` template,\n");
340    body.push_str("# which keeps backwards-compat with the legacy singular shape.\n");
341    body.push_str("pipeline:\n");
342    body.push_str("  sources:\n");
343    body.push_str(&format!("    {template}:\n"));
344    body.push_str(&format!("      type: {source_kind}\n"));
345    body.push_str("      config:\n");
346    body.push_str(&source_yaml);
347    body.push('\n');
348    body.push_str("  # transforms:\n");
349    body.push_str("  #   - { type: keys_case, config: { mode: snake } }\n\n");
350    body.push_str("  sinks:\n");
351    body.push_str(&format!("    {template}:\n"));
352    body.push_str(&format!("      type: {sink_kind}\n"));
353    body.push_str("      config:\n");
354    body.push_str(&sink_yaml);
355    body.push('\n');
356    body.push_str("  # Optional state store (required by CDC sources and resumable runs).\n");
357    body.push_str("  # state:\n");
358    body.push_str("  #   type: file\n");
359    body.push_str("  #   config: { path: ./.faucet-state }\n\n");
360    body.push_str("  # Optional Dead Letter Queue.\n");
361    body.push_str("  # dlq:\n");
362    body.push_str("  #   sink:\n");
363    body.push_str("  #     type: jsonl\n");
364    body.push_str("  #     config: { path: ./dlq.jsonl }\n");
365    body.push_str("  #   on_batch_error: propagate   # or dlq_all\n\n");
366    body.push_str("# Optional matrix block. Each row picks a template via ref:\n");
367    body.push_str("# (omit ref: to inherit the `default` template above) and may\n");
368    body.push_str("# override `type:` / `config:` per row.\n");
369    body.push_str("# matrix:\n");
370    body.push_str("#   - id: users\n");
371    body.push_str(&format!(
372        "#     source: {{ ref: {template}, config: {{ path: /v1/users }} }}\n"
373    ));
374    body
375}