1use std::{
8 collections::{btree_map::Entry as BTreeMapEntry, BTreeMap, HashSet, VecDeque},
9 fs::File,
10 io, mem,
11 path::{Path, PathBuf},
12};
13
14use compact_str::CompactString;
15use fs_lock::FileLock;
16use home::cargo_home;
17use merge::Merge;
18use miette::Diagnostic;
19use normalize_path::NormalizePath;
20use serde::Deserialize;
21use thiserror::Error;
22
23#[derive(Clone, Debug, Deserialize, Merge)]
24pub struct Install {
25 #[merge(strategy = merge::option::overwrite_none)]
27 pub root: Option<PathBuf>,
28}
29
30#[derive(Clone, Debug, Deserialize, Merge)]
31pub struct Http {
32 #[merge(strategy = merge::option::overwrite_none)]
36 pub proxy: Option<CompactString>,
37 #[merge(strategy = merge::option::overwrite_none)]
41 pub timeout: Option<u64>,
42 #[merge(strategy = merge::option::overwrite_none)]
44 pub cainfo: Option<PathBuf>,
45}
46
47#[derive(Eq, PartialEq, Debug, Deserialize)]
48#[serde(untagged)]
49pub enum Env {
50 Value(CompactString),
51 WithOptions {
52 value: CompactString,
53 force: Option<bool>,
54 relative: Option<bool>,
55 },
56}
57
58#[derive(Debug, Deserialize, Merge)]
59pub struct Registry {
60 #[merge(strategy = merge::option::overwrite_none)]
61 pub index: Option<CompactString>,
62 #[serde(rename = "replace-with")]
63 #[merge(strategy = merge::option::overwrite_none)]
64 pub replace_with: Option<CompactString>,
65 #[serde(rename = "credential-provider")]
66 #[merge(strategy = merge::option::overwrite_none)]
67 pub credential_provider: Option<CredentialProvider>,
68}
69
70type GlobalCredentialProviders = Option<VecDeque<CompactString>>;
71fn merge_global_credential_providers(
72 left: &mut GlobalCredentialProviders,
73 right: GlobalCredentialProviders,
74) {
75 match (left.as_mut(), right) {
76 (None, right) => *left = right,
77 (Some(_), None) => (),
78 (Some(left), Some(right)) => {
79 left.reserve(right.len());
80 for provider in right.into_iter().rev() {
81 left.push_front(provider);
82 }
83 }
84 }
85}
86
87#[derive(Debug, Deserialize, Merge)]
88pub struct DefaultRegistry {
89 #[merge(strategy = merge::option::overwrite_none)]
90 pub default: Option<CompactString>,
91 #[serde(rename = "credential-provider")]
92 #[merge(strategy = merge::option::overwrite_none)]
93 pub credential_provider: Option<CredentialProvider>,
94 #[serde(rename = "global-credential-providers")]
95 #[merge(strategy = merge_global_credential_providers)]
96 pub global_credential_providers: GlobalCredentialProviders,
97}
98
99#[derive(Clone, Debug, Deserialize)]
100#[serde(untagged)]
101pub enum CredentialProvider {
102 String(CompactString),
103 Array(Vec<CompactString>),
104}
105
106#[derive(Deserialize, Debug)]
107#[serde(untagged)]
108pub enum IncludedConfig {
109 Path(PathBuf),
110 Extended {
111 path: PathBuf,
112 #[serde(default)]
113 optional: bool,
114 },
115}
116
117impl IncludedConfig {
118 pub fn path(&self) -> &Path {
119 match self {
120 Self::Path(path) => path,
121 Self::Extended { path, .. } => path,
122 }
123 }
124
125 pub fn path_mut(&mut self) -> &mut PathBuf {
126 match self {
127 Self::Path(path) => path,
128 Self::Extended { path, .. } => path,
129 }
130 }
131
132 pub fn optional(&self) -> bool {
133 match self {
134 Self::Path(..) => false,
135 Self::Extended { optional, .. } => *optional,
136 }
137 }
138
139 fn open(&self) -> io::Result<Option<FileLock>> {
140 let path = self.path().canonicalize()?;
141
142 match File::open(&path) {
143 Err(err) if err.kind() == io::ErrorKind::NotFound && self.optional() => Ok(None),
144 res => Ok(Some(FileLock::new_shared(res?)?.set_file_path(path))),
145 }
146 }
147}
148
149fn merge_btreemap<K: Ord, V>(left: &mut BTreeMap<K, V>, right: BTreeMap<K, V>) {
150 for (k, v) in right.into_iter() {
151 left.entry(k).or_insert(v);
152 }
153}
154
155fn merge_btreemap_recursive<K: Ord, V: Merge>(left: &mut BTreeMap<K, V>, right: BTreeMap<K, V>) {
156 for (k, v) in right.into_iter() {
157 match left.entry(k) {
158 BTreeMapEntry::Vacant(entry) => {
159 entry.insert(v);
160 }
161 BTreeMapEntry::Occupied(entry) => entry.into_mut().merge(v),
162 }
163 }
164}
165
166#[derive(Debug, Default, Deserialize, Merge)]
167#[non_exhaustive]
168pub struct Config {
169 #[merge(strategy = merge::option::recurse)]
170 pub install: Option<Install>,
171 #[merge(strategy = merge::option::recurse)]
172 pub http: Option<Http>,
173 #[serde(default)]
174 #[merge(strategy = merge_btreemap)]
175 pub env: BTreeMap<CompactString, Env>,
176 #[serde(default)]
177 #[merge(strategy = merge_btreemap_recursive)]
178 pub registries: BTreeMap<CompactString, Registry>,
179 #[merge(strategy = merge::option::recurse)]
180 pub registry: Option<DefaultRegistry>,
181 #[serde(default)]
182 #[merge(skip)]
183 pub include: Vec<IncludedConfig>,
184 #[serde(default, rename = "credential-alias")]
185 #[merge(strategy = merge_btreemap)]
186 pub credential_alias: BTreeMap<CompactString, CredentialProvider>,
187}
188
189fn join_if_relative(path: Option<&mut PathBuf>, dir: &Path) {
190 match path {
191 Some(path) if path.is_relative() => *path = dir.join(&*path),
192 _ => (),
193 }
194}
195
196fn iterate_reverse_preorder(
197 mut stack: Vec<IncludedConfig>,
198 mut load_config: impl FnMut(
199 &mut dyn io::Read,
200 &Path,
201 ) -> Result<Vec<IncludedConfig>, ConfigLoadError>,
202) -> Result<(), ConfigLoadError> {
203 let mut visited_path = HashSet::new();
205
206 while let Some(config_path) = stack.pop() {
207 let Some(file) = config_path.open()? else {
208 continue;
209 };
210
211 let path = file.get_file_path().unwrap(); let parent = config_path.path().parent().unwrap(); if !visited_path.insert((path.to_owned(), parent.normalize())) {
222 return Err(ConfigLoadError::DeadLoopInLoading {
223 path: path.into(),
224 parent: parent.into(),
225 });
226 }
227
228 stack.extend(load_config(&mut (&file), parent)?);
229 }
230
231 Ok(())
232}
233
234impl Config {
235 pub fn default_path() -> Result<PathBuf, ConfigLoadError> {
236 Ok(cargo_home()?.join("config.toml"))
237 }
238
239 pub fn load() -> Result<Self, ConfigLoadError> {
240 Self::load_from_path(Self::default_path()?)
241 }
242
243 fn load_from_reader_inner(
244 reader: &mut dyn io::Read,
245 dir: &Path,
246 ) -> Result<Self, ConfigLoadError> {
247 let mut vec = Vec::new();
248 reader.read_to_end(&mut vec)?;
249
250 if vec.is_empty() {
251 Ok(Default::default())
252 } else {
253 let mut config: Config = toml_edit::de::from_slice(&vec)?;
254 join_if_relative(
255 config
256 .install
257 .as_mut()
258 .and_then(|install| install.root.as_mut()),
259 dir,
260 );
261 join_if_relative(
262 config.http.as_mut().and_then(|http| http.cainfo.as_mut()),
263 dir,
264 );
265 for env in config.env.values_mut() {
266 let Env::WithOptions {
267 value,
268 relative: Some(true),
269 ..
270 } = env
271 else {
272 continue;
273 };
274 let path = Path::new(&value);
275 if path.is_relative() {
276 *value = dir.join(path).to_string_lossy().into();
277 }
278 }
279
280 for included_config in &mut config.include {
281 join_if_relative(Some(included_config.path_mut()), dir);
282 }
283
284 Ok(config)
285 }
286 }
287
288 pub fn load_from_reader<R: io::Read>(
293 mut reader: R,
294 dir: &Path,
295 ) -> Result<Self, ConfigLoadError> {
296 fn inner(reader: &mut dyn io::Read, dir: &Path) -> Result<Config, ConfigLoadError> {
297 let mut root_config = Config::load_from_reader_inner(reader, dir)?;
298
299 iterate_reverse_preorder(mem::take(&mut root_config.include), |file, parent| {
300 let mut config = Config::load_from_reader_inner(file, parent)?;
301 let includes = mem::take(&mut config.include);
302 root_config.merge(config);
303 Ok(includes)
304 })?;
305
306 Ok(root_config)
307 }
308
309 inner(&mut reader, dir)
310 }
311
312 pub fn load_from_path(path: impl AsRef<Path>) -> Result<Self, ConfigLoadError> {
313 fn inner(path: &Path) -> Result<Config, ConfigLoadError> {
314 match File::open(path) {
315 Ok(file) => {
316 let file = FileLock::new_shared(file)?.set_file_path(path);
317 Config::load_from_reader(file, path.parent().unwrap())
319 }
320 Err(err) if err.kind() == io::ErrorKind::NotFound => Ok(Default::default()),
321 Err(err) => Err(err.into()),
322 }
323 }
324
325 inner(path.as_ref())
326 }
327
328 pub fn get_registry_index(&self, name: &str) -> Option<&str> {
329 let registry = self.registries.get(name)?;
330
331 if let Some(name) = registry.replace_with.as_deref() {
332 self.get_registry_index(name)
333 } else {
334 registry.index.as_deref()
335 }
336 }
337
338 pub fn get_registry(&self, name: &str) -> Option<&Registry> {
339 let registry = self.registries.get(name)?;
340
341 if let Some(name) = registry.replace_with.as_deref() {
342 self.get_registry(name)
343 } else {
344 Some(registry)
345 }
346 }
347}
348
349#[derive(Debug, Diagnostic, Error)]
350#[non_exhaustive]
351pub enum ConfigLoadError {
352 #[error("I/O Error: {0}")]
353 Io(#[from] io::Error),
354
355 #[error("Failed to deserialize toml: {0}")]
356 TomlParse(Box<toml_edit::de::Error>),
357
358 #[error("Detect deadloop in toml at `{path}` with parent `{parent}`")]
359 DeadLoopInLoading { path: Box<Path>, parent: Box<Path> },
360}
361
362impl From<toml_edit::de::Error> for ConfigLoadError {
363 fn from(e: toml_edit::de::Error) -> Self {
364 ConfigLoadError::TomlParse(Box::new(e))
365 }
366}
367
368impl From<toml_edit::TomlError> for ConfigLoadError {
369 fn from(e: toml_edit::TomlError) -> Self {
370 ConfigLoadError::TomlParse(Box::new(e.into()))
371 }
372}
373
374#[cfg(test)]
375mod tests {
376 use super::*;
377
378 use std::{io::Cursor, path::MAIN_SEPARATOR};
379
380 use compact_str::{format_compact, ToCompactString};
381
382 const CONFIG: &str = r#"
383[env]
384# Set ENV_VAR_NAME=value for any process run by Cargo
385ENV_VAR_NAME = "value"
386# Set even if already present in environment
387ENV_VAR_NAME_2 = { value = "value", force = true }
388# Value is relative to .cargo directory containing `config.toml`, make absolute
389ENV_VAR_NAME_3 = { value = "relative-path", relative = true }
390
391[http]
392debug = false # HTTP debugging
393proxy = "host:port" # HTTP proxy in libcurl format
394timeout = 30 # timeout for each HTTP request, in seconds
395cainfo = "cert.pem" # path to Certificate Authority (CA) bundle
396
397[install]
398root = "/some/path" # `cargo install` destination directory
399
400[registries.private-registry]
401index = "sparse+https://registry.example.com/index/"
402credential-provider = "cargo:token"
403
404[registry]
405default = "private-registry"
406credential-provider = "cargo:token"
407global-credential-providers = ["cargo:token", "cargo:libsecret"]
408
409[credential-alias]
410custom = ["cargo-credential-example", "--account", "test"]
411 "#;
412
413 #[test]
414 fn test_loading() {
415 let config = Config::load_from_reader(Cursor::new(&CONFIG), Path::new("root")).unwrap();
416
417 assert_eq!(
418 config.install.unwrap().root.as_deref().unwrap(),
419 Path::new("/some/path")
420 );
421
422 let http = config.http.unwrap();
423 assert_eq!(http.proxy.unwrap(), CompactString::const_new("host:port"));
424 assert_eq!(http.timeout.unwrap(), 30);
425 assert_eq!(http.cainfo.unwrap(), Path::new("root").join("cert.pem"));
426
427 let env = config.env;
428 assert_eq!(env.len(), 3);
429 assert_eq!(
430 env.get("ENV_VAR_NAME").unwrap(),
431 &Env::Value(CompactString::const_new("value"))
432 );
433 assert_eq!(
434 env.get("ENV_VAR_NAME_2").unwrap(),
435 &Env::WithOptions {
436 value: CompactString::new("value"),
437 force: Some(true),
438 relative: None,
439 }
440 );
441 assert_eq!(
442 env.get("ENV_VAR_NAME_3").unwrap(),
443 &Env::WithOptions {
444 value: format_compact!("root{MAIN_SEPARATOR}relative-path"),
445 force: None,
446 relative: Some(true),
447 }
448 );
449
450 let registries = config.registries;
451 let private_registry = registries.get("private-registry").unwrap();
452 assert_eq!(
453 private_registry.index.as_deref(),
454 Some("sparse+https://registry.example.com/index/")
455 );
456 assert!(matches!(
457 private_registry.credential_provider.as_ref(),
458 Some(CredentialProvider::String(provider)) if provider == "cargo:token"
459 ));
460
461 let mut registry = config.registry.unwrap();
462 assert_eq!(registry.default.as_deref(), Some("private-registry"));
463 assert!(matches!(
464 registry.credential_provider.as_ref(),
465 Some(CredentialProvider::String(provider)) if provider == "cargo:token"
466 ));
467 assert_eq!(
468 registry
469 .global_credential_providers
470 .as_mut()
471 .map(VecDeque::make_contiguous)
472 .as_deref(),
473 Some(
474 &[
475 CompactString::const_new("cargo:token"),
476 CompactString::const_new("cargo:libsecret"),
477 ][..]
478 )
479 );
480
481 let aliases = config.credential_alias;
482 assert!(matches!(
483 aliases.get("custom"),
484 Some(CredentialProvider::Array(provider))
485 if provider
486 == &[
487 CompactString::const_new("cargo-credential-example"),
488 CompactString::const_new("--account"),
489 CompactString::const_new("test"),
490 ]
491 ));
492 }
493
494 #[test]
495 fn test_merge_config() {
496 let mut config = Config {
497 install: None,
499 http: None,
500 include: Vec::new(),
502 credential_alias: BTreeMap::new(),
504
505 env: BTreeMap::from([
506 (CompactString::new("1"), Env::Value(CompactString::new("1"))),
507 (CompactString::new("2"), Env::Value(CompactString::new("2"))),
508 ]),
509 registries: BTreeMap::from([
510 (
511 CompactString::new("1"),
512 Registry {
513 index: None,
514 replace_with: None,
515 credential_provider: None,
516 },
517 ),
518 (
519 CompactString::new("2"),
520 Registry {
521 index: Some(CompactString::new("!")),
522 replace_with: None,
523 credential_provider: None,
524 },
525 ),
526 ]),
527 registry: Some(DefaultRegistry {
528 default: Some(CompactString::new("1")),
529 credential_provider: None,
530 global_credential_providers: Some(VecDeque::from([CompactString::new("left")])),
531 }),
532 };
533 config.merge(Config {
534 install: None,
535 http: None,
536 include: Vec::new(),
537 credential_alias: BTreeMap::new(),
538
539 env: BTreeMap::from([
540 (
541 CompactString::new("2"),
542 Env::Value(CompactString::new("qwewrd")),
543 ),
544 (CompactString::new("3"), Env::Value(CompactString::new("3"))),
545 ]),
546 registries: BTreeMap::from([
547 (
548 CompactString::new("2"),
549 Registry {
550 index: Some(CompactString::new("indexex")),
551 replace_with: Some(CompactString::new("ere")),
552 credential_provider: None,
553 },
554 ),
555 (
556 CompactString::new("3"),
557 Registry {
558 index: None,
559 replace_with: Some(CompactString::new("re")),
560 credential_provider: Some(CredentialProvider::String(CompactString::new(
561 "213",
562 ))),
563 },
564 ),
565 ]),
566 registry: Some(DefaultRegistry {
567 default: Some(CompactString::new("www1")),
568 credential_provider: Some(CredentialProvider::String(CompactString::new("ww213"))),
569 global_credential_providers: Some(
570 ["right", "2"].into_iter().map(Into::into).collect(),
571 ),
572 }),
573 });
574
575 assert_eq!(
576 config.env,
577 [1, 2, 3]
578 .iter()
579 .map(ToCompactString::to_compact_string)
580 .map(|s| (s.clone(), Env::Value(s)))
581 .collect::<BTreeMap<_, _>>(),
582 );
583
584 assert_eq!(
585 config.registries.keys().collect::<Vec<_>>(),
586 ["1", "2", "3"]
587 );
588
589 let registry_1 = config.registries.remove("1").unwrap();
590 assert_eq!(registry_1.index, None);
591 assert_eq!(registry_1.replace_with, None);
592 assert!(registry_1.credential_provider.is_none());
593
594 let registry_2 = config.registries.remove("2").unwrap();
595 assert_eq!(registry_2.index, Some(CompactString::new("!")));
596 assert_eq!(registry_2.replace_with, Some(CompactString::new("ere")));
597 assert!(registry_2.credential_provider.is_none());
598
599 let registry_3 = config.registries.remove("3").unwrap();
600 assert_eq!(registry_3.index, None);
601 assert_eq!(registry_3.replace_with, Some(CompactString::new("re")));
602 assert!(
603 matches!(registry_3.credential_provider.unwrap(), CredentialProvider::String(v) if v == "213")
604 );
605
606 let default_registry = config.registry.unwrap();
607 assert_eq!(default_registry.default, Some(CompactString::new("1")));
608 assert!(
609 matches!(default_registry.credential_provider.unwrap(), CredentialProvider::String(v) if v == "ww213")
610 );
611 assert_eq!(
612 default_registry.global_credential_providers.unwrap(),
613 ["right", "2", "left"]
614 .into_iter()
615 .map(CompactString::new)
616 .collect::<Vec<_>>(),
617 );
618 }
619}