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 if !args.param.is_empty() || !args.param_env.is_empty() {
76 return Err(CliError::Config(
77 "--param / --param-env have no effect in --from-env mode: the `params:` block \
78 lives in a config file, and every value already comes from the environment"
79 .into(),
80 ));
81 }
82 crate::env_config::from_process_env()?
83 } else {
84 let inputs = crate::config::RunInputs {
88 params: crate::params::collect_cli_params(&args.param)?,
89 env: crate::params::collect_env_overrides(&args.param_env)?
90 .into_iter()
91 .collect(),
92 mode: crate::params::BindMode::Strict,
93 };
94 PipelineConfig::from_path_async_with(
95 resolved_config_path
96 .as_ref()
97 .expect("YAML mode always resolves a path above"),
98 args.profile.as_deref(),
99 &inputs,
100 )
101 .await?
102 };
103
104 execute(cfg, args, resolved_config_path).await
105}
106
107pub(crate) async fn execute(
116 cfg: PipelineConfig,
117 args: RunArgs,
118 resolved_config_path: Option<std::path::PathBuf>,
119) -> CliResult<()> {
120 #[cfg(not(feature = "cli-tui"))]
121 if args.tui {
122 return Err(CliError::Config(
123 "--tui requires a binary built with the `cli-tui` feature \
124 (e.g. `cargo install faucet-cli --features cli-tui`)"
125 .into(),
126 ));
127 }
128 #[cfg(feature = "cli-tui")]
129 let tui_active = crate::tui::is_tui_session(args.tui);
130 #[cfg(not(feature = "cli-tui"))]
131 let tui_active = false;
132
133 #[cfg_attr(
138 not(any(feature = "cli-tui", feature = "cli-progress")),
139 allow(unused_mut)
140 )]
141 let mut live_view_owns_recorder = false;
142
143 #[cfg(feature = "cli-tui")]
144 let tui_handle = if tui_active {
145 let h = crate::tui::setup_observability(&cfg)?;
146 live_view_owns_recorder = true;
147 Some(h)
148 } else {
149 if args.tui {
150 tracing::info!("--tui: stdout is not a terminal; running without the TUI");
151 }
152 None
153 };
154
155 #[cfg(feature = "cli-progress")]
159 let progress_handle =
160 if !tui_active && crate::progress::is_progress_session(args.quiet, args.tui) {
161 let h = crate::livemetrics::setup_observability(&cfg)?;
162 live_view_owns_recorder = true;
163 Some(h)
164 } else {
165 None
166 };
167
168 if !live_view_owns_recorder {
169 crate::obs::install(&cfg)?;
170 }
171
172 let pipeline_name = cfg.name.clone().unwrap_or_else(|| {
173 resolved_config_path
174 .as_ref()
175 .and_then(|p| p.file_stem())
176 .and_then(|s| s.to_str())
177 .unwrap_or("pipeline")
178 .to_owned()
179 });
180
181 let auth = crate::auth_catalog::build_auth_catalog(cfg.auth.as_ref())?;
182
183 if crate::topology::is_topology(&cfg) {
187 let started_at = Utc::now();
188 let summary = crate::topology::run_topology(
189 &cfg,
190 &auth,
191 crate::topology::TopologyRunOptions {
192 cancel: None,
193 dry_run: args.dry_run,
194 limit: args.limit,
195 clock: Some(resolve_run_clock(args.clock.as_deref())?),
196 },
197 )
198 .await?;
199 let finished_at = Utc::now();
200 return finish_topology_run(
201 &pipeline_name,
202 started_at,
203 finished_at,
204 &summary,
205 args.output,
206 );
207 }
208
209 #[cfg(feature = "lineage")]
210 let lineage = crate::lineage_glue::build_emitter(cfg.lineage.as_ref())
211 .map_err(|e| CliError::Config(format!("lineage: {e}")))?;
212 let resilience = match &cfg.resilience {
213 Some(spec) => Some(spec.to_policy()?),
214 None => None,
215 };
216 #[cfg(feature = "notify")]
217 let notifier = crate::notify::Notifier::from_specs(&cfg.notifications)?;
218 #[cfg(feature = "catalog")]
219 let catalog = match cfg.catalog.as_ref() {
220 Some(spec) => Some(crate::catalog::connect_from_spec(spec).await?),
221 None => None,
222 };
223 let mut cfg = cfg;
227 crate::partition::resolve_config_bounds(&mut cfg, &auth).await?;
228 let nodes = expand(&cfg)?;
229 #[cfg(feature = "catalog")]
234 let snapshot_inputs = catalog
235 .as_ref()
236 .map(|handle| (handle.clone(), nodes.clone(), pipeline_name.clone()));
237 let selection =
241 crate::select::RunSelection::from_args(&args.selection, cfg.selection.as_ref())?;
242 let nodes = crate::select::select_nodes(nodes, &selection, !cfg.matrix.is_empty())?;
243 #[cfg(feature = "cli-tui")]
246 let tui_cancel = tui_active.then(faucet_core::CancellationToken::new);
247 #[cfg(not(feature = "cli-tui"))]
248 let tui_cancel: Option<faucet_core::CancellationToken> = None;
249 let started_at = Utc::now();
250 let run_fut = run_expanded(
251 nodes,
252 ExecuteOptions {
253 pipeline_name: pipeline_name.clone(),
254 run_id: None,
255 execution: cfg.execution.clone(),
256 dry_run: args.dry_run,
257 limit: args.limit,
258 state_path_override: args.state_path.clone(),
259 shard: None,
260 auth,
261 clock: resolve_run_clock(args.clock.as_deref())?,
262 cancel: tui_cancel.clone(),
266 resilience,
267 sla: cfg.sla.clone(),
268 reconcile: cfg.reconcile.clone(),
269 #[cfg(feature = "lineage")]
270 lineage,
271 #[cfg(feature = "lineage")]
272 lineage_cfg: cfg.lineage.clone(),
273 #[cfg(feature = "notify")]
274 notifier,
275 #[cfg(feature = "catalog")]
276 catalog,
277 },
278 );
279 #[cfg(feature = "cli-tui")]
280 let summary = match (tui_handle, tui_cancel) {
281 (Some(handle), Some(cancel)) => {
282 let result = crate::tui::drive(run_fut, &pipeline_name, handle, cancel).await;
283 if result
284 .as_ref()
285 .map(|s| s.failure_count() > 0)
286 .unwrap_or(true)
287 {
288 crate::tui::flush_logs_to_stderr(25);
291 }
292 result?
293 }
294 _ => {
295 #[cfg(feature = "cli-progress")]
296 let s = drive_progress_or_plain(run_fut, &pipeline_name, progress_handle).await?;
297 #[cfg(not(feature = "cli-progress"))]
298 let s = run_fut.await?;
299 s
300 }
301 };
302 #[cfg(not(feature = "cli-tui"))]
303 let summary = {
304 #[cfg(feature = "cli-progress")]
305 let s = drive_progress_or_plain(run_fut, &pipeline_name, progress_handle).await?;
306 #[cfg(not(feature = "cli-progress"))]
307 let s = run_fut.await?;
308 s
309 };
310
311 let finished_at = Utc::now();
312 let total_written: usize = summary.invocations.iter().map(|i| i.records_written).sum();
313 let success = summary
314 .invocations
315 .iter()
316 .filter(|i| i.error.is_none())
317 .count();
318 let failed = summary.failure_count();
319
320 #[cfg(feature = "catalog")]
323 if let Some((handle, snap_nodes, name)) = snapshot_inputs {
324 crate::catalog::snapshot::record_if_ok(
325 Some(&handle),
326 &name,
327 crate::catalog::snapshot::on_error_str(&cfg.execution),
328 &snap_nodes,
329 failed == 0,
330 chrono::Utc::now(),
331 )
332 .await;
333 }
334
335 tracing::info!(
336 pipeline = %pipeline_name,
337 invocations = summary.invocations.len(),
338 succeeded = success,
339 failed,
340 records_written = total_written,
341 "pipeline completed"
342 );
343 match args.output {
347 RunOutput::Text => eprintln!(
351 "{}: {} invocation{}, {} ok, {} failed, wrote {} record{}",
352 pipeline_name,
353 summary.invocations.len(),
354 if summary.invocations.len() == 1 {
355 ""
356 } else {
357 "s"
358 },
359 success,
360 failed,
361 total_written,
362 if total_written == 1 { "" } else { "s" }
363 ),
364 RunOutput::Json => {
365 let doc = summary_document(&pipeline_name, started_at, finished_at, &summary);
366 let rendered = serde_json::to_string_pretty(&doc).unwrap_or_else(|_| "{}".to_string());
367 println!("{}", crate::secrets::registry::redact(&rendered));
370 }
371 RunOutput::Ndjson => {
372 for row in summary_rows(&summary) {
373 let line = serde_json::to_string(&row).unwrap_or_else(|_| "{}".to_string());
374 println!("{}", crate::secrets::registry::redact(&line));
375 }
376 }
377 }
378
379 faucet_core::shutdown_otel();
382
383 if summary.had_failures() {
384 return Err(CliError::PipelineHadFailures { count: failed });
385 }
386 Ok(())
387}
388
389#[derive(Debug, Serialize)]
393pub(crate) struct RunRowSummary {
394 pub row_id: String,
395 #[serde(skip_serializing_if = "Option::is_none")]
396 pub parent_key: Option<String>,
397 pub source: String,
398 pub sink: String,
399 pub status: &'static str,
400 pub rows_in: Option<u64>,
401 pub rows_out: u64,
402 pub duration_ms: u64,
403 pub dlq_count: u64,
404 #[serde(skip_serializing_if = "Option::is_none")]
405 pub bookmark: Option<serde_json::Value>,
406 #[serde(skip_serializing_if = "Option::is_none")]
407 pub error: Option<String>,
408}
409
410#[derive(Debug, Serialize)]
412pub(crate) struct RunTotals {
413 pub rows: usize,
414 pub rows_out: u64,
415 pub dlq_count: u64,
416 pub ok: usize,
417 pub failed: usize,
418}
419
420#[derive(Debug, Serialize)]
422pub(crate) struct RunSummaryDocument {
423 pub pipeline: String,
424 pub started_at: DateTime<Utc>,
425 pub finished_at: DateTime<Utc>,
426 pub status: &'static str,
427 pub totals: RunTotals,
428 pub rows: Vec<RunRowSummary>,
429}
430
431pub(crate) fn summary_rows(summary: &RunSummary) -> Vec<RunRowSummary> {
433 summary
434 .invocations
435 .iter()
436 .map(|o| {
437 let m = o.metrics.clone().unwrap_or_default();
438 RunRowSummary {
439 row_id: o.row_id.clone(),
440 parent_key: o.parent_record_key.clone(),
441 source: m.source_kind,
442 sink: m.sink_kind,
443 status: if o.error.is_some() { "failed" } else { "ok" },
444 rows_in: m.records_read,
445 rows_out: o.records_written as u64,
446 duration_ms: m.duration_ms,
447 dlq_count: m.dlq_count,
448 bookmark: m.bookmark,
449 error: o.error.clone(),
450 }
451 })
452 .collect()
453}
454
455pub(crate) fn summary_document(
457 pipeline: &str,
458 started_at: DateTime<Utc>,
459 finished_at: DateTime<Utc>,
460 summary: &RunSummary,
461) -> RunSummaryDocument {
462 let rows = summary_rows(summary);
463 let failed = rows.iter().filter(|r| r.status == "failed").count();
464 let totals = RunTotals {
465 rows: rows.len(),
466 rows_out: rows.iter().map(|r| r.rows_out).sum(),
467 dlq_count: rows.iter().map(|r| r.dlq_count).sum(),
468 ok: rows.len() - failed,
469 failed,
470 };
471 RunSummaryDocument {
472 pipeline: pipeline.to_string(),
473 started_at,
474 finished_at,
475 status: if failed > 0 { "failed" } else { "ok" },
476 totals,
477 rows,
478 }
479}
480
481fn finish_topology_run(
484 pipeline_name: &str,
485 started_at: DateTime<Utc>,
486 finished_at: DateTime<Utc>,
487 summary: &RunSummary,
488 output: RunOutput,
489) -> CliResult<()> {
490 let total_written: usize = summary.invocations.iter().map(|i| i.records_written).sum();
491 let failed = summary.failure_count();
492 let success = summary.invocations.len() - failed;
493
494 tracing::info!(
495 pipeline = %pipeline_name,
496 nodes = summary.invocations.len(),
497 succeeded = success,
498 failed,
499 records_written = total_written,
500 "topology completed"
501 );
502
503 match output {
504 RunOutput::Text => eprintln!(
506 "{}: {} sink node{}, {} ok, {} failed, wrote {} record{}",
507 pipeline_name,
508 summary.invocations.len(),
509 if summary.invocations.len() == 1 {
510 ""
511 } else {
512 "s"
513 },
514 success,
515 failed,
516 total_written,
517 if total_written == 1 { "" } else { "s" }
518 ),
519 RunOutput::Json => {
520 let doc = summary_document(pipeline_name, started_at, finished_at, summary);
521 let rendered = serde_json::to_string_pretty(&doc).unwrap_or_else(|_| "{}".to_string());
522 println!("{}", crate::secrets::registry::redact(&rendered));
523 }
524 RunOutput::Ndjson => {
525 for row in summary_rows(summary) {
526 let line = serde_json::to_string(&row).unwrap_or_else(|_| "{}".to_string());
527 println!("{}", crate::secrets::registry::redact(&line));
528 }
529 }
530 }
531
532 faucet_core::shutdown_otel();
533
534 if summary.had_failures() {
535 return Err(CliError::TopologyHadFailures { count: failed });
536 }
537 Ok(())
538}
539
540#[cfg(test)]
541mod tests {
542 use super::*;
543 use crate::executor::{InvocationMetrics, InvocationOutcome};
544
545 fn outcome(id: &str, written: usize, err: Option<&str>) -> InvocationOutcome {
546 InvocationOutcome {
547 row_id: id.into(),
548 parent_record_key: None,
549 records_written: written,
550 error: err.map(|s| s.to_string()),
551 metrics: Some(InvocationMetrics {
552 source_kind: "rest".into(),
553 sink_kind: "jsonl".into(),
554 duration_ms: 12,
555 records_read: Some(written as u64),
556 dlq_count: 0,
557 bookmark: None,
558 }),
559 }
560 }
561
562 #[test]
563 fn summary_document_aggregates_rows_and_status() {
564 let summary = RunSummary {
565 invocations: vec![outcome("a", 3, None), outcome("b", 0, Some("boom"))],
566 };
567 let now = Utc::now();
568 let doc = summary_document("demo", now, now, &summary);
569 assert_eq!(doc.status, "failed");
570 assert_eq!(doc.totals.rows, 2);
571 assert_eq!(doc.totals.rows_out, 3);
572 assert_eq!(doc.totals.ok, 1);
573 assert_eq!(doc.totals.failed, 1);
574 assert_eq!(doc.rows[0].source, "rest");
575 assert_eq!(doc.rows[0].rows_in, Some(3));
576 assert_eq!(doc.rows[1].status, "failed");
577 assert_eq!(doc.rows[1].error.as_deref(), Some("boom"));
578 let json = serde_json::to_string(&doc).unwrap();
580 assert!(json.contains("\"pipeline\":\"demo\""), "{json}");
581 }
582
583 #[test]
584 fn all_ok_run_reports_ok_status() {
585 let summary = RunSummary {
586 invocations: vec![outcome("only", 5, None)],
587 };
588 let now = Utc::now();
589 let doc = summary_document("p", now, now, &summary);
590 assert_eq!(doc.status, "ok");
591 assert_eq!(doc.totals.failed, 0);
592 }
593
594 #[cfg(feature = "cli-progress")]
595 #[tokio::test]
596 async fn drive_progress_or_plain_without_handle_just_awaits() {
597 let out = super::drive_progress_or_plain(async { 7_usize }, "p", None).await;
599 assert_eq!(out, 7);
600 }
601
602 #[test]
603 fn run_clock_parses_rfc3339_date_and_defaults() {
604 let c = resolve_run_clock(Some("2026-01-31T12:00:00Z")).unwrap();
606 assert_eq!(c.format("%Y-%m-%d %H:%M").to_string(), "2026-01-31 12:00");
607 let c = resolve_run_clock(Some("2026-01-31")).unwrap();
609 assert_eq!(c.format("%Y-%m-%d %H:%M").to_string(), "2026-01-31 00:00");
610 let c = resolve_run_clock(None).unwrap();
612 assert!(c.format("%Y").to_string().parse::<i32>().unwrap() >= 2025);
613 assert!(resolve_run_clock(Some("not-a-date")).is_err());
615 }
616}