use crate::embedded;
use std::borrow::Cow;
use std::io::ErrorKind;
use std::path::Path;
#[derive(Debug, Clone)]
pub struct Theme {
pub name: Cow<'static, str>,
pub embedded: Option<&'static str>,
}
pub const DEFAULT_THEME_NAME: &str = "default";
#[derive(Debug, Clone)]
pub struct Themes {
temas: Vec<Theme>,
}
impl Themes {
pub fn incorporados() -> Self {
Self {
temas: embedded::EMBEBIDOS
.iter()
.map(|(name, page)| Theme {
name: Cow::Borrowed(name),
embedded: Some(page),
})
.collect(),
}
}
pub fn descubrir(content_dir: &Path) -> Self {
let mut registro = Self::incorporados();
let mut descubiertos: Vec<Theme> = Vec::new();
match std::fs::read_dir(content_dir) {
Ok(entradas) => {
for entrada in entradas.filter_map(Result::ok) {
if let Some(tema) = tema_descubierto(&entrada) {
descubiertos.push(tema);
}
}
}
Err(err) if err.kind() == ErrorKind::NotFound => {}
Err(err) => {
tracing::warn!(
dir = %content_dir.display(),
error = %err,
"no se pudo listar el directorio de contenido: se sirven solo los temas embebidos"
);
}
}
descubiertos.sort_by(|a, b| a.name.as_ref().cmp(b.name.as_ref()));
registro.temas.extend(descubiertos);
registro
}
pub fn by_name(&self, name: &str) -> Option<&Theme> {
self.temas.iter().find(|theme| theme.name == name)
}
pub fn names(&self) -> String {
self.iter()
.map(|theme| theme.name.as_ref())
.collect::<Vec<_>>()
.join(", ")
}
pub fn iter(&self) -> std::slice::Iter<'_, Theme> {
self.temas.iter()
}
}
fn tema_descubierto(entrada: &std::fs::DirEntry) -> Option<Theme> {
let path = entrada.path();
if !path.is_file() {
return None;
}
if path.extension().and_then(|ext| ext.to_str()) != Some("html") {
return None;
}
let stem = path.file_stem().and_then(|stem| stem.to_str())?;
if stem.is_empty() {
return None;
}
if embedded::EMBEBIDOS.iter().any(|(name, _)| *name == stem) {
return None;
}
Some(Theme {
name: Cow::Owned(stem.to_string()),
embedded: None,
})
}
pub fn resolve<'a>(
registry: &'a Themes,
requested: Option<&str>,
configured: &'a Theme,
) -> &'a Theme {
requested
.and_then(|nombre| registry.by_name(nombre))
.unwrap_or(configured)
}
impl Theme {
pub fn page_path(&self) -> String {
format!("{}.html", self.name)
}
pub fn page(&self, content_dir: &Path) -> Result<Vec<u8>, std::io::Error> {
let path = content_dir.join(self.page_path());
match std::fs::read(&path) {
Ok(bytes) => Ok(bytes),
Err(err) if err.kind() == ErrorKind::NotFound => match self.embedded {
Some(embedded) => Ok(embedded.as_bytes().to_vec()),
None => {
tracing::warn!(
theme = %self.name,
path = %path.display(),
"el tema descubierto no tiene fichero ni copia embebida: se responderá el respaldo de texto"
);
Err(err)
}
},
Err(err) => {
tracing::warn!(
theme = %self.name,
path = %path.display(),
error = %err,
"no se pudo leer la página del tema: se responderá el respaldo de texto"
);
Err(err)
}
}
}
}
#[cfg(test)]
mod tests {
use super::{DEFAULT_THEME_NAME, Theme, Themes, resolve};
use crate::embedded::EMBEBIDOS;
use std::fs;
use std::path::PathBuf;
fn embebida(theme: &Theme) -> &'static str {
theme
.embedded
.expect("un tema embebido siempre tiene copia embebida")
}
fn fixtures_dir(nombre: &str) -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("tests/fixtures")
.join(nombre)
}
fn descubrir(fixture: &str) -> Themes {
Themes::descubrir(&fixtures_dir(fixture))
}
fn dir_temporal(nombre: &str) -> PathBuf {
let dir =
std::env::temp_dir().join(format!("errserve-theme-{}-{nombre}", std::process::id()));
let _ = fs::remove_dir_all(&dir);
fs::create_dir_all(&dir).expect("el directorio temporal tiene que poder crearse");
dir
}
#[test]
fn embebidos_no_esta_vacio_y_los_nombres_son_unicos() {
assert!(!EMBEBIDOS.is_empty(), "EMBEBIDOS no puede estar vacío");
let mut nombres: Vec<&str> = EMBEBIDOS.iter().map(|(n, _)| *n).collect();
let mut unicos = nombres.clone();
unicos.sort();
unicos.dedup();
assert_eq!(
nombres.len(),
unicos.len(),
"los nombres de los temas embebidos tienen que ser únicos"
);
nombres.sort();
assert_eq!(
nombres, unicos,
"EMBEBIDOS tiene que estar ordenado por nombre"
);
}
#[test]
fn incorporados_carga_todos_los_embebidos() {
let registro = Themes::incorporados();
assert_eq!(registro.iter().count(), EMBEBIDOS.len());
for (nombre, _) in EMBEBIDOS {
assert!(
registro.by_name(nombre).is_some(),
"falta el tema `{nombre}` en incorporados()"
);
}
}
#[test]
fn by_name_es_exacto() {
let registro = Themes::incorporados();
assert!(
registro.by_name(DEFAULT_THEME_NAME).is_some(),
"el tema por defecto tiene que estar en el registro"
);
assert!(
registro.by_name("Default").is_none(),
"la comparación es sensible a mayúsculas"
);
assert!(
registro.by_name("").is_none(),
"la cadena vacía no es un tema"
);
}
#[test]
fn descubrir_anade_temas_del_directorio() {
let registro = descubrir("temas-extras");
let neon = registro
.by_name("neon")
.expect("neon.html es un tema descubierto");
assert!(
neon.embedded.is_none(),
"un tema descubierto no tiene copia embebida"
);
assert_eq!(neon.page_path(), "neon.html");
}
#[test]
fn descubrir_salta_los_embebidos() {
let registro = descubrir("temas-extras");
let default = registro
.by_name("default")
.expect("default es un tema embebido");
assert!(
default.embedded.is_some(),
"el `default` del registro es el embebido: conserva su copia"
);
assert_eq!(
registro.iter().filter(|t| t.name == "default").count(),
1,
"`default.html` no puede añadir un tema duplicado"
);
}
#[test]
fn descubrir_acepta_cualquier_html() {
let registro = descubrir("temas-extras");
for nombre in ["200", "404", "999"] {
assert!(
registro.by_name(nombre).is_some(),
"`{nombre}.html` tiene que ser un tema: el modelo nuevo no excluye tallos numéricos"
);
}
}
#[test]
fn descubrir_ignora_subdirectorios() {
let registro = descubrir("temas-extras");
assert!(
registro.by_name("profundo").is_none(),
"un fichero en un subdirectorio no es un tema: el nombre de un tema no es una ruta"
);
assert!(
registro.by_name("neon").is_some(),
"neon.html está en la raíz, así que sí es un tema"
);
}
#[test]
fn descubrir_ignora_extensiones_que_no_son_html() {
let registro = descubrir("temas-extension");
assert_eq!(
registro.iter().count(),
EMBEBIDOS.len(),
"neon.htm, neon.HTML y neon.css no añaden ningún tema"
);
assert!(
registro.by_name("neon").is_none(),
"la extensión tiene que ser exactamente html"
);
}
#[test]
fn descubrir_sin_directorio_no_falla() {
let registro = Themes::descubrir(&fixtures_dir("inexistente-y-ademas-inexistente"));
assert_eq!(
registro.iter().count(),
EMBEBIDOS.len(),
"un CONTENT_DIR que no existe deja los embebidos sin fallar"
);
}
#[test]
fn descubrir_con_directorio_no_listable_no_falla() {
let registro = Themes::descubrir(&fixtures_dir("temas-extras").join("neon.html"));
assert_eq!(
registro.iter().count(),
EMBEBIDOS.len(),
"un CONTENT_DIR que existe y no se puede listar deja los embebidos sin fallar"
);
}
#[test]
fn la_pagina_legible_del_disco_gana_a_la_embebida() {
let dir = dir_temporal("legible");
let propia = b"<p>pagina propia del operador, distinguible</p>".to_vec();
fs::write(dir.join("matrix.html"), &propia).expect("el fixture tiene que poder escribirse");
let registro = Themes::incorporados();
let theme = registro.by_name("matrix").expect("`matrix` es un embebido");
assert_eq!(
theme
.page(&dir)
.expect("la página legible del disco tiene que ganar"),
propia,
"un fichero legible en CONTENT_DIR/<nombre>.html tiene que ganar a la copia embebida"
);
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn la_pagina_ausente_cae_a_la_embebida_sin_warn() {
let dir = dir_temporal("ausente");
let registro = Themes::incorporados();
for (nombre, _) in EMBEBIDOS {
let theme = registro.by_name(nombre).expect("tiene que estar");
let pagina = theme
.page(&dir)
.expect("un fichero ausente no es un fallo: cae a la copia embebida");
assert_eq!(
pagina.as_slice(),
embebida(theme).as_bytes(),
"sin fichero en el disco, el tema `{nombre}` tiene que servir su copia embebida"
);
}
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn la_pagina_presente_pero_ilegible_es_un_error_de_e_s() {
let dir = dir_temporal("ilegible");
fs::create_dir(dir.join("matrix.html")).expect("el fixture tiene que poder crearse");
let registro = Themes::incorporados();
let theme = registro.by_name("matrix").expect("`matrix` es un embebido");
assert!(
theme.page(&dir).is_err(),
"una página del operador que existe y no se puede leer tiene que ser un error de E/S, \
no la copia embebida"
);
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn el_tema_descubierto_cuyo_fichero_desaparece_es_un_error_de_e_s() {
let dir = dir_temporal("descubierto-desaparece");
let bytes = fs::read(fixtures_dir("temas-extras").join("neon.html"))
.expect("el fixture tiene que poder leerse");
fs::write(dir.join("neon.html"), &bytes).expect("el fixture tiene que poder escribirse");
let registro = Themes::descubrir(&dir);
let neon = registro
.by_name("neon")
.expect("`neon.html` es un tema descubierto");
let con_fichero = neon
.page(&dir)
.expect("con el fichero presente, la página se lee");
assert_eq!(
con_fichero, bytes,
"con el fichero presente, `page` devuelve sus bytes"
);
fs::remove_file(dir.join("neon.html")).expect("el fichero tiene que poder borrarse");
assert!(
neon.page(&dir).is_err(),
"un tema descubierto cuyo fichero desaparece es un error de E/S: no tiene copia \
embebida a la que caer, así que la respuesta del servicio será el respaldo de texto \
con el estado intacto, y **no** un 500"
);
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn la_ruta_sale_del_nombre_del_registro() {
let registro = descubrir("temas-extras");
let configurado = registro
.by_name("minimal")
.expect("`minimal` es un embebido");
for intento in [
"../../etc/passwd",
"/etc/passwd",
"..",
"neon/../../x",
"neon.html",
] {
let elegido = resolve(®istro, Some(intento), configurado);
assert_eq!(
elegido.name, "minimal",
"`X-Theme: {intento}` no es un nombre del registro: tiene que caer al configurado"
);
assert_eq!(
elegido.page_path(),
"minimal.html",
"la ruta sale del registro, nunca del valor de `X-Theme`: `{intento}` no puede \
componer una ruta"
);
}
}
#[test]
fn anadir_un_tema_con_el_servicio_en_marcha_no_lo_convierte_en_tema() {
let dir = dir_temporal("anadir-en-marcha");
let registro = Themes::descubrir(&dir);
assert!(
registro.by_name("nuevo").is_none(),
"en un directorio vacío el registro son los embebidos y nada más"
);
fs::write(dir.join("nuevo.html"), b"<p>tema que llega tarde</p>")
.expect("el tema tiene que poder escribirse");
assert!(
registro.by_name("nuevo").is_none(),
"el registro ya construido no ve un fichero que apareció después: añadir un tema exige \
reiniciar"
);
let siguiente = Themes::descubrir(&dir);
assert!(
siguiente.by_name("nuevo").is_some(),
"un `nuevo.html` presente al arrancar sí es un tema"
);
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn los_descubiertos_van_tras_los_embebidos_y_ordenados_por_nombre() {
let dir = dir_temporal("orden-alfabetico");
for nombre in ["alfa", "media", "zeta"] {
fs::write(dir.join(format!("{nombre}.html")), b"<p>tema</p>")
.expect("el tema tiene que poder escribirse");
}
let registro = Themes::descubrir(&dir);
let nombres: Vec<&str> = registro.iter().map(|t| t.name.as_ref()).collect();
let mut embebidos: Vec<&str> = EMBEBIDOS.iter().map(|(n, _)| *n).collect();
embebidos.sort();
let mut esperados = embebidos;
esperados.extend(["alfa", "media", "zeta"]);
assert_eq!(
nombres, esperados,
"los descubiertos van **tras** los embebidos y **ordenados por nombre**"
);
let _ = fs::remove_dir_all(&dir);
}
}