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