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 if !args.json {
63 for (scheme, reference) in &refs {
64 println!("secret: {scheme}:{reference} → resolved");
65 }
66 }
67 cfg
68 };
69 if !cfg.params.is_empty() && !args.json {
70 let required: Vec<&str> = cfg
71 .params
72 .iter()
73 .filter(|(_, p)| p.required)
74 .map(|(n, _)| n.as_str())
75 .collect();
76 println!(
77 "params: {} declared ({}){}",
78 cfg.params.len(),
79 if required.is_empty() {
80 String::from("all optional")
81 } else {
82 format!("required: {}", required.join(", "))
83 },
84 if args.param.is_empty() && !required.is_empty() {
85 " — validated against placeholders; pass --param NAME=VALUE to bind for real"
86 } else {
87 ""
88 }
89 );
90 }
91 if crate::topology::is_topology(&cfg) {
95 let auth = crate::auth_catalog::build_auth_catalog(cfg.auth.as_ref())?;
96 let topo = crate::topology::build_topology(&cfg, &auth).await?;
97 let inert: Vec<(&str, &str)> = crate::topology::inert_blocks(&cfg);
98 if args.json {
99 let out = serde_json::json!({
100 "valid": true,
101 "mode": "topology",
102 "name": cfg.name.as_deref().unwrap_or("unnamed"),
103 "node_count": topo.nodes().len(),
104 "edge_count": topo.edges().len(),
105 "nodes": topo.nodes().iter()
106 .map(|n| serde_json::json!({ "id": n.id, "kind": n.kind.kind_str() }))
107 .collect::<Vec<_>>(),
108 "warnings": inert.iter()
109 .map(|(block, consequence)| serde_json::json!({
110 "block": block, "consequence": consequence,
111 }))
112 .collect::<Vec<_>>(),
113 });
114 println!(
115 "{}",
116 serde_json::to_string_pretty(&out).unwrap_or_else(|_| out.to_string())
117 );
118 return Ok(());
119 }
120 println!(
121 "topology '{}': {} node(s), {} edge(s) — valid",
122 cfg.name.as_deref().unwrap_or("unnamed"),
123 topo.nodes().len(),
124 topo.edges().len()
125 );
126 for n in topo.nodes() {
127 println!(" - {} ({})", n.id, n.kind.kind_str());
128 }
129 for (block, consequence) in &inert {
133 println!(" WARNING: `{block}:` is ignored in topology mode — {consequence}");
134 }
135 return Ok(());
136 }
137
138 let unprobed: Vec<String> = std::iter::once(("<pipeline>", cfg.partition.as_ref()))
141 .chain(
142 cfg.matrix
143 .iter()
144 .map(|r| (r.id.as_deref().unwrap_or("<row>"), r.partition.as_ref())),
145 )
146 .filter_map(|(id, p)| {
147 p.filter(|s| crate::partition::needs_probe(s))
148 .map(|_| id.to_string())
149 })
150 .collect();
151
152 let nodes = expand(&cfg)?;
153
154 if !unprobed.is_empty() && !args.json {
155 println!(
156 "partition: {} row(s) discover their bound at run time ({}) — the chunk count \
157 cannot be planned offline, so it is not validated here",
158 unprobed.len(),
159 unprobed.join(", ")
160 );
161 }
162
163 if let Some(spec) = &cfg.replication {
166 crate::replication::compiled::CompiledReplication::compile(spec, &cfg)?;
167 if !args.json {
168 println!("replication: mode={:?} — valid", spec.mode);
169 }
170 }
171
172 if let Some(spec) = &cfg.backfill {
177 let source_configs: Vec<String> = nodes
178 .iter()
179 .filter(|n| matches!(n.role, crate::expand::NodeRole::Root))
180 .map(|n| n.source.config.to_string())
181 .collect();
182 spec.validate(&source_configs)?;
183 if !args.json {
184 println!("backfill: defaults valid");
185 }
186 }
187
188 #[cfg(feature = "schedule")]
191 if let Some(spec) = &cfg.schedule {
192 crate::schedule::compiled::CompiledSchedule::compile(spec)?;
193 if !args.json {
194 println!(
195 "schedule: cron '{}' tz '{}' — valid",
196 spec.cron, spec.timezone
197 );
198 }
199 }
200
201 #[cfg(feature = "notify")]
204 if !cfg.notifications.is_empty() {
205 crate::notify::validate_all(&cfg.notifications)?;
206 if !args.json {
207 println!("notifications: {} rule(s) — valid", cfg.notifications.len());
208 }
209 }
210
211 #[cfg(feature = "lineage")]
214 if let Some(lc) = cfg.lineage.as_ref()
215 && !args.json
216 {
217 match crate::lineage_glue::check_transport(lc).await {
218 Ok(msg) => println!("lineage: {msg}"),
219 Err(msg) => println!("lineage: WARNING — {msg} (lineage never blocks a run)"),
220 }
221 }
222
223 for node in &nodes {
224 source_schema(&node.source.kind)?;
226 sink_schema(&node.sink.kind)?;
227 for t in &node.transforms {
228 if !available_transforms().contains(&t.kind.as_str()) {
229 return Err(CliError::UnknownTransform {
230 name: format!("{} (row '{}')", t.kind, node.id),
231 available: available_transforms().join(", "),
232 });
233 }
234 }
235 if let Some(state) = &node.state
236 && !available_state_kinds().contains(&state.kind.as_str())
237 {
238 return Err(CliError::UnknownStateStore {
239 name: format!("{} (row '{}')", state.kind, node.id),
240 available: available_state_kinds().join(", "),
241 });
242 }
243 }
244
245 check_transforms(&nodes)?;
248
249 let roots = nodes
250 .iter()
251 .filter(|n| matches!(n.role, NodeRole::Root))
252 .count();
253 let children = nodes.len() - roots;
254
255 let selection = RunSelection::from_args(&args.selection, cfg.selection.as_ref())?;
261 let uses_selection_model = nodes
262 .iter()
263 .any(|n| n.status != SourceStatus::Active || !n.tags.is_empty());
264 let selection_active = selection.narrows() || uses_selection_model;
265 let has_matrix = !cfg.matrix.is_empty();
266 let selected = if selection_active {
267 Some(crate::select::select_nodes(
268 nodes.clone(),
269 &selection,
270 has_matrix,
271 ))
272 } else {
273 None
274 };
275 let run_ids: HashSet<String> = match &selected {
276 Some(Ok(sel)) => sel.iter().map(|n| n.id.clone()).collect(),
277 _ => HashSet::new(),
278 };
279 let decision_for = |node: &crate::expand::ExpandedNode| -> Option<&'static str> {
280 selection_active.then(|| {
281 if run_ids.contains(&node.id) {
282 "run"
283 } else {
284 "skip"
285 }
286 })
287 };
288
289 if args.json {
290 let rows: Vec<serde_json::Value> = nodes
291 .iter()
292 .map(|node| {
293 let (role, parent_id, parent_key) = match &node.role {
294 NodeRole::Root => ("root", None, None),
295 NodeRole::Child {
296 parent_id,
297 parent_key,
298 } => ("child", Some(parent_id.clone()), Some(parent_key.clone())),
299 };
300 serde_json::json!({
301 "id": node.id,
302 "source": node.source.kind,
303 "sink": node.sink.kind,
304 "role": role,
305 "parent_id": parent_id,
306 "parent_key": parent_key,
307 "depends_on": &node.depends_on,
308 "delivery": node.delivery_guarantee.to_string(),
309 "status": node.status.as_str(),
310 "tags": &node.tags,
311 "decision": decision_for(node),
312 })
313 })
314 .collect();
315 let out = serde_json::json!({
316 "valid": true,
317 "mode": "matrix",
318 "name": cfg.name.as_deref().unwrap_or("(unnamed)"),
319 "row_count": nodes.len(),
320 "roots": roots,
321 "children": children,
322 "selection_active": selection_active,
323 "rows": rows,
324 });
325 println!(
326 "{}",
327 serde_json::to_string_pretty(&out).unwrap_or_else(|_| out.to_string())
328 );
329 if let Some(sel) = selected {
332 sel?;
333 }
334 return Ok(());
335 }
336
337 println!(
338 "ok: '{}' rows={} (roots={}, children={}) execution={}",
339 cfg.name.as_deref().unwrap_or("(unnamed)"),
340 nodes.len(),
341 roots,
342 children,
343 cfg.execution
344 .as_ref()
345 .map(|e| format!(
346 "max_concurrent={:?} on_error={:?}",
347 e.max_concurrent.unwrap_or(0),
348 e.on_error
349 ))
350 .unwrap_or_else(|| "(defaults)".to_owned()),
351 );
352 for node in &nodes {
353 println!("{}", row_line(node));
354 }
355
356 if selection_active {
360 println!(
361 "run selection (include_parents={}):",
362 selection.include_parents.as_str()
363 );
364 for node in &nodes {
365 let decision = if run_ids.contains(&node.id) {
366 "RUN"
367 } else {
368 "skip"
369 };
370 let tags = if node.tags.is_empty() {
371 String::new()
372 } else {
373 format!(" tags=[{}]", node.tags.join(", "))
374 };
375 println!(
376 " - {} status={}{} -> {}",
377 node.id,
378 node.status.as_str(),
379 tags,
380 decision
381 );
382 }
383 if let Some(sel) = selected {
386 sel?;
387 }
388 }
389 Ok(())
390}
391
392fn row_line(node: &crate::expand::ExpandedNode) -> String {
394 let role = match &node.role {
395 NodeRole::Root => "root".to_owned(),
396 NodeRole::Child {
397 parent_id,
398 parent_key,
399 } => {
400 format!("child of '{parent_id}' (parent_key={parent_key})")
401 }
402 };
403 let deps = if node.depends_on.is_empty() {
404 String::new()
405 } else {
406 format!(" depends_on=[{}]", node.depends_on.join(", "))
407 };
408 format!(
409 " - {} [{}] source={} sink={}{} delivery={}",
410 node.id, role, node.source.kind, node.sink.kind, deps, node.delivery_guarantee
411 )
412}
413
414fn check_transforms(nodes: &[crate::expand::ExpandedNode]) -> CliResult<()> {
421 for n in nodes {
422 if n.transforms.is_empty() {
423 continue;
424 }
425 crate::transforms::compile_transforms(&n.transforms)
426 .map_err(|e| CliError::Config(format!("row '{}': {e}", n.id)))?;
427 }
428 Ok(())
429}
430
431#[cfg(test)]
432mod tests {
433 use super::{check_transforms, row_line};
434 use crate::expand::expand;
435
436 #[test]
437 fn row_line_renders_role_and_depends_on() {
438 let cfg = crate::config::parse_with_extension(
439 r#"
440version: 1
441pipeline:
442 source: { type: rest, config: {} }
443 sink: { type: jsonl, config: { path: ./o } }
444matrix:
445 - id: dims
446 - id: posts
447 parent: dims
448 parent_key: id
449 - id: facts
450 depends_on: [dims]
451"#,
452 "yaml",
453 )
454 .unwrap();
455 let nodes = expand(&cfg).unwrap();
456 let line_for = |id: &str| row_line(nodes.iter().find(|n| n.id == id).unwrap());
457 assert_eq!(
458 line_for("dims"),
459 " - dims [root] source=rest sink=jsonl delivery=at-least-once"
460 );
461 assert_eq!(
462 line_for("posts"),
463 " - posts [child of 'dims' (parent_key=id)] source=rest sink=jsonl \
464 delivery=at-least-once"
465 );
466 assert_eq!(
467 line_for("facts"),
468 " - facts [root] source=rest sink=jsonl depends_on=[dims] delivery=at-least-once"
469 );
470 }
471
472 #[test]
473 fn row_line_reports_derived_effectively_once_guarantees() {
474 let cfg = crate::config::parse_with_extension(
477 r#"
478version: 1
479pipeline:
480 source: { type: rest, config: {} }
481 sink:
482 type: postgres
483 config:
484 connection_url: "postgres://localhost/db"
485 table_name: t
486 column_mapping: auto_map
487 write_mode: upsert
488 key: [id]
489"#,
490 "yaml",
491 )
492 .unwrap();
493 let nodes = expand(&cfg).unwrap();
494 assert!(
495 row_line(&nodes[0]).ends_with("delivery=effectively-once (keyed upsert)"),
496 "got: {}",
497 row_line(&nodes[0])
498 );
499
500 let cfg = crate::config::parse_with_extension(
503 r#"
504version: 1
505delivery: exactly_once
506pipeline:
507 source:
508 type: postgres-cdc
509 config: { connection_url: "postgres://localhost/db", slot: s, publication: p }
510 sink:
511 type: postgres
512 config:
513 connection_url: "postgres://localhost/db"
514 table_name: t
515 column_mapping: auto_map
516 state: { type: file, config: { path: ./state } }
517"#,
518 "yaml",
519 )
520 .unwrap();
521 let nodes = expand(&cfg).unwrap();
522 assert!(
523 row_line(&nodes[0]).ends_with("delivery=effectively-once (atomic watermark)"),
524 "got: {}",
525 row_line(&nodes[0])
526 );
527 }
528
529 #[test]
530 fn transform_chains_are_compiled_not_just_shape_checked() {
531 let cfg = crate::config::parse_with_extension(
534 r#"
535version: 1
536pipeline:
537 source: { type: rest, config: {} }
538 transforms:
539 - type: set
540 config: { fields: { a: 1 } }
541 sink: { type: jsonl, config: { path: ./o } }
542matrix:
543 - id: rowA
544"#,
545 "yaml",
546 )
547 .unwrap();
548 let err = check_transforms(&expand(&cfg).unwrap())
549 .unwrap_err()
550 .to_string();
551 assert!(err.contains("rowA"), "names the row: {err}");
552 assert!(err.contains("values"), "names the missing field: {err}");
553
554 let cfg = crate::config::parse_with_extension(
556 r#"
557version: 1
558pipeline:
559 source: { type: rest, config: {} }
560 transforms:
561 - type: set
562 config: { values: { a: 1 } }
563 sink: { type: jsonl, config: { path: ./o } }
564"#,
565 "yaml",
566 )
567 .unwrap();
568 check_transforms(&expand(&cfg).unwrap()).unwrap();
569 }
570}