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    let cfg = if args.no_secrets {
39        // Grammar / structure only — never touch the network.
40        PipelineConfig::from_path_tolerating_secrets(&path, args.profile.as_deref())?
41    } else {
42        // Real preflight: report each secret reference, then resolve.
43        let refs = crate::secrets::scan_path_refs(&path, args.profile.as_deref())?;
44        let cfg = PipelineConfig::from_path_async(&path, args.profile.as_deref()).await?;
45        for (scheme, reference) in &refs {
46            println!("secret: {scheme}:{reference} → resolved");
47        }
48        cfg
49    };
50    // Topology mode (#71/#72): build + validate the node graph instead of the
51    // matrix. `build_topology` runs the core structural validator (arity,
52    // fan-out, join edges, cycle, reachability).
53    if crate::topology::is_topology(&cfg) {
54        let auth = crate::auth_catalog::build_auth_catalog(cfg.auth.as_ref())?;
55        let topo = crate::topology::build_topology(&cfg, &auth).await?;
56        println!(
57            "topology '{}': {} node(s), {} edge(s) — valid",
58            cfg.name.as_deref().unwrap_or("unnamed"),
59            topo.nodes().len(),
60            topo.edges().len()
61        );
62        for n in topo.nodes() {
63            println!("  - {} ({})", n.id, n.kind.kind_str());
64        }
65        return Ok(());
66    }
67
68    let nodes = expand(&cfg)?;
69
70    // Validate the replication block (snapshot source / CDC source / state) so
71    // `faucet validate` catches misconfiguration without running.
72    if let Some(spec) = &cfg.replication {
73        crate::replication::compiled::CompiledReplication::compile(spec, &cfg)?;
74        println!("replication: mode={:?} — valid", spec.mode);
75    }
76
77    // Validate the backfill defaults block (window / concurrency / timezone)
78    // and the window-scoping requirement: a `backfill:` block on a pipeline
79    // whose sources reference no `${backfill.*}` / `${now.*}` token would
80    // replay identical data into every window (#282). Offline-safe.
81    if let Some(spec) = &cfg.backfill {
82        let source_configs: Vec<String> = nodes
83            .iter()
84            .filter(|n| matches!(n.role, crate::expand::NodeRole::Root))
85            .map(|n| n.source.config.to_string())
86            .collect();
87        spec.validate(&source_configs)?;
88        println!("backfill: defaults valid");
89    }
90
91    // Validate the schedule block (cron / timezone / bounds) so `faucet validate`
92    // catches schedule misconfiguration in CI without running. Offline-safe.
93    #[cfg(feature = "schedule")]
94    if let Some(spec) = &cfg.schedule {
95        crate::schedule::compiled::CompiledSchedule::compile(spec)?;
96        println!(
97            "schedule: cron '{}' tz '{}' — valid",
98            spec.cron, spec.timezone
99        );
100    }
101
102    // Validate the notifications block (unique names, non-empty channel fields)
103    // so `faucet validate` catches misconfiguration without running. Offline.
104    #[cfg(feature = "notify")]
105    if !cfg.notifications.is_empty() {
106        crate::notify::validate_all(&cfg.notifications)?;
107        println!("notifications: {} rule(s) — valid", cfg.notifications.len());
108    }
109
110    // Lineage transport reachability — best-effort. A failure here is only a
111    // warning: lineage emission never blocks a pipeline run.
112    #[cfg(feature = "lineage")]
113    if let Some(lc) = cfg.lineage.as_ref() {
114        match crate::lineage_glue::check_transport(lc).await {
115            Ok(msg) => println!("lineage: {msg}"),
116            Err(msg) => println!("lineage: WARNING — {msg} (lineage never blocks a run)"),
117        }
118    }
119
120    for node in &nodes {
121        // Verifying the schema lookup also catches unknown connector kinds.
122        source_schema(&node.source.kind)?;
123        sink_schema(&node.sink.kind)?;
124        for t in &node.transforms {
125            if !available_transforms().contains(&t.kind.as_str()) {
126                return Err(CliError::UnknownTransform {
127                    name: format!("{} (row '{}')", t.kind, node.id),
128                    available: available_transforms().join(", "),
129                });
130            }
131        }
132        if let Some(state) = &node.state
133            && !available_state_kinds().contains(&state.kind.as_str())
134        {
135            return Err(CliError::UnknownStateStore {
136                name: format!("{} (row '{}')", state.kind, node.id),
137                available: available_state_kinds().join(", "),
138            });
139        }
140    }
141
142    let roots = nodes
143        .iter()
144        .filter(|n| matches!(n.role, NodeRole::Root))
145        .count();
146    let children = nodes.len() - roots;
147    println!(
148        "ok: '{}' rows={} (roots={}, children={}) execution={}",
149        cfg.name.as_deref().unwrap_or("(unnamed)"),
150        nodes.len(),
151        roots,
152        children,
153        cfg.execution
154            .as_ref()
155            .map(|e| format!(
156                "max_concurrent={:?} on_error={:?}",
157                e.max_concurrent.unwrap_or(0),
158                e.on_error
159            ))
160            .unwrap_or_else(|| "(defaults)".to_owned()),
161    );
162    for node in &nodes {
163        println!("{}", row_line(node));
164    }
165
166    // Runtime row-selection report (#370/#371/#376/#377). Only printed when the
167    // config actually uses the readiness ladder / tags, or a selector was
168    // passed — so a plain config's `validate` output is unchanged. The
169    // selection is computed the same way `faucet run` computes it, so the
170    // run/skip decision here matches what a run would do; a selection error
171    // (empty run set, missing ancestor, unknown token) is surfaced after the
172    // report so `validate` catches it in CI without a run.
173    let selection = RunSelection::from_args(&args.selection, cfg.selection.as_ref())?;
174    let uses_selection_model = nodes
175        .iter()
176        .any(|n| n.status != SourceStatus::Active || !n.tags.is_empty());
177    if selection.narrows() || uses_selection_model {
178        let has_matrix = !cfg.matrix.is_empty();
179        let selected = crate::select::select_nodes(nodes.clone(), &selection, has_matrix);
180        let run_ids: HashSet<String> = match &selected {
181            Ok(sel) => sel.iter().map(|n| n.id.clone()).collect(),
182            Err(_) => HashSet::new(),
183        };
184        println!(
185            "run selection (include_parents={}):",
186            selection.include_parents.as_str()
187        );
188        for node in &nodes {
189            let decision = if run_ids.contains(&node.id) {
190                "RUN"
191            } else {
192                "skip"
193            };
194            let tags = if node.tags.is_empty() {
195                String::new()
196            } else {
197                format!(" tags=[{}]", node.tags.join(", "))
198            };
199            println!(
200                "  - {} status={}{} -> {}",
201                node.id,
202                node.status.as_str(),
203                tags,
204                decision
205            );
206        }
207        // Propagate any selection error (empty run set / missing ancestor /
208        // unknown token) now that the report has been printed.
209        selected?;
210    }
211    Ok(())
212}
213
214/// Render one per-row report line for `faucet validate` output.
215fn row_line(node: &crate::expand::ExpandedNode) -> String {
216    let role = match &node.role {
217        NodeRole::Root => "root".to_owned(),
218        NodeRole::Child {
219            parent_id,
220            parent_key,
221        } => {
222            format!("child of '{parent_id}' (parent_key={parent_key})")
223        }
224    };
225    let deps = if node.depends_on.is_empty() {
226        String::new()
227    } else {
228        format!(" depends_on=[{}]", node.depends_on.join(", "))
229    };
230    format!(
231        "  - {} [{}] source={} sink={}{} delivery={}",
232        node.id, role, node.source.kind, node.sink.kind, deps, node.delivery_guarantee
233    )
234}
235
236#[cfg(test)]
237mod tests {
238    use super::row_line;
239    use crate::expand::expand;
240
241    #[test]
242    fn row_line_renders_role_and_depends_on() {
243        let cfg = crate::config::parse_with_extension(
244            r#"
245version: 1
246pipeline:
247  source: { type: rest, config: {} }
248  sink:   { type: jsonl, config: { path: ./o } }
249matrix:
250  - id: dims
251  - id: posts
252    parent: dims
253    parent_key: id
254  - id: facts
255    depends_on: [dims]
256"#,
257            "yaml",
258        )
259        .unwrap();
260        let nodes = expand(&cfg).unwrap();
261        let line_for = |id: &str| row_line(nodes.iter().find(|n| n.id == id).unwrap());
262        assert_eq!(
263            line_for("dims"),
264            "  - dims [root] source=rest sink=jsonl delivery=at-least-once"
265        );
266        assert_eq!(
267            line_for("posts"),
268            "  - posts [child of 'dims' (parent_key=id)] source=rest sink=jsonl \
269             delivery=at-least-once"
270        );
271        assert_eq!(
272            line_for("facts"),
273            "  - facts [root] source=rest sink=jsonl depends_on=[dims] delivery=at-least-once"
274        );
275    }
276
277    #[test]
278    fn row_line_reports_derived_effectively_once_guarantees() {
279        // Keyed upsert is reported even when the user did not request
280        // `delivery: exactly_once` (truthful derived guarantee, #292)…
281        let cfg = crate::config::parse_with_extension(
282            r#"
283version: 1
284pipeline:
285  source: { type: rest, config: {} }
286  sink:
287    type: postgres
288    config:
289      connection_url: "postgres://localhost/db"
290      table_name: t
291      column_mapping: auto_map
292      write_mode: upsert
293      key: [id]
294"#,
295            "yaml",
296        )
297        .unwrap();
298        let nodes = expand(&cfg).unwrap();
299        assert!(
300            row_line(&nodes[0]).ends_with("delivery=effectively-once (keyed upsert)"),
301            "got: {}",
302            row_line(&nodes[0])
303        );
304
305        // …and the atomic-watermark mechanism is reported for a CDC → SQL
306        // exactly_once topology.
307        let cfg = crate::config::parse_with_extension(
308            r#"
309version: 1
310delivery: exactly_once
311pipeline:
312  source:
313    type: postgres-cdc
314    config: { connection_url: "postgres://localhost/db", slot: s, publication: p }
315  sink:
316    type: postgres
317    config:
318      connection_url: "postgres://localhost/db"
319      table_name: t
320      column_mapping: auto_map
321  state: { type: file, config: { path: ./state } }
322"#,
323            "yaml",
324        )
325        .unwrap();
326        let nodes = expand(&cfg).unwrap();
327        assert!(
328            row_line(&nodes[0]).ends_with("delivery=effectively-once (atomic watermark)"),
329            "got: {}",
330            row_line(&nodes[0])
331        );
332    }
333}