Skip to content
New issue

Have a question about this project? # for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “#”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? # to your account

config: Read environment variables from [env] section in config.toml #12

Merged
merged 1 commit into from
Mar 4, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
291 changes: 287 additions & 4 deletions src/config.rs
Original file line number Diff line number Diff line change
@@ -1,10 +1,47 @@
use crate::error::Error;
use serde::Deserialize;
use std::path::Path;
use std::{
borrow::Cow,
collections::BTreeMap,
env::VarError,
fmt::{self, Display, Formatter},
io,
ops::Deref,
path::{Path, PathBuf},
};

#[derive(Debug, Deserialize)]
/// Specific errors that can be raised during environment parsing
#[derive(Debug)]
pub enum EnvError {
Io(PathBuf, io::Error),
Var(VarError),
}

impl From<VarError> for EnvError {
fn from(var: VarError) -> Self {
Self::Var(var)
}
}

impl Display for EnvError {
fn fmt(&self, f: &mut Formatter) -> fmt::Result {
match self {
Self::Io(path, error) => write!(f, "{}: {}", path.display(), error),
Self::Var(error) => error.fmt(f),
}
}
}

impl std::error::Error for EnvError {}

type Result<T, E = EnvError> = std::result::Result<T, E>;

#[derive(Clone, Debug, Deserialize, PartialEq)]
#[serde(rename_all = "kebab-case")]
pub struct Config {
pub build: Option<Build>,
/// <https://doc.rust-lang.org/cargo/reference/config.html#env>
pub env: Option<BTreeMap<String, EnvOption>>,
}

impl Config {
Expand All @@ -14,8 +51,254 @@ impl Config {
}
}

#[derive(Debug, Deserialize)]
#[derive(Clone, Debug)]
pub struct LocalizedConfig {
MarijnS95 marked this conversation as resolved.
Show resolved Hide resolved
pub config: Config,
/// The directory containing `./.cargo/config.toml`
pub workspace: PathBuf,
}

impl Deref for LocalizedConfig {
type Target = Config;

fn deref(&self) -> &Self::Target {
&self.config
}
}

impl LocalizedConfig {
pub fn new(workspace: PathBuf) -> Result<Self, Error> {
Ok(Self {
config: Config::parse_from_toml(&workspace.join(".cargo/config.toml"))?,
workspace,
})
}

/// Search for `.cargo/config.toml` in any parent of the workspace root path.
/// Returns the directory which contains this path, not the path to the config file.
fn find_cargo_config_parent(workspace: impl AsRef<Path>) -> Result<Option<PathBuf>, Error> {
let workspace = workspace.as_ref();
let workspace =
dunce::canonicalize(workspace).map_err(|e| Error::Io(workspace.to_owned(), e))?;
Ok(workspace
.ancestors()
.find(|dir| dir.join(".cargo/config.toml").is_file())
.map(|p| p.to_path_buf()))
}

/// Search for and open `.cargo/config.toml` in any parent of the workspace root path.
pub fn find_cargo_config_for_workspace(
workspace: impl AsRef<Path>,
) -> Result<Option<Self>, Error> {
let config = Self::find_cargo_config_parent(workspace)?;
config.map(LocalizedConfig::new).transpose()
}
MarijnS95 marked this conversation as resolved.
Show resolved Hide resolved

/// Read an environment variable from the `[env]` section in this `.cargo/config.toml`.
///
/// It is interpreted as path and canonicalized relative to [`Self::workspace`] if
/// [`EnvOption::Value::relative`] is set.
///
/// Process environment variables (from [`std::env::var()`]) have [precedence]
/// unless [`EnvOption::Value::force`] is set. This value is also returned if
/// the given key was not set under `[env]`.
///
/// [precedence]: https://doc.rust-lang.org/cargo/reference/config.html#env
pub fn resolve_env(&self, key: &str) -> Result<Cow<'_, str>> {
let config_var = self.config.env.as_ref().and_then(|env| env.get(key));

// Environment variables always have precedence unless
// the extended format is used to set `force = true`:
if let Some(env_option @ EnvOption::Value { force: true, .. }) = config_var {
// Errors iresolving (canonicalizing, really) the config variable take precedence, too:
return env_option.resolve_value(&self.workspace);
}

let process_var = std::env::var(key);
if process_var != Err(VarError::NotPresent) {
// Errors from env::var() also have precedence here:
return Ok(process_var?.into());
}

// Finally, the value in `[env]` (if it exists) is taken into account
config_var
.ok_or(VarError::NotPresent)?
.resolve_value(&self.workspace)
}
}

#[derive(Clone, Debug, Deserialize, PartialEq)]
#[serde(rename_all = "kebab-case")]
pub struct Build {
#[serde(rename = "target-dir")]
pub target_dir: Option<String>,
}

/// Serializable environment variable in cargo config, configurable as per
/// <https://doc.rust-lang.org/cargo/reference/config.html#env>,
#[derive(Clone, Debug, Deserialize, PartialEq)]
#[serde(untagged, rename_all = "kebab-case")]
pub enum EnvOption {
String(String),
Value {
value: String,
#[serde(default)]
force: bool,
#[serde(default)]
relative: bool,
},
}

impl EnvOption {
/// Retrieve the value and canonicalize it relative to `config_parent` when [`EnvOption::Value::relative`] is set.
///
/// `config_parent` is the directory containing `.cargo/config.toml` where this was parsed from.
pub fn resolve_value(&self, config_parent: impl AsRef<Path>) -> Result<Cow<'_, str>> {
Ok(match self {
Self::Value {
value,
relative: true,
force: _,
} => {
let value = config_parent.as_ref().join(value);
let value = dunce::canonicalize(&value).map_err(|e| EnvError::Io(value, e))?;
value
.into_os_string()
.into_string()
.map_err(VarError::NotUnicode)?
.into()
}
Self::String(value) | Self::Value { value, .. } => value.into(),
})
}
}

#[test]
fn test_env_parsing() {
let toml = r#"
[env]
# Set ENV_VAR_NAME=value for any process run by Cargo
ENV_VAR_NAME = "value"
# Set even if already present in environment
ENV_VAR_NAME_2 = { value = "value", force = true }
# Value is relative to .cargo directory containing `config.toml`, make absolute
ENV_VAR_NAME_3 = { value = "relative/path", relative = true }"#;

let mut env = BTreeMap::new();
env.insert(
"ENV_VAR_NAME".to_string(),
EnvOption::String("value".into()),
);
env.insert(
"ENV_VAR_NAME_2".to_string(),
EnvOption::Value {
value: "value".into(),
force: true,
relative: false,
},
);
env.insert(
"ENV_VAR_NAME_3".to_string(),
EnvOption::Value {
value: "relative/path".into(),
force: false,
relative: true,
},
);

assert_eq!(
toml::from_str::<Config>(toml),
Ok(Config {
build: None,
env: Some(env)
})
);
}

#[test]
fn test_env_precedence_rules() {
let toml = r#"
[env]
CARGO_SUBCOMMAND_TEST_ENV_NOT_FORCED = "not forced"
CARGO_SUBCOMMAND_TEST_ENV_FORCED = { value = "forced", force = true }"#;

let config = LocalizedConfig {
config: toml::from_str::<Config>(toml).unwrap(),
workspace: PathBuf::new(),
};

assert!(matches!(
config.resolve_env("CARGO_SUBCOMMAND_TEST_ENV_NOT_SET"),
Err(EnvError::Var(VarError::NotPresent))
));
assert_eq!(
config
.resolve_env("CARGO_SUBCOMMAND_TEST_ENV_NOT_FORCED")
.unwrap(),
Cow::from("not forced")
);
assert_eq!(
config
.resolve_env("CARGO_SUBCOMMAND_TEST_ENV_FORCED")
.unwrap(),
Cow::from("forced")
);

std::env::set_var("CARGO_SUBCOMMAND_TEST_ENV_NOT_SET", "set in env");
std::env::set_var(
"CARGO_SUBCOMMAND_TEST_ENV_NOT_FORCED",
"not forced overridden",
);
std::env::set_var("CARGO_SUBCOMMAND_TEST_ENV_FORCED", "forced overridden");

assert_eq!(
config
.resolve_env("CARGO_SUBCOMMAND_TEST_ENV_NOT_SET")
.unwrap(),
// Even if the value isn't present in [env] it should still resolve to the
// value in the process environment
Cow::from("set in env")
);
assert_eq!(
config
.resolve_env("CARGO_SUBCOMMAND_TEST_ENV_NOT_FORCED")
.unwrap(),
// Value changed now that it is set in the environment
Cow::from("not forced overridden")
);
assert_eq!(
config
.resolve_env("CARGO_SUBCOMMAND_TEST_ENV_FORCED")
.unwrap(),
// Value stays at how it was configured in [env] with force=true, despite
// also being set in the process environment
Cow::from("forced")
);
}

#[test]
fn test_env_canonicalization() {
use std::ffi::OsStr;

let toml = r#"
[env]
CARGO_SUBCOMMAND_TEST_ENV_SRC_DIR = { value = "src", force = true, relative = true }
CARGO_SUBCOMMAND_TEST_ENV_INEXISTENT_DIR = { value = "blahblahthisfolderdoesntexist", force = true, relative = true }
"#;

let config = LocalizedConfig {
config: toml::from_str::<Config>(toml).unwrap(),
workspace: PathBuf::new(),
};

let path = config
.resolve_env("CARGO_SUBCOMMAND_TEST_ENV_SRC_DIR")
.expect("Canonicalization for a known-to-exist ./src folder should not fail");
let path = Path::new(path.as_ref());
assert!(path.is_absolute());
assert!(path.is_dir());
assert_eq!(path.file_name(), Some(OsStr::new("src")));

assert!(config
.resolve_env("CARGO_SUBCOMMAND_TEST_ENV_INEXISTENT_DIR")
.is_err());
}
1 change: 1 addition & 0 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ mod subcommand;
mod utils;

pub use artifact::{Artifact, CrateType};
pub use config::{EnvError, EnvOption, LocalizedConfig};
pub use error::Error;
pub use profile::Profile;
pub use subcommand::Subcommand;
12 changes: 10 additions & 2 deletions src/subcommand.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
use crate::artifact::Artifact;
use crate::error::Error;
use crate::profile::Profile;
use crate::utils;
use crate::{utils, LocalizedConfig};
use std::io::BufRead;
use std::path::{Path, PathBuf};
use std::process::Command;
Expand All @@ -18,6 +18,7 @@ pub struct Subcommand {
profile: Profile,
artifacts: Vec<Artifact>,
quiet: bool,
config: Option<LocalizedConfig>,
}

impl Subcommand {
Expand Down Expand Up @@ -103,13 +104,15 @@ impl Subcommand {
}
});

let config = LocalizedConfig::find_cargo_config_for_workspace(&root_dir)?;

let target_dir = target_dir.unwrap_or_else(|| {
utils::find_workspace(&manifest, &package)
.unwrap()
.unwrap_or_else(|| manifest.clone())
.parent()
.unwrap()
.join(utils::get_target_dir_name(root_dir).unwrap())
.join(utils::get_target_dir_name(config.as_deref()).unwrap())
});
if examples {
for file in utils::list_rust_files(&root_dir.join("examples"))? {
Expand Down Expand Up @@ -145,6 +148,7 @@ impl Subcommand {
profile,
artifacts,
quiet,
config,
})
}

Expand Down Expand Up @@ -187,6 +191,10 @@ impl Subcommand {
pub fn quiet(&self) -> bool {
self.quiet
}

pub fn config(&self) -> Option<&LocalizedConfig> {
self.config.as_ref()
}
}

#[cfg(test)]
Expand Down
17 changes: 5 additions & 12 deletions src/utils.rs
Original file line number Diff line number Diff line change
Expand Up @@ -78,18 +78,11 @@ pub fn find_workspace(manifest: &Path, name: &str) -> Result<Option<PathBuf>, Er
Ok(None)
}

/// Search for .cargo/config.toml file relative to the workspace root path.
pub fn find_cargo_config(path: &Path) -> Result<Option<PathBuf>, Error> {
let path = dunce::canonicalize(path).map_err(|e| Error::Io(path.to_owned(), e))?;
Ok(path
.ancestors()
.map(|dir| dir.join(".cargo/config.toml"))
.find(|dir| dir.is_file()))
}

pub fn get_target_dir_name(path: &Path) -> Result<String, Error> {
if let Some(config_path) = find_cargo_config(path)? {
let config = Config::parse_from_toml(&config_path)?;
/// Returns the [`target-dir`] configured in `.cargo/config.toml` or `"target"` if not set.
///
/// [`target-dir`](https://doc.rust-lang.org/cargo/reference/config.html#buildtarget-dir)
pub fn get_target_dir_name(config: Option<&Config>) -> Result<String, Error> {
if let Some(config) = config {
if let Some(build) = config.build.as_ref() {
if let Some(target_dir) = &build.target_dir {
return Ok(target_dir.clone());
Expand Down