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