faucet_cli/commands/
validate.rs1use crate::cli::ValidateArgs;
7use crate::config::PipelineConfig;
8use crate::error::{CliError, CliResult};
9use crate::expand::{NodeRole, expand};
10use crate::registry::{sink_schema, source_schema};
11use crate::state::available_state_kinds;
12use crate::transforms::available_transforms;
13
14pub async fn run(args: ValidateArgs) -> CliResult<()> {
16 let cwd = std::env::current_dir()?;
17 let env_path =
18 crate::env_loader::resolve_env_file(args.env_file.as_deref(), args.no_env_file, &cwd)?;
19 crate::env_loader::load_env_file_if_present(env_path.as_deref())?;
20
21 let path = match args.config {
22 Some(p) => p,
23 None => crate::env_loader::discover_config_path(&cwd).ok_or(CliError::NoConfigOrFromEnv)?,
24 };
25
26 if args.show_composed {
27 let composed = crate::compose::compose(&path, args.profile.as_deref())?;
28 println!("{}", composed.trim_end_matches('\n'));
33 return Ok(());
34 }
35
36 let cfg = if args.no_secrets {
37 PipelineConfig::from_path_tolerating_secrets(&path, args.profile.as_deref())?
39 } else {
40 let refs = crate::secrets::scan_path_refs(&path, args.profile.as_deref())?;
42 let cfg = PipelineConfig::from_path_async(&path, args.profile.as_deref()).await?;
43 for (scheme, reference) in &refs {
44 println!("secret: {scheme}:{reference} → resolved");
45 }
46 cfg
47 };
48 let nodes = expand(&cfg)?;
49
50 if let Some(spec) = &cfg.replication {
53 crate::replication::compiled::CompiledReplication::compile(spec, &cfg)?;
54 println!("replication: mode={:?} — valid", spec.mode);
55 }
56
57 if let Some(spec) = &cfg.backfill {
62 let source_configs: Vec<String> = nodes
63 .iter()
64 .filter(|n| matches!(n.role, crate::expand::NodeRole::Root))
65 .map(|n| n.source.config.to_string())
66 .collect();
67 spec.validate(&source_configs)?;
68 println!("backfill: defaults valid");
69 }
70
71 #[cfg(feature = "schedule")]
74 if let Some(spec) = &cfg.schedule {
75 crate::schedule::compiled::CompiledSchedule::compile(spec)?;
76 println!(
77 "schedule: cron '{}' tz '{}' — valid",
78 spec.cron, spec.timezone
79 );
80 }
81
82 #[cfg(feature = "notify")]
85 if !cfg.notifications.is_empty() {
86 crate::notify::validate_all(&cfg.notifications)?;
87 println!("notifications: {} rule(s) — valid", cfg.notifications.len());
88 }
89
90 #[cfg(feature = "lineage")]
93 if let Some(lc) = cfg.lineage.as_ref() {
94 match crate::lineage_glue::check_transport(lc).await {
95 Ok(msg) => println!("lineage: {msg}"),
96 Err(msg) => println!("lineage: WARNING — {msg} (lineage never blocks a run)"),
97 }
98 }
99
100 for node in &nodes {
101 source_schema(&node.source.kind)?;
103 sink_schema(&node.sink.kind)?;
104 for t in &node.transforms {
105 if !available_transforms().contains(&t.kind.as_str()) {
106 return Err(CliError::UnknownTransform {
107 name: format!("{} (row '{}')", t.kind, node.id),
108 available: available_transforms().join(", "),
109 });
110 }
111 }
112 if let Some(state) = &node.state
113 && !available_state_kinds().contains(&state.kind.as_str())
114 {
115 return Err(CliError::UnknownStateStore {
116 name: format!("{} (row '{}')", state.kind, node.id),
117 available: available_state_kinds().join(", "),
118 });
119 }
120 }
121
122 let roots = nodes
123 .iter()
124 .filter(|n| matches!(n.role, NodeRole::Root))
125 .count();
126 let children = nodes.len() - roots;
127 println!(
128 "ok: '{}' rows={} (roots={}, children={}) execution={}",
129 cfg.name.as_deref().unwrap_or("(unnamed)"),
130 nodes.len(),
131 roots,
132 children,
133 cfg.execution
134 .as_ref()
135 .map(|e| format!(
136 "max_concurrent={:?} on_error={:?}",
137 e.max_concurrent.unwrap_or(0),
138 e.on_error
139 ))
140 .unwrap_or_else(|| "(defaults)".to_owned()),
141 );
142 for node in &nodes {
143 println!("{}", row_line(node));
144 }
145 Ok(())
146}
147
148fn row_line(node: &crate::expand::ExpandedNode) -> String {
150 let role = match &node.role {
151 NodeRole::Root => "root".to_owned(),
152 NodeRole::Child {
153 parent_id,
154 parent_key,
155 } => {
156 format!("child of '{parent_id}' (parent_key={parent_key})")
157 }
158 };
159 let deps = if node.depends_on.is_empty() {
160 String::new()
161 } else {
162 format!(" depends_on=[{}]", node.depends_on.join(", "))
163 };
164 format!(
165 " - {} [{}] source={} sink={}{} delivery={}",
166 node.id, role, node.source.kind, node.sink.kind, deps, node.delivery_guarantee
167 )
168}
169
170#[cfg(test)]
171mod tests {
172 use super::row_line;
173 use crate::expand::expand;
174
175 #[test]
176 fn row_line_renders_role_and_depends_on() {
177 let cfg = crate::config::parse_with_extension(
178 r#"
179version: 1
180pipeline:
181 source: { type: rest, config: {} }
182 sink: { type: jsonl, config: { path: ./o } }
183matrix:
184 - id: dims
185 - id: posts
186 parent: dims
187 parent_key: id
188 - id: facts
189 depends_on: [dims]
190"#,
191 "yaml",
192 )
193 .unwrap();
194 let nodes = expand(&cfg).unwrap();
195 let line_for = |id: &str| row_line(nodes.iter().find(|n| n.id == id).unwrap());
196 assert_eq!(
197 line_for("dims"),
198 " - dims [root] source=rest sink=jsonl delivery=at-least-once"
199 );
200 assert_eq!(
201 line_for("posts"),
202 " - posts [child of 'dims' (parent_key=id)] source=rest sink=jsonl \
203 delivery=at-least-once"
204 );
205 assert_eq!(
206 line_for("facts"),
207 " - facts [root] source=rest sink=jsonl depends_on=[dims] delivery=at-least-once"
208 );
209 }
210
211 #[test]
212 fn row_line_reports_derived_effectively_once_guarantees() {
213 let cfg = crate::config::parse_with_extension(
216 r#"
217version: 1
218pipeline:
219 source: { type: rest, config: {} }
220 sink:
221 type: postgres
222 config:
223 connection_url: "postgres://localhost/db"
224 table_name: t
225 column_mapping: auto_map
226 write_mode: upsert
227 key: [id]
228"#,
229 "yaml",
230 )
231 .unwrap();
232 let nodes = expand(&cfg).unwrap();
233 assert!(
234 row_line(&nodes[0]).ends_with("delivery=effectively-once (keyed upsert)"),
235 "got: {}",
236 row_line(&nodes[0])
237 );
238
239 let cfg = crate::config::parse_with_extension(
242 r#"
243version: 1
244delivery: exactly_once
245pipeline:
246 source:
247 type: postgres-cdc
248 config: { connection_url: "postgres://localhost/db", slot: s, publication: p }
249 sink:
250 type: postgres
251 config:
252 connection_url: "postgres://localhost/db"
253 table_name: t
254 column_mapping: auto_map
255 state: { type: file, config: { path: ./state } }
256"#,
257 "yaml",
258 )
259 .unwrap();
260 let nodes = expand(&cfg).unwrap();
261 assert!(
262 row_line(&nodes[0]).ends_with("delivery=effectively-once (atomic watermark)"),
263 "got: {}",
264 row_line(&nodes[0])
265 );
266 }
267}