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;
12mod runtime;
13
14use std::fs;
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, ensure_local_stack_with_profiles,
21 is_local_database, should_bootstrap_local_stack,
22};
23use crate::runtime::{
24 Context as RuntimeContext, Environment, IntoResource, LoaderAndRunner, ResourceInputBuilder,
25};
26use anyhow::{Context as AnyhowContext, Result, anyhow};
27use async_trait::async_trait;
28use ch_config::config::AppConfig;
29use serde::Serialize;
30use sqlx::MySqlPool;
31use sqlx::mysql::MySqlPoolOptions;
32
33pub use crate::runtime::Environment as RuntimeEnvironment;
34pub use project::Project;
35
36#[doc(hidden)]
37pub mod __internals {
38 pub use crate::runtime::{
39 Context, Environment, IntoResource, LoaderAndRunner, MetadataMap, ResourceInputBuilder,
40 };
41}
42
43#[derive(Debug)]
46pub struct Runtime {
47 runner: LoaderAndRunner,
48 project: Project,
49 environment_load: ch_config::environment::EnvLoad,
50}
51
52impl Runtime {
53 pub fn bootstrap() -> Result<Self> {
55 Self::bootstrap_with_environment(RuntimeEnvironment::Automatic)
56 }
57
58 pub fn bootstrap_with_environment(environment: RuntimeEnvironment) -> Result<Self> {
60 let project = Project::discover()?;
61 let environment_load = project.load_environment()?;
62 let mut context = RuntimeContext::with_environment(environment);
63 context.set_project_root(project.root().to_path_buf());
64 Ok(Self {
65 runner: LoaderAndRunner::with_context(context),
66 project,
67 environment_load,
68 })
69 }
70
71 pub fn project(&self) -> &Project {
73 &self.project
74 }
75
76 pub fn environment_load(&self) -> ch_config::environment::EnvLoad {
78 self.environment_load
79 }
80
81 pub async fn load<RB, Input, Output>(&self, builder: RB) -> Result<Output>
83 where
84 RB: ResourceInputBuilder<Input = Input>,
85 Input: IntoResource<Output> + Send,
86 Output: Send,
87 {
88 self.runner.load(builder).await
89 }
90}
91
92#[derive(Debug, Clone, Serialize)]
94pub struct LocalStack {
95 project_name: String,
96 env_path: PathBuf,
97 spacetime_module: Option<LocalSpacetimeModule>,
98}
99
100#[derive(Debug, Clone, Serialize)]
101struct LocalSpacetimeModule {
102 name: String,
103 path: PathBuf,
104}
105
106impl LocalStack {
107 pub fn new(project_name: impl Into<String>, env_path: impl Into<PathBuf>) -> Self {
109 Self {
110 project_name: project_name.into(),
111 env_path: env_path.into(),
112 spacetime_module: None,
113 }
114 }
115
116 pub fn discover() -> Result<Self> {
118 let current_dir = std::env::current_dir()
119 .context("failed to determine the current directory for local-stack discovery")?;
120 Self::discover_from(current_dir)
121 }
122
123 pub fn discover_from(start: impl AsRef<Path>) -> Result<Self> {
128 let start = project::discovery_root(start.as_ref());
129
130 for project_root in start.ancestors() {
131 let env_path = project_root.join("services.env");
132 if !env_path.is_file() {
133 continue;
134 }
135
136 let app_manifest = project_root.join("app").join("Cargo.toml");
137 let root_manifest = project_root.join("Cargo.toml");
138 let package_name = cargo_package_name(&app_manifest)?
139 .or(cargo_package_name(&root_manifest)?)
140 .ok_or_else(|| {
141 anyhow!(
142 "local project at {} has no package name in app/Cargo.toml or Cargo.toml",
143 project_root.display()
144 )
145 })?;
146 let spacetime_module = discover_spacetime_module(project_root)?;
147
148 return Ok(Self {
149 project_name: format!("{package_name}-local"),
150 env_path,
151 spacetime_module,
152 });
153 }
154
155 Err(anyhow!(
156 "no local project with services.env was found from {}",
157 start.display()
158 ))
159 }
160
161 pub fn project_name(&self) -> &str {
163 &self.project_name
164 }
165
166 pub fn env_path(&self) -> &Path {
168 &self.env_path
169 }
170}
171
172fn cargo_package_name(manifest_path: &Path) -> Result<Option<String>> {
173 if !manifest_path.is_file() {
174 return Ok(None);
175 }
176
177 let source = fs::read_to_string(manifest_path)
178 .with_context(|| format!("failed to read Cargo manifest {}", manifest_path.display()))?;
179 let manifest = toml::from_str::<toml::Table>(&source)
180 .with_context(|| format!("failed to parse Cargo manifest {}", manifest_path.display()))?;
181
182 Ok(manifest
183 .get("package")
184 .and_then(toml::Value::as_table)
185 .and_then(|package| package.get("name"))
186 .and_then(toml::Value::as_str)
187 .map(str::trim)
188 .filter(|name| !name.is_empty())
189 .map(str::to_owned))
190}
191
192fn discover_spacetime_module(project_root: &Path) -> Result<Option<LocalSpacetimeModule>> {
193 let path = project_root
194 .join("crates")
195 .join("database")
196 .join("spacetimedb");
197 let manifest_path = path.join("Cargo.toml");
198 let Some(package_name) = cargo_package_name(&manifest_path)? else {
199 return Ok(None);
200 };
201 let name = package_name
202 .strip_suffix("-spacetimedb")
203 .unwrap_or(package_name.as_str())
204 .to_owned();
205
206 Ok(Some(LocalSpacetimeModule { name, path }))
207}
208
209#[derive(Debug, Default, Clone)]
211pub struct MariaDB {
212 database_name: Option<String>,
213 local_uri: Option<String>,
214 port: Option<u16>,
215 config: Option<Arc<AppConfig>>,
216 local_stack: Option<LocalStack>,
217 max_connections: Option<u32>,
218}
219
220impl MariaDB {
221 pub fn builder() -> Self {
223 Self::default()
224 }
225
226 pub fn database_name(mut self, database_name: impl Into<String>) -> Self {
228 self.database_name = Some(database_name.into());
229 self
230 }
231
232 pub fn local_uri(mut self, local_uri: impl Into<String>) -> Self {
234 self.local_uri = Some(local_uri.into());
235 self
236 }
237
238 pub fn port(mut self, port: u16) -> Self {
240 self.port = Some(port);
241 self
242 }
243
244 pub fn config(mut self, config: Arc<AppConfig>) -> Self {
246 self.config = Some(config);
247 self
248 }
249
250 pub fn local_stack(mut self, local_stack: LocalStack) -> Self {
252 self.local_stack = Some(local_stack);
253 self
254 }
255
256 pub fn max_connections(mut self, max: u32) -> Self {
258 self.max_connections = Some(max);
259 self
260 }
261
262 pub fn database_name_override(&self) -> Option<&str> {
264 self.database_name.as_deref()
265 }
266
267 pub fn local_uri_override(&self) -> Option<&str> {
269 self.local_uri.as_deref()
270 }
271
272 pub fn port_override(&self) -> Option<u16> {
274 self.port
275 }
276}
277
278#[derive(Debug, Default, Clone)]
280pub struct SpacetimeDB {
281 local_uri: Option<String>,
282 port: Option<u16>,
283 config: Option<Arc<AppConfig>>,
284 local_stack: Option<LocalStack>,
285 module_name: Option<String>,
286 module_path: Option<PathBuf>,
287}
288
289impl SpacetimeDB {
290 pub fn builder() -> Self {
292 Self::default()
293 }
294
295 pub fn local_uri(mut self, local_uri: impl Into<String>) -> Self {
297 self.local_uri = Some(local_uri.into());
298 self
299 }
300
301 pub fn port(mut self, port: u16) -> Self {
303 self.port = Some(port);
304 self
305 }
306
307 pub fn config(mut self, config: Arc<AppConfig>) -> Self {
309 self.config = Some(config);
310 self
311 }
312
313 pub fn local_stack(mut self, local_stack: LocalStack) -> Self {
315 self.local_stack = Some(local_stack);
316 self
317 }
318
319 pub fn module_name(mut self, module_name: impl Into<String>) -> Self {
321 self.module_name = Some(module_name.into());
322 self
323 }
324
325 pub fn module_path(mut self, path: impl Into<PathBuf>) -> Self {
327 self.module_path = Some(path.into());
328 self
329 }
330}
331
332#[derive(Debug, Clone)]
334pub struct WebBind {
335 addr: String,
336}
337
338impl WebBind {
339 pub fn builder() -> Self {
341 Self {
342 addr: "0.0.0.0:4000".to_string(),
343 }
344 }
345
346 pub fn addr(mut self, addr: impl Into<String>) -> Self {
348 self.addr = addr.into();
349 self
350 }
351}
352
353#[derive(Debug, Clone, Serialize)]
355pub struct MariaDbResource {
356 connection_string: String,
357 max_connections: Option<u32>,
358 local_stack: Option<LocalStack>,
359 bootstrap_local_stack: bool,
360}
361
362impl MariaDbResource {
363 pub fn connection_string(&self) -> &str {
365 &self.connection_string
366 }
367}
368
369#[derive(Debug, Clone, Serialize)]
371pub struct SpacetimeDbResource {
372 url: String,
373 local_stack: Option<LocalStack>,
374 bootstrap_local_stack: bool,
375 module_name: Option<String>,
376 module_path: Option<PathBuf>,
377}
378
379impl SpacetimeDbResource {
380 pub fn url(&self) -> &str {
382 &self.url
383 }
384}
385
386pub struct SpacetimeDbConnection {
389 endpoint_url: String,
390 auth_token: Option<String>,
391}
392
393impl SpacetimeDbConnection {
394 pub fn endpoint_url(&self) -> &str {
396 self.endpoint_url.as_str()
397 }
398
399 pub fn auth_token(&self) -> Option<&str> {
402 self.auth_token.as_deref()
403 }
404
405 pub fn with_auth_token(mut self, auth_token: impl Into<String>) -> Self {
408 self.auth_token = Some(auth_token.into());
409 self
410 }
411}
412
413impl std::fmt::Debug for SpacetimeDbConnection {
414 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
415 formatter
416 .debug_struct("SpacetimeDbConnection")
417 .field("endpoint_url", &self.endpoint_url)
418 .field(
419 "auth_token",
420 &self.auth_token.as_ref().map(|_| "[redacted]"),
421 )
422 .finish()
423 }
424}
425
426#[derive(Debug, Clone, Serialize)]
428pub struct WebBindResource {
429 addr: String,
430}
431
432impl WebBindResource {
433 pub fn addr(&self) -> &str {
435 &self.addr
436 }
437}
438
439impl ResourceInputBuilder for MariaDB {
440 type Input = MariaDbResource;
441
442 fn build(self, context: &RuntimeContext) -> Result<Self::Input> {
443 let mut local_stack = self.local_stack;
444 let has_configured_database = self
445 .config
446 .as_ref()
447 .is_some_and(|config| config.mariadb.is_configured());
448 if self.local_uri.is_none()
449 && !has_configured_database
450 && context.environment().allows_local_resources()
451 && local_stack.is_none()
452 {
453 local_stack = Some(match context.project_root() {
454 Some(project_root) => LocalStack::discover_from(project_root)?,
455 None => LocalStack::discover()?,
456 });
457 }
458 let mut connection_string = match self.local_uri {
459 Some(override_uri) => override_uri,
460 None => resolve_connection_string(
461 context.environment(),
462 self.config.as_deref(),
463 local_stack.as_ref(),
464 )?,
465 };
466
467 if context.environment().allows_local_resources()
468 && let Some(port) = self.port
469 {
470 connection_string = rewrite_connection_port(&connection_string, port)?;
471 }
472
473 if let Some(database_name) = self.database_name {
474 connection_string = rewrite_database_name(&connection_string, &database_name)?;
475 }
476
477 let (local_stack, bootstrap_local_stack) = resolve_local_stack(
478 context.environment(),
479 &connection_string,
480 local_stack,
481 context.project_root(),
482 )?;
483
484 Ok(MariaDbResource {
485 connection_string,
486 max_connections: self.max_connections,
487 local_stack,
488 bootstrap_local_stack,
489 })
490 }
491}
492
493impl ResourceInputBuilder for SpacetimeDB {
494 type Input = SpacetimeDbResource;
495
496 fn build(self, context: &RuntimeContext) -> Result<Self::Input> {
497 let url = resolve_spacetime_url(
498 context.environment(),
499 self.local_uri,
500 self.port,
501 self.config.as_deref(),
502 )?;
503 let (local_stack, bootstrap_local_stack) = resolve_local_stack(
504 context.environment(),
505 &url,
506 self.local_stack,
507 context.project_root(),
508 )?;
509 if self.module_path.is_some() != self.module_name.is_some() {
510 return Err(anyhow!(
511 "SpacetimeDB module name and path must be supplied together"
512 ));
513 }
514 let (module_name, module_path) = match (self.module_name, self.module_path) {
515 (Some(name), Some(path)) => (Some(name), Some(path)),
516 (None, None) => local_stack
517 .as_ref()
518 .and_then(|stack| stack.spacetime_module.as_ref())
519 .map_or((None, None), |module| {
520 (Some(module.name.clone()), Some(module.path.clone()))
521 }),
522 _ => unreachable!("module name/path pairing was validated above"),
523 };
524
525 Ok(SpacetimeDbResource {
526 url,
527 local_stack,
528 bootstrap_local_stack,
529 module_name,
530 module_path,
531 })
532 }
533}
534
535impl ResourceInputBuilder for WebBind {
536 type Input = WebBindResource;
537
538 fn build(self, _context: &RuntimeContext) -> Result<Self::Input> {
539 let addr = match ch_config::environment::value("PORT")? {
540 Some(port) => {
541 let port = port
542 .parse::<u16>()
543 .context("PORT must be an integer TCP port")?;
544 let mut addr = self
545 .addr
546 .parse::<SocketAddr>()
547 .context("the fallback web listener address must be a socket address")?;
548 addr.set_port(port);
549 addr.to_string()
550 }
551 None => self.addr,
552 };
553
554 Ok(WebBindResource { addr })
555 }
556}
557
558#[async_trait]
559impl IntoResource<String> for MariaDbResource {
560 async fn into_resource(self, _context: &RuntimeContext) -> Result<String> {
561 let connection_string = apply_local_stack_overrides(
562 self.connection_string,
563 self.local_stack.as_ref(),
564 self.bootstrap_local_stack,
565 )
566 .await?;
567
568 Ok(connection_string)
569 }
570}
571
572#[async_trait]
573impl IntoResource<MySqlPool> for MariaDbResource {
574 async fn into_resource(self, _context: &RuntimeContext) -> Result<MySqlPool> {
575 let mut builder = MySqlPoolOptions::new();
576 if let Some(max) = self.max_connections {
577 builder = builder.max_connections(max);
578 }
579
580 let connection_string = apply_local_stack_overrides(
581 self.connection_string,
582 self.local_stack.as_ref(),
583 self.bootstrap_local_stack,
584 )
585 .await?;
586
587 builder
588 .connect(&connection_string)
589 .await
590 .with_context(|| anyhow!("failed to connect to MariaDB"))
591 }
592}
593
594#[async_trait]
595impl IntoResource<String> for SpacetimeDbResource {
596 async fn into_resource(self, _context: &RuntimeContext) -> Result<String> {
597 Ok(resolve_spacetime_resource(self).await?.endpoint_url)
598 }
599}
600
601#[async_trait]
602impl IntoResource<SpacetimeDbConnection> for SpacetimeDbResource {
603 async fn into_resource(self, _context: &RuntimeContext) -> Result<SpacetimeDbConnection> {
604 resolve_spacetime_resource(self).await
605 }
606}
607
608async fn resolve_spacetime_resource(
609 resource: SpacetimeDbResource,
610) -> Result<SpacetimeDbConnection> {
611 let manage_local_module = resource.local_stack.is_some();
612 let url = apply_local_stack_with_profiles(
613 resource.url,
614 resource.local_stack.as_ref(),
615 resource.bootstrap_local_stack,
616 &["spacetime"],
617 )
618 .await?;
619
620 let mut auth_token = None;
621 if manage_local_module && let Some(module_path) = resource.module_path.as_deref() {
622 let module_name = resource.module_name.as_deref().ok_or_else(|| {
623 anyhow!("SpacetimeDB module name must accompany an explicit module path")
624 })?;
625 auth_token = ensure_local_spacetime_module(url.as_str(), module_name, module_path)?;
626 }
627
628 Ok(SpacetimeDbConnection {
629 endpoint_url: url,
630 auth_token,
631 })
632}
633
634#[async_trait]
635impl IntoResource<SocketAddr> for WebBindResource {
636 async fn into_resource(self, _context: &RuntimeContext) -> Result<SocketAddr> {
637 self.addr
638 .parse()
639 .with_context(|| format!("invalid web bind address `{}`", self.addr))
640 }
641}
642
643async fn apply_local_stack_overrides(
645 connection_string: String,
646 local_stack: Option<&LocalStack>,
647 bootstrap: bool,
648) -> Result<String> {
649 if !bootstrap {
650 return Ok(connection_string);
651 }
652 let local_stack = local_stack
653 .ok_or_else(|| anyhow!("local-stack settings are required for local bootstrap"))?;
654
655 ensure_local_stack(local_stack, connection_string.as_str()).await?;
656 Ok(connection_string)
657}
658
659async fn apply_local_stack_with_profiles(
661 value: String,
662 local_stack: Option<&LocalStack>,
663 bootstrap: bool,
664 profiles: &[&str],
665) -> Result<String> {
666 if !bootstrap {
667 return Ok(value);
668 }
669 let local_stack = local_stack
670 .ok_or_else(|| anyhow!("local-stack settings are required for local bootstrap"))?;
671
672 ensure_local_stack_with_profiles(local_stack, profiles, Some(value.as_str())).await?;
673 Ok(value)
674}
675
676fn resolve_connection_string(
677 environment: Environment,
678 config: Option<&AppConfig>,
679 local_stack: Option<&LocalStack>,
680) -> Result<String> {
681 if let Some(config) = config.filter(|config| config.mariadb.is_configured()) {
682 return Ok(config.mariadb.connection_url()?);
683 }
684
685 if environment.allows_local_resources()
686 && let Some(local_stack) = local_stack
687 {
688 return local_mariadb_url(local_stack.env_path());
689 }
690
691 Err(anyhow!("MariaDB configuration must be supplied explicitly"))
692}
693
694fn local_mariadb_url(env_path: &Path) -> Result<String> {
695 let values = dotenvy::from_path_iter(env_path)
696 .with_context(|| {
697 format!(
698 "failed to read local MariaDB environment from {}",
699 env_path.display()
700 )
701 })?
702 .collect::<std::result::Result<std::collections::HashMap<_, _>, _>>()
703 .with_context(|| {
704 format!(
705 "failed to parse local MariaDB environment from {}",
706 env_path.display()
707 )
708 })?;
709 let required = |name: &'static str| {
710 values
711 .get(name)
712 .map(String::as_str)
713 .filter(|value| !value.trim().is_empty())
714 .ok_or_else(|| {
715 anyhow!(
716 "{name} must be set in the local MariaDB environment file {}",
717 env_path.display()
718 )
719 })
720 };
721 let user = required("MARIADB_USER")?;
722 let password = required("MARIADB_PASSWORD")?;
723 let database = required("MARIADB_DATABASE")?;
724 let port = values
725 .get("MARIADB_PORT")
726 .map(String::as_str)
727 .filter(|value| !value.trim().is_empty())
728 .unwrap_or("3306")
729 .parse::<u16>()
730 .with_context(|| {
731 format!(
732 "MARIADB_PORT must be a valid TCP port in {}",
733 env_path.display()
734 )
735 })?;
736
737 let mut url = url::Url::parse("mysql://127.0.0.1")
738 .expect("the static local MariaDB base URL must be valid");
739 url.set_username(user)
740 .map_err(|()| anyhow!("MARIADB_USER contains unsupported URL data"))?;
741 url.set_password(Some(password))
742 .map_err(|()| anyhow!("MARIADB_PASSWORD contains unsupported URL data"))?;
743 url.set_port(Some(port))
744 .map_err(|()| anyhow!("MARIADB_PORT cannot be represented in the connection URL"))?;
745 url.set_path(database);
746
747 Ok(url.into())
748}
749
750fn resolve_spacetime_url(
751 environment: Environment,
752 local_uri: Option<String>,
753 port: Option<u16>,
754 config: Option<&AppConfig>,
755) -> Result<String> {
756 if let Some(local_uri) = local_uri {
757 return Ok(local_uri);
758 }
759
760 let mut url = match config.filter(|config| config.spacetimedb.is_configured()) {
761 Some(config) => config.spacetimedb.endpoint_url()?.to_owned(),
762 None if environment.allows_local_resources() => local_spacetime_url(port),
763 None => {
764 return Err(anyhow!(
765 "SpacetimeDB configuration must be supplied explicitly outside local development"
766 ));
767 }
768 };
769
770 if environment.allows_local_resources()
771 && let Some(port) = port
772 {
773 url = rewrite_url_port(&url, port)?;
774 }
775
776 Ok(url)
777}
778
779fn local_spacetime_url(port: Option<u16>) -> String {
780 let port = port.unwrap_or(4530);
781
782 format!("http://127.0.0.1:{port}")
783}
784
785fn resolve_local_stack(
786 environment: Environment,
787 connection_url: &str,
788 local_stack: Option<LocalStack>,
789 project_root: Option<&Path>,
790) -> Result<(Option<LocalStack>, bool)> {
791 let bootstrap = should_bootstrap_local_stack(environment, connection_url);
792 let local_stack = match local_stack {
793 Some(local_stack) => Some(local_stack),
794 None if environment.allows_local_resources() && is_local_database(connection_url) => {
795 let discovered = match project_root {
796 Some(project_root) => LocalStack::discover_from(project_root),
797 None => LocalStack::discover(),
798 };
799 match discovered {
800 Ok(local_stack) => Some(local_stack),
801 Err(error) if bootstrap => return Err(error),
802 Err(_) => None,
803 }
804 }
805 None => None,
806 };
807 let Some(local_stack) = local_stack else {
808 return Ok((None, false));
809 };
810 if local_stack.project_name().trim().is_empty() {
811 return Err(anyhow!("local-stack project name must not be empty"));
812 }
813 if local_stack.env_path().as_os_str().is_empty() {
814 return Err(anyhow!("local-stack env-file path must not be empty"));
815 }
816
817 Ok((Some(local_stack), bootstrap))
818}
819
820fn rewrite_url_port(current: &str, port: u16) -> Result<String> {
821 let scheme_end = current
822 .find("://")
823 .ok_or_else(|| anyhow!("invalid url: {current}"))?;
824 let authority_start = scheme_end + "://".len();
825 let suffix_start = current[authority_start..]
826 .find(['/', '?', '#'])
827 .map(|index| authority_start + index)
828 .unwrap_or(current.len());
829 rewrite_authority_port(current, authority_start, suffix_start, port)
830}
831
832fn rewrite_connection_port(current: &str, port: u16) -> Result<String> {
834 let scheme_end = current
835 .find("://")
836 .ok_or_else(|| anyhow!("invalid database url: {current}"))?;
837 let authority_start = scheme_end + "://".len();
838 let relative_path_start = current[authority_start..]
839 .find('/')
840 .ok_or_else(|| anyhow!("invalid database url: {current}"))?;
841 let path_start = authority_start + relative_path_start;
842 rewrite_authority_port(current, authority_start, path_start, port)
843}
844
845fn rewrite_authority_port(
846 current: &str,
847 authority_start: usize,
848 suffix_start: usize,
849 port: u16,
850) -> Result<String> {
851 let authority = ¤t[authority_start..suffix_start];
852 let host_start = authority.rfind('@').map(|index| index + 1).unwrap_or(0);
853 let (userinfo, host_port) = authority.split_at(host_start);
854 let host_port = rewrite_host_port(host_port, port)?;
855
856 let mut rebuilt = String::with_capacity(current.len() + port.to_string().len() + ":".len());
857 rebuilt.push_str(¤t[..authority_start]);
858 rebuilt.push_str(userinfo);
859 rebuilt.push_str(&host_port);
860 rebuilt.push_str(¤t[suffix_start..]);
861
862 Ok(rebuilt)
863}
864
865fn rewrite_host_port(host_port: &str, port: u16) -> Result<String> {
866 if host_port.trim().is_empty() {
867 return Err(anyhow!("invalid database url host"));
868 }
869
870 if let Some(rest) = host_port.strip_prefix('[') {
871 let bracket_end = rest
872 .find(']')
873 .ok_or_else(|| anyhow!("invalid bracketed database url host: {host_port}"))?;
874 let host_end = bracket_end + 2;
875 let host = &host_port[..host_end];
876 return Ok(format!("{host}:{port}"));
877 }
878
879 if let Some((host, candidate_port)) = host_port.rsplit_once(':')
880 && !host.is_empty()
881 && candidate_port.chars().all(|ch| ch.is_ascii_digit())
882 {
883 return Ok(format!("{host}:{port}"));
884 }
885
886 Ok(format!("{host_port}:{port}"))
887}
888
889fn rewrite_database_name(current: &str, database_name: &str) -> Result<String> {
891 let trimmed = database_name.trim();
892 if trimmed.is_empty() {
893 return Err(anyhow!("database_name must not be empty"));
894 }
895
896 let last_slash = current
897 .rfind('/')
898 .ok_or_else(|| anyhow!("invalid database url: {current}"))?;
899
900 let suffix_start = current[last_slash..]
901 .find(|ch| ['?', '#'].contains(&ch))
902 .map(|relative| last_slash + relative);
903
904 let (base, suffix) = match suffix_start {
905 Some(index) => current.split_at(index),
906 None => (current, ""),
907 };
908
909 let (prefix, _) = base
910 .rsplit_once('/')
911 .ok_or_else(|| anyhow!("invalid database url: {current}"))?;
912
913 let mut rebuilt = String::with_capacity(prefix.len() + 1 + trimmed.len() + suffix.len());
914 rebuilt.push_str(prefix);
915 rebuilt.push('/');
916 rebuilt.push_str(trimmed);
917 rebuilt.push_str(suffix);
918
919 Ok(rebuilt)
920}
921
922#[cfg(test)]
923mod tests {
924 use super::*;
925 use crate::__internals::{Context, Environment};
926 use std::sync::atomic::{AtomicU64, Ordering};
927 use tempfile::tempdir;
928
929 static LOCAL_STACK_SEQUENCE: AtomicU64 = AtomicU64::new(0);
930
931 fn test_config() -> Arc<AppConfig> {
932 use ch_config::config::{CaptchaConfig, MariadbConfig, SpacetimeDbConfig};
933
934 Arc::new(AppConfig {
935 mariadb: MariadbConfig::new("mysql://user:pass@127.0.0.1:4520/app"),
936 spacetimedb: SpacetimeDbConfig::new("http://127.0.0.1:4530"),
937 captcha: CaptchaConfig::standard(),
938 ..AppConfig::default()
939 })
940 }
941
942 fn test_local_stack() -> LocalStack {
943 let sequence = LOCAL_STACK_SEQUENCE.fetch_add(1, Ordering::Relaxed);
944 let env_path =
945 std::env::temp_dir().join(format!("local-stack-{}-{sequence}.env", std::process::id()));
946 std::fs::write(
947 &env_path,
948 "MARIADB_ROOT_PASSWORD=root\nMARIADB_DATABASE=app\nMARIADB_USER=user\nMARIADB_PASSWORD=pass\n",
949 )
950 .expect("test stack env should be writable");
951 LocalStack::new("test-data-services", env_path)
952 }
953
954 #[test]
955 fn preserves_query_params_when_rewriting_database_name() {
956 let url = "mysql://user:pa#ss@127.0.0.1:4520/demo?ssl-mode=REQUIRED&pool_min=3";
957 let rewritten =
958 rewrite_database_name(url, "custom_db").expect("database name rewrite should succeed");
959
960 assert_eq!(
961 rewritten,
962 "mysql://user:pa#ss@127.0.0.1:4520/custom_db?ssl-mode=REQUIRED&pool_min=3"
963 );
964 }
965
966 #[test]
967 fn preserves_credentials_and_suffix_when_rewriting_port() {
968 let url = "mysql://user:pa#ss@word@127.0.0.1:3306/demo?ssl-mode=REQUIRED&pool_min=3";
969 let rewritten = rewrite_connection_port(url, 4521).expect("port rewrite should succeed");
970
971 assert_eq!(
972 rewritten,
973 "mysql://user:pa#ss@word@127.0.0.1:4521/demo?ssl-mode=REQUIRED&pool_min=3"
974 );
975 }
976
977 #[test]
978 fn appends_missing_connection_port() {
979 let url = "mysql://user:pass@localhost/demo";
980 let rewritten = rewrite_connection_port(url, 4524).expect("port rewrite should succeed");
981
982 assert_eq!(rewritten, "mysql://user:pass@localhost:4524/demo");
983 }
984
985 #[test]
986 fn applies_local_port_override_to_connection_url() {
987 let context = Context::with_environment(Environment::Local);
988 let resource = MariaDB::builder()
989 .local_uri("mysql://user:pass@127.0.0.1:3306/app?ssl-mode=DISABLED")
990 .port(4521)
991 .local_stack(test_local_stack())
992 .build(&context)
993 .expect("resource build should succeed");
994
995 assert_eq!(
996 resource.connection_string(),
997 "mysql://user:pass@127.0.0.1:4521/app?ssl-mode=DISABLED"
998 );
999 }
1000
1001 #[test]
1002 fn resolves_local_mariadb_from_the_explicit_stack_env_file() {
1003 let context = Context::with_environment(Environment::Local);
1004 let resource = MariaDB::builder()
1005 .local_stack(test_local_stack())
1006 .build(&context)
1007 .expect("the explicit local stack should provide its MariaDB settings");
1008
1009 assert_eq!(
1010 resource.connection_string(),
1011 "mysql://user:pass@127.0.0.1:3306/app"
1012 );
1013 }
1014
1015 #[test]
1016 fn discovers_local_stack_and_spacetime_module_from_project_layout() {
1017 let temp = tempdir().expect("temporary project should be created");
1018 let project_root = temp.path().join("example");
1019 let nested_start = project_root.join("app").join("src");
1020 let app_dir = project_root.join("app");
1021 let module_dir = project_root
1022 .join("crates")
1023 .join("database")
1024 .join("spacetimedb");
1025 fs::create_dir_all(&nested_start).expect("nested project path should be created");
1026 fs::create_dir_all(&module_dir).expect("module directory should be created");
1027 fs::write(
1028 project_root.join("services.env"),
1029 "MARIADB_DATABASE=example\n",
1030 )
1031 .expect("local env should be written");
1032 fs::write(
1033 app_dir.join("Cargo.toml"),
1034 "[package]\nname = \"example-service\"\nversion = \"0.1.0\"\n",
1035 )
1036 .expect("app manifest should be written");
1037 fs::write(
1038 module_dir.join("Cargo.toml"),
1039 "[package]\nname = \"example-spacetimedb\"\nversion = \"0.1.0\"\n",
1040 )
1041 .expect("module manifest should be written");
1042
1043 let stack = LocalStack::discover_from(&nested_start)
1044 .expect("the conventional project layout should be discovered");
1045
1046 assert_eq!(stack.project_name(), "example-service-local");
1047 assert_eq!(stack.env_path(), project_root.join("services.env"));
1048 let module = stack
1049 .spacetime_module
1050 .expect("the conventional SpacetimeDB module should be discovered");
1051 assert_eq!(module.name, "example");
1052 assert_eq!(module.path, module_dir);
1053 }
1054
1055 #[test]
1056 fn production_mariadb_still_requires_explicit_app_config() {
1057 let context = Context::with_environment(Environment::Production);
1058 let error = MariaDB::builder()
1059 .local_stack(test_local_stack())
1060 .build(&context)
1061 .expect_err("production must not read credentials from a local stack file");
1062
1063 assert!(
1064 error
1065 .to_string()
1066 .contains("MariaDB configuration must be supplied explicitly")
1067 );
1068 }
1069
1070 fn docker_available() -> bool {
1071 std::process::Command::new("docker")
1072 .arg("--version")
1073 .output()
1074 .is_ok()
1075 }
1076
1077 #[test]
1078 fn builds_spacetime_resource_from_explicit_url() {
1079 let context = Context::with_environment(Environment::Local);
1080 let resource = SpacetimeDB::builder()
1081 .local_uri("http://127.0.0.1:4531")
1082 .local_stack(test_local_stack())
1083 .module_name("example-module")
1084 .module_path("crates/database/spacetimedb")
1085 .build(&context)
1086 .expect("resource build should succeed");
1087
1088 assert_eq!(resource.url(), "http://127.0.0.1:4531");
1089 }
1090
1091 #[test]
1092 fn builds_spacetime_resource_from_config() {
1093 let context = Context::with_environment(Environment::Local);
1094 let resource = SpacetimeDB::builder()
1095 .config(test_config())
1096 .local_stack(test_local_stack())
1097 .module_name("example-module")
1098 .module_path("crates/database/spacetimedb")
1099 .build(&context)
1100 .expect("resource build should succeed");
1101
1102 assert_eq!(resource.url(), "http://127.0.0.1:4530");
1103 }
1104
1105 #[test]
1106 fn applies_local_spacetime_port_override_to_config_url() {
1107 let context = Context::with_environment(Environment::Local);
1108 let resource = SpacetimeDB::builder()
1109 .config(test_config())
1110 .local_stack(test_local_stack())
1111 .module_name("example-module")
1112 .module_path("crates/database/spacetimedb")
1113 .port(4531)
1114 .build(&context)
1115 .expect("resource build should succeed");
1116
1117 assert_eq!(resource.url(), "http://127.0.0.1:4531");
1118 }
1119
1120 #[test]
1121 #[ignore = "requires a local Docker daemon and compose stack"]
1122 fn loads_default_connection_from_config() {
1123 if !docker_available() {
1124 eprintln!("skipping docker-backed test because Docker is not available");
1125 return;
1126 }
1127
1128 let builder = MariaDB::builder()
1129 .config(test_config())
1130 .local_stack(test_local_stack());
1131 let context = Context::with_environment(Environment::Local);
1132 let resource = builder
1133 .build(&context)
1134 .expect("resource build should succeed");
1135
1136 let runtime = tokio::runtime::Builder::new_current_thread()
1137 .enable_all()
1138 .build()
1139 .expect("failed to create runtime");
1140 let connection: String = runtime
1141 .block_on(resource.into_resource(&context))
1142 .expect("into_resource should return a connection string");
1143
1144 assert!(connection.starts_with("mysql://"));
1145 }
1146
1147 #[test]
1148 #[ignore = "requires a local Docker daemon and compose stack"]
1149 fn applies_database_name_override() {
1150 if !docker_available() {
1151 eprintln!("skipping docker-backed test because Docker is not available");
1152 return;
1153 }
1154
1155 let builder = MariaDB::builder()
1156 .config(test_config())
1157 .local_stack(test_local_stack())
1158 .database_name("custom_db");
1159 let context = Context::with_environment(Environment::Local);
1160 let resource = builder
1161 .build(&context)
1162 .expect("resource build should succeed");
1163
1164 let runtime = tokio::runtime::Builder::new_current_thread()
1165 .enable_all()
1166 .build()
1167 .expect("failed to create runtime");
1168 let connection: String = runtime
1169 .block_on(resource.into_resource(&context))
1170 .expect("into_resource should return a connection string");
1171
1172 assert!(connection.ends_with("/custom_db"));
1173 }
1174}