mod cli;
mod commands;
mod config;
mod credentials;
mod defaults;
mod formatter;
pub mod terminal;
pub mod util;
mod volumespec;
use std::path::{Path, PathBuf};
use std::process;
use std::sync::OnceLock;
use clap::CommandFactory;
use clap::Parser;
use cli::Cli;
use tracing_appender::non_blocking::WorkerGuard;
use tracing_subscriber::{EnvFilter, Layer, fmt, layer::SubscriberExt, util::SubscriberInitExt};
static FILE_LOG_GUARD: OnceLock<WorkerGuard> = OnceLock::new();
fn main() {
let mut cli = Cli::parse();
if let cli::Commands::Completion(args) = &cli.command {
let mut cmd = Cli::command();
cli::generate_completion(&args.shell, &mut cmd, "boxlite", &mut std::io::stdout());
process::exit(0);
}
cli.global.experimental_features = match cli::experimental_features_from_env() {
Ok(features) => features,
Err(error) => {
eprintln!("Error: {error}");
process::exit(1);
}
};
let rt = tokio::runtime::Builder::new_multi_thread()
.enable_all()
.build()
.expect("Failed to build tokio runtime");
let code = rt.block_on(run_cli(cli));
drop(rt);
process::exit(code);
}
async fn run_cli(cli: Cli) -> i32 {
let debug = cli.global.debug;
let rust_log_env = std::env::var("RUST_LOG").ok();
let stderr_layer = fmt::layer()
.with_writer(std::io::stderr)
.with_filter(build_filter(debug, "warn", rust_log_env.as_deref()));
let serve_mode = matches!(cli.command, cli::Commands::Serve(_));
let serve_home_dir = if serve_mode {
cli.global
.resolve_runtime_options()
.ok()
.map(|o| o.home_dir)
} else {
None
};
let file_layer = build_file_layer(
serve_home_dir.as_deref(),
serve_mode,
debug,
rust_log_env.as_deref(),
);
tracing_subscriber::registry()
.with(stderr_layer)
.with(file_layer)
.init();
let global = cli.global;
let result: anyhow::Result<i32> = match cli.command {
cli::Commands::Run(args) => commands::run::execute(args, &global).await,
cli::Commands::Exec(args) => commands::exec::execute(args, &global).await,
cli::Commands::Create(args) => commands::create::execute(args, &global).await.map(|_| 0),
cli::Commands::List(args) => commands::list::execute(args, &global).await.map(|_| 0),
cli::Commands::Rm(args) => commands::rm::execute(args, &global).await.map(|_| 0),
cli::Commands::Start(args) => commands::start::execute(args, &global).await.map(|_| 0),
cli::Commands::Stop(args) => commands::stop::execute(args, &global).await.map(|_| 0),
cli::Commands::Restart(args) => commands::restart::execute(args, &global).await.map(|_| 0),
cli::Commands::Pull(args) => commands::pull::execute(args, &global).await.map(|_| 0),
cli::Commands::Images(args) => commands::images::execute(args, &global).await.map(|_| 0),
cli::Commands::Inspect(args) => commands::inspect::execute(args, &global).await.map(|_| 0),
cli::Commands::Cp(args) => commands::cp::execute(args, &global).await.map(|_| 0),
cli::Commands::Info(args) => commands::info::execute(args, &global).await.map(|_| 0),
cli::Commands::Logs(args) => commands::logs::execute(args, &global).await.map(|_| 0),
cli::Commands::Stats(args) => commands::stats::execute(args, &global).await.map(|_| 0),
cli::Commands::Network(args) => commands::network::execute(args, &global).await.map(|_| 0),
cli::Commands::Serve(args) => commands::serve::execute(args, &global).await.map(|_| 0),
cli::Commands::Auth(args) => commands::auth::run(args, &global).await.map(|_| 0),
cli::Commands::Volume(args) => commands::volume::execute(args, &global).await.map(|_| 0),
cli::Commands::Completion(_) => {
unreachable!("completion subcommand is handled before tokio in main()")
}
};
match result {
Ok(code) => code,
Err(error) => {
eprintln!("Error: {:#}", error);
1
}
}
}
fn build_filter(debug: bool, default_level: &str, rust_log: Option<&str>) -> EnvFilter {
if debug {
return EnvFilter::new("debug");
}
if let Some(value) = rust_log.filter(|v| !v.is_empty())
&& let Ok(filter) = EnvFilter::try_new(value)
{
return filter;
}
EnvFilter::try_new(default_level).unwrap_or_else(|_| EnvFilter::new(default_level))
}
fn resolve_explicit_log_path(value: &str) -> Option<(PathBuf, String)> {
let path = PathBuf::from(value);
let filename = path.file_name()?.to_string_lossy().into_owned();
let dir = match path.parent() {
Some(p) if !p.as_os_str().is_empty() => p.to_path_buf(),
_ => PathBuf::from("."),
};
Some((dir, filename))
}
fn prepare_serve_log_dir(serve_home: &Path) -> std::io::Result<PathBuf> {
let logs_dir = boxlite::runtime::layout::FilesystemLayout::logs_dir_for(serve_home);
std::fs::create_dir_all(&logs_dir)?;
Ok(logs_dir)
}
fn build_file_layer<S>(
serve_home: Option<&Path>,
serve_mode: bool,
debug: bool,
rust_log: Option<&str>,
) -> Option<Box<dyn Layer<S> + Send + Sync + 'static>>
where
S: tracing::Subscriber + for<'a> tracing_subscriber::registry::LookupSpan<'a>,
{
let writer = if let Some(path_str) = std::env::var("BOXLITE_LOG_FILE")
.ok()
.filter(|p| !p.is_empty())
{
let (dir, filename) = resolve_explicit_log_path(&path_str)?;
std::fs::create_dir_all(&dir).ok()?;
let appender = tracing_appender::rolling::never(dir, filename);
let (non_blocking, guard) = tracing_appender::non_blocking(appender);
let _ = FILE_LOG_GUARD.set(guard);
non_blocking
} else if serve_mode {
let home_dir = serve_home?;
let logs_dir = match prepare_serve_log_dir(home_dir) {
Ok(p) => p,
Err(err) => {
eprintln!(
"warning: could not create log dir {}: {}; serve logs will be stderr only",
boxlite::runtime::layout::FilesystemLayout::logs_dir_for(home_dir).display(),
err,
);
return None;
}
};
let appender = tracing_appender::rolling::daily(&logs_dir, "serve.log");
let (non_blocking, guard) = tracing_appender::non_blocking(appender);
let _ = FILE_LOG_GUARD.set(guard);
non_blocking
} else {
return None;
};
Some(
fmt::layer()
.json()
.with_writer(writer)
.with_ansi(false)
.with_filter(build_filter(debug, "info", rust_log))
.boxed(),
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn build_filter_debug_flag_overrides_rust_log_env() {
let filter = build_filter(true, "warn", Some("warn"));
assert_eq!(filter.to_string(), "debug");
}
#[test]
fn build_filter_rust_log_overrides_default_when_no_debug() {
let filter = build_filter(false, "warn", Some("error"));
assert_eq!(filter.to_string(), "error");
}
#[test]
fn build_filter_falls_back_to_default_when_no_rust_log() {
let filter = build_filter(false, "warn", None);
assert_eq!(filter.to_string(), "warn");
let filter_empty = build_filter(false, "warn", Some(""));
assert_eq!(filter_empty.to_string(), "warn");
}
#[test]
fn resolve_explicit_log_path_bare_filename_is_cwd_relative() {
let (dir, filename) = resolve_explicit_log_path("boxlite.log")
.expect("bare filename should resolve, not be dropped");
assert_eq!(dir, PathBuf::from("."));
assert_eq!(filename, "boxlite.log");
}
#[test]
fn resolve_explicit_log_path_absolute_path_splits_correctly() {
let (dir, filename) = resolve_explicit_log_path("/tmp/boxlite/app.log").unwrap();
assert_eq!(dir, PathBuf::from("/tmp/boxlite"));
assert_eq!(filename, "app.log");
}
#[test]
fn resolve_explicit_log_path_relative_path_splits_correctly() {
let (dir, filename) = resolve_explicit_log_path("logs/app.log").unwrap();
assert_eq!(dir, PathBuf::from("logs"));
assert_eq!(filename, "app.log");
}
#[test]
fn prepare_serve_log_dir_surfaces_mkdir_error() {
let home = PathBuf::from("/dev/null");
let result = prepare_serve_log_dir(&home);
assert!(
result.is_err(),
"creating logs/ under /dev/null must fail, got Ok({:?})",
result.ok(),
);
}
#[test]
fn prepare_serve_log_dir_creates_and_returns_logs_subdir() {
let tmp = tempfile::tempdir().expect("tempdir");
let logs_dir =
prepare_serve_log_dir(tmp.path()).expect("mkdir on writable tempdir should succeed");
assert_eq!(logs_dir, tmp.path().join("logs"));
assert!(logs_dir.is_dir(), "logs dir should exist after mkdir");
}
}