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 #[cfg(feature = "schedule")]
60 if let Some(spec) = &cfg.schedule {
61 crate::schedule::compiled::CompiledSchedule::compile(spec)?;
62 println!(
63 "schedule: cron '{}' tz '{}' — valid",
64 spec.cron, spec.timezone
65 );
66 }
67
68 #[cfg(feature = "notify")]
71 if !cfg.notifications.is_empty() {
72 crate::notify::validate_all(&cfg.notifications)?;
73 println!("notifications: {} rule(s) — valid", cfg.notifications.len());
74 }
75
76 #[cfg(feature = "lineage")]
79 if let Some(lc) = cfg.lineage.as_ref() {
80 match crate::lineage_glue::check_transport(lc).await {
81 Ok(msg) => println!("lineage: {msg}"),
82 Err(msg) => println!("lineage: WARNING — {msg} (lineage never blocks a run)"),
83 }
84 }
85
86 for node in &nodes {
87 source_schema(&node.source.kind)?;
89 sink_schema(&node.sink.kind)?;
90 for t in &node.transforms {
91 if !available_transforms().contains(&t.kind.as_str()) {
92 return Err(CliError::UnknownTransform {
93 name: format!("{} (row '{}')", t.kind, node.id),
94 available: available_transforms().join(", "),
95 });
96 }
97 }
98 if let Some(state) = &node.state
99 && !available_state_kinds().contains(&state.kind.as_str())
100 {
101 return Err(CliError::UnknownStateStore {
102 name: format!("{} (row '{}')", state.kind, node.id),
103 available: available_state_kinds().join(", "),
104 });
105 }
106 }
107
108 let roots = nodes
109 .iter()
110 .filter(|n| matches!(n.role, NodeRole::Root))
111 .count();
112 let children = nodes.len() - roots;
113 println!(
114 "ok: '{}' rows={} (roots={}, children={}) execution={}",
115 cfg.name.as_deref().unwrap_or("(unnamed)"),
116 nodes.len(),
117 roots,
118 children,
119 cfg.execution
120 .as_ref()
121 .map(|e| format!(
122 "max_concurrent={:?} on_error={:?}",
123 e.max_concurrent.unwrap_or(0),
124 e.on_error
125 ))
126 .unwrap_or_else(|| "(defaults)".to_owned()),
127 );
128 for node in &nodes {
129 println!("{}", row_line(node));
130 }
131 Ok(())
132}
133
134fn row_line(node: &crate::expand::ExpandedNode) -> String {
136 let role = match &node.role {
137 NodeRole::Root => "root".to_owned(),
138 NodeRole::Child {
139 parent_id,
140 parent_key,
141 } => {
142 format!("child of '{parent_id}' (parent_key={parent_key})")
143 }
144 };
145 let deps = if node.depends_on.is_empty() {
146 String::new()
147 } else {
148 format!(" depends_on=[{}]", node.depends_on.join(", "))
149 };
150 format!(
151 " - {} [{}] source={} sink={}{} delivery={}",
152 node.id, role, node.source.kind, node.sink.kind, deps, node.delivery_guarantee
153 )
154}
155
156#[cfg(test)]
157mod tests {
158 use super::row_line;
159 use crate::expand::expand;
160
161 #[test]
162 fn row_line_renders_role_and_depends_on() {
163 let cfg = crate::config::parse_with_extension(
164 r#"
165version: 1
166pipeline:
167 source: { type: rest, config: {} }
168 sink: { type: jsonl, config: { path: ./o } }
169matrix:
170 - id: dims
171 - id: posts
172 parent: dims
173 parent_key: id
174 - id: facts
175 depends_on: [dims]
176"#,
177 "yaml",
178 )
179 .unwrap();
180 let nodes = expand(&cfg).unwrap();
181 let line_for = |id: &str| row_line(nodes.iter().find(|n| n.id == id).unwrap());
182 assert_eq!(
183 line_for("dims"),
184 " - dims [root] source=rest sink=jsonl delivery=at-least-once"
185 );
186 assert_eq!(
187 line_for("posts"),
188 " - posts [child of 'dims' (parent_key=id)] source=rest sink=jsonl \
189 delivery=at-least-once"
190 );
191 assert_eq!(
192 line_for("facts"),
193 " - facts [root] source=rest sink=jsonl depends_on=[dims] delivery=at-least-once"
194 );
195 }
196
197 #[test]
198 fn row_line_reports_derived_effectively_once_guarantees() {
199 let cfg = crate::config::parse_with_extension(
202 r#"
203version: 1
204pipeline:
205 source: { type: rest, config: {} }
206 sink:
207 type: postgres
208 config:
209 connection_url: "postgres://localhost/db"
210 table_name: t
211 column_mapping: auto_map
212 write_mode: upsert
213 key: [id]
214"#,
215 "yaml",
216 )
217 .unwrap();
218 let nodes = expand(&cfg).unwrap();
219 assert!(
220 row_line(&nodes[0]).ends_with("delivery=effectively-once (keyed upsert)"),
221 "got: {}",
222 row_line(&nodes[0])
223 );
224
225 let cfg = crate::config::parse_with_extension(
228 r#"
229version: 1
230delivery: exactly_once
231pipeline:
232 source:
233 type: postgres-cdc
234 config: { connection_url: "postgres://localhost/db", slot: s, publication: p }
235 sink:
236 type: postgres
237 config:
238 connection_url: "postgres://localhost/db"
239 table_name: t
240 column_mapping: auto_map
241 state: { type: file, config: { path: ./state } }
242"#,
243 "yaml",
244 )
245 .unwrap();
246 let nodes = expand(&cfg).unwrap();
247 assert!(
248 row_line(&nodes[0]).ends_with("delivery=effectively-once (atomic watermark)"),
249 "got: {}",
250 row_line(&nodes[0])
251 );
252 }
253}