1use 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
20pub 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#[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?; let streams = faucet_source_singer::catalog_stream_ids(&raw_catalog);
77
78 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 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 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#[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}