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