Skip to main content

cratefield_core/
venture.rs

1//! The venture descriptor: who this backend belongs to (issue #2).
2
3use crate::config::ConfigError;
4
5/// Deployment environment. Mirrors the `ENV` config key; `Production`
6/// drives the mandatory-captcha rule (architecture section 11).
7#[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    /// Parses the `ENV` config value; anything unknown is `None`.
25    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/// Venture branding for mail templates (issue #12). Defaults are
36/// text-only: factory-zero orange accent, no logo, no footer line.
37#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
38pub struct Brand {
39    /// Accent colour as a `#rrggbb` string (links and rule lines).
40    pub accent: String,
41    /// Optional logo image URL; mails must read fine with images off.
42    pub logo_url: Option<String>,
43    /// Optional footer line (e.g. a company address).
44    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/// Identity and CORS configuration for the venture this harness serves.
58///
59/// ```
60/// use cratefield_core::Venture;
61///
62/// let v = Venture::new("factory0", "factory0.ventures")
63///     .public_url("https://factory0.ventures")
64///     .cors_origins(["https://factory0.ventures"]);
65/// assert_eq!(v.name, "factory0");
66/// ```
67#[derive(Debug, Clone)]
68pub struct Venture {
69    /// Kebab-case venture name (`factory0`).
70    pub name: String,
71    /// Apex domain (`factory0.ventures`); the API serves `api.<domain>`.
72    pub domain: String,
73    /// Absolute public URL the API is linked from.
74    pub public_url: String,
75    /// CORS allowlist. Never `*` (architecture section 6).
76    pub cors_origins: Vec<String>,
77    /// Deployment environment.
78    pub env: VentureEnv,
79    /// Mail branding (issue #12).
80    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    /// Mail branding for templates (accent, logo, footer).
115    #[must_use]
116    pub fn brand(mut self, brand: Brand) -> Self {
117        self.brand = brand;
118        self
119    }
120
121    /// Appends every rule violation to `errors` (issue #2: collect all
122    /// problems, report them together).
123    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    // scheme://host[:port] with no path, query or fragment.
172    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}