Skip to main content

faucet_cli/commands/
validate.rs

1//! `faucet validate` — parse + expand a pipeline config without running.
2//!
3//! Surfaces every per-row error with the row id, so a config with multiple
4//! issues reports them together instead of failing at the first one.
5
6use crate::cli::ValidateArgs;
7use crate::config::{PipelineConfig, SourceStatus};
8use crate::error::{CliError, CliResult};
9use crate::expand::{NodeRole, expand};
10use crate::registry::{sink_schema, source_schema};
11use crate::select::RunSelection;
12use crate::state::available_state_kinds;
13use crate::transforms::available_transforms;
14use std::collections::HashSet;
15
16/// Execute the `validate` subcommand.
17pub async fn run(args: ValidateArgs) -> CliResult<()> {
18    let cwd = std::env::current_dir()?;
19    let env_path =
20        crate::env_loader::resolve_env_file(args.env_file.as_deref(), args.no_env_file, &cwd)?;
21    crate::env_loader::load_env_file_if_present(env_path.as_deref())?;
22
23    let path = match args.config {
24        Some(p) => p,
25        None => crate::env_loader::discover_config_path(&cwd).ok_or(CliError::NoConfigOrFromEnv)?,
26    };
27
28    if args.show_composed {
29        let composed = crate::compose::compose(&path, args.profile.as_deref())?;
30        // Normalize to exactly one trailing newline: the YAML serializer appends
31        // one but `serde_json::to_string_pretty` (JSON-format configs) does not,
32        // and the fast path echoes the file verbatim. A single `\n` keeps
33        // `faucet validate … --show-composed > out.{yaml,json}` well-formed.
34        println!("{}", composed.trim_end_matches('\n'));
35        return Ok(());
36    }
37
38    // Typed run params (#444). With no `--param`, required params bind to
39    // type-shaped placeholders so a parameterized config still validates in CI;
40    // supplying any `--param` opts into strict binding, which is how you check a
41    // concrete invocation.
42    let inputs = crate::config::RunInputs {
43        params: crate::params::collect_cli_params(&args.param)?,
44        env: crate::params::collect_env_overrides(&args.param_env)?
45            .into_iter()
46            .collect(),
47        mode: if args.param.is_empty() {
48            crate::params::BindMode::Placeholder
49        } else {
50            crate::params::BindMode::Strict
51        },
52    };
53
54    let cfg = if args.no_secrets {
55        // Grammar / structure only — never touch the network.
56        PipelineConfig::from_path_tolerating_secrets_with(&path, args.profile.as_deref(), &inputs)?
57    } else {
58        // Real preflight: report each secret reference, then resolve.
59        let refs = crate::secrets::scan_path_refs_with(&path, args.profile.as_deref(), &inputs)?;
60        let cfg =
61            PipelineConfig::from_path_async_with(&path, args.profile.as_deref(), &inputs).await?;
62        if !args.json {
63            for (scheme, reference) in &refs {
64                println!("secret: {scheme}:{reference} → resolved");
65            }
66        }
67        cfg
68    };
69    if !cfg.params.is_empty() && !args.json {
70        let required: Vec<&str> = cfg
71            .params
72            .iter()
73            .filter(|(_, p)| p.required)
74            .map(|(n, _)| n.as_str())
75            .collect();
76        println!(
77            "params: {} declared ({}){}",
78            cfg.params.len(),
79            if required.is_empty() {
80                String::from("all optional")
81            } else {
82                format!("required: {}", required.join(", "))
83            },
84            if args.param.is_empty() && !required.is_empty() {
85                " — validated against placeholders; pass --param NAME=VALUE to bind for real"
86            } else {
87                ""
88            }
89        );
90    }
91    // Topology mode (#71/#72): build + validate the node graph instead of the
92    // matrix. `build_topology` runs the core structural validator (arity,
93    // fan-out, join edges, cycle, reachability).
94    if crate::topology::is_topology(&cfg) {
95        let auth = crate::auth_catalog::build_auth_catalog(cfg.auth.as_ref())?;
96        let topo = crate::topology::build_topology(&cfg, &auth).await?;
97        let inert: Vec<(&str, &str)> = crate::topology::inert_blocks(&cfg);
98        if args.json {
99            let out = serde_json::json!({
100                "valid": true,
101                "mode": "topology",
102                "name": cfg.name.as_deref().unwrap_or("unnamed"),
103                "node_count": topo.nodes().len(),
104                "edge_count": topo.edges().len(),
105                "nodes": topo.nodes().iter()
106                    .map(|n| serde_json::json!({ "id": n.id, "kind": n.kind.kind_str() }))
107                    .collect::<Vec<_>>(),
108                "warnings": inert.iter()
109                    .map(|(block, consequence)| serde_json::json!({
110                        "block": block, "consequence": consequence,
111                    }))
112                    .collect::<Vec<_>>(),
113            });
114            println!(
115                "{}",
116                serde_json::to_string_pretty(&out).unwrap_or_else(|_| out.to_string())
117            );
118            return Ok(());
119        }
120        println!(
121            "topology '{}': {} node(s), {} edge(s) — valid",
122            cfg.name.as_deref().unwrap_or("unnamed"),
123            topo.nodes().len(),
124            topo.edges().len()
125        );
126        for n in topo.nodes() {
127            println!("  - {} ({})", n.id, n.kind.kind_str());
128        }
129        // Say what topology mode will *not* do. Printing "valid" while silently
130        // ignoring a declared block is how an operator ends up believing a policy
131        // is enforced when it is not (#456 M2).
132        for (block, consequence) in &inert {
133            println!("  WARNING: `{block}:` is ignored in topology mode — {consequence}");
134        }
135        return Ok(());
136    }
137
138    // `validate` is offline by design, so a discoverable bound is not probed
139    // here. Report it rather than silently validating a plan we could not build.
140    let unprobed: Vec<String> = std::iter::once(("<pipeline>", cfg.partition.as_ref()))
141        .chain(
142            cfg.matrix
143                .iter()
144                .map(|r| (r.id.as_deref().unwrap_or("<row>"), r.partition.as_ref())),
145        )
146        .filter_map(|(id, p)| {
147            p.filter(|s| crate::partition::needs_probe(s))
148                .map(|_| id.to_string())
149        })
150        .collect();
151
152    let nodes = expand(&cfg)?;
153
154    if !unprobed.is_empty() && !args.json {
155        println!(
156            "partition: {} row(s) discover their bound at run time ({}) — the chunk count \
157             cannot be planned offline, so it is not validated here",
158            unprobed.len(),
159            unprobed.join(", ")
160        );
161    }
162
163    // Validate the replication block (snapshot source / CDC source / state) so
164    // `faucet validate` catches misconfiguration without running.
165    if let Some(spec) = &cfg.replication {
166        crate::replication::compiled::CompiledReplication::compile(spec, &cfg)?;
167        if !args.json {
168            println!("replication: mode={:?} — valid", spec.mode);
169        }
170    }
171
172    // Validate the backfill defaults block (window / concurrency / timezone)
173    // and the window-scoping requirement: a `backfill:` block on a pipeline
174    // whose sources reference no `${backfill.*}` / `${now.*}` token would
175    // replay identical data into every window (#282). Offline-safe.
176    if let Some(spec) = &cfg.backfill {
177        let source_configs: Vec<String> = nodes
178            .iter()
179            .filter(|n| matches!(n.role, crate::expand::NodeRole::Root))
180            .map(|n| n.source.config.to_string())
181            .collect();
182        spec.validate(&source_configs)?;
183        if !args.json {
184            println!("backfill: defaults valid");
185        }
186    }
187
188    // Validate the schedule block (cron / timezone / bounds) so `faucet validate`
189    // catches schedule misconfiguration in CI without running. Offline-safe.
190    #[cfg(feature = "schedule")]
191    if let Some(spec) = &cfg.schedule {
192        crate::schedule::compiled::CompiledSchedule::compile(spec)?;
193        if !args.json {
194            println!(
195                "schedule: cron '{}' tz '{}' — valid",
196                spec.cron, spec.timezone
197            );
198        }
199    }
200
201    // Validate the notifications block (unique names, non-empty channel fields)
202    // so `faucet validate` catches misconfiguration without running. Offline.
203    #[cfg(feature = "notify")]
204    if !cfg.notifications.is_empty() {
205        crate::notify::validate_all(&cfg.notifications)?;
206        if !args.json {
207            println!("notifications: {} rule(s) — valid", cfg.notifications.len());
208        }
209    }
210
211    // Lineage transport reachability — best-effort. A failure here is only a
212    // warning: lineage emission never blocks a pipeline run.
213    #[cfg(feature = "lineage")]
214    if let Some(lc) = cfg.lineage.as_ref()
215        && !args.json
216    {
217        match crate::lineage_glue::check_transport(lc).await {
218            Ok(msg) => println!("lineage: {msg}"),
219            Err(msg) => println!("lineage: WARNING — {msg} (lineage never blocks a run)"),
220        }
221    }
222
223    for node in &nodes {
224        // Verifying the schema lookup also catches unknown connector kinds.
225        source_schema(&node.source.kind)?;
226        // A discovery row (#501) enumerates a value-set and has no sink — its
227        // `sink` field is a never-built placeholder, so skip the sink check.
228        if !matches!(node.role, NodeRole::Discovery { .. }) {
229            sink_schema(&node.sink.kind)?;
230        }
231        for t in &node.transforms {
232            if !available_transforms().contains(&t.kind.as_str()) {
233                return Err(CliError::UnknownTransform {
234                    name: format!("{} (row '{}')", t.kind, node.id),
235                    available: available_transforms().join(", "),
236                });
237            }
238        }
239        if let Some(state) = &node.state
240            && !available_state_kinds().contains(&state.kind.as_str())
241        {
242            return Err(CliError::UnknownStateStore {
243                name: format!("{} (row '{}')", state.kind, node.id),
244                available: available_state_kinds().join(", "),
245            });
246        }
247    }
248
249    // Transform *kinds* are checked above; this compiles each chain so a bad
250    // transform *config* fails validation too.
251    check_transforms(&nodes)?;
252
253    // "children" = per-parent-record fan-out rows; discovery / product rows run
254    // independently (no parent), so they count as top-level like roots.
255    let children = nodes
256        .iter()
257        .filter(|n| matches!(n.role, NodeRole::Child { .. }))
258        .count();
259    let roots = nodes.len() - children;
260
261    // Runtime row-selection (#370/#371/#376/#377). The selection is computed the
262    // same way `faucet run` computes it, so the run/skip decision here matches
263    // what a run would do; a selection error (empty run set, missing ancestor,
264    // unknown token) is surfaced after the report so `validate` catches it in CI
265    // without a run.
266    let selection = RunSelection::from_args(&args.selection, cfg.selection.as_ref())?;
267    let uses_selection_model = nodes
268        .iter()
269        .any(|n| n.status != SourceStatus::Active || !n.tags.is_empty());
270    let selection_active = selection.narrows() || uses_selection_model;
271    let has_matrix = !cfg.matrix.is_empty();
272    let selected = if selection_active {
273        Some(crate::select::select_nodes(
274            nodes.clone(),
275            &selection,
276            has_matrix,
277        ))
278    } else {
279        None
280    };
281    let run_ids: HashSet<String> = match &selected {
282        Some(Ok(sel)) => sel.iter().map(|n| n.id.clone()).collect(),
283        _ => HashSet::new(),
284    };
285    let decision_for = |node: &crate::expand::ExpandedNode| -> Option<&'static str> {
286        selection_active.then(|| {
287            if run_ids.contains(&node.id) {
288                "run"
289            } else {
290                "skip"
291            }
292        })
293    };
294
295    if args.json {
296        let rows: Vec<serde_json::Value> = nodes
297            .iter()
298            .map(|node| {
299                let (role, parent_id, parent_key) = match &node.role {
300                    NodeRole::Root => ("root", None, None),
301                    NodeRole::Child {
302                        parent_id,
303                        parent_key,
304                    } => ("child", Some(parent_id.clone()), Some(parent_key.clone())),
305                    NodeRole::Discovery { .. } => ("discovery", None, None),
306                    NodeRole::Product { .. } => ("product", None, None),
307                };
308                serde_json::json!({
309                    "id": node.id,
310                    "source": node.source.kind,
311                    "sink": node.sink.kind,
312                    "role": role,
313                    "parent_id": parent_id,
314                    "parent_key": parent_key,
315                    "depends_on": &node.depends_on,
316                    "delivery": node.delivery_guarantee.to_string(),
317                    "status": node.status.as_str(),
318                    "tags": &node.tags,
319                    "decision": decision_for(node),
320                })
321            })
322            .collect();
323        let out = serde_json::json!({
324            "valid": true,
325            "mode": "matrix",
326            "name": cfg.name.as_deref().unwrap_or("(unnamed)"),
327            "row_count": nodes.len(),
328            "roots": roots,
329            "children": children,
330            "selection_active": selection_active,
331            "rows": rows,
332        });
333        println!(
334            "{}",
335            serde_json::to_string_pretty(&out).unwrap_or_else(|_| out.to_string())
336        );
337        // Propagate any selection error after emitting the summary so the exit
338        // code still reflects an invalid selection.
339        if let Some(sel) = selected {
340            sel?;
341        }
342        return Ok(());
343    }
344
345    println!(
346        "ok: '{}' rows={} (roots={}, children={}) execution={}",
347        cfg.name.as_deref().unwrap_or("(unnamed)"),
348        nodes.len(),
349        roots,
350        children,
351        cfg.execution
352            .as_ref()
353            .map(|e| format!(
354                "max_concurrent={:?} on_error={:?}",
355                e.max_concurrent.unwrap_or(0),
356                e.on_error
357            ))
358            .unwrap_or_else(|| "(defaults)".to_owned()),
359    );
360    for node in &nodes {
361        println!("{}", row_line(node));
362    }
363
364    // The selection report is only printed when the config actually uses the
365    // readiness ladder / tags, or a selector was passed — so a plain config's
366    // `validate` output is unchanged.
367    if selection_active {
368        println!(
369            "run selection (include_parents={}):",
370            selection.include_parents.as_str()
371        );
372        for node in &nodes {
373            let decision = if run_ids.contains(&node.id) {
374                "RUN"
375            } else {
376                "skip"
377            };
378            let tags = if node.tags.is_empty() {
379                String::new()
380            } else {
381                format!(" tags=[{}]", node.tags.join(", "))
382            };
383            println!(
384                "  - {} status={}{} -> {}",
385                node.id,
386                node.status.as_str(),
387                tags,
388                decision
389            );
390        }
391        // Propagate any selection error (empty run set / missing ancestor /
392        // unknown token) now that the report has been printed.
393        if let Some(sel) = selected {
394            sel?;
395        }
396    }
397    Ok(())
398}
399
400/// Render one per-row report line for `faucet validate` output.
401fn row_line(node: &crate::expand::ExpandedNode) -> String {
402    let role = match &node.role {
403        NodeRole::Root => "root".to_owned(),
404        NodeRole::Child {
405            parent_id,
406            parent_key,
407        } => {
408            format!("child of '{parent_id}' (parent_key={parent_key})")
409        }
410        NodeRole::Discovery { as_alias, .. } => {
411            format!("discovery (as={as_alias})")
412        }
413        NodeRole::Product { dims, .. } => {
414            format!("product of [{}]", dims.join(", "))
415        }
416    };
417    let deps = if node.depends_on.is_empty() {
418        String::new()
419    } else {
420        format!(" depends_on=[{}]", node.depends_on.join(", "))
421    };
422    format!(
423        "  - {} [{}] source={} sink={}{} delivery={}",
424        node.id, role, node.source.kind, node.sink.kind, deps, node.delivery_guarantee
425    )
426}
427
428/// Compile every row's transform chain.
429///
430/// `expand` only checks the *shape* of a `transforms:` entry, so a misspelled
431/// field (`set: { fields: … }` instead of `values:`) or an invalid SQL/WASM stage
432/// used to pass validation and then fail on the first page of a real run. Pure and
433/// offline — no connector is built.
434fn check_transforms(nodes: &[crate::expand::ExpandedNode]) -> CliResult<()> {
435    for n in nodes {
436        if n.transforms.is_empty() {
437            continue;
438        }
439        crate::transforms::compile_transforms(&n.transforms)
440            .map_err(|e| CliError::Config(format!("row '{}': {e}", n.id)))?;
441    }
442    Ok(())
443}
444
445#[cfg(test)]
446mod tests {
447    use super::{check_transforms, row_line};
448    use crate::expand::expand;
449
450    #[test]
451    fn row_line_renders_role_and_depends_on() {
452        let cfg = crate::config::parse_with_extension(
453            r#"
454version: 1
455pipeline:
456  source: { type: rest, config: {} }
457  sink:   { type: jsonl, config: { path: ./o } }
458matrix:
459  - id: dims
460  - id: posts
461    parent: dims
462    parent_key: id
463  - id: facts
464    depends_on: [dims]
465"#,
466            "yaml",
467        )
468        .unwrap();
469        let nodes = expand(&cfg).unwrap();
470        let line_for = |id: &str| row_line(nodes.iter().find(|n| n.id == id).unwrap());
471        assert_eq!(
472            line_for("dims"),
473            "  - dims [root] source=rest sink=jsonl delivery=at-least-once"
474        );
475        assert_eq!(
476            line_for("posts"),
477            "  - posts [child of 'dims' (parent_key=id)] source=rest sink=jsonl \
478             delivery=at-least-once"
479        );
480        assert_eq!(
481            line_for("facts"),
482            "  - facts [root] source=rest sink=jsonl depends_on=[dims] delivery=at-least-once"
483        );
484    }
485
486    #[test]
487    fn row_line_reports_derived_effectively_once_guarantees() {
488        // Keyed upsert is reported even when the user did not request
489        // `delivery: exactly_once` (truthful derived guarantee, #292)…
490        let cfg = crate::config::parse_with_extension(
491            r#"
492version: 1
493pipeline:
494  source: { type: rest, config: {} }
495  sink:
496    type: postgres
497    config:
498      connection_url: "postgres://localhost/db"
499      table_name: t
500      column_mapping: auto_map
501      write_mode: upsert
502      key: [id]
503"#,
504            "yaml",
505        )
506        .unwrap();
507        let nodes = expand(&cfg).unwrap();
508        assert!(
509            row_line(&nodes[0]).ends_with("delivery=effectively-once (keyed upsert)"),
510            "got: {}",
511            row_line(&nodes[0])
512        );
513
514        // …and the atomic-watermark mechanism is reported for a CDC → SQL
515        // exactly_once topology.
516        let cfg = crate::config::parse_with_extension(
517            r#"
518version: 1
519delivery: exactly_once
520pipeline:
521  source:
522    type: postgres-cdc
523    config: { connection_url: "postgres://localhost/db", slot: s, publication: p }
524  sink:
525    type: postgres
526    config:
527      connection_url: "postgres://localhost/db"
528      table_name: t
529      column_mapping: auto_map
530  state: { type: file, config: { path: ./state } }
531"#,
532            "yaml",
533        )
534        .unwrap();
535        let nodes = expand(&cfg).unwrap();
536        assert!(
537            row_line(&nodes[0]).ends_with("delivery=effectively-once (atomic watermark)"),
538            "got: {}",
539            row_line(&nodes[0])
540        );
541    }
542
543    #[test]
544    fn transform_chains_are_compiled_not_just_shape_checked() {
545        // `set` takes `values:`; `fields:` is a plausible-looking typo that used to
546        // validate cleanly and then fail on the first page of a real run.
547        let cfg = crate::config::parse_with_extension(
548            r#"
549version: 1
550pipeline:
551  source: { type: rest, config: {} }
552  transforms:
553    - type: set
554      config: { fields: { a: 1 } }
555  sink:   { type: jsonl, config: { path: ./o } }
556matrix:
557  - id: rowA
558"#,
559            "yaml",
560        )
561        .unwrap();
562        let err = check_transforms(&expand(&cfg).unwrap())
563            .unwrap_err()
564            .to_string();
565        assert!(err.contains("rowA"), "names the row: {err}");
566        assert!(err.contains("values"), "names the missing field: {err}");
567
568        // A well-formed chain compiles.
569        let cfg = crate::config::parse_with_extension(
570            r#"
571version: 1
572pipeline:
573  source: { type: rest, config: {} }
574  transforms:
575    - type: set
576      config: { values: { a: 1 } }
577  sink:   { type: jsonl, config: { path: ./o } }
578"#,
579            "yaml",
580        )
581        .unwrap();
582        check_transforms(&expand(&cfg).unwrap()).unwrap();
583    }
584}