Skip to content

Commit

Permalink
config: Read environment variables from [env] section in config.toml
Browse files Browse the repository at this point in the history
Environment variables can be set and optionally override the process
environment through `.cargo/config.toml`'s `[env]` section:
https://doc.rust-lang.org/cargo/reference/config.html#env

These config variables have specific precedence rules with regards to
overriding the environment set in the process, and can optionally
represent paths relative to the parent of the containing `.cargo/`
folder.
  • Loading branch information
MarijnS95 committed Mar 2, 2022
1 parent 421131a commit e992e82
Show file tree
Hide file tree
Showing 4 changed files with 290 additions and 20 deletions.
261 changes: 257 additions & 4 deletions src/config.rs
Original file line number Diff line number Diff line change
@@ -1,10 +1,53 @@
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(io::Error),
Var(VarError),
}

impl From<io::Error> for EnvError {
fn from(error: io::Error) -> Self {
Self::Io(error)
}
}

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(error) => error.fmt(f),
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: BTreeMap<String, EnvOption>,
}

impl Config {
Expand All @@ -14,8 +57,218 @@ impl Config {
}
}

#[derive(Debug, Deserialize)]
#[derive(Clone, Debug)]
pub struct LocalizedConfig {
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 = dunce::canonicalize(workspace)?;
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()
}

/// 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.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(key);
}

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(key)
}
}

#[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 canonicalized = dunce::canonicalize(config_parent.as_ref().join(value))?;
canonicalized
.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 })
);
}

#[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")
);
}
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;
31 changes: 27 additions & 4 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).unwrap();

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 All @@ -195,14 +203,29 @@ mod tests {

#[test]
fn test_separator_space() {
let args = ["cargo", "subcommand", "build", "--target", "x86_64-unknown-linux-gnu"].iter().map(|s| s.to_string());
let args = [
"cargo",
"subcommand",
"build",
"--target",
"x86_64-unknown-linux-gnu",
]
.iter()
.map(|s| s.to_string());
let cmd = Subcommand::new(args, "subcommand", |_, _| Ok(false)).unwrap();
assert_eq!(cmd.target(), Some("x86_64-unknown-linux-gnu"));
}

#[test]
fn test_separator_equals() {
let args = ["cargo", "subcommand", "build", "--target=x86_64-unknown-linux-gnu"].iter().map(|s| s.to_string());
let args = [
"cargo",
"subcommand",
"build",
"--target=x86_64-unknown-linux-gnu",
]
.iter()
.map(|s| s.to_string());
let cmd = Subcommand::new(args, "subcommand", |_, _| Ok(false)).unwrap();
assert_eq!(cmd.target(), Some("x86_64-unknown-linux-gnu"));
}
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)?;
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

0 comments on commit e992e82

Please sign in to comment.