cargo_subcommand/
config.rs

1use crate::error::Error;
2use serde::Deserialize;
3use 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};
12
13/// Specific errors that can be raised during environment parsing
14#[derive(Debug)]
15pub enum EnvError {
16    Io(PathBuf, io::Error),
17    Var(VarError),
18}
19
20pub type Result<T, E = EnvError> = std::result::Result<T, E>;
21
22impl From<VarError> for EnvError {
23    fn from(var: VarError) -> Self {
24        Self::Var(var)
25    }
26}
27
28impl Display for EnvError {
29    fn fmt(&self, f: &mut Formatter) -> fmt::Result {
30        match self {
31            Self::Io(path, error) => write!(f, "{}: {}", path.display(), error),
32            Self::Var(error) => error.fmt(f),
33        }
34    }
35}
36
37impl std::error::Error for EnvError {}
38
39#[derive(Clone, Debug, Deserialize, PartialEq, Eq)]
40#[serde(rename_all = "kebab-case")]
41pub struct Config {
42    pub build: Option<Build>,
43    /// <https://doc.rust-lang.org/cargo/reference/config.html#env>
44    pub env: Option<BTreeMap<String, EnvOption>>,
45}
46
47impl Config {
48    pub fn parse_from_toml(path: &Path) -> Result<Self, Error> {
49        let contents = std::fs::read_to_string(path).map_err(|e| Error::Io(path.to_owned(), e))?;
50        toml::from_str(&contents).map_err(|e| Error::Toml(path.to_owned(), e))
51    }
52}
53
54#[derive(Clone, Debug)]
55pub struct LocalizedConfig {
56    pub config: Config,
57    /// The directory containing `./.cargo/config.toml`
58    pub workspace: PathBuf,
59}
60
61impl Deref for LocalizedConfig {
62    type Target = Config;
63
64    fn deref(&self) -> &Self::Target {
65        &self.config
66    }
67}
68
69impl 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    /// Propagate environment variables from this `.cargo/config.toml` to the process environment
98    /// using [`std::env::set_var()`].
99    ///
100    /// Note that this is automatically performed when calling [`Subcommand::new()`][super::Subcommand::new()].
101    pub fn set_env_vars(&self) -> Result<()> {
102        if let Some(env) = &self.config.env {
103            for (key, env_option) in env {
104                // Existing environment variables always have precedence unless
105                // the extended format is used to set `force = true`:
106                if !matches!(env_option, EnvOption::Value { force: true, .. })
107                    && std::env::var_os(key).is_some()
108                {
109                    continue;
110                }
111
112                std::env::set_var(key, env_option.resolve_value(&self.workspace)?.as_ref())
113            }
114        }
115
116        Ok(())
117    }
118}
119
120#[derive(Clone, Debug, Deserialize, PartialEq, Eq)]
121#[serde(rename_all = "kebab-case")]
122pub struct Build {
123    pub target_dir: Option<String>,
124}
125
126/// Serializable environment variable in cargo config, configurable as per
127/// <https://doc.rust-lang.org/cargo/reference/config.html#env>,
128#[derive(Clone, Debug, Deserialize, PartialEq, Eq)]
129#[serde(untagged, rename_all = "kebab-case")]
130pub enum EnvOption {
131    String(String),
132    Value {
133        value: String,
134        #[serde(default)]
135        force: bool,
136        #[serde(default)]
137        relative: bool,
138    },
139}
140
141impl EnvOption {
142    /// Retrieve the value and canonicalize it relative to `config_parent` when [`EnvOption::Value::relative`] is set.
143    ///
144    /// `config_parent` is the directory containing `.cargo/config.toml` where this was parsed from.
145    pub fn resolve_value(&self, config_parent: impl AsRef<Path>) -> Result<Cow<'_, str>> {
146        Ok(match self {
147            Self::Value {
148                value,
149                relative: true,
150                force: _,
151            } => {
152                let value = config_parent.as_ref().join(value);
153                let value = dunce::canonicalize(&value).map_err(|e| EnvError::Io(value, e))?;
154                value
155                    .into_os_string()
156                    .into_string()
157                    .map_err(VarError::NotUnicode)?
158                    .into()
159            }
160            Self::String(value) | Self::Value { value, .. } => value.into(),
161        })
162    }
163}
164
165#[test]
166fn test_env_parsing() {
167    let toml = r#"
168[env]
169# Set ENV_VAR_NAME=value for any process run by Cargo
170ENV_VAR_NAME = "value"
171# Set even if already present in environment
172ENV_VAR_NAME_2 = { value = "value", force = true }
173# Value is relative to .cargo directory containing `config.toml`, make absolute
174ENV_VAR_NAME_3 = { value = "relative/path", relative = true }"#;
175
176    let mut env = BTreeMap::new();
177    env.insert(
178        "ENV_VAR_NAME".to_string(),
179        EnvOption::String("value".into()),
180    );
181    env.insert(
182        "ENV_VAR_NAME_2".to_string(),
183        EnvOption::Value {
184            value: "value".into(),
185            force: true,
186            relative: false,
187        },
188    );
189    env.insert(
190        "ENV_VAR_NAME_3".to_string(),
191        EnvOption::Value {
192            value: "relative/path".into(),
193            force: false,
194            relative: true,
195        },
196    );
197
198    assert_eq!(
199        toml::from_str::<Config>(toml),
200        Ok(Config {
201            build: None,
202            env: Some(env)
203        })
204    );
205}
206
207#[test]
208fn test_env_precedence_rules() {
209    let toml = r#"
210[env]
211CARGO_SUBCOMMAND_TEST_ENV_NOT_FORCED = "not forced"
212CARGO_SUBCOMMAND_TEST_ENV_FORCED = { value = "forced", force = true }"#;
213
214    let config = LocalizedConfig {
215        config: toml::from_str::<Config>(toml).unwrap(),
216        workspace: PathBuf::new(),
217    };
218
219    // Check if all values are propagated to the environment
220    config.set_env_vars().unwrap();
221
222    assert!(matches!(
223        std::env::var("CARGO_SUBCOMMAND_TEST_ENV_NOT_SET"),
224        Err(VarError::NotPresent)
225    ));
226    assert_eq!(
227        std::env::var("CARGO_SUBCOMMAND_TEST_ENV_NOT_FORCED").unwrap(),
228        "not forced"
229    );
230    assert_eq!(
231        std::env::var("CARGO_SUBCOMMAND_TEST_ENV_FORCED").unwrap(),
232        "forced"
233    );
234
235    // Set some environment values
236    std::env::set_var(
237        "CARGO_SUBCOMMAND_TEST_ENV_NOT_FORCED",
238        "not forced process environment value",
239    );
240    std::env::set_var(
241        "CARGO_SUBCOMMAND_TEST_ENV_FORCED",
242        "forced process environment value",
243    );
244
245    config.set_env_vars().unwrap();
246
247    assert_eq!(
248        std::env::var("CARGO_SUBCOMMAND_TEST_ENV_NOT_FORCED").unwrap(),
249        // Value remains what is set in the process environment,
250        // and is not overwritten by set_env_vars()
251        "not forced process environment value"
252    );
253    assert_eq!(
254        std::env::var("CARGO_SUBCOMMAND_TEST_ENV_FORCED").unwrap(),
255        // Value is overwritten thanks to force=true, despite
256        // also being set in the process environment
257        "forced"
258    );
259}
260
261#[test]
262fn test_env_canonicalization() {
263    use std::ffi::OsStr;
264
265    let toml = r#"
266[env]
267CARGO_SUBCOMMAND_TEST_ENV_SRC_DIR = { value = "src", force = true, relative = true }
268"#;
269
270    let config = LocalizedConfig {
271        config: toml::from_str::<Config>(toml).unwrap(),
272        workspace: PathBuf::new(),
273    };
274
275    config.set_env_vars().unwrap();
276
277    let path = std::env::var("CARGO_SUBCOMMAND_TEST_ENV_SRC_DIR")
278        .expect("Canonicalization for a known-to-exist ./src folder should not fail");
279    let path = PathBuf::from(path);
280    assert!(path.is_absolute());
281    assert!(path.is_dir());
282    assert_eq!(path.file_name(), Some(OsStr::new("src")));
283
284    let toml = r#"
285[env]
286CARGO_SUBCOMMAND_TEST_ENV_INEXISTENT_DIR = { value = "blahblahthisfolderdoesntexist", force = true, relative = true }
287"#;
288
289    let config = LocalizedConfig {
290        config: toml::from_str::<Config>(toml).unwrap(),
291        workspace: PathBuf::new(),
292    };
293
294    assert!(matches!(config.set_env_vars(), Err(EnvError::Io(..))));
295}