Skip to main content

faucet_cli/
lib.rs

1#![cfg_attr(docsrs, feature(doc_cfg))]
2
3//! # faucet-cli
4//!
5//! A config-driven runner for [`faucet-stream`](https://docs.rs/faucet-stream)
6//! pipelines.  Define a source, optional transforms, a sink, and (optionally)
7//! a state store in a YAML or JSON file, then run it with the `faucet` binary —
8//! no Rust code required.
9//!
10//! The library half of this crate exposes the same building blocks the binary
11//! uses (config parsing, env interpolation, the connector registry) so that
12//! integrations and tests can reuse them.
13
14pub mod auth_catalog;
15pub mod backfill;
16#[cfg(feature = "catalog")]
17pub mod catalog;
18pub mod cli;
19pub mod commands;
20pub mod compose;
21pub mod config;
22pub mod conformance;
23pub mod dlq_replay;
24pub mod env_config;
25pub mod env_loader;
26pub mod error;
27pub mod executor;
28pub mod expand;
29pub mod init_template;
30pub mod interpolate;
31#[cfg(feature = "lineage")]
32pub mod lineage_glue;
33/// Shared live-view metrics plumbing (recorder install + Prometheus-text
34/// sampler), compiled when either live-view feature is on.
35#[cfg(any(feature = "cli-tui", feature = "cli-progress"))]
36pub mod livemetrics;
37pub mod merge;
38#[cfg(feature = "notify")]
39pub mod notify;
40pub mod obs;
41pub mod pipeline_test;
42#[cfg(feature = "cli-progress")]
43pub mod progress;
44pub mod registry;
45pub mod registry_index;
46pub mod replication;
47pub mod scaffold;
48#[cfg(feature = "schedule")]
49pub mod schedule;
50pub mod schema_compose;
51pub mod secrets;
52pub mod select;
53#[cfg(feature = "serve")]
54pub mod serve;
55pub mod sla;
56pub mod state;
57pub mod transforms;
58#[cfg(feature = "cli-tui")]
59pub mod tui;
60
61pub use error::{CliError, CliResult};
62
63use crate::cli::{Cli, Command};
64use crate::registry::PluginRegistry;
65
66/// Entry point for a custom `faucet` binary that bundles third-party
67/// connectors.
68///
69/// A custom-CLI author writes a tiny `main.rs` that builds a [`PluginRegistry`]
70/// with their connectors registered on top of the built-ins and hands it here:
71///
72/// ```no_run
73/// use faucet_cli::registry::PluginRegistry;
74/// fn main() -> std::process::ExitCode {
75///     faucet_cli::run_main(PluginRegistry::with_builtins())
76/// }
77/// ```
78///
79/// This installs `registry` as the process-global connector registry (so every
80/// command — `run`, `validate`, `schema`, `list`, `preview`, `serve`, … — sees
81/// the custom connectors), parses argv, installs the tracing subscriber, and
82/// dispatches. The return value is the process exit code (the failed-probe /
83/// failed-case / failed-unit count for `doctor` / `test` / `backfill`, `1` for
84/// any other error, `0` on success). The stock `faucet` binary calls this with
85/// `PluginRegistry::with_builtins()`.
86pub fn run_main(registry: PluginRegistry) -> std::process::ExitCode {
87    use clap::Parser;
88    use std::process::ExitCode;
89
90    if let Err(err) = registry.install() {
91        commands::report(&err);
92        return ExitCode::from(1);
93    }
94
95    // Dynamic shell completion (#383): when the shell invokes us with the
96    // `COMPLETE` env var set, compute and print candidates, then exit — before
97    // any normal parsing/tracing/runtime setup. A no-op otherwise. The registry
98    // is installed above so connector-kind candidates reflect the live binary.
99    clap_complete::env::CompleteEnv::with_factory(<Cli as clap::CommandFactory>::command)
100        .complete();
101
102    let cli = Cli::parse();
103    #[cfg(feature = "serve")]
104    let is_serve = matches!(cli.command, Command::Serve(_));
105    #[cfg(not(feature = "serve"))]
106    let is_serve = false;
107    // A `--tui` run on a real terminal routes logs into the TUI's in-memory
108    // ring (the stdout subscriber would corrupt the alternate screen).
109    #[cfg(feature = "cli-tui")]
110    let is_tui = matches!(&cli.command, Command::Run(a) if tui::is_tui_session(a.tui));
111    #[cfg(not(feature = "cli-tui"))]
112    let is_tui = false;
113    // `serve` installs its own (redacting, run-scoped) subscriber; every other
114    // command uses the plain redacting fmt subscriber.
115    if !is_serve && !is_tui {
116        install_tracing(&cli.log_level);
117    }
118    #[cfg(feature = "cli-tui")]
119    if is_tui {
120        tui::install_tui_tracing(&cli.log_level);
121    }
122
123    let runtime = match tokio::runtime::Builder::new_multi_thread()
124        .enable_all()
125        .build()
126    {
127        Ok(rt) => rt,
128        Err(e) => {
129            eprintln!("error: failed to start async runtime: {e}");
130            return ExitCode::from(1);
131        }
132    };
133
134    runtime.block_on(async move {
135        match run_command(cli).await {
136            Ok(()) => ExitCode::SUCCESS,
137            // `doctor` / `test` / `backfill` already printed their report; the
138            // exit code is the failed count (clamped to 255).
139            Err(CliError::DoctorFailed { failed }) => ExitCode::from(failed.min(255) as u8),
140            Err(CliError::TestsFailed { failed }) => ExitCode::from(failed.min(255) as u8),
141            Err(CliError::BackfillFailed { failed }) => ExitCode::from(failed.min(255) as u8),
142            Err(err) => {
143                commands::report(&err);
144                ExitCode::from(1)
145            }
146        }
147    })
148}
149
150/// Dispatch a parsed [`Cli`] to the matching command. Public so custom hosts and
151/// integration tests can drive the exact same code path as [`run_main`] with a
152/// programmatically-built `Cli` (and a registry installed via
153/// [`PluginRegistry::install`]).
154pub async fn run_command(cli: Cli) -> CliResult<()> {
155    #[cfg(feature = "serve")]
156    let serve_log_level = cli.log_level.clone();
157    match cli.command {
158        Command::Run(args) => commands::run::run(args).await,
159        Command::Backfill(args) => commands::backfill::run(args).await,
160        Command::Replicate(args) => commands::replicate::run(args).await,
161        Command::Discover(args) => commands::discover::run(args).await,
162        Command::Validate(args) => commands::validate::run(args).await,
163        Command::Schema(args) => commands::schema::run(args).await,
164        Command::List(args) => commands::list::run(args).await,
165        Command::Search(args) => commands::search::run(args).await,
166        Command::Conformance(args) => commands::conformance::run(args).await,
167        Command::Install(args) => commands::install::run(args).await,
168        Command::Preview(args) => commands::preview::run(args).await,
169        Command::Plan(args) => commands::plan::run(args).await,
170        #[cfg(feature = "cli-dev")]
171        Command::Dev(args) => commands::dev::run(args).await,
172        Command::Init(args) => commands::init::run(args).await,
173        Command::New(args) => commands::new::run(args).await,
174        Command::Doctor(args) => commands::doctor::run(args).await,
175        Command::Test(args) => commands::test::run(args).await,
176        Command::Dlq(args) => commands::dlq::run(args).await,
177        #[cfg(feature = "contract")]
178        Command::Contract(args) => commands::contract::run(args).await,
179        #[cfg(feature = "masking")]
180        Command::Masking(args) => commands::masking::run(args).await,
181        #[cfg(feature = "schedule")]
182        Command::Schedule(args) => commands::schedule::run(args).await,
183        #[cfg(feature = "serve")]
184        Command::Serve(args) => commands::serve::run(args, serve_log_level).await,
185        #[cfg(feature = "notify")]
186        Command::Notify(args) => commands::notify::run(args).await,
187        #[cfg(feature = "catalog")]
188        Command::Catalog(args) => commands::catalog::run(args).await,
189        Command::Completions(args) => commands::completions::run(args.shell),
190        Command::Migrate(args) => commands::migrate::run(args).await,
191        Command::Fmt(args) => commands::fmt::run(args).await,
192        Command::Explain(args) => commands::explain::run(args).await,
193        #[cfg(feature = "catalog")]
194        Command::History(args) => commands::history::run(args).await,
195    }
196}
197
198#[cfg(feature = "observability")]
199fn install_tracing(level: &str) {
200    use crate::secrets::registry::RedactingMakeWriter;
201    use tracing_subscriber::EnvFilter;
202    let filter = EnvFilter::try_new(level).unwrap_or_else(|_| EnvFilter::new("info"));
203    let _ = tracing_subscriber::fmt()
204        .with_env_filter(filter)
205        .with_writer(RedactingMakeWriter)
206        .try_init();
207}
208
209/// Stub used when the `observability` feature is disabled. Logging falls back to
210/// whatever the host environment has wired (or nothing).
211#[cfg(not(feature = "observability"))]
212fn install_tracing(_level: &str) {}
213
214/// Convenience entry point for integration tests and custom hosts: parse a
215/// YAML config string, expand the matrix, and run all rows.
216///
217/// This skips the `install_observability` call that [`commands::run::run`]
218/// performs — callers can wire their own `metrics` recorder / tracing
219/// subscriber before calling this function (or not at all).
220pub async fn run_from_yaml_str(yaml: &str) -> CliResult<executor::RunSummary> {
221    // Parse first, then resolve ${env}/${file}/${secret} INTO the parsed tree
222    // (post-parse) so a resolved value can never alter the document's structure
223    // (F43) — mirroring the binary's `from_path` path.
224    let mut value: serde_json::Value =
225        serde_yaml::from_str(yaml).map_err(|e| CliError::ParseConfig {
226            path: std::path::PathBuf::from("<yaml-string>"),
227            message: e.to_string(),
228        })?;
229    interpolate::interpolate_value(&mut value)?;
230    let interpolated = serde_yaml::to_string(&value).map_err(|e| CliError::ParseConfig {
231        path: std::path::PathBuf::from("<yaml-string>"),
232        message: e.to_string(),
233    })?;
234    let mut cfg: config::PipelineConfig =
235        serde_yaml::from_str(&interpolated).map_err(|e| CliError::ParseConfig {
236            path: std::path::PathBuf::from("<yaml-string>"),
237            message: e.to_string(),
238        })?;
239    if cfg.version != 1 {
240        return Err(CliError::ParseConfig {
241            path: std::path::PathBuf::from("<yaml-string>"),
242            message: format!(
243                "unsupported pipeline version {}, only version 1 is recognised",
244                cfg.version
245            ),
246        });
247    }
248    crate::secrets::resolve_secrets(&mut cfg).await?;
249    let pipeline_name = cfg.name.clone().unwrap_or_else(|| "unnamed".to_string());
250    let auth = auth_catalog::build_auth_catalog(cfg.auth.as_ref())?;
251    let resilience = match &cfg.resilience {
252        Some(spec) => Some(spec.to_policy()?),
253        None => None,
254    };
255    #[cfg(feature = "catalog")]
256    let catalog = match cfg.catalog.as_ref() {
257        Some(spec) => Some(catalog::connect_from_spec(spec).await?),
258        None => None,
259    };
260    let nodes = expand::expand(&cfg)?;
261    executor::run_expanded(
262        nodes,
263        executor::ExecuteOptions {
264            pipeline_name,
265            execution: cfg.execution.clone(),
266            dry_run: false,
267            limit: None,
268            state_path_override: None,
269            shard: None,
270            auth,
271            clock: chrono::Utc::now().fixed_offset(),
272            cancel: None,
273            resilience,
274            sla: cfg.sla.clone(),
275            #[cfg(feature = "lineage")]
276            lineage: None,
277            #[cfg(feature = "lineage")]
278            lineage_cfg: None,
279            #[cfg(feature = "notify")]
280            notifier: None,
281            #[cfg(feature = "catalog")]
282            catalog,
283        },
284    )
285    .await
286}