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)",
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    // Guard: the emitted document must itself load + expand. Configs holding
83    // secrets-manager directives can't be re-verified offline — warn, don't fail.
84    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
112/// Resolve the source template to introspect: the named entry in
113/// `pipeline.sources`, or the legacy singular `pipeline.source` (which
114/// registers as `default`).
115fn 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
142/// Simple `*`-wildcard glob match (case-sensitive). `*` matches any run of
143/// characters, including none; every other character matches literally.
144fn glob_match(pattern: &str, name: &str) -> bool {
145    // Dynamic-programming over the pattern segments split by '*': the name
146    // must start with the first segment, end with the last, and contain the
147    // middle segments in order.
148    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
174/// Apply `--include` / `--exclude` glob filters to the discovered datasets.
175/// No `--include` patterns = include everything; any matching `--exclude`
176/// pattern removes a dataset.
177fn 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
192/// Sanitize a dataset name into a matrix row id: `[A-Za-z0-9_-]` only, other
193/// runs collapsed to a single `_`. Never empty.
194fn 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
214/// Assign unique row ids to the datasets (a `-2`, `-3`, … suffix on collision).
215fn 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
228/// One-line column summary for a dataset's schema comment, e.g.
229/// `id integer, note string?, total number` (`?` = nullable), capped at
230/// `max_cols` columns with a `…` marker.
231fn 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
258/// Render a serde value as a YAML sequence item indented under `matrix:`.
259fn 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
275/// Build the generated config document: the raw composed input with the
276/// `matrix:` block replaced by one row per dataset (schema summaries as
277/// comments). Pure — unit-testable without a live source.
278fn 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    // ── glob matching ─────────────────────────────────────────────────────────
337
338    #[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        // The trailing segment must not re-consume the head segment.
350        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        // No include patterns = everything (minus excludes).
365        let got = filter_datasets(all, &[], &["audit.*".into()]);
366        assert_eq!(got.len(), 2);
367    }
368
369    // ── row ids ───────────────────────────────────────────────────────────────
370
371    #[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    // ── schema summary ────────────────────────────────────────────────────────
385
386    #[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    // ── rendering ─────────────────────────────────────────────────────────────
415
416    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        // Raw `${env:…}` reference echoed, never a resolved value.
453        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        // The generated document must parse and expand to one node per dataset.
460        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    // ── template selection ────────────────────────────────────────────────────
507
508    #[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    //! Command-level tests driving `run()` end-to-end against a real (file)
521    //! SQLite catalog — no Docker, no network.
522    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        // Re-running without --force refuses to overwrite.
605        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        // --include narrows to one row; --json emits the descriptor list.
611        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}