1use crate::auth_catalog;
13use crate::cli::PlanArgs;
14use crate::error::{CliError, CliResult};
15use crate::expand::{self, ExpandedNode, NodeRole};
16use crate::pipeline_test::runner::{ResolvedCase, run_case};
17use serde::Serialize;
18use serde_json::Value;
19
20#[derive(Debug, Serialize)]
22pub struct PlanReport {
23 pub row: String,
24 pub source: String,
25 pub sink: String,
26 pub write_mode: String,
27 pub transforms: Vec<String>,
28 pub delivery_guarantee: String,
29 pub quality: bool,
30 pub contract: bool,
31 pub masking: bool,
32 pub schema_drift: Option<String>,
33 #[serde(skip_serializing_if = "Vec::is_empty")]
34 pub lineage: Vec<String>,
35 pub sink_probe: Option<String>,
36 #[serde(skip_serializing_if = "Option::is_none")]
37 pub sample: Option<SampleReport>,
38}
39
40#[derive(Debug, Serialize)]
42pub struct SampleReport {
43 pub source: String,
44 pub input_records: usize,
45 pub output_records: usize,
46 pub dlq_records: usize,
47 pub inferred_schema: Value,
48 #[serde(skip_serializing_if = "Option::is_none")]
49 pub sink_schema: Option<Value>,
50 pub schema_delta: SchemaDeltaReport,
51 #[serde(skip_serializing_if = "Option::is_none")]
52 pub error: Option<String>,
53}
54
55#[derive(Debug, Serialize)]
57pub struct SchemaDeltaReport {
58 pub schemaless_sink: bool,
59 pub additions: Vec<String>,
60 pub widenings: Vec<String>,
61 pub incompatible: Vec<String>,
62 pub droppable_required: Vec<String>,
63}
64
65pub fn build_plan_report(node: &ExpandedNode) -> PlanReport {
67 let write_mode = node
68 .sink
69 .config
70 .get("write_mode")
71 .and_then(Value::as_str)
72 .unwrap_or("append")
73 .to_owned();
74 PlanReport {
75 row: node.id.clone(),
76 source: node.source.kind.clone(),
77 sink: node.sink.kind.clone(),
78 write_mode,
79 transforms: node.transforms.iter().map(|t| t.kind.clone()).collect(),
80 delivery_guarantee: format!("{:?}", node.delivery_guarantee),
81 quality: quality_present(node),
82 contract: contract_present(node),
83 masking: masking_present(node),
84 schema_drift: node.schema.as_ref().map(|s| format!("{:?}", s.on_drift)),
85 lineage: Vec::new(),
86 sink_probe: None,
87 sample: None,
88 }
89}
90
91#[cfg(feature = "quality")]
92fn quality_present(node: &ExpandedNode) -> bool {
93 node.quality.is_some()
94}
95#[cfg(not(feature = "quality"))]
96fn quality_present(_node: &ExpandedNode) -> bool {
97 false
98}
99#[cfg(feature = "contract")]
100fn contract_present(node: &ExpandedNode) -> bool {
101 node.contract.is_some()
102}
103#[cfg(not(feature = "contract"))]
104fn contract_present(_node: &ExpandedNode) -> bool {
105 false
106}
107#[cfg(feature = "masking")]
108fn masking_present(node: &ExpandedNode) -> bool {
109 node.masking.is_some()
110}
111#[cfg(not(feature = "masking"))]
112fn masking_present(_node: &ExpandedNode) -> bool {
113 false
114}
115
116async fn load_sample(
119 args: &PlanArgs,
120 node: &ExpandedNode,
121 auth: &auth_catalog::AuthCatalog,
122) -> CliResult<Option<Vec<Value>>> {
123 if let Some(path) = &args.sample {
124 return Ok(Some(read_sample_file(path)?));
125 }
126 if args.live {
127 let source = crate::registry::build_source(
128 &node.source.kind,
129 node.source.config.clone(),
130 auth,
131 None,
132 )
133 .await?;
134 let records = pull_capped(source.as_ref(), args.limit).await?;
135 return Ok(Some(records));
136 }
137 Ok(None)
138}
139
140fn read_sample_file(path: &std::path::Path) -> CliResult<Vec<Value>> {
142 let text = std::fs::read_to_string(path)?;
143 let trimmed = text.trim_start();
144 if trimmed.starts_with('[') {
145 serde_json::from_str(trimmed).map_err(|e| {
146 CliError::Config(format!(
147 "invalid --sample JSON array `{}`: {e}",
148 path.display()
149 ))
150 })
151 } else {
152 text.lines()
153 .filter(|l| !l.trim().is_empty())
154 .map(|l| {
155 serde_json::from_str(l).map_err(|e| {
156 CliError::Config(format!(
157 "invalid --sample JSONL line in `{}`: {e}",
158 path.display()
159 ))
160 })
161 })
162 .collect()
163 }
164}
165
166async fn pull_capped(source: &dyn faucet_core::Source, limit: usize) -> CliResult<Vec<Value>> {
169 use futures::StreamExt;
170 let ctx = std::collections::HashMap::new();
171 let stream = source.stream_pages(&ctx, limit.max(1));
172 futures::pin_mut!(stream);
173 let mut out = Vec::new();
174 while out.len() < limit {
175 match stream.next().await {
176 Some(Ok(page)) => {
177 out.extend(page.records);
178 }
179 Some(Err(e)) => return Err(CliError::from(e)),
180 None => break,
181 }
182 }
183 out.truncate(limit);
184 Ok(out)
185}
186
187pub(crate) fn resolved_case_from_node(
188 node: &ExpandedNode,
189 input: Vec<Value>,
190 clock: chrono::DateTime<chrono::FixedOffset>,
191) -> ResolvedCase {
192 ResolvedCase {
193 name: format!("plan:{}", node.id),
194 transforms: node.transforms.clone(),
195 #[cfg(feature = "quality")]
196 quality: node.quality.clone(),
197 #[cfg(feature = "contract")]
198 contract: node.contract.clone(),
199 #[cfg(feature = "masking")]
200 masking: node.masking.clone(),
201 input,
202 page_size: 0,
203 clock,
204 }
205}
206
207fn render_delta(dest: &Value, inferred: &Value) -> SchemaDeltaReport {
208 let diff = faucet_core::drift::diff_schema(dest, inferred, true);
209 SchemaDeltaReport {
210 schemaless_sink: false,
211 additions: diff.additions.iter().map(|c| c.name.clone()).collect(),
212 widenings: diff.widenings.iter().map(|c| c.name.clone()).collect(),
213 incompatible: diff.incompatible.iter().map(|c| c.name.clone()).collect(),
214 droppable_required: diff.droppable_required.clone(),
215 }
216}
217
218pub async fn run(args: PlanArgs) -> CliResult<()> {
220 let cwd = std::env::current_dir()?;
221 let path = match &args.config {
222 Some(p) => p.clone(),
223 None => crate::env_loader::discover_config_path(&cwd).ok_or(CliError::NoConfigOrFromEnv)?,
224 };
225 let cfg = if args.resolve_secrets {
226 crate::config::PipelineConfig::from_path_async(&path, args.profile.as_deref()).await?
227 } else {
228 crate::config::PipelineConfig::from_path_tolerating_secrets(&path, args.profile.as_deref())?
229 };
230 let auth = auth_catalog::build_auth_catalog(cfg.auth.as_ref())?;
231 let nodes = expand::expand(&cfg)?;
232 let node = select_root(&nodes, args.row.as_deref())?;
233 let clock = chrono::Utc::now().fixed_offset();
234
235 let mut report = build_plan_report(node);
236 #[cfg(feature = "lineage")]
237 {
238 report.lineage = crate::lineage_glue::column_ops(&node.transforms, masking_present(node))
239 .iter()
240 .map(|op| format!("{op:?}"))
241 .collect();
242 }
243
244 if let Some(input) = load_sample(&args, node, &auth).await? {
245 let input_records = input.len();
246 let case = resolved_case_from_node(node, input, clock);
247 let run = run_case(&case).await?;
248 let inferred = faucet_core::schema::infer_schema(&run.written);
249
250 let sink = crate::registry::build_sink(&node.sink.kind, node.sink.config.clone(), &auth)
253 .await
254 .ok();
255 let sink_schema = match &sink {
256 Some(s) => s.current_schema().await.ok().flatten(),
257 None => None,
258 };
259 report.sink_probe = match &sink {
260 Some(s) => Some(probe_summary(s.as_ref()).await),
261 None => Some("sink could not be built (skipped probe)".to_owned()),
262 };
263 let schema_delta = match &sink_schema {
264 Some(dest) => render_delta(dest, &inferred),
265 None => SchemaDeltaReport {
266 schemaless_sink: true,
267 additions: vec![],
268 widenings: vec![],
269 incompatible: vec![],
270 droppable_required: vec![],
271 },
272 };
273 report.sample = Some(SampleReport {
274 source: match &args.sample {
275 Some(p) => format!("fixture:{}", p.display()),
276 None => format!("live:{} (≤{})", node.source.kind, args.limit),
277 },
278 input_records,
279 output_records: run.written.len(),
280 dlq_records: run.dlq_payloads.len(),
281 inferred_schema: inferred,
282 sink_schema,
283 schema_delta,
284 error: run.error,
285 });
286 }
287
288 if args.json {
289 let out =
290 serde_json::to_string_pretty(&report).map_err(|e| CliError::Config(e.to_string()))?;
291 println!("{out}");
292 } else {
293 render_human(&report);
294 }
295 Ok(())
296}
297
298async fn probe_summary(sink: &dyn faucet_core::Sink) -> String {
299 let ctx = faucet_core::CheckContext::default();
300 match sink.check(&ctx).await {
301 Ok(report) => format!("{} probe(s)", report.probes.len()),
302 Err(e) => format!("probe unavailable: {e}"),
303 }
304}
305
306pub(crate) fn select_root<'a>(
307 nodes: &'a [ExpandedNode],
308 row: Option<&str>,
309) -> CliResult<&'a ExpandedNode> {
310 match row {
311 Some(id) => nodes
312 .iter()
313 .find(|n| n.id == id)
314 .ok_or_else(|| CliError::Config(format!("no row with id '{id}' in this config"))),
315 None => nodes
316 .iter()
317 .find(|n| matches!(n.role, NodeRole::Root))
318 .ok_or_else(|| CliError::Config("config has no root row to plan".to_owned())),
319 }
320}
321
322fn render_human(r: &PlanReport) {
323 println!("Plan for row `{}`:", r.row);
324 println!(" source: {}", r.source);
325 println!(" sink: {} (write_mode: {})", r.sink, r.write_mode);
326 println!(" delivery: {}", r.delivery_guarantee);
327 if r.transforms.is_empty() {
328 println!(" transforms: (none)");
329 } else {
330 println!(" transforms: {}", r.transforms.join(" → "));
331 }
332 let mut policies = Vec::new();
333 if r.quality {
334 policies.push("quality");
335 }
336 if r.contract {
337 policies.push("contract");
338 }
339 if r.masking {
340 policies.push("masking");
341 }
342 if let Some(d) = &r.schema_drift {
343 println!(" schema-drift: on_drift={d}");
344 }
345 println!(
346 " policies: {}",
347 if policies.is_empty() {
348 "(none)".to_owned()
349 } else {
350 policies.join(", ")
351 }
352 );
353 if !r.lineage.is_empty() {
354 println!(" lineage ops: {}", r.lineage.join(", "));
355 }
356 if let Some(p) = &r.sink_probe {
357 println!(" sink check: {p}");
358 }
359 match &r.sample {
360 None => {
361 println!(
362 "\n (pass --sample <fixture> or --live --limit N to preview the output schema, volume, and sink delta — no writes either way)"
363 );
364 }
365 Some(s) => {
366 println!("\n sample ({}):", s.source);
367 println!(
368 " {} in → {} out, {} to DLQ",
369 s.input_records, s.output_records, s.dlq_records
370 );
371 if let Some(err) = &s.error {
372 println!(" run error: {err}");
373 }
374 if s.schema_delta.schemaless_sink {
375 println!(" sink schema delta: schemaless sink — no delta");
376 } else {
377 let d = &s.schema_delta;
378 println!(
379 " sink schema delta: +{} added, {} widened, {} incompatible, {} newly-absent-required",
380 d.additions.len(),
381 d.widenings.len(),
382 d.incompatible.len(),
383 d.droppable_required.len()
384 );
385 if !d.additions.is_empty() {
386 println!(" add: {}", d.additions.join(", "));
387 }
388 if !d.incompatible.is_empty() {
389 println!(" incompatible: {}", d.incompatible.join(", "));
390 }
391 }
392 }
393 }
394 println!("\n (read-only — no sink was written)");
395}
396
397#[cfg(test)]
398mod tests {
399 use super::*;
400
401 #[tokio::test]
402 async fn plan_reports_resolved_pipeline_and_never_writes() {
403 let dir = tempfile::tempdir().unwrap();
406 let out = dir.path().join("out.jsonl");
407 let sample = dir.path().join("sample.jsonl");
408 std::fs::write(&sample, "{\"a\": 1}\n{\"a\": 2}\n").unwrap();
409 let cfg_path = dir.path().join("pipe.yaml");
410 std::fs::write(
411 &cfg_path,
412 format!(
413 "version: 1\nname: plan-test\npipeline:\n source:\n type: csv\n config:\n path: in.csv\n sink:\n type: jsonl\n config:\n path: {}\n transforms:\n - type: flatten\n config: {{}}\n",
414 out.display()
415 ),
416 )
417 .unwrap();
418
419 let args = PlanArgs {
420 config: Some(cfg_path),
421 row: None,
422 sample: Some(sample),
423 live: false,
424 limit: 10,
425 json: false,
426 resolve_secrets: false,
427 profile: None,
428 };
429 super::run(args).await.expect("plan runs");
430 assert!(!out.exists(), "plan must not write to the sink");
431 }
432
433 #[tokio::test]
434 async fn plan_json_has_sample_and_schemaless_delta() {
435 let dir = tempfile::tempdir().unwrap();
436 let sample = dir.path().join("s.jsonl");
437 std::fs::write(&sample, "{\"x\": 1}\n").unwrap();
438 let cfg_path = dir.path().join("p.yaml");
439 std::fs::write(
440 &cfg_path,
441 "version: 1\npipeline:\n source:\n type: csv\n config:\n path: in.csv\n sink:\n type: jsonl\n config:\n path: /tmp/should-not-be-written.jsonl\n",
442 )
443 .unwrap();
444 let cfg =
446 crate::config::PipelineConfig::from_path_tolerating_secrets(&cfg_path, None).unwrap();
447 let nodes = crate::expand::expand(&cfg).unwrap();
448 let report = build_plan_report(&nodes[0]);
449 assert_eq!(report.source, "csv");
450 assert_eq!(report.sink, "jsonl");
451 assert_eq!(report.write_mode, "append");
452 }
453}