cratefield_core/
venture.rs1use crate::config::ConfigError;
4
5#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
8pub enum VentureEnv {
9 #[default]
10 Development,
11 Staging,
12 Production,
13}
14
15impl VentureEnv {
16 pub fn as_str(&self) -> &'static str {
17 match self {
18 VentureEnv::Development => "development",
19 VentureEnv::Staging => "staging",
20 VentureEnv::Production => "production",
21 }
22 }
23
24 pub fn parse(value: &str) -> Option<Self> {
26 match value.trim().to_ascii_lowercase().as_str() {
27 "development" => Some(VentureEnv::Development),
28 "staging" => Some(VentureEnv::Staging),
29 "production" => Some(VentureEnv::Production),
30 _ => None,
31 }
32 }
33}
34
35#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
38pub struct Brand {
39 pub accent: String,
41 pub logo_url: Option<String>,
43 pub footer: Option<String>,
45}
46
47impl Default for Brand {
48 fn default() -> Self {
49 Self {
50 accent: "#FF5A36".to_owned(),
51 logo_url: None,
52 footer: None,
53 }
54 }
55}
56
57#[derive(Debug, Clone)]
68pub struct Venture {
69 pub name: String,
71 pub domain: String,
73 pub public_url: String,
75 pub cors_origins: Vec<String>,
77 pub env: VentureEnv,
79 pub brand: Brand,
81}
82
83impl Venture {
84 pub fn new(name: impl Into<String>, domain: impl Into<String>) -> Self {
85 let domain = domain.into();
86 Self {
87 name: name.into(),
88 public_url: format!("https://{domain}"),
89 domain,
90 cors_origins: Vec::new(),
91 env: VentureEnv::default(),
92 brand: Brand::default(),
93 }
94 }
95
96 #[must_use]
97 pub fn public_url(mut self, url: impl Into<String>) -> Self {
98 self.public_url = url.into();
99 self
100 }
101
102 #[must_use]
103 pub fn cors_origins(mut self, origins: impl IntoIterator<Item = impl Into<String>>) -> Self {
104 self.cors_origins = origins.into_iter().map(Into::into).collect();
105 self
106 }
107
108 #[must_use]
109 pub fn env(mut self, env: VentureEnv) -> Self {
110 self.env = env;
111 self
112 }
113
114 #[must_use]
116 pub fn brand(mut self, brand: Brand) -> Self {
117 self.brand = brand;
118 self
119 }
120
121 pub(crate) fn validate(&self, errors: &mut ConfigError) {
124 if self.name.is_empty() {
125 errors.push("venture: name must not be empty");
126 } else if !is_kebab_case(&self.name) {
127 errors.push(format!(
128 "venture: name `{}` must be kebab-case ([a-z0-9]+ separated by '-')",
129 self.name
130 ));
131 }
132
133 if self.domain.trim().is_empty() {
134 errors.push("venture: domain must not be empty");
135 }
136
137 if self.cors_origins.is_empty() {
138 errors.push(format!(
139 "venture `{}`: at least one CORS origin is required",
140 self.name
141 ));
142 } else {
143 for origin in &self.cors_origins {
144 if origin == "*" {
145 errors.push(format!(
146 "venture `{}`: wildcard CORS origin `*` is not allowed",
147 self.name
148 ));
149 } else if !is_valid_origin(origin) {
150 errors.push(format!(
151 "venture `{}`: CORS origin `{origin}` must be scheme://host[:port]",
152 self.name
153 ));
154 }
155 }
156 }
157 }
158}
159
160fn is_kebab_case(name: &str) -> bool {
161 !name.is_empty()
162 && name.split('-').all(|part| {
163 !part.is_empty()
164 && part
165 .chars()
166 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit())
167 })
168}
169
170fn is_valid_origin(origin: &str) -> bool {
171 let Some((scheme, rest)) = origin.split_once("://") else {
173 return false;
174 };
175 if scheme.is_empty() || !scheme.chars().all(|c| c.is_ascii_alphanumeric()) {
176 return false;
177 }
178 if rest.contains(['/', '?', '#']) {
179 return false;
180 }
181 let hostport = rest;
182 let host = hostport.split_once(':').map_or(hostport, |(h, _)| h);
183 !host.is_empty()
184 && host
185 .chars()
186 .all(|c| c.is_ascii_alphanumeric() || c == '.' || c == '-' || c == ':')
187}
188
189#[cfg(test)]
190mod tests {
191 use super::*;
192
193 fn errors(v: &Venture) -> Vec<String> {
194 let mut errs = ConfigError::default();
195 v.validate(&mut errs);
196 errs.problems
197 }
198
199 #[test]
200 fn valid_venture_has_no_errors() {
201 let v = Venture::new("factory0", "factory0.ventures")
202 .cors_origins(["https://factory0.ventures"]);
203 assert!(errors(&v).is_empty());
204 }
205
206 #[test]
207 fn name_must_be_kebab_case() {
208 let v = Venture::new("Factory0", "factory0.ventures")
209 .cors_origins(["https://factory0.ventures"]);
210 assert!(errors(&v).iter().any(|e| e.contains("kebab-case")));
211 }
212
213 #[test]
214 fn domain_must_be_non_empty() {
215 let v = Venture::new("factory0", " ").cors_origins(["https://x.dev"]);
216 assert!(errors(&v).iter().any(|e| e.contains("domain")));
217 }
218
219 #[test]
220 fn at_least_one_cors_origin() {
221 let v = Venture::new("factory0", "factory0.ventures");
222 assert!(errors(&v).iter().any(|e| e.contains("CORS origin")));
223 }
224
225 #[test]
226 fn wildcard_origin_rejected() {
227 let v = Venture::new("factory0", "factory0.ventures").cors_origins(["*"]);
228 assert!(errors(&v).iter().any(|e| e.contains("wildcard")));
229 }
230}