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;
8use crate::error::{CliError, CliResult};
9use crate::expand::{NodeRole, expand};
10use crate::registry::{sink_schema, source_schema};
11use crate::state::available_state_kinds;
12use crate::transforms::available_transforms;
13
14/// Execute the `validate` subcommand.
15pub async fn run(args: ValidateArgs) -> CliResult<()> {
16    let cwd = std::env::current_dir()?;
17    let env_path =
18        crate::env_loader::resolve_env_file(args.env_file.as_deref(), args.no_env_file, &cwd)?;
19    crate::env_loader::load_env_file_if_present(env_path.as_deref())?;
20
21    let path = match args.config {
22        Some(p) => p,
23        None => crate::env_loader::discover_config_path(&cwd).ok_or(CliError::NoConfigOrFromEnv)?,
24    };
25
26    if args.show_composed {
27        let composed = crate::compose::compose(&path, args.profile.as_deref())?;
28        // Normalize to exactly one trailing newline: the YAML serializer appends
29        // one but `serde_json::to_string_pretty` (JSON-format configs) does not,
30        // and the fast path echoes the file verbatim. A single `\n` keeps
31        // `faucet validate … --show-composed > out.{yaml,json}` well-formed.
32        println!("{}", composed.trim_end_matches('\n'));
33        return Ok(());
34    }
35
36    let cfg = if args.no_secrets {
37        // Grammar / structure only — never touch the network.
38        PipelineConfig::from_path_tolerating_secrets(&path, args.profile.as_deref())?
39    } else {
40        // Real preflight: report each secret reference, then resolve.
41        let refs = crate::secrets::scan_path_refs(&path, args.profile.as_deref())?;
42        let cfg = PipelineConfig::from_path_async(&path, args.profile.as_deref()).await?;
43        for (scheme, reference) in &refs {
44            println!("secret: {scheme}:{reference} → resolved");
45        }
46        cfg
47    };
48    let nodes = expand(&cfg)?;
49
50    // Validate the replication block (snapshot source / CDC source / state) so
51    // `faucet validate` catches misconfiguration without running.
52    if let Some(spec) = &cfg.replication {
53        crate::replication::compiled::CompiledReplication::compile(spec, &cfg)?;
54        println!("replication: mode={:?} — valid", spec.mode);
55    }
56
57    // Validate the backfill defaults block (window / concurrency / timezone)
58    // and the window-scoping requirement: a `backfill:` block on a pipeline
59    // whose sources reference no `${backfill.*}` / `${now.*}` token would
60    // replay identical data into every window (#282). Offline-safe.
61    if let Some(spec) = &cfg.backfill {
62        let source_configs: Vec<String> = nodes
63            .iter()
64            .filter(|n| matches!(n.role, crate::expand::NodeRole::Root))
65            .map(|n| n.source.config.to_string())
66            .collect();
67        spec.validate(&source_configs)?;
68        println!("backfill: defaults valid");
69    }
70
71    // Validate the schedule block (cron / timezone / bounds) so `faucet validate`
72    // catches schedule misconfiguration in CI without running. Offline-safe.
73    #[cfg(feature = "schedule")]
74    if let Some(spec) = &cfg.schedule {
75        crate::schedule::compiled::CompiledSchedule::compile(spec)?;
76        println!(
77            "schedule: cron '{}' tz '{}' — valid",
78            spec.cron, spec.timezone
79        );
80    }
81
82    // Validate the notifications block (unique names, non-empty channel fields)
83    // so `faucet validate` catches misconfiguration without running. Offline.
84    #[cfg(feature = "notify")]
85    if !cfg.notifications.is_empty() {
86        crate::notify::validate_all(&cfg.notifications)?;
87        println!("notifications: {} rule(s) — valid", cfg.notifications.len());
88    }
89
90    // Lineage transport reachability — best-effort. A failure here is only a
91    // warning: lineage emission never blocks a pipeline run.
92    #[cfg(feature = "lineage")]
93    if let Some(lc) = cfg.lineage.as_ref() {
94        match crate::lineage_glue::check_transport(lc).await {
95            Ok(msg) => println!("lineage: {msg}"),
96            Err(msg) => println!("lineage: WARNING — {msg} (lineage never blocks a run)"),
97        }
98    }
99
100    for node in &nodes {
101        // Verifying the schema lookup also catches unknown connector kinds.
102        source_schema(&node.source.kind)?;
103        sink_schema(&node.sink.kind)?;
104        for t in &node.transforms {
105            if !available_transforms().contains(&t.kind.as_str()) {
106                return Err(CliError::UnknownTransform {
107                    name: format!("{} (row '{}')", t.kind, node.id),
108                    available: available_transforms().join(", "),
109                });
110            }
111        }
112        if let Some(state) = &node.state
113            && !available_state_kinds().contains(&state.kind.as_str())
114        {
115            return Err(CliError::UnknownStateStore {
116                name: format!("{} (row '{}')", state.kind, node.id),
117                available: available_state_kinds().join(", "),
118            });
119        }
120    }
121
122    let roots = nodes
123        .iter()
124        .filter(|n| matches!(n.role, NodeRole::Root))
125        .count();
126    let children = nodes.len() - roots;
127    println!(
128        "ok: '{}' rows={} (roots={}, children={}) execution={}",
129        cfg.name.as_deref().unwrap_or("(unnamed)"),
130        nodes.len(),
131        roots,
132        children,
133        cfg.execution
134            .as_ref()
135            .map(|e| format!(
136                "max_concurrent={:?} on_error={:?}",
137                e.max_concurrent.unwrap_or(0),
138                e.on_error
139            ))
140            .unwrap_or_else(|| "(defaults)".to_owned()),
141    );
142    for node in &nodes {
143        println!("{}", row_line(node));
144    }
145    Ok(())
146}
147
148/// Render one per-row report line for `faucet validate` output.
149fn row_line(node: &crate::expand::ExpandedNode) -> String {
150    let role = match &node.role {
151        NodeRole::Root => "root".to_owned(),
152        NodeRole::Child {
153            parent_id,
154            parent_key,
155        } => {
156            format!("child of '{parent_id}' (parent_key={parent_key})")
157        }
158    };
159    let deps = if node.depends_on.is_empty() {
160        String::new()
161    } else {
162        format!(" depends_on=[{}]", node.depends_on.join(", "))
163    };
164    format!(
165        "  - {} [{}] source={} sink={}{} delivery={}",
166        node.id, role, node.source.kind, node.sink.kind, deps, node.delivery_guarantee
167    )
168}
169
170#[cfg(test)]
171mod tests {
172    use super::row_line;
173    use crate::expand::expand;
174
175    #[test]
176    fn row_line_renders_role_and_depends_on() {
177        let cfg = crate::config::parse_with_extension(
178            r#"
179version: 1
180pipeline:
181  source: { type: rest, config: {} }
182  sink:   { type: jsonl, config: { path: ./o } }
183matrix:
184  - id: dims
185  - id: posts
186    parent: dims
187    parent_key: id
188  - id: facts
189    depends_on: [dims]
190"#,
191            "yaml",
192        )
193        .unwrap();
194        let nodes = expand(&cfg).unwrap();
195        let line_for = |id: &str| row_line(nodes.iter().find(|n| n.id == id).unwrap());
196        assert_eq!(
197            line_for("dims"),
198            "  - dims [root] source=rest sink=jsonl delivery=at-least-once"
199        );
200        assert_eq!(
201            line_for("posts"),
202            "  - posts [child of 'dims' (parent_key=id)] source=rest sink=jsonl \
203             delivery=at-least-once"
204        );
205        assert_eq!(
206            line_for("facts"),
207            "  - facts [root] source=rest sink=jsonl depends_on=[dims] delivery=at-least-once"
208        );
209    }
210
211    #[test]
212    fn row_line_reports_derived_effectively_once_guarantees() {
213        // Keyed upsert is reported even when the user did not request
214        // `delivery: exactly_once` (truthful derived guarantee, #292)…
215        let cfg = crate::config::parse_with_extension(
216            r#"
217version: 1
218pipeline:
219  source: { type: rest, config: {} }
220  sink:
221    type: postgres
222    config:
223      connection_url: "postgres://localhost/db"
224      table_name: t
225      column_mapping: auto_map
226      write_mode: upsert
227      key: [id]
228"#,
229            "yaml",
230        )
231        .unwrap();
232        let nodes = expand(&cfg).unwrap();
233        assert!(
234            row_line(&nodes[0]).ends_with("delivery=effectively-once (keyed upsert)"),
235            "got: {}",
236            row_line(&nodes[0])
237        );
238
239        // …and the atomic-watermark mechanism is reported for a CDC → SQL
240        // exactly_once topology.
241        let cfg = crate::config::parse_with_extension(
242            r#"
243version: 1
244delivery: exactly_once
245pipeline:
246  source:
247    type: postgres-cdc
248    config: { connection_url: "postgres://localhost/db", slot: s, publication: p }
249  sink:
250    type: postgres
251    config:
252      connection_url: "postgres://localhost/db"
253      table_name: t
254      column_mapping: auto_map
255  state: { type: file, config: { path: ./state } }
256"#,
257            "yaml",
258        )
259        .unwrap();
260        let nodes = expand(&cfg).unwrap();
261        assert!(
262            row_line(&nodes[0]).ends_with("delivery=effectively-once (atomic watermark)"),
263            "got: {}",
264            row_line(&nodes[0])
265        );
266    }
267}