1use 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
16pub 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 println!("{}", composed.trim_end_matches('\n'));
35 return Ok(());
36 }
37
38 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 PipelineConfig::from_path_tolerating_secrets_with(&path, args.profile.as_deref(), &inputs)?
57 } else {
58 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 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 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 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 if let Some(spec) = &cfg.replication {
141 crate::replication::compiled::CompiledReplication::compile(spec, &cfg)?;
142 println!("replication: mode={:?} — valid", spec.mode);
143 }
144
145 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 #[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 #[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 #[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 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 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 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 selected?;
282 }
283 Ok(())
284}
285
286fn 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
308fn 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 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 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 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 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}