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        sink_schema(&node.sink.kind)?;
227        for t in &node.transforms {
228            if !available_transforms().contains(&t.kind.as_str()) {
229                return Err(CliError::UnknownTransform {
230                    name: format!("{} (row '{}')", t.kind, node.id),
231                    available: available_transforms().join(", "),
232                });
233            }
234        }
235        if let Some(state) = &node.state
236            && !available_state_kinds().contains(&state.kind.as_str())
237        {
238            return Err(CliError::UnknownStateStore {
239                name: format!("{} (row '{}')", state.kind, node.id),
240                available: available_state_kinds().join(", "),
241            });
242        }
243    }
244
245    // Transform *kinds* are checked above; this compiles each chain so a bad
246    // transform *config* fails validation too.
247    check_transforms(&nodes)?;
248
249    let roots = nodes
250        .iter()
251        .filter(|n| matches!(n.role, NodeRole::Root))
252        .count();
253    let children = nodes.len() - roots;
254
255    // Runtime row-selection (#370/#371/#376/#377). The selection is computed the
256    // same way `faucet run` computes it, so the run/skip decision here matches
257    // what a run would do; a selection error (empty run set, missing ancestor,
258    // unknown token) is surfaced after the report so `validate` catches it in CI
259    // without a run.
260    let selection = RunSelection::from_args(&args.selection, cfg.selection.as_ref())?;
261    let uses_selection_model = nodes
262        .iter()
263        .any(|n| n.status != SourceStatus::Active || !n.tags.is_empty());
264    let selection_active = selection.narrows() || uses_selection_model;
265    let has_matrix = !cfg.matrix.is_empty();
266    let selected = if selection_active {
267        Some(crate::select::select_nodes(
268            nodes.clone(),
269            &selection,
270            has_matrix,
271        ))
272    } else {
273        None
274    };
275    let run_ids: HashSet<String> = match &selected {
276        Some(Ok(sel)) => sel.iter().map(|n| n.id.clone()).collect(),
277        _ => HashSet::new(),
278    };
279    let decision_for = |node: &crate::expand::ExpandedNode| -> Option<&'static str> {
280        selection_active.then(|| {
281            if run_ids.contains(&node.id) {
282                "run"
283            } else {
284                "skip"
285            }
286        })
287    };
288
289    if args.json {
290        let rows: Vec<serde_json::Value> = nodes
291            .iter()
292            .map(|node| {
293                let (role, parent_id, parent_key) = match &node.role {
294                    NodeRole::Root => ("root", None, None),
295                    NodeRole::Child {
296                        parent_id,
297                        parent_key,
298                    } => ("child", Some(parent_id.clone()), Some(parent_key.clone())),
299                };
300                serde_json::json!({
301                    "id": node.id,
302                    "source": node.source.kind,
303                    "sink": node.sink.kind,
304                    "role": role,
305                    "parent_id": parent_id,
306                    "parent_key": parent_key,
307                    "depends_on": &node.depends_on,
308                    "delivery": node.delivery_guarantee.to_string(),
309                    "status": node.status.as_str(),
310                    "tags": &node.tags,
311                    "decision": decision_for(node),
312                })
313            })
314            .collect();
315        let out = serde_json::json!({
316            "valid": true,
317            "mode": "matrix",
318            "name": cfg.name.as_deref().unwrap_or("(unnamed)"),
319            "row_count": nodes.len(),
320            "roots": roots,
321            "children": children,
322            "selection_active": selection_active,
323            "rows": rows,
324        });
325        println!(
326            "{}",
327            serde_json::to_string_pretty(&out).unwrap_or_else(|_| out.to_string())
328        );
329        // Propagate any selection error after emitting the summary so the exit
330        // code still reflects an invalid selection.
331        if let Some(sel) = selected {
332            sel?;
333        }
334        return Ok(());
335    }
336
337    println!(
338        "ok: '{}' rows={} (roots={}, children={}) execution={}",
339        cfg.name.as_deref().unwrap_or("(unnamed)"),
340        nodes.len(),
341        roots,
342        children,
343        cfg.execution
344            .as_ref()
345            .map(|e| format!(
346                "max_concurrent={:?} on_error={:?}",
347                e.max_concurrent.unwrap_or(0),
348                e.on_error
349            ))
350            .unwrap_or_else(|| "(defaults)".to_owned()),
351    );
352    for node in &nodes {
353        println!("{}", row_line(node));
354    }
355
356    // The selection report is only printed when the config actually uses the
357    // readiness ladder / tags, or a selector was passed — so a plain config's
358    // `validate` output is unchanged.
359    if selection_active {
360        println!(
361            "run selection (include_parents={}):",
362            selection.include_parents.as_str()
363        );
364        for node in &nodes {
365            let decision = if run_ids.contains(&node.id) {
366                "RUN"
367            } else {
368                "skip"
369            };
370            let tags = if node.tags.is_empty() {
371                String::new()
372            } else {
373                format!(" tags=[{}]", node.tags.join(", "))
374            };
375            println!(
376                "  - {} status={}{} -> {}",
377                node.id,
378                node.status.as_str(),
379                tags,
380                decision
381            );
382        }
383        // Propagate any selection error (empty run set / missing ancestor /
384        // unknown token) now that the report has been printed.
385        if let Some(sel) = selected {
386            sel?;
387        }
388    }
389    Ok(())
390}
391
392/// Render one per-row report line for `faucet validate` output.
393fn row_line(node: &crate::expand::ExpandedNode) -> String {
394    let role = match &node.role {
395        NodeRole::Root => "root".to_owned(),
396        NodeRole::Child {
397            parent_id,
398            parent_key,
399        } => {
400            format!("child of '{parent_id}' (parent_key={parent_key})")
401        }
402    };
403    let deps = if node.depends_on.is_empty() {
404        String::new()
405    } else {
406        format!(" depends_on=[{}]", node.depends_on.join(", "))
407    };
408    format!(
409        "  - {} [{}] source={} sink={}{} delivery={}",
410        node.id, role, node.source.kind, node.sink.kind, deps, node.delivery_guarantee
411    )
412}
413
414/// Compile every row's transform chain.
415///
416/// `expand` only checks the *shape* of a `transforms:` entry, so a misspelled
417/// field (`set: { fields: … }` instead of `values:`) or an invalid SQL/WASM stage
418/// used to pass validation and then fail on the first page of a real run. Pure and
419/// offline — no connector is built.
420fn check_transforms(nodes: &[crate::expand::ExpandedNode]) -> CliResult<()> {
421    for n in nodes {
422        if n.transforms.is_empty() {
423            continue;
424        }
425        crate::transforms::compile_transforms(&n.transforms)
426            .map_err(|e| CliError::Config(format!("row '{}': {e}", n.id)))?;
427    }
428    Ok(())
429}
430
431#[cfg(test)]
432mod tests {
433    use super::{check_transforms, row_line};
434    use crate::expand::expand;
435
436    #[test]
437    fn row_line_renders_role_and_depends_on() {
438        let cfg = crate::config::parse_with_extension(
439            r#"
440version: 1
441pipeline:
442  source: { type: rest, config: {} }
443  sink:   { type: jsonl, config: { path: ./o } }
444matrix:
445  - id: dims
446  - id: posts
447    parent: dims
448    parent_key: id
449  - id: facts
450    depends_on: [dims]
451"#,
452            "yaml",
453        )
454        .unwrap();
455        let nodes = expand(&cfg).unwrap();
456        let line_for = |id: &str| row_line(nodes.iter().find(|n| n.id == id).unwrap());
457        assert_eq!(
458            line_for("dims"),
459            "  - dims [root] source=rest sink=jsonl delivery=at-least-once"
460        );
461        assert_eq!(
462            line_for("posts"),
463            "  - posts [child of 'dims' (parent_key=id)] source=rest sink=jsonl \
464             delivery=at-least-once"
465        );
466        assert_eq!(
467            line_for("facts"),
468            "  - facts [root] source=rest sink=jsonl depends_on=[dims] delivery=at-least-once"
469        );
470    }
471
472    #[test]
473    fn row_line_reports_derived_effectively_once_guarantees() {
474        // Keyed upsert is reported even when the user did not request
475        // `delivery: exactly_once` (truthful derived guarantee, #292)…
476        let cfg = crate::config::parse_with_extension(
477            r#"
478version: 1
479pipeline:
480  source: { type: rest, config: {} }
481  sink:
482    type: postgres
483    config:
484      connection_url: "postgres://localhost/db"
485      table_name: t
486      column_mapping: auto_map
487      write_mode: upsert
488      key: [id]
489"#,
490            "yaml",
491        )
492        .unwrap();
493        let nodes = expand(&cfg).unwrap();
494        assert!(
495            row_line(&nodes[0]).ends_with("delivery=effectively-once (keyed upsert)"),
496            "got: {}",
497            row_line(&nodes[0])
498        );
499
500        // …and the atomic-watermark mechanism is reported for a CDC → SQL
501        // exactly_once topology.
502        let cfg = crate::config::parse_with_extension(
503            r#"
504version: 1
505delivery: exactly_once
506pipeline:
507  source:
508    type: postgres-cdc
509    config: { connection_url: "postgres://localhost/db", slot: s, publication: p }
510  sink:
511    type: postgres
512    config:
513      connection_url: "postgres://localhost/db"
514      table_name: t
515      column_mapping: auto_map
516  state: { type: file, config: { path: ./state } }
517"#,
518            "yaml",
519        )
520        .unwrap();
521        let nodes = expand(&cfg).unwrap();
522        assert!(
523            row_line(&nodes[0]).ends_with("delivery=effectively-once (atomic watermark)"),
524            "got: {}",
525            row_line(&nodes[0])
526        );
527    }
528
529    #[test]
530    fn transform_chains_are_compiled_not_just_shape_checked() {
531        // `set` takes `values:`; `fields:` is a plausible-looking typo that used to
532        // validate cleanly and then fail on the first page of a real run.
533        let cfg = crate::config::parse_with_extension(
534            r#"
535version: 1
536pipeline:
537  source: { type: rest, config: {} }
538  transforms:
539    - type: set
540      config: { fields: { a: 1 } }
541  sink:   { type: jsonl, config: { path: ./o } }
542matrix:
543  - id: rowA
544"#,
545            "yaml",
546        )
547        .unwrap();
548        let err = check_transforms(&expand(&cfg).unwrap())
549            .unwrap_err()
550            .to_string();
551        assert!(err.contains("rowA"), "names the row: {err}");
552        assert!(err.contains("values"), "names the missing field: {err}");
553
554        // A well-formed chain compiles.
555        let cfg = crate::config::parse_with_extension(
556            r#"
557version: 1
558pipeline:
559  source: { type: rest, config: {} }
560  transforms:
561    - type: set
562      config: { values: { a: 1 } }
563  sink:   { type: jsonl, config: { path: ./o } }
564"#,
565            "yaml",
566        )
567        .unwrap();
568        check_transforms(&expand(&cfg).unwrap()).unwrap();
569    }
570}