use std::collections::HashMap;
use std::env;
use thiserror::Error;
pub use clap::Parser;
pub use clap::{Args, Subcommand, ValueEnum};
pub use clap::arg;
pub use clap::command;
pub use lino_env::{read_lino_env, write_lino_env, LinoEnv};
#[derive(Error, Debug)]
pub enum ConfigError {
#[error("Environment variable error: {0}")]
EnvError(String),
#[error("Parse error: {0}")]
ParseError(String),
#[error("Configuration file error: {0}")]
FileError(String),
#[error("IO error: {0}")]
IoError(#[from] std::io::Error),
}
#[ctor::ctor]
fn auto_init() {
init();
}
pub fn init() {
load_lenv_file(".lenv").ok();
load_env_file(".env").ok();
}
pub fn init_with(lenv_path: Option<&str>, env_path: Option<&str>) {
if let Some(path) = lenv_path {
load_lenv_file(path).ok();
}
if let Some(path) = env_path {
load_env_file(path).ok();
}
}
pub trait LinoParser: Parser {
fn lino_parse() -> Self {
init();
<Self as Parser>::parse()
}
fn lino_parse_with(lenv_path: Option<&str>, env_path: Option<&str>) -> Self {
init_with(lenv_path, env_path);
<Self as Parser>::parse()
}
fn lino_parse_from<I, T>(args: I) -> Self
where
I: IntoIterator<Item = T>,
T: Into<std::ffi::OsString> + Clone,
{
init();
<Self as Parser>::parse_from(args)
}
fn lino_parse_from_with<I, T>(args: I, lenv_path: Option<&str>, env_path: Option<&str>) -> Self
where
I: IntoIterator<Item = T>,
T: Into<std::ffi::OsString> + Clone,
{
init_with(lenv_path, env_path);
<Self as Parser>::parse_from(args)
}
}
impl<T: Parser> LinoParser for T {}
pub fn load_lenv_file(file_path: &str) -> Result<usize, ConfigError> {
let lenv = read_lino_env(file_path)?;
let mut loaded_count = 0;
for key in lenv.keys() {
if env::var(&key).is_err() {
if let Some(value) = lenv.get(&key) {
env::set_var(&key, &value);
loaded_count += 1;
}
}
}
Ok(loaded_count)
}
pub fn load_lenv_file_override(file_path: &str) -> Result<usize, ConfigError> {
let lenv = read_lino_env(file_path)?;
let mut loaded_count = 0;
for key in lenv.keys() {
if let Some(value) = lenv.get(&key) {
env::set_var(&key, &value);
loaded_count += 1;
}
}
Ok(loaded_count)
}
pub fn load_env_file(file_path: &str) -> Result<usize, ConfigError> {
let path = std::path::Path::new(file_path);
if !path.exists() {
return Ok(0);
}
let iter = dotenvy::from_path_iter(path)
.map_err(|e| ConfigError::FileError(format!("Failed to read {}: {}", file_path, e)))?;
let mut loaded_count = 0;
for item in iter {
match item {
Ok((key, value)) => {
if env::var(&key).is_err() {
env::set_var(&key, &value);
loaded_count += 1;
}
}
Err(_) => continue,
}
}
Ok(loaded_count)
}
pub fn load_env_file_override(file_path: &str) -> Result<usize, ConfigError> {
let path = std::path::Path::new(file_path);
if !path.exists() {
return Ok(0);
}
let iter = dotenvy::from_path_iter(path)
.map_err(|e| ConfigError::FileError(format!("Failed to read {}: {}", file_path, e)))?;
let mut loaded_count = 0;
for item in iter {
match item {
Ok((key, value)) => {
env::set_var(&key, &value);
loaded_count += 1;
}
Err(_) => continue,
}
}
Ok(loaded_count)
}
pub fn to_upper_case(s: &str) -> String {
if s.chars().all(|c| c.is_uppercase() || c == '_' || c == '-') {
return s.replace('-', "_");
}
let mut result = String::new();
let chars: Vec<char> = s.chars().collect();
for (i, c) in chars.iter().enumerate() {
if c.is_uppercase() && i > 0 {
result.push('_');
}
if *c == '-' || *c == ' ' {
result.push('_');
} else {
result.push(c.to_ascii_uppercase());
}
}
result = result.trim_start_matches('_').to_string();
while result.contains("__") {
result = result.replace("__", "_");
}
result
}
pub fn to_camel_case(s: &str) -> String {
let lower = s.to_lowercase();
let mut result = String::new();
let mut capitalize_next = false;
for c in lower.chars() {
if c == '-' || c == '_' || c == ' ' {
capitalize_next = true;
} else if capitalize_next {
result.push(c.to_ascii_uppercase());
capitalize_next = false;
} else {
result.push(c);
}
}
if let Some(first) = result.chars().next() {
if first.is_uppercase() {
result = first.to_lowercase().to_string() + &result[1..];
}
}
result
}
pub fn to_kebab_case(s: &str) -> String {
if s.chars().all(|c| c.is_uppercase() || c == '_') && s.contains('_') {
return s.replace('_', "-").to_lowercase();
}
let mut result = String::new();
let chars: Vec<char> = s.chars().collect();
for (i, c) in chars.iter().enumerate() {
if c.is_uppercase() && i > 0 {
result.push('-');
}
if *c == '_' || *c == ' ' {
result.push('-');
} else {
result.push(c.to_ascii_lowercase());
}
}
result = result.trim_start_matches('-').to_string();
while result.contains("--") {
result = result.replace("--", "-");
}
result
}
pub fn to_snake_case(s: &str) -> String {
if s.chars().all(|c| c.is_uppercase() || c == '_') && s.contains('_') {
return s.to_lowercase();
}
let mut result = String::new();
let chars: Vec<char> = s.chars().collect();
for (i, c) in chars.iter().enumerate() {
if c.is_uppercase() && i > 0 {
result.push('_');
}
if *c == '-' || *c == ' ' {
result.push('_');
} else {
result.push(c.to_ascii_lowercase());
}
}
result = result.trim_start_matches('_').to_string();
while result.contains("__") {
result = result.replace("__", "_");
}
result
}
pub fn to_pascal_case(s: &str) -> String {
let lower = s.to_lowercase();
let mut result = String::new();
let mut capitalize_next = true;
for c in lower.chars() {
if c == '-' || c == '_' || c == ' ' {
capitalize_next = true;
} else if capitalize_next {
result.push(c.to_ascii_uppercase());
capitalize_next = false;
} else {
result.push(c);
}
}
result
}
pub fn getenv(key: &str, default: &str) -> String {
let variants = [
key.to_string(),
to_upper_case(key),
to_camel_case(key),
to_kebab_case(key),
to_snake_case(key),
to_pascal_case(key),
];
for variant in variants.iter() {
if let Ok(value) = env::var(variant) {
return value;
}
}
default.to_string()
}
pub fn getenv_int(key: &str, default: i64) -> i64 {
let value = getenv(key, "");
if value.is_empty() {
return default;
}
value.parse().unwrap_or(default)
}
pub fn getenv_bool(key: &str, default: bool) -> bool {
let value = getenv(key, "");
if value.is_empty() {
return default;
}
match value.to_lowercase().as_str() {
"true" | "1" | "yes" | "on" => true,
"false" | "0" | "no" | "off" => false,
_ => default,
}
}
#[derive(Debug, Clone)]
pub struct Config {
values: HashMap<String, String>,
}
impl Config {
pub fn get(&self, key: &str) -> String {
let camel = to_camel_case(key);
self.values
.get(&camel)
.or_else(|| self.values.get(key))
.cloned()
.unwrap_or_default()
}
pub fn get_int(&self, key: &str, default: i64) -> i64 {
let val = self.get(key);
if val.is_empty() {
return default;
}
val.parse().unwrap_or(default)
}
pub fn get_bool(&self, key: &str) -> bool {
let val = self.get(key);
matches!(val.to_lowercase().as_str(), "true" | "1" | "yes" | "on")
}
pub fn has(&self, key: &str) -> bool {
let camel = to_camel_case(key);
self.values.contains_key(&camel) || self.values.contains_key(key)
}
}
#[derive(Debug, Clone)]
struct OptionDef {
name: String,
description: String,
default: String,
is_flag: bool,
short: Option<char>,
}
pub struct ConfigBuilder {
options: Vec<OptionDef>,
lenv_path: Option<String>,
lenv_override: bool,
env_path: Option<String>,
env_override: bool,
app_name: Option<String>,
app_about: Option<String>,
app_version: Option<String>,
}
impl ConfigBuilder {
fn new() -> Self {
ConfigBuilder {
options: Vec::new(),
lenv_path: None,
lenv_override: false,
env_path: None,
env_override: false,
app_name: None,
app_about: None,
app_version: None,
}
}
pub fn name(&mut self, name: &str) -> &mut Self {
self.app_name = Some(name.to_string());
self
}
pub fn about(&mut self, about: &str) -> &mut Self {
self.app_about = Some(about.to_string());
self
}
pub fn version(&mut self, version: &str) -> &mut Self {
self.app_version = Some(version.to_string());
self
}
pub fn lenv(&mut self, path: &str) -> &mut Self {
self.lenv_path = Some(path.to_string());
self.lenv_override = false;
self
}
pub fn lenv_override(&mut self, path: &str) -> &mut Self {
self.lenv_path = Some(path.to_string());
self.lenv_override = true;
self
}
pub fn env(&mut self, path: &str) -> &mut Self {
self.env_path = Some(path.to_string());
self.env_override = false;
self
}
pub fn env_override(&mut self, path: &str) -> &mut Self {
self.env_path = Some(path.to_string());
self.env_override = true;
self
}
pub fn option(&mut self, name: &str, description: &str, default: &str) -> &mut Self {
self.options.push(OptionDef {
name: name.to_string(),
description: description.to_string(),
default: default.to_string(),
is_flag: false,
short: None,
});
self
}
pub fn option_short(
&mut self,
name: &str,
short: char,
description: &str,
default: &str,
) -> &mut Self {
self.options.push(OptionDef {
name: name.to_string(),
description: description.to_string(),
default: default.to_string(),
is_flag: false,
short: Some(short),
});
self
}
pub fn flag(&mut self, name: &str, description: &str) -> &mut Self {
self.options.push(OptionDef {
name: name.to_string(),
description: description.to_string(),
default: String::new(),
is_flag: true,
short: None,
});
self
}
pub fn flag_short(&mut self, name: &str, short: char, description: &str) -> &mut Self {
self.options.push(OptionDef {
name: name.to_string(),
description: description.to_string(),
default: String::new(),
is_flag: true,
short: Some(short),
});
self
}
fn build(&self) -> Config {
self.build_from(env::args_os().collect())
}
fn build_from(&self, args: Vec<std::ffi::OsString>) -> Config {
if let Some(ref path) = self.lenv_path {
if self.lenv_override {
let _ = load_lenv_file_override(path);
} else {
let _ = load_lenv_file(path);
}
}
if let Some(ref path) = self.env_path {
if self.env_override {
let _ = load_env_file_override(path);
} else {
let _ = load_env_file(path);
}
}
let mut cmd =
clap::Command::new(self.app_name.clone().unwrap_or_else(|| "app".to_string()));
if let Some(ref about) = self.app_about {
cmd = cmd.about(about.clone());
}
if let Some(ref version) = self.app_version {
cmd = cmd.version(version.clone());
}
cmd = cmd.arg(
clap::Arg::new("configuration")
.long("configuration")
.short('c')
.help("Path to configuration .lenv file")
.value_name("PATH"),
);
for opt in &self.options {
let kebab_name = to_kebab_case(&opt.name);
let env_name = to_upper_case(&opt.name);
let mut arg = clap::Arg::new(kebab_name.clone()).long(kebab_name.clone());
arg = arg.help(opt.description.clone());
if let Some(short) = opt.short {
arg = arg.short(short);
}
if opt.is_flag {
arg = arg.action(clap::ArgAction::SetTrue);
} else {
arg = arg.env(env_name);
if !opt.default.is_empty() {
arg = arg.default_value(opt.default.clone());
}
}
cmd = cmd.arg(arg);
}
let matches = cmd.get_matches_from(args);
if let Some(config_path) = matches.get_one::<String>("configuration") {
let _ = load_lenv_file_override(config_path);
}
let mut values = HashMap::new();
for opt in &self.options {
let kebab_name = to_kebab_case(&opt.name);
let camel_name = to_camel_case(&opt.name);
if opt.is_flag {
let val = matches.get_flag(&kebab_name);
values.insert(camel_name, val.to_string());
} else if let Some(val) = matches.get_one::<String>(&kebab_name) {
values.insert(camel_name, val.clone());
}
}
Config { values }
}
}
pub fn make_config<F>(configure: F) -> Config
where
F: FnOnce(&mut ConfigBuilder) -> &mut ConfigBuilder,
{
let mut builder = ConfigBuilder::new();
configure(&mut builder);
builder.build()
}
pub fn make_config_from<I, T, F>(args: I, configure: F) -> Config
where
I: IntoIterator<Item = T>,
T: Into<std::ffi::OsString>,
F: FnOnce(&mut ConfigBuilder) -> &mut ConfigBuilder,
{
let mut builder = ConfigBuilder::new();
configure(&mut builder);
builder.build_from(args.into_iter().map(|a| a.into()).collect())
}
#[cfg(test)]
mod tests {
use super::*;
mod case_conversion {
use super::*;
#[test]
fn test_to_upper_case() {
assert_eq!(to_upper_case("apiKey"), "API_KEY");
assert_eq!(to_upper_case("myVariableName"), "MY_VARIABLE_NAME");
assert_eq!(to_upper_case("api-key"), "API_KEY");
assert_eq!(to_upper_case("API_KEY"), "API_KEY");
}
#[test]
fn test_to_camel_case() {
assert_eq!(to_camel_case("api-key"), "apiKey");
assert_eq!(to_camel_case("API_KEY"), "apiKey");
assert_eq!(to_camel_case("my_variable_name"), "myVariableName");
}
#[test]
fn test_to_kebab_case() {
assert_eq!(to_kebab_case("apiKey"), "api-key");
assert_eq!(to_kebab_case("API_KEY"), "api-key");
assert_eq!(to_kebab_case("MyVariableName"), "my-variable-name");
}
#[test]
fn test_to_snake_case() {
assert_eq!(to_snake_case("apiKey"), "api_key");
assert_eq!(to_snake_case("api-key"), "api_key");
assert_eq!(to_snake_case("API_KEY"), "api_key");
}
#[test]
fn test_to_pascal_case() {
assert_eq!(to_pascal_case("api-key"), "ApiKey");
assert_eq!(to_pascal_case("api_key"), "ApiKey");
assert_eq!(to_pascal_case("my-variable-name"), "MyVariableName");
}
}
mod getenv_tests {
use super::*;
use std::env;
#[test]
fn test_getenv_with_default() {
let result = getenv("NON_EXISTENT_VAR_12345", "default");
assert_eq!(result, "default");
}
#[test]
fn test_getenv_finds_var() {
env::set_var("TEST_LINO_VAR", "test_value");
let result = getenv("TEST_LINO_VAR", "default");
assert_eq!(result, "test_value");
env::remove_var("TEST_LINO_VAR");
}
#[test]
fn test_getenv_int() {
env::set_var("TEST_PORT", "8080");
let result = getenv_int("TEST_PORT", 3000);
assert_eq!(result, 8080);
env::remove_var("TEST_PORT");
}
#[test]
fn test_getenv_bool() {
env::set_var("TEST_DEBUG", "true");
let result = getenv_bool("TEST_DEBUG", false);
assert!(result);
env::remove_var("TEST_DEBUG");
env::set_var("TEST_DEBUG", "1");
let result = getenv_bool("TEST_DEBUG", false);
assert!(result);
env::remove_var("TEST_DEBUG");
}
}
}