Skip to main content

faucet_cli/commands/
run.rs

1//! `faucet run` — load a pipeline config, expand the matrix, execute every
2//! invocation under bounded concurrency.
3
4use 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
12/// Parse the optional `--clock` override (RFC3339 or `YYYY-MM-DD`), or default
13/// to process start. Returned as a UTC fixed-offset clock for `${now.*}`.
14/// Shared with `faucet test` (the `--clock` flag and per-case `clock:` field).
15pub(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/// Drive the run future under the inline progress line when a recorder handle
37/// is present (interactive terminal, not `--quiet`/`--tui`), else await it
38/// plainly. Keeps the two summary call sites in `run` free of nested cfgs.
39#[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
51/// Execute the `run` subcommand.
52pub 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    // Exactly one observability install. A live view (the full-screen `--tui`
100    // or the inline `--progress` line) owns the Prometheus recorder so it can
101    // render the recorder's output; otherwise the standard install runs. The
102    // TUI supersedes the inline line when both are eligible.
103    #[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    // Inline progress line (#385): only when the TUI isn't taking over, stdout
122    // is a terminal, and the operator did not pass `--quiet`. On a non-TTY /
123    // `--quiet` this is `None` and the run falls back to periodic log lines.
124    #[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    // Topology mode (#71/#72): an explicit `pipeline.nodes` graph replaces the
150    // matrix entirely. Run it directly and report through the same summary
151    // surfaces (`--output text|json|ndjson`), then return.
152    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    // Capture the config-snapshot inputs (#374) before `nodes` / `catalog` are
181    // moved into the executor; recorded after a fully-successful run below. The
182    // snapshot represents the fully-resolved config (all rows) — runtime row
183    // selection is a per-invocation concern, so it is captured pre-selection.
184    #[cfg(feature = "catalog")]
185    let snapshot_inputs = catalog
186        .as_ref()
187        .map(|handle| (handle.clone(), nodes.clone(), pipeline_name.clone()));
188    // Runtime matrix-row selection (#370/#371/#376/#377): status gate → tag
189    // narrowing → parent policy → skip. A plain config (no `status`/`tags`, no
190    // selection flags) returns every row unchanged.
191    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    // The TUI wires `q` / Ctrl-C to this token: in-flight invocations stop at
195    // their next page boundary and flush (#146 H16). Plain runs keep `None`.
196    #[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            // Plain runs have no external cancel signal (the executor still
213            // cooperatively cancels in-flight rows on `on_error: stop`); a
214            // TUI session cancels via `q` / Ctrl-C.
215            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                // The failure context lived on the alternate screen — replay
238                // the tail of the log ring to stderr now that it's gone.
239                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    // Record the resolved config snapshot for `faucet plan --diff` (best-effort;
270    // #374 / #279) — only on a fully-successful run.
271    #[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    // End-of-run summary. `text` is the human line (default); `json` / `ndjson`
293    // emit a machine-readable summary and keep stdout otherwise clean so
294    // `faucet run` is scriptable in CI / cron / Slack (#390). Logs are on stderr.
295    match args.output {
296        // Human status → stderr, so stdout belongs exclusively to the sink /
297        // the machine-readable json|ndjson contract (#424). Piping a
298        // stdout-sink run stays clean.
299        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            // Belt-and-suspenders: scrub any resolved secret that reached an
317            // error string before it hits stdout (#390 secret-redaction AC).
318            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    // Flush any buffered OTLP telemetry before the process exits (no-op without
329    // the `otel` feature). Done on both the success and failure exit paths.
330    faucet_core::shutdown_otel();
331
332    if summary.had_failures() {
333        return Err(CliError::PipelineHadFailures { count: failed });
334    }
335    Ok(())
336}
337
338/// One matrix row's line in a `--output json`/`ndjson` summary (#390). Every
339/// field is a counter the pipeline already maintains; `rows_in` is `null` when
340/// input sampling was not active (no `lineage:` / `catalog:` block).
341#[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/// Aggregate counters across every row.
360#[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/// The full `--output json` document.
370#[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
380/// Project one invocation outcome into its summary row.
381pub(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
404/// Build the top-level `--output json` document from a run summary.
405pub(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
430/// Report a topology-mode run through the same `--output` surfaces as a matrix
431/// run, then map any node failures to the process exit code (#71/#72).
432fn 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        // Human status → stderr; stdout stays clean for the sink / json|ndjson (#424).
454        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        // Serializes cleanly.
528        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        // No recorder handle (non-TTY / --quiet) → the future is awaited plainly.
547        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        // RFC3339
554        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        // date-only → midnight UTC
557        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        // default = now (just assert it's Ok / recent year)
560        let c = resolve_run_clock(None).unwrap();
561        assert!(c.format("%Y").to_string().parse::<i32>().unwrap() >= 2025);
562        // bad input errors
563        assert!(resolve_run_clock(Some("not-a-date")).is_err());
564    }
565}