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    };
145    RowExplanation {
146        id: node.id.clone(),
147        role,
148        parent,
149        source: describe_connector(&node.source.kind, &node.source.config),
150        transforms: node.transforms.iter().map(|t| t.kind.clone()).collect(),
151        sink: describe_connector(&node.sink.kind, &node.sink.config),
152        write_mode: string_field(&node.sink.config, "write_mode"),
153        key: key_field(&node.sink.config),
154        delivery_guarantee: node.delivery_guarantee.to_string(),
155        state: node.state.as_ref().map(|s| s.kind.clone()),
156        incremental: INCREMENTAL_KEYS
157            .iter()
158            .any(|k| node.source.config.get(*k).is_some()),
159    }
160}
161
162/// `kind (field=value, …)` using only the safe descriptor allowlist. Falls back
163/// to bare `kind` when no allowlisted field is present.
164fn describe_connector(kind: &str, config: &Value) -> String {
165    let Some(obj) = config.as_object() else {
166        return kind.to_string();
167    };
168    let mut parts = Vec::new();
169    for k in SAFE_DESCRIPTOR_KEYS {
170        if let Some(v) = obj.get(*k) {
171            parts.push(format!("{k}={}", scalar_str(v)));
172            if parts.len() == 2 {
173                break; // two identifying fields is plenty for a narration
174            }
175        }
176    }
177    if parts.is_empty() {
178        kind.to_string()
179    } else {
180        format!("{kind} ({})", parts.join(", "))
181    }
182}
183
184/// A compact, non-secret rendering of a scalar (or a shape hint for containers).
185fn scalar_str(v: &Value) -> String {
186    match v {
187        Value::String(s) => s.clone(),
188        Value::Number(n) => n.to_string(),
189        Value::Bool(b) => b.to_string(),
190        Value::Array(a) => format!("[{} item(s)]", a.len()),
191        Value::Object(_) => "{…}".to_string(),
192        Value::Null => "null".to_string(),
193    }
194}
195
196fn string_field(config: &Value, key: &str) -> Option<String> {
197    config
198        .get(key)
199        .and_then(Value::as_str)
200        .map(|s| s.to_string())
201}
202
203/// Render a `key` field that may be a string or an array of column names.
204fn key_field(config: &Value) -> Option<String> {
205    match config.get("key") {
206        Some(Value::String(s)) => Some(s.clone()),
207        Some(Value::Array(a)) => {
208            let cols: Vec<String> = a
209                .iter()
210                .filter_map(Value::as_str)
211                .map(|s| s.to_string())
212                .collect();
213            (!cols.is_empty()).then(|| cols.join(", "))
214        }
215        _ => None,
216    }
217}
218
219/// Render the explanation as prose. Large matrices are summarized unless
220/// `show_all` (`--rows`) is set.
221pub(crate) fn render_prose(r: &Explanation, show_all: bool) -> String {
222    let mut out = String::new();
223    if r.rows_total == 0 {
224        out.push_str(&format!(
225            "Pipeline '{}' has no runnable rows.\n",
226            r.pipeline
227        ));
228        return out;
229    }
230
231    // Intro line: expansion shape.
232    if r.rows_total == 1 {
233        out.push_str(&format!(
234            "Pipeline '{}' is a single pipeline.\n",
235            r.pipeline
236        ));
237    } else {
238        out.push_str(&format!(
239            "Pipeline '{}' expands to {} rows ({} root{}, {} child{}).",
240            r.pipeline,
241            r.rows_total,
242            r.roots,
243            if r.roots == 1 { "" } else { "s" },
244            r.children,
245            if r.children == 1 { "" } else { "ren" },
246        ));
247        if r.incremental_rows > 0 {
248            out.push_str(&format!(
249                " {} row{} incremental.",
250                r.incremental_rows,
251                if r.incremental_rows == 1 {
252                    " is"
253                } else {
254                    "s are"
255                }
256            ));
257        }
258        out.push('\n');
259    }
260    if let Some(mode) = &r.replication {
261        out.push_str(&format!("Replication mode: {mode}.\n"));
262    }
263    out.push('\n');
264
265    let summarize = !show_all && r.rows_total > SUMMARIZE_THRESHOLD;
266    let shown = if summarize {
267        SUMMARIZE_THRESHOLD
268    } else {
269        r.rows.len()
270    };
271    for row in r.rows.iter().take(shown) {
272        out.push_str(&narrate_row(row));
273    }
274    if summarize {
275        out.push_str(&format!(
276            "… and {} more row(s). Pass --rows to narrate every row.\n",
277            r.rows_total - shown
278        ));
279    }
280    out
281}
282
283fn narrate_row(row: &RowExplanation) -> String {
284    let lineage = if row.transforms.is_empty() {
285        " → ".to_string()
286    } else {
287        format!(" → applies {} → ", row.transforms.join(", "))
288    };
289    let parent = match &row.parent {
290        Some(p) => format!(" (per record from '{p}')"),
291        None => String::new(),
292    };
293    let write = match (&row.write_mode, &row.key) {
294        (Some(mode), Some(key)) => format!(" [{mode} on {key}]"),
295        (Some(mode), None) => format!(" [{mode}]"),
296        _ => String::new(),
297    };
298    let state = match &row.state {
299        Some(s) => format!(", state: {s}"),
300        None => String::new(),
301    };
302    format!(
303        "• {}{}: reads from {}{}writes to {}{}. delivery: {}{}.\n",
304        row.id, parent, row.source, lineage, row.sink, write, row.delivery_guarantee, state,
305    )
306}
307
308#[cfg(test)]
309mod tests {
310    use super::*;
311    use crate::config::parse_with_extension;
312
313    fn explain_yaml(yaml: &str) -> Explanation {
314        let cfg = parse_with_extension(yaml, "yaml").unwrap();
315        let nodes = expand(&cfg).unwrap();
316        build_report(&cfg, &nodes)
317    }
318
319    #[test]
320    fn describe_connector_uses_only_safe_fields() {
321        let cfg = serde_json::json!({
322            "table_name": "orders",
323            "connection_url": "postgres://user:secret@host/db",
324            "auth": { "token": "hunter2" }
325        });
326        let d = describe_connector("postgres", &cfg);
327        assert!(d.contains("table_name=orders"), "{d}");
328        assert!(!d.contains("secret"), "must not leak connection_url: {d}");
329        assert!(!d.contains("hunter2"), "must not leak auth: {d}");
330    }
331
332    #[test]
333    fn single_pipeline_prose_names_source_and_sink() {
334        let r = explain_yaml(
335            r#"
336version: 1
337name: demo
338pipeline:
339  source: { type: rest, config: { path: /events } }
340  sink: { type: jsonl, config: { path: out.jsonl } }
341  transforms:
342    - { type: flatten }
343"#,
344        );
345        assert_eq!(r.rows_total, 1);
346        let prose = render_prose(&r, false);
347        assert!(prose.contains("reads from rest"), "{prose}");
348        assert!(prose.contains("applies flatten"), "{prose}");
349        assert!(prose.contains("writes to jsonl"), "{prose}");
350        assert!(prose.contains("delivery:"), "{prose}");
351    }
352
353    #[test]
354    fn matrix_expansion_and_upsert_are_reported() {
355        let r = explain_yaml(
356            r#"
357version: 1
358name: fan
359pipeline:
360  source: { type: rest, config: {} }
361  sink:
362    type: postgres
363    config:
364      connection_url: "postgres://localhost/db"
365      table_name: t
366      column_mapping: auto_map
367      write_mode: upsert
368      key: [id]
369matrix:
370  - id: us
371  - id: eu
372"#,
373        );
374        assert_eq!(r.rows_total, 2);
375        assert_eq!(r.roots, 2);
376        let row = &r.rows[0];
377        assert_eq!(row.write_mode.as_deref(), Some("upsert"));
378        assert_eq!(row.key.as_deref(), Some("id"));
379        assert!(row.delivery_guarantee.contains("effectively-once"));
380        let prose = render_prose(&r, false);
381        assert!(prose.contains("expands to 2 rows"), "{prose}");
382        assert!(prose.contains("[upsert on id]"), "{prose}");
383    }
384
385    #[test]
386    fn parent_child_matrix_describes_fan_out() {
387        let r = explain_yaml(
388            r#"
389version: 1
390name: pc
391pipeline:
392  source: { type: rest, config: {} }
393  sink: { type: jsonl, config: { path: o } }
394matrix:
395  - id: dims
396  - id: facts
397    parent: dims
398    parent_key: id
399"#,
400        );
401        assert_eq!(r.roots, 1);
402        assert_eq!(r.children, 1);
403        let child = r.rows.iter().find(|x| x.id == "facts").unwrap();
404        assert_eq!(child.parent.as_deref(), Some("dims"));
405        let prose = render_prose(&r, true);
406        assert!(prose.contains("per record from 'dims'"), "{prose}");
407    }
408
409    #[test]
410    fn large_matrix_summarizes_without_rows_flag() {
411        let mut yaml = String::from(
412            "version: 1\nname: big\npipeline:\n  source: { type: rest, config: {} }\n  sink: { type: jsonl, config: { path: o } }\nmatrix:\n",
413        );
414        for i in 0..20 {
415            yaml.push_str(&format!("  - id: r{i}\n"));
416        }
417        let r = explain_yaml(&yaml);
418        let summarized = render_prose(&r, false);
419        assert!(summarized.contains("and 12 more row(s)"), "{summarized}");
420        let full = render_prose(&r, true);
421        assert!(!full.contains("more row(s)"), "--rows narrates all");
422    }
423
424    #[test]
425    fn json_output_is_serializable_and_deterministic() {
426        let r = explain_yaml(
427            r#"
428version: 1
429name: j
430pipeline:
431  source: { type: rest, config: { path: /x } }
432  sink: { type: jsonl, config: { path: o } }
433"#,
434        );
435        let a = serde_json::to_string(&r).unwrap();
436        let b = serde_json::to_string(&explain_yaml(
437            "version: 1\nname: j\npipeline:\n  source: { type: rest, config: { path: /x } }\n  sink: { type: jsonl, config: { path: o } }\n",
438        ))
439        .unwrap();
440        assert_eq!(a, b);
441    }
442
443    // ── `run` command flow (offline; tempfile) ───────────────────────────────
444
445    fn write_cfg(body: &str) -> (tempfile::TempDir, std::path::PathBuf) {
446        let dir = tempfile::tempdir().expect("tempdir");
447        let path = dir.path().join("faucet.yaml");
448        std::fs::write(&path, body).expect("write");
449        (dir, path)
450    }
451
452    fn args(path: std::path::PathBuf, json: bool, rows: bool) -> ExplainArgs {
453        ExplainArgs {
454            config: Some(path),
455            env_file: None,
456            no_env_file: true,
457            profile: None,
458            json,
459            rows,
460        }
461    }
462
463    const CFG: &str = "version: 1\nname: demo\npipeline:\n  source: { type: rest, config: { path: /x } }\n  sink: { type: jsonl, config: { path: o } }\n";
464
465    #[tokio::test]
466    async fn run_prose_succeeds() {
467        let (_d, path) = write_cfg(CFG);
468        run(args(path, false, false)).await.expect("prose ok");
469    }
470
471    #[tokio::test]
472    async fn run_json_succeeds() {
473        let (_d, path) = write_cfg(CFG);
474        run(args(path, true, false)).await.expect("json ok");
475    }
476
477    #[tokio::test]
478    async fn run_prose_all_rows_on_a_matrix() {
479        // A matrix config exercises the `--rows` (narrate every row) path.
480        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";
481        let (_d, path) = write_cfg(cfg);
482        run(args(path, false, true)).await.expect("matrix prose ok");
483    }
484}