surrealdb_migrate_config/
lib.rs

1//! Configuration mechanism for the [`surrealdb-migrate`] crate.
2//!
3//! To be able to use the `MigrationRunner` of the [`surrealdb-migrate`] crate
4//! some configuration settings are needed for the runner itself and the
5//! DB-connection to the database the migrations shall be applied to. These
6//! configuration settings can be provided by an application through its own
7//! configuration mechanism or this crate is used to load the settings from
8//! a configuration file and/or some environment variables.
9//!
10//! ## Concept
11//!
12//! All settings have default values. The user needs to provide only those
13//! settings which shall get a value different from the default value and omit
14//! those settings where the default value is suitable for the application.
15//!
16//! First the settings are loaded from a configuration file with the name
17//! `surrealdb-migrate.toml`. By default, this configuration file must be
18//! located in the current working directory. To load the configuration file
19//! from a different directory, the environment variable
20//! `SURREALDB_MIGRATE_CONFIG_DIR` can be set to point to the directory where
21//! the configuration file is located. For example:
22//!
23//! ```dotenv
24//! SURREALDB_MIGRATE_CONFIG_DIR="my_application/config"
25//! ```
26//!
27//! The configuration does not need to define all available settings. If a
28//! setting is not present in the configuration file the default value is used.
29//! If the configuration file is not present or can not be found, the default
30//! values for all settings are used.
31//!
32//! All available settings with their default values are listed in the example
33//! configuration file [surrealdb-migrate.default.toml].
34//!
35//! In a second step the settings are loaded from the environment. Each
36//! specified environment variable that defines a settings overwrites this
37//! setting in the resulting configuration.
38//!
39//! If an environment variable is not set, the value from the configuration file
40//! is used. If a setting is neither specified in the configuration file nor set
41//! via an environment variable, the default value is used.
42//!
43//! All available environment variables that define configuration settings are
44//! listed in the example dotenv file [default.env].
45//!
46//! ## Usage
47//!
48//! To load the settings via the mechanism described in the previous chapter,
49//! the [`load()`](Settings::load) function of the [`Settings`] struct is used.
50//!
51//! ```no_run
52//! use database_migration::error::Error;
53//! use surrealdb_migrate_config::Settings;
54//!
55//! fn main() -> Result<(), Error> {
56//!     let _settings = Settings::load()?;
57//!
58//!     Ok(())
59//! }
60//! ```
61//!
62//! The [`load()`](Settings::load) function searches for the configuration file
63//! in the directory specified by the environment variable
64//! `SURREALDB_MIGRATE_CONFIG_DIR` if set or in the current working directory
65//! otherwise.
66//!
67//! The directory where the configuration file is located can also be specified
68//! when using the [`load_from_dir()`](Settings::load_from_dir()) function
69//! instead.
70//!
71//! ```no_run
72//! use std::path::Path;
73//! use database_migration::error::Error;
74//! use surrealdb_migrate_config::Settings;
75//!
76//! fn main() -> Result<(), Error> {
77//!     let _settings = Settings::load_from_dir(Path::new("my_application/config"))?;
78//!
79//!     Ok(())
80//! }
81//! ```
82//!
83//! The loaded settings can then be used to get a [`DbClientConfig`] and a
84//! [`RunnerConfig`].
85//!
86//! ```no_run
87//! # use database_migration::error::Error;
88//! # use surrealdb_migrate_config::Settings;
89//!
90//! fn main() -> Result<(), Error> {
91//!     let settings = Settings::load()?;
92//!
93//!     let _db_config = settings.db_client_config();
94//!     let _runner_config = settings.runner_config();
95//!
96//!     Ok(())
97//! }
98//! ```
99//!
100//! [default.env]: https://github.com/innoave/surrealdb-migrate/blob/main/surrealdb-migrate-config/resources/default.env
101//! [surrealdb-migrate.default.toml]: https://github.com/innoave/surrealdb-migrate/blob/main/surrealdb-migrate-config/resources/surrealdb-migrate.default.toml
102//! [`surrealdb-migrate`]: https://docs.rs/surrealdb-migrate/0.1.0
103
104mod env;
105
106use config::{Config, File, FileFormat};
107use database_migration::config::{DbAuthLevel, DbClientConfig, RunnerConfig};
108use database_migration::error::Error;
109use serde::de::{Unexpected, Visitor};
110use serde::{Deserialize, Deserializer};
111use std::collections::HashMap;
112use std::fmt::{Formatter, Write as _};
113use std::path::Path;
114
115pub const CONFIG_DIR_ENVIRONMENT_VAR: &str = "SURREALDB_MIGRATE_CONFIG_DIR";
116pub const CONFIG_FILENAME: &str = "surrealdb-migrate";
117
118const DEFAULT_SETTINGS: &str = include_str!("../resources/surrealdb-migrate.default.toml");
119
120#[derive(Deserialize, Debug, Clone, PartialEq, Eq)]
121pub struct Settings {
122    pub migration: MigrationSettings,
123    pub files: FilesSettings,
124    pub database: DatabaseSettings,
125}
126
127#[derive(Deserialize, Debug, Clone, PartialEq, Eq)]
128#[serde(rename_all = "kebab-case")]
129pub struct MigrationSettings {
130    pub ignore_checksum: bool,
131    pub ignore_order: bool,
132}
133
134#[derive(Deserialize, Debug, Clone, PartialEq, Eq)]
135#[serde(rename_all = "kebab-case")]
136pub struct FilesSettings {
137    pub migrations_folder: String,
138    pub script_extension: String,
139    pub up_script_extension: String,
140    pub down_script_extension: String,
141    pub exclude: String,
142}
143
144#[derive(Deserialize, Debug, Clone, PartialEq, Eq)]
145#[serde(rename_all = "kebab-case")]
146pub struct DatabaseSettings {
147    pub migrations_table: String,
148    pub address: String,
149    pub username: String,
150    pub password: String,
151    #[serde(deserialize_with = "db_auth_level_from_string")]
152    pub auth_level: DbAuthLevel,
153    pub namespace: String,
154    pub database: String,
155    pub capacity: usize,
156}
157
158fn read_environment() -> String {
159    const MIGRATION_PREFIX: &str = "SURMIG_MIGRATION_";
160    const FILES_PREFIX: &str = "SURMIG_FILES_";
161    const DATABASE_PREFIX: &str = "SURMIG_DATABASE_";
162
163    let mut migration = HashMap::new();
164    let mut files = HashMap::new();
165    let mut database = HashMap::new();
166
167    for (key, val) in env::vars() {
168        if key.starts_with(MIGRATION_PREFIX) {
169            let offset = MIGRATION_PREFIX.len();
170            migration.insert(to_kebab_case(&key, offset), val);
171        } else if key.starts_with(DATABASE_PREFIX) {
172            let offset = DATABASE_PREFIX.len();
173            database.insert(to_kebab_case(&key, offset), val);
174        } else if key.starts_with(FILES_PREFIX) {
175            let offset = FILES_PREFIX.len();
176            files.insert(to_kebab_case(&key, offset), val);
177        }
178    }
179
180    let mut environment_toml = String::new();
181    if !migration.is_empty() {
182        environment_toml.push_str("[migration]\n");
183        for (key, val) in migration {
184            let _ = writeln!(environment_toml, "{key} = \"{val}\"");
185        }
186    }
187    if !files.is_empty() {
188        environment_toml.push_str("[files]\n");
189        for (key, val) in files {
190            let _ = writeln!(environment_toml, "{key} = \"{val}\"");
191        }
192    }
193    if !database.is_empty() {
194        environment_toml.push_str("[database]\n");
195        for (key, val) in database {
196            let _ = writeln!(environment_toml, "{key} = \"{val}\"");
197        }
198    }
199    environment_toml
200}
201
202fn to_kebab_case(s: &str, offset: usize) -> String {
203    s.chars()
204        .skip(offset)
205        .map(|c| {
206            if c == '_' {
207                '-'
208            } else {
209                c.to_ascii_lowercase()
210            }
211        })
212        .collect()
213}
214
215fn db_auth_level_from_string<'de, D>(deserializer: D) -> Result<DbAuthLevel, D::Error>
216where
217    D: Deserializer<'de>,
218{
219    deserializer.deserialize_str(DbAuthLevelVisitor)
220}
221
222struct DbAuthLevelVisitor;
223
224impl Visitor<'_> for DbAuthLevelVisitor {
225    type Value = DbAuthLevel;
226
227    fn expecting(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result {
228        formatter
229            .write_str("expecting a string containing one of 'Root', 'Namespace' or 'Database'")
230    }
231
232    fn visit_str<E>(self, v: &str) -> Result<Self::Value, E>
233    where
234        E: serde::de::Error,
235    {
236        match &v.to_ascii_lowercase()[..] {
237            "root" => Ok(DbAuthLevel::Root),
238            "namespace" => Ok(DbAuthLevel::Namespace),
239            "database" => Ok(DbAuthLevel::Database),
240            _ => Err(serde::de::Error::invalid_value(
241                Unexpected::Str(v),
242                &"Root, Namespace or Database",
243            )),
244        }
245    }
246}
247
248impl Settings {
249    pub fn load() -> Result<Self, Error> {
250        let config_dir = env::var(CONFIG_DIR_ENVIRONMENT_VAR).unwrap_or_else(|_| "./".into());
251        Self::load_from_dir(Path::new(&config_dir))
252    }
253
254    pub fn load_from_dir(path: &Path) -> Result<Self, Error> {
255        let environment = read_environment();
256        let config_file = path.join(CONFIG_FILENAME);
257        let config = Config::builder()
258            .add_source(File::from_str(DEFAULT_SETTINGS, FileFormat::Toml))
259            .add_source(File::from(config_file).required(false))
260            .add_source(File::from_str(&environment, FileFormat::Toml))
261            .build()
262            .map_err(|err| Error::Configuration(err.to_string()))?;
263
264        config
265            .try_deserialize()
266            .map_err(|err| Error::Configuration(err.to_string()))
267    }
268
269    pub fn runner_config(&self) -> RunnerConfig<'_> {
270        RunnerConfig {
271            migrations_folder: Path::new(&self.files.migrations_folder).into(),
272            excluded_files: self.files.exclude.parse().unwrap_or_else(|err| panic!("failed to create default `RunnerConfig`: {err} -- THIS IS AN IMPLEMENTATION ERROR! Please file a bug.")),
273            migrations_table: (&self.database.migrations_table).into(),
274            ignore_checksum: self.migration.ignore_checksum,
275            ignore_order: self.migration.ignore_order,
276        }
277    }
278
279    pub fn db_client_config(&self) -> DbClientConfig<'_> {
280        DbClientConfig {
281            address: (&self.database.address).into(),
282            namespace: (&self.database.namespace).into(),
283            database: (&self.database.database).into(),
284            auth_level: self.database.auth_level,
285            username: (&self.database.username).into(),
286            password: (&self.database.password).into(),
287            capacity: self.database.capacity,
288        }
289    }
290}
291
292#[cfg(test)]
293mod tests;