forked from vercel/turborepo
-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
chore: Port configuration files to Rust (vercel#3440)
A full Rust port of configuration reading and writing. This PR is probably best reviewed in each individual commit. This PR establishes, but doesn't enforce a pattern of `ConfigLoader`, `Config`, and `ConfigInner`. In the future we can generalize this to reduce boilerplate, but considering we have two configs at the moment that seemed like overkill. - `ConfigInner`: This is the configuration file layout on disk. This is just a data object and shouldn't have any logic - `Config`: Handles the applying of defaults and writing config updates to disk. Internally it holds onto a copy of the config that comes from the file exclusively to avoid accidentally writing a value that was provided via command line or environment variable to the configuration file. - `ConfigLoader`: Handles the loading of the `Config` structure this is where the command line overrides are applied and can be used to mock out the environment to allow for testing without polluting the test proc's environment. Notes: - We need to use a handrolled `MappedEnvironment` to provide custom mappings from environment variable names to config file keys. e.g. `TURBO_TEAM` gets mapped to `teamslug` in `config.json`
- Loading branch information
1 parent
941da9a
commit 01e143d
Showing
5 changed files
with
465 additions
and
61 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,95 @@ | ||
use std::collections::HashMap; | ||
|
||
use config::Environment; | ||
|
||
#[derive(Debug, Clone, Default)] | ||
pub struct MappedEnvironment { | ||
inner: Environment, | ||
replacements: HashMap<String, String>, | ||
} | ||
|
||
impl MappedEnvironment { | ||
#[allow(dead_code)] | ||
pub fn with_prefix(s: &str) -> Self { | ||
Self { | ||
inner: Environment::with_prefix(s), | ||
..Default::default() | ||
} | ||
} | ||
|
||
#[allow(dead_code)] | ||
pub fn source(mut self, source: Option<HashMap<String, String>>) -> Self { | ||
self.inner = self.inner.source(source); | ||
self | ||
} | ||
|
||
/// Adds a replacement rule that will map environment variable names to new | ||
/// ones | ||
/// | ||
/// Useful when environment variable names don't match up with config file | ||
/// names Replacement happens after config::Environment normalization | ||
#[allow(dead_code)] | ||
pub fn replace<S: Into<String>>(mut self, variable_name: S, replacement: S) -> Self { | ||
self.replacements | ||
.insert(variable_name.into(), replacement.into()); | ||
self | ||
} | ||
} | ||
|
||
impl config::Source for MappedEnvironment { | ||
fn clone_into_box(&self) -> Box<dyn config::Source + Send + Sync> { | ||
Box::new(Self { | ||
inner: self.inner.clone(), | ||
replacements: self.replacements.clone(), | ||
}) | ||
} | ||
|
||
fn collect( | ||
&self, | ||
) -> std::result::Result<config::Map<String, config::Value>, config::ConfigError> { | ||
self.inner.collect().map(|config| { | ||
config | ||
.into_iter() | ||
.map(|(key, value)| { | ||
let key = self.replacements.get(&key).cloned().unwrap_or(key); | ||
(key, value) | ||
}) | ||
.collect() | ||
}) | ||
} | ||
} | ||
|
||
#[cfg(test)] | ||
mod test { | ||
use config::Config; | ||
use serde::Deserialize; | ||
|
||
use super::*; | ||
|
||
#[test] | ||
fn test_replacement() { | ||
#[derive(Debug, Clone, Deserialize)] | ||
struct TestConfig { | ||
bar: u32, | ||
baz: String, | ||
} | ||
|
||
let mapped_env = MappedEnvironment::with_prefix("TURBO") | ||
.replace("foo", "bar") | ||
.source({ | ||
let mut map = HashMap::new(); | ||
map.insert("TURBO_FOO".into(), "42".into()); | ||
map.insert("TURBO_BAZ".into(), "sweet".into()); | ||
Some(map) | ||
}); | ||
|
||
let config: TestConfig = Config::builder() | ||
.add_source(mapped_env) | ||
.build() | ||
.unwrap() | ||
.try_deserialize() | ||
.unwrap(); | ||
assert_eq!(config.bar, 42); | ||
assert_eq!(config.baz, "sweet"); | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,43 @@ | ||
mod env; | ||
mod repo; | ||
mod user; | ||
|
||
use std::path::{Path, PathBuf}; | ||
|
||
use anyhow::{Context, Result}; | ||
#[cfg(not(windows))] | ||
use dirs_next::config_dir; | ||
// Go's xdg implementation uses FOLDERID_LocalAppData for config home | ||
// https://github.com/adrg/xdg/blob/master/paths_windows.go#L28 | ||
// Rust xdg implementations uses FOLDERID_RoamingAppData for config home | ||
// We use cache_dir so we can find the config dir that the Go code uses | ||
#[cfg(windows)] | ||
use dirs_next::data_local_dir as config_dir; | ||
pub use env::MappedEnvironment; | ||
pub use repo::{RepoConfig, RepoConfigLoader}; | ||
use serde::Serialize; | ||
pub use user::{UserConfig, UserConfigLoader}; | ||
|
||
pub fn default_user_config_path() -> Result<PathBuf> { | ||
config_dir() | ||
.map(|p| p.join("turborepo").join("config.json")) | ||
.context("default config path not found") | ||
} | ||
|
||
#[allow(dead_code)] | ||
pub fn data_dir() -> Option<PathBuf> { | ||
dirs_next::data_dir().map(|p| p.join("turborepo")) | ||
} | ||
|
||
fn write_to_disk<T>(path: &Path, config: &T) -> Result<()> | ||
where | ||
T: Serialize, | ||
{ | ||
if let Some(parent_dir) = path.parent() { | ||
std::fs::create_dir_all(parent_dir)?; | ||
} | ||
let config_file = std::fs::File::create(path)?; | ||
serde_json::to_writer_pretty(&config_file, &config)?; | ||
config_file.sync_all()?; | ||
Ok(()) | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,242 @@ | ||
use std::{collections::HashMap, path::PathBuf}; | ||
|
||
use anyhow::Result; | ||
use config::Config; | ||
use serde::{Deserialize, Serialize}; | ||
|
||
use super::{write_to_disk, MappedEnvironment}; | ||
|
||
const DEFAULT_API_URL: &str = "https://vercel.com/api"; | ||
const DEFAULT_LOGIN_URL: &str = "https://vercel.com"; | ||
|
||
#[derive(Debug, Clone, PartialEq, Eq)] | ||
pub struct RepoConfig { | ||
disk_config: RepoConfigValue, | ||
config: RepoConfigValue, | ||
path: PathBuf, | ||
} | ||
|
||
#[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Eq, Default)] | ||
struct RepoConfigValue { | ||
apiurl: Option<String>, | ||
loginurl: Option<String>, | ||
teamslug: Option<String>, | ||
teamid: Option<String>, | ||
} | ||
|
||
#[derive(Debug, Clone)] | ||
pub struct RepoConfigLoader { | ||
path: PathBuf, | ||
api: Option<String>, | ||
login: Option<String>, | ||
teamslug: Option<String>, | ||
environment: Option<HashMap<String, String>>, | ||
} | ||
|
||
impl RepoConfig { | ||
#[allow(dead_code)] | ||
pub fn api_url(&self) -> &str { | ||
self.config.apiurl.as_deref().unwrap_or(DEFAULT_API_URL) | ||
} | ||
|
||
#[allow(dead_code)] | ||
pub fn login_url(&self) -> &str { | ||
self.config.loginurl.as_deref().unwrap_or(DEFAULT_LOGIN_URL) | ||
} | ||
|
||
#[allow(dead_code)] | ||
pub fn team_slug(&self) -> Option<&str> { | ||
self.config.teamslug.as_deref() | ||
} | ||
|
||
#[allow(dead_code)] | ||
pub fn team_id(&self) -> Option<&str> { | ||
self.config.teamid.as_deref() | ||
} | ||
|
||
/// Sets the team id and clears the team slug, since it may have been from | ||
/// an old team | ||
#[allow(dead_code)] | ||
pub fn set_team_id(&mut self, team_id: Option<String>) -> Result<()> { | ||
self.disk_config.teamslug = None; | ||
self.config.teamslug = None; | ||
self.disk_config.teamid = team_id.clone(); | ||
self.config.teamid = team_id; | ||
self.write_to_disk() | ||
} | ||
|
||
fn write_to_disk(&self) -> Result<()> { | ||
write_to_disk(&self.path, &self.disk_config) | ||
} | ||
} | ||
|
||
impl RepoConfigLoader { | ||
#[allow(dead_code)] | ||
pub fn new(path: PathBuf) -> Self { | ||
Self { | ||
path, | ||
api: None, | ||
login: None, | ||
teamslug: None, | ||
environment: None, | ||
} | ||
} | ||
|
||
#[allow(dead_code)] | ||
pub fn with_api(mut self, api: Option<String>) -> Self { | ||
self.api = api; | ||
self | ||
} | ||
|
||
#[allow(dead_code)] | ||
pub fn with_login(mut self, login: Option<String>) -> Self { | ||
self.login = login; | ||
self | ||
} | ||
|
||
#[allow(dead_code)] | ||
pub fn with_team_slug(mut self, team_slug: Option<String>) -> Self { | ||
self.teamslug = team_slug; | ||
self | ||
} | ||
|
||
#[allow(dead_code)] | ||
pub fn with_environment(mut self, environment: Option<HashMap<String, String>>) -> Self { | ||
self.environment = environment; | ||
self | ||
} | ||
|
||
#[allow(dead_code)] | ||
pub fn load(self) -> Result<RepoConfig> { | ||
let Self { | ||
path, | ||
api, | ||
login, | ||
teamslug, | ||
environment, | ||
} = self; | ||
let raw_disk_config = Config::builder() | ||
.add_source( | ||
config::File::with_name(path.to_string_lossy().as_ref()) | ||
.format(config::FileFormat::Json) | ||
.required(false), | ||
) | ||
.build()?; | ||
|
||
let has_teamslug_override = teamslug.is_some(); | ||
|
||
let mut config: RepoConfigValue = Config::builder() | ||
.add_source(raw_disk_config.clone()) | ||
.add_source( | ||
MappedEnvironment::with_prefix("turbo") | ||
.source(environment) | ||
.replace("api", "apiurl") | ||
.replace("login", "loginurl") | ||
.replace("team", "teamslug"), | ||
) | ||
.set_override_option("apiurl", api)? | ||
.set_override_option("loginurl", login)? | ||
.set_override_option("teamslug", teamslug)? | ||
// set teamid to none if teamslug present | ||
.build()? | ||
.try_deserialize()?; | ||
|
||
let disk_config: RepoConfigValue = raw_disk_config.try_deserialize()?; | ||
|
||
// If teamid was passed via command line flag we ignore team slug as it | ||
// might not match. | ||
if has_teamslug_override { | ||
config.teamid = None; | ||
} | ||
|
||
Ok(RepoConfig { | ||
disk_config, | ||
config, | ||
path, | ||
}) | ||
} | ||
} | ||
|
||
#[cfg(test)] | ||
mod test { | ||
use std::io::Write; | ||
|
||
use tempfile::NamedTempFile; | ||
|
||
use super::*; | ||
|
||
#[test] | ||
fn test_repo_config_with_team_and_api_flags() -> Result<()> { | ||
let mut config_file = NamedTempFile::new()?; | ||
writeln!(&mut config_file, "{{\"teamId\": \"123\"}}")?; | ||
|
||
let config = RepoConfigLoader::new(config_file.path().to_path_buf()) | ||
.with_team_slug(Some("my-team-slug".into())) | ||
.with_api(Some("http://my-login-url".into())) | ||
.load()?; | ||
|
||
assert_eq!(config.team_id(), None); | ||
assert_eq!(config.team_slug(), Some("my-team-slug")); | ||
assert_eq!(config.api_url(), "http://my-login-url"); | ||
|
||
Ok(()) | ||
} | ||
|
||
#[test] | ||
fn test_team_override_clears_id() -> Result<()> { | ||
let mut config_file = NamedTempFile::new()?; | ||
writeln!(&mut config_file, "{{\"teamId\": \"123\"}}")?; | ||
let loader = RepoConfigLoader::new(config_file.path().to_path_buf()) | ||
.with_team_slug(Some("foo".into())); | ||
|
||
let config = loader.load()?; | ||
assert_eq!(config.team_slug(), Some("foo")); | ||
assert_eq!(config.team_id(), None); | ||
|
||
Ok(()) | ||
} | ||
|
||
#[test] | ||
fn test_set_team_clears_id() -> Result<()> { | ||
let mut config_file = NamedTempFile::new()?; | ||
// We will never pragmatically write the "teamslug" field as camelCase, | ||
// but viper is case insensitive and we want to keep this functionality. | ||
writeln!(&mut config_file, "{{\"teamSlug\": \"my-team\"}}")?; | ||
let loader = RepoConfigLoader::new(config_file.path().to_path_buf()); | ||
|
||
let mut config = loader.clone().load()?; | ||
config.set_team_id(Some("my-team-id".into()))?; | ||
|
||
let new_config = loader.load()?; | ||
assert_eq!(new_config.team_slug(), None); | ||
assert_eq!(new_config.team_id(), Some("my-team-id")); | ||
|
||
Ok(()) | ||
} | ||
|
||
#[test] | ||
fn test_repo_env_variable() -> Result<()> { | ||
let mut config_file = NamedTempFile::new()?; | ||
writeln!(&mut config_file, "{{\"teamslug\": \"other-team\"}}")?; | ||
let login_url = "http://my-login-url"; | ||
let api_url = "http://my-api"; | ||
let team_id = "123"; | ||
let team_slug = "my-team"; | ||
let config = RepoConfigLoader::new(config_file.path().to_path_buf()) | ||
.with_environment({ | ||
let mut env = HashMap::new(); | ||
env.insert("TURBO_API".into(), api_url.into()); | ||
env.insert("TURBO_LOGIN".into(), login_url.into()); | ||
env.insert("TURBO_TEAM".into(), team_slug.into()); | ||
env.insert("TURBO_TEAMID".into(), team_id.into()); | ||
Some(env) | ||
}) | ||
.load()?; | ||
|
||
assert_eq!(config.login_url(), login_url); | ||
assert_eq!(config.api_url(), api_url); | ||
assert_eq!(config.team_id(), Some(team_id)); | ||
assert_eq!(config.team_slug(), Some(team_slug)); | ||
Ok(()) | ||
} | ||
} |
Oops, something went wrong.