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 cfg = if args.no_secrets {
39 PipelineConfig::from_path_tolerating_secrets(&path, args.profile.as_deref())?
41 } else {
42 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 if crate::topology::is_topology(&cfg) {
54 let auth = crate::auth_catalog::build_auth_catalog(cfg.auth.as_ref())?;
55 let topo = crate::topology::build_topology(&cfg, &auth).await?;
56 println!(
57 "topology '{}': {} node(s), {} edge(s) — valid",
58 cfg.name.as_deref().unwrap_or("unnamed"),
59 topo.nodes().len(),
60 topo.edges().len()
61 );
62 for n in topo.nodes() {
63 println!(" - {} ({})", n.id, n.kind.kind_str());
64 }
65 return Ok(());
66 }
67
68 let nodes = expand(&cfg)?;
69
70 if let Some(spec) = &cfg.replication {
73 crate::replication::compiled::CompiledReplication::compile(spec, &cfg)?;
74 println!("replication: mode={:?} — valid", spec.mode);
75 }
76
77 if let Some(spec) = &cfg.backfill {
82 let source_configs: Vec<String> = nodes
83 .iter()
84 .filter(|n| matches!(n.role, crate::expand::NodeRole::Root))
85 .map(|n| n.source.config.to_string())
86 .collect();
87 spec.validate(&source_configs)?;
88 println!("backfill: defaults valid");
89 }
90
91 #[cfg(feature = "schedule")]
94 if let Some(spec) = &cfg.schedule {
95 crate::schedule::compiled::CompiledSchedule::compile(spec)?;
96 println!(
97 "schedule: cron '{}' tz '{}' — valid",
98 spec.cron, spec.timezone
99 );
100 }
101
102 #[cfg(feature = "notify")]
105 if !cfg.notifications.is_empty() {
106 crate::notify::validate_all(&cfg.notifications)?;
107 println!("notifications: {} rule(s) — valid", cfg.notifications.len());
108 }
109
110 #[cfg(feature = "lineage")]
113 if let Some(lc) = cfg.lineage.as_ref() {
114 match crate::lineage_glue::check_transport(lc).await {
115 Ok(msg) => println!("lineage: {msg}"),
116 Err(msg) => println!("lineage: WARNING — {msg} (lineage never blocks a run)"),
117 }
118 }
119
120 for node in &nodes {
121 source_schema(&node.source.kind)?;
123 sink_schema(&node.sink.kind)?;
124 for t in &node.transforms {
125 if !available_transforms().contains(&t.kind.as_str()) {
126 return Err(CliError::UnknownTransform {
127 name: format!("{} (row '{}')", t.kind, node.id),
128 available: available_transforms().join(", "),
129 });
130 }
131 }
132 if let Some(state) = &node.state
133 && !available_state_kinds().contains(&state.kind.as_str())
134 {
135 return Err(CliError::UnknownStateStore {
136 name: format!("{} (row '{}')", state.kind, node.id),
137 available: available_state_kinds().join(", "),
138 });
139 }
140 }
141
142 let roots = nodes
143 .iter()
144 .filter(|n| matches!(n.role, NodeRole::Root))
145 .count();
146 let children = nodes.len() - roots;
147 println!(
148 "ok: '{}' rows={} (roots={}, children={}) execution={}",
149 cfg.name.as_deref().unwrap_or("(unnamed)"),
150 nodes.len(),
151 roots,
152 children,
153 cfg.execution
154 .as_ref()
155 .map(|e| format!(
156 "max_concurrent={:?} on_error={:?}",
157 e.max_concurrent.unwrap_or(0),
158 e.on_error
159 ))
160 .unwrap_or_else(|| "(defaults)".to_owned()),
161 );
162 for node in &nodes {
163 println!("{}", row_line(node));
164 }
165
166 let selection = RunSelection::from_args(&args.selection, cfg.selection.as_ref())?;
174 let uses_selection_model = nodes
175 .iter()
176 .any(|n| n.status != SourceStatus::Active || !n.tags.is_empty());
177 if selection.narrows() || uses_selection_model {
178 let has_matrix = !cfg.matrix.is_empty();
179 let selected = crate::select::select_nodes(nodes.clone(), &selection, has_matrix);
180 let run_ids: HashSet<String> = match &selected {
181 Ok(sel) => sel.iter().map(|n| n.id.clone()).collect(),
182 Err(_) => HashSet::new(),
183 };
184 println!(
185 "run selection (include_parents={}):",
186 selection.include_parents.as_str()
187 );
188 for node in &nodes {
189 let decision = if run_ids.contains(&node.id) {
190 "RUN"
191 } else {
192 "skip"
193 };
194 let tags = if node.tags.is_empty() {
195 String::new()
196 } else {
197 format!(" tags=[{}]", node.tags.join(", "))
198 };
199 println!(
200 " - {} status={}{} -> {}",
201 node.id,
202 node.status.as_str(),
203 tags,
204 decision
205 );
206 }
207 selected?;
210 }
211 Ok(())
212}
213
214fn row_line(node: &crate::expand::ExpandedNode) -> String {
216 let role = match &node.role {
217 NodeRole::Root => "root".to_owned(),
218 NodeRole::Child {
219 parent_id,
220 parent_key,
221 } => {
222 format!("child of '{parent_id}' (parent_key={parent_key})")
223 }
224 };
225 let deps = if node.depends_on.is_empty() {
226 String::new()
227 } else {
228 format!(" depends_on=[{}]", node.depends_on.join(", "))
229 };
230 format!(
231 " - {} [{}] source={} sink={}{} delivery={}",
232 node.id, role, node.source.kind, node.sink.kind, deps, node.delivery_guarantee
233 )
234}
235
236#[cfg(test)]
237mod tests {
238 use super::row_line;
239 use crate::expand::expand;
240
241 #[test]
242 fn row_line_renders_role_and_depends_on() {
243 let cfg = crate::config::parse_with_extension(
244 r#"
245version: 1
246pipeline:
247 source: { type: rest, config: {} }
248 sink: { type: jsonl, config: { path: ./o } }
249matrix:
250 - id: dims
251 - id: posts
252 parent: dims
253 parent_key: id
254 - id: facts
255 depends_on: [dims]
256"#,
257 "yaml",
258 )
259 .unwrap();
260 let nodes = expand(&cfg).unwrap();
261 let line_for = |id: &str| row_line(nodes.iter().find(|n| n.id == id).unwrap());
262 assert_eq!(
263 line_for("dims"),
264 " - dims [root] source=rest sink=jsonl delivery=at-least-once"
265 );
266 assert_eq!(
267 line_for("posts"),
268 " - posts [child of 'dims' (parent_key=id)] source=rest sink=jsonl \
269 delivery=at-least-once"
270 );
271 assert_eq!(
272 line_for("facts"),
273 " - facts [root] source=rest sink=jsonl depends_on=[dims] delivery=at-least-once"
274 );
275 }
276
277 #[test]
278 fn row_line_reports_derived_effectively_once_guarantees() {
279 let cfg = crate::config::parse_with_extension(
282 r#"
283version: 1
284pipeline:
285 source: { type: rest, config: {} }
286 sink:
287 type: postgres
288 config:
289 connection_url: "postgres://localhost/db"
290 table_name: t
291 column_mapping: auto_map
292 write_mode: upsert
293 key: [id]
294"#,
295 "yaml",
296 )
297 .unwrap();
298 let nodes = expand(&cfg).unwrap();
299 assert!(
300 row_line(&nodes[0]).ends_with("delivery=effectively-once (keyed upsert)"),
301 "got: {}",
302 row_line(&nodes[0])
303 );
304
305 let cfg = crate::config::parse_with_extension(
308 r#"
309version: 1
310delivery: exactly_once
311pipeline:
312 source:
313 type: postgres-cdc
314 config: { connection_url: "postgres://localhost/db", slot: s, publication: p }
315 sink:
316 type: postgres
317 config:
318 connection_url: "postgres://localhost/db"
319 table_name: t
320 column_mapping: auto_map
321 state: { type: file, config: { path: ./state } }
322"#,
323 "yaml",
324 )
325 .unwrap();
326 let nodes = expand(&cfg).unwrap();
327 assert!(
328 row_line(&nodes[0]).ends_with("delivery=effectively-once (atomic watermark)"),
329 "got: {}",
330 row_line(&nodes[0])
331 );
332 }
333}