1use std::ffi::OsString;
27use std::{fs, io, sync::Arc};
28
29use figment::Figment;
30use void::ResultVoidExt as _;
31
32use crate::err::ConfigError;
33use crate::{CmdLine, ConfigurationTree};
34
35use std::path::{Path, PathBuf};
36
37#[derive(Clone, Debug, Default)]
39pub struct ConfigurationSources {
40 files: Vec<(ConfigurationSource, MustRead)>,
42 options: Vec<String>,
44 mistrust: fs_mistrust::Mistrust,
46}
47
48#[derive(Clone, Debug, Copy, Eq, PartialEq)]
54#[allow(clippy::exhaustive_enums)]
55pub enum MustRead {
56 TolerateAbsence,
58
59 MustRead,
61}
62
63#[derive(Clone, Debug, Eq, PartialEq, Hash, Ord, PartialOrd)]
68#[allow(clippy::exhaustive_enums)]
69pub enum ConfigurationSource {
70 File(PathBuf),
72
73 Dir(PathBuf),
75
76 Verbatim(Arc<String>),
78}
79
80impl ConfigurationSource {
81 pub fn from_path<P: Into<PathBuf>>(p: P) -> ConfigurationSource {
88 use ConfigurationSource as CS;
89 let p = p.into();
90 if is_syntactically_directory(&p) {
91 CS::Dir(p)
92 } else {
93 CS::File(p)
94 }
95 }
96
97 pub fn from_verbatim(text: String) -> ConfigurationSource {
99 Self::Verbatim(Arc::new(text))
100 }
101
102 pub fn as_path(&self) -> Option<&Path> {
104 use ConfigurationSource as CS;
105 match self {
106 CS::File(p) | CS::Dir(p) => Some(p),
107 CS::Verbatim(_) => None,
108 }
109 }
110}
111
112#[derive(Debug)]
119pub struct FoundConfigFiles<'srcs> {
120 files: Vec<FoundConfigFile>,
130
131 sources: &'srcs ConfigurationSources,
133}
134
135#[derive(Debug, Clone)]
137struct FoundConfigFile {
138 source: ConfigurationSource,
140
141 must_read: MustRead,
143}
144
145impl ConfigurationSources {
146 pub fn new_empty() -> Self {
148 Self::default()
149 }
150
151 pub fn from_cmdline<F, O>(
155 default_config_files: impl IntoIterator<Item = ConfigurationSource>,
156 config_files_options: impl IntoIterator<Item = F>,
157 cmdline_toml_override_options: impl IntoIterator<Item = O>,
158 ) -> Self
159 where
160 F: Into<PathBuf>,
161 O: Into<String>,
162 {
163 ConfigurationSources::try_from_cmdline(
164 || Ok(default_config_files),
165 config_files_options,
166 cmdline_toml_override_options,
167 )
168 .void_unwrap()
169 }
170
171 pub fn try_from_cmdline<F, O, DEF, E>(
189 default_config_files: impl FnOnce() -> Result<DEF, E>,
190 config_files_options: impl IntoIterator<Item = F>,
191 cmdline_toml_override_options: impl IntoIterator<Item = O>,
192 ) -> Result<Self, E>
193 where
194 F: Into<PathBuf>,
195 O: Into<String>,
196 DEF: IntoIterator<Item = ConfigurationSource>,
197 {
198 let mut cfg_sources = ConfigurationSources::new_empty();
199
200 let mut any_files = false;
201 for f in config_files_options {
202 let f = f.into();
203 cfg_sources.push_source(ConfigurationSource::from_path(f), MustRead::MustRead);
204 any_files = true;
205 }
206 if !any_files {
207 for default in default_config_files()? {
208 cfg_sources.push_source(default, MustRead::TolerateAbsence);
209 }
210 }
211
212 for s in cmdline_toml_override_options {
213 cfg_sources.push_option(s);
214 }
215
216 Ok(cfg_sources)
217 }
218
219 pub fn push_source(&mut self, src: ConfigurationSource, must_read: MustRead) {
226 self.files.push((src, must_read));
227 }
228
229 pub fn push_option(&mut self, option: impl Into<String>) {
236 self.options.push(option.into());
237 }
238
239 pub fn options(&self) -> impl Iterator<Item = &String> + Clone {
243 self.options.iter()
244 }
245
246 pub fn set_mistrust(&mut self, mistrust: fs_mistrust::Mistrust) {
254 self.mistrust = mistrust;
255 }
256
257 pub fn mistrust(&self) -> &fs_mistrust::Mistrust {
265 &self.mistrust
266 }
267
268 pub fn load(&self) -> Result<ConfigurationTree, ConfigError> {
273 let files = self.scan()?;
274 files.load()
275 }
276
277 pub fn scan(&self) -> Result<FoundConfigFiles, ConfigError> {
279 let mut out = vec![];
280
281 for &(ref source, must_read) in &self.files {
282 let required = must_read == MustRead::MustRead;
283
284 let handle_io_error = |e: io::Error, p: &Path| {
287 if e.kind() == io::ErrorKind::NotFound && !required {
288 Result::<_, crate::ConfigError>::Ok(())
289 } else {
290 Err(crate::ConfigError::Io {
291 action: "reading",
292 path: p.to_owned(),
293 err: Arc::new(e),
294 })
295 }
296 };
297
298 use ConfigurationSource as CS;
299 match &source {
300 CS::Dir(dirname) => {
301 let dir = match fs::read_dir(dirname) {
302 Ok(y) => y,
303 Err(e) => {
304 handle_io_error(e, dirname.as_ref())?;
305 continue;
306 }
307 };
308 out.push(FoundConfigFile {
309 source: source.clone(),
310 must_read,
311 });
312 let mut entries = vec![];
314 for found in dir {
315 let found = match found {
318 Ok(y) => y,
319 Err(e) => {
320 handle_io_error(e, dirname.as_ref())?;
321 continue;
322 }
323 };
324 let leaf = found.file_name();
325 let leaf: &Path = leaf.as_ref();
326 match leaf.extension() {
327 Some(e) if e == "toml" => {}
328 _ => continue,
329 }
330 entries.push(found.path());
331 }
332 entries.sort();
333 out.extend(entries.into_iter().map(|path| FoundConfigFile {
334 source: CS::File(path),
335 must_read: MustRead::TolerateAbsence,
336 }));
337 }
338 CS::File(_) | CS::Verbatim(_) => {
339 out.push(FoundConfigFile {
340 source: source.clone(),
341 must_read,
342 });
343 }
344 }
345 }
346
347 Ok(FoundConfigFiles {
348 files: out,
349 sources: self,
350 })
351 }
352}
353
354impl FoundConfigFiles<'_> {
355 pub fn iter(&self) -> impl Iterator<Item = &ConfigurationSource> {
359 self.files.iter().map(|f| &f.source)
360 }
361
362 fn add_sources(self, mut builder: Figment) -> Result<Figment, ConfigError> {
365 use figment::providers::Format;
366
367 for FoundConfigFile { source, must_read } in self.files {
376 use ConfigurationSource as CS;
377
378 let required = must_read == MustRead::MustRead;
379
380 let file = match source {
381 CS::File(file) => file,
382 CS::Dir(_) => continue,
383 CS::Verbatim(text) => {
384 builder = builder.merge(figment::providers::Toml::string(&text));
385 continue;
386 }
387 };
388
389 match self
390 .sources
391 .mistrust
392 .verifier()
393 .permit_readable()
394 .check(&file)
395 {
396 Ok(()) => {}
397 Err(fs_mistrust::Error::NotFound(_)) if !required => {
398 continue;
399 }
400 Err(e) => return Err(ConfigError::FileAccess(e)),
401 }
402
403 let f = figment::providers::Toml::file_exact(file);
406 builder = builder.merge(f);
407 }
408
409 let mut cmdline = CmdLine::new();
410 for opt in &self.sources.options {
411 cmdline.push_toml_line(opt.clone());
412 }
413 builder = builder.merge(cmdline);
414
415 Ok(builder)
416 }
417
418 pub fn load(self) -> Result<ConfigurationTree, ConfigError> {
420 let mut builder = Figment::new();
421 builder = self.add_sources(builder)?;
422
423 Ok(ConfigurationTree(builder))
424 }
425}
426
427fn is_syntactically_directory(p: &Path) -> bool {
429 use std::path::Component as PC;
430
431 match p.components().next_back() {
432 None => false,
433 Some(PC::Prefix(_)) | Some(PC::RootDir) | Some(PC::CurDir) | Some(PC::ParentDir) => true,
434 Some(PC::Normal(_)) => {
435 let l = p.components().count();
437
438 let mut appended = OsString::from(p);
446 appended.push("a");
447 let l2 = PathBuf::from(appended).components().count();
448 l2 != l
449 }
450 }
451}
452
453#[cfg(test)]
454mod test {
455 #![allow(clippy::bool_assert_comparison)]
457 #![allow(clippy::clone_on_copy)]
458 #![allow(clippy::dbg_macro)]
459 #![allow(clippy::mixed_attributes_style)]
460 #![allow(clippy::print_stderr)]
461 #![allow(clippy::print_stdout)]
462 #![allow(clippy::single_char_pattern)]
463 #![allow(clippy::unwrap_used)]
464 #![allow(clippy::unchecked_time_subtraction)]
465 #![allow(clippy::useless_vec)]
466 #![allow(clippy::needless_pass_by_value)]
467 use super::*;
470 use itertools::Itertools;
471 use tempfile::tempdir;
472
473 static EX_TOML: &str = "
474[hello]
475world = \"stuff\"
476friends = 4242
477";
478
479 fn sources_nodefaults<P: AsRef<Path>>(
481 files: &[(P, MustRead)],
482 opts: &[String],
483 ) -> ConfigurationSources {
484 let mistrust = fs_mistrust::Mistrust::new_dangerously_trust_everyone();
485 let files = files
486 .iter()
487 .map(|(p, m)| (ConfigurationSource::from_path(p.as_ref()), *m))
488 .collect_vec();
489 let options = opts.iter().cloned().collect_vec();
490 ConfigurationSources {
491 files,
492 options,
493 mistrust,
494 }
495 }
496
497 fn load_nodefaults<P: AsRef<Path>>(
500 files: &[(P, MustRead)],
501 opts: &[String],
502 ) -> Result<ConfigurationTree, crate::ConfigError> {
503 sources_nodefaults(files, opts).load()
504 }
505
506 #[test]
507 fn non_required_file() {
508 let td = tempdir().unwrap();
509 let dflt = td.path().join("a_file");
510 let files = vec![(dflt, MustRead::TolerateAbsence)];
511 load_nodefaults(&files, Default::default()).unwrap();
512 }
513
514 static EX2_TOML: &str = "
515[hello]
516world = \"nonsense\"
517";
518
519 #[test]
520 fn both_required_and_not() {
521 let td = tempdir().unwrap();
522 let dflt = td.path().join("a_file");
523 let cf = td.path().join("other_file");
524 std::fs::write(&cf, EX2_TOML).unwrap();
525 let files = vec![(dflt, MustRead::TolerateAbsence), (cf, MustRead::MustRead)];
526 let c = load_nodefaults(&files, Default::default()).unwrap();
527
528 assert!(c.get_string("hello.friends").is_err());
529 assert_eq!(c.get_string("hello.world").unwrap(), "nonsense");
530 }
531
532 #[test]
533 fn dir_with_some() {
534 let td = tempdir().unwrap();
535 let cf = td.path().join("1.toml");
536 let d = td.path().join("extra.d/");
537 let df = d.join("2.toml");
538 let xd = td.path().join("nonexistent.d/");
539 std::fs::create_dir(&d).unwrap();
540 std::fs::write(&cf, EX_TOML).unwrap();
541 std::fs::write(df, EX2_TOML).unwrap();
542 std::fs::write(d.join("not-toml"), "SYNTAX ERROR").unwrap();
543
544 let files = vec![
545 (cf, MustRead::MustRead),
546 (d, MustRead::MustRead),
547 (xd.clone(), MustRead::TolerateAbsence),
548 ];
549 let c = sources_nodefaults(&files, Default::default());
550 let found = c.scan().unwrap();
551
552 assert_eq!(
553 found
554 .iter()
555 .map(|p| p
556 .as_path()
557 .unwrap()
558 .strip_prefix(&td)
559 .unwrap()
560 .to_str()
561 .unwrap())
562 .collect_vec(),
563 &["1.toml", "extra.d", "extra.d/2.toml"]
564 );
565
566 let c = found.load().unwrap();
567
568 assert_eq!(c.get_string("hello.friends").unwrap(), "4242");
569 assert_eq!(c.get_string("hello.world").unwrap(), "nonsense");
570
571 let files = vec![(xd, MustRead::MustRead)];
572 let e = load_nodefaults(&files, Default::default())
573 .unwrap_err()
574 .to_string();
575 assert!(dbg!(e).contains("nonexistent.d"));
576 }
577
578 #[test]
579 fn load_two_files_with_cmdline() {
580 let td = tempdir().unwrap();
581 let cf1 = td.path().join("a_file");
582 let cf2 = td.path().join("other_file");
583 std::fs::write(&cf1, EX_TOML).unwrap();
584 std::fs::write(&cf2, EX2_TOML).unwrap();
585 let v = vec![(cf1, MustRead::TolerateAbsence), (cf2, MustRead::MustRead)];
586 let v2 = vec!["other.var=present".to_string()];
587 let c = load_nodefaults(&v, &v2).unwrap();
588
589 assert_eq!(c.get_string("hello.friends").unwrap(), "4242");
590 assert_eq!(c.get_string("hello.world").unwrap(), "nonsense");
591 assert_eq!(c.get_string("other.var").unwrap(), "present");
592 }
593
594 #[test]
595 fn from_cmdline() {
596 let sources = ConfigurationSources::from_cmdline(
598 [ConfigurationSource::from_path("/etc/loid.toml")],
599 ["/family/yor.toml", "/family/anya.toml"],
600 ["decade=1960", "snack=peanuts"],
601 );
602 let files: Vec<_> = sources
603 .files
604 .iter()
605 .map(|file| file.0.as_path().unwrap().to_str().unwrap())
606 .collect();
607 assert_eq!(files, vec!["/family/yor.toml", "/family/anya.toml"]);
608 assert_eq!(sources.files[0].1, MustRead::MustRead);
609 assert_eq!(
610 &sources.options,
611 &vec!["decade=1960".to_owned(), "snack=peanuts".to_owned()]
612 );
613
614 let sources = ConfigurationSources::from_cmdline(
616 [ConfigurationSource::from_path("/etc/loid.toml")],
617 Vec::<PathBuf>::new(),
618 ["decade=1960", "snack=peanuts"],
619 );
620 assert_eq!(
621 &sources.files,
622 &vec![(
623 ConfigurationSource::from_path("/etc/loid.toml"),
624 MustRead::TolerateAbsence
625 )]
626 );
627 }
628
629 #[test]
630 fn dir_syntax() {
631 let chk = |tf, s: &str| assert_eq!(tf, is_syntactically_directory(s.as_ref()), "{:?}", s);
632
633 chk(false, "");
634 chk(false, "1");
635 chk(false, "1/2");
636 chk(false, "/1");
637 chk(false, "/1/2");
638
639 chk(true, "/");
640 chk(true, ".");
641 chk(true, "./");
642 chk(true, "..");
643 chk(true, "../");
644 chk(true, "/");
645 chk(true, "1/");
646 chk(true, "1/2/");
647 chk(true, "/1/");
648 chk(true, "/1/2/");
649 }
650}