Skip to main content

ch_rds/
lib.rs

1#![doc = include_str!("../README.md")]
2#![forbid(unsafe_code)]
3#![warn(missing_docs)]
4
5/// Background-job scheduling and execution primitives.
6pub mod cronjob;
7/// Development commands for building and running an application workspace.
8#[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/// Runtime facade used by app entrypoints to load shared resources before
44/// handing control to the app server future.
45#[derive(Debug)]
46pub struct Runtime {
47    runner: LoaderAndRunner,
48    project: Project,
49    environment_load: ch_config::environment::EnvLoad,
50}
51
52impl Runtime {
53    /// Discovers the project and loads its conventional environment files.
54    pub fn bootstrap() -> Result<Self> {
55        Self::bootstrap_with_environment(RuntimeEnvironment::Automatic)
56    }
57
58    /// Bootstraps resources with an explicit local-resource policy.
59    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    /// Returns the discovered application layout.
72    pub fn project(&self) -> &Project {
73        &self.project
74    }
75
76    /// Reports which conventional dotenv files were loaded during bootstrap.
77    pub fn environment_load(&self) -> ch_config::environment::EnvLoad {
78        self.environment_load
79    }
80
81    /// Loads one shared resource through its builder.
82    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/// Settings for the bundled local Docker Compose stack.
93#[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    /// Creates local-stack settings from an explicit project name and env file.
108    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    /// Discovers the conventional local-stack settings from the current directory.
117    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    /// Discovers a project from an ancestor containing `services.env`.
124    ///
125    /// The Docker Compose project name is derived from `app/Cargo.toml`. A
126    /// module at `crates/database/spacetimedb` is detected automatically.
127    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    /// Returns the caller-owned Docker Compose project name.
162    pub fn project_name(&self) -> &str {
163        &self.project_name
164    }
165
166    /// Returns the caller-owned Docker Compose env-file path.
167    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/// Configures how a MariaDB resource should be resolved for the runtime.
210#[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    /// Starts a builder without an implicit connection source.
222    pub fn builder() -> Self {
223        Self::default()
224    }
225
226    /// Overrides the logical database name used within the connection string.
227    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    /// Supplies an explicit connection URI that bypasses the application configuration.
233    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    /// Sets the local MariaDB host port in the resolved connection URI.
239    pub fn port(mut self, port: u16) -> Self {
240        self.port = Some(port);
241        self
242    }
243
244    /// Supplies the application-owned configuration loaded before runtime setup.
245    pub fn config(mut self, config: Arc<AppConfig>) -> Self {
246        self.config = Some(config);
247        self
248    }
249
250    /// Overrides the automatically discovered local Docker settings.
251    pub fn local_stack(mut self, local_stack: LocalStack) -> Self {
252        self.local_stack = Some(local_stack);
253        self
254    }
255
256    /// Adjusts the maximum connection count when constructing SQLx pools.
257    pub fn max_connections(mut self, max: u32) -> Self {
258        self.max_connections = Some(max);
259        self
260    }
261
262    /// Returns the configured database override if present.
263    pub fn database_name_override(&self) -> Option<&str> {
264        self.database_name.as_deref()
265    }
266
267    /// Returns the configured URI override if present.
268    pub fn local_uri_override(&self) -> Option<&str> {
269        self.local_uri.as_deref()
270    }
271
272    /// Returns the configured local port override if present.
273    pub fn port_override(&self) -> Option<u16> {
274        self.port
275    }
276}
277
278/// Configures how a SpacetimeDB endpoint should be resolved for the runtime.
279#[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    /// Starts a builder without an implicit endpoint source.
291    pub fn builder() -> Self {
292        Self::default()
293    }
294
295    /// Supplies an explicit endpoint URI that bypasses application configuration.
296    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    /// Sets the local host port used when the config URL is rewritten locally.
302    pub fn port(mut self, port: u16) -> Self {
303        self.port = Some(port);
304        self
305    }
306
307    /// Supplies the application-owned configuration loaded before runtime setup.
308    pub fn config(mut self, config: Arc<AppConfig>) -> Self {
309        self.config = Some(config);
310        self
311    }
312
313    /// Overrides the automatically discovered local Docker settings.
314    pub fn local_stack(mut self, local_stack: LocalStack) -> Self {
315        self.local_stack = Some(local_stack);
316        self
317    }
318
319    /// Overrides the discovered SpacetimeDB module identity.
320    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    /// Overrides the discovered SpacetimeDB module source directory.
326    pub fn module_path(mut self, path: impl Into<PathBuf>) -> Self {
327        self.module_path = Some(path.into());
328        self
329    }
330}
331
332/// Configures the app web listener address for the runtime.
333#[derive(Debug, Clone)]
334pub struct WebBind {
335    addr: String,
336}
337
338impl WebBind {
339    /// Starts a builder with the fallback web listener address.
340    pub fn builder() -> Self {
341        Self {
342            addr: "0.0.0.0:4000".to_string(),
343        }
344    }
345
346    /// Sets the concrete listener address used by the app runtime.
347    pub fn addr(mut self, addr: impl Into<String>) -> Self {
348        self.addr = addr.into();
349        self
350    }
351}
352
353/// Intermediate configuration derived from [`MariaDB`] used during loading.
354#[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    /// Exposes the resolved connection string, mainly for diagnostics.
364    pub fn connection_string(&self) -> &str {
365        &self.connection_string
366    }
367}
368
369/// Intermediate configuration derived from [`SpacetimeDB`] used during loading.
370#[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    /// Exposes the resolved endpoint URL, mainly for diagnostics.
381    pub fn url(&self) -> &str {
382        &self.url
383    }
384}
385
386/// Resolved SpacetimeDB endpoint plus the private module-owner token when the
387/// shared runtime manages a local development module.
388pub struct SpacetimeDbConnection {
389    endpoint_url: String,
390    auth_token: Option<String>,
391}
392
393impl SpacetimeDbConnection {
394    /// Returns the endpoint URL used by generated SpacetimeDB clients.
395    pub fn endpoint_url(&self) -> &str {
396        self.endpoint_url.as_str()
397    }
398
399    /// Returns the module-owner token required for private host-owned tables
400    /// and reducers. Remote deployments supply this separately.
401    pub fn auth_token(&self) -> Option<&str> {
402        self.auth_token.as_deref()
403    }
404
405    /// Supplies a deployment-owned module-owner token when the endpoint is not
406    /// managed by the local development stack.
407    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/// Intermediate configuration derived from [`WebBind`] used during loading.
427#[derive(Debug, Clone, Serialize)]
428pub struct WebBindResource {
429    addr: String,
430}
431
432impl WebBindResource {
433    /// Exposes the configured listener address, mainly for diagnostics.
434    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
643/// Ensures the local development stack is running when requested.
644async 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
659/// Ensures the local development stack is running with optional compose profiles.
660async 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
832/// Rewrites the authority port while preserving credentials, host, path and suffix.
833fn 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 = &current[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(&current[..authority_start]);
858    rebuilt.push_str(userinfo);
859    rebuilt.push_str(&host_port);
860    rebuilt.push_str(&current[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
889/// Rewrites the database name segment while preserving any URL suffix.
890fn 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}