Skip to main content

librebar/
lib.rs

1//! Liblibrebar: opinionated application foundation for Rust CLIs and services.
2//!
3//! Feature-gated modules for CLI, config, logging, and more.
4//! Each module is usable independently (escape hatches) or wired
5//! together through the builder. Enable only what you need.
6//!
7//! # Features
8//!
9//! Every module is behind a Cargo feature flag. No features are enabled
10//! by default — you opt in to exactly what your application needs.
11//!
12//! ## Core application features
13//!
14//! | Feature | Module | Use when your app needs... |
15//! |---------|--------|---------------------------|
16//! | `cli` | [`cli`] | Clap-based CLI with `--quiet`, `--verbose`, `--color`, `--json` flags |
17//! | `config` | [`config`] | Multi-format config discovery (TOML/YAML/JSON) with layered merge |
18//! | `logging` | [`logging`] | Structured JSONL file logging with rotation |
19//! | `shutdown` | [`shutdown`] | Graceful shutdown with SIGINT/SIGTERM handling |
20//! | `crash` | [`crash`] | Structured JSON crash dumps on panic |
21//!
22//! ## Networking and data
23//!
24//! | Feature | Module | Use when your app needs... |
25//! |---------|--------|---------------------------|
26//! | `http` | [`http`] | HTTPS client with tracing, timeouts, user-agent (rustls + Mozilla CA roots) |
27//! | `cache` | [`cache`] | File-based key-value cache with TTL (XDG cache directory) |
28//! | `update` | [`update`] | "Update available" notifications via GitHub releases API |
29//!
30//! ## Integration features
31//!
32//! | Feature | Module | Use when your app needs... |
33//! |---------|--------|---------------------------|
34//! | `otel` | [`otel`] | OpenTelemetry tracing export (OTLP/HTTP) |
35//! | `otel-grpc` | [`otel`] | OpenTelemetry via gRPC (adds Tonic transport) |
36//! | `mcp` | [`mcp`] | Model Context Protocol server support |
37//!
38//! ## Operational features
39//!
40//! | Feature | Module | Use when your app needs... |
41//! |---------|--------|---------------------------|
42//! | `lockfile` | [`lockfile`] | Exclusive file locks to prevent concurrent instances |
43//! | `dispatch` | [`dispatch`] | Git-style `{app}-{subcommand}` plugin lookup on PATH |
44//! | `diagnostics` | [`diagnostics`] | `doctor` command framework + `.tar.gz` debug bundles |
45//!
46//! ## Benchmarking (dev-only)
47//!
48//! | Feature | Module | Use when your project needs... |
49//! |---------|--------|-------------------------------|
50//! | `bench` | [`bench`](mod@bench) | Wall-clock benchmarks via [divan](https://crates.io/crates/divan) (any platform) |
51//! | `bench-gungraun` | [`bench`](mod@bench) | Instruction-count benchmarks via [gungraun](https://crates.io/crates/gungraun) / Valgrind (Linux/Intel) |
52//!
53//! ## Feature implications
54//!
55//! Some features automatically enable their dependencies:
56//!
57//! - `update` implies `http` + `cache` (needs both for network checks and 24h caching)
58//! - `dispatch` implies `cli` (subcommand dispatch extends the CLI)
59//! - `diagnostics` implies `config` + `logging` (bundles need config sources and log paths)
60//! - `otel` implies `logging` (OTEL layer composes with the tracing subscriber)
61//! - `otel-grpc` implies `otel`
62//!
63//! ## Typical feature sets
64//!
65//! ```toml
66//! # Minimal CLI tool
67//! librebar = { version = "0.1", features = ["cli", "config", "logging"] }
68//!
69//! # CLI tool with update checks
70//! librebar = { version = "0.1", features = ["cli", "config", "logging", "shutdown", "update"] }
71//!
72//! # Long-running service with observability
73//! librebar = { version = "0.1", features = ["cli", "config", "logging", "shutdown", "otel", "crash"] }
74//!
75//! # Plugin-extensible CLI (git-style subcommands)
76//! librebar = { version = "0.1", features = ["cli", "config", "logging", "dispatch"] }
77//! ```
78//!
79//! # Builder usage
80//!
81//! The builder wires enabled features together in the correct init order:
82//!
83//! ```no_run
84//! use clap::{Parser, Subcommand};
85//! # use serde::{Deserialize, Serialize};
86//! #
87//! # #[derive(Default, Deserialize, Serialize)]
88//! # #[serde(default)]
89//! # struct Config {}
90//!
91//! #[derive(Parser)]
92//! struct Cli {
93//!     #[command(flatten)]
94//!     pub common: librebar::cli::CommonArgs,
95//!     #[command(subcommand)]
96//!     pub command: Option<Commands>,
97//! }
98//!
99//! # #[derive(Subcommand)]
100//! # enum Commands { Run }
101//! #
102//! # fn main() -> librebar::Result<()> {
103//! let cli = Cli::parse();
104//!
105//! let app = librebar::init(env!("CARGO_PKG_NAME"))
106//!     .with_version(env!("CARGO_PKG_VERSION"))
107//!     .with_cli(cli.common)
108//!     .config::<Config>()
109//!     .logging()
110//!     .shutdown()
111//!     .crash_handler()
112//!     .start()?;
113//! # let _ = app;
114//! # Ok(())
115//! # }
116//! ```
117//!
118//! Modules not wired through the builder (lockfile, http, cache, update,
119//! dispatch, diagnostics, bench) are used directly via their public APIs.
120//!
121//! # Type-state pattern
122//!
123//! The builder uses a type-state transition to carry the config type:
124//! - [`init()`] returns [`Builder`]
125//! - [`Builder::config`] / [`Builder::config_from_file`] / [`Builder::with_config`]
126//!   transition to [`ConfiguredBuilder<C>`]
127//! - Each builder has its own [`start()`](Builder::start) returning the
128//!   appropriate [`App`] type (`App<()>` or `App<C>`)
129//!
130//! # Initialization order
131//!
132//! [`start()`](Builder::start) initializes subsystems in this order:
133//! 1. Load config (if requested via `.config::<C>()` or `.config_from_file()`)
134//! 2. Initialize logging (reads verbosity from CLI flags if provided)
135//! 3. Return [`App<C>`] holding all initialized state and guards
136#![deny(unsafe_code)]
137
138pub mod error;
139
140#[cfg(feature = "cli")]
141pub mod cli;
142
143#[cfg(feature = "config")]
144pub mod config;
145
146#[cfg(feature = "logging")]
147pub mod logging;
148
149#[cfg(feature = "otel")]
150pub mod otel;
151
152#[cfg(feature = "shutdown")]
153pub mod shutdown;
154
155#[cfg(feature = "crash")]
156pub mod crash;
157
158#[cfg(feature = "mcp")]
159pub mod mcp;
160
161#[cfg(feature = "lockfile")]
162pub mod lockfile;
163
164#[cfg(feature = "http")]
165pub mod http;
166
167#[cfg(feature = "cache")]
168pub mod cache;
169
170#[cfg(feature = "update")]
171pub mod update;
172
173#[cfg(feature = "dispatch")]
174pub mod dispatch;
175
176#[cfg(feature = "diagnostics")]
177pub mod diagnostics;
178
179#[cfg(any(feature = "bench", feature = "bench-gungraun"))]
180pub mod bench;
181
182#[cfg(feature = "logging")]
183use tracing_subscriber::layer::SubscriberExt;
184#[cfg(feature = "logging")]
185use tracing_subscriber::util::SubscriberInitExt;
186
187pub use error::{Error, Result};
188
189// ─── App ────────────────────────────────────────────────────────────
190
191/// The initialized application state.
192///
193/// Holds config, CLI args, and guards for logging/tracing.
194/// `C` is the user's config type (defaults to `()` when config is not used).
195pub struct App<C = ()> {
196    app_name: String,
197    version: String,
198    config: C,
199    #[cfg(feature = "config")]
200    config_sources: config::ConfigSources,
201    #[cfg(feature = "cli")]
202    cli: cli::CommonArgs,
203    #[cfg(feature = "shutdown")]
204    shutdown_handle: Option<shutdown::ShutdownHandle>,
205    #[cfg(feature = "otel")]
206    _otel_guard: Option<otel::OtelGuard>,
207    #[cfg(feature = "logging")]
208    _logging_guard: Option<logging::LoggingGuard>,
209}
210
211impl<C> App<C> {
212    /// Returns a reference to the loaded configuration.
213    pub const fn config(&self) -> &C {
214        &self.config
215    }
216
217    /// Returns the application name.
218    pub fn app_name(&self) -> &str {
219        &self.app_name
220    }
221
222    /// Returns the application version.
223    pub fn version(&self) -> &str {
224        &self.version
225    }
226}
227
228#[cfg(feature = "config")]
229impl<C> App<C> {
230    /// Returns metadata about which config files were loaded.
231    pub const fn config_sources(&self) -> &config::ConfigSources {
232        &self.config_sources
233    }
234}
235
236#[cfg(feature = "cli")]
237impl<C> App<C> {
238    /// Returns the parsed common CLI arguments.
239    pub const fn cli(&self) -> &cli::CommonArgs {
240        &self.cli
241    }
242}
243
244#[cfg(feature = "shutdown")]
245impl<C> App<C> {
246    /// Get a shutdown token for waiting on graceful shutdown.
247    ///
248    /// Returns `None` if `.shutdown()` was not called on the builder.
249    pub fn shutdown_token(&self) -> Option<shutdown::ShutdownToken> {
250        self.shutdown_handle.as_ref().map(|h| h.token())
251    }
252
253    /// Trigger shutdown programmatically.
254    pub fn shutdown(&self) {
255        if let Some(ref handle) = self.shutdown_handle {
256            handle.shutdown();
257        }
258    }
259}
260
261// ─── Builder ────────────────────────────────────────────────────────
262
263/// Shared state for subsystem toggles.
264///
265/// Used internally by [`Builder`] and [`ConfiguredBuilder`]. Both outer
266/// types delegate their builder methods here via [`builder_methods!`].
267struct BuilderInner {
268    app_name: String,
269    version: Option<String>,
270    #[cfg(feature = "cli")]
271    cli: Option<cli::CommonArgs>,
272    #[cfg(feature = "logging")]
273    enable_logging: bool,
274    #[cfg(feature = "logging")]
275    log_dir: Option<std::path::PathBuf>,
276    #[cfg(feature = "otel")]
277    enable_otel: bool,
278    #[cfg(feature = "shutdown")]
279    enable_shutdown: bool,
280    #[cfg(feature = "crash")]
281    enable_crash: bool,
282}
283
284/// Intermediate result from [`BuilderInner::init_subsystems`].
285struct SubsystemInit {
286    app_name: String,
287    version: String,
288    #[cfg(feature = "cli")]
289    cli: cli::CommonArgs,
290    #[cfg(feature = "shutdown")]
291    shutdown_handle: Option<shutdown::ShutdownHandle>,
292    #[cfg(feature = "otel")]
293    otel_guard: Option<otel::OtelGuard>,
294    #[cfg(feature = "logging")]
295    logging_guard: Option<logging::LoggingGuard>,
296}
297
298impl BuilderInner {
299    #[cfg(all(feature = "logging", feature = "cli"))]
300    fn cli_flags(&self) -> (bool, u8) {
301        self.cli
302            .as_ref()
303            .map_or((false, 0), |c| (c.quiet, c.verbose))
304    }
305
306    #[cfg(all(feature = "logging", not(feature = "cli")))]
307    fn cli_flags(&self) -> (bool, u8) {
308        (false, 0)
309    }
310
311    /// Initialize tracing, shutdown, and crash subsystems.
312    ///
313    /// Consumes the inner builder and returns the initialized
314    /// subsystem handles, ready to be assembled into an [`App`].
315    fn init_subsystems(self) -> Result<SubsystemInit> {
316        #[cfg(feature = "logging")]
317        let cli_flags = self.cli_flags();
318        #[cfg(feature = "logging")]
319        let do_logging = self.enable_logging;
320        let app_version = self
321            .version
322            .unwrap_or_else(|| env!("CARGO_PKG_VERSION").to_string());
323
324        // Build layers
325        #[cfg(feature = "logging")]
326        let (log_layer, log_guard) = if do_logging {
327            let log_cfg =
328                logging::LoggingConfig::from_app_name(&self.app_name).with_log_dir(self.log_dir);
329            let (layer, guard) = logging::build_json_layer(&log_cfg)?;
330            (Some(layer), Some(logging::LoggingGuard::from_guard(guard)))
331        } else {
332            (None, None)
333        };
334
335        #[cfg(feature = "otel")]
336        let (otel_layer, otel_guard) = if self.enable_otel {
337            let otel_cfg = otel::OtelConfig::from_app_name(&self.app_name, &app_version);
338            otel::build_otel_layer(&otel_cfg)?
339        } else {
340            (None, None)
341        };
342
343        // Compose tracing subscriber
344        #[cfg(all(feature = "logging", not(feature = "otel")))]
345        if log_layer.is_some() {
346            let (quiet, verbose) = cli_flags;
347            let filter = logging::env_filter(quiet, verbose, "info");
348            tracing_subscriber::registry()
349                .with(filter)
350                .with(log_layer)
351                .try_init()
352                .map_err(Error::TracingInit)?;
353        }
354
355        #[cfg(all(feature = "logging", feature = "otel"))]
356        if log_layer.is_some() || otel_layer.is_some() {
357            let (quiet, verbose) = cli_flags;
358            let filter = logging::env_filter(quiet, verbose, "info");
359            let mut layers: Vec<
360                Box<dyn tracing_subscriber::Layer<tracing_subscriber::Registry> + Send + Sync>,
361            > = Vec::new();
362            layers.push(Box::new(filter));
363            if let Some(l) = log_layer {
364                layers.push(Box::new(l));
365            }
366            if let Some(l) = otel_layer {
367                layers.push(l);
368            }
369            tracing_subscriber::registry()
370                .with(layers)
371                .try_init()
372                .map_err(Error::TracingInit)?;
373        }
374
375        #[cfg(feature = "shutdown")]
376        let shutdown_handle = if self.enable_shutdown {
377            let handle = shutdown::ShutdownHandle::new();
378            handle.register_signals()?;
379            Some(handle)
380        } else {
381            None
382        };
383
384        #[cfg(feature = "crash")]
385        if self.enable_crash {
386            crash::install(&self.app_name, &app_version);
387        }
388
389        Ok(SubsystemInit {
390            app_name: self.app_name,
391            version: app_version,
392            #[cfg(feature = "cli")]
393            cli: self.cli.unwrap_or_else(default_cli),
394            #[cfg(feature = "shutdown")]
395            shutdown_handle,
396            #[cfg(feature = "otel")]
397            otel_guard,
398            #[cfg(feature = "logging")]
399            logging_guard: log_guard,
400        })
401    }
402}
403
404/// Shared builder methods for subsystem toggles.
405///
406/// Both [`Builder`] and [`ConfiguredBuilder`] delegate to the same
407/// [`BuilderInner`] fields. This macro generates the forwarding
408/// methods so the documentation and behavior stay in sync.
409macro_rules! builder_methods {
410    () => {
411        /// Provide parsed CLI common arguments.
412        #[cfg(feature = "cli")]
413        pub fn with_cli(mut self, common: cli::CommonArgs) -> Self {
414            self.inner.cli = Some(common);
415            self
416        }
417
418        /// Enable JSONL logging.
419        #[cfg(feature = "logging")]
420        pub const fn logging(mut self) -> Self {
421            self.inner.enable_logging = true;
422            self
423        }
424
425        /// Set the log directory explicitly.
426        #[cfg(feature = "logging")]
427        pub fn with_log_dir(mut self, dir: std::path::PathBuf) -> Self {
428            self.inner.log_dir = Some(dir);
429            self
430        }
431
432        /// Enable OpenTelemetry tracing export.
433        #[cfg(feature = "otel")]
434        pub const fn otel(mut self) -> Self {
435            self.inner.enable_otel = true;
436            self
437        }
438
439        /// Enable graceful shutdown with signal handling.
440        #[cfg(feature = "shutdown")]
441        pub const fn shutdown(mut self) -> Self {
442            self.inner.enable_shutdown = true;
443            self
444        }
445
446        /// Install a structured crash handler (panic hook with dump files).
447        #[cfg(feature = "crash")]
448        pub const fn crash_handler(mut self) -> Self {
449            self.inner.enable_crash = true;
450            self
451        }
452
453        /// Set the application version for crash dumps and OTEL resource
454        /// attributes.
455        ///
456        /// If not set, crash and OTEL use the librebar crate version.
457        pub fn with_version(mut self, version: &str) -> Self {
458            self.inner.version = Some(version.to_string());
459            self
460        }
461    };
462}
463
464/// Start building a librebar application.
465///
466/// ```no_run
467/// # use clap::Parser;
468/// # use serde::{Deserialize, Serialize};
469/// #
470/// # #[derive(Default, Deserialize, Serialize)]
471/// # #[serde(default)]
472/// # struct Config {}
473/// #
474/// # #[derive(Parser)]
475/// # struct Cli {
476/// #     #[command(flatten)]
477/// #     pub common: librebar::cli::CommonArgs,
478/// # }
479/// #
480/// # fn main() -> librebar::Result<()> {
481/// # let cli = Cli::parse();
482/// let app = librebar::init(env!("CARGO_PKG_NAME"))
483///     .with_cli(cli.common)
484///     .config::<Config>()
485///     .logging()
486///     .start()?;
487/// # let _ = app;
488/// # Ok(())
489/// # }
490/// ```
491pub fn init(app_name: &str) -> Builder {
492    Builder {
493        inner: BuilderInner {
494            app_name: app_name.to_string(),
495            version: None,
496            #[cfg(feature = "cli")]
497            cli: None,
498            #[cfg(feature = "logging")]
499            enable_logging: false,
500            #[cfg(feature = "logging")]
501            log_dir: None,
502            #[cfg(feature = "otel")]
503            enable_otel: false,
504            #[cfg(feature = "shutdown")]
505            enable_shutdown: false,
506            #[cfg(feature = "crash")]
507            enable_crash: false,
508        },
509    }
510}
511
512/// Builder for librebar application initialization.
513///
514/// Wires config discovery, logging setup, and CLI args in the correct
515/// initialization order.
516pub struct Builder {
517    inner: BuilderInner,
518}
519
520impl Builder {
521    builder_methods!();
522
523    /// Finalize initialization without config.
524    ///
525    /// Returns `App<()>`. Use [`config_from_file`](Self::config_from_file)
526    /// or [`with_config`](Self::with_config) to get `App<C>` with a typed config.
527    ///
528    /// # Errors
529    ///
530    /// Returns an error if logging initialization fails.
531    pub fn start(self) -> Result<App> {
532        let sub = self.inner.init_subsystems()?;
533        Ok(App {
534            app_name: sub.app_name,
535            version: sub.version,
536            config: (),
537            #[cfg(feature = "config")]
538            config_sources: config::ConfigSources::default(),
539            #[cfg(feature = "cli")]
540            cli: sub.cli,
541            #[cfg(feature = "shutdown")]
542            shutdown_handle: sub.shutdown_handle,
543            #[cfg(feature = "otel")]
544            _otel_guard: sub.otel_guard,
545            #[cfg(feature = "logging")]
546            _logging_guard: sub.logging_guard,
547        })
548    }
549}
550
551// ─── Config builder transitions ─────────────────────────────────────
552
553#[cfg(feature = "config")]
554impl Builder {
555    /// Load config from a specific file.
556    ///
557    /// Transitions the builder to [`ConfiguredBuilder<C>`] which holds
558    /// the config type information.
559    pub fn config_from_file<C>(self, path: &camino::Utf8Path) -> ConfiguredBuilder<C>
560    where
561        C: serde::de::DeserializeOwned + Default + serde::Serialize,
562    {
563        ConfiguredBuilder {
564            inner: self.inner,
565            config_source: CfgSource::File(path.to_path_buf()),
566        }
567    }
568
569    /// Enable config discovery from standard locations.
570    ///
571    /// Transitions the builder to [`ConfiguredBuilder<C>`].
572    pub fn config<C>(self) -> ConfiguredBuilder<C>
573    where
574        C: serde::de::DeserializeOwned + Default + serde::Serialize,
575    {
576        ConfiguredBuilder {
577            inner: self.inner,
578            config_source: CfgSource::Discover,
579        }
580    }
581
582    /// Provide a pre-loaded config (escape hatch).
583    ///
584    /// Transitions the builder to [`ConfiguredBuilder<C>`].
585    pub fn with_config<C>(self, config: C) -> ConfiguredBuilder<C>
586    where
587        C: serde::Serialize,
588    {
589        ConfiguredBuilder {
590            inner: self.inner,
591            config_source: CfgSource::Preloaded(config),
592        }
593    }
594}
595
596// ─── ConfiguredBuilder ──────────────────────────────────────────────
597
598/// How config should be loaded when `start()` is called.
599///
600/// - `Discover`: walk up from cwd looking for config files, merge with user config
601/// - `File`: load from a specific path (skips user config)
602/// - `Preloaded`: use a config value provided directly (no file I/O)
603#[cfg(feature = "config")]
604enum CfgSource<C> {
605    Discover,
606    File(camino::Utf8PathBuf),
607    Preloaded(C),
608}
609
610/// Builder with config type information.
611///
612/// Created by [`Builder::config`], [`Builder::config_from_file`],
613/// or [`Builder::with_config`]. Call [`.start()`](Self::start) to finalize.
614#[cfg(feature = "config")]
615pub struct ConfiguredBuilder<C> {
616    inner: BuilderInner,
617    config_source: CfgSource<C>,
618}
619
620#[cfg(feature = "config")]
621impl<C> ConfiguredBuilder<C> {
622    builder_methods!();
623}
624
625#[cfg(feature = "config")]
626impl<C> ConfiguredBuilder<C>
627where
628    C: serde::de::DeserializeOwned + Default + serde::Serialize,
629{
630    /// Finalize initialization with config.
631    ///
632    /// # Errors
633    ///
634    /// Returns an error if config loading or logging initialization fails.
635    pub fn start(self) -> Result<App<C>> {
636        let (config, sources) = match self.config_source {
637            CfgSource::Discover => {
638                let cwd = std::env::current_dir().map_err(crate::Error::Io)?;
639                let cwd = camino::Utf8PathBuf::try_from(cwd).map_err(|e| {
640                    crate::Error::Io(std::io::Error::new(
641                        std::io::ErrorKind::InvalidData,
642                        format!(
643                            "current directory is not valid UTF-8: {}",
644                            e.into_path_buf().display()
645                        ),
646                    ))
647                })?;
648                config::ConfigLoader::new(&self.inner.app_name)
649                    .with_project_search(&cwd)
650                    .load::<C>()?
651            }
652            CfgSource::File(path) => config::ConfigLoader::new(&self.inner.app_name)
653                .with_user_config(false)
654                .with_file(&path)
655                .load::<C>()?,
656            CfgSource::Preloaded(config) => (config, config::ConfigSources::default()),
657        };
658
659        let sub = self.inner.init_subsystems()?;
660        Ok(App {
661            app_name: sub.app_name,
662            version: sub.version,
663            config,
664            config_sources: sources,
665            #[cfg(feature = "cli")]
666            cli: sub.cli,
667            #[cfg(feature = "shutdown")]
668            shutdown_handle: sub.shutdown_handle,
669            #[cfg(feature = "otel")]
670            _otel_guard: sub.otel_guard,
671            #[cfg(feature = "logging")]
672            _logging_guard: sub.logging_guard,
673        })
674    }
675}
676
677// ─── Helpers ────────────────────────────────────────────────────────
678
679#[cfg(feature = "cli")]
680const fn default_cli() -> cli::CommonArgs {
681    cli::CommonArgs {
682        version_only: false,
683        chdir: None,
684        quiet: false,
685        verbose: 0,
686        color: cli::ColorChoice::Auto,
687        json: false,
688    }
689}