Skip to main content

faucet_cli/commands/
plan.rs

1//! `faucet plan` — a read-only "what would this config do" preview (#283).
2//!
3//! Reports the resolved pipeline (source / sink / transforms / policies /
4//! write-mode / delivery guarantee), and — given a sample (an offline
5//! `--sample` fixture or a capped `--live --limit` read-only pull) — the
6//! inferred output schema, the sink schema delta (via `diff_schema` when the
7//! sink exposes `current_schema()`), the lineage column ops, and a volume
8//! estimate. It runs the sink's non-mutating `check()` probe but **never writes
9//! to any sink** — the data pass goes through the offline capturing harness
10//! (`pipeline_test::run_case`).
11
12use 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/// The read-only plan for one row. Serialized verbatim by `--json`.
21#[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/// The data-derived part of the plan (present only when a sample was supplied).
41#[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/// A rendered `diff_schema` result, or a note that the sink is schemaless.
56#[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
65/// Build the static (no-I/O) part of the plan directly from a resolved node.
66pub 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
116/// Load a sample: an offline `--sample` fixture, or a capped `--live --limit`
117/// read-only pull from the real source. Returns `None` when neither is given.
118async 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
140/// Read a `.jsonl` (one JSON object per line) or `.json` (array) sample file.
141fn 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
166/// Pull at most `limit` records from a source without advancing any bookmark
167/// (uses a throwaway page pull; no state store is wired).
168async 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
218/// Execute the `plan` subcommand.
219pub 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        // Build the sink ONLY to probe it and read its live schema — never to
265        // write. `check()` is best-effort; `current_schema()` yields the delta.
266        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/// `faucet plan --diff` (#374): compare the current resolved+expanded config
412/// against the last snapshot recorded by a successful run. Secrets are resolved
413/// (so redaction matches the record side) and never printed.
414#[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    // Resolve secrets so the redacted current config matches what `run` stored.
423    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(), &current);
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        // csv source → jsonl sink, with a rename transform. Plan against a
463        // fixture; the jsonl sink path must NOT be created (zero writes).
464        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        // Exercise the report builder directly for a deterministic assertion.
505        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    /// `plan --diff` end-to-end over a real sqlite catalog: first run reports
515    /// first-run (nothing recorded), then after a snapshot is recorded the diff
516    /// compares against it. Exercises `run_diff` both branches.
517    #[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        // 1. Nothing recorded yet → first-run path.
545        super::run(mk_args()).await.expect("first plan --diff");
546
547        // 2. Record a snapshot exactly as a successful run would.
548        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        // 3. Now the diff compares against the recorded snapshot (unchanged).
566        super::run(mk_args()).await.expect("second plan --diff");
567    }
568
569    /// `plan --diff` on a config with no `catalog:` block is a clear error
570    /// (there is nothing to diff against).
571    #[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}