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        for (scheme, reference) in &refs {
63            println!("secret: {scheme}:{reference} → resolved");
64        }
65        cfg
66    };
67    if !cfg.params.is_empty() {
68        let required: Vec<&str> = cfg
69            .params
70            .iter()
71            .filter(|(_, p)| p.required)
72            .map(|(n, _)| n.as_str())
73            .collect();
74        println!(
75            "params: {} declared ({}){}",
76            cfg.params.len(),
77            if required.is_empty() {
78                String::from("all optional")
79            } else {
80                format!("required: {}", required.join(", "))
81            },
82            if args.param.is_empty() && !required.is_empty() {
83                " — validated against placeholders; pass --param NAME=VALUE to bind for real"
84            } else {
85                ""
86            }
87        );
88    }
89    // Topology mode (#71/#72): build + validate the node graph instead of the
90    // matrix. `build_topology` runs the core structural validator (arity,
91    // fan-out, join edges, cycle, reachability).
92    if crate::topology::is_topology(&cfg) {
93        let auth = crate::auth_catalog::build_auth_catalog(cfg.auth.as_ref())?;
94        let topo = crate::topology::build_topology(&cfg, &auth).await?;
95        println!(
96            "topology '{}': {} node(s), {} edge(s) — valid",
97            cfg.name.as_deref().unwrap_or("unnamed"),
98            topo.nodes().len(),
99            topo.edges().len()
100        );
101        for n in topo.nodes() {
102            println!("  - {} ({})", n.id, n.kind.kind_str());
103        }
104        // Say what topology mode will *not* do. Printing "valid" while silently
105        // ignoring a declared block is how an operator ends up believing a policy
106        // is enforced when it is not (#456 M2).
107        for (block, consequence) in crate::topology::inert_blocks(&cfg) {
108            println!("  WARNING: `{block}:` is ignored in topology mode — {consequence}");
109        }
110        return Ok(());
111    }
112
113    // `validate` is offline by design, so a discoverable bound is not probed
114    // here. Report it rather than silently validating a plan we could not build.
115    let unprobed: Vec<String> = std::iter::once(("<pipeline>", cfg.partition.as_ref()))
116        .chain(
117            cfg.matrix
118                .iter()
119                .map(|r| (r.id.as_deref().unwrap_or("<row>"), r.partition.as_ref())),
120        )
121        .filter_map(|(id, p)| {
122            p.filter(|s| crate::partition::needs_probe(s))
123                .map(|_| id.to_string())
124        })
125        .collect();
126
127    let nodes = expand(&cfg)?;
128
129    if !unprobed.is_empty() {
130        println!(
131            "partition: {} row(s) discover their bound at run time ({}) — the chunk count \
132             cannot be planned offline, so it is not validated here",
133            unprobed.len(),
134            unprobed.join(", ")
135        );
136    }
137
138    // Validate the replication block (snapshot source / CDC source / state) so
139    // `faucet validate` catches misconfiguration without running.
140    if let Some(spec) = &cfg.replication {
141        crate::replication::compiled::CompiledReplication::compile(spec, &cfg)?;
142        println!("replication: mode={:?} — valid", spec.mode);
143    }
144
145    // Validate the backfill defaults block (window / concurrency / timezone)
146    // and the window-scoping requirement: a `backfill:` block on a pipeline
147    // whose sources reference no `${backfill.*}` / `${now.*}` token would
148    // replay identical data into every window (#282). Offline-safe.
149    if let Some(spec) = &cfg.backfill {
150        let source_configs: Vec<String> = nodes
151            .iter()
152            .filter(|n| matches!(n.role, crate::expand::NodeRole::Root))
153            .map(|n| n.source.config.to_string())
154            .collect();
155        spec.validate(&source_configs)?;
156        println!("backfill: defaults valid");
157    }
158
159    // Validate the schedule block (cron / timezone / bounds) so `faucet validate`
160    // catches schedule misconfiguration in CI without running. Offline-safe.
161    #[cfg(feature = "schedule")]
162    if let Some(spec) = &cfg.schedule {
163        crate::schedule::compiled::CompiledSchedule::compile(spec)?;
164        println!(
165            "schedule: cron '{}' tz '{}' — valid",
166            spec.cron, spec.timezone
167        );
168    }
169
170    // Validate the notifications block (unique names, non-empty channel fields)
171    // so `faucet validate` catches misconfiguration without running. Offline.
172    #[cfg(feature = "notify")]
173    if !cfg.notifications.is_empty() {
174        crate::notify::validate_all(&cfg.notifications)?;
175        println!("notifications: {} rule(s) — valid", cfg.notifications.len());
176    }
177
178    // Lineage transport reachability — best-effort. A failure here is only a
179    // warning: lineage emission never blocks a pipeline run.
180    #[cfg(feature = "lineage")]
181    if let Some(lc) = cfg.lineage.as_ref() {
182        match crate::lineage_glue::check_transport(lc).await {
183            Ok(msg) => println!("lineage: {msg}"),
184            Err(msg) => println!("lineage: WARNING — {msg} (lineage never blocks a run)"),
185        }
186    }
187
188    for node in &nodes {
189        // Verifying the schema lookup also catches unknown connector kinds.
190        source_schema(&node.source.kind)?;
191        sink_schema(&node.sink.kind)?;
192        for t in &node.transforms {
193            if !available_transforms().contains(&t.kind.as_str()) {
194                return Err(CliError::UnknownTransform {
195                    name: format!("{} (row '{}')", t.kind, node.id),
196                    available: available_transforms().join(", "),
197                });
198            }
199        }
200        if let Some(state) = &node.state
201            && !available_state_kinds().contains(&state.kind.as_str())
202        {
203            return Err(CliError::UnknownStateStore {
204                name: format!("{} (row '{}')", state.kind, node.id),
205                available: available_state_kinds().join(", "),
206            });
207        }
208    }
209
210    // Transform *kinds* are checked above; this compiles each chain so a bad
211    // transform *config* fails validation too.
212    check_transforms(&nodes)?;
213
214    let roots = nodes
215        .iter()
216        .filter(|n| matches!(n.role, NodeRole::Root))
217        .count();
218    let children = nodes.len() - roots;
219    println!(
220        "ok: '{}' rows={} (roots={}, children={}) execution={}",
221        cfg.name.as_deref().unwrap_or("(unnamed)"),
222        nodes.len(),
223        roots,
224        children,
225        cfg.execution
226            .as_ref()
227            .map(|e| format!(
228                "max_concurrent={:?} on_error={:?}",
229                e.max_concurrent.unwrap_or(0),
230                e.on_error
231            ))
232            .unwrap_or_else(|| "(defaults)".to_owned()),
233    );
234    for node in &nodes {
235        println!("{}", row_line(node));
236    }
237
238    // Runtime row-selection report (#370/#371/#376/#377). Only printed when the
239    // config actually uses the readiness ladder / tags, or a selector was
240    // passed — so a plain config's `validate` output is unchanged. The
241    // selection is computed the same way `faucet run` computes it, so the
242    // run/skip decision here matches what a run would do; a selection error
243    // (empty run set, missing ancestor, unknown token) is surfaced after the
244    // report so `validate` catches it in CI without a run.
245    let selection = RunSelection::from_args(&args.selection, cfg.selection.as_ref())?;
246    let uses_selection_model = nodes
247        .iter()
248        .any(|n| n.status != SourceStatus::Active || !n.tags.is_empty());
249    if selection.narrows() || uses_selection_model {
250        let has_matrix = !cfg.matrix.is_empty();
251        let selected = crate::select::select_nodes(nodes.clone(), &selection, has_matrix);
252        let run_ids: HashSet<String> = match &selected {
253            Ok(sel) => sel.iter().map(|n| n.id.clone()).collect(),
254            Err(_) => HashSet::new(),
255        };
256        println!(
257            "run selection (include_parents={}):",
258            selection.include_parents.as_str()
259        );
260        for node in &nodes {
261            let decision = if run_ids.contains(&node.id) {
262                "RUN"
263            } else {
264                "skip"
265            };
266            let tags = if node.tags.is_empty() {
267                String::new()
268            } else {
269                format!(" tags=[{}]", node.tags.join(", "))
270            };
271            println!(
272                "  - {} status={}{} -> {}",
273                node.id,
274                node.status.as_str(),
275                tags,
276                decision
277            );
278        }
279        // Propagate any selection error (empty run set / missing ancestor /
280        // unknown token) now that the report has been printed.
281        selected?;
282    }
283    Ok(())
284}
285
286/// Render one per-row report line for `faucet validate` output.
287fn row_line(node: &crate::expand::ExpandedNode) -> String {
288    let role = match &node.role {
289        NodeRole::Root => "root".to_owned(),
290        NodeRole::Child {
291            parent_id,
292            parent_key,
293        } => {
294            format!("child of '{parent_id}' (parent_key={parent_key})")
295        }
296    };
297    let deps = if node.depends_on.is_empty() {
298        String::new()
299    } else {
300        format!(" depends_on=[{}]", node.depends_on.join(", "))
301    };
302    format!(
303        "  - {} [{}] source={} sink={}{} delivery={}",
304        node.id, role, node.source.kind, node.sink.kind, deps, node.delivery_guarantee
305    )
306}
307
308/// Compile every row's transform chain.
309///
310/// `expand` only checks the *shape* of a `transforms:` entry, so a misspelled
311/// field (`set: { fields: … }` instead of `values:`) or an invalid SQL/WASM stage
312/// used to pass validation and then fail on the first page of a real run. Pure and
313/// offline — no connector is built.
314fn check_transforms(nodes: &[crate::expand::ExpandedNode]) -> CliResult<()> {
315    for n in nodes {
316        if n.transforms.is_empty() {
317            continue;
318        }
319        crate::transforms::compile_transforms(&n.transforms)
320            .map_err(|e| CliError::Config(format!("row '{}': {e}", n.id)))?;
321    }
322    Ok(())
323}
324
325#[cfg(test)]
326mod tests {
327    use super::{check_transforms, row_line};
328    use crate::expand::expand;
329
330    #[test]
331    fn row_line_renders_role_and_depends_on() {
332        let cfg = crate::config::parse_with_extension(
333            r#"
334version: 1
335pipeline:
336  source: { type: rest, config: {} }
337  sink:   { type: jsonl, config: { path: ./o } }
338matrix:
339  - id: dims
340  - id: posts
341    parent: dims
342    parent_key: id
343  - id: facts
344    depends_on: [dims]
345"#,
346            "yaml",
347        )
348        .unwrap();
349        let nodes = expand(&cfg).unwrap();
350        let line_for = |id: &str| row_line(nodes.iter().find(|n| n.id == id).unwrap());
351        assert_eq!(
352            line_for("dims"),
353            "  - dims [root] source=rest sink=jsonl delivery=at-least-once"
354        );
355        assert_eq!(
356            line_for("posts"),
357            "  - posts [child of 'dims' (parent_key=id)] source=rest sink=jsonl \
358             delivery=at-least-once"
359        );
360        assert_eq!(
361            line_for("facts"),
362            "  - facts [root] source=rest sink=jsonl depends_on=[dims] delivery=at-least-once"
363        );
364    }
365
366    #[test]
367    fn row_line_reports_derived_effectively_once_guarantees() {
368        // Keyed upsert is reported even when the user did not request
369        // `delivery: exactly_once` (truthful derived guarantee, #292)…
370        let cfg = crate::config::parse_with_extension(
371            r#"
372version: 1
373pipeline:
374  source: { type: rest, config: {} }
375  sink:
376    type: postgres
377    config:
378      connection_url: "postgres://localhost/db"
379      table_name: t
380      column_mapping: auto_map
381      write_mode: upsert
382      key: [id]
383"#,
384            "yaml",
385        )
386        .unwrap();
387        let nodes = expand(&cfg).unwrap();
388        assert!(
389            row_line(&nodes[0]).ends_with("delivery=effectively-once (keyed upsert)"),
390            "got: {}",
391            row_line(&nodes[0])
392        );
393
394        // …and the atomic-watermark mechanism is reported for a CDC → SQL
395        // exactly_once topology.
396        let cfg = crate::config::parse_with_extension(
397            r#"
398version: 1
399delivery: exactly_once
400pipeline:
401  source:
402    type: postgres-cdc
403    config: { connection_url: "postgres://localhost/db", slot: s, publication: p }
404  sink:
405    type: postgres
406    config:
407      connection_url: "postgres://localhost/db"
408      table_name: t
409      column_mapping: auto_map
410  state: { type: file, config: { path: ./state } }
411"#,
412            "yaml",
413        )
414        .unwrap();
415        let nodes = expand(&cfg).unwrap();
416        assert!(
417            row_line(&nodes[0]).ends_with("delivery=effectively-once (atomic watermark)"),
418            "got: {}",
419            row_line(&nodes[0])
420        );
421    }
422
423    #[test]
424    fn transform_chains_are_compiled_not_just_shape_checked() {
425        // `set` takes `values:`; `fields:` is a plausible-looking typo that used to
426        // validate cleanly and then fail on the first page of a real run.
427        let cfg = crate::config::parse_with_extension(
428            r#"
429version: 1
430pipeline:
431  source: { type: rest, config: {} }
432  transforms:
433    - type: set
434      config: { fields: { a: 1 } }
435  sink:   { type: jsonl, config: { path: ./o } }
436matrix:
437  - id: rowA
438"#,
439            "yaml",
440        )
441        .unwrap();
442        let err = check_transforms(&expand(&cfg).unwrap())
443            .unwrap_err()
444            .to_string();
445        assert!(err.contains("rowA"), "names the row: {err}");
446        assert!(err.contains("values"), "names the missing field: {err}");
447
448        // A well-formed chain compiles.
449        let cfg = crate::config::parse_with_extension(
450            r#"
451version: 1
452pipeline:
453  source: { type: rest, config: {} }
454  transforms:
455    - type: set
456      config: { values: { a: 1 } }
457  sink:   { type: jsonl, config: { path: ./o } }
458"#,
459            "yaml",
460        )
461        .unwrap();
462        check_transforms(&expand(&cfg).unwrap()).unwrap();
463    }
464}