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}