From c1d70a2cad3868d93c76bbdaf352dd84cf7e1b40 Mon Sep 17 00:00:00 2001 From: Marijn Suijten Date: Thu, 24 Feb 2022 17:03:00 +0100 Subject: [PATCH] config: Parse `[env]` block https://doc.rust-lang.org/cargo/reference/config.html#env --- src/config.rs | 261 +++++++++++++++++++++++++++++++++++++++++++++- src/error.rs | 2 +- src/lib.rs | 1 + src/subcommand.rs | 31 +++++- src/utils.rs | 17 +-- 5 files changed, 291 insertions(+), 21 deletions(-) diff --git a/src/config.rs b/src/config.rs index 900b398..82c98ae 100644 --- a/src/config.rs +++ b/src/config.rs @@ -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 for EnvError { + fn from(error: io::Error) -> Self { + Self::Io(error) + } +} + +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(error) => error.fmt(f), + Self::Var(error) => error.fmt(f), + } + } +} + +impl std::error::Error for Error {} + +type Result = std::result::Result; + +#[derive(Clone, Debug, Deserialize, PartialEq)] +#[serde(rename_all = "kebab-case")] pub struct Config { pub build: Option, + /// + pub env: BTreeMap, } impl Config { @@ -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 { + 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 = 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, + ) -> 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.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, } + +/// 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 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::(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::(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") + ); +} diff --git a/src/error.rs b/src/error.rs index def5334..4d33280 100644 --- a/src/error.rs +++ b/src/error.rs @@ -25,7 +25,7 @@ impl Display for Error { } } -impl std::error::Error for Error {} +// impl std::error::Error for Error {} impl From for Error { fn from(error: IoError) -> Self { diff --git a/src/lib.rs b/src/lib.rs index 04c4e3b..e3cd7d3 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::{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 3eb5e0a..0f33d17 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).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"))? { @@ -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)] @@ -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")); } diff --git a/src/utils.rs b/src/utils.rs index f2a1e42..32d1357 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)?; - 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());