Skip to content

Commit aeb0f50

Browse files
authored
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.
1 parent 0a53c89 commit aeb0f50

File tree

4 files changed

+303
-18
lines changed

4 files changed

+303
-18
lines changed

src/config.rs

+287-4
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,47 @@
11
use crate::error::Error;
22
use serde::Deserialize;
3-
use std::path::Path;
3+
use std::{
4+
borrow::Cow,
5+
collections::BTreeMap,
6+
env::VarError,
7+
fmt::{self, Display, Formatter},
8+
io,
9+
ops::Deref,
10+
path::{Path, PathBuf},
11+
};
412

5-
#[derive(Debug, Deserialize)]
13+
/// Specific errors that can be raised during environment parsing
14+
#[derive(Debug)]
15+
pub enum EnvError {
16+
Io(PathBuf, io::Error),
17+
Var(VarError),
18+
}
19+
20+
impl From<VarError> for EnvError {
21+
fn from(var: VarError) -> Self {
22+
Self::Var(var)
23+
}
24+
}
25+
26+
impl Display for EnvError {
27+
fn fmt(&self, f: &mut Formatter) -> fmt::Result {
28+
match self {
29+
Self::Io(path, error) => write!(f, "{}: {}", path.display(), error),
30+
Self::Var(error) => error.fmt(f),
31+
}
32+
}
33+
}
34+
35+
impl std::error::Error for EnvError {}
36+
37+
type Result<T, E = EnvError> = std::result::Result<T, E>;
38+
39+
#[derive(Clone, Debug, Deserialize, PartialEq)]
40+
#[serde(rename_all = "kebab-case")]
641
pub struct Config {
742
pub build: Option<Build>,
43+
/// <https://doc.rust-lang.org/cargo/reference/config.html#env>
44+
pub env: Option<BTreeMap<String, EnvOption>>,
845
}
946

1047
impl Config {
@@ -14,8 +51,254 @@ impl Config {
1451
}
1552
}
1653

17-
#[derive(Debug, Deserialize)]
54+
#[derive(Clone, Debug)]
55+
pub struct LocalizedConfig {
56+
pub config: Config,
57+
/// The directory containing `./.cargo/config.toml`
58+
pub workspace: PathBuf,
59+
}
60+
61+
impl Deref for LocalizedConfig {
62+
type Target = Config;
63+
64+
fn deref(&self) -> &Self::Target {
65+
&self.config
66+
}
67+
}
68+
69+
impl LocalizedConfig {
70+
pub fn new(workspace: PathBuf) -> Result<Self, Error> {
71+
Ok(Self {
72+
config: Config::parse_from_toml(&workspace.join(".cargo/config.toml"))?,
73+
workspace,
74+
})
75+
}
76+
77+
/// Search for `.cargo/config.toml` in any parent of the workspace root path.
78+
/// Returns the directory which contains this path, not the path to the config file.
79+
fn find_cargo_config_parent(workspace: impl AsRef<Path>) -> Result<Option<PathBuf>, Error> {
80+
let workspace = workspace.as_ref();
81+
let workspace =
82+
dunce::canonicalize(workspace).map_err(|e| Error::Io(workspace.to_owned(), e))?;
83+
Ok(workspace
84+
.ancestors()
85+
.find(|dir| dir.join(".cargo/config.toml").is_file())
86+
.map(|p| p.to_path_buf()))
87+
}
88+
89+
/// Search for and open `.cargo/config.toml` in any parent of the workspace root path.
90+
pub fn find_cargo_config_for_workspace(
91+
workspace: impl AsRef<Path>,
92+
) -> Result<Option<Self>, Error> {
93+
let config = Self::find_cargo_config_parent(workspace)?;
94+
config.map(LocalizedConfig::new).transpose()
95+
}
96+
97+
/// Read an environment variable from the `[env]` section in this `.cargo/config.toml`.
98+
///
99+
/// It is interpreted as path and canonicalized relative to [`Self::workspace`] if
100+
/// [`EnvOption::Value::relative`] is set.
101+
///
102+
/// Process environment variables (from [`std::env::var()`]) have [precedence]
103+
/// unless [`EnvOption::Value::force`] is set. This value is also returned if
104+
/// the given key was not set under `[env]`.
105+
///
106+
/// [precedence]: https://doc.rust-lang.org/cargo/reference/config.html#env
107+
pub fn resolve_env(&self, key: &str) -> Result<Cow<'_, str>> {
108+
let config_var = self.config.env.as_ref().and_then(|env| env.get(key));
109+
110+
// Environment variables always have precedence unless
111+
// the extended format is used to set `force = true`:
112+
if let Some(env_option @ EnvOption::Value { force: true, .. }) = config_var {
113+
// Errors iresolving (canonicalizing, really) the config variable take precedence, too:
114+
return env_option.resolve_value(&self.workspace);
115+
}
116+
117+
let process_var = std::env::var(key);
118+
if process_var != Err(VarError::NotPresent) {
119+
// Errors from env::var() also have precedence here:
120+
return Ok(process_var?.into());
121+
}
122+
123+
// Finally, the value in `[env]` (if it exists) is taken into account
124+
config_var
125+
.ok_or(VarError::NotPresent)?
126+
.resolve_value(&self.workspace)
127+
}
128+
}
129+
130+
#[derive(Clone, Debug, Deserialize, PartialEq)]
131+
#[serde(rename_all = "kebab-case")]
18132
pub struct Build {
19-
#[serde(rename = "target-dir")]
20133
pub target_dir: Option<String>,
21134
}
135+
136+
/// Serializable environment variable in cargo config, configurable as per
137+
/// <https://doc.rust-lang.org/cargo/reference/config.html#env>,
138+
#[derive(Clone, Debug, Deserialize, PartialEq)]
139+
#[serde(untagged, rename_all = "kebab-case")]
140+
pub enum EnvOption {
141+
String(String),
142+
Value {
143+
value: String,
144+
#[serde(default)]
145+
force: bool,
146+
#[serde(default)]
147+
relative: bool,
148+
},
149+
}
150+
151+
impl EnvOption {
152+
/// Retrieve the value and canonicalize it relative to `config_parent` when [`EnvOption::Value::relative`] is set.
153+
///
154+
/// `config_parent` is the directory containing `.cargo/config.toml` where this was parsed from.
155+
pub fn resolve_value(&self, config_parent: impl AsRef<Path>) -> Result<Cow<'_, str>> {
156+
Ok(match self {
157+
Self::Value {
158+
value,
159+
relative: true,
160+
force: _,
161+
} => {
162+
let value = config_parent.as_ref().join(value);
163+
let value = dunce::canonicalize(&value).map_err(|e| EnvError::Io(value, e))?;
164+
value
165+
.into_os_string()
166+
.into_string()
167+
.map_err(VarError::NotUnicode)?
168+
.into()
169+
}
170+
Self::String(value) | Self::Value { value, .. } => value.into(),
171+
})
172+
}
173+
}
174+
175+
#[test]
176+
fn test_env_parsing() {
177+
let toml = r#"
178+
[env]
179+
# Set ENV_VAR_NAME=value for any process run by Cargo
180+
ENV_VAR_NAME = "value"
181+
# Set even if already present in environment
182+
ENV_VAR_NAME_2 = { value = "value", force = true }
183+
# Value is relative to .cargo directory containing `config.toml`, make absolute
184+
ENV_VAR_NAME_3 = { value = "relative/path", relative = true }"#;
185+
186+
let mut env = BTreeMap::new();
187+
env.insert(
188+
"ENV_VAR_NAME".to_string(),
189+
EnvOption::String("value".into()),
190+
);
191+
env.insert(
192+
"ENV_VAR_NAME_2".to_string(),
193+
EnvOption::Value {
194+
value: "value".into(),
195+
force: true,
196+
relative: false,
197+
},
198+
);
199+
env.insert(
200+
"ENV_VAR_NAME_3".to_string(),
201+
EnvOption::Value {
202+
value: "relative/path".into(),
203+
force: false,
204+
relative: true,
205+
},
206+
);
207+
208+
assert_eq!(
209+
toml::from_str::<Config>(toml),
210+
Ok(Config {
211+
build: None,
212+
env: Some(env)
213+
})
214+
);
215+
}
216+
217+
#[test]
218+
fn test_env_precedence_rules() {
219+
let toml = r#"
220+
[env]
221+
CARGO_SUBCOMMAND_TEST_ENV_NOT_FORCED = "not forced"
222+
CARGO_SUBCOMMAND_TEST_ENV_FORCED = { value = "forced", force = true }"#;
223+
224+
let config = LocalizedConfig {
225+
config: toml::from_str::<Config>(toml).unwrap(),
226+
workspace: PathBuf::new(),
227+
};
228+
229+
assert!(matches!(
230+
config.resolve_env("CARGO_SUBCOMMAND_TEST_ENV_NOT_SET"),
231+
Err(EnvError::Var(VarError::NotPresent))
232+
));
233+
assert_eq!(
234+
config
235+
.resolve_env("CARGO_SUBCOMMAND_TEST_ENV_NOT_FORCED")
236+
.unwrap(),
237+
Cow::from("not forced")
238+
);
239+
assert_eq!(
240+
config
241+
.resolve_env("CARGO_SUBCOMMAND_TEST_ENV_FORCED")
242+
.unwrap(),
243+
Cow::from("forced")
244+
);
245+
246+
std::env::set_var("CARGO_SUBCOMMAND_TEST_ENV_NOT_SET", "set in env");
247+
std::env::set_var(
248+
"CARGO_SUBCOMMAND_TEST_ENV_NOT_FORCED",
249+
"not forced overridden",
250+
);
251+
std::env::set_var("CARGO_SUBCOMMAND_TEST_ENV_FORCED", "forced overridden");
252+
253+
assert_eq!(
254+
config
255+
.resolve_env("CARGO_SUBCOMMAND_TEST_ENV_NOT_SET")
256+
.unwrap(),
257+
// Even if the value isn't present in [env] it should still resolve to the
258+
// value in the process environment
259+
Cow::from("set in env")
260+
);
261+
assert_eq!(
262+
config
263+
.resolve_env("CARGO_SUBCOMMAND_TEST_ENV_NOT_FORCED")
264+
.unwrap(),
265+
// Value changed now that it is set in the environment
266+
Cow::from("not forced overridden")
267+
);
268+
assert_eq!(
269+
config
270+
.resolve_env("CARGO_SUBCOMMAND_TEST_ENV_FORCED")
271+
.unwrap(),
272+
// Value stays at how it was configured in [env] with force=true, despite
273+
// also being set in the process environment
274+
Cow::from("forced")
275+
);
276+
}
277+
278+
#[test]
279+
fn test_env_canonicalization() {
280+
use std::ffi::OsStr;
281+
282+
let toml = r#"
283+
[env]
284+
CARGO_SUBCOMMAND_TEST_ENV_SRC_DIR = { value = "src", force = true, relative = true }
285+
CARGO_SUBCOMMAND_TEST_ENV_INEXISTENT_DIR = { value = "blahblahthisfolderdoesntexist", force = true, relative = true }
286+
"#;
287+
288+
let config = LocalizedConfig {
289+
config: toml::from_str::<Config>(toml).unwrap(),
290+
workspace: PathBuf::new(),
291+
};
292+
293+
let path = config
294+
.resolve_env("CARGO_SUBCOMMAND_TEST_ENV_SRC_DIR")
295+
.expect("Canonicalization for a known-to-exist ./src folder should not fail");
296+
let path = Path::new(path.as_ref());
297+
assert!(path.is_absolute());
298+
assert!(path.is_dir());
299+
assert_eq!(path.file_name(), Some(OsStr::new("src")));
300+
301+
assert!(config
302+
.resolve_env("CARGO_SUBCOMMAND_TEST_ENV_INEXISTENT_DIR")
303+
.is_err());
304+
}

src/lib.rs

+1
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ mod subcommand;
77
mod utils;
88

99
pub use artifact::{Artifact, CrateType};
10+
pub use config::{EnvError, EnvOption, LocalizedConfig};
1011
pub use error::Error;
1112
pub use profile::Profile;
1213
pub use subcommand::Subcommand;

src/subcommand.rs

+10-2
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
use crate::artifact::Artifact;
22
use crate::error::Error;
33
use crate::profile::Profile;
4-
use crate::utils;
4+
use crate::{utils, LocalizedConfig};
55
use std::io::BufRead;
66
use std::path::{Path, PathBuf};
77
use std::process::Command;
@@ -18,6 +18,7 @@ pub struct Subcommand {
1818
profile: Profile,
1919
artifacts: Vec<Artifact>,
2020
quiet: bool,
21+
config: Option<LocalizedConfig>,
2122
}
2223

2324
impl Subcommand {
@@ -103,13 +104,15 @@ impl Subcommand {
103104
}
104105
});
105106

107+
let config = LocalizedConfig::find_cargo_config_for_workspace(&root_dir)?;
108+
106109
let target_dir = target_dir.unwrap_or_else(|| {
107110
utils::find_workspace(&manifest, &package)
108111
.unwrap()
109112
.unwrap_or_else(|| manifest.clone())
110113
.parent()
111114
.unwrap()
112-
.join(utils::get_target_dir_name(root_dir).unwrap())
115+
.join(utils::get_target_dir_name(config.as_deref()).unwrap())
113116
});
114117
if examples {
115118
for file in utils::list_rust_files(&root_dir.join("examples"))? {
@@ -145,6 +148,7 @@ impl Subcommand {
145148
profile,
146149
artifacts,
147150
quiet,
151+
config,
148152
})
149153
}
150154

@@ -187,6 +191,10 @@ impl Subcommand {
187191
pub fn quiet(&self) -> bool {
188192
self.quiet
189193
}
194+
195+
pub fn config(&self) -> Option<&LocalizedConfig> {
196+
self.config.as_ref()
197+
}
190198
}
191199

192200
#[cfg(test)]

src/utils.rs

+5-12
Original file line numberDiff line numberDiff line change
@@ -78,18 +78,11 @@ pub fn find_workspace(manifest: &Path, name: &str) -> Result<Option<PathBuf>, Er
7878
Ok(None)
7979
}
8080

81-
/// Search for .cargo/config.toml file relative to the workspace root path.
82-
pub fn find_cargo_config(path: &Path) -> Result<Option<PathBuf>, Error> {
83-
let path = dunce::canonicalize(path).map_err(|e| Error::Io(path.to_owned(), e))?;
84-
Ok(path
85-
.ancestors()
86-
.map(|dir| dir.join(".cargo/config.toml"))
87-
.find(|dir| dir.is_file()))
88-
}
89-
90-
pub fn get_target_dir_name(path: &Path) -> Result<String, Error> {
91-
if let Some(config_path) = find_cargo_config(path)? {
92-
let config = Config::parse_from_toml(&config_path)?;
81+
/// Returns the [`target-dir`] configured in `.cargo/config.toml` or `"target"` if not set.
82+
///
83+
/// [`target-dir`](https://doc.rust-lang.org/cargo/reference/config.html#buildtarget-dir)
84+
pub fn get_target_dir_name(config: Option<&Config>) -> Result<String, Error> {
85+
if let Some(config) = config {
9386
if let Some(build) = config.build.as_ref() {
9487
if let Some(target_dir) = &build.target_dir {
9588
return Ok(target_dir.clone());

0 commit comments

Comments
 (0)