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::{DEFAULT_THEME_NAME, Theme, Themes};
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 themes: Themes,
pub theme: Theme,
}
pub enum ConfigError {
InvalidPort(String),
UnknownLogFormat(String),
DefaultErrorPageOutOfRange(String),
InvalidRetryAfter(String),
UnknownTheme {
value: String,
validos: 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, validos } => write!(
f,
"`{THEME_VAR}` está presente pero no es un tema válido: {value:?}. \
Se espera uno de {validos} (¿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 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),
};
let themes = Themes::descubrir(&content_dir);
let theme = match vars.get(THEME_VAR) {
Some(raw) => themes
.by_name(raw)
.ok_or_else(|| ConfigError::UnknownTheme {
value: raw.clone(),
validos: themes.names(),
})?
.clone(),
None => themes
.by_name(DEFAULT_THEME_NAME)
.cloned()
.expect("el tema por defecto tiene que estar en el registro"),
};
Ok(Config {
port,
log_level,
log_format,
content_dir,
default_error_page,
retry_after,
themes,
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()
}
fn sin_contenido_dir() -> String {
let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("tests/fixtures/inexistente-y-ademas-inexistente");
assert!(
!dir.exists(),
"el fixture `sin contenido` no puede existir: {}",
dir.display()
);
dir.to_string_lossy().into_owned()
}
fn temas_extras_dir() -> String {
std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("tests/fixtures/temas-extras")
.to_string_lossy()
.into_owned()
}
#[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 dir = sin_contenido_dir();
let err = Config::from_env_iter(env(&[("CONTENT_DIR", dir.as_str()), ("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 [
"default", "minimal", "terminal", "paper", "amber", "compact", "matrix",
] {
assert!(
message.contains(nombre),
"el error tiene que enumerar los temas válidos: falta `{nombre}` en {message}"
);
}
}
#[test]
fn el_theme_matrix_es_valido() {
let dir = sin_contenido_dir();
let config =
Config::from_env_iter(env(&[("CONTENT_DIR", dir.as_str()), ("THEME", "matrix")]))
.expect("`THEME=matrix` es uno de los siete temas válidos");
assert_eq!(
config.theme.name, "matrix",
"`THEME=matrix` tiene que elegir el tema `matrix`"
);
}
#[test]
fn un_theme_presente_pero_vacio_no_arranca() {
let dir = sin_contenido_dir();
let err = Config::from_env_iter(env(&[("CONTENT_DIR", dir.as_str()), ("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}"
);
}
#[test]
fn un_theme_descubierto_arranca() {
let dir = temas_extras_dir();
let config =
Config::from_env_iter(env(&[("CONTENT_DIR", dir.as_str()), ("THEME", "neon")]))
.expect("un tema descubierto en CONTENT_DIR tiene que ser un `THEME` válido");
assert_eq!(
config.theme.name, "neon",
"`THEME=neon` tiene que elegir el tema descubierto `neon`"
);
assert!(
config.theme.embedded.is_none(),
"un tema descubierto **no** tiene copia embebida: su fichero es su única copia"
);
assert!(
config.themes.by_name("neon").is_some(),
"el tema descubierto tiene que estar en el registro efectivo"
);
}
#[test]
fn un_theme_correcto_con_el_volumen_sin_montar_no_arranca() {
let dir = sin_contenido_dir();
let err = Config::from_env_iter(env(&[("CONTENT_DIR", dir.as_str()), ("THEME", "neon")]))
.expect_err("`THEME=neon` sin `neon.html` en CONTENT_DIR tiene que abortar");
let message = err.to_string();
assert!(
message.contains("THEME"),
"el error debe nombrar la variable: {message}"
);
for (nombre, _) in crate::embedded::EMBEBIDOS {
assert!(
message.contains(nombre),
"el error tiene que enumerar los temas del registro: falta `{nombre}` en {message}"
);
}
assert!(
message.contains("\"neon\""),
"el error tiene que citar el valor ofensor, como los demás errores de \
configuración: {message}"
);
let mut nombres: Vec<&str> = crate::embedded::EMBEBIDOS.iter().map(|(n, _)| *n).collect();
nombres.sort();
let enumeracion = nombres.join(", ");
assert!(
message.contains(&enumeracion),
"el error tiene que enumerar los nombres del registro de ese arranque, en orden: \
{message}"
);
assert!(
!message.contains(&format!("{enumeracion}, neon")),
"sin el fichero, `neon` no está en el registro y no puede aparecer en la \
enumeración: {message}"
);
}
#[test]
fn el_error_enumera_los_nombres_del_registro() {
let dir = temas_extras_dir();
let err =
Config::from_env_iter(env(&[("CONTENT_DIR", dir.as_str()), ("THEME", "noexiste")]))
.expect_err("`THEME=noexiste` no es un nombre del registro: tiene que abortar");
let message = err.to_string();
for nombre in [
"default", "minimal", "terminal", "paper", "amber", "compact", "matrix", "neon",
] {
assert!(
message.contains(nombre),
"el error tiene que enumerar los nombres del registro de ese arranque: falta \
`{nombre}` en {message}"
);
}
}
}