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 if args.diff {
221 #[cfg(feature = "catalog")]
222 {
223 return run_diff(args).await;
224 }
225 #[cfg(not(feature = "catalog"))]
226 {
227 return Err(CliError::Config(
228 "`faucet plan --diff` requires a binary built with the `catalog` feature \
229 (e.g. `cargo install faucet-cli --features catalog`)"
230 .into(),
231 ));
232 }
233 }
234 let cwd = std::env::current_dir()?;
235 let path = match &args.config {
236 Some(p) => p.clone(),
237 None => crate::env_loader::discover_config_path(&cwd).ok_or(CliError::NoConfigOrFromEnv)?,
238 };
239 let cfg = if args.resolve_secrets {
240 crate::config::PipelineConfig::from_path_async(&path, args.profile.as_deref()).await?
241 } else {
242 crate::config::PipelineConfig::from_path_tolerating_secrets(&path, args.profile.as_deref())?
243 };
244 let auth = auth_catalog::build_auth_catalog(cfg.auth.as_ref())?;
245 let nodes = expand::expand(&cfg)?;
246 let node = select_root(&nodes, args.row.as_deref())?;
247 let clock = chrono::Utc::now().fixed_offset();
248
249 let mut report = build_plan_report(node);
250 #[cfg(feature = "lineage")]
251 {
252 report.lineage = crate::lineage_glue::column_ops(&node.transforms, masking_present(node))
253 .iter()
254 .map(|op| format!("{op:?}"))
255 .collect();
256 }
257
258 if let Some(input) = load_sample(&args, node, &auth).await? {
259 let input_records = input.len();
260 let case = resolved_case_from_node(node, input, clock);
261 let run = run_case(&case).await?;
262 let inferred = faucet_core::schema::infer_schema(&run.written);
263
264 let sink = crate::registry::build_sink(&node.sink.kind, node.sink.config.clone(), &auth)
267 .await
268 .ok();
269 let sink_schema = match &sink {
270 Some(s) => s.current_schema().await.ok().flatten(),
271 None => None,
272 };
273 report.sink_probe = match &sink {
274 Some(s) => Some(probe_summary(s.as_ref()).await),
275 None => Some("sink could not be built (skipped probe)".to_owned()),
276 };
277 let schema_delta = match &sink_schema {
278 Some(dest) => render_delta(dest, &inferred),
279 None => SchemaDeltaReport {
280 schemaless_sink: true,
281 additions: vec![],
282 widenings: vec![],
283 incompatible: vec![],
284 droppable_required: vec![],
285 },
286 };
287 report.sample = Some(SampleReport {
288 source: match &args.sample {
289 Some(p) => format!("fixture:{}", p.display()),
290 None => format!("live:{} (≤{})", node.source.kind, args.limit),
291 },
292 input_records,
293 output_records: run.written.len(),
294 dlq_records: run.dlq_payloads.len(),
295 inferred_schema: inferred,
296 sink_schema,
297 schema_delta,
298 error: run.error,
299 });
300 }
301
302 if args.json {
303 let out =
304 serde_json::to_string_pretty(&report).map_err(|e| CliError::Config(e.to_string()))?;
305 println!("{out}");
306 } else {
307 render_human(&report);
308 }
309 Ok(())
310}
311
312async fn probe_summary(sink: &dyn faucet_core::Sink) -> String {
313 let ctx = faucet_core::CheckContext::default();
314 match sink.check(&ctx).await {
315 Ok(report) => format!("{} probe(s)", report.probes.len()),
316 Err(e) => format!("probe unavailable: {e}"),
317 }
318}
319
320pub(crate) fn select_root<'a>(
321 nodes: &'a [ExpandedNode],
322 row: Option<&str>,
323) -> CliResult<&'a ExpandedNode> {
324 match row {
325 Some(id) => nodes
326 .iter()
327 .find(|n| n.id == id)
328 .ok_or_else(|| CliError::Config(format!("no row with id '{id}' in this config"))),
329 None => nodes
330 .iter()
331 .find(|n| matches!(n.role, NodeRole::Root))
332 .ok_or_else(|| CliError::Config("config has no root row to plan".to_owned())),
333 }
334}
335
336fn render_human(r: &PlanReport) {
337 println!("Plan for row `{}`:", r.row);
338 println!(" source: {}", r.source);
339 println!(" sink: {} (write_mode: {})", r.sink, r.write_mode);
340 println!(" delivery: {}", r.delivery_guarantee);
341 if r.transforms.is_empty() {
342 println!(" transforms: (none)");
343 } else {
344 println!(" transforms: {}", r.transforms.join(" → "));
345 }
346 let mut policies = Vec::new();
347 if r.quality {
348 policies.push("quality");
349 }
350 if r.contract {
351 policies.push("contract");
352 }
353 if r.masking {
354 policies.push("masking");
355 }
356 if let Some(d) = &r.schema_drift {
357 println!(" schema-drift: on_drift={d}");
358 }
359 println!(
360 " policies: {}",
361 if policies.is_empty() {
362 "(none)".to_owned()
363 } else {
364 policies.join(", ")
365 }
366 );
367 if !r.lineage.is_empty() {
368 println!(" lineage ops: {}", r.lineage.join(", "));
369 }
370 if let Some(p) = &r.sink_probe {
371 println!(" sink check: {p}");
372 }
373 match &r.sample {
374 None => {
375 println!(
376 "\n (pass --sample <fixture> or --live --limit N to preview the output schema, volume, and sink delta — no writes either way)"
377 );
378 }
379 Some(s) => {
380 println!("\n sample ({}):", s.source);
381 println!(
382 " {} in → {} out, {} to DLQ",
383 s.input_records, s.output_records, s.dlq_records
384 );
385 if let Some(err) = &s.error {
386 println!(" run error: {err}");
387 }
388 if s.schema_delta.schemaless_sink {
389 println!(" sink schema delta: schemaless sink — no delta");
390 } else {
391 let d = &s.schema_delta;
392 println!(
393 " sink schema delta: +{} added, {} widened, {} incompatible, {} newly-absent-required",
394 d.additions.len(),
395 d.widenings.len(),
396 d.incompatible.len(),
397 d.droppable_required.len()
398 );
399 if !d.additions.is_empty() {
400 println!(" add: {}", d.additions.join(", "));
401 }
402 if !d.incompatible.is_empty() {
403 println!(" incompatible: {}", d.incompatible.join(", "));
404 }
405 }
406 }
407 }
408 println!("\n (read-only — no sink was written)");
409}
410
411#[cfg(feature = "catalog")]
415async fn run_diff(args: PlanArgs) -> CliResult<()> {
416 use crate::catalog::snapshot;
417 let cwd = std::env::current_dir()?;
418 let path = match &args.config {
419 Some(p) => p.clone(),
420 None => crate::env_loader::discover_config_path(&cwd).ok_or(CliError::NoConfigOrFromEnv)?,
421 };
422 let cfg =
424 crate::config::PipelineConfig::from_path_async(&path, args.profile.as_deref()).await?;
425 let spec = cfg.catalog.as_ref().ok_or_else(|| {
426 CliError::Config(
427 "`faucet plan --diff` needs a `catalog:` block to read the last recorded run \
428 (see `faucet schema catalog`)"
429 .into(),
430 )
431 })?;
432 let nodes = expand::expand(&cfg)?;
433 let pipeline = snapshot::resolve_name(&cfg, Some(&path));
434 let current = snapshot::build_snapshot(
435 pipeline.clone(),
436 snapshot::on_error_str(&cfg.execution),
437 &nodes,
438 chrono::Utc::now(),
439 );
440 let handle = crate::catalog::connect_from_spec(spec).await?;
441 let previous = handle
442 .store
443 .catalog_last_config_snapshot(&pipeline)
444 .await
445 .map_err(|e| CliError::Config(format!("catalog read failed: {e}")))?;
446 let d = snapshot::diff(previous.as_ref(), ¤t);
447 if args.json {
448 let out = serde_json::to_string_pretty(&d).map_err(|e| CliError::Config(e.to_string()))?;
449 println!("{out}");
450 } else {
451 print!("{}", snapshot::render_human(&d));
452 }
453 Ok(())
454}
455
456#[cfg(test)]
457mod tests {
458 use super::*;
459
460 #[tokio::test]
461 async fn plan_reports_resolved_pipeline_and_never_writes() {
462 let dir = tempfile::tempdir().unwrap();
465 let out = dir.path().join("out.jsonl");
466 let sample = dir.path().join("sample.jsonl");
467 std::fs::write(&sample, "{\"a\": 1}\n{\"a\": 2}\n").unwrap();
468 let cfg_path = dir.path().join("pipe.yaml");
469 std::fs::write(
470 &cfg_path,
471 format!(
472 "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",
473 out.display()
474 ),
475 )
476 .unwrap();
477
478 let args = PlanArgs {
479 config: Some(cfg_path),
480 row: None,
481 sample: Some(sample),
482 live: false,
483 limit: 10,
484 json: false,
485 diff: false,
486 resolve_secrets: false,
487 profile: None,
488 };
489 super::run(args).await.expect("plan runs");
490 assert!(!out.exists(), "plan must not write to the sink");
491 }
492
493 #[tokio::test]
494 async fn plan_json_has_sample_and_schemaless_delta() {
495 let dir = tempfile::tempdir().unwrap();
496 let sample = dir.path().join("s.jsonl");
497 std::fs::write(&sample, "{\"x\": 1}\n").unwrap();
498 let cfg_path = dir.path().join("p.yaml");
499 std::fs::write(
500 &cfg_path,
501 "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",
502 )
503 .unwrap();
504 let cfg =
506 crate::config::PipelineConfig::from_path_tolerating_secrets(&cfg_path, None).unwrap();
507 let nodes = crate::expand::expand(&cfg).unwrap();
508 let report = build_plan_report(&nodes[0]);
509 assert_eq!(report.source, "csv");
510 assert_eq!(report.sink, "jsonl");
511 assert_eq!(report.write_mode, "append");
512 }
513
514 #[cfg(all(feature = "catalog", feature = "serve-history-sqlite"))]
518 #[tokio::test]
519 async fn plan_diff_first_run_then_against_recorded_snapshot() {
520 let dir = tempfile::tempdir().unwrap();
521 let db = dir.path().join("cat.db");
522 let cfg_path = dir.path().join("pipe.yaml");
523 std::fs::write(
524 &cfg_path,
525 format!(
526 "version: 1\nname: diffpipe\npipeline:\n source:\n type: csv\n config:\n path: in.csv\n sink:\n type: jsonl\n config:\n path: out.jsonl\ncatalog:\n url: \"sqlite:{}\"\n",
527 db.display()
528 ),
529 )
530 .unwrap();
531
532 let mk_args = || PlanArgs {
533 config: Some(cfg_path.clone()),
534 row: None,
535 sample: None,
536 live: false,
537 limit: 10,
538 json: false,
539 diff: true,
540 resolve_secrets: false,
541 profile: None,
542 };
543
544 super::run(mk_args()).await.expect("first plan --diff");
546
547 let cfg = crate::config::PipelineConfig::from_path_async(&cfg_path, None)
549 .await
550 .unwrap();
551 let nodes = crate::expand::expand(&cfg).unwrap();
552 let handle = crate::catalog::connect_from_spec(cfg.catalog.as_ref().unwrap())
553 .await
554 .unwrap();
555 crate::catalog::snapshot::record_if_ok(
556 Some(&handle),
557 "diffpipe",
558 "continue",
559 &nodes,
560 true,
561 chrono::Utc::now(),
562 )
563 .await;
564
565 super::run(mk_args()).await.expect("second plan --diff");
567 }
568
569 #[cfg(feature = "catalog")]
572 #[tokio::test]
573 async fn plan_diff_without_catalog_block_errors() {
574 let dir = tempfile::tempdir().unwrap();
575 let cfg_path = dir.path().join("p.yaml");
576 std::fs::write(
577 &cfg_path,
578 "version: 1\nname: nocat\npipeline:\n source: { type: csv, config: { path: in.csv } }\n sink: { type: jsonl, config: { path: out.jsonl } }\n",
579 )
580 .unwrap();
581 let args = PlanArgs {
582 config: Some(cfg_path),
583 row: None,
584 sample: None,
585 live: false,
586 limit: 10,
587 json: false,
588 diff: true,
589 resolve_secrets: false,
590 profile: None,
591 };
592 let err = super::run(args).await.unwrap_err();
593 assert!(err.to_string().contains("catalog:"), "{err}");
594 }
595}