use crate::config::ConfigError;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum VentureEnv {
#[default]
Development,
Staging,
Production,
}
impl VentureEnv {
pub fn as_str(&self) -> &'static str {
match self {
VentureEnv::Development => "development",
VentureEnv::Staging => "staging",
VentureEnv::Production => "production",
}
}
pub fn parse(value: &str) -> Option<Self> {
match value.trim().to_ascii_lowercase().as_str() {
"development" => Some(VentureEnv::Development),
"staging" => Some(VentureEnv::Staging),
"production" => Some(VentureEnv::Production),
_ => None,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct Brand {
pub accent: String,
pub logo_url: Option<String>,
pub footer: Option<String>,
}
impl Default for Brand {
fn default() -> Self {
Self {
accent: "#FF5A36".to_owned(),
logo_url: None,
footer: None,
}
}
}
#[derive(Debug, Clone)]
pub struct Venture {
pub name: String,
pub domain: String,
pub public_url: String,
pub cors_origins: Vec<String>,
pub env: VentureEnv,
pub brand: Brand,
}
impl Venture {
pub fn new(name: impl Into<String>, domain: impl Into<String>) -> Self {
let domain = domain.into();
Self {
name: name.into(),
public_url: format!("https://{domain}"),
domain,
cors_origins: Vec::new(),
env: VentureEnv::default(),
brand: Brand::default(),
}
}
#[must_use]
pub fn public_url(mut self, url: impl Into<String>) -> Self {
self.public_url = url.into();
self
}
#[must_use]
pub fn cors_origins(mut self, origins: impl IntoIterator<Item = impl Into<String>>) -> Self {
self.cors_origins = origins.into_iter().map(Into::into).collect();
self
}
#[must_use]
pub fn env(mut self, env: VentureEnv) -> Self {
self.env = env;
self
}
#[must_use]
pub fn brand(mut self, brand: Brand) -> Self {
self.brand = brand;
self
}
pub(crate) fn validate(&self, errors: &mut ConfigError) {
if self.name.is_empty() {
errors.push("venture: name must not be empty");
} else if !is_kebab_case(&self.name) {
errors.push(format!(
"venture: name `{}` must be kebab-case ([a-z0-9]+ separated by '-')",
self.name
));
}
if self.domain.trim().is_empty() {
errors.push("venture: domain must not be empty");
}
if self.cors_origins.is_empty() {
errors.push(format!(
"venture `{}`: at least one CORS origin is required",
self.name
));
} else {
for origin in &self.cors_origins {
if origin == "*" {
errors.push(format!(
"venture `{}`: wildcard CORS origin `*` is not allowed",
self.name
));
} else if !is_valid_origin(origin) {
errors.push(format!(
"venture `{}`: CORS origin `{origin}` must be scheme://host[:port]",
self.name
));
}
}
}
}
}
fn is_kebab_case(name: &str) -> bool {
!name.is_empty()
&& name.split('-').all(|part| {
!part.is_empty()
&& part
.chars()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit())
})
}
fn is_valid_origin(origin: &str) -> bool {
let Some((scheme, rest)) = origin.split_once("://") else {
return false;
};
if scheme.is_empty() || !scheme.chars().all(|c| c.is_ascii_alphanumeric()) {
return false;
}
if rest.contains(['/', '?', '#']) {
return false;
}
let hostport = rest;
let host = hostport.split_once(':').map_or(hostport, |(h, _)| h);
!host.is_empty()
&& host
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '.' || c == '-' || c == ':')
}
#[cfg(test)]
mod tests {
use super::*;
fn errors(v: &Venture) -> Vec<String> {
let mut errs = ConfigError::default();
v.validate(&mut errs);
errs.problems
}
#[test]
fn valid_venture_has_no_errors() {
let v = Venture::new("factory0", "factory0.ventures")
.cors_origins(["https://factory0.ventures"]);
assert!(errors(&v).is_empty());
}
#[test]
fn name_must_be_kebab_case() {
let v = Venture::new("Factory0", "factory0.ventures")
.cors_origins(["https://factory0.ventures"]);
assert!(errors(&v).iter().any(|e| e.contains("kebab-case")));
}
#[test]
fn domain_must_be_non_empty() {
let v = Venture::new("factory0", " ").cors_origins(["https://x.dev"]);
assert!(errors(&v).iter().any(|e| e.contains("domain")));
}
#[test]
fn at_least_one_cors_origin() {
let v = Venture::new("factory0", "factory0.ventures");
assert!(errors(&v).iter().any(|e| e.contains("CORS origin")));
}
#[test]
fn wildcard_origin_rejected() {
let v = Venture::new("factory0", "factory0.ventures").cors_origins(["*"]);
assert!(errors(&v).iter().any(|e| e.contains("wildcard")));
}
}