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_duration_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}