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 };
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
162fn 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; }
175 }
176 }
177 if parts.is_empty() {
178 kind.to_string()
179 } else {
180 format!("{kind} ({})", parts.join(", "))
181 }
182}
183
184fn 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
203fn 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
219pub(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 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 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 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}