diff --git a/Cargo.lock b/Cargo.lock index 61007be4..bb3d62b1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -600,7 +600,7 @@ checksum = "edd0f118536f44f5ccd48bcb8b111bdc3de888b58c74639dfb034a357d0f206d" [[package]] name = "encstr" -version = "0.29.0-alpha.4" +version = "0.29.0-alpha.5" [[package]] name = "enum-map" @@ -757,7 +757,7 @@ checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" [[package]] name = "hl" -version = "0.29.0-alpha.4" +version = "0.29.0-alpha.5" dependencies = [ "atoi", "bincode", diff --git a/Cargo.toml b/Cargo.toml index 6f093c2f..abed95ad 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,7 +4,7 @@ members = [".", "crate/encstr"] [workspace.package] repository = "https://github.com/pamburus/hl" authors = ["Pavel Ivanov "] -version = "0.29.0-alpha.4" +version = "0.29.0-alpha.5" edition = "2021" license = "MIT" @@ -40,7 +40,7 @@ chrono = { version = "0.4", default-features = false, features = [ "std", ] } chrono-tz = { version = "0", features = ["serde"] } -clap = { version = "4", features = ["wrap_help", "derive", "env"] } +clap = { version = "4", features = ["wrap_help", "derive", "env", "string"] } clap_complete = "4" closure = "0" collection_macros = "0" diff --git a/src/cli.rs b/src/cli.rs index 45bfb7ee..7eed41cd 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -2,7 +2,7 @@ use std::path::PathBuf; // third-party imports -use clap::{value_parser, ArgAction, Parser, ValueEnum}; +use clap::{value_parser, ArgAction, Args, Parser, ValueEnum}; use clap_complete::Shell; use std::num::NonZeroUsize; @@ -16,10 +16,76 @@ use crate::{ // --- +#[derive(Args)] +pub struct BootstrapArgs { + /// Configuration file path. + #[arg(long, overrides_with = "config", value_name = "FILE", env = "HL_CONFIG", default_value = default_config_path(), num_args=1)] + pub config: String, +} + +/// JSON and logfmt log converter to human readable representation. +#[derive(Parser)] +#[clap(version, disable_help_flag = true)] +pub struct BootstrapOpt { + #[command(flatten)] + pub args: BootstrapArgs, +} + +impl BootstrapOpt { + pub fn parse() -> clap::error::Result { + Self::try_parse_from(Self::args()) + } + + pub fn args() -> Vec { + let mut args = std::env::args(); + let Some(first) = args.next() else { + return vec![]; + }; + + let mut result = vec![first]; + let mut follow_up = false; + + while let Some(arg) = args.next() { + match (arg.as_bytes(), follow_up) { + (b"--", _) => { + break; + } + ([b'-', b'-', b'c', b'o', b'n', b'f', b'i', b'g', b'=', ..], _) => { + result.push(arg); + follow_up = false; + } + (b"--config", _) => { + result.push(arg); + follow_up = true; + } + ([b'-'], true) => { + result.push(arg); + follow_up = false; + } + ([b'-', ..], true) => { + follow_up = false; + } + (_, true) => { + result.push(arg); + follow_up = false; + } + _ => {} + } + } + + result + } +} + +// --- + /// JSON and logfmt log converter to human readable representation. #[derive(Parser)] #[clap(version, disable_help_flag = true)] pub struct Opt { + #[command(flatten)] + pub bootstrap: BootstrapArgs, + /// Sort messages chronologically. #[arg(long, short = 's', overrides_with = "sort")] pub sort: bool, @@ -408,3 +474,11 @@ fn parse_non_zero_size(s: &str) -> std::result::Result clap::builder::OsStr { + if let Some(dirs) = config::app_dirs() { + dirs.config_dir.join("config.yaml").into_os_string().into() + } else { + "".into() + } +} diff --git a/src/config.rs b/src/config.rs index 7b43bdc8..e22f800a 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,31 +1,47 @@ +// std imports +use std::sync::Mutex; + // third-party imports use once_cell::sync::Lazy; use platform_dirs::AppDirs; // local imports -use crate::settings::Settings; +use crate::{error::Result, settings::Settings}; // --- pub const APP_NAME: &str = "hl"; -static CONFIG: Lazy = Lazy::new(load); -static DEFAULT: Lazy = Lazy::new(Settings::default); +static PENDING: Mutex> = Mutex::new(None); +static RESOLVED: Lazy = Lazy::new(|| PENDING.lock().unwrap().take().unwrap_or_default()); + +/// Call initialize before any calls to get otherwise it will have no effect. +pub fn initialize(settings: Settings) { + *PENDING.lock().unwrap() = Some(settings); +} +/// Get the resolved settings. +/// If initialized was called before, then a clone of those settings will be returned. +/// Otherwise, the default settings will be returned. pub fn get() -> &'static Settings { - &CONFIG + &RESOLVED } +/// Get the default settings. pub fn default() -> &'static Settings { - &DEFAULT + Default::default() } -pub fn app_dirs() -> AppDirs { - AppDirs::new(Some(APP_NAME), true).unwrap() -} +/// Load settings from the given file or the default configuration file per platform. +pub fn load(path: String) -> Result { + if path.is_empty() { + return Ok(Default::default()); + } -// --- + Settings::load(&path) +} -fn load() -> Settings { - Settings::load(&app_dirs()).unwrap() +/// Get the application platform-specific directories. +pub fn app_dirs() -> Option { + AppDirs::new(Some(APP_NAME), true) } diff --git a/src/error.rs b/src/error.rs index c3e0d7e5..0c3d6d15 100644 --- a/src/error.rs +++ b/src/error.rs @@ -95,6 +95,8 @@ pub enum Error { ParseFloatError(#[from] ParseFloatError), #[error(transparent)] ParseIntError(#[from] ParseIntError), + #[error("failed to detect application directories")] + AppDirs, } /// SizeParseError is an error which may occur when parsing size. @@ -134,3 +136,18 @@ pub struct InvalidLevelError { pub type Result = std::result::Result; pub const HILITE: Color = Color::Yellow; + +pub fn log(err: &Error) { + eprintln!("{} {}", Color::LightRed.bold().paint("error:"), err); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_log() { + let err = Error::Io(std::io::Error::new(std::io::ErrorKind::Other, "test")); + log(&err); + } +} diff --git a/src/main.rs b/src/main.rs index fe687465..902e4bc6 100644 --- a/src/main.rs +++ b/src/main.rs @@ -12,7 +12,6 @@ use std::{ use chrono::Utc; use clap::{CommandFactory, Parser}; use itertools::Itertools; -use nu_ansi_term::Color; // local imports use hl::{ @@ -32,14 +31,25 @@ use hl::{ // --- fn run() -> Result<()> { - let app_dirs = config::app_dirs(); - let settings = config::get(); - let opt = cli::Opt::parse(); + let bootstrap = match cli::BootstrapOpt::parse() { + Ok(bootstrap) => bootstrap, + Err(err) => err.exit(), + }; + let settings = config::load(bootstrap.args.config)?; + config::initialize(settings.clone()); + let opt = cli::Opt::parse(); if opt.help { return cli::Opt::command().print_help().map_err(Error::Io); } + let app_dirs = match config::app_dirs() { + Some(app_dirs) => app_dirs, + None => { + return Err(Error::AppDirs); + } + }; + if let Some(shell) = opt.shell_completions { let mut cmd = cli::Opt::command(); let name = cmd.get_name().to_string(); @@ -310,7 +320,7 @@ fn run() -> Result<()> { fn main() { if let Err(err) = run() { - eprintln!("{}: {}", Color::Red.paint("error"), err); + hl::error::log(&err); process::exit(1); } } diff --git a/src/settings.rs b/src/settings.rs index e2ca4902..5cdd284f 100644 --- a/src/settings.rs +++ b/src/settings.rs @@ -6,7 +6,7 @@ use std::include_str; use chrono_tz::Tz; use config::{Config, File, FileFormat}; use derive_deref::Deref; -use platform_dirs::AppDirs; +use once_cell::sync::Lazy; use serde::{Deserialize, Serialize, Serializer}; use strum::IntoEnumIterator; @@ -16,11 +16,12 @@ use crate::level::Level; // --- -static DEFAULT_SETTINGS: &str = include_str!("../etc/defaults/config.yaml"); +static DEFAULT_SETTINGS_RAW: &str = include_str!("../etc/defaults/config.yaml"); +static DEFAULT_SETTINGS: Lazy = Lazy::new(|| Settings::load_from_str("", FileFormat::Yaml)); // --- -#[derive(Debug, Deserialize)] +#[derive(Debug, Deserialize, Clone)] #[serde(rename_all = "kebab-case")] pub struct Settings { pub fields: Fields, @@ -32,22 +33,18 @@ pub struct Settings { } impl Settings { - pub fn load(app_dirs: &AppDirs) -> Result { - let filename = std::env::var("HL_CONFIG") - .unwrap_or_else(|_| app_dirs.config_dir.join("config.yaml").to_string_lossy().to_string()); - + pub fn load(filename: &str) -> Result { Ok(Config::builder() - .add_source(File::from_str(DEFAULT_SETTINGS, FileFormat::Yaml)) - .add_source(File::with_name(&filename).required(false)) + .add_source(File::from_str(DEFAULT_SETTINGS_RAW, FileFormat::Yaml)) + .add_source(File::with_name(filename)) .build()? .try_deserialize()?) } -} -impl Default for Settings { - fn default() -> Self { + pub fn load_from_str(value: &str, format: FileFormat) -> Self { Config::builder() - .add_source(File::from_str(DEFAULT_SETTINGS, FileFormat::Yaml)) + .add_source(File::from_str(DEFAULT_SETTINGS_RAW, FileFormat::Yaml)) + .add_source(File::from_str(value, format)) .build() .unwrap() .try_deserialize() @@ -55,6 +52,18 @@ impl Default for Settings { } } +impl Default for Settings { + fn default() -> Self { + DEFAULT_SETTINGS.clone() + } +} + +impl Default for &'static Settings { + fn default() -> Self { + &DEFAULT_SETTINGS + } +} + // --- #[derive(Debug, Serialize, Deserialize, Clone, Default)] @@ -80,7 +89,7 @@ pub struct PredefinedFields { // --- -#[derive(Debug, Serialize, Deserialize, Deref, Clone)] +#[derive(Debug, Serialize, Deserialize, Deref, Clone, PartialEq, Eq)] pub struct TimeField(pub Field); impl Default for TimeField { @@ -189,7 +198,7 @@ impl Default for CallerLineField { // --- -#[derive(Debug, Serialize, Deserialize, Clone)] +#[derive(Debug, Serialize, Deserialize, Clone, Eq, PartialEq)] pub struct Field { pub names: Vec, } @@ -292,3 +301,36 @@ where let ordered: BTreeMap<_, _> = value.iter().collect(); ordered.serialize(serializer) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_default_settings() { + let test = |settings: &Settings| { + assert_eq!(settings.concurrency, None); + assert_eq!(settings.time_format, "%b %d %T.%3N"); + assert_eq!(settings.time_zone, chrono_tz::UTC); + assert_eq!(settings.theme, "universal"); + }; + + let settings: &'static Settings = Default::default(); + test(settings); + test(&Settings::default()); + } + + #[test] + fn test_load_settings_k8s() { + let settings = Settings::load("etc/defaults/config-k8s.yaml").unwrap(); + assert_eq!( + settings.fields.predefined.time, + TimeField(Field { + names: vec!["ts".into()] + }) + ); + assert_eq!(settings.time_format, "%b %d %T.%3N"); + assert_eq!(settings.time_zone, chrono_tz::UTC); + assert_eq!(settings.theme, "universal"); + } +}