1use crate::cli::{RunArgs, RunOutput};
5use crate::config::PipelineConfig;
6use crate::error::{CliError, CliResult};
7use crate::executor::{ExecuteOptions, RunSummary, run_expanded};
8use crate::expand::expand;
9use chrono::{DateTime, Utc};
10use serde::Serialize;
11
12pub(crate) fn resolve_run_clock(
16 flag: Option<&str>,
17) -> CliResult<chrono::DateTime<chrono::FixedOffset>> {
18 use chrono::{DateTime, NaiveDate, TimeZone, Utc};
19 match flag {
20 None => Ok(Utc::now().fixed_offset()),
21 Some(s) => {
22 if let Ok(dt) = DateTime::parse_from_rfc3339(s) {
23 return Ok(dt);
24 }
25 if let Ok(d) = NaiveDate::parse_from_str(s, "%Y-%m-%d") {
26 let ndt = d.and_hms_opt(0, 0, 0).expect("00:00:00 is valid");
27 return Ok(Utc.from_utc_datetime(&ndt).fixed_offset());
28 }
29 Err(CliError::Config(format!(
30 "--clock '{s}' is not RFC3339 (2026-01-31T00:00:00Z) or a date (2026-01-31)"
31 )))
32 }
33 }
34}
35
36#[cfg(feature = "cli-progress")]
40async fn drive_progress_or_plain<T>(
41 run: impl Future<Output = T>,
42 pipeline: &str,
43 handle: Option<metrics_exporter_prometheus::PrometheusHandle>,
44) -> T {
45 match handle {
46 Some(h) => crate::progress::drive(run, pipeline, h).await,
47 None => run.await,
48 }
49}
50
51pub async fn run(args: RunArgs) -> CliResult<()> {
53 let cwd = std::env::current_dir()?;
54 let env_path =
55 crate::env_loader::resolve_env_file(args.env_file.as_deref(), args.no_env_file, &cwd)?;
56 crate::env_loader::load_env_file_if_present(env_path.as_deref())?;
57
58 let resolved_config_path: Option<std::path::PathBuf> = if args.from_env {
59 None
60 } else {
61 Some(match args.config.as_ref() {
62 Some(p) => p.clone(),
63 None => {
64 crate::env_loader::discover_config_path(&cwd).ok_or(CliError::NoConfigOrFromEnv)?
65 }
66 })
67 };
68
69 let cfg = if args.from_env {
70 if args.profile.is_some() {
71 tracing::warn!(
72 "--profile / FAUCET_PROFILE has no effect in --from-env mode (no config file to compose); ignoring"
73 );
74 }
75 crate::env_config::from_process_env()?
76 } else {
77 PipelineConfig::from_path_async(
78 resolved_config_path
79 .as_ref()
80 .expect("YAML mode always resolves a path above"),
81 args.profile.as_deref(),
82 )
83 .await?
84 };
85
86 #[cfg(not(feature = "cli-tui"))]
87 if args.tui {
88 return Err(CliError::Config(
89 "--tui requires a binary built with the `cli-tui` feature \
90 (e.g. `cargo install faucet-cli --features cli-tui`)"
91 .into(),
92 ));
93 }
94 #[cfg(feature = "cli-tui")]
95 let tui_active = crate::tui::is_tui_session(args.tui);
96 #[cfg(not(feature = "cli-tui"))]
97 let tui_active = false;
98
99 #[cfg_attr(
104 not(any(feature = "cli-tui", feature = "cli-progress")),
105 allow(unused_mut)
106 )]
107 let mut live_view_owns_recorder = false;
108
109 #[cfg(feature = "cli-tui")]
110 let tui_handle = if tui_active {
111 let h = crate::tui::setup_observability(&cfg)?;
112 live_view_owns_recorder = true;
113 Some(h)
114 } else {
115 if args.tui {
116 tracing::info!("--tui: stdout is not a terminal; running without the TUI");
117 }
118 None
119 };
120
121 #[cfg(feature = "cli-progress")]
125 let progress_handle =
126 if !tui_active && crate::progress::is_progress_session(args.quiet, args.tui) {
127 let h = crate::livemetrics::setup_observability(&cfg)?;
128 live_view_owns_recorder = true;
129 Some(h)
130 } else {
131 None
132 };
133
134 if !live_view_owns_recorder {
135 crate::obs::install(&cfg)?;
136 }
137
138 let pipeline_name = cfg.name.clone().unwrap_or_else(|| {
139 resolved_config_path
140 .as_ref()
141 .and_then(|p| p.file_stem())
142 .and_then(|s| s.to_str())
143 .unwrap_or("pipeline")
144 .to_owned()
145 });
146
147 let auth = crate::auth_catalog::build_auth_catalog(cfg.auth.as_ref())?;
148
149 if crate::topology::is_topology(&cfg) {
153 let started_at = Utc::now();
154 let summary = crate::topology::run_topology(&cfg, &auth, None).await?;
155 let finished_at = Utc::now();
156 return finish_topology_run(
157 &pipeline_name,
158 started_at,
159 finished_at,
160 &summary,
161 args.output,
162 );
163 }
164
165 #[cfg(feature = "lineage")]
166 let lineage = crate::lineage_glue::build_emitter(cfg.lineage.as_ref())
167 .map_err(|e| CliError::Config(format!("lineage: {e}")))?;
168 let resilience = match &cfg.resilience {
169 Some(spec) => Some(spec.to_policy()?),
170 None => None,
171 };
172 #[cfg(feature = "notify")]
173 let notifier = crate::notify::Notifier::from_specs(&cfg.notifications)?;
174 #[cfg(feature = "catalog")]
175 let catalog = match cfg.catalog.as_ref() {
176 Some(spec) => Some(crate::catalog::connect_from_spec(spec).await?),
177 None => None,
178 };
179 let nodes = expand(&cfg)?;
180 #[cfg(feature = "catalog")]
185 let snapshot_inputs = catalog
186 .as_ref()
187 .map(|handle| (handle.clone(), nodes.clone(), pipeline_name.clone()));
188 let selection =
192 crate::select::RunSelection::from_args(&args.selection, cfg.selection.as_ref())?;
193 let nodes = crate::select::select_nodes(nodes, &selection, !cfg.matrix.is_empty())?;
194 #[cfg(feature = "cli-tui")]
197 let tui_cancel = tui_active.then(faucet_core::CancellationToken::new);
198 #[cfg(not(feature = "cli-tui"))]
199 let tui_cancel: Option<faucet_core::CancellationToken> = None;
200 let started_at = Utc::now();
201 let run_fut = run_expanded(
202 nodes,
203 ExecuteOptions {
204 pipeline_name: pipeline_name.clone(),
205 execution: cfg.execution.clone(),
206 dry_run: args.dry_run,
207 limit: args.limit,
208 state_path_override: args.state_path.clone(),
209 shard: None,
210 auth,
211 clock: resolve_run_clock(args.clock.as_deref())?,
212 cancel: tui_cancel.clone(),
216 resilience,
217 sla: cfg.sla.clone(),
218 #[cfg(feature = "lineage")]
219 lineage,
220 #[cfg(feature = "lineage")]
221 lineage_cfg: cfg.lineage.clone(),
222 #[cfg(feature = "notify")]
223 notifier,
224 #[cfg(feature = "catalog")]
225 catalog,
226 },
227 );
228 #[cfg(feature = "cli-tui")]
229 let summary = match (tui_handle, tui_cancel) {
230 (Some(handle), Some(cancel)) => {
231 let result = crate::tui::drive(run_fut, &pipeline_name, handle, cancel).await;
232 if result
233 .as_ref()
234 .map(|s| s.failure_count() > 0)
235 .unwrap_or(true)
236 {
237 crate::tui::flush_logs_to_stderr(25);
240 }
241 result?
242 }
243 _ => {
244 #[cfg(feature = "cli-progress")]
245 let s = drive_progress_or_plain(run_fut, &pipeline_name, progress_handle).await?;
246 #[cfg(not(feature = "cli-progress"))]
247 let s = run_fut.await?;
248 s
249 }
250 };
251 #[cfg(not(feature = "cli-tui"))]
252 let summary = {
253 #[cfg(feature = "cli-progress")]
254 let s = drive_progress_or_plain(run_fut, &pipeline_name, progress_handle).await?;
255 #[cfg(not(feature = "cli-progress"))]
256 let s = run_fut.await?;
257 s
258 };
259
260 let finished_at = Utc::now();
261 let total_written: usize = summary.invocations.iter().map(|i| i.records_written).sum();
262 let success = summary
263 .invocations
264 .iter()
265 .filter(|i| i.error.is_none())
266 .count();
267 let failed = summary.failure_count();
268
269 #[cfg(feature = "catalog")]
272 if let Some((handle, snap_nodes, name)) = snapshot_inputs {
273 crate::catalog::snapshot::record_if_ok(
274 Some(&handle),
275 &name,
276 crate::catalog::snapshot::on_error_str(&cfg.execution),
277 &snap_nodes,
278 failed == 0,
279 chrono::Utc::now(),
280 )
281 .await;
282 }
283
284 tracing::info!(
285 pipeline = %pipeline_name,
286 invocations = summary.invocations.len(),
287 succeeded = success,
288 failed,
289 records_written = total_written,
290 "pipeline completed"
291 );
292 match args.output {
296 RunOutput::Text => eprintln!(
300 "{}: {} invocation{}, {} ok, {} failed, wrote {} record{}",
301 pipeline_name,
302 summary.invocations.len(),
303 if summary.invocations.len() == 1 {
304 ""
305 } else {
306 "s"
307 },
308 success,
309 failed,
310 total_written,
311 if total_written == 1 { "" } else { "s" }
312 ),
313 RunOutput::Json => {
314 let doc = summary_document(&pipeline_name, started_at, finished_at, &summary);
315 let rendered = serde_json::to_string_pretty(&doc).unwrap_or_else(|_| "{}".to_string());
316 println!("{}", crate::secrets::registry::redact(&rendered));
319 }
320 RunOutput::Ndjson => {
321 for row in summary_rows(&summary) {
322 let line = serde_json::to_string(&row).unwrap_or_else(|_| "{}".to_string());
323 println!("{}", crate::secrets::registry::redact(&line));
324 }
325 }
326 }
327
328 faucet_core::shutdown_otel();
331
332 if summary.had_failures() {
333 return Err(CliError::PipelineHadFailures { count: failed });
334 }
335 Ok(())
336}
337
338#[derive(Debug, Serialize)]
342pub(crate) struct RunRowSummary {
343 pub row_id: String,
344 #[serde(skip_serializing_if = "Option::is_none")]
345 pub parent_key: Option<String>,
346 pub source: String,
347 pub sink: String,
348 pub status: &'static str,
349 pub rows_in: Option<u64>,
350 pub rows_out: u64,
351 pub duration_ms: u64,
352 pub dlq_count: u64,
353 #[serde(skip_serializing_if = "Option::is_none")]
354 pub bookmark: Option<serde_json::Value>,
355 #[serde(skip_serializing_if = "Option::is_none")]
356 pub error: Option<String>,
357}
358
359#[derive(Debug, Serialize)]
361pub(crate) struct RunTotals {
362 pub rows: usize,
363 pub rows_out: u64,
364 pub dlq_count: u64,
365 pub ok: usize,
366 pub failed: usize,
367}
368
369#[derive(Debug, Serialize)]
371pub(crate) struct RunSummaryDocument {
372 pub pipeline: String,
373 pub started_at: DateTime<Utc>,
374 pub finished_at: DateTime<Utc>,
375 pub status: &'static str,
376 pub totals: RunTotals,
377 pub rows: Vec<RunRowSummary>,
378}
379
380pub(crate) fn summary_rows(summary: &RunSummary) -> Vec<RunRowSummary> {
382 summary
383 .invocations
384 .iter()
385 .map(|o| {
386 let m = o.metrics.clone().unwrap_or_default();
387 RunRowSummary {
388 row_id: o.row_id.clone(),
389 parent_key: o.parent_record_key.clone(),
390 source: m.source_kind,
391 sink: m.sink_kind,
392 status: if o.error.is_some() { "failed" } else { "ok" },
393 rows_in: m.records_read,
394 rows_out: o.records_written as u64,
395 duration_ms: m.duration_ms,
396 dlq_count: m.dlq_count,
397 bookmark: m.bookmark,
398 error: o.error.clone(),
399 }
400 })
401 .collect()
402}
403
404pub(crate) fn summary_document(
406 pipeline: &str,
407 started_at: DateTime<Utc>,
408 finished_at: DateTime<Utc>,
409 summary: &RunSummary,
410) -> RunSummaryDocument {
411 let rows = summary_rows(summary);
412 let failed = rows.iter().filter(|r| r.status == "failed").count();
413 let totals = RunTotals {
414 rows: rows.len(),
415 rows_out: rows.iter().map(|r| r.rows_out).sum(),
416 dlq_count: rows.iter().map(|r| r.dlq_count).sum(),
417 ok: rows.len() - failed,
418 failed,
419 };
420 RunSummaryDocument {
421 pipeline: pipeline.to_string(),
422 started_at,
423 finished_at,
424 status: if failed > 0 { "failed" } else { "ok" },
425 totals,
426 rows,
427 }
428}
429
430fn finish_topology_run(
433 pipeline_name: &str,
434 started_at: DateTime<Utc>,
435 finished_at: DateTime<Utc>,
436 summary: &RunSummary,
437 output: RunOutput,
438) -> CliResult<()> {
439 let total_written: usize = summary.invocations.iter().map(|i| i.records_written).sum();
440 let failed = summary.failure_count();
441 let success = summary.invocations.len() - failed;
442
443 tracing::info!(
444 pipeline = %pipeline_name,
445 nodes = summary.invocations.len(),
446 succeeded = success,
447 failed,
448 records_written = total_written,
449 "topology completed"
450 );
451
452 match output {
453 RunOutput::Text => eprintln!(
455 "{}: {} sink node{}, {} ok, {} failed, wrote {} record{}",
456 pipeline_name,
457 summary.invocations.len(),
458 if summary.invocations.len() == 1 {
459 ""
460 } else {
461 "s"
462 },
463 success,
464 failed,
465 total_written,
466 if total_written == 1 { "" } else { "s" }
467 ),
468 RunOutput::Json => {
469 let doc = summary_document(pipeline_name, started_at, finished_at, summary);
470 let rendered = serde_json::to_string_pretty(&doc).unwrap_or_else(|_| "{}".to_string());
471 println!("{}", crate::secrets::registry::redact(&rendered));
472 }
473 RunOutput::Ndjson => {
474 for row in summary_rows(summary) {
475 let line = serde_json::to_string(&row).unwrap_or_else(|_| "{}".to_string());
476 println!("{}", crate::secrets::registry::redact(&line));
477 }
478 }
479 }
480
481 faucet_core::shutdown_otel();
482
483 if summary.had_failures() {
484 return Err(CliError::TopologyHadFailures { count: failed });
485 }
486 Ok(())
487}
488
489#[cfg(test)]
490mod tests {
491 use super::*;
492 use crate::executor::{InvocationMetrics, InvocationOutcome};
493
494 fn outcome(id: &str, written: usize, err: Option<&str>) -> InvocationOutcome {
495 InvocationOutcome {
496 row_id: id.into(),
497 parent_record_key: None,
498 records_written: written,
499 error: err.map(|s| s.to_string()),
500 metrics: Some(InvocationMetrics {
501 source_kind: "rest".into(),
502 sink_kind: "jsonl".into(),
503 duration_ms: 12,
504 records_read: Some(written as u64),
505 dlq_count: 0,
506 bookmark: None,
507 }),
508 }
509 }
510
511 #[test]
512 fn summary_document_aggregates_rows_and_status() {
513 let summary = RunSummary {
514 invocations: vec![outcome("a", 3, None), outcome("b", 0, Some("boom"))],
515 };
516 let now = Utc::now();
517 let doc = summary_document("demo", now, now, &summary);
518 assert_eq!(doc.status, "failed");
519 assert_eq!(doc.totals.rows, 2);
520 assert_eq!(doc.totals.rows_out, 3);
521 assert_eq!(doc.totals.ok, 1);
522 assert_eq!(doc.totals.failed, 1);
523 assert_eq!(doc.rows[0].source, "rest");
524 assert_eq!(doc.rows[0].rows_in, Some(3));
525 assert_eq!(doc.rows[1].status, "failed");
526 assert_eq!(doc.rows[1].error.as_deref(), Some("boom"));
527 let json = serde_json::to_string(&doc).unwrap();
529 assert!(json.contains("\"pipeline\":\"demo\""), "{json}");
530 }
531
532 #[test]
533 fn all_ok_run_reports_ok_status() {
534 let summary = RunSummary {
535 invocations: vec![outcome("only", 5, None)],
536 };
537 let now = Utc::now();
538 let doc = summary_document("p", now, now, &summary);
539 assert_eq!(doc.status, "ok");
540 assert_eq!(doc.totals.failed, 0);
541 }
542
543 #[cfg(feature = "cli-progress")]
544 #[tokio::test]
545 async fn drive_progress_or_plain_without_handle_just_awaits() {
546 let out = super::drive_progress_or_plain(async { 7_usize }, "p", None).await;
548 assert_eq!(out, 7);
549 }
550
551 #[test]
552 fn run_clock_parses_rfc3339_date_and_defaults() {
553 let c = resolve_run_clock(Some("2026-01-31T12:00:00Z")).unwrap();
555 assert_eq!(c.format("%Y-%m-%d %H:%M").to_string(), "2026-01-31 12:00");
556 let c = resolve_run_clock(Some("2026-01-31")).unwrap();
558 assert_eq!(c.format("%Y-%m-%d %H:%M").to_string(), "2026-01-31 00:00");
559 let c = resolve_run_clock(None).unwrap();
561 assert!(c.format("%Y").to_string().parse::<i32>().unwrap() >= 2025);
562 assert!(resolve_run_clock(Some("not-a-date")).is_err());
564 }
565}