Skip to main content

faucet_cli/commands/
discover.rs

1//! `faucet discover` — connect to a config's source, enumerate the datasets
2//! living behind it (tables / collections / indices / prefixes), and emit a
3//! ready-to-run config with one matrix row per dataset (#211).
4//!
5//! The generated document is the **raw composed** input config (so `${env:…}`
6//! and secrets-manager directives are echoed verbatim, never their resolved
7//! values) with the `matrix:` block replaced by one row per discovered
8//! dataset. Each row deep-merges the dataset's
9//! [`config_patch`](faucet_core::DatasetDescriptor::config_patch) over the
10//! connection config.
11
12use crate::cli::DiscoverArgs;
13use crate::config::{ConnectorSpec, PipelineConfig, PipelineSpec};
14use crate::error::{CliError, CliResult};
15use faucet_core::DatasetDescriptor;
16use serde_json::{Value, json};
17
18/// Execute the `discover` subcommand.
19pub 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    // Interpolated + secrets-resolved config: used to CONNECT.
30    let cfg = PipelineConfig::from_path_async(&path, args.profile.as_deref()).await?;
31    // Raw composed text (extends/!include/profile folded, `${…}` untouched):
32    // used to ECHO the connection config without leaking resolved secrets.
33    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    // Guard: the emitted document must itself load + expand. Configs holding
84    // secrets-manager directives can't be re-verified offline — warn, don't fail.
85    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
113/// Resolve the source template to introspect: the named entry in
114/// `pipeline.sources`, or the legacy singular `pipeline.source` (which
115/// registers as `default`).
116fn 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
143/// Simple `*`-wildcard glob match (case-sensitive). `*` matches any run of
144/// characters, including none; every other character matches literally.
145fn glob_match(pattern: &str, name: &str) -> bool {
146    // Dynamic-programming over the pattern segments split by '*': the name
147    // must start with the first segment, end with the last, and contain the
148    // middle segments in order.
149    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
175/// Apply `--include` / `--exclude` glob filters to the discovered datasets.
176/// No `--include` patterns = include everything; any matching `--exclude`
177/// pattern removes a dataset.
178fn 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
193/// Sanitize a dataset name into a matrix row id: `[A-Za-z0-9_-]` only, other
194/// runs collapsed to a single `_`. Never empty.
195fn 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
215/// Assign unique row ids to the datasets (a `-2`, `-3`, … suffix on collision).
216fn 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
229/// One-line column summary for a dataset's schema comment, e.g.
230/// `id integer, note string?, total number` (`?` = nullable), capped at
231/// `max_cols` columns with a `…` marker.
232fn 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
259/// Render a serde value as a YAML sequence item indented under `matrix:`.
260fn 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
276/// Build the generated config document: the raw composed input with the
277/// `matrix:` block replaced by one row per dataset (schema summaries as
278/// comments). Pure — unit-testable without a live source.
279fn 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    // ── glob matching ─────────────────────────────────────────────────────────
338
339    #[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        // The trailing segment must not re-consume the head segment.
351        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        // No include patterns = everything (minus excludes).
366        let got = filter_datasets(all, &[], &["audit.*".into()]);
367        assert_eq!(got.len(), 2);
368    }
369
370    // ── row ids ───────────────────────────────────────────────────────────────
371
372    #[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    // ── schema summary ────────────────────────────────────────────────────────
386
387    #[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    // ── rendering ─────────────────────────────────────────────────────────────
416
417    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        // Raw `${env:…}` reference echoed, never a resolved value.
454        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        // The generated document must parse and expand to one node per dataset.
461        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    // ── template selection ────────────────────────────────────────────────────
508
509    #[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    //! Command-level tests driving `run()` end-to-end against a real (file)
522    //! SQLite catalog — no Docker, no network.
523    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        // Re-running without --force refuses to overwrite.
606        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        // --include narrows to one row; --json emits the descriptor list.
612        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}