From aeb0f502a3558babc15184b76da0e38aab4bc048 Mon Sep 17 00:00:00 2001 From: Marijn Suijten Date: Fri, 4 Mar 2022 10:52:36 +0100 Subject: [PATCH] config: Read environment variables from `[env]` section in `config.toml` (#12) 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. --- src/config.rs | 291 +++++++++++++++++++++++++++++++++++++++++++++- src/lib.rs | 1 + src/subcommand.rs | 12 +- src/utils.rs | 17 +-- 4 files changed, 303 insertions(+), 18 deletions(-) diff --git a/src/config.rs b/src/config.rs index cf440f0..aad57e6 100644 --- a/src/config.rs +++ b/src/config.rs @@ -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 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 = std::result::Result; + +#[derive(Clone, Debug, Deserialize, PartialEq)] +#[serde(rename_all = "kebab-case")] pub struct Config { pub build: Option, + /// + pub env: Option>, } impl Config { @@ -14,8 +51,254 @@ 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 { + 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) -> Result, 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, + ) -> Result, 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> { + 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, } + +/// Serializable environment variable in cargo config, configurable as per +/// , +#[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) -> Result> { + 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::(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::(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::(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()); +} diff --git a/src/lib.rs b/src/lib.rs index 04c4e3b..8d9ff96 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -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; diff --git a/src/subcommand.rs b/src/subcommand.rs index 84a29c6..47cd1ac 100644 --- a/src/subcommand.rs +++ b/src/subcommand.rs @@ -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; @@ -18,6 +18,7 @@ pub struct Subcommand { profile: Profile, artifacts: Vec, quiet: bool, + config: Option, } impl Subcommand { @@ -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"))? { @@ -145,6 +148,7 @@ impl Subcommand { profile, artifacts, quiet, + config, }) } @@ -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)] diff --git a/src/utils.rs b/src/utils.rs index b32b846..5936177 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -78,18 +78,11 @@ pub fn find_workspace(manifest: &Path, name: &str) -> Result, Er Ok(None) } -/// Search for .cargo/config.toml file relative to the workspace root path. -pub fn find_cargo_config(path: &Path) -> Result, 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 { - 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 { + 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());