use std::collections::HashMap;
use std::fmt;
use std::path::PathBuf;
use crate::resolve::{MAX_ERROR_CODE, MIN_ERROR_CODE, is_usable_code};
use crate::theme::{self, DEFAULT_THEME_NAME, THEME_NAMES, Theme};
const DEFAULT_PORT: u16 = 8080;
const DEFAULT_LOG_LEVEL: &str = "info";
const DEFAULT_LOG_FORMAT: LogFormat = LogFormat::Text;
const DEFAULT_CONTENT_DIR: &str = "content";
const DEFAULT_ERROR_PAGE: u16 = 404;
const DEFAULT_RETRY_AFTER: u32 = 120;
const PORT_VAR: &str = "PORT";
const LOG_LEVEL_VAR: &str = "LOG_LEVEL";
const LOG_FORMAT_VAR: &str = "LOG_FORMAT";
const CONTENT_DIR_VAR: &str = "CONTENT_DIR";
const DEFAULT_ERROR_PAGE_VAR: &str = "DEFAULT_ERROR_PAGE";
const RETRY_AFTER_VAR: &str = "RETRY_AFTER";
const THEME_VAR: &str = "THEME";
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum LogFormat {
#[default]
Text,
Json,
}
impl LogFormat {
fn from_value(value: &str) -> Option<Self> {
match value {
"text" => Some(LogFormat::Text),
"json" => Some(LogFormat::Json),
_ => None,
}
}
}
#[derive(Debug, Clone)]
pub struct Config {
pub port: u16,
pub log_level: String,
pub log_format: LogFormat,
pub content_dir: PathBuf,
pub default_error_page: u16,
pub retry_after: u32,
pub theme: &'static Theme,
}
pub enum ConfigError {
InvalidPort(String),
UnknownLogFormat(String),
DefaultErrorPageOutOfRange(String),
InvalidRetryAfter(String),
UnknownTheme(String),
}
impl fmt::Debug for ConfigError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
fmt::Display::fmt(self, f)
}
}
impl fmt::Display for ConfigError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
ConfigError::InvalidPort(value) => write!(
f,
"`{PORT_VAR}` está presente pero no es un puerto válido: {value:?} \
(¿una variable vacía?). Omitir la variable usa {DEFAULT_PORT}."
),
ConfigError::UnknownLogFormat(value) => write!(
f,
"`{LOG_FORMAT_VAR}` está presente pero no es un formato de registro válido: \
{value:?}. Se espera `text` o `json` (¿una variable vacía?). Omitir la \
variable usa `text`."
),
ConfigError::DefaultErrorPageOutOfRange(value) => write!(
f,
"`{DEFAULT_ERROR_PAGE_VAR}` está presente pero no es un código de error \
utilizable: {value:?}. Se espera un entero entre {MIN_ERROR_CODE} y \
{MAX_ERROR_CODE} (¿una variable vacía?). Omitir la variable usa \
{DEFAULT_ERROR_PAGE}."
),
ConfigError::InvalidRetryAfter(value) => write!(
f,
"`{RETRY_AFTER_VAR}` está presente pero no son segundos válidos: {value:?}. \
Se espera un entero de segundos (¿una variable vacía?). Omitir la variable \
usa {DEFAULT_RETRY_AFTER}."
),
ConfigError::UnknownTheme(value) => write!(
f,
"`{THEME_VAR}` está presente pero no es un tema válido: {value:?}. \
Se espera uno de {THEME_NAMES} (¿una variable vacía?). Omitir la variable \
usa `{DEFAULT_THEME_NAME}`."
),
}
}
}
impl std::error::Error for ConfigError {}
impl Config {
pub fn from_env_iter<I>(iter: I) -> Result<Config, ConfigError>
where
I: IntoIterator<Item = (String, String)>,
{
let vars: HashMap<String, String> = iter.into_iter().collect();
let port = match vars.get(PORT_VAR) {
Some(raw) => raw
.parse::<u16>()
.map_err(|_| ConfigError::InvalidPort(raw.clone()))?,
None => DEFAULT_PORT,
};
let default_error_page = match vars.get(DEFAULT_ERROR_PAGE_VAR) {
Some(raw) => {
let code = raw
.parse::<u16>()
.map_err(|_| ConfigError::DefaultErrorPageOutOfRange(raw.clone()))?;
if !is_usable_code(code) {
return Err(ConfigError::DefaultErrorPageOutOfRange(raw.clone()));
}
code
}
None => DEFAULT_ERROR_PAGE,
};
let retry_after = match vars.get(RETRY_AFTER_VAR) {
Some(raw) => raw
.parse::<u32>()
.map_err(|_| ConfigError::InvalidRetryAfter(raw.clone()))?,
None => DEFAULT_RETRY_AFTER,
};
let log_format = match vars.get(LOG_FORMAT_VAR) {
Some(raw) => LogFormat::from_value(raw)
.ok_or_else(|| ConfigError::UnknownLogFormat(raw.clone()))?,
None => DEFAULT_LOG_FORMAT,
};
let theme = match vars.get(THEME_VAR) {
Some(raw) => {
theme::theme_by_name(raw).ok_or_else(|| ConfigError::UnknownTheme(raw.clone()))?
}
None => theme::default_theme(),
};
let log_level = match vars.get(LOG_LEVEL_VAR) {
Some(value) => value.clone(),
None => DEFAULT_LOG_LEVEL.to_string(),
};
let content_dir = match vars.get(CONTENT_DIR_VAR) {
Some(value) => PathBuf::from(value),
None => PathBuf::from(DEFAULT_CONTENT_DIR),
};
Ok(Config {
port,
log_level,
log_format,
content_dir,
default_error_page,
retry_after,
theme,
})
}
pub fn from_env() -> Result<Config, ConfigError> {
let vars = std::env::vars_os().filter_map(|(key, value)| {
let key = key.into_string().ok()?;
let value = value.into_string().ok()?;
Some((key, value))
});
Self::from_env_iter(vars)
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
fn env(pairs: &[(&str, &str)]) -> Vec<(String, String)> {
pairs
.iter()
.map(|(key, value)| ((*key).to_string(), (*value).to_string()))
.collect()
}
#[test]
fn sin_port_el_puerto_por_defecto_es_8080() {
let config = Config::from_env_iter(env(&[])).unwrap();
assert_eq!(config.port, 8080);
}
#[test]
fn port_cambia_el_puerto() {
let config = Config::from_env_iter(env(&[("PORT", "9999")])).unwrap();
assert_eq!(config.port, 9999);
}
#[test]
fn el_resto_de_defectos_de_la_spec() {
let config = Config::from_env_iter(env(&[])).unwrap();
assert_eq!(config.log_level, "info");
assert_eq!(config.content_dir, PathBuf::from("content"));
assert_eq!(config.default_error_page, 404);
}
#[test]
fn las_variables_del_entorno_se_respetan() {
let config = Config::from_env_iter(env(&[
("PORT", "8081"),
("LOG_LEVEL", "debug"),
("CONTENT_DIR", "/srv/pages"),
("DEFAULT_ERROR_PAGE", "503"),
]))
.unwrap();
assert_eq!(config.port, 8081);
assert_eq!(config.log_level, "debug");
assert_eq!(config.content_dir, PathBuf::from("/srv/pages"));
assert_eq!(config.default_error_page, 503);
}
#[test]
fn una_variable_presente_pero_vacia_no_es_una_variable_ausente() {
let result = Config::from_env_iter(env(&[("PORT", "")]));
assert!(result.is_err(), "una PORT vacía debe ser un error, no 8080");
let result = Config::from_env_iter(env(&[("DEFAULT_ERROR_PAGE", "")]));
assert!(
result.is_err(),
"un DEFAULT_ERROR_PAGE vacío debe ser un error, no 404"
);
}
#[test]
fn una_port_no_numerica_es_un_error_tipado() {
assert!(Config::from_env_iter(env(&[("PORT", "ochenta")])).is_err());
assert!(Config::from_env_iter(env(&[("DEFAULT_ERROR_PAGE", "muchos")])).is_err());
}
#[test]
fn un_default_error_page_fuera_de_rango_no_arranca() {
for value in ["200", "100", "204", "304", "0", "399", "600", "1000"] {
let result = Config::from_env_iter(env(&[("DEFAULT_ERROR_PAGE", value)]));
match result {
Err(err) => {
let message = err.to_string();
assert!(
message.contains("DEFAULT_ERROR_PAGE"),
"el error de `{value}` debe nombrar la variable: {message}"
);
}
Ok(config) => panic!(
"`DEFAULT_ERROR_PAGE={value}` debe ser un error de arranque, no {:?}",
config.default_error_page
),
}
}
}
#[test]
fn un_default_error_page_del_rango_arranca() {
for (value, expected) in [("400", 400), ("404", 404), ("503", 503), ("599", 599)] {
let config = Config::from_env_iter(env(&[("DEFAULT_ERROR_PAGE", value)])).unwrap();
assert_eq!(config.default_error_page, expected);
}
}
#[test]
fn las_claves_desconocidas_se_ignoran() {
let config =
Config::from_env_iter(env(&[("PATH", "/usr/bin"), ("HOME", "/root")])).unwrap();
assert_eq!(config.port, 8080);
assert_eq!(config.default_error_page, 404);
}
#[test]
fn log_format_json_declara_el_formato_json() {
let config = Config::from_env_iter(env(&[("LOG_FORMAT", "json")])).unwrap();
let debug = format!("{config:?}");
assert!(
debug.contains("log_format"),
"el `Config` debe declarar el formato de registro: {debug}"
);
assert!(
debug.contains("Json"),
"`LOG_FORMAT=json` debe declarar el formato JSON: {debug}"
);
}
#[test]
fn log_format_text_declara_el_formato_de_texto() {
let config = Config::from_env_iter(env(&[("LOG_FORMAT", "text")])).unwrap();
let debug = format!("{config:?}");
assert!(
debug.contains("log_format"),
"el `Config` debe declarar el formato de registro: {debug}"
);
assert!(
!debug.contains("Json"),
"`LOG_FORMAT=text` no puede declarar el formato JSON: {debug}"
);
}
#[test]
fn sin_log_format_el_defecto_es_texto() {
let config = Config::from_env_iter(env(&[])).unwrap();
let debug = format!("{config:?}");
assert!(
debug.contains("log_format"),
"el `Config` debe declarar el formato de registro: {debug}"
);
assert!(
!debug.contains("Json"),
"sin `LOG_FORMAT` el defecto es texto, no JSON: {debug}"
);
}
#[test]
fn un_log_format_desconocido_no_arranca() {
match Config::from_env_iter(env(&[("LOG_FORMAT", "basura")])) {
Err(err) => {
let message = err.to_string();
assert!(
message.contains("LOG_FORMAT"),
"el error debe nombrar la variable: {message}"
);
}
Ok(config) => {
panic!("`LOG_FORMAT=basura` debe ser un error de arranque, no {config:?}")
}
}
}
#[test]
fn un_log_format_presente_pero_vacio_no_arranca() {
match Config::from_env_iter(env(&[("LOG_FORMAT", "")])) {
Err(err) => {
let message = err.to_string();
assert!(
message.contains("LOG_FORMAT"),
"el error debe nombrar la variable: {message}"
);
}
Ok(config) => {
panic!("un `LOG_FORMAT` vacío debe ser un error de arranque, no {config:?}")
}
}
}
#[test]
fn sin_retry_after_el_defecto_es_120() {
let config = Config::from_env_iter(env(&[])).unwrap();
assert_eq!(
config.retry_after, 120,
"sin `RETRY_AFTER` el defecto es 120, el valor actual y el de `error-pages`: {config:?}"
);
}
#[test]
fn el_retry_after_configurado_se_respeta() {
let config = Config::from_env_iter(env(&[("RETRY_AFTER", "30")])).unwrap();
assert_eq!(
config.retry_after, 30,
"`RETRY_AFTER=30` debe respetarse: {config:?}"
);
}
#[test]
fn un_retry_after_presente_pero_invalido_no_arranca() {
for value in ["", "medio minuto", "-1"] {
match Config::from_env_iter(env(&[("RETRY_AFTER", value)])) {
Err(err) => {
let message = err.to_string();
assert!(
message.contains("RETRY_AFTER"),
"el error de `{value:?}` debe nombrar la variable: {message}"
);
}
Ok(config) => {
panic!("`RETRY_AFTER={value:?}` debe ser un error de arranque, no {config:?}")
}
}
}
}
#[test]
fn un_retry_after_cero_arranca() {
let config = Config::from_env_iter(env(&[("RETRY_AFTER", "0")]))
.expect("`RETRY_AFTER=0` debe ser válido, no un error de arranque");
assert_eq!(
config.retry_after, 0,
"`RETRY_AFTER=0` debe llegar tal cual, no como 120: {config:?}"
);
}
#[test]
fn un_theme_desconocido_no_arranca() {
let err = Config::from_env_iter(env(&[("THEME", "neon")]))
.expect_err("`THEME=neon` debe ser un error de arranque, no un `Ok`");
let message = err.to_string();
assert!(
message.contains("THEME"),
"el error debe nombrar la variable: {message}"
);
for nombre in ["minimal", "terminal", "paper", "amber", "compact"] {
assert!(
message.contains(nombre),
"el error tiene que enumerar los temas válidos: falta `{nombre}` en {message}"
);
}
}
#[test]
fn un_theme_presente_pero_vacio_no_arranca() {
let err = Config::from_env_iter(env(&[("THEME", "")]))
.expect_err("un `THEME` vacío debe ser un error de arranque, no `default`");
let message = err.to_string();
assert!(
message.contains("THEME"),
"el error de `THEME` vacío debe nombrar la variable: {message}"
);
}
}