Skip to main content

coil_runtime/builder/
customer_root.rs

1use super::*;
2use coil_auth::{LoadedAuthModelPackage, load_auth_model_package_at};
3use coil_config::{CustomerAppBootstrapManifest, CustomerAppBootstrapManifestError};
4use coil_i18n::TranslationCatalog;
5use std::collections::BTreeSet;
6use std::env;
7use std::path::Path;
8use std::path::PathBuf;
9
10/// Explicit runtime bootstrap entrypoint for ADR 96 customer-root binaries/workspaces.
11///
12/// This keeps the linked-customer composition path visible in code without forcing every
13/// customer binary to start from the lower-level `RuntimeBuilder::new(...)` surface.
14pub struct CustomerRootRuntimeBuilder<P> {
15    inner: RuntimeBuilder<P>,
16    config: PlatformConfig,
17    app_root: Option<PathBuf>,
18    enabled_modules: Vec<String>,
19}
20
21pub struct CustomerRootBootstrapInputs {
22    pub app_root: PathBuf,
23    pub manifest_path: PathBuf,
24    pub enabled_modules: Vec<String>,
25    pub config_path: PathBuf,
26    pub config: PlatformConfig,
27    pub auth_package_name: String,
28    pub auth_package: LoadedAuthModelPackage,
29    pub translation_catalogs: Vec<TranslationCatalog>,
30}
31
32pub fn customer_root_runtime<P>(
33    config: PlatformConfig,
34    auth_package: P,
35) -> CustomerRootRuntimeBuilder<P>
36where
37    P: AuthModelPackage + 'static,
38{
39    CustomerRootRuntimeBuilder::new(config, auth_package)
40}
41
42pub fn customer_root_runtime_from_env()
43-> Result<CustomerRootRuntimeBuilder<LoadedAuthModelPackage>, RuntimeBootstrapError> {
44    CustomerRootRuntimeBuilder::from_env()
45}
46
47pub fn customer_root_runtime_from_paths(
48    app_root: impl AsRef<Path>,
49    config_path: impl AsRef<Path>,
50) -> Result<CustomerRootRuntimeBuilder<LoadedAuthModelPackage>, RuntimeBootstrapError> {
51    CustomerRootRuntimeBuilder::from_paths(app_root, config_path)
52}
53
54pub fn customer_root_bootstrap_inputs_from_env()
55-> Result<CustomerRootBootstrapInputs, RuntimeBootstrapError> {
56    CustomerRootBootstrapInputs::from_env()
57}
58
59pub fn customer_root_bootstrap_inputs_from_paths(
60    app_root: impl AsRef<Path>,
61    config_path: impl AsRef<Path>,
62) -> Result<CustomerRootBootstrapInputs, RuntimeBootstrapError> {
63    CustomerRootBootstrapInputs::from_paths(app_root, config_path)
64}
65
66impl<P> CustomerRootRuntimeBuilder<P>
67where
68    P: AuthModelPackage + 'static,
69{
70    pub fn new(config: PlatformConfig, auth_package: P) -> Self {
71        Self {
72            inner: RuntimeBuilder::new(config.clone(), auth_package),
73            config,
74            app_root: None,
75            enabled_modules: Vec::new(),
76        }
77    }
78
79    /// Mount a customer workspace/app root that contains the checked-in template tree.
80    ///
81    /// This is the customer-root equivalent of `with_template_root(...)`.
82    pub fn with_customer_root<A>(mut self, root: A) -> Self
83    where
84        A: Into<PathBuf>,
85    {
86        let root = root.into();
87        self.app_root = Some(root.clone());
88        self.inner = self.inner.with_template_root(root);
89        self
90    }
91
92    /// Link a native customer backend plugin into the runtime plan.
93    pub fn with_linked_customer_plugin<C>(mut self, plugin: C) -> Self
94    where
95        C: CustomerBackendPlugin,
96    {
97        self.inner = self.inner.register_customer_plugin(plugin);
98        self
99    }
100
101    pub fn with_boxed_linked_customer_plugin(
102        mut self,
103        plugin: Box<dyn CustomerBackendPlugin>,
104    ) -> Self {
105        self.inner = self.inner.with_boxed_customer_plugin(plugin);
106        self
107    }
108
109    pub fn with_module<M>(mut self, module: M) -> Self
110    where
111        M: PlatformModule + 'static,
112    {
113        self.inner = self.inner.with_module(module);
114        self
115    }
116
117    pub fn register_module<M>(self, module: M) -> Self
118    where
119        M: PlatformModule + 'static,
120    {
121        self.with_module(module)
122    }
123
124    pub fn with_boxed_module(mut self, module: Box<dyn PlatformModule>) -> Self {
125        self.inner = self.inner.with_boxed_module(module);
126        self
127    }
128
129    pub fn register_customer_plugin<C>(self, plugin: C) -> Self
130    where
131        C: CustomerBackendPlugin,
132    {
133        self.with_linked_customer_plugin(plugin)
134    }
135
136    pub fn with_installed_extension(mut self, extension: InstalledExtension) -> Self {
137        self.inner = self.inner.with_installed_extension(extension);
138        self
139    }
140
141    pub fn with_template(mut self, template: coil_template::TemplateDefinition) -> Self {
142        self.inner = self.inner.with_template(template);
143        self
144    }
145
146    pub fn with_templates<I>(mut self, templates: I) -> Self
147    where
148        I: IntoIterator<Item = coil_template::TemplateDefinition>,
149    {
150        self.inner = self.inner.with_templates(templates);
151        self
152    }
153
154    pub fn with_storage_policy_rule(mut self, rule: PathPolicyRule) -> Self {
155        self.inner = self.inner.with_storage_policy_rule(rule);
156        self
157    }
158
159    pub fn with_storage_policies(mut self, policies: StoragePolicySet) -> Self {
160        self.inner = self.inner.with_storage_policies(policies);
161        self
162    }
163
164    pub fn with_route(mut self, route: RouteDefinition) -> Self {
165        self.inner = self.inner.with_route(route);
166        self
167    }
168
169    pub fn with_handler(mut self, handler: HandlerDefinition) -> Self {
170        self.inner = self.inner.with_handler(handler);
171        self
172    }
173
174    pub fn with_feature_flag(mut self, feature_flag: FeatureFlag) -> Self {
175        self.inner = self.inner.with_feature_flag(feature_flag);
176        self
177    }
178
179    pub fn with_translation_catalog(mut self, catalog: TranslationCatalog) -> Self {
180        self.inner = self.inner.with_translation_catalog(catalog);
181        self
182    }
183
184    pub fn with_translation_catalogs<I>(mut self, catalogs: I) -> Self
185    where
186        I: IntoIterator<Item = TranslationCatalog>,
187    {
188        self.inner = self.inner.with_translation_catalogs(catalogs);
189        self
190    }
191
192    pub fn with_maintenance_mode(mut self, maintenance_mode: MaintenanceMode) -> Self {
193        self.inner = self.inner.with_maintenance_mode(maintenance_mode);
194        self
195    }
196
197    /// Drop to the lower-level runtime builder when the customer binary needs advanced knobs that
198    /// are not part of the customer-root convenience surface yet.
199    pub fn into_runtime_builder(self) -> RuntimeBuilder<P> {
200        self.inner
201    }
202
203    pub fn build(self) -> Result<RuntimePlan, RuntimeBuildError> {
204        let enabled_modules = if self.enabled_modules.is_empty() {
205            self.resolve_enabled_modules_from_customer_root()?
206        } else {
207            self.enabled_modules
208        };
209        self.inner
210            .resolve_enabled_customer_modules(&enabled_modules)?
211            .build()
212    }
213
214    pub fn run_from_env(self) -> Result<(), RuntimeBootstrapError> {
215        self.build()?.serve_from_env(env::var("COIL_BIND").ok())
216    }
217}
218
219impl CustomerRootRuntimeBuilder<LoadedAuthModelPackage> {
220    pub fn from_env() -> Result<Self, RuntimeBootstrapError> {
221        let app_root = env::current_dir().map_err(RuntimeBootstrapError::CurrentDirectory)?;
222        let config_path = discover_default_config_path(&app_root).ok_or_else(|| {
223            RuntimeBootstrapError::ConfigNotFound {
224                app_root: app_root.clone(),
225            }
226        })?;
227        Self::from_paths(app_root, config_path)
228    }
229
230    pub fn from_paths(
231        app_root: impl AsRef<Path>,
232        config_path: impl AsRef<Path>,
233    ) -> Result<Self, RuntimeBootstrapError> {
234        let inputs = CustomerRootBootstrapInputs::from_paths(app_root, config_path)?;
235        Ok(Self::from_bootstrap_inputs(inputs))
236    }
237
238    pub fn from_bootstrap_inputs(inputs: CustomerRootBootstrapInputs) -> Self {
239        let mut builder = Self::new(inputs.config, inputs.auth_package)
240            .with_customer_root(inputs.app_root)
241            .with_translation_catalogs(inputs.translation_catalogs);
242        builder.enabled_modules = inputs.enabled_modules;
243        builder
244    }
245}
246
247impl<P> CustomerRootRuntimeBuilder<P>
248where
249    P: AuthModelPackage + 'static,
250{
251    fn resolve_enabled_modules_from_customer_root(&self) -> Result<Vec<String>, RuntimeBuildError> {
252        let Some(app_root) = &self.app_root else {
253            return Err(RuntimeBuildError::CustomerRootNotConfigured);
254        };
255        let manifest_path = app_root.join("app.toml");
256        let manifest = load_customer_root_manifest(&manifest_path)
257            .map_err(|error| customer_root_manifest_error_into_build(&manifest_path, error))?;
258        manifest
259            .validate_runtime_config_alignment(&self.config)
260            .map_err(|error| customer_root_manifest_error_into_build(&manifest_path, error))?;
261        Ok(manifest.enabled_modules().to_vec())
262    }
263}
264
265impl CustomerRootBootstrapInputs {
266    pub fn from_env() -> Result<Self, RuntimeBootstrapError> {
267        let app_root = env::current_dir().map_err(RuntimeBootstrapError::CurrentDirectory)?;
268        let config_path = discover_default_config_path(&app_root).ok_or_else(|| {
269            RuntimeBootstrapError::ConfigNotFound {
270                app_root: app_root.clone(),
271            }
272        })?;
273        Self::from_paths(app_root, config_path)
274    }
275
276    pub fn from_paths(
277        app_root: impl AsRef<Path>,
278        config_path: impl AsRef<Path>,
279    ) -> Result<Self, RuntimeBootstrapError> {
280        let app_root = app_root.as_ref().to_path_buf();
281        let config_path = resolve_path(&app_root, config_path.as_ref());
282        let manifest_path = app_root.join("app.toml");
283        let config = PlatformConfig::from_file(&config_path).map_err(|error| {
284            RuntimeBootstrapError::ConfigLoad {
285                path: config_path.clone(),
286                reason: error.to_string(),
287            }
288        })?;
289        let manifest = load_customer_root_manifest(&manifest_path)
290            .map_err(|error| customer_root_manifest_error_into_bootstrap(&manifest_path, error))?;
291        manifest
292            .validate_runtime_config_alignment(&config)
293            .map_err(|error| customer_root_manifest_error_into_bootstrap(&manifest_path, error))?;
294        let translation_catalogs = load_customer_translation_catalogs(&app_root, &manifest)
295            .map_err(|reason| RuntimeBootstrapError::ManifestLoad {
296                path: manifest_path.clone(),
297                reason,
298            })?;
299        let auth_package_name = config.auth.package.clone();
300        let auth_package =
301            load_auth_model_package_at(&auth_package_name, &app_root).map_err(|error| {
302                RuntimeBootstrapError::AuthPackageLoad {
303                    package: auth_package_name.clone(),
304                    app_root: app_root.clone(),
305                    reason: error.to_string(),
306                }
307            })?;
308
309        Ok(Self {
310            app_root,
311            manifest_path,
312            enabled_modules: manifest.enabled_modules().to_vec(),
313            config_path,
314            config,
315            auth_package_name,
316            auth_package,
317            translation_catalogs,
318        })
319    }
320}
321
322fn resolve_path(app_root: &Path, path: &Path) -> PathBuf {
323    if path.is_absolute() {
324        path.to_path_buf()
325    } else {
326        app_root.join(path)
327    }
328}
329
330fn discover_default_config_path(app_root: &Path) -> Option<PathBuf> {
331    env::var("COIL_CONFIG")
332        .ok()
333        .map(PathBuf::from)
334        .map(|path| resolve_path(app_root, &path))
335        .filter(|path| path.is_file())
336        .or_else(|| {
337            [
338                PathBuf::from("platform.toml"),
339                PathBuf::from("platform.dev.toml"),
340                PathBuf::from("config/platform.toml"),
341                PathBuf::from("coil.toml"),
342                PathBuf::from("config/coil.toml"),
343            ]
344            .into_iter()
345            .map(|path| app_root.join(path))
346            .find(|path| path.is_file())
347        })
348}
349
350fn load_customer_root_manifest(
351    manifest_path: &Path,
352) -> Result<CustomerAppBootstrapManifest, CustomerAppBootstrapManifestError> {
353    CustomerAppBootstrapManifest::from_file(manifest_path)
354}
355
356fn customer_root_manifest_error_into_bootstrap(
357    manifest_path: &Path,
358    error: CustomerAppBootstrapManifestError,
359) -> RuntimeBootstrapError {
360    match error {
361        CustomerAppBootstrapManifestError::Read { path, reason }
362        | CustomerAppBootstrapManifestError::Parse { path, reason } => {
363            RuntimeBootstrapError::ManifestLoad { path, reason }
364        }
365        other => RuntimeBootstrapError::ManifestLoad {
366            path: manifest_path.to_path_buf(),
367            reason: other.to_string(),
368        },
369    }
370}
371
372fn customer_root_manifest_error_into_build(
373    manifest_path: &Path,
374    error: CustomerAppBootstrapManifestError,
375) -> RuntimeBuildError {
376    match error {
377        CustomerAppBootstrapManifestError::Read { path, reason }
378        | CustomerAppBootstrapManifestError::Parse { path, reason } => {
379            RuntimeBuildError::CustomerManifestLoad { path, reason }
380        }
381        other => RuntimeBuildError::CustomerManifestLoad {
382            path: manifest_path.to_path_buf(),
383            reason: other.to_string(),
384        },
385    }
386}
387
388fn load_customer_translation_catalogs(
389    app_root: &Path,
390    manifest: &CustomerAppBootstrapManifest,
391) -> Result<Vec<TranslationCatalog>, String> {
392    let supported_locales = manifest.supported_locales();
393    let mut seen_locales = BTreeSet::new();
394    manifest
395        .translation_catalogs()
396        .iter()
397        .map(|catalog| {
398            if !supported_locales.iter().any(|locale| locale == catalog.locale()) {
399                return Err(format!(
400                    "translation catalog locale `{}` is not declared in the customer app supported locale set",
401                    catalog.locale()
402                ));
403            }
404            if !seen_locales.insert(catalog.locale().to_string()) {
405                return Err(format!(
406                    "translation catalog for locale `{}` is declared more than once",
407                    catalog.locale()
408                ));
409            }
410            let path = app_root.join(catalog.path());
411            TranslationCatalog::from_toml_file(
412                coil_i18n::LocaleTag::new(catalog.locale()).map_err(|error| error.to_string())?,
413                &path,
414            )
415            .map_err(|error| {
416                format!(
417                    "failed to load translation catalog `{}` for locale `{}`: {error}",
418                    catalog.path(),
419                    catalog.locale()
420                )
421            })
422        })
423        .collect()
424}
425
426pub(crate) fn resolve_enabled_customer_modules(
427    enabled_modules: &[String],
428    modules: Vec<Box<dyn PlatformModule>>,
429) -> Result<Vec<Box<dyn PlatformModule>>, RuntimeBuildError> {
430    let enabled = enabled_modules.iter().cloned().collect::<BTreeSet<_>>();
431    let mut linked = BTreeSet::new();
432    let mut selected = Vec::new();
433    for module in modules {
434        let name = module.manifest().name;
435        linked.insert(name.clone());
436        if enabled.contains(&name) {
437            selected.push(module);
438        }
439    }
440    let missing = enabled
441        .into_iter()
442        .filter(|module| !linked.contains(module))
443        .collect::<Vec<_>>();
444    if !missing.is_empty() {
445        return Err(RuntimeBuildError::CustomerManifestMissingLinkedModules { modules: missing });
446    }
447    Ok(selected)
448}