use std::path::PathBuf;
use serde::{Deserialize, Serialize};
use vorma_matcher::ensure_leading_and_trailing_slash;
use crate::tsgen::{TsDrafter, TsExtraType};
#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
pub struct ServerConfig {
pub cargo_package: String,
pub cargo_bin: String,
}
#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
pub struct DevWatchConfig {
pub watch_patterns: Vec<String>,
pub on_change_recompile_server: Vec<String>,
pub on_change_client_revalidate: Vec<String>,
}
#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
pub struct FrontendConfig {
pub ui_variant: UiVariant,
pub js_package_manager_base_cmd: String,
pub js_package_manager_dir: String,
pub vite_config_file: String,
pub entry_file: String,
pub public_static_src_dir: String,
pub critical_css_file: String,
}
#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum UiVariant {
#[default]
React,
Preact,
Solid,
}
impl UiVariant {
pub const fn as_str(self) -> &'static str {
match self {
Self::React => "react",
Self::Preact => "preact",
Self::Solid => "solid",
}
}
}
impl std::fmt::Display for UiVariant {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_str())
}
}
#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
pub struct TsGenConfig {
pub out_file: String,
#[serde(default)]
#[serde(skip)]
pub extra_types: Vec<TsExtraType>,
#[serde(default)]
#[serde(skip)]
pub extra_ts: TsDrafter,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
pub struct PathConfig {
pub public_static_base: String,
pub api_base: String,
}
impl Default for PathConfig {
fn default() -> Self {
Self {
public_static_base: String::new(),
api_base: "/api/".to_owned(),
}
}
}
#[doc(hidden)]
pub fn normalize_api_mount_root(api_base: &str) -> Result<String, String> {
let api_base = api_base.trim();
if api_base.is_empty() || api_base == "/" {
return Err("api_base must be a non-root path prefix such as /api/".to_owned());
}
let normalized = ensure_leading_and_trailing_slash(api_base);
if normalized == "/" {
return Err("api_base must be a non-root path prefix such as /api/".to_owned());
}
Ok(normalized)
}
#[doc(hidden)]
pub fn normalize_public_static_base(public_static_base: &str) -> String {
ensure_leading_and_trailing_slash(public_static_base.trim())
}
#[doc(hidden)]
pub fn validate_public_static_base_against_api_mount(
public_static_base: &str,
api_base: &str,
) -> Result<(String, String), String> {
let public_static_base = normalize_public_static_base(public_static_base);
let api_base = normalize_api_mount_root(api_base)?;
if public_static_base != "/" && public_static_base.starts_with(&api_base) {
return Err(format!(
"public_static_base {public_static_base:?} must not be under api_base {api_base:?}"
));
}
Ok((public_static_base, api_base))
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[doc(hidden)]
pub struct Config {
pub root_dir: PathBuf,
pub server_config: ServerConfig,
pub dist_dir: String,
pub path_config: PathConfig,
pub frontend_config: FrontendConfig,
pub ts_gen_config: TsGenConfig,
pub dev_watch_config: DevWatchConfig,
}
impl Default for Config {
fn default() -> Self {
Self {
root_dir: PathBuf::new(),
server_config: ServerConfig::default(),
dist_dir: String::new(),
path_config: PathConfig::default(),
frontend_config: FrontendConfig::default(),
ts_gen_config: TsGenConfig::default(),
dev_watch_config: DevWatchConfig::default(),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn path_config_defaults_api_mount_under_api() {
let path_config = PathConfig::default();
assert_eq!(path_config.api_base, "/api/");
}
#[test]
fn config_default_uses_safe_api_mount_root() {
let config = Config::default();
assert_eq!(config.path_config.api_base, "/api/");
}
#[test]
fn api_mount_root_must_not_be_root() {
assert_eq!(normalize_api_mount_root("api").unwrap(), "/api/");
assert_eq!(
normalize_api_mount_root("").unwrap_err(),
"api_base must be a non-root path prefix such as /api/"
);
assert_eq!(
normalize_api_mount_root("/").unwrap_err(),
"api_base must be a non-root path prefix such as /api/"
);
}
#[test]
fn public_static_base_must_not_overlap_api_mount_root() {
assert_eq!(
validate_public_static_base_against_api_mount("static", "api").unwrap(),
("/static/".to_owned(), "/api/".to_owned())
);
assert_eq!(
validate_public_static_base_against_api_mount("", "api").unwrap(),
("/".to_owned(), "/api/".to_owned())
);
assert_eq!(
validate_public_static_base_against_api_mount("api/assets", "api").unwrap_err(),
"public_static_base \"/api/assets/\" must not be under api_base \"/api/\""
);
assert!(validate_public_static_base_against_api_mount("api-static", "api").is_ok());
assert_eq!(
validate_public_static_base_against_api_mount("api/v1/assets", "api/v1").unwrap_err(),
"public_static_base \"/api/v1/assets/\" must not be under api_base \"/api/v1/\""
);
assert!(validate_public_static_base_against_api_mount("api", "api/v1").is_ok());
}
}