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