1#![doc = include_str!("../README.md")]
2#![forbid(unsafe_code)]
3#![warn(missing_docs)]
4
5pub mod cronjob;
7#[cfg(feature = "dev")]
9pub mod dev;
10mod docker;
11mod project;
12
13use std::fs;
14use std::future::Future;
15use std::net::SocketAddr;
16use std::path::{Path, PathBuf};
17use std::sync::Arc;
18
19use crate::docker::{
20 ensure_local_spacetime_module, ensure_local_stack, is_local_database,
21 should_bootstrap_local_stack,
22};
23use anyhow::{Context as _, Result, anyhow};
24use ch_config::config::AppConfig;
25use ch_database::mariadb::url::redact_mysql_url;
26
27pub use project::Project;
28
29#[derive(Debug, Clone, Copy, PartialEq, Eq)]
31pub enum RuntimeEnvironment {
32 Local,
34 Production,
36}
37
38impl RuntimeEnvironment {
39 fn allows_local_resources(self) -> bool {
40 matches!(self, Self::Local)
41 }
42}
43
44#[derive(Debug, thiserror::Error)]
46#[non_exhaustive]
47pub enum RuntimeError {
48 #[error("failed to discover the runtime project: {0}")]
50 Io(#[from] std::io::Error),
51 #[error(transparent)]
53 Configuration(#[from] ch_config::config::ConfigError),
54 #[error("failed to discover an application root from {0}")]
56 ProjectNotFound(PathBuf),
57 #[error("failed to load {resource}: {source}")]
59 Resource {
60 resource: &'static str,
62 #[source]
64 source: Box<dyn std::error::Error + Send + Sync>,
65 },
66}
67
68pub trait Resource: Send {
72 type Output: Send;
74
75 fn load(
77 self,
78 runtime: &Runtime,
79 ) -> impl Future<Output = std::result::Result<Self::Output, RuntimeError>> + Send;
80}
81
82#[derive(Debug)]
84pub struct Runtime {
85 environment: RuntimeEnvironment,
86 project: Project,
87 environment_load: ch_config::environment::EnvLoad,
88}
89
90impl Runtime {
91 pub fn bootstrap() -> std::result::Result<Self, RuntimeError> {
93 Self::bootstrap_with_environment(RuntimeEnvironment::Local)
94 }
95
96 pub fn bootstrap_with_environment(
98 environment: RuntimeEnvironment,
99 ) -> std::result::Result<Self, RuntimeError> {
100 let project = Project::discover()?;
101 let environment_load = project.load_environment()?;
102 Ok(Self {
103 environment,
104 project,
105 environment_load,
106 })
107 }
108
109 pub fn environment(&self) -> RuntimeEnvironment {
111 self.environment
112 }
113
114 pub fn project(&self) -> &Project {
116 &self.project
117 }
118
119 pub fn environment_load(&self) -> ch_config::environment::EnvLoad {
121 self.environment_load
122 }
123
124 pub async fn load<R: Resource>(
126 &self,
127 resource: R,
128 ) -> std::result::Result<R::Output, RuntimeError> {
129 resource.load(self).await
130 }
131}
132
133#[derive(Debug, Clone)]
135pub struct LocalStack {
136 project_name: String,
137 env_path: PathBuf,
138 spacetime_module: Option<LocalSpacetimeModule>,
139}
140
141#[derive(Debug, Clone)]
142struct LocalSpacetimeModule {
143 name: String,
144 path: PathBuf,
145}
146
147impl LocalStack {
148 pub fn new(project_name: impl Into<String>, env_path: impl Into<PathBuf>) -> Self {
150 Self {
151 project_name: project_name.into(),
152 env_path: env_path.into(),
153 spacetime_module: None,
154 }
155 }
156
157 pub fn discover() -> std::result::Result<Self, RuntimeError> {
159 Self::discover_from(std::env::current_dir()?)
160 }
161
162 pub fn discover_from(start: impl AsRef<Path>) -> std::result::Result<Self, RuntimeError> {
164 discover_local_stack(start.as_ref()).map_err(|source| RuntimeError::Resource {
165 resource: "local stack",
166 source: source.into_boxed_dyn_error(),
167 })
168 }
169
170 pub fn project_name(&self) -> &str {
172 &self.project_name
173 }
174
175 pub fn env_path(&self) -> &Path {
177 &self.env_path
178 }
179}
180
181fn discover_local_stack(start: &Path) -> Result<LocalStack> {
182 let start = project::discovery_root(start);
183
184 for project_root in start.ancestors() {
185 let env_path = project_root.join("services.env");
186 if !env_path.is_file() {
187 continue;
188 }
189
190 let app_manifest = project_root.join("app").join("Cargo.toml");
191 let root_manifest = project_root.join("Cargo.toml");
192 let package_name = cargo_package_name(&app_manifest)?
193 .or(cargo_package_name(&root_manifest)?)
194 .ok_or_else(|| {
195 anyhow!(
196 "local project at {} has no package name in app/Cargo.toml or Cargo.toml",
197 project_root.display()
198 )
199 })?;
200 let spacetime_module = discover_spacetime_module(project_root)?;
201
202 return Ok(LocalStack {
203 project_name: format!("{package_name}-local"),
204 env_path,
205 spacetime_module,
206 });
207 }
208
209 Err(anyhow!(
210 "no local project with services.env was found from {}",
211 start.display()
212 ))
213}
214
215fn cargo_package_name(manifest_path: &Path) -> Result<Option<String>> {
216 if !manifest_path.is_file() {
217 return Ok(None);
218 }
219
220 let source = fs::read_to_string(manifest_path)
221 .with_context(|| format!("failed to read Cargo manifest {}", manifest_path.display()))?;
222 let manifest = toml::from_str::<toml::Table>(&source)
223 .with_context(|| format!("failed to parse Cargo manifest {}", manifest_path.display()))?;
224
225 Ok(manifest
226 .get("package")
227 .and_then(toml::Value::as_table)
228 .and_then(|package| package.get("name"))
229 .and_then(toml::Value::as_str)
230 .map(str::trim)
231 .filter(|name| !name.is_empty())
232 .map(str::to_owned))
233}
234
235fn discover_spacetime_module(project_root: &Path) -> Result<Option<LocalSpacetimeModule>> {
236 let path = project_root
237 .join("crates")
238 .join("database")
239 .join("spacetimedb");
240 let manifest_path = path.join("Cargo.toml");
241 let Some(package_name) = cargo_package_name(&manifest_path)? else {
242 return Ok(None);
243 };
244 let name = package_name
245 .strip_suffix("-spacetimedb")
246 .unwrap_or(package_name.as_str())
247 .to_owned();
248
249 Ok(Some(LocalSpacetimeModule { name, path }))
250}
251
252fn apply_local_project_name(
253 local_stack: Option<&mut LocalStack>,
254 project_name: Option<String>,
255) -> Result<()> {
256 let Some(project_name) = project_name else {
257 return Ok(());
258 };
259 let project_name = project_name.trim();
260 if project_name.is_empty() {
261 return Err(anyhow!("local-stack project name must not be empty"));
262 }
263 if let Some(local_stack) = local_stack {
264 local_stack.project_name = project_name.to_owned();
265 }
266 Ok(())
267}
268
269#[derive(Default, Clone)]
271pub struct MariaDB {
272 database_name: Option<String>,
273 local_uri: Option<String>,
274 port: Option<u16>,
275 config: Option<Arc<AppConfig>>,
276 local_stack: Option<LocalStack>,
277 local_project_name: Option<String>,
278}
279
280impl MariaDB {
281 pub fn builder() -> Self {
283 Self::default()
284 }
285
286 pub fn database_name(mut self, database_name: impl Into<String>) -> Self {
288 self.database_name = Some(database_name.into());
289 self
290 }
291
292 pub fn local_uri(mut self, local_uri: impl Into<String>) -> Self {
294 self.local_uri = Some(local_uri.into());
295 self
296 }
297
298 pub fn port(mut self, port: u16) -> Self {
300 self.port = Some(port);
301 self
302 }
303
304 pub fn config(mut self, config: Arc<AppConfig>) -> Self {
306 self.config = Some(config);
307 self
308 }
309
310 pub fn local_stack(mut self, local_stack: LocalStack) -> Self {
312 self.local_stack = Some(local_stack);
313 self
314 }
315
316 pub fn local_project_name(mut self, project_name: impl Into<String>) -> Self {
318 self.local_project_name = Some(project_name.into());
319 self
320 }
321}
322
323#[derive(Default, Clone)]
325pub struct SpacetimeDB {
326 local_uri: Option<String>,
327 port: Option<u16>,
328 config: Option<Arc<AppConfig>>,
329 local_stack: Option<LocalStack>,
330 local_project_name: Option<String>,
331 module_name: Option<String>,
332 module_path: Option<PathBuf>,
333}
334
335impl SpacetimeDB {
336 pub fn builder() -> Self {
338 Self::default()
339 }
340
341 pub fn local_uri(mut self, local_uri: impl Into<String>) -> Self {
343 self.local_uri = Some(local_uri.into());
344 self
345 }
346
347 pub fn port(mut self, port: u16) -> Self {
349 self.port = Some(port);
350 self
351 }
352
353 pub fn config(mut self, config: Arc<AppConfig>) -> Self {
355 self.config = Some(config);
356 self
357 }
358
359 pub fn local_stack(mut self, local_stack: LocalStack) -> Self {
361 self.local_stack = Some(local_stack);
362 self
363 }
364
365 pub fn local_project_name(mut self, project_name: impl Into<String>) -> Self {
367 self.local_project_name = Some(project_name.into());
368 self
369 }
370
371 pub fn module_name(mut self, module_name: impl Into<String>) -> Self {
373 self.module_name = Some(module_name.into());
374 self
375 }
376
377 pub fn module_path(mut self, path: impl Into<PathBuf>) -> Self {
379 self.module_path = Some(path.into());
380 self
381 }
382}
383
384#[derive(Debug, Clone)]
386pub struct WebBind {
387 addr: String,
388}
389
390impl WebBind {
391 pub fn builder() -> Self {
393 Self {
394 addr: "0.0.0.0:4000".to_string(),
395 }
396 }
397
398 pub fn addr(mut self, addr: impl Into<String>) -> Self {
400 self.addr = addr.into();
401 self
402 }
403}
404
405struct MariaDbResource {
407 connection_string: String,
408 local_stack: Option<LocalStack>,
409 bootstrap_local_stack: bool,
410}
411
412struct SpacetimeDbResource {
414 url: String,
415 local_stack: Option<LocalStack>,
416 bootstrap_local_stack: bool,
417 module_name: Option<String>,
418 module_path: Option<PathBuf>,
419}
420
421pub struct SpacetimeDbConnection {
424 endpoint_url: String,
425 auth_token: Option<String>,
426}
427
428impl SpacetimeDbConnection {
429 pub fn endpoint_url(&self) -> &str {
431 self.endpoint_url.as_str()
432 }
433
434 pub fn auth_token(&self) -> Option<&str> {
437 self.auth_token.as_deref()
438 }
439
440 pub fn with_auth_token(mut self, auth_token: impl Into<String>) -> Self {
443 self.auth_token = Some(auth_token.into());
444 self
445 }
446}
447
448impl std::fmt::Debug for SpacetimeDbConnection {
449 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
450 formatter
451 .debug_struct("SpacetimeDbConnection")
452 .field("endpoint_url", &redact_mysql_url(&self.endpoint_url))
453 .field(
454 "auth_token",
455 &self.auth_token.as_ref().map(|_| "[redacted]"),
456 )
457 .finish()
458 }
459}
460
461impl MariaDB {
462 fn resolve(self, context: &Runtime) -> Result<MariaDbResource> {
463 let mut local_stack = self.local_stack;
464 let local_project_name = self.local_project_name;
465 let has_configured_database = self
466 .config
467 .as_ref()
468 .is_some_and(|config| config.mariadb.is_configured());
469 if self.local_uri.is_none()
470 && !has_configured_database
471 && context.environment.allows_local_resources()
472 && local_stack.is_none()
473 {
474 local_stack = Some(discover_local_stack(context.project.root())?);
475 }
476 let mut connection_string = match self.local_uri {
477 Some(override_uri) => override_uri,
478 None => resolve_connection_string(
479 context.environment,
480 self.config.as_deref(),
481 local_stack.as_ref(),
482 )?,
483 };
484
485 if context.environment.allows_local_resources()
486 && let Some(port) = self.port
487 {
488 connection_string = rewrite_connection_port(&connection_string, port)?;
489 }
490
491 if let Some(database_name) = self.database_name {
492 connection_string = rewrite_database_name(&connection_string, &database_name)?;
493 }
494
495 let (mut local_stack, bootstrap_local_stack) = resolve_local_stack(
496 context.environment,
497 &connection_string,
498 local_stack,
499 context.project.root(),
500 )?;
501 apply_local_project_name(local_stack.as_mut(), local_project_name)?;
502
503 Ok(MariaDbResource {
504 connection_string,
505 local_stack,
506 bootstrap_local_stack,
507 })
508 }
509}
510
511impl SpacetimeDB {
512 fn resolve(self, context: &Runtime) -> Result<SpacetimeDbResource> {
513 let mut local_stack = self.local_stack;
514 let local_project_name = self.local_project_name;
515 let has_configured_spacetime = self
516 .config
517 .as_ref()
518 .is_some_and(|config| config.spacetimedb.is_configured());
519 if self.local_uri.is_none()
520 && !has_configured_spacetime
521 && context.environment.allows_local_resources()
522 && local_stack.is_none()
523 {
524 local_stack = Some(discover_local_stack(context.project.root())?);
525 }
526 let url = resolve_spacetime_url(
527 context.environment,
528 self.local_uri,
529 self.port,
530 self.config.as_deref(),
531 local_stack.as_ref(),
532 )?;
533 let (mut local_stack, bootstrap_local_stack) = resolve_local_stack(
534 context.environment,
535 &url,
536 local_stack,
537 context.project.root(),
538 )?;
539 apply_local_project_name(local_stack.as_mut(), local_project_name)?;
540 if self.module_path.is_some() != self.module_name.is_some() {
541 return Err(anyhow!(
542 "SpacetimeDB module name and path must be supplied together"
543 ));
544 }
545 let (module_name, module_path) = match (self.module_name, self.module_path) {
546 (Some(name), Some(path)) => (Some(name), Some(path)),
547 (None, None) => local_stack
548 .as_ref()
549 .and_then(|stack| stack.spacetime_module.as_ref())
550 .map_or((None, None), |module| {
551 (Some(module.name.clone()), Some(module.path.clone()))
552 }),
553 _ => unreachable!("module name/path pairing was validated above"),
554 };
555
556 Ok(SpacetimeDbResource {
557 url,
558 local_stack,
559 bootstrap_local_stack,
560 module_name,
561 module_path,
562 })
563 }
564}
565
566impl Resource for MariaDB {
567 type Output = String;
568
569 async fn load(self, runtime: &Runtime) -> std::result::Result<String, RuntimeError> {
570 let result = async {
571 let resource = self.resolve(runtime)?;
572 if resource.bootstrap_local_stack {
573 let stack = resource.local_stack.as_ref().ok_or_else(|| {
574 anyhow!("local-stack settings are required for local bootstrap")
575 })?;
576 ensure_local_stack(stack, &[], &resource.connection_string).await?;
577 }
578 Ok::<_, anyhow::Error>(resource.connection_string)
579 }
580 .await;
581 result.map_err(|source| RuntimeError::Resource {
582 resource: "MariaDB",
583 source: source.into_boxed_dyn_error(),
584 })
585 }
586}
587
588impl Resource for SpacetimeDB {
589 type Output = SpacetimeDbConnection;
590
591 async fn load(
592 self,
593 runtime: &Runtime,
594 ) -> std::result::Result<SpacetimeDbConnection, RuntimeError> {
595 let result = async {
596 let resource = self.resolve(runtime)?;
597 if resource.bootstrap_local_stack {
598 let stack = resource.local_stack.as_ref().ok_or_else(|| {
599 anyhow!("local-stack settings are required for local bootstrap")
600 })?;
601 ensure_local_stack(stack, &["spacetime"], &resource.url).await?;
602 }
603 let auth_token =
604 if runtime.environment.allows_local_resources() && resource.local_stack.is_some() {
605 match (
606 resource.module_name.as_deref(),
607 resource.module_path.as_deref(),
608 ) {
609 (Some(name), Some(path)) => {
610 ensure_local_spacetime_module(&resource.url, name, path).await?
611 }
612 _ => None,
613 }
614 } else {
615 None
616 };
617 Ok::<_, anyhow::Error>(SpacetimeDbConnection {
618 endpoint_url: resource.url,
619 auth_token,
620 })
621 }
622 .await;
623 result.map_err(|source| RuntimeError::Resource {
624 resource: "SpacetimeDB",
625 source: source.into_boxed_dyn_error(),
626 })
627 }
628}
629
630impl Resource for WebBind {
631 type Output = SocketAddr;
632
633 async fn load(self, _runtime: &Runtime) -> std::result::Result<SocketAddr, RuntimeError> {
634 let result = (|| -> anyhow::Result<SocketAddr> {
635 let mut addr = self
636 .addr
637 .parse::<SocketAddr>()
638 .context("the web listener address must be a socket address")?;
639 if let Some(port) = ch_config::environment::value("PORT")? {
640 addr.set_port(
641 port.parse::<u16>()
642 .context("PORT must be an integer TCP port")?,
643 );
644 }
645 Ok(addr)
646 })();
647 result.map_err(|source| RuntimeError::Resource {
648 resource: "web listener",
649 source: source.into_boxed_dyn_error(),
650 })
651 }
652}
653
654impl std::fmt::Debug for MariaDB {
655 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
656 f.debug_struct("MariaDB")
657 .field("database_name", &self.database_name)
658 .field(
659 "local_uri",
660 &self.local_uri.as_deref().map(redact_mysql_url),
661 )
662 .field("port", &self.port)
663 .field("config", &self.config.as_ref().map(|_| "[configured]"))
664 .field("local_stack", &self.local_stack)
665 .field("local_project_name", &self.local_project_name)
666 .finish()
667 }
668}
669
670impl std::fmt::Debug for SpacetimeDB {
671 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
672 f.debug_struct("SpacetimeDB")
673 .field(
674 "local_uri",
675 &self.local_uri.as_deref().map(redact_mysql_url),
676 )
677 .field("port", &self.port)
678 .field("config", &self.config.as_ref().map(|_| "[configured]"))
679 .field("local_stack", &self.local_stack)
680 .field("local_project_name", &self.local_project_name)
681 .field("module_name", &self.module_name)
682 .field("module_path", &self.module_path)
683 .finish()
684 }
685}
686
687impl std::fmt::Debug for MariaDbResource {
688 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
689 f.debug_struct("MariaDbResource")
690 .field(
691 "connection_string",
692 &redact_mysql_url(&self.connection_string),
693 )
694 .field("local_stack", &self.local_stack)
695 .field("bootstrap_local_stack", &self.bootstrap_local_stack)
696 .finish()
697 }
698}
699
700impl std::fmt::Debug for SpacetimeDbResource {
701 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
702 f.debug_struct("SpacetimeDbResource")
703 .field("url", &redact_mysql_url(&self.url))
704 .field("local_stack", &self.local_stack)
705 .field("bootstrap_local_stack", &self.bootstrap_local_stack)
706 .field("module_name", &self.module_name)
707 .field("module_path", &self.module_path)
708 .finish()
709 }
710}
711
712fn resolve_connection_string(
713 environment: RuntimeEnvironment,
714 config: Option<&AppConfig>,
715 local_stack: Option<&LocalStack>,
716) -> Result<String> {
717 if let Some(config) = config.filter(|config| config.mariadb.is_configured()) {
718 return Ok(config.mariadb.connection_url()?);
719 }
720
721 if environment.allows_local_resources()
722 && let Some(local_stack) = local_stack
723 {
724 return local_mariadb_url(local_stack.env_path());
725 }
726
727 Err(anyhow!("MariaDB configuration must be supplied explicitly"))
728}
729
730fn local_mariadb_url(env_path: &Path) -> Result<String> {
731 let values = dotenvy::from_path_iter(env_path)
732 .with_context(|| {
733 format!(
734 "failed to read local MariaDB environment from {}",
735 env_path.display()
736 )
737 })?
738 .collect::<std::result::Result<std::collections::HashMap<_, _>, _>>()
739 .with_context(|| {
740 format!(
741 "failed to parse local MariaDB environment from {}",
742 env_path.display()
743 )
744 })?;
745 let required = |name: &'static str| {
746 values
747 .get(name)
748 .map(String::as_str)
749 .filter(|value| !value.trim().is_empty())
750 .ok_or_else(|| {
751 anyhow!(
752 "{name} must be set in the local MariaDB environment file {}",
753 env_path.display()
754 )
755 })
756 };
757 let user = required("MARIADB_USER")?;
758 let password = required("MARIADB_PASSWORD")?;
759 let database = required("MARIADB_DATABASE")?;
760 let port = values
761 .get("MARIADB_PORT")
762 .map(String::as_str)
763 .filter(|value| !value.trim().is_empty())
764 .unwrap_or("3306")
765 .parse::<u16>()
766 .with_context(|| {
767 format!(
768 "MARIADB_PORT must be a valid TCP port in {}",
769 env_path.display()
770 )
771 })?;
772
773 let mut url = url::Url::parse("mysql://127.0.0.1")
774 .expect("the static local MariaDB base URL must be valid");
775 url.set_username(user)
776 .map_err(|()| anyhow!("MARIADB_USER contains unsupported URL data"))?;
777 url.set_password(Some(password))
778 .map_err(|()| anyhow!("MARIADB_PASSWORD contains unsupported URL data"))?;
779 url.set_port(Some(port))
780 .map_err(|()| anyhow!("MARIADB_PORT cannot be represented in the connection URL"))?;
781 url.set_path(database);
782
783 Ok(url.into())
784}
785
786fn resolve_spacetime_url(
787 environment: RuntimeEnvironment,
788 local_uri: Option<String>,
789 port: Option<u16>,
790 config: Option<&AppConfig>,
791 local_stack: Option<&LocalStack>,
792) -> Result<String> {
793 if let Some(local_uri) = local_uri {
794 return Ok(local_uri);
795 }
796
797 let mut url = match config.filter(|config| config.spacetimedb.is_configured()) {
798 Some(config) => config.spacetimedb.endpoint_url()?.to_owned(),
799 None if environment.allows_local_resources() => local_spacetime_url(port, local_stack)?,
800 None => {
801 return Err(anyhow!(
802 "SpacetimeDB configuration must be supplied explicitly outside local development"
803 ));
804 }
805 };
806
807 if environment.allows_local_resources()
808 && let Some(port) = port
809 {
810 url = rewrite_url_port(&url, port)?;
811 }
812
813 Ok(url)
814}
815
816fn local_spacetime_url(port: Option<u16>, local_stack: Option<&LocalStack>) -> Result<String> {
817 let port = match (port, local_stack) {
818 (Some(port), _) => port,
819 (None, Some(local_stack)) => local_spacetime_port(local_stack.env_path())?,
820 (None, None) => 4530,
821 };
822
823 Ok(format!("http://127.0.0.1:{port}"))
824}
825
826fn local_spacetime_port(env_path: &Path) -> Result<u16> {
827 let entries = dotenvy::from_path_iter(env_path).with_context(|| {
828 format!(
829 "failed to read local SpacetimeDB environment from {}",
830 env_path.display()
831 )
832 })?;
833
834 for entry in entries {
835 let (name, value) = entry.with_context(|| {
836 format!(
837 "failed to parse local SpacetimeDB environment from {}",
838 env_path.display()
839 )
840 })?;
841 if name == "SPACETIME_PORT" {
842 return value.parse::<u16>().with_context(|| {
843 format!(
844 "SPACETIME_PORT must be a valid TCP port in {}",
845 env_path.display()
846 )
847 });
848 }
849 }
850
851 Ok(4530)
852}
853
854fn resolve_local_stack(
855 environment: RuntimeEnvironment,
856 connection_url: &str,
857 local_stack: Option<LocalStack>,
858 project_root: &Path,
859) -> Result<(Option<LocalStack>, bool)> {
860 let bootstrap = should_bootstrap_local_stack(environment, connection_url);
861 let local_stack = match local_stack {
862 Some(local_stack) => Some(local_stack),
863 None if environment.allows_local_resources() && is_local_database(connection_url) => {
864 let discovered = discover_local_stack(project_root);
865 match discovered {
866 Ok(local_stack) => Some(local_stack),
867 Err(error) if bootstrap => return Err(error),
868 Err(_) => None,
869 }
870 }
871 None => None,
872 };
873 let Some(local_stack) = local_stack else {
874 return Ok((None, false));
875 };
876 if local_stack.project_name().trim().is_empty() {
877 return Err(anyhow!("local-stack project name must not be empty"));
878 }
879 if local_stack.env_path().as_os_str().is_empty() {
880 return Err(anyhow!("local-stack env-file path must not be empty"));
881 }
882
883 Ok((Some(local_stack), bootstrap))
884}
885
886fn rewrite_url_port(current: &str, port: u16) -> Result<String> {
887 let scheme_end = current
888 .find("://")
889 .ok_or_else(|| anyhow!("invalid url: {}", redact_mysql_url(current)))?;
890 let authority_start = scheme_end + "://".len();
891 let suffix_start = current[authority_start..]
892 .find(['/', '?', '#'])
893 .map(|index| authority_start + index)
894 .unwrap_or(current.len());
895 rewrite_authority_port(current, authority_start, suffix_start, port)
896}
897
898fn rewrite_connection_port(current: &str, port: u16) -> Result<String> {
900 let scheme_end = current
901 .find("://")
902 .ok_or_else(|| anyhow!("invalid database url: {}", redact_mysql_url(current)))?;
903 let authority_start = scheme_end + "://".len();
904 let relative_path_start = current[authority_start..]
905 .find('/')
906 .ok_or_else(|| anyhow!("invalid database url: {}", redact_mysql_url(current)))?;
907 let path_start = authority_start + relative_path_start;
908 rewrite_authority_port(current, authority_start, path_start, port)
909}
910
911fn rewrite_authority_port(
912 current: &str,
913 authority_start: usize,
914 suffix_start: usize,
915 port: u16,
916) -> Result<String> {
917 let authority = ¤t[authority_start..suffix_start];
918 let host_start = authority.rfind('@').map(|index| index + 1).unwrap_or(0);
919 let (userinfo, host_port) = authority.split_at(host_start);
920 let host_port = rewrite_host_port(host_port, port)?;
921
922 let mut rebuilt = String::with_capacity(current.len() + port.to_string().len() + ":".len());
923 rebuilt.push_str(¤t[..authority_start]);
924 rebuilt.push_str(userinfo);
925 rebuilt.push_str(&host_port);
926 rebuilt.push_str(¤t[suffix_start..]);
927
928 Ok(rebuilt)
929}
930
931fn rewrite_host_port(host_port: &str, port: u16) -> Result<String> {
932 if host_port.trim().is_empty() {
933 return Err(anyhow!("invalid database url host"));
934 }
935
936 if let Some(rest) = host_port.strip_prefix('[') {
937 let bracket_end = rest
938 .find(']')
939 .ok_or_else(|| anyhow!("invalid bracketed database url host: {host_port}"))?;
940 let host_end = bracket_end + 2;
941 let host = &host_port[..host_end];
942 return Ok(format!("{host}:{port}"));
943 }
944
945 if let Some((host, candidate_port)) = host_port.rsplit_once(':')
946 && !host.is_empty()
947 && candidate_port.chars().all(|ch| ch.is_ascii_digit())
948 {
949 return Ok(format!("{host}:{port}"));
950 }
951
952 Ok(format!("{host_port}:{port}"))
953}
954
955fn rewrite_database_name(current: &str, database_name: &str) -> Result<String> {
957 let trimmed = database_name.trim();
958 if trimmed.is_empty() {
959 return Err(anyhow!("database_name must not be empty"));
960 }
961
962 let last_slash = current
963 .rfind('/')
964 .ok_or_else(|| anyhow!("invalid database url: {}", redact_mysql_url(current)))?;
965
966 let suffix_start = current[last_slash..]
967 .find(|ch| ['?', '#'].contains(&ch))
968 .map(|relative| last_slash + relative);
969
970 let (base, suffix) = match suffix_start {
971 Some(index) => current.split_at(index),
972 None => (current, ""),
973 };
974
975 let (prefix, _) = base
976 .rsplit_once('/')
977 .ok_or_else(|| anyhow!("invalid database url: {}", redact_mysql_url(current)))?;
978
979 let mut rebuilt = String::with_capacity(prefix.len() + 1 + trimmed.len() + suffix.len());
980 rebuilt.push_str(prefix);
981 rebuilt.push('/');
982 rebuilt.push_str(trimmed);
983 rebuilt.push_str(suffix);
984
985 Ok(rebuilt)
986}
987
988#[cfg(test)]
989mod tests {
990 use super::*;
991 use std::sync::atomic::{AtomicU64, Ordering};
992 use tempfile::tempdir;
993
994 static LOCAL_STACK_SEQUENCE: AtomicU64 = AtomicU64::new(0);
995
996 fn test_runtime(environment: RuntimeEnvironment) -> Runtime {
997 Runtime {
998 environment,
999 project: Project::discover_from(env!("CARGO_MANIFEST_DIR")).unwrap(),
1000 environment_load: Default::default(),
1001 }
1002 }
1003
1004 fn test_config() -> Arc<AppConfig> {
1005 use ch_config::config::{CaptchaConfig, MariadbConfig, SpacetimeDbConfig};
1006
1007 Arc::new(AppConfig {
1008 mariadb: MariadbConfig::new("mysql://user:pass@127.0.0.1:4520/app"),
1009 spacetimedb: SpacetimeDbConfig::new("http://127.0.0.1:4530"),
1010 captcha: CaptchaConfig::standard(),
1011 ..AppConfig::default()
1012 })
1013 }
1014
1015 fn test_local_stack() -> LocalStack {
1016 let sequence = LOCAL_STACK_SEQUENCE.fetch_add(1, Ordering::Relaxed);
1017 let env_path =
1018 std::env::temp_dir().join(format!("local-stack-{}-{sequence}.env", std::process::id()));
1019 std::fs::write(
1020 &env_path,
1021 "MARIADB_ROOT_PASSWORD=root\nMARIADB_DATABASE=app\nMARIADB_USER=user\nMARIADB_PASSWORD=pass\n",
1022 )
1023 .expect("test stack env should be writable");
1024 LocalStack::new("test-data-services", env_path)
1025 }
1026
1027 fn test_local_stack_with_spacetime_port(port: u16) -> LocalStack {
1028 let stack = test_local_stack();
1029 let mut contents =
1030 fs::read_to_string(stack.env_path()).expect("test stack env should remain readable");
1031 contents.push_str(format!("SPACETIME_PORT={port}\n").as_str());
1032 fs::write(stack.env_path(), contents).expect("SpacetimeDB port should be persisted");
1033 stack
1034 }
1035
1036 #[test]
1037 fn preserves_query_params_when_rewriting_database_name() {
1038 let url = "mysql://user:pa#ss@127.0.0.1:4520/demo?ssl-mode=REQUIRED&pool_min=3";
1039 let rewritten =
1040 rewrite_database_name(url, "custom_db").expect("database name rewrite should succeed");
1041
1042 assert_eq!(
1043 rewritten,
1044 "mysql://user:pa#ss@127.0.0.1:4520/custom_db?ssl-mode=REQUIRED&pool_min=3"
1045 );
1046 }
1047
1048 #[test]
1049 fn preserves_credentials_and_suffix_when_rewriting_port() {
1050 let url = "mysql://user:pa#ss@word@127.0.0.1:3306/demo?ssl-mode=REQUIRED&pool_min=3";
1051 let rewritten = rewrite_connection_port(url, 4521).expect("port rewrite should succeed");
1052
1053 assert_eq!(
1054 rewritten,
1055 "mysql://user:pa#ss@word@127.0.0.1:4521/demo?ssl-mode=REQUIRED&pool_min=3"
1056 );
1057 }
1058
1059 #[test]
1060 fn appends_missing_connection_port() {
1061 let url = "mysql://user:pass@localhost/demo";
1062 let rewritten = rewrite_connection_port(url, 4524).expect("port rewrite should succeed");
1063
1064 assert_eq!(rewritten, "mysql://user:pass@localhost:4524/demo");
1065 }
1066
1067 #[test]
1068 fn applies_local_port_override_to_connection_url() {
1069 let context = test_runtime(RuntimeEnvironment::Local);
1070 let resource = MariaDB::builder()
1071 .local_uri("mysql://user:pass@127.0.0.1:3306/app?ssl-mode=DISABLED")
1072 .port(4521)
1073 .local_stack(test_local_stack())
1074 .resolve(&context)
1075 .expect("resource build should succeed");
1076
1077 assert_eq!(
1078 resource.connection_string,
1079 "mysql://user:pass@127.0.0.1:4521/app?ssl-mode=DISABLED"
1080 );
1081 }
1082
1083 #[test]
1084 fn resolves_local_mariadb_from_the_explicit_stack_env_file() {
1085 let context = test_runtime(RuntimeEnvironment::Local);
1086 let resource = MariaDB::builder()
1087 .local_stack(test_local_stack())
1088 .resolve(&context)
1089 .expect("the explicit local stack should provide its MariaDB settings");
1090
1091 assert_eq!(
1092 resource.connection_string,
1093 "mysql://user:pass@127.0.0.1:3306/app"
1094 );
1095 }
1096
1097 #[test]
1098 fn local_resource_builders_override_only_the_compose_project_name() {
1099 let context = test_runtime(RuntimeEnvironment::Local);
1100 let mariadb = MariaDB::builder()
1101 .local_stack(test_local_stack())
1102 .local_project_name("foxfyp")
1103 .resolve(&context)
1104 .expect("MariaDB local project name override should build");
1105 let spacetime = SpacetimeDB::builder()
1106 .local_stack(test_local_stack_with_spacetime_port(4531))
1107 .local_project_name("foxfyp")
1108 .resolve(&context)
1109 .expect("SpacetimeDB local project name override should build");
1110
1111 assert_eq!(
1112 mariadb
1113 .local_stack
1114 .as_ref()
1115 .expect("MariaDB local stack should remain available")
1116 .project_name(),
1117 "foxfyp"
1118 );
1119 assert_eq!(
1120 spacetime
1121 .local_stack
1122 .as_ref()
1123 .expect("SpacetimeDB local stack should remain available")
1124 .project_name(),
1125 "foxfyp"
1126 );
1127 }
1128
1129 #[test]
1130 fn local_resource_builders_reject_an_empty_compose_project_name() {
1131 let context = test_runtime(RuntimeEnvironment::Local);
1132 let mariadb_error = MariaDB::builder()
1133 .local_stack(test_local_stack())
1134 .local_project_name(" ")
1135 .resolve(&context)
1136 .expect_err("MariaDB must reject an empty local project name");
1137 let spacetime_error = SpacetimeDB::builder()
1138 .local_stack(test_local_stack_with_spacetime_port(4531))
1139 .local_project_name("\t")
1140 .resolve(&context)
1141 .expect_err("SpacetimeDB must reject an empty local project name");
1142
1143 assert!(
1144 mariadb_error
1145 .to_string()
1146 .contains("local-stack project name must not be empty")
1147 );
1148 assert!(
1149 spacetime_error
1150 .to_string()
1151 .contains("local-stack project name must not be empty")
1152 );
1153 }
1154
1155 #[test]
1156 fn configured_local_resources_override_the_late_discovered_compose_project_name() {
1157 let temp = tempdir().expect("temporary project should be created");
1158 let project_root = temp.path().join("example");
1159 let app_dir = project_root.join("app");
1160 let module_dir = project_root
1161 .join("crates")
1162 .join("database")
1163 .join("spacetimedb");
1164 fs::create_dir_all(&app_dir).expect("app directory should be created");
1165 fs::create_dir_all(&module_dir).expect("module directory should be created");
1166 fs::write(
1167 project_root.join("services.env"),
1168 "MARIADB_ROOT_PASSWORD=root\nMARIADB_DATABASE=app\nMARIADB_USER=user\nMARIADB_PASSWORD=pass\nSPACETIME_PORT=4530\n",
1169 )
1170 .expect("local env should be written");
1171 fs::write(
1172 app_dir.join("Cargo.toml"),
1173 "[package]\nname = \"ffyp\"\nversion = \"0.1.0\"\n",
1174 )
1175 .expect("app manifest should be written");
1176 fs::write(
1177 module_dir.join("Cargo.toml"),
1178 "[package]\nname = \"ffyp-spacetimedb\"\nversion = \"0.1.0\"\n",
1179 )
1180 .expect("module manifest should be written");
1181
1182 let mut context = test_runtime(RuntimeEnvironment::Local);
1183 context.project = Project::discover_from(&project_root).unwrap();
1184 let mariadb = MariaDB::builder()
1185 .config(test_config())
1186 .local_project_name("foxfyp")
1187 .resolve(&context)
1188 .expect("configured MariaDB resource should discover its local stack");
1189 let spacetime = SpacetimeDB::builder()
1190 .config(test_config())
1191 .local_project_name("foxfyp")
1192 .resolve(&context)
1193 .expect("configured SpacetimeDB resource should discover its local stack");
1194
1195 assert_eq!(
1196 mariadb
1197 .local_stack
1198 .as_ref()
1199 .expect("MariaDB local stack should be discovered")
1200 .project_name(),
1201 "foxfyp"
1202 );
1203 assert_eq!(
1204 mariadb
1205 .local_stack
1206 .as_ref()
1207 .expect("MariaDB local stack should be discovered")
1208 .env_path(),
1209 project_root.join("services.env")
1210 );
1211 assert_eq!(
1212 spacetime
1213 .local_stack
1214 .as_ref()
1215 .expect("SpacetimeDB local stack should be discovered")
1216 .project_name(),
1217 "foxfyp"
1218 );
1219 assert_eq!(
1220 spacetime
1221 .local_stack
1222 .as_ref()
1223 .expect("SpacetimeDB local stack should be discovered")
1224 .env_path(),
1225 project_root.join("services.env")
1226 );
1227 assert_eq!(spacetime.module_name.as_deref(), Some("ffyp"));
1228 assert_eq!(spacetime.module_path.as_deref(), Some(module_dir.as_path()));
1229 }
1230
1231 #[test]
1232 fn discovers_local_stack_and_spacetime_module_from_project_layout() {
1233 let temp = tempdir().expect("temporary project should be created");
1234 let project_root = temp.path().join("example");
1235 let nested_start = project_root.join("app").join("src");
1236 let app_dir = project_root.join("app");
1237 let module_dir = project_root
1238 .join("crates")
1239 .join("database")
1240 .join("spacetimedb");
1241 fs::create_dir_all(&nested_start).expect("nested project path should be created");
1242 fs::create_dir_all(&module_dir).expect("module directory should be created");
1243 fs::write(
1244 project_root.join("services.env"),
1245 "MARIADB_DATABASE=example\n",
1246 )
1247 .expect("local env should be written");
1248 fs::write(
1249 app_dir.join("Cargo.toml"),
1250 "[package]\nname = \"example-service\"\nversion = \"0.1.0\"\n",
1251 )
1252 .expect("app manifest should be written");
1253 fs::write(
1254 module_dir.join("Cargo.toml"),
1255 "[package]\nname = \"example-spacetimedb\"\nversion = \"0.1.0\"\n",
1256 )
1257 .expect("module manifest should be written");
1258
1259 let stack = LocalStack::discover_from(&nested_start)
1260 .expect("the conventional project layout should be discovered");
1261
1262 assert_eq!(stack.project_name(), "example-service-local");
1263 assert_eq!(stack.env_path(), project_root.join("services.env"));
1264 let module = stack
1265 .spacetime_module
1266 .expect("the conventional SpacetimeDB module should be discovered");
1267 assert_eq!(module.name, "example");
1268 assert_eq!(module.path, module_dir);
1269 }
1270
1271 #[test]
1272 fn production_mariadb_still_requires_explicit_app_config() {
1273 let context = test_runtime(RuntimeEnvironment::Production);
1274 let error = MariaDB::builder()
1275 .local_stack(test_local_stack())
1276 .resolve(&context)
1277 .expect_err("production must not read credentials from a local stack file");
1278
1279 assert!(
1280 error
1281 .to_string()
1282 .contains("MariaDB configuration must be supplied explicitly")
1283 );
1284 }
1285
1286 #[test]
1287 fn builds_spacetime_resource_from_explicit_url() {
1288 let context = test_runtime(RuntimeEnvironment::Local);
1289 let resource = SpacetimeDB::builder()
1290 .local_uri("http://127.0.0.1:4531")
1291 .local_stack(test_local_stack())
1292 .module_name("example-module")
1293 .module_path("crates/database/spacetimedb")
1294 .resolve(&context)
1295 .expect("resource build should succeed");
1296
1297 assert_eq!(resource.url, "http://127.0.0.1:4531");
1298 }
1299
1300 #[test]
1301 fn resolves_local_spacetime_from_the_explicit_stack_env_file() {
1302 let context = test_runtime(RuntimeEnvironment::Local);
1303 let resource = SpacetimeDB::builder()
1304 .local_stack(test_local_stack_with_spacetime_port(4531))
1305 .resolve(&context)
1306 .expect("the explicit local stack should provide its SpacetimeDB port");
1307
1308 assert_eq!(resource.url, "http://127.0.0.1:4531");
1309 }
1310
1311 #[test]
1312 fn builds_spacetime_resource_from_config() {
1313 let context = test_runtime(RuntimeEnvironment::Local);
1314 let resource = SpacetimeDB::builder()
1315 .config(test_config())
1316 .local_stack(test_local_stack())
1317 .module_name("example-module")
1318 .module_path("crates/database/spacetimedb")
1319 .resolve(&context)
1320 .expect("resource build should succeed");
1321
1322 assert_eq!(resource.url, "http://127.0.0.1:4530");
1323 }
1324
1325 #[test]
1326 fn applies_local_spacetime_port_override_to_config_url() {
1327 let context = test_runtime(RuntimeEnvironment::Local);
1328 let resource = SpacetimeDB::builder()
1329 .config(test_config())
1330 .local_stack(test_local_stack())
1331 .module_name("example-module")
1332 .module_path("crates/database/spacetimedb")
1333 .port(4531)
1334 .resolve(&context)
1335 .expect("resource build should succeed");
1336
1337 assert_eq!(resource.url, "http://127.0.0.1:4531");
1338 }
1339
1340 #[tokio::test]
1341 async fn production_never_prepares_explicit_local_spacetime_modules() {
1342 let directory = tempfile::tempdir().unwrap();
1343 let runtime = test_runtime(RuntimeEnvironment::Production);
1344 let connection = runtime
1345 .load(
1346 SpacetimeDB::builder()
1347 .local_uri("http://127.0.0.1:19381")
1348 .local_stack(LocalStack::new(
1349 "production-policy-test",
1350 directory.path().join("absent.env"),
1351 ))
1352 .module_name("production-policy-test")
1353 .module_path(directory.path().join("absent-module")),
1354 )
1355 .await
1356 .unwrap();
1357 assert_eq!(connection.endpoint_url(), "http://127.0.0.1:19381");
1358 assert!(connection.auth_token().is_none());
1359 assert_eq!(fs::read_dir(directory.path()).unwrap().count(), 0);
1360 }
1361
1362 #[tokio::test]
1363 async fn production_resolves_explicit_mariadb_without_a_local_stack_file() {
1364 let directory = tempfile::tempdir().unwrap();
1365 let runtime = test_runtime(RuntimeEnvironment::Production);
1366 let url = runtime
1367 .load(
1368 MariaDB::builder()
1369 .local_uri("mysql://app:secret@127.0.0.1/database")
1370 .local_stack(LocalStack::new(
1371 "production-policy-test",
1372 directory.path().join("absent.env"),
1373 )),
1374 )
1375 .await
1376 .unwrap();
1377 assert_eq!(url, "mysql://app:secret@127.0.0.1/database");
1378 assert_eq!(fs::read_dir(directory.path()).unwrap().count(), 0);
1379 }
1380
1381 #[test]
1382 fn resource_debug_output_redacts_passwords_and_tokens() {
1383 let config = Arc::new(AppConfig {
1384 mariadb: ch_config::config::MariadbConfig::new(
1385 "mysql://app:config-secret@localhost/database",
1386 ),
1387 ..AppConfig::default()
1388 });
1389 let maria = MariaDB::builder()
1390 .local_uri("mysql://app:url-secret@localhost/database")
1391 .config(Arc::clone(&config));
1392 let spacetime = SpacetimeDB::builder()
1393 .local_uri("http://app:url-secret@localhost:3000")
1394 .config(config);
1395 let connection = SpacetimeDbConnection {
1396 endpoint_url: "http://app:url-secret@localhost:3000".into(),
1397 auth_token: Some("owner-token-secret".into()),
1398 };
1399 for debug in [
1400 format!("{maria:?}"),
1401 format!("{spacetime:?}"),
1402 format!("{connection:?}"),
1403 ] {
1404 for secret in ["config-secret", "url-secret", "owner-token-secret"] {
1405 assert!(!debug.contains(secret), "secret leaked in Debug: {debug}");
1406 }
1407 }
1408 }
1409
1410 #[test]
1411 fn runtime_error_preserves_the_original_error_type() {
1412 fn assert_error<T: std::error::Error + Send + Sync + 'static>() {}
1413 assert_error::<RuntimeError>();
1414 let error = RuntimeError::Io(std::io::Error::new(
1415 std::io::ErrorKind::NotFound,
1416 "missing project",
1417 ));
1418 let source = std::error::Error::source(&error)
1419 .unwrap()
1420 .downcast_ref::<std::io::Error>()
1421 .unwrap();
1422 assert_eq!(source.kind(), std::io::ErrorKind::NotFound);
1423 }
1424}