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    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        // Build the sink ONLY to probe it and read its live schema — never to
251        // write. `check()` is best-effort; `current_schema()` yields the delta.
252        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        // csv source → jsonl sink, with a rename transform. Plan against a
404        // fixture; the jsonl sink path must NOT be created (zero writes).
405        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        // Exercise the report builder directly for a deterministic assertion.
445        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}