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        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        // Typed run params (#444): `--param name=value` / `--param-env NAME[=V]`
85        // are bound before the typed parse, so `${param.*}` never reaches a
86        // connector. A config with no `params:` block is unaffected.
87        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
107/// Execute an already-loaded config: install observability, build the auth
108/// catalog, expand + select rows, run, and report.
109///
110/// Split out of [`run`] so a caller that obtains its config some other way runs
111/// through the *identical* path — `faucet template run` materializes a
112/// registered template and hands it straight here, rather than re-implementing
113/// (and inevitably under-implementing) the lineage / notification / catalog /
114/// SLA / progress wiring below.
115pub(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    // Exactly one observability install. A live view (the full-screen `--tui`
134    // or the inline `--progress` line) owns the Prometheus recorder so it can
135    // render the recorder's output; otherwise the standard install runs. The
136    // TUI supersedes the inline line when both are eligible.
137    #[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    // Inline progress line (#385): only when the TUI isn't taking over, stdout
156    // is a terminal, and the operator did not pass `--quiet`. On a non-TTY /
157    // `--quiet` this is `None` and the run falls back to periodic log lines.
158    #[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    // Topology mode (#71/#72): an explicit `pipeline.nodes` graph replaces the
184    // matrix entirely. Run it directly and report through the same summary
185    // surfaces (`--output text|json|ndjson`), then return.
186    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    // Resolve discoverable partition bounds before planning (#479): `expand` is
224    // synchronous and has no registry access, and it needs concrete bounds. A
225    // config with no probes does no I/O here.
226    let mut cfg = cfg;
227    crate::partition::resolve_config_bounds(&mut cfg, &auth).await?;
228    let nodes = expand(&cfg)?;
229    // Capture the config-snapshot inputs (#374) before `nodes` / `catalog` are
230    // moved into the executor; recorded after a fully-successful run below. The
231    // snapshot represents the fully-resolved config (all rows) — runtime row
232    // selection is a per-invocation concern, so it is captured pre-selection.
233    #[cfg(feature = "catalog")]
234    let snapshot_inputs = catalog
235        .as_ref()
236        .map(|handle| (handle.clone(), nodes.clone(), pipeline_name.clone()));
237    // Runtime matrix-row selection (#370/#371/#376/#377): status gate → tag
238    // narrowing → parent policy → skip. A plain config (no `status`/`tags`, no
239    // selection flags) returns every row unchanged.
240    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    // The TUI wires `q` / Ctrl-C to this token: in-flight invocations stop at
244    // their next page boundary and flush (#146 H16). Plain runs keep `None`.
245    #[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            // Plain runs have no external cancel signal (the executor still
263            // cooperatively cancels in-flight rows on `on_error: stop`); a
264            // TUI session cancels via `q` / Ctrl-C.
265            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                // The failure context lived on the alternate screen — replay
289                // the tail of the log ring to stderr now that it's gone.
290                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    // Record the resolved config snapshot for `faucet plan --diff` (best-effort;
321    // #374 / #279) — only on a fully-successful run.
322    #[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    // End-of-run summary. `text` is the human line (default); `json` / `ndjson`
344    // emit a machine-readable summary and keep stdout otherwise clean so
345    // `faucet run` is scriptable in CI / cron / Slack (#390). Logs are on stderr.
346    match args.output {
347        // Human status → stderr, so stdout belongs exclusively to the sink /
348        // the machine-readable json|ndjson contract (#424). Piping a
349        // stdout-sink run stays clean.
350        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            // Belt-and-suspenders: scrub any resolved secret that reached an
368            // error string before it hits stdout (#390 secret-redaction AC).
369            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    // Flush any buffered OTLP telemetry before the process exits (no-op without
380    // the `otel` feature). Done on both the success and failure exit paths.
381    faucet_core::shutdown_otel();
382
383    if summary.had_failures() {
384        return Err(CliError::PipelineHadFailures { count: failed });
385    }
386    Ok(())
387}
388
389/// One matrix row's line in a `--output json`/`ndjson` summary (#390). Every
390/// field is a counter the pipeline already maintains; `rows_in` is `null` when
391/// input sampling was not active (no `lineage:` / `catalog:` block).
392#[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/// Aggregate counters across every row.
411#[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/// The full `--output json` document.
421#[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
431/// Project one invocation outcome into its summary row.
432pub(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
455/// Build the top-level `--output json` document from a run summary.
456pub(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
481/// Report a topology-mode run through the same `--output` surfaces as a matrix
482/// run, then map any node failures to the process exit code (#71/#72).
483fn 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        // Human status → stderr; stdout stays clean for the sink / json|ndjson (#424).
505        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        // Serializes cleanly.
579        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        // No recorder handle (non-TTY / --quiet) → the future is awaited plainly.
598        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        // RFC3339
605        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        // date-only → midnight UTC
608        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        // default = now (just assert it's Ok / recent year)
611        let c = resolve_run_clock(None).unwrap();
612        assert!(c.format("%Y").to_string().parse::<i32>().unwrap() >= 2025);
613        // bad input errors
614        assert!(resolve_run_clock(Some("not-a-date")).is_err());
615    }
616}