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