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