Skip to main content

faucet_cli/commands/
explain.rs

1//! `faucet explain` — plain-English narration of what a pipeline does (#389).
2//!
3//! Where `faucet plan` is structured and machine-oriented, `explain` reads like
4//! prose: "Reads `orders` from S3 → applies `flatten`, `rename_keys` → writes to
5//! BigQuery with upsert on `id`. Expands to 4 matrix rows; delivery:
6//! effectively-once." It is built entirely from the already-resolved
7//! [`ExpandedNode`]s — **fully offline, zero I/O, no source is touched.**
8//!
9//! Secrets are never printed: connectors are described by their kind plus a
10//! curated allowlist of structural, non-secret fields (table, path, topic, …).
11//! `url` / `connection_url` / `auth` and friends are deliberately excluded, and
12//! the rendered output is run through the secret scrubber as a backstop.
13
14use crate::cli::ExplainArgs;
15use crate::config::PipelineConfig;
16use crate::error::{CliError, CliResult};
17use crate::expand::{ExpandedNode, NodeRole, expand};
18use serde::Serialize;
19use serde_json::Value;
20
21/// Structural connector fields that are safe to surface in a narration — never
22/// credentials or endpoints that can embed them. `url` / `base_url` /
23/// `connection_url` are intentionally absent (they routinely carry `user:pass@`).
24const SAFE_DESCRIPTOR_KEYS: &[&str] = &[
25    "table_name",
26    "table",
27    "path",
28    "topic",
29    "topics",
30    "index",
31    "bucket",
32    "prefix",
33    "database",
34    "collection",
35    "dataset",
36    "stream",
37    "key_pattern",
38    "pattern",
39    "query",
40];
41
42/// Config keys that mark a source as doing incremental (bookmark-based) reads.
43const INCREMENTAL_KEYS: &[&str] = &[
44    "replication",
45    "incremental",
46    "cursor_field",
47    "replication_key",
48    "start_replication_value",
49    "bookmark_key",
50];
51
52/// Above this many matrix rows, prose output summarizes with counts instead of
53/// narrating every row (override with `--rows`).
54const SUMMARIZE_THRESHOLD: usize = 8;
55
56/// Execute the `explain` subcommand.
57pub async fn run(args: ExplainArgs) -> CliResult<()> {
58    let cwd = std::env::current_dir()?;
59    let env_path =
60        crate::env_loader::resolve_env_file(args.env_file.as_deref(), args.no_env_file, &cwd)?;
61    crate::env_loader::load_env_file_if_present(env_path.as_deref())?;
62
63    let path = match args.config {
64        Some(p) => p,
65        None => crate::env_loader::discover_config_path(&cwd).ok_or(CliError::NoConfigOrFromEnv)?,
66    };
67
68    // Offline: tolerate (do not fetch) secret-manager directives — `explain`
69    // never touches the network. `${env:…}` is resolved at load time, so the
70    // narration only ever surfaces the safe allowlist below, never raw config.
71    let cfg = PipelineConfig::from_path_tolerating_secrets(&path, args.profile.as_deref())?;
72    let nodes = expand(&cfg)?;
73    let report = build_report(&cfg, &nodes);
74
75    if args.json {
76        let json = serde_json::to_string_pretty(&report)
77            .map_err(|e| CliError::Config(format!("cannot serialize explanation: {e}")))?;
78        println!("{}", crate::secrets::registry::redact(&json));
79    } else {
80        let prose = render_prose(&report, args.rows);
81        print!("{}", crate::secrets::registry::redact(&prose));
82    }
83    Ok(())
84}
85
86/// A machine-readable explanation (`--json`) and the source for the prose.
87#[derive(Debug, Serialize)]
88pub(crate) struct Explanation {
89    pub pipeline: String,
90    pub rows_total: usize,
91    pub roots: usize,
92    pub children: usize,
93    pub incremental_rows: usize,
94    #[serde(skip_serializing_if = "Option::is_none")]
95    pub replication: Option<String>,
96    pub rows: Vec<RowExplanation>,
97}
98
99#[derive(Debug, Serialize)]
100pub(crate) struct RowExplanation {
101    pub id: String,
102    pub role: String,
103    #[serde(skip_serializing_if = "Option::is_none")]
104    pub parent: Option<String>,
105    pub source: String,
106    pub transforms: Vec<String>,
107    pub sink: String,
108    #[serde(skip_serializing_if = "Option::is_none")]
109    pub write_mode: Option<String>,
110    #[serde(skip_serializing_if = "Option::is_none")]
111    pub key: Option<String>,
112    pub delivery_guarantee: String,
113    #[serde(skip_serializing_if = "Option::is_none")]
114    pub state: Option<String>,
115    pub incremental: bool,
116}
117
118/// Build the full explanation from the resolved config + expanded nodes.
119pub(crate) fn build_report(cfg: &PipelineConfig, nodes: &[ExpandedNode]) -> Explanation {
120    let roots = nodes
121        .iter()
122        .filter(|n| matches!(n.role, NodeRole::Root))
123        .count();
124    let rows: Vec<RowExplanation> = nodes.iter().map(row_explanation).collect();
125    let incremental_rows = rows.iter().filter(|r| r.incremental).count();
126    Explanation {
127        pipeline: cfg.name.clone().unwrap_or_else(|| "(unnamed)".to_string()),
128        rows_total: nodes.len(),
129        roots,
130        children: nodes.len() - roots,
131        incremental_rows,
132        replication: cfg
133            .replication
134            .as_ref()
135            .map(|r| format!("{:?}", r.mode).to_lowercase()),
136        rows,
137    }
138}
139
140fn row_explanation(node: &ExpandedNode) -> RowExplanation {
141    let (role, parent) = match &node.role {
142        NodeRole::Root => ("root".to_string(), None),
143        NodeRole::Child { parent_id, .. } => ("child".to_string(), Some(parent_id.clone())),
144        NodeRole::Discovery { .. } => ("discovery".to_string(), None),
145        NodeRole::Product { dims, .. } => (format!("product[{}]", dims.join(",")), None),
146    };
147    RowExplanation {
148        id: node.id.clone(),
149        role,
150        parent,
151        source: describe_connector(&node.source.kind, &node.source.config),
152        transforms: node.transforms.iter().map(|t| t.kind.clone()).collect(),
153        sink: describe_connector(&node.sink.kind, &node.sink.config),
154        write_mode: string_field(&node.sink.config, "write_mode"),
155        key: key_field(&node.sink.config),
156        delivery_guarantee: node.delivery_guarantee.to_string(),
157        state: node.state.as_ref().map(|s| s.kind.clone()),
158        incremental: INCREMENTAL_KEYS
159            .iter()
160            .any(|k| node.source.config.get(*k).is_some()),
161    }
162}
163
164/// `kind (field=value, …)` using only the safe descriptor allowlist. Falls back
165/// to bare `kind` when no allowlisted field is present.
166fn describe_connector(kind: &str, config: &Value) -> String {
167    let Some(obj) = config.as_object() else {
168        return kind.to_string();
169    };
170    let mut parts = Vec::new();
171    for k in SAFE_DESCRIPTOR_KEYS {
172        if let Some(v) = obj.get(*k) {
173            parts.push(format!("{k}={}", scalar_str(v)));
174            if parts.len() == 2 {
175                break; // two identifying fields is plenty for a narration
176            }
177        }
178    }
179    if parts.is_empty() {
180        kind.to_string()
181    } else {
182        format!("{kind} ({})", parts.join(", "))
183    }
184}
185
186/// A compact, non-secret rendering of a scalar (or a shape hint for containers).
187fn scalar_str(v: &Value) -> String {
188    match v {
189        Value::String(s) => s.clone(),
190        Value::Number(n) => n.to_string(),
191        Value::Bool(b) => b.to_string(),
192        Value::Array(a) => format!("[{} item(s)]", a.len()),
193        Value::Object(_) => "{…}".to_string(),
194        Value::Null => "null".to_string(),
195    }
196}
197
198fn string_field(config: &Value, key: &str) -> Option<String> {
199    config
200        .get(key)
201        .and_then(Value::as_str)
202        .map(|s| s.to_string())
203}
204
205/// Render a `key` field that may be a string or an array of column names.
206fn key_field(config: &Value) -> Option<String> {
207    match config.get("key") {
208        Some(Value::String(s)) => Some(s.clone()),
209        Some(Value::Array(a)) => {
210            let cols: Vec<String> = a
211                .iter()
212                .filter_map(Value::as_str)
213                .map(|s| s.to_string())
214                .collect();
215            (!cols.is_empty()).then(|| cols.join(", "))
216        }
217        _ => None,
218    }
219}
220
221/// Render the explanation as prose. Large matrices are summarized unless
222/// `show_all` (`--rows`) is set.
223pub(crate) fn render_prose(r: &Explanation, show_all: bool) -> String {
224    let mut out = String::new();
225    if r.rows_total == 0 {
226        out.push_str(&format!(
227            "Pipeline '{}' has no runnable rows.\n",
228            r.pipeline
229        ));
230        return out;
231    }
232
233    // Intro line: expansion shape.
234    if r.rows_total == 1 {
235        out.push_str(&format!(
236            "Pipeline '{}' is a single pipeline.\n",
237            r.pipeline
238        ));
239    } else {
240        out.push_str(&format!(
241            "Pipeline '{}' expands to {} rows ({} root{}, {} child{}).",
242            r.pipeline,
243            r.rows_total,
244            r.roots,
245            if r.roots == 1 { "" } else { "s" },
246            r.children,
247            if r.children == 1 { "" } else { "ren" },
248        ));
249        if r.incremental_rows > 0 {
250            out.push_str(&format!(
251                " {} row{} incremental.",
252                r.incremental_rows,
253                if r.incremental_rows == 1 {
254                    " is"
255                } else {
256                    "s are"
257                }
258            ));
259        }
260        out.push('\n');
261    }
262    if let Some(mode) = &r.replication {
263        out.push_str(&format!("Replication mode: {mode}.\n"));
264    }
265    out.push('\n');
266
267    let summarize = !show_all && r.rows_total > SUMMARIZE_THRESHOLD;
268    let shown = if summarize {
269        SUMMARIZE_THRESHOLD
270    } else {
271        r.rows.len()
272    };
273    for row in r.rows.iter().take(shown) {
274        out.push_str(&narrate_row(row));
275    }
276    if summarize {
277        out.push_str(&format!(
278            "… and {} more row(s). Pass --rows to narrate every row.\n",
279            r.rows_total - shown
280        ));
281    }
282    out
283}
284
285fn narrate_row(row: &RowExplanation) -> String {
286    let lineage = if row.transforms.is_empty() {
287        " → ".to_string()
288    } else {
289        format!(" → applies {} → ", row.transforms.join(", "))
290    };
291    let parent = match &row.parent {
292        Some(p) => format!(" (per record from '{p}')"),
293        None => String::new(),
294    };
295    let write = match (&row.write_mode, &row.key) {
296        (Some(mode), Some(key)) => format!(" [{mode} on {key}]"),
297        (Some(mode), None) => format!(" [{mode}]"),
298        _ => String::new(),
299    };
300    let state = match &row.state {
301        Some(s) => format!(", state: {s}"),
302        None => String::new(),
303    };
304    format!(
305        "• {}{}: reads from {}{}writes to {}{}. delivery: {}{}.\n",
306        row.id, parent, row.source, lineage, row.sink, write, row.delivery_guarantee, state,
307    )
308}
309
310#[cfg(test)]
311mod tests {
312    use super::*;
313    use crate::config::parse_with_extension;
314
315    fn explain_yaml(yaml: &str) -> Explanation {
316        let cfg = parse_with_extension(yaml, "yaml").unwrap();
317        let nodes = expand(&cfg).unwrap();
318        build_report(&cfg, &nodes)
319    }
320
321    #[test]
322    fn describe_connector_uses_only_safe_fields() {
323        let cfg = serde_json::json!({
324            "table_name": "orders",
325            "connection_url": "postgres://user:secret@host/db",
326            "auth": { "token": "hunter2" }
327        });
328        let d = describe_connector("postgres", &cfg);
329        assert!(d.contains("table_name=orders"), "{d}");
330        assert!(!d.contains("secret"), "must not leak connection_url: {d}");
331        assert!(!d.contains("hunter2"), "must not leak auth: {d}");
332    }
333
334    #[test]
335    fn single_pipeline_prose_names_source_and_sink() {
336        let r = explain_yaml(
337            r#"
338version: 1
339name: demo
340pipeline:
341  source: { type: rest, config: { path: /events } }
342  sink: { type: jsonl, config: { path: out.jsonl } }
343  transforms:
344    - { type: flatten }
345"#,
346        );
347        assert_eq!(r.rows_total, 1);
348        let prose = render_prose(&r, false);
349        assert!(prose.contains("reads from rest"), "{prose}");
350        assert!(prose.contains("applies flatten"), "{prose}");
351        assert!(prose.contains("writes to jsonl"), "{prose}");
352        assert!(prose.contains("delivery:"), "{prose}");
353    }
354
355    #[test]
356    fn matrix_expansion_and_upsert_are_reported() {
357        let r = explain_yaml(
358            r#"
359version: 1
360name: fan
361pipeline:
362  source: { type: rest, config: {} }
363  sink:
364    type: postgres
365    config:
366      connection_url: "postgres://localhost/db"
367      table_name: t
368      column_mapping: auto_map
369      write_mode: upsert
370      key: [id]
371matrix:
372  - id: us
373  - id: eu
374"#,
375        );
376        assert_eq!(r.rows_total, 2);
377        assert_eq!(r.roots, 2);
378        let row = &r.rows[0];
379        assert_eq!(row.write_mode.as_deref(), Some("upsert"));
380        assert_eq!(row.key.as_deref(), Some("id"));
381        assert!(row.delivery_guarantee.contains("effectively-once"));
382        let prose = render_prose(&r, false);
383        assert!(prose.contains("expands to 2 rows"), "{prose}");
384        assert!(prose.contains("[upsert on id]"), "{prose}");
385    }
386
387    #[test]
388    fn parent_child_matrix_describes_fan_out() {
389        let r = explain_yaml(
390            r#"
391version: 1
392name: pc
393pipeline:
394  source: { type: rest, config: {} }
395  sink: { type: jsonl, config: { path: o } }
396matrix:
397  - id: dims
398  - id: facts
399    parent: dims
400    parent_key: id
401"#,
402        );
403        assert_eq!(r.roots, 1);
404        assert_eq!(r.children, 1);
405        let child = r.rows.iter().find(|x| x.id == "facts").unwrap();
406        assert_eq!(child.parent.as_deref(), Some("dims"));
407        let prose = render_prose(&r, true);
408        assert!(prose.contains("per record from 'dims'"), "{prose}");
409    }
410
411    #[test]
412    fn large_matrix_summarizes_without_rows_flag() {
413        let mut yaml = String::from(
414            "version: 1\nname: big\npipeline:\n  source: { type: rest, config: {} }\n  sink: { type: jsonl, config: { path: o } }\nmatrix:\n",
415        );
416        for i in 0..20 {
417            yaml.push_str(&format!("  - id: r{i}\n"));
418        }
419        let r = explain_yaml(&yaml);
420        let summarized = render_prose(&r, false);
421        assert!(summarized.contains("and 12 more row(s)"), "{summarized}");
422        let full = render_prose(&r, true);
423        assert!(!full.contains("more row(s)"), "--rows narrates all");
424    }
425
426    #[test]
427    fn json_output_is_serializable_and_deterministic() {
428        let r = explain_yaml(
429            r#"
430version: 1
431name: j
432pipeline:
433  source: { type: rest, config: { path: /x } }
434  sink: { type: jsonl, config: { path: o } }
435"#,
436        );
437        let a = serde_json::to_string(&r).unwrap();
438        let b = serde_json::to_string(&explain_yaml(
439            "version: 1\nname: j\npipeline:\n  source: { type: rest, config: { path: /x } }\n  sink: { type: jsonl, config: { path: o } }\n",
440        ))
441        .unwrap();
442        assert_eq!(a, b);
443    }
444
445    // ── `run` command flow (offline; tempfile) ───────────────────────────────
446
447    fn write_cfg(body: &str) -> (tempfile::TempDir, std::path::PathBuf) {
448        let dir = tempfile::tempdir().expect("tempdir");
449        let path = dir.path().join("faucet.yaml");
450        std::fs::write(&path, body).expect("write");
451        (dir, path)
452    }
453
454    fn args(path: std::path::PathBuf, json: bool, rows: bool) -> ExplainArgs {
455        ExplainArgs {
456            config: Some(path),
457            env_file: None,
458            no_env_file: true,
459            profile: None,
460            json,
461            rows,
462        }
463    }
464
465    const CFG: &str = "version: 1\nname: demo\npipeline:\n  source: { type: rest, config: { path: /x } }\n  sink: { type: jsonl, config: { path: o } }\n";
466
467    #[tokio::test]
468    async fn run_prose_succeeds() {
469        let (_d, path) = write_cfg(CFG);
470        run(args(path, false, false)).await.expect("prose ok");
471    }
472
473    #[tokio::test]
474    async fn run_json_succeeds() {
475        let (_d, path) = write_cfg(CFG);
476        run(args(path, true, false)).await.expect("json ok");
477    }
478
479    #[tokio::test]
480    async fn run_prose_all_rows_on_a_matrix() {
481        // A matrix config exercises the `--rows` (narrate every row) path.
482        let cfg = "version: 1\nname: m\nmatrix:\n  - { id: a }\n  - { id: b }\npipeline:\n  source: { type: rest, config: { path: /x } }\n  sink: { type: jsonl, config: { path: o } }\n";
483        let (_d, path) = write_cfg(cfg);
484        run(args(path, false, true)).await.expect("matrix prose ok");
485    }
486}