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;
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/// Selects whether the runtime may manage local development services.
30#[derive(Debug, Clone, Copy, PartialEq, Eq)]
31pub enum RuntimeEnvironment {
32    /// Resolve configured resources and manage loopback services when needed.
33    Local,
34    /// Use configured resources without managing local services or modules.
35    Production,
36}
37
38impl RuntimeEnvironment {
39    fn allows_local_resources(self) -> bool {
40        matches!(self, Self::Local)
41    }
42}
43
44/// Failures while discovering a project or loading its resources.
45#[derive(Debug, thiserror::Error)]
46#[non_exhaustive]
47pub enum RuntimeError {
48    /// A filesystem or process-environment operation failed during discovery.
49    #[error("failed to discover the runtime project: {0}")]
50    Io(#[from] std::io::Error),
51    /// Application environment settings could not be loaded.
52    #[error(transparent)]
53    Configuration(#[from] ch_config::config::ConfigError),
54    /// No application root could be found from the provided path.
55    #[error("failed to discover an application root from {0}")]
56    ProjectNotFound(PathBuf),
57    /// A resource could not be resolved or prepared.
58    #[error("failed to load {resource}: {source}")]
59    Resource {
60        /// Resource or discovery operation that failed.
61        resource: &'static str,
62        /// Underlying failure, retained for inspection through `Error::source`.
63        #[source]
64        source: Box<dyn std::error::Error + Send + Sync>,
65    },
66}
67
68/// Loads one resource using the selected runtime policy and project.
69///
70/// Implementations return a concrete output and a `Send` future without boxing.
71pub trait Resource: Send {
72    /// Value returned after the resource has been prepared.
73    type Output: Send;
74
75    /// Resolves and prepares this resource for the runtime.
76    fn load(
77        self,
78        runtime: &Runtime,
79    ) -> impl Future<Output = std::result::Result<Self::Output, RuntimeError>> + Send;
80}
81
82/// Discovers application settings and prepares runtime resources.
83#[derive(Debug)]
84pub struct Runtime {
85    environment: RuntimeEnvironment,
86    project: Project,
87    environment_load: ch_config::environment::EnvLoad,
88}
89
90impl Runtime {
91    /// Discovers the project and loads its conventional environment files.
92    pub fn bootstrap() -> std::result::Result<Self, RuntimeError> {
93        Self::bootstrap_with_environment(RuntimeEnvironment::Local)
94    }
95
96    /// Bootstraps resources with an explicit local-resource policy.
97    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    /// Returns the resource policy selected for this runtime.
110    pub fn environment(&self) -> RuntimeEnvironment {
111        self.environment
112    }
113
114    /// Returns the discovered application layout.
115    pub fn project(&self) -> &Project {
116        &self.project
117    }
118
119    /// Reports which conventional dotenv files were loaded during bootstrap.
120    pub fn environment_load(&self) -> ch_config::environment::EnvLoad {
121        self.environment_load
122    }
123
124    /// Resolves and prepares one resource through its owning implementation.
125    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/// Settings for the bundled local Docker Compose stack.
134#[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    /// Creates local-stack settings from an explicit project name and env file.
149    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    /// Discovers the conventional local-stack settings from the current directory.
158    pub fn discover() -> std::result::Result<Self, RuntimeError> {
159        Self::discover_from(std::env::current_dir()?)
160    }
161
162    /// Discovers a project from an ancestor containing `services.env`.
163    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    /// Returns the caller-owned Docker Compose project name.
171    pub fn project_name(&self) -> &str {
172        &self.project_name
173    }
174
175    /// Returns the caller-owned Docker Compose env-file path.
176    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/// Configures how a MariaDB resource should be resolved for the runtime.
270#[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    /// Starts a builder without an implicit connection source.
282    pub fn builder() -> Self {
283        Self::default()
284    }
285
286    /// Overrides the logical database name used within the connection string.
287    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    /// Supplies an explicit connection URI that bypasses the application configuration.
293    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    /// Sets the local MariaDB host port in the resolved connection URI.
299    pub fn port(mut self, port: u16) -> Self {
300        self.port = Some(port);
301        self
302    }
303
304    /// Supplies the application-owned configuration loaded before runtime setup.
305    pub fn config(mut self, config: Arc<AppConfig>) -> Self {
306        self.config = Some(config);
307        self
308    }
309
310    /// Overrides the automatically discovered local Docker settings.
311    pub fn local_stack(mut self, local_stack: LocalStack) -> Self {
312        self.local_stack = Some(local_stack);
313        self
314    }
315
316    /// Overrides only the Docker Compose project name of the resolved local stack.
317    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/// Configures how a SpacetimeDB endpoint should be resolved for the runtime.
324#[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    /// Starts a builder without an implicit endpoint source.
337    pub fn builder() -> Self {
338        Self::default()
339    }
340
341    /// Supplies an explicit endpoint URI that bypasses application configuration.
342    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    /// Sets the local host port used when the config URL is rewritten locally.
348    pub fn port(mut self, port: u16) -> Self {
349        self.port = Some(port);
350        self
351    }
352
353    /// Supplies the application-owned configuration loaded before runtime setup.
354    pub fn config(mut self, config: Arc<AppConfig>) -> Self {
355        self.config = Some(config);
356        self
357    }
358
359    /// Overrides the automatically discovered local Docker settings.
360    pub fn local_stack(mut self, local_stack: LocalStack) -> Self {
361        self.local_stack = Some(local_stack);
362        self
363    }
364
365    /// Overrides only the Docker Compose project name of the resolved local stack.
366    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    /// Overrides the discovered SpacetimeDB module identity.
372    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    /// Overrides the discovered SpacetimeDB module source directory.
378    pub fn module_path(mut self, path: impl Into<PathBuf>) -> Self {
379        self.module_path = Some(path.into());
380        self
381    }
382}
383
384/// Configures the app web listener address for the runtime.
385#[derive(Debug, Clone)]
386pub struct WebBind {
387    addr: String,
388}
389
390impl WebBind {
391    /// Starts a builder with the fallback web listener address.
392    pub fn builder() -> Self {
393        Self {
394            addr: "0.0.0.0:4000".to_string(),
395        }
396    }
397
398    /// Sets the concrete listener address used by the app runtime.
399    pub fn addr(mut self, addr: impl Into<String>) -> Self {
400        self.addr = addr.into();
401        self
402    }
403}
404
405/// Intermediate configuration derived from [`MariaDB`] used during loading.
406struct MariaDbResource {
407    connection_string: String,
408    local_stack: Option<LocalStack>,
409    bootstrap_local_stack: bool,
410}
411
412/// Intermediate configuration derived from [`SpacetimeDB`] used during loading.
413struct 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
421/// Resolved SpacetimeDB endpoint plus the private module-owner token when the
422/// shared runtime manages a local development module.
423pub struct SpacetimeDbConnection {
424    endpoint_url: String,
425    auth_token: Option<String>,
426}
427
428impl SpacetimeDbConnection {
429    /// Returns the endpoint URL used by generated SpacetimeDB clients.
430    pub fn endpoint_url(&self) -> &str {
431        self.endpoint_url.as_str()
432    }
433
434    /// Returns the module-owner token required for private host-owned tables
435    /// and reducers. Remote deployments supply this separately.
436    pub fn auth_token(&self) -> Option<&str> {
437        self.auth_token.as_deref()
438    }
439
440    /// Supplies a deployment-owned module-owner token when the endpoint is not
441    /// managed by the local development stack.
442    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
898/// Rewrites the authority port while preserving credentials, host, path and suffix.
899fn 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 = &current[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(&current[..authority_start]);
924    rebuilt.push_str(userinfo);
925    rebuilt.push_str(&host_port);
926    rebuilt.push_str(&current[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
955/// Rewrites the database name segment while preserving any URL suffix.
956fn 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}