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