1use 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
21const 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
42const INCREMENTAL_KEYS: &[&str] = &[
44 "replication",
45 "incremental",
46 "cursor_field",
47 "replication_key",
48 "start_replication_value",
49 "bookmark_key",
50];
51
52const SUMMARIZE_THRESHOLD: usize = 8;
55
56pub 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 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#[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
118pub(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
164fn 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; }
177 }
178 }
179 if parts.is_empty() {
180 kind.to_string()
181 } else {
182 format!("{kind} ({})", parts.join(", "))
183 }
184}
185
186fn 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
205fn 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
221pub(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 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 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 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}