Skip to content

Commit

Permalink
chore: Port configuration files to Rust (vercel#3440)
Browse files Browse the repository at this point in the history
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
chris-olszewski authored Jan 24, 2023
1 parent 941da9a commit 01e143d
Show file tree
Hide file tree
Showing 5 changed files with 465 additions and 61 deletions.
4 changes: 2 additions & 2 deletions crates/turborepo-lib/src/commands/logout.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,12 @@ use anyhow::Result;
use log::error;

use crate::{
config::{default_user_config_path, UserConfig},
config::{default_user_config_path, UserConfigLoader},
ui::{GREY, UI},
};

pub fn logout(ui: UI) -> Result<()> {
let mut config = UserConfig::load(&default_user_config_path()?, None)?;
let mut config = UserConfigLoader::new(default_user_config_path()?).load()?;

if let Err(err) = config.set_token(None) {
error!("could not logout. Something went wrong: {}", err);
Expand Down
95 changes: 95 additions & 0 deletions crates/turborepo-lib/src/config/env.rs
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");
}
}
43 changes: 43 additions & 0 deletions crates/turborepo-lib/src/config/mod.rs
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(())
}
242 changes: 242 additions & 0 deletions crates/turborepo-lib/src/config/repo.rs
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(())
}
}
Loading

0 comments on commit 01e143d

Please sign in to comment.