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 #[cfg(feature = "lineage")]
149 let lineage = crate::lineage_glue::build_emitter(cfg.lineage.as_ref())
150 .map_err(|e| CliError::Config(format!("lineage: {e}")))?;
151 let resilience = match &cfg.resilience {
152 Some(spec) => Some(spec.to_policy()?),
153 None => None,
154 };
155 #[cfg(feature = "notify")]
156 let notifier = crate::notify::Notifier::from_specs(&cfg.notifications)?;
157 #[cfg(feature = "catalog")]
158 let catalog = match cfg.catalog.as_ref() {
159 Some(spec) => Some(crate::catalog::connect_from_spec(spec).await?),
160 None => None,
161 };
162 let nodes = expand(&cfg)?;
163 #[cfg(feature = "catalog")]
168 let snapshot_inputs = catalog
169 .as_ref()
170 .map(|handle| (handle.clone(), nodes.clone(), pipeline_name.clone()));
171 let selection =
175 crate::select::RunSelection::from_args(&args.selection, cfg.selection.as_ref())?;
176 let nodes = crate::select::select_nodes(nodes, &selection, !cfg.matrix.is_empty())?;
177 #[cfg(feature = "cli-tui")]
180 let tui_cancel = tui_active.then(faucet_core::CancellationToken::new);
181 #[cfg(not(feature = "cli-tui"))]
182 let tui_cancel: Option<faucet_core::CancellationToken> = None;
183 let started_at = Utc::now();
184 let run_fut = run_expanded(
185 nodes,
186 ExecuteOptions {
187 pipeline_name: pipeline_name.clone(),
188 execution: cfg.execution.clone(),
189 dry_run: args.dry_run,
190 limit: args.limit,
191 state_path_override: args.state_path.clone(),
192 shard: None,
193 auth,
194 clock: resolve_run_clock(args.clock.as_deref())?,
195 cancel: tui_cancel.clone(),
199 resilience,
200 sla: cfg.sla.clone(),
201 #[cfg(feature = "lineage")]
202 lineage,
203 #[cfg(feature = "lineage")]
204 lineage_cfg: cfg.lineage.clone(),
205 #[cfg(feature = "notify")]
206 notifier,
207 #[cfg(feature = "catalog")]
208 catalog,
209 },
210 );
211 #[cfg(feature = "cli-tui")]
212 let summary = match (tui_handle, tui_cancel) {
213 (Some(handle), Some(cancel)) => {
214 let result = crate::tui::drive(run_fut, &pipeline_name, handle, cancel).await;
215 if result
216 .as_ref()
217 .map(|s| s.failure_count() > 0)
218 .unwrap_or(true)
219 {
220 crate::tui::flush_logs_to_stderr(25);
223 }
224 result?
225 }
226 _ => {
227 #[cfg(feature = "cli-progress")]
228 let s = drive_progress_or_plain(run_fut, &pipeline_name, progress_handle).await?;
229 #[cfg(not(feature = "cli-progress"))]
230 let s = run_fut.await?;
231 s
232 }
233 };
234 #[cfg(not(feature = "cli-tui"))]
235 let summary = {
236 #[cfg(feature = "cli-progress")]
237 let s = drive_progress_or_plain(run_fut, &pipeline_name, progress_handle).await?;
238 #[cfg(not(feature = "cli-progress"))]
239 let s = run_fut.await?;
240 s
241 };
242
243 let finished_at = Utc::now();
244 let total_written: usize = summary.invocations.iter().map(|i| i.records_written).sum();
245 let success = summary
246 .invocations
247 .iter()
248 .filter(|i| i.error.is_none())
249 .count();
250 let failed = summary.failure_count();
251
252 #[cfg(feature = "catalog")]
255 if let Some((handle, snap_nodes, name)) = snapshot_inputs {
256 crate::catalog::snapshot::record_if_ok(
257 Some(&handle),
258 &name,
259 crate::catalog::snapshot::on_error_str(&cfg.execution),
260 &snap_nodes,
261 failed == 0,
262 chrono::Utc::now(),
263 )
264 .await;
265 }
266
267 tracing::info!(
268 pipeline = %pipeline_name,
269 invocations = summary.invocations.len(),
270 succeeded = success,
271 failed,
272 records_written = total_written,
273 "pipeline completed"
274 );
275 match args.output {
279 RunOutput::Text => println!(
280 "{}: {} invocation{}, {} ok, {} failed, wrote {} record{}",
281 pipeline_name,
282 summary.invocations.len(),
283 if summary.invocations.len() == 1 {
284 ""
285 } else {
286 "s"
287 },
288 success,
289 failed,
290 total_written,
291 if total_written == 1 { "" } else { "s" }
292 ),
293 RunOutput::Json => {
294 let doc = summary_document(&pipeline_name, started_at, finished_at, &summary);
295 let rendered = serde_json::to_string_pretty(&doc).unwrap_or_else(|_| "{}".to_string());
296 println!("{}", crate::secrets::registry::redact(&rendered));
299 }
300 RunOutput::Ndjson => {
301 for row in summary_rows(&summary) {
302 let line = serde_json::to_string(&row).unwrap_or_else(|_| "{}".to_string());
303 println!("{}", crate::secrets::registry::redact(&line));
304 }
305 }
306 }
307
308 faucet_core::shutdown_otel();
311
312 if summary.had_failures() {
313 return Err(CliError::PipelineHadFailures { count: failed });
314 }
315 Ok(())
316}
317
318#[derive(Debug, Serialize)]
322pub(crate) struct RunRowSummary {
323 pub row_id: String,
324 #[serde(skip_serializing_if = "Option::is_none")]
325 pub parent_key: Option<String>,
326 pub source: String,
327 pub sink: String,
328 pub status: &'static str,
329 pub rows_in: Option<u64>,
330 pub rows_out: u64,
331 pub duration_ms: u64,
332 pub dlq_count: u64,
333 #[serde(skip_serializing_if = "Option::is_none")]
334 pub bookmark: Option<serde_json::Value>,
335 #[serde(skip_serializing_if = "Option::is_none")]
336 pub error: Option<String>,
337}
338
339#[derive(Debug, Serialize)]
341pub(crate) struct RunTotals {
342 pub rows: usize,
343 pub rows_out: u64,
344 pub dlq_count: u64,
345 pub ok: usize,
346 pub failed: usize,
347}
348
349#[derive(Debug, Serialize)]
351pub(crate) struct RunSummaryDocument {
352 pub pipeline: String,
353 pub started_at: DateTime<Utc>,
354 pub finished_at: DateTime<Utc>,
355 pub status: &'static str,
356 pub totals: RunTotals,
357 pub rows: Vec<RunRowSummary>,
358}
359
360pub(crate) fn summary_rows(summary: &RunSummary) -> Vec<RunRowSummary> {
362 summary
363 .invocations
364 .iter()
365 .map(|o| {
366 let m = o.metrics.clone().unwrap_or_default();
367 RunRowSummary {
368 row_id: o.row_id.clone(),
369 parent_key: o.parent_record_key.clone(),
370 source: m.source_kind,
371 sink: m.sink_kind,
372 status: if o.error.is_some() { "failed" } else { "ok" },
373 rows_in: m.records_read,
374 rows_out: o.records_written as u64,
375 duration_ms: m.duration_ms,
376 dlq_count: m.dlq_count,
377 bookmark: m.bookmark,
378 error: o.error.clone(),
379 }
380 })
381 .collect()
382}
383
384pub(crate) fn summary_document(
386 pipeline: &str,
387 started_at: DateTime<Utc>,
388 finished_at: DateTime<Utc>,
389 summary: &RunSummary,
390) -> RunSummaryDocument {
391 let rows = summary_rows(summary);
392 let failed = rows.iter().filter(|r| r.status == "failed").count();
393 let totals = RunTotals {
394 rows: rows.len(),
395 rows_out: rows.iter().map(|r| r.rows_out).sum(),
396 dlq_count: rows.iter().map(|r| r.dlq_count).sum(),
397 ok: rows.len() - failed,
398 failed,
399 };
400 RunSummaryDocument {
401 pipeline: pipeline.to_string(),
402 started_at,
403 finished_at,
404 status: if failed > 0 { "failed" } else { "ok" },
405 totals,
406 rows,
407 }
408}
409
410#[cfg(test)]
411mod tests {
412 use super::*;
413 use crate::executor::{InvocationMetrics, InvocationOutcome};
414
415 fn outcome(id: &str, written: usize, err: Option<&str>) -> InvocationOutcome {
416 InvocationOutcome {
417 row_id: id.into(),
418 parent_record_key: None,
419 records_written: written,
420 error: err.map(|s| s.to_string()),
421 metrics: Some(InvocationMetrics {
422 source_kind: "rest".into(),
423 sink_kind: "jsonl".into(),
424 duration_ms: 12,
425 records_read: Some(written as u64),
426 dlq_count: 0,
427 bookmark: None,
428 }),
429 }
430 }
431
432 #[test]
433 fn summary_document_aggregates_rows_and_status() {
434 let summary = RunSummary {
435 invocations: vec![outcome("a", 3, None), outcome("b", 0, Some("boom"))],
436 };
437 let now = Utc::now();
438 let doc = summary_document("demo", now, now, &summary);
439 assert_eq!(doc.status, "failed");
440 assert_eq!(doc.totals.rows, 2);
441 assert_eq!(doc.totals.rows_out, 3);
442 assert_eq!(doc.totals.ok, 1);
443 assert_eq!(doc.totals.failed, 1);
444 assert_eq!(doc.rows[0].source, "rest");
445 assert_eq!(doc.rows[0].rows_in, Some(3));
446 assert_eq!(doc.rows[1].status, "failed");
447 assert_eq!(doc.rows[1].error.as_deref(), Some("boom"));
448 let json = serde_json::to_string(&doc).unwrap();
450 assert!(json.contains("\"pipeline\":\"demo\""), "{json}");
451 }
452
453 #[test]
454 fn all_ok_run_reports_ok_status() {
455 let summary = RunSummary {
456 invocations: vec![outcome("only", 5, None)],
457 };
458 let now = Utc::now();
459 let doc = summary_document("p", now, now, &summary);
460 assert_eq!(doc.status, "ok");
461 assert_eq!(doc.totals.failed, 0);
462 }
463
464 #[cfg(feature = "cli-progress")]
465 #[tokio::test]
466 async fn drive_progress_or_plain_without_handle_just_awaits() {
467 let out = super::drive_progress_or_plain(async { 7_usize }, "p", None).await;
469 assert_eq!(out, 7);
470 }
471
472 #[test]
473 fn run_clock_parses_rfc3339_date_and_defaults() {
474 let c = resolve_run_clock(Some("2026-01-31T12:00:00Z")).unwrap();
476 assert_eq!(c.format("%Y-%m-%d %H:%M").to_string(), "2026-01-31 12:00");
477 let c = resolve_run_clock(Some("2026-01-31")).unwrap();
479 assert_eq!(c.format("%Y-%m-%d %H:%M").to_string(), "2026-01-31 00:00");
480 let c = resolve_run_clock(None).unwrap();
482 assert!(c.format("%Y").to_string().parse::<i32>().unwrap() >= 2025);
483 assert!(resolve_run_clock(Some("not-a-date")).is_err());
485 }
486}