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 if !matches!(node.role, NodeRole::Discovery { .. }) {
229 sink_schema(&node.sink.kind)?;
230 }
231 for t in &node.transforms {
232 if !available_transforms().contains(&t.kind.as_str()) {
233 return Err(CliError::UnknownTransform {
234 name: format!("{} (row '{}')", t.kind, node.id),
235 available: available_transforms().join(", "),
236 });
237 }
238 }
239 if let Some(state) = &node.state
240 && !available_state_kinds().contains(&state.kind.as_str())
241 {
242 return Err(CliError::UnknownStateStore {
243 name: format!("{} (row '{}')", state.kind, node.id),
244 available: available_state_kinds().join(", "),
245 });
246 }
247 }
248
249 check_transforms(&nodes)?;
252
253 let children = nodes
256 .iter()
257 .filter(|n| matches!(n.role, NodeRole::Child { .. }))
258 .count();
259 let roots = nodes.len() - children;
260
261 let selection = RunSelection::from_args(&args.selection, cfg.selection.as_ref())?;
267 let uses_selection_model = nodes
268 .iter()
269 .any(|n| n.status != SourceStatus::Active || !n.tags.is_empty());
270 let selection_active = selection.narrows() || uses_selection_model;
271 let has_matrix = !cfg.matrix.is_empty();
272 let selected = if selection_active {
273 Some(crate::select::select_nodes(
274 nodes.clone(),
275 &selection,
276 has_matrix,
277 ))
278 } else {
279 None
280 };
281 let run_ids: HashSet<String> = match &selected {
282 Some(Ok(sel)) => sel.iter().map(|n| n.id.clone()).collect(),
283 _ => HashSet::new(),
284 };
285 let decision_for = |node: &crate::expand::ExpandedNode| -> Option<&'static str> {
286 selection_active.then(|| {
287 if run_ids.contains(&node.id) {
288 "run"
289 } else {
290 "skip"
291 }
292 })
293 };
294
295 if args.json {
296 let rows: Vec<serde_json::Value> = nodes
297 .iter()
298 .map(|node| {
299 let (role, parent_id, parent_key) = match &node.role {
300 NodeRole::Root => ("root", None, None),
301 NodeRole::Child {
302 parent_id,
303 parent_key,
304 } => ("child", Some(parent_id.clone()), Some(parent_key.clone())),
305 NodeRole::Discovery { .. } => ("discovery", None, None),
306 NodeRole::Product { .. } => ("product", None, None),
307 };
308 serde_json::json!({
309 "id": node.id,
310 "source": node.source.kind,
311 "sink": node.sink.kind,
312 "role": role,
313 "parent_id": parent_id,
314 "parent_key": parent_key,
315 "depends_on": &node.depends_on,
316 "delivery": node.delivery_guarantee.to_string(),
317 "status": node.status.as_str(),
318 "tags": &node.tags,
319 "decision": decision_for(node),
320 })
321 })
322 .collect();
323 let out = serde_json::json!({
324 "valid": true,
325 "mode": "matrix",
326 "name": cfg.name.as_deref().unwrap_or("(unnamed)"),
327 "row_count": nodes.len(),
328 "roots": roots,
329 "children": children,
330 "selection_active": selection_active,
331 "rows": rows,
332 });
333 println!(
334 "{}",
335 serde_json::to_string_pretty(&out).unwrap_or_else(|_| out.to_string())
336 );
337 if let Some(sel) = selected {
340 sel?;
341 }
342 return Ok(());
343 }
344
345 println!(
346 "ok: '{}' rows={} (roots={}, children={}) execution={}",
347 cfg.name.as_deref().unwrap_or("(unnamed)"),
348 nodes.len(),
349 roots,
350 children,
351 cfg.execution
352 .as_ref()
353 .map(|e| format!(
354 "max_concurrent={:?} on_error={:?}",
355 e.max_concurrent.unwrap_or(0),
356 e.on_error
357 ))
358 .unwrap_or_else(|| "(defaults)".to_owned()),
359 );
360 for node in &nodes {
361 println!("{}", row_line(node));
362 }
363
364 if selection_active {
368 println!(
369 "run selection (include_parents={}):",
370 selection.include_parents.as_str()
371 );
372 for node in &nodes {
373 let decision = if run_ids.contains(&node.id) {
374 "RUN"
375 } else {
376 "skip"
377 };
378 let tags = if node.tags.is_empty() {
379 String::new()
380 } else {
381 format!(" tags=[{}]", node.tags.join(", "))
382 };
383 println!(
384 " - {} status={}{} -> {}",
385 node.id,
386 node.status.as_str(),
387 tags,
388 decision
389 );
390 }
391 if let Some(sel) = selected {
394 sel?;
395 }
396 }
397 Ok(())
398}
399
400fn row_line(node: &crate::expand::ExpandedNode) -> String {
402 let role = match &node.role {
403 NodeRole::Root => "root".to_owned(),
404 NodeRole::Child {
405 parent_id,
406 parent_key,
407 } => {
408 format!("child of '{parent_id}' (parent_key={parent_key})")
409 }
410 NodeRole::Discovery { as_alias, .. } => {
411 format!("discovery (as={as_alias})")
412 }
413 NodeRole::Product { dims, .. } => {
414 format!("product of [{}]", dims.join(", "))
415 }
416 };
417 let deps = if node.depends_on.is_empty() {
418 String::new()
419 } else {
420 format!(" depends_on=[{}]", node.depends_on.join(", "))
421 };
422 format!(
423 " - {} [{}] source={} sink={}{} delivery={}",
424 node.id, role, node.source.kind, node.sink.kind, deps, node.delivery_guarantee
425 )
426}
427
428fn check_transforms(nodes: &[crate::expand::ExpandedNode]) -> CliResult<()> {
435 for n in nodes {
436 if n.transforms.is_empty() {
437 continue;
438 }
439 crate::transforms::compile_transforms(&n.transforms)
440 .map_err(|e| CliError::Config(format!("row '{}': {e}", n.id)))?;
441 }
442 Ok(())
443}
444
445#[cfg(test)]
446mod tests {
447 use super::{check_transforms, row_line};
448 use crate::expand::expand;
449
450 #[test]
451 fn row_line_renders_role_and_depends_on() {
452 let cfg = crate::config::parse_with_extension(
453 r#"
454version: 1
455pipeline:
456 source: { type: rest, config: {} }
457 sink: { type: jsonl, config: { path: ./o } }
458matrix:
459 - id: dims
460 - id: posts
461 parent: dims
462 parent_key: id
463 - id: facts
464 depends_on: [dims]
465"#,
466 "yaml",
467 )
468 .unwrap();
469 let nodes = expand(&cfg).unwrap();
470 let line_for = |id: &str| row_line(nodes.iter().find(|n| n.id == id).unwrap());
471 assert_eq!(
472 line_for("dims"),
473 " - dims [root] source=rest sink=jsonl delivery=at-least-once"
474 );
475 assert_eq!(
476 line_for("posts"),
477 " - posts [child of 'dims' (parent_key=id)] source=rest sink=jsonl \
478 delivery=at-least-once"
479 );
480 assert_eq!(
481 line_for("facts"),
482 " - facts [root] source=rest sink=jsonl depends_on=[dims] delivery=at-least-once"
483 );
484 }
485
486 #[test]
487 fn row_line_reports_derived_effectively_once_guarantees() {
488 let cfg = crate::config::parse_with_extension(
491 r#"
492version: 1
493pipeline:
494 source: { type: rest, config: {} }
495 sink:
496 type: postgres
497 config:
498 connection_url: "postgres://localhost/db"
499 table_name: t
500 column_mapping: auto_map
501 write_mode: upsert
502 key: [id]
503"#,
504 "yaml",
505 )
506 .unwrap();
507 let nodes = expand(&cfg).unwrap();
508 assert!(
509 row_line(&nodes[0]).ends_with("delivery=effectively-once (keyed upsert)"),
510 "got: {}",
511 row_line(&nodes[0])
512 );
513
514 let cfg = crate::config::parse_with_extension(
517 r#"
518version: 1
519delivery: exactly_once
520pipeline:
521 source:
522 type: postgres-cdc
523 config: { connection_url: "postgres://localhost/db", slot: s, publication: p }
524 sink:
525 type: postgres
526 config:
527 connection_url: "postgres://localhost/db"
528 table_name: t
529 column_mapping: auto_map
530 state: { type: file, config: { path: ./state } }
531"#,
532 "yaml",
533 )
534 .unwrap();
535 let nodes = expand(&cfg).unwrap();
536 assert!(
537 row_line(&nodes[0]).ends_with("delivery=effectively-once (atomic watermark)"),
538 "got: {}",
539 row_line(&nodes[0])
540 );
541 }
542
543 #[test]
544 fn transform_chains_are_compiled_not_just_shape_checked() {
545 let cfg = crate::config::parse_with_extension(
548 r#"
549version: 1
550pipeline:
551 source: { type: rest, config: {} }
552 transforms:
553 - type: set
554 config: { fields: { a: 1 } }
555 sink: { type: jsonl, config: { path: ./o } }
556matrix:
557 - id: rowA
558"#,
559 "yaml",
560 )
561 .unwrap();
562 let err = check_transforms(&expand(&cfg).unwrap())
563 .unwrap_err()
564 .to_string();
565 assert!(err.contains("rowA"), "names the row: {err}");
566 assert!(err.contains("values"), "names the missing field: {err}");
567
568 let cfg = crate::config::parse_with_extension(
570 r#"
571version: 1
572pipeline:
573 source: { type: rest, config: {} }
574 transforms:
575 - type: set
576 config: { values: { a: 1 } }
577 sink: { type: jsonl, config: { path: ./o } }
578"#,
579 "yaml",
580 )
581 .unwrap();
582 check_transforms(&expand(&cfg).unwrap()).unwrap();
583 }
584}