Skip to main content

spawn_db/
config.rs

1use crate::engine::{postgres_psql::PSQL, DatabaseConfig, Engine, EngineType};
2use crate::pinfile::LockData;
3use crate::variables::Variables;
4use anyhow::{anyhow, Context, Result};
5use opendal::Operator;
6use std::collections::HashMap;
7
8use serde::{Deserialize, Serialize};
9
10static PINFILE_LOCK_NAME: &str = "lock.toml";
11
12// 1. The "Blueprint" struct. Use this for Deserialization.
13#[derive(Clone, Debug, Deserialize, Serialize)]
14pub struct ConfigLoaderSaver {
15    pub spawn_folder: String,
16    pub database: Option<String>,
17    pub environment: Option<String>,
18    pub databases: Option<HashMap<String, DatabaseConfig>>,
19    /// Unique project identifier for telemetry (UUID string)
20    pub project_id: Option<String>,
21    /// Set to false to disable telemetry
22    #[serde(default = "default_telemetry", skip_serializing_if = "Option::is_none")]
23    pub telemetry: Option<bool>,
24}
25
26fn default_telemetry() -> Option<bool> {
27    None
28}
29
30impl ConfigLoaderSaver {
31    // 2. A method to transform the Loader into the actual Config
32    pub fn build(self, base_fs: Operator, spawn_fs: Option<Operator>) -> Config {
33        Config {
34            spawn_folder: self.spawn_folder,
35            database: self.database,
36            environment: self.environment,
37            databases: self.databases.unwrap_or_default(),
38            project_id: self.project_id,
39            telemetry: self.telemetry.unwrap_or(true),
40            base_fs,
41            spawn_fs,
42        }
43    }
44
45    pub async fn load(
46        path: &str,
47        op: &Operator,
48        database: Option<String>,
49    ) -> Result<ConfigLoaderSaver> {
50        let bytes = op
51            .read(path)
52            .await
53            .context(format!("No config found at path '{}'", &path))?
54            .to_bytes();
55        let main_config = String::from_utf8(bytes.to_vec())?;
56        let source = config::File::from_str(&main_config, config::FileFormat::Toml);
57
58        let mut settings = config::Config::builder().add_source(source);
59
60        // Used to override the version in a repo.  For example, if you want to have your own local dev variables for testing reasons, that can be in .gitignore.
61        match op.read(path).await {
62            Ok(data) => {
63                let bytes = String::from_utf8(data.to_bytes().to_vec())?;
64                let override_config = config::File::from_str(&bytes, config::FileFormat::Toml);
65                settings = settings.add_source(override_config);
66            }
67            Err(e) => match e.kind() {
68                // If file not found, no override.  But any other failure is
69                // an error to attend to.
70                opendal::ErrorKind::NotFound => {}
71                _ => return Err(e.into()),
72            },
73        };
74
75        let settings = settings
76            // Add in settings from the environment (with a prefix of APP)
77            // Eg.. `APP_DEBUG=1 ./target/app` would set the `debug` key
78            .add_source(config::Environment::with_prefix("SPAWN"))
79            .set_override_option("database", database)?
80            .set_default("environment", "prod")
81            .context("could not set default environment")?
82            .build()?
83            .try_deserialize()?;
84
85        Ok(settings)
86    }
87
88    pub async fn save(&self, path: &str, op: &Operator) -> Result<()> {
89        let toml_content = toml::to_string(self)?;
90        op.write(path, toml_content).await?;
91        Ok(())
92    }
93}
94
95#[derive(Clone)]
96pub struct FolderPather {
97    pub spawn_folder: String,
98}
99
100impl FolderPather {
101    pub fn spawn_folder_path(&self) -> &str {
102        self.spawn_folder.as_ref()
103    }
104
105    pub fn pinned_folder(&self) -> String {
106        let mut s = self.spawn_folder_path().to_string();
107        s.push_str("/pinned");
108        s
109    }
110
111    pub fn components_folder(&self) -> String {
112        let mut s = self.spawn_folder_path().to_string();
113        s.push_str("/components");
114        s
115    }
116
117    pub fn migrations_folder(&self) -> String {
118        let mut s = self.spawn_folder_path().to_string();
119        s.push_str("/migrations");
120        s
121    }
122
123    pub fn tests_folder(&self) -> String {
124        let mut s = self.spawn_folder_path().to_string();
125        s.push_str("/tests");
126        s
127    }
128
129    pub fn migration_folder(&self, script_path: &str) -> String {
130        let mut s = self.migrations_folder();
131        s.push('/');
132        s.push_str(script_path);
133        s
134    }
135
136    pub fn migration_script_file_path(&self, script_path: &str) -> String {
137        let mut s = self.migration_folder(script_path);
138        s.push_str("/up.sql");
139        s
140    }
141
142    pub fn test_folder(&self, test_path: &str) -> String {
143        let mut s = self.tests_folder();
144        s.push('/');
145        s.push_str(test_path);
146        s
147    }
148
149    pub fn test_file_path(&self, test_path: &str) -> String {
150        let mut s = self.test_folder(test_path);
151        s.push_str("/test.sql");
152        s
153    }
154
155    pub fn migration_lock_file_path(&self, script_path: &str) -> String {
156        let mut s = self.migrations_folder();
157        s.push('/');
158        s.push_str(script_path);
159        s.push('/');
160        s.push_str(PINFILE_LOCK_NAME);
161        s
162    }
163}
164
165#[derive(Debug, Clone)]
166pub struct Config {
167    spawn_folder: String,
168    pub database: Option<String>,
169    pub environment: Option<String>, // Override the environment for the db config
170    pub databases: HashMap<String, DatabaseConfig>,
171    /// Unique project identifier for telemetry (UUID string)
172    pub project_id: Option<String>,
173    /// Whether telemetry is enabled in config
174    pub telemetry: bool,
175
176    // base_fs is the operator we used to load config, and may be the one we use
177    // for all other interactions too.
178    base_fs: Operator,
179    // spawn_fs, when set, is an operator that differs from the one we used to
180    // load the config.  Usually this will happen when our config file points to
181    // another filesystem/location that should be used for spawn.
182    spawn_fs: Option<Operator>,
183}
184
185impl Config {
186    pub fn pather(&self) -> FolderPather {
187        FolderPather {
188            spawn_folder: self.spawn_folder.clone(),
189        }
190    }
191
192    pub async fn new_engine(&self) -> Result<Box<dyn Engine>> {
193        let db_config = self.db_config()?;
194
195        match db_config.engine {
196            EngineType::PostgresPSQL => Ok(PSQL::new(&db_config).await?),
197        }
198    }
199
200    pub fn db_config(&self) -> Result<DatabaseConfig> {
201        let db_name = self
202            .database
203            .as_ref()
204            .ok_or(anyhow!("no database selected"))?;
205        let mut conf = self
206            .databases
207            .get(db_name)
208            .ok_or(anyhow!("no database defined with name '{}'", db_name,))?
209            .clone();
210
211        if let Some(env) = &self.environment {
212            conf.environment = env.clone();
213        }
214
215        Ok(conf)
216    }
217
218    pub async fn load(path: &str, op: &Operator, database: Option<String>) -> Result<Config> {
219        let config_loader = ConfigLoaderSaver::load(path, op, database).await?;
220        Ok(config_loader.build(op.clone(), None))
221    }
222
223    pub fn operator(&self) -> &Operator {
224        if let Some(spawn_fs) = &self.spawn_fs {
225            &spawn_fs
226        } else {
227            &self.base_fs
228        }
229    }
230
231    pub async fn load_lock_file(&self, lock_file_path: &str) -> Result<LockData> {
232        let contents = self.operator().read(lock_file_path).await?.to_bytes();
233        let contents = String::from_utf8(contents.to_vec())?;
234        let lock_data: LockData = toml::from_str(&contents)?;
235
236        Ok(lock_data)
237    }
238
239    /// Load variables from a file path.
240    /// The file type is determined by the file extension.
241    pub async fn load_variables_from_path(&self, path: &str) -> Result<Variables> {
242        let content = self
243            .operator()
244            .read(path)
245            .await
246            .context(format!("Failed to read variables file '{}'", path))?
247            .to_bytes();
248        let content_str =
249            String::from_utf8(content.to_vec()).context("Variables file is not valid UTF-8")?;
250
251        let extension = path.split('.').last().unwrap_or("");
252        Variables::from_str(extension, &content_str)
253            .context(format!("Failed to parse variables file '{}'", path))
254    }
255}