1use crate::cli::DiscoverArgs;
13use crate::config::{ConnectorSpec, PipelineConfig, PipelineSpec};
14use crate::error::{CliError, CliResult};
15use faucet_core::DatasetDescriptor;
16use serde_json::{Value, json};
17
18pub async fn run(args: DiscoverArgs) -> CliResult<()> {
20 let cwd = std::env::current_dir()?;
21 let env_path =
22 crate::env_loader::resolve_env_file(args.env_file.as_deref(), args.no_env_file, &cwd)?;
23 crate::env_loader::load_env_file_if_present(env_path.as_deref())?;
24 let path = match args.config {
25 Some(p) => p,
26 None => crate::env_loader::discover_config_path(&cwd).ok_or(CliError::NoConfigOrFromEnv)?,
27 };
28
29 let cfg = PipelineConfig::from_path_async(&path, args.profile.as_deref()).await?;
31 let raw_composed = crate::compose::compose(&path, args.profile.as_deref())?;
34
35 let template = args.source.as_deref().unwrap_or("default");
36 let spec = select_source_template(&cfg.pipeline, template)?;
37
38 let auth = crate::auth_catalog::build_auth_catalog(cfg.auth.as_ref())?;
39 let source =
40 crate::registry::build_source(&spec.kind, spec.config.clone(), &auth, None).await?;
41 if !source.supports_discover() {
42 return Err(CliError::Config(format!(
43 "source '{}' does not support dataset discovery — discovery is available for \
44 catalog-backed sources (postgres, mysql, mssql, sqlite, mongodb, elasticsearch, \
45 bigquery, snowflake, spanner, s3, gcs)",
46 spec.kind
47 )));
48 }
49
50 let datasets = source.discover().await.map_err(|e| {
51 CliError::Config(format!(
52 "discovery against source '{}' failed: {e}",
53 spec.kind
54 ))
55 })?;
56 let total = datasets.len();
57 let datasets = filter_datasets(datasets, &args.include, &args.exclude);
58 if datasets.is_empty() {
59 return Err(CliError::Config(format!(
60 "discovery found no datasets{} (source '{}' reported {total} before filtering)",
61 if args.include.is_empty() && args.exclude.is_empty() {
62 ""
63 } else {
64 " matching the --include/--exclude filters"
65 },
66 spec.kind
67 )));
68 }
69
70 if args.json {
71 let out = json!({ "source": spec.kind, "datasets": datasets });
72 println!(
73 "{}",
74 serde_json::to_string_pretty(&out)
75 .map_err(|e| CliError::Internal(format!("json render: {e}")))?
76 );
77 return Ok(());
78 }
79
80 let doc = render_discovered_config(&raw_composed, template, &datasets)?;
81
82 if let Err(e) =
85 PipelineConfig::from_text(&doc, &path).and_then(|c| crate::expand::expand(&c).map(|_| ()))
86 {
87 tracing::warn!(error = %e, "generated config failed offline re-validation — review it before running");
88 }
89
90 match args.output {
91 Some(out) => {
92 if out.exists() && !args.force {
93 return Err(CliError::Config(format!(
94 "output file {} already exists — pass --force to overwrite",
95 out.display()
96 )));
97 }
98 std::fs::write(&out, &doc)?;
99 eprintln!(
100 "wrote {} ({} dataset{} from '{}' source)",
101 out.display(),
102 datasets.len(),
103 if datasets.len() == 1 { "" } else { "s" },
104 spec.kind
105 );
106 }
107 None => print!("{doc}"),
108 }
109 Ok(())
110}
111
112fn select_source_template<'a>(
116 pipeline: &'a PipelineSpec,
117 name: &str,
118) -> CliResult<&'a ConnectorSpec> {
119 if let Some(spec) = pipeline.sources.get(name) {
120 return Ok(spec);
121 }
122 if name == "default"
123 && let Some(spec) = pipeline.source.as_ref()
124 {
125 return Ok(spec);
126 }
127 let mut available: Vec<&str> = pipeline.sources.keys().map(String::as_str).collect();
128 if pipeline.source.is_some() {
129 available.push("default");
130 }
131 available.sort_unstable();
132 Err(CliError::Config(format!(
133 "no source template named '{name}' — available: {}",
134 if available.is_empty() {
135 "none (the config has no `pipeline.source` or `pipeline.sources`)".to_string()
136 } else {
137 available.join(", ")
138 }
139 )))
140}
141
142fn glob_match(pattern: &str, name: &str) -> bool {
145 let segments: Vec<&str> = pattern.split('*').collect();
149 if segments.len() == 1 {
150 return pattern == name;
151 }
152 let mut rest = name;
153 for (i, seg) in segments.iter().enumerate() {
154 if seg.is_empty() {
155 continue;
156 }
157 if i == 0 {
158 match rest.strip_prefix(seg) {
159 Some(r) => rest = r,
160 None => return false,
161 }
162 } else if i == segments.len() - 1 {
163 return rest.ends_with(seg) && rest.len() >= seg.len();
164 } else {
165 match rest.find(seg) {
166 Some(pos) => rest = &rest[pos + seg.len()..],
167 None => return false,
168 }
169 }
170 }
171 true
172}
173
174fn filter_datasets(
178 datasets: Vec<DatasetDescriptor>,
179 include: &[String],
180 exclude: &[String],
181) -> Vec<DatasetDescriptor> {
182 datasets
183 .into_iter()
184 .filter(|d| {
185 let included = include.is_empty() || include.iter().any(|p| glob_match(p, &d.name));
186 let excluded = exclude.iter().any(|p| glob_match(p, &d.name));
187 included && !excluded
188 })
189 .collect()
190}
191
192fn sanitize_row_id(name: &str) -> String {
195 let mut out = String::with_capacity(name.len());
196 let mut last_was_sep = false;
197 for c in name.chars() {
198 if c.is_ascii_alphanumeric() || c == '_' || c == '-' {
199 out.push(c);
200 last_was_sep = false;
201 } else if !last_was_sep && !out.is_empty() {
202 out.push('_');
203 last_was_sep = true;
204 }
205 }
206 let trimmed = out.trim_matches('_').to_string();
207 if trimmed.is_empty() {
208 "dataset".to_string()
209 } else {
210 trimmed
211 }
212}
213
214fn unique_row_ids(datasets: &[DatasetDescriptor]) -> Vec<String> {
216 let mut seen = std::collections::HashMap::<String, usize>::new();
217 datasets
218 .iter()
219 .map(|d| {
220 let base = sanitize_row_id(&d.name);
221 let n = seen.entry(base.clone()).or_insert(0);
222 *n += 1;
223 if *n == 1 { base } else { format!("{base}-{n}") }
224 })
225 .collect()
226}
227
228fn schema_summary(schema: &Value, max_cols: usize) -> Option<String> {
232 let props = schema.get("properties")?.as_object()?;
233 if props.is_empty() {
234 return None;
235 }
236 let mut parts: Vec<String> = Vec::new();
237 for (name, fragment) in props.iter().take(max_cols) {
238 let (ty, nullable) = match fragment.get("type") {
239 Some(Value::String(t)) => (t.clone(), false),
240 Some(Value::Array(a)) => {
241 let base = a
242 .iter()
243 .filter_map(|v| v.as_str())
244 .find(|t| *t != "null")
245 .unwrap_or("any");
246 (base.to_string(), a.iter().any(|v| v == "null"))
247 }
248 _ => ("any".to_string(), false),
249 };
250 parts.push(format!("{name} {ty}{}", if nullable { "?" } else { "" }));
251 }
252 if props.len() > max_cols {
253 parts.push("…".to_string());
254 }
255 Some(parts.join(", "))
256}
257
258fn yaml_seq_item(value: &Value) -> CliResult<String> {
260 let body = serde_yaml::to_string(value)
261 .map_err(|e| CliError::Internal(format!("yaml render: {e}")))?;
262 let mut out = String::new();
263 for (i, line) in body.trim_end().lines().enumerate() {
264 if i == 0 {
265 out.push_str(" - ");
266 } else {
267 out.push_str(" ");
268 }
269 out.push_str(line);
270 out.push('\n');
271 }
272 Ok(out)
273}
274
275fn render_discovered_config(
279 raw_composed: &str,
280 template: &str,
281 datasets: &[DatasetDescriptor],
282) -> CliResult<String> {
283 let mut root: serde_yaml::Value = serde_yaml::from_str(raw_composed)
284 .map_err(|e| CliError::Config(format!("could not re-parse composed config: {e}")))?;
285 let map = root
286 .as_mapping_mut()
287 .ok_or_else(|| CliError::Config("composed config is not a mapping".into()))?;
288 let replaced_matrix = map
289 .remove(serde_yaml::Value::String("matrix".into()))
290 .is_some();
291
292 let head = serde_yaml::to_string(&root)
293 .map_err(|e| CliError::Internal(format!("yaml render: {e}")))?;
294
295 let row_ids = unique_row_ids(datasets);
296 let mut doc = String::new();
297 doc.push_str(&head);
298 if !head.ends_with('\n') {
299 doc.push('\n');
300 }
301 doc.push('\n');
302 doc.push_str(&format!(
303 "# Generated by `faucet discover` — one row per discovered dataset ({}).\n",
304 datasets.len()
305 ));
306 if replaced_matrix {
307 doc.push_str("# NOTE: the input config's `matrix:` block was replaced.\n");
308 }
309 doc.push_str("matrix:\n");
310 for (d, id) in datasets.iter().zip(&row_ids) {
311 let est = d
312 .estimated_rows
313 .map(|n| format!(", ~{n} rows"))
314 .unwrap_or_default();
315 doc.push_str(&format!(" # {} ({}{})\n", d.name, d.kind, est));
316 if let Some(summary) = d.schema.as_ref().and_then(|s| schema_summary(s, 12)) {
317 doc.push_str(&format!(" # columns: {summary}\n"));
318 }
319 let mut row = json!({ "id": id, "source": { "config": d.config_patch } });
320 if template != "default" {
321 row["source"]["ref"] = json!(template);
322 }
323 doc.push_str(&yaml_seq_item(&row)?);
324 }
325 Ok(doc)
326}
327
328#[cfg(test)]
329mod tests {
330 use super::*;
331
332 fn ds(name: &str, kind: &str, patch: Value) -> DatasetDescriptor {
333 DatasetDescriptor::new(name, kind, patch)
334 }
335
336 #[test]
339 fn glob_exact_and_wildcards() {
340 assert!(glob_match("orders", "orders"));
341 assert!(!glob_match("orders", "orders2"));
342 assert!(glob_match("public.*", "public.orders"));
343 assert!(!glob_match("public.*", "sales.orders"));
344 assert!(glob_match("*.orders", "public.orders"));
345 assert!(glob_match("*order*", "public.orders"));
346 assert!(glob_match("*", "anything"));
347 assert!(glob_match("a*b*c", "aXXbYYc"));
348 assert!(!glob_match("a*b*c", "aXXcYYb"));
349 assert!(!glob_match("ab*ab", "ab"));
351 }
352
353 #[test]
354 fn filter_include_exclude_composition() {
355 let all = vec![
356 ds("public.orders", "table", json!({})),
357 ds("public.users", "table", json!({})),
358 ds("audit.log", "table", json!({})),
359 ];
360 let got = filter_datasets(all.clone(), &["public.*".into()], &["*.users".into()]);
361 let names: Vec<&str> = got.iter().map(|d| d.name.as_str()).collect();
362 assert_eq!(names, vec!["public.orders"]);
363
364 let got = filter_datasets(all, &[], &["audit.*".into()]);
366 assert_eq!(got.len(), 2);
367 }
368
369 #[test]
372 fn row_ids_sanitized_and_unique() {
373 assert_eq!(sanitize_row_id("public.orders"), "public_orders");
374 assert_eq!(sanitize_row_id("raw/orders/"), "raw_orders");
375 assert_eq!(sanitize_row_id("...."), "dataset");
376 let ids = unique_row_ids(&[
377 ds("a.b", "table", json!({})),
378 ds("a/b", "table", json!({})),
379 ds("a.b", "table", json!({})),
380 ]);
381 assert_eq!(ids, vec!["a_b", "a_b-2", "a_b-3"]);
382 }
383
384 #[test]
387 fn schema_summary_marks_nullable_and_caps() {
388 let schema = json!({
389 "type": "object",
390 "properties": {
391 "id": {"type": "integer"},
392 "note": {"type": ["string", "null"]},
393 }
394 });
395 let s = schema_summary(&schema, 12).unwrap();
396 assert!(s.contains("id integer"), "{s}");
397 assert!(s.contains("note string?"), "{s}");
398
399 let mut props = serde_json::Map::new();
400 for i in 0..15 {
401 props.insert(format!("c{i:02}"), json!({"type": "integer"}));
402 }
403 let big = json!({"type": "object", "properties": props});
404 let s = schema_summary(&big, 12).unwrap();
405 assert!(s.ends_with("…"), "capped: {s}");
406 }
407
408 #[test]
409 fn schema_summary_empty_is_none() {
410 assert!(schema_summary(&json!({"type": "object", "properties": {}}), 12).is_none());
411 assert!(schema_summary(&json!({"type": "string"}), 12).is_none());
412 }
413
414 const RAW: &str = r#"
417version: 1
418name: conn
419pipeline:
420 source:
421 type: postgres
422 config:
423 connection_url: ${env:DATABASE_URL}
424 query: SELECT 1
425 sink:
426 type: jsonl
427 config:
428 path: ./out.jsonl
429"#;
430
431 #[test]
432 fn render_emits_matrix_rows_with_comments() {
433 let datasets = vec![
434 ds(
435 "public.orders",
436 "table",
437 json!({"query": "SELECT * FROM \"public\".\"orders\""}),
438 )
439 .with_schema(json!({
440 "type": "object",
441 "properties": {"id": {"type": "integer"}}
442 }))
443 .with_estimated_rows(120),
444 ds(
445 "sales.leads",
446 "table",
447 json!({"query": "SELECT * FROM \"sales\".\"leads\""}),
448 ),
449 ];
450 let doc = render_discovered_config(RAW, "default", &datasets).unwrap();
451
452 assert!(doc.contains("${env:DATABASE_URL}"), "{doc}");
454 assert!(doc.contains("# public.orders (table, ~120 rows)"), "{doc}");
455 assert!(doc.contains("# columns: id integer"), "{doc}");
456 assert!(doc.contains("- id: public_orders"), "{doc}");
457 assert!(doc.contains("SELECT * FROM \"public\".\"orders\""), "{doc}");
458
459 let cfg = crate::config::parse_with_extension(&doc, "yaml").unwrap();
461 let nodes = crate::expand::expand(&cfg).unwrap();
462 assert_eq!(nodes.len(), 2);
463 assert_eq!(nodes[0].id, "public_orders");
464 assert_eq!(
465 nodes[0].source.config["query"], "SELECT * FROM \"public\".\"orders\"",
466 "row patch deep-merged over the connection config"
467 );
468 assert_eq!(
469 nodes[0].source.config["connection_url"], "${env:DATABASE_URL}",
470 "connection settings inherited"
471 );
472 }
473
474 #[test]
475 fn render_replaces_existing_matrix_and_notes_it() {
476 let raw = format!("{RAW}matrix:\n - id: old\n");
477 let datasets = vec![ds("t", "table", json!({"query": "SELECT * FROM t"}))];
478 let doc = render_discovered_config(&raw, "default", &datasets).unwrap();
479 assert!(doc.contains("was replaced"), "{doc}");
480 assert!(!doc.contains("id: old"), "{doc}");
481 }
482
483 #[test]
484 fn render_named_template_sets_source_ref() {
485 let raw = r#"
486version: 1
487pipeline:
488 sources:
489 warehouse:
490 type: postgres
491 config: { connection_url: "postgres://x", query: "SELECT 1" }
492 sinks:
493 default:
494 type: jsonl
495 config: { path: ./out.jsonl }
496"#;
497 let datasets = vec![ds("t", "table", json!({"query": "SELECT * FROM t"}))];
498 let doc = render_discovered_config(raw, "warehouse", &datasets).unwrap();
499 assert!(doc.contains("ref: warehouse"), "{doc}");
500 let cfg = crate::config::parse_with_extension(&doc, "yaml").unwrap();
501 let nodes = crate::expand::expand(&cfg).unwrap();
502 assert_eq!(nodes.len(), 1);
503 assert_eq!(nodes[0].source.kind, "postgres");
504 }
505
506 #[test]
509 fn select_template_falls_back_to_singular_default() {
510 let cfg = crate::config::parse_with_extension(RAW, "yaml").unwrap();
511 let spec = select_source_template(&cfg.pipeline, "default").unwrap();
512 assert_eq!(spec.kind, "postgres");
513 let err = select_source_template(&cfg.pipeline, "nope").unwrap_err();
514 assert!(err.to_string().contains("available: default"), "{err}");
515 }
516}
517
518#[cfg(all(test, feature = "source-sqlite", feature = "sink-jsonl"))]
519mod run_tests {
520 use super::run;
523 use crate::cli::DiscoverArgs;
524
525 fn args(config: std::path::PathBuf) -> DiscoverArgs {
526 DiscoverArgs {
527 config: Some(config),
528 source: None,
529 include: vec![],
530 exclude: vec![],
531 output: None,
532 force: false,
533 json: false,
534 env_file: None,
535 no_env_file: true,
536 profile: None,
537 }
538 }
539
540 fn write_config(dir: &std::path::Path, db: &str) -> std::path::PathBuf {
541 let cfg = dir.join("conn.yaml");
542 std::fs::write(
543 &cfg,
544 format!(
545 "version: 1\nname: conn\npipeline:\n source:\n type: sqlite\n config:\n database_url: \"sqlite://{db}\"\n query: SELECT 1\n sink:\n type: jsonl\n config: {{ path: ./out.jsonl }}\n"
546 ),
547 )
548 .unwrap();
549 cfg
550 }
551
552 async fn seed_db(dir: &std::path::Path) -> String {
553 let db = dir.join("cat.db").display().to_string();
554 let pool = sqlx::SqlitePool::connect(&format!("sqlite://{db}?mode=rwc"))
555 .await
556 .expect("create db");
557 sqlx::query("CREATE TABLE orders (id INTEGER PRIMARY KEY, note TEXT)")
558 .execute(&pool)
559 .await
560 .unwrap();
561 sqlx::query("CREATE TABLE users (id INTEGER NOT NULL, active BOOLEAN)")
562 .execute(&pool)
563 .await
564 .unwrap();
565 pool.close().await;
566 db
567 }
568
569 #[tokio::test]
570 async fn run_errors_on_unsupported_source_kind() {
571 let dir = tempfile::tempdir().unwrap();
572 let cfg = dir.path().join("conn.yaml");
573 std::fs::write(
574 &cfg,
575 "version: 1\npipeline:\n source: { type: csv, config: { path: ./in.csv } }\n sink: { type: jsonl, config: { path: ./o.jsonl } }\n",
576 )
577 .unwrap();
578 let err = run(args(cfg)).await.unwrap_err();
579 assert!(
580 err.to_string()
581 .contains("does not support dataset discovery"),
582 "{err}"
583 );
584 }
585
586 #[tokio::test]
587 async fn run_writes_generated_config_that_expands() {
588 let dir = tempfile::tempdir().unwrap();
589 let db = seed_db(dir.path()).await;
590 let cfg = write_config(dir.path(), &db);
591 let out = dir.path().join("generated.yaml");
592
593 let mut a = args(cfg.clone());
594 a.output = Some(out.clone());
595 run(a).await.expect("discover runs");
596
597 let text = std::fs::read_to_string(&out).unwrap();
598 let generated = crate::config::parse_with_extension(&text, "yaml").unwrap();
599 let nodes = crate::expand::expand(&generated).unwrap();
600 assert_eq!(nodes.len(), 2, "one row per table: {text}");
601 assert!(text.contains("orders"), "{text}");
602 assert!(text.contains("users"), "{text}");
603
604 let mut a = args(cfg.clone());
606 a.output = Some(out.clone());
607 let err = run(a).await.unwrap_err();
608 assert!(err.to_string().contains("--force"), "{err}");
609
610 let mut a = args(cfg.clone());
612 a.output = Some(dir.path().join("orders-only.yaml"));
613 a.include = vec!["orders".into()];
614 run(a).await.expect("filtered discover runs");
615 let text = std::fs::read_to_string(dir.path().join("orders-only.yaml")).unwrap();
616 assert!(
617 text.contains("orders") && !text.contains("- id: users"),
618 "{text}"
619 );
620
621 let mut a = args(cfg);
622 a.json = true;
623 run(a).await.expect("json mode runs");
624 }
625
626 #[tokio::test]
627 async fn run_errors_when_filters_match_nothing() {
628 let dir = tempfile::tempdir().unwrap();
629 let db = seed_db(dir.path()).await;
630 let cfg = write_config(dir.path(), &db);
631 let mut a = args(cfg);
632 a.include = vec!["nothing_matches_*".into()];
633 let err = run(a).await.unwrap_err();
634 assert!(err.to_string().contains("no datasets"), "{err}");
635 }
636}