taler-rust

GNU Taler code in Rust. Largely core banking integrations.
Log | Files | Refs | Submodules | README | LICENSE

config.rs (48203B)


      1 /*
      2   This file is part of TALER
      3   Copyright (C) 2025-2026 Taler Systems SA
      4 
      5   TALER is free software; you can redistribute it and/or modify it under the
      6   terms of the GNU Affero General Public License as published by the Free Software
      7   Foundation; either version 3, or (at your option) any later version.
      8 
      9   TALER is distributed in the hope that it will be useful, but WITHOUT ANY
     10   WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
     11   A PARTICULAR PURPOSE.  See the GNU Affero General Public License for more details.
     12 
     13   You should have received a copy of the GNU Affero General Public License along with
     14   TALER; see the file COPYING.  If not, see <http://www.gnu.org/licenses/>
     15 */
     16 
     17 use std::{
     18     borrow::Cow, fs::Permissions, os::unix::fs::PermissionsExt, path::PathBuf, str::FromStr,
     19     sync::Arc, time::Duration,
     20 };
     21 
     22 use compact_str::CompactString;
     23 use indexmap::IndexMap;
     24 use jiff::{SignedDuration, Span};
     25 use url::Url;
     26 
     27 use crate::types::{
     28     amount::{Amount, Currency},
     29     payto::PaytoURI,
     30     validate_base_url,
     31 };
     32 
     33 pub mod parser {
     34     use std::{
     35         borrow::Cow,
     36         fmt::Display,
     37         io::{BufRead, BufReader},
     38         path::PathBuf,
     39         str::FromStr,
     40         sync::Arc,
     41     };
     42 
     43     use indexmap::IndexMap;
     44     use tracing::{trace, warn};
     45 
     46     use super::Config;
     47     use crate::config::{CfgErr, Inner, Line, Location, make_lowercase};
     48 
     49     fn io_err(
     50         action: impl Into<Cow<'static, str>>,
     51         path: impl Into<PathBuf>,
     52         err: std::io::Error,
     53     ) -> CfgErr {
     54         CfgErr::IO(action.into(), path.into(), err)
     55     }
     56     fn line_err(
     57         msg: impl Into<Cow<'static, str>>,
     58         path: impl Into<PathBuf>,
     59         line: usize,
     60     ) -> CfgErr {
     61         CfgErr::Line(msg.into(), path.into(), line, None)
     62     }
     63     fn line_cause_err(
     64         msg: impl Into<Cow<'static, str>>,
     65         cause: impl Display,
     66         path: impl Into<PathBuf>,
     67         line: usize,
     68     ) -> CfgErr {
     69         CfgErr::Line(msg.into(), path.into(), line, Some(cause.to_string()))
     70     }
     71     pub struct Parser {
     72         sections: IndexMap<String, IndexMap<String, Line>>,
     73         files: Vec<PathBuf>,
     74         install_path: PathBuf,
     75         buf: String,
     76     }
     77 
     78     impl Parser {
     79         pub fn empty() -> Self {
     80             Self {
     81                 sections: IndexMap::new(),
     82                 files: Vec::new(),
     83                 install_path: PathBuf::new(),
     84                 buf: String::new(),
     85             }
     86         }
     87 
     88         pub fn load_env(&mut self, src: ConfigSource) -> Result<(), CfgErr> {
     89             let ConfigSource { project_name, .. } = src;
     90 
     91             // Load default path
     92             let dir = src
     93                 .install_path()
     94                 .map_err(|(p, e)| io_err("find installation path", p, e))?;
     95             self.install_path = dir.clone();
     96 
     97             let paths = IndexMap::from_iter(
     98                 [
     99                     ("PREFIX", dir.join("")),
    100                     ("BINDIR", dir.join("bin")),
    101                     ("LIBEXECDIR", dir.join(project_name).join("libexec")),
    102                     ("DOCDIR", dir.join("share").join("doc").join(project_name)),
    103                     ("ICONDIR", dir.join("bin").join("share").join("icons")),
    104                     ("LOCALEDIR", dir.join("share").join("locale")),
    105                     ("LIBDIR", dir.join("lib").join(project_name)),
    106                     ("DATADIR", dir.join("share").join(project_name)),
    107                 ]
    108                 .map(|(a, b)| {
    109                     (
    110                         a.to_owned(),
    111                         Line {
    112                             content: b.to_string_lossy().into_owned(),
    113                             loc: None,
    114                         },
    115                     )
    116                 }),
    117             );
    118             self.sections.insert("paths".to_owned(), paths);
    119 
    120             // Load default configs
    121             let cfg_dir = dir.join("share").join(project_name).join("config.d");
    122             match std::fs::read_dir(&cfg_dir) {
    123                 Ok(entries) => {
    124                     for entry in entries {
    125                         match entry {
    126                             Ok(entry) => self.parse_file(entry.path(), 0)?,
    127                             Err(err) => {
    128                                 warn!(target: "config", "{}", io_err("read base config directory", &cfg_dir, err));
    129                             }
    130                         }
    131                     }
    132                 }
    133                 Err(err) => {
    134                     warn!(target: "config", "{}", io_err("read base config directory", &cfg_dir, err))
    135                 }
    136             }
    137 
    138             Ok(())
    139         }
    140 
    141         pub fn parse_str(&mut self, str: &str) -> Result<(), CfgErr> {
    142             self.parse(
    143                 std::io::Cursor::new(str),
    144                 PathBuf::from_str("mem").unwrap(),
    145                 0,
    146             )
    147         }
    148 
    149         pub fn parse_file(&mut self, src: PathBuf, depth: u8) -> Result<(), CfgErr> {
    150             trace!(target: "config", "load file at '{}'", src.to_string_lossy());
    151             match std::fs::File::open(&src) {
    152                 Ok(file) => self.parse(BufReader::new(file), src, depth + 1),
    153                 Err(e) => Err(io_err("read config", src, e)),
    154             }
    155         }
    156 
    157         fn parse<B: BufRead>(
    158             &mut self,
    159             mut reader: B,
    160             src: PathBuf,
    161             depth: u8,
    162         ) -> Result<(), CfgErr> {
    163             let file = self.files.len();
    164             self.files.push(src.clone());
    165             let src = &src;
    166 
    167             let mut current_section: Option<&mut IndexMap<String, Line>> = None;
    168             let mut line = 0;
    169 
    170             loop {
    171                 // Read a new line
    172                 line += 1;
    173                 self.buf.clear();
    174                 match reader.read_line(&mut self.buf) {
    175                     Ok(0) => break,
    176                     Ok(_) => {}
    177                     Err(e) => return Err(io_err("read config", src, e)),
    178                 }
    179                 // Trim whitespace
    180                 let l = self.buf.trim_ascii();
    181 
    182                 if l.is_empty() || l.starts_with(['#', '%']) {
    183                     // Skip empty lines and comments
    184                     continue;
    185                 } else if let Some(directive) = l.strip_prefix("@") {
    186                     // Parse directive
    187                     let Some((name, arg)) = directive.split_once('@') else {
    188                         return Err(line_err(format!("invalid directive line '{l}'"), src, line));
    189                     };
    190                     let arg = arg.trim_ascii_start();
    191                     // Exit current section
    192                     current_section = None;
    193                     // Check current file has a parent
    194                     let Some(parent) = src.parent() else {
    195                         return Err(line_err("no parent", src, line));
    196                     };
    197                     // Check recursion depth
    198                     if depth > 128 {
    199                         return Err(line_err("recursion limit in config inlining", src, line));
    200                     }
    201 
    202                     match make_lowercase(name).as_ref() {
    203                         "inline" => self.parse_file(parent.join(arg), depth)?,
    204                         "inline-matching" => {
    205                             let paths =
    206                                 glob::glob(&parent.join(arg).to_string_lossy()).map_err(|e| {
    207                                     line_cause_err("malformed glob regex", e, src, line)
    208                                 })?;
    209                             for path in paths {
    210                                 let path =
    211                                     path.map_err(|e| line_cause_err("Glob error", e, src, line))?;
    212                                 self.parse_file(path, depth)?;
    213                             }
    214                         }
    215                         "inline-secret" => {
    216                             let (section, secret_file) = arg.split_once(" ").ok_or_else(||
    217                                 line_err(
    218                                     "invalid configuration, @inline-secret@ directive requires exactly two arguments",
    219                                     src,
    220                                     line
    221                                 )
    222                             )?;
    223 
    224                             let section = section.to_lowercase();
    225                             let mut secret_cfg = Parser::empty();
    226 
    227                             if let Err(e) = secret_cfg.parse_file(parent.join(secret_file), depth) {
    228                                 if let CfgErr::IO(_, path, err) = e {
    229                                     warn!(target: "config", "{}", io_err(format!("read secret section [{section}]"), &path, err))
    230                                 } else {
    231                                     return Err(e);
    232                                 }
    233                             } else if let Some(secret_section) =
    234                                 secret_cfg.sections.swap_remove(&section)
    235                             {
    236                                 self.sections
    237                                     .entry(section)
    238                                     .or_default()
    239                                     .extend(secret_section);
    240                             } else {
    241                                 warn!(target: "config", "{}", line_err(format!("configuration file at '{secret_file}' loaded with @inline-secret@ does not contain section [{section}]"), src, line));
    242                             }
    243                         }
    244                         unknown => {
    245                             return Err(line_err(
    246                                 format!("invalid directive '{unknown}'"),
    247                                 src,
    248                                 line,
    249                             ));
    250                         }
    251                     }
    252                 } else if let Some(section) = l.strip_prefix('[').and_then(|l| l.strip_suffix(']'))
    253                 {
    254                     current_section =
    255                         Some(self.sections.entry(section.to_lowercase()).or_default());
    256                 } else if let Some((name, value)) = l.split_once('=') {
    257                     if let Some(current_section) = &mut current_section {
    258                         // Trim whitespace
    259                         let name = name.trim_ascii_end().to_uppercase();
    260                         let value = value.trim_ascii_start();
    261                         // Escape value
    262                         let value =
    263                             if value.len() > 1 && value.starts_with('"') && value.ends_with('"') {
    264                                 &value[1..value.len() - 1]
    265                             } else {
    266                                 value
    267                             };
    268                         current_section.insert(
    269                             name,
    270                             Line {
    271                                 content: value.to_owned(),
    272                                 loc: Some(Location { file, line }),
    273                             },
    274                         );
    275                     } else {
    276                         return Err(line_err("expected section header or directive", src, line));
    277                     }
    278                 } else {
    279                     return Err(line_err(
    280                         "expected section header, option assignment or directive",
    281                         src,
    282                         line,
    283                     ));
    284                 }
    285             }
    286             Ok(())
    287         }
    288 
    289         /// Get a read-only shareable Config from the parser
    290         pub fn finish(self) -> Config {
    291             // Convert to a read-only config struct without location info
    292             Config(Arc::new(Inner {
    293                 sections: self.sections,
    294                 files: self.files,
    295                 install_path: self.install_path,
    296             }))
    297         }
    298     }
    299 
    300     /** Information about how the configuration is loaded */
    301     #[derive(Debug, Clone, Copy)]
    302     pub struct ConfigSource {
    303         /** Name of the high-level project */
    304         pub project_name: &'static str,
    305         /** Name of the component within the package */
    306         pub component_name: &'static str,
    307         /**
    308          * Executable name that will be located on $PATH to
    309          * find the installation path of the package
    310          */
    311         pub exec_name: &'static str,
    312     }
    313 
    314     impl ConfigSource {
    315         /// Create a new config source
    316         pub const fn new(
    317             project_name: &'static str,
    318             component_name: &'static str,
    319             exec_name: &'static str,
    320         ) -> Self {
    321             Self {
    322                 project_name,
    323                 component_name,
    324                 exec_name,
    325             }
    326         }
    327 
    328         /// Create a config source where the project, component and exec names are the same
    329         pub const fn simple(name: &'static str) -> Self {
    330             Self::new(name, name, name)
    331         }
    332 
    333         /**
    334          * Search the default configuration file path
    335          *
    336          * I will be the first existing file from this list:
    337          * - $XDG_CONFIG_HOME/$componentName.conf
    338          * - $HOME/.config/$componentName.conf
    339          * - /etc/$componentName.conf
    340          * - /etc/$projectName/$componentName.conf
    341          * */
    342         fn default_config_path(&self) -> Result<Option<PathBuf>, (PathBuf, std::io::Error)> {
    343             // TODO use a generator
    344             let conf_name = format!("{}.conf", self.component_name);
    345 
    346             if let Some(xdg) = std::env::var_os("XDG_CONFIG_HOME") {
    347                 let path = PathBuf::from(xdg).join(&conf_name);
    348                 match path.try_exists() {
    349                     Ok(false) => {}
    350                     Ok(true) => return Ok(Some(path)),
    351                     Err(e) => return Err((path, e)),
    352                 }
    353             }
    354 
    355             if let Some(home) = std::env::var_os("HOME") {
    356                 let path = PathBuf::from(home).join(".config").join(&conf_name);
    357                 match path.try_exists() {
    358                     Ok(false) => {}
    359                     Ok(true) => return Ok(Some(path)),
    360                     Err(e) => return Err((path, e)),
    361                 }
    362             }
    363 
    364             let path = PathBuf::from("/etc").join(&conf_name);
    365             match path.try_exists() {
    366                 Ok(false) => {}
    367                 Ok(true) => return Ok(Some(path)),
    368                 Err(e) => return Err((path, e)),
    369             }
    370 
    371             let path = PathBuf::from("/etc")
    372                 .join(self.project_name)
    373                 .join(&conf_name);
    374             match path.try_exists() {
    375                 Ok(false) => {}
    376                 Ok(true) => return Ok(Some(path)),
    377                 Err(e) => return Err((path, e)),
    378             }
    379 
    380             Ok(None)
    381         }
    382 
    383         /** Search for the binary installation path in PATH */
    384         fn install_path(&self) -> Result<PathBuf, (PathBuf, std::io::Error)> {
    385             let path_env = std::env::var("PATH").unwrap_or_default();
    386             for path_dir in path_env.split(':') {
    387                 let path_dir = PathBuf::from(path_dir);
    388                 let bin_path = path_dir.join(self.exec_name);
    389                 if bin_path.exists()
    390                     && let Some(parent) = path_dir.parent()
    391                 {
    392                     return parent.canonicalize().map_err(|e| (parent.to_path_buf(), e));
    393                 }
    394             }
    395             Ok(PathBuf::from("/usr"))
    396         }
    397     }
    398 
    399     impl Config {
    400         /// Load a config for a Taler component, optionally also load from a file.
    401         /// This is the standard way to load a Taler component config
    402         pub fn load(src: ConfigSource, path: Option<impl Into<PathBuf>>) -> Result<Config, CfgErr> {
    403             let mut parser = Parser::empty();
    404             parser.load_env(src)?;
    405             match path {
    406                 Some(path) => parser.parse_file(path.into(), 0)?,
    407                 None => {
    408                     if let Some(default) = src
    409                         .default_config_path()
    410                         .map_err(|(p, e)| io_err("find default config path", p, e))?
    411                     {
    412                         parser.parse_file(default, 0)?;
    413                     }
    414                 }
    415             }
    416             Ok(parser.finish())
    417         }
    418 
    419         /// Load config from an in memory string for testing
    420         pub fn from_mem(str: &str) -> Result<Config, CfgErr> {
    421             let mut parser = Parser::empty();
    422             parser.parse_str(str)?;
    423             Ok(parser.finish())
    424         }
    425 
    426         /// Load config from an in memory string with env from a Taler component for testing
    427         pub fn from_mem_with_env(src: ConfigSource, str: &str) -> Result<Config, CfgErr> {
    428             let mut parser = Parser::empty();
    429             parser.load_env(src)?;
    430             parser.parse_str(str)?;
    431             Ok(parser.finish())
    432         }
    433 
    434         /// Load a config for a Taler component, optionally also load from a file and an in memory string, for testing
    435         pub fn from_file_override(
    436             src: ConfigSource,
    437             path: Option<impl Into<PathBuf>>,
    438             str: &str,
    439         ) -> Result<Config, CfgErr> {
    440             let mut parser = Parser::empty();
    441             parser.load_env(src)?;
    442             match path {
    443                 Some(path) => {
    444                     parser.parse_file(path.into(), 0)?;
    445                 }
    446                 None => {
    447                     if let Some(default) = src
    448                         .default_config_path()
    449                         .map_err(|(p, e)| io_err("find default config path", p, e))?
    450                     {
    451                         parser.parse_file(default, 0)?;
    452                     }
    453                 }
    454             }
    455             parser.parse_str(str)?;
    456             Ok(parser.finish())
    457         }
    458     }
    459 }
    460 
    461 #[macro_export]
    462 macro_rules! config_bail {
    463     ($msg:literal $(,)?) => {
    464         return Err($crate::config::CfgErr::Custom(format!($msg)))
    465     };
    466     ($err:expr $(,)?) => {
    467         return Err($crate::config::CfgErr::Custom(format!($err)))
    468     };
    469     ($fmt:expr, $($arg:tt)*) => {
    470         return Err($crate::config::CfgErr::Custom(format!($fmt, $($arg)*)))
    471     };
    472 }
    473 
    474 /// DD102: will fail with exit code 6 and will not be restarted by systemd
    475 #[derive(Debug)]
    476 pub enum CfgErr {
    477     IO(Cow<'static, str>, PathBuf, std::io::Error),
    478     Line(Cow<'static, str>, PathBuf, usize, Option<String>),
    479     Missing {
    480         ty: String,
    481         section: String,
    482         option: String,
    483     },
    484     Invalid {
    485         ty: String,
    486         section: String,
    487         option: String,
    488         err: String,
    489     },
    490     Custom(String),
    491 }
    492 
    493 impl std::error::Error for CfgErr {}
    494 
    495 impl std::fmt::Display for CfgErr {
    496     fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
    497         match self {
    498             CfgErr::IO(action, path, err) => write!(
    499                 f,
    500                 "could not {action} at '{}': {}",
    501                 path.to_string_lossy(),
    502                 err.kind()
    503             ),
    504             CfgErr::Line(msg, path, line, cause) => {
    505                 if let Some(cause) = cause {
    506                     write!(f, "{msg} at '{}:{line}': {cause}", path.to_string_lossy())
    507                 } else {
    508                     write!(f, "{msg} at '{}:{line}'", path.to_string_lossy())
    509                 }
    510             }
    511             CfgErr::Missing {
    512                 ty,
    513                 section,
    514                 option,
    515             } => write!(f, "missing {ty} option {option} in section [{section}]"),
    516             CfgErr::Invalid {
    517                 ty,
    518                 section,
    519                 option,
    520                 err,
    521             } => write!(
    522                 f,
    523                 "invalid {ty} option {option} in section [{section}]: {err}"
    524             ),
    525             CfgErr::Custom(e) => e.fmt(f),
    526         }
    527     }
    528 }
    529 
    530 impl CfgErr {
    531     pub fn custom(cause: impl std::fmt::Display) -> Self {
    532         Self::Custom(cause.to_string())
    533     }
    534 }
    535 
    536 #[derive(Debug, thiserror::Error)]
    537 pub enum PathsubErr {
    538     #[error("recursion limit in path substitution exceeded for '{0}'")]
    539     Recursion(String),
    540     #[error("unbalanced variable expression '{0}'")]
    541     Unbalanced(String),
    542     #[error("bad substitution '{0}'")]
    543     Substitution(String),
    544     #[error("unbound variable '{0}'")]
    545     Unbound(String),
    546 }
    547 
    548 #[derive(Debug, Clone)]
    549 struct Location {
    550     file: usize,
    551     line: usize,
    552 }
    553 
    554 #[derive(Debug, Clone)]
    555 struct Line {
    556     content: String,
    557     loc: Option<Location>,
    558 }
    559 
    560 #[derive(Debug)]
    561 struct Inner {
    562     sections: IndexMap<String, IndexMap<String, Line>>,
    563     files: Vec<PathBuf>,
    564     install_path: PathBuf,
    565 }
    566 
    567 #[derive(Clone)]
    568 pub struct Config(Arc<Inner>);
    569 
    570 impl std::fmt::Debug for Config {
    571     fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
    572         self.0.fmt(f)
    573     }
    574 }
    575 
    576 fn make_lowercase<'a>(s: &'a str) -> Cow<'a, str> {
    577     if s.chars().all(|c| c.is_ascii_lowercase()) {
    578         Cow::Borrowed(s)
    579     } else {
    580         Cow::Owned(s.to_ascii_lowercase())
    581     }
    582 }
    583 
    584 fn make_uppercase<'a>(s: &'a str) -> Cow<'a, str> {
    585     if s.chars().all(|c| c.is_ascii_uppercase()) {
    586         Cow::Borrowed(s)
    587     } else {
    588         Cow::Owned(s.to_ascii_uppercase())
    589     }
    590 }
    591 
    592 impl Config {
    593     /// Get a config section from its name
    594     pub fn section<'cfg, 'arg>(&'cfg self, name: &'arg str) -> Section<'cfg, 'arg> {
    595         Section {
    596             name,
    597             config: self,
    598             values: self.0.sections.get(make_lowercase(name).as_ref()),
    599         }
    600     }
    601 
    602     /// List all config sections
    603     pub fn sections<'cfg>(&'cfg self) -> impl Iterator<Item = Section<'cfg, 'cfg>> {
    604         self.0.sections.iter().map(|(name, values)| Section {
    605             name,
    606             config: self,
    607             values: Some(values),
    608         })
    609     }
    610 
    611     /**
    612      * Substitute ${...} and $... placeholders in a string
    613      * with values from the PATHS section in the
    614      * configuration and environment variables
    615      *
    616      * This substitution is typically only done for paths.
    617      */
    618     pub fn pathsub(&self, str: &str, depth: u8) -> Result<String, PathsubErr> {
    619         if depth > 128 {
    620             return Err(PathsubErr::Recursion(str.to_owned()));
    621         } else if !str.contains('$') {
    622             return Ok(str.to_owned());
    623         }
    624 
    625         /** Lookup for variable value from PATHS section in the configuration and environment variables */
    626         fn lookup(cfg: &Config, name: &str, depth: u8) -> Option<Result<String, PathsubErr>> {
    627             if let Some(path_res) = cfg
    628                 .0
    629                 .sections
    630                 .get("paths")
    631                 .and_then(|section| section.get(make_uppercase(name).as_ref()))
    632             {
    633                 return Some(cfg.pathsub(&path_res.content, depth + 1));
    634             }
    635 
    636             if let Ok(val) = std::env::var(name) {
    637                 return Some(Ok(val));
    638             }
    639             None
    640         }
    641 
    642         let mut result = String::new();
    643         let mut remaining = str;
    644         loop {
    645             // Look for the next variable
    646             let Some((normal, value)) = remaining.split_once('$') else {
    647                 result.push_str(remaining);
    648                 return Ok(result);
    649             };
    650 
    651             // Append normal character
    652             result.push_str(normal);
    653             remaining = value;
    654 
    655             // Check if variable is enclosed
    656             let is_enclosed = if let Some(enclosed) = remaining.strip_prefix('{') {
    657                 // ${var
    658                 remaining = enclosed;
    659                 true
    660             } else {
    661                 false // $var
    662             };
    663 
    664             // Extract variable name
    665             let name_end = remaining
    666                 .find(|c: char| !c.is_alphanumeric() && c != '_')
    667                 .unwrap_or(remaining.len());
    668             let (name, after_name) = remaining.split_at(name_end);
    669 
    670             // Extract variable default if enclosed
    671             let default = if !is_enclosed {
    672                 remaining = after_name;
    673                 None
    674             } else if let Some(after_enclosed) = after_name.strip_prefix('}') {
    675                 // ${var}
    676                 remaining = after_enclosed;
    677                 None
    678             } else if let Some(default) = after_name.strip_prefix(":-") {
    679                 // ${var:-default}
    680                 let mut depth = 1;
    681                 let Some((default, after_default)) = default.split_once(|c| {
    682                     if c == '{' {
    683                         depth += 1;
    684                         false
    685                     } else if c == '}' {
    686                         depth -= 1;
    687                         depth == 0
    688                     } else {
    689                         false
    690                     }
    691                 }) else {
    692                     return Err(PathsubErr::Unbalanced(default.to_owned()));
    693                 };
    694                 remaining = after_default;
    695                 Some(default)
    696             } else {
    697                 return Err(PathsubErr::Substitution(after_name.to_owned()));
    698             };
    699             if let Some(resolved) = lookup(self, name, depth + 1) {
    700                 result.push_str(&resolved?);
    701                 continue;
    702             } else if let Some(default) = default {
    703                 let resolved = self.pathsub(default, depth + 1)?;
    704                 result.push_str(&resolved);
    705                 continue;
    706             }
    707             return Err(PathsubErr::Unbound(name.to_owned()));
    708         }
    709     }
    710 
    711     /// Print config in a human format, optionally with diagnostics information
    712     pub fn print(&self, mut f: impl std::io::Write, diagnostics: bool) -> std::io::Result<()> {
    713         let Inner {
    714             sections,
    715             files,
    716             install_path,
    717         } = self.0.as_ref();
    718         if diagnostics {
    719             writeln!(f, "#")?;
    720             writeln!(f, "# Configuration file diagnostics")?;
    721             writeln!(f, "#")?;
    722             writeln!(f, "# File Loaded:")?;
    723             for path in files {
    724                 writeln!(f, "# {}", path.to_string_lossy())?;
    725             }
    726             writeln!(f, "#")?;
    727             writeln!(f, "# Installation path: {}", install_path.to_string_lossy())?;
    728             writeln!(f, "#")?;
    729             writeln!(f)?;
    730         }
    731         for (sect, values) in sections {
    732             writeln!(f, "[{sect}]")?;
    733             if diagnostics {
    734                 writeln!(f)?;
    735             }
    736             for (key, Line { content, loc }) in values {
    737                 if diagnostics {
    738                     match loc {
    739                         Some(Location { file, line }) => {
    740                             let path = &files[*file];
    741                             writeln!(f, "# {}:{line}", path.to_string_lossy())?;
    742                         }
    743                         None => writeln!(f, "# default")?,
    744                     }
    745                 }
    746                 writeln!(f, "{key} = {content}")?;
    747                 if diagnostics {
    748                     writeln!(f)?;
    749                 }
    750             }
    751             writeln!(f)?;
    752         }
    753         Ok(())
    754     }
    755 }
    756 
    757 /** Accessor/Converter for Taler-like configuration sections */
    758 pub struct Section<'cfg, 'arg> {
    759     pub name: &'arg str,
    760     config: &'cfg Config,
    761     values: Option<&'cfg IndexMap<String, Line>>,
    762 }
    763 
    764 #[macro_export]
    765 macro_rules! map_config {
    766     ($self:expr, $ty:expr, $option:expr, $($key:expr => $parse:block),*$(,)?) => {
    767         {
    768             let keys = &[$($key,)*];
    769             $self.map($ty, $option, |value| {
    770                 match value {
    771                     $($key => {
    772                         (||Ok($parse))().map_err(|e| $crate::config::MapErr::Err(e))
    773                     })*,
    774                     _ => Err($crate::config::MapErr::Invalid(keys))
    775                 }
    776             })
    777         }
    778     }
    779 }
    780 
    781 pub use map_config;
    782 
    783 #[doc(hidden)]
    784 pub enum MapErr {
    785     Invalid(&'static [&'static str]),
    786     Err(CfgErr),
    787 }
    788 
    789 impl<'cfg, 'arg> Section<'cfg, 'arg> {
    790     #[doc(hidden)]
    791     fn inner<T>(
    792         &self,
    793         ty: &'arg str,
    794         option: &'arg str,
    795         transform: impl FnOnce(&'cfg str) -> Result<T, CfgErr>,
    796     ) -> Value<'arg, T> {
    797         let value = self
    798             .values
    799             .and_then(|m| m.get(make_uppercase(option).as_ref()))
    800             .filter(|it| !it.content.is_empty())
    801             .map(|raw| transform(&raw.content))
    802             .transpose();
    803         Value {
    804             value,
    805             option,
    806             ty,
    807             section: self.name,
    808         }
    809     }
    810 
    811     #[doc(hidden)]
    812     pub fn map<T>(
    813         &self,
    814         ty: &'arg str,
    815         option: &'arg str,
    816         transform: impl FnOnce(&'cfg str) -> Result<T, MapErr>,
    817     ) -> Value<'arg, T> {
    818         self.inner(ty, option, |v| {
    819             transform(v).map_err(|e| match e {
    820                 MapErr::Invalid(keys) => {
    821                     let mut buf = "expected '".to_owned();
    822                     match keys {
    823                         [] => unreachable!("you must provide at least one mapping"),
    824                         [unique] => buf.push_str(unique),
    825                         [first, other @ .., last] => {
    826                             buf.push_str(first);
    827                             for k in other {
    828                                 buf.push_str("', '");
    829                                 buf.push_str(k);
    830                             }
    831                             buf.push_str("' or '");
    832                             buf.push_str(last);
    833                         }
    834                     }
    835                     buf.push_str("' got '");
    836                     buf.push_str(v);
    837                     buf.push('\'');
    838                     CfgErr::Invalid {
    839                         ty: ty.to_owned(),
    840                         section: self.name.to_lowercase(),
    841                         option: option.to_uppercase(),
    842                         err: buf,
    843                     }
    844                 }
    845                 MapErr::Err(e) => e,
    846             })
    847         })
    848     }
    849 
    850     /** Setup an accessor/converted for a [type] at [option] using [transform] */
    851     pub fn value<T, E: std::fmt::Display>(
    852         &self,
    853         ty: &'arg str,
    854         option: &'arg str,
    855         transform: impl FnOnce(&'cfg str) -> Result<T, E>,
    856     ) -> Value<'arg, T> {
    857         self.inner(ty, option, |v| {
    858             transform(v).map_err(|e| CfgErr::Invalid {
    859                 ty: ty.to_owned(),
    860                 section: self.name.to_lowercase(),
    861                 option: option.to_uppercase(),
    862                 err: e.to_string(),
    863             })
    864         })
    865     }
    866 
    867     /** Access [option] as a parsable type */
    868     pub fn parse<E: std::fmt::Display, T: FromStr<Err = E>>(
    869         &self,
    870         ty: &'arg str,
    871         option: &'arg str,
    872     ) -> Value<'arg, T> {
    873         self.value(ty, option, |it| it.parse::<T>().map_err(|e| e.to_string()))
    874     }
    875 
    876     /** Access [option] as str */
    877     pub fn str(&self, option: &'arg str) -> Value<'arg, String> {
    878         self.value("string", option, |it| Ok::<_, &str>(it.to_owned()))
    879     }
    880 
    881     /** Access [option] as compact str */
    882     pub fn cstr(&self, option: &'arg str) -> Value<'arg, CompactString> {
    883         self.value("string", option, |it| Ok::<_, CompactString>(it.into()))
    884     }
    885 
    886     /** Access [option] as hex encode bytes */
    887     pub fn hex(&self, option: &'arg str) -> Value<'arg, Vec<u8>> {
    888         self.value("hex", option, |it| {
    889             crate::encoding::hex::decode(it.as_bytes())
    890         })
    891     }
    892 
    893     /** Access [option] as base32 encode bytes */
    894     pub fn b32(&self, option: &'arg str) -> Value<'arg, Vec<u8>> {
    895         self.value("b32", option, |it| {
    896             crate::encoding::base32::decode(it.as_bytes())
    897         })
    898     }
    899 
    900     /** Access [option] as base64 encode bytes */
    901     pub fn b64(&self, option: &'arg str) -> Value<'arg, Vec<u8>> {
    902         self.value("b64", option, |it| {
    903             crate::encoding::base64::decode(it.as_bytes())
    904         })
    905     }
    906 
    907     /** Access [option] as path */
    908     pub fn path(&self, option: &'arg str) -> Value<'arg, String> {
    909         self.value("path", option, |it| self.config.pathsub(it, 0))
    910     }
    911 
    912     /** Access [option] as UNIX permissions */
    913     pub fn unix_mode(&self, option: &'arg str) -> Value<'arg, Permissions> {
    914         self.value("unix mode", option, |it| {
    915             u32::from_str_radix(it, 8)
    916                 .map(Permissions::from_mode)
    917                 .map_err(|_| format!("'{it}' not a valid number"))
    918         })
    919     }
    920 
    921     /** Access [option] as a number */
    922     pub fn number<T: FromStr>(&self, option: &'arg str) -> Value<'arg, T> {
    923         self.value("number", option, |it| {
    924             it.parse::<T>()
    925                 .map_err(|_| format!("'{it}' not a valid number"))
    926         })
    927     }
    928 
    929     /** Access [option] as Boolean */
    930     pub fn boolean(&self, option: &'arg str) -> Value<'arg, bool> {
    931         self.value("boolean", option, |it| match it.to_uppercase().as_str() {
    932             "YES" => Ok(true),
    933             "NO" => Ok(false),
    934             _ => Err(format!("expected 'YES' or 'NO' got '{it}'")),
    935         })
    936     }
    937 
    938     /** Access [option] as a Currency */
    939     pub fn currency(&self, option: &'arg str) -> Value<'arg, Currency> {
    940         self.parse("currency", option)
    941     }
    942 
    943     /** Access [option] as an Amount */
    944     pub fn amount(&self, option: &'arg str, currency: &Currency) -> Value<'arg, Amount> {
    945         self.value("amount", option, |it| {
    946             let amount = it.parse::<Amount>().map_err(|e| e.to_string())?;
    947             if amount.currency != *currency {
    948                 return Err(format!(
    949                     "expected currency {currency} got {}",
    950                     amount.currency
    951                 ));
    952             }
    953             Ok(amount)
    954         })
    955     }
    956 
    957     /** Access [option] as url */
    958     pub fn url(&self, option: &'arg str) -> Value<'arg, Url> {
    959         self.parse("url", option)
    960     }
    961 
    962     /** Access [option] as base url */
    963     pub fn base_url(&self, option: &'arg str) -> Value<'arg, Url> {
    964         self.value("url", option, |s| {
    965             let url = Url::from_str(s).map_err(|e| e.to_string())?;
    966             validate_base_url(&url)?;
    967             Ok::<_, String>(url)
    968         })
    969     }
    970 
    971     /** Access [option] as payto */
    972     pub fn payto(&self, option: &'arg str) -> Value<'arg, PaytoURI> {
    973         self.parse("payto", option)
    974     }
    975 
    976     /** Access [option] as Postgres URI */
    977     pub fn postgres(&self, option: &'arg str) -> Value<'arg, sqlx::postgres::PgConnectOptions> {
    978         self.parse("Postgres URI", option)
    979     }
    980 
    981     /** Access [option] as a timestamp */
    982     pub fn timestamp(&self, option: &'arg str) -> Value<'arg, jiff::Timestamp> {
    983         self.parse("Timestamp", option)
    984     }
    985 
    986     /** Access [option] as a time */
    987     pub fn time(&self, option: &'arg str) -> Value<'arg, jiff::civil::Time> {
    988         self.parse("Time", option)
    989     }
    990 
    991     /** Access [option] as a date */
    992     pub fn date(&self, option: &'arg str) -> Value<'arg, jiff::civil::Date> {
    993         self.parse("Date", option)
    994     }
    995 
    996     /** Access [option] as a duration */
    997     pub fn duration(&self, option: &'arg str) -> Value<'arg, Duration> {
    998         self.value("temporal", option, |it| {
    999             let tmp = SignedDuration::from_str(it).map_err(|e| e.to_string())?;
   1000             Ok::<_, String>(Duration::from_millis(tmp.as_millis() as u64))
   1001         })
   1002     }
   1003 
   1004     /** Access [option] as a duration */
   1005     pub fn span(&self, option: &'arg str) -> Value<'arg, Span> {
   1006         self.parse("temporal", option)
   1007     }
   1008 
   1009     /** Access [option] as a regex */
   1010     pub fn regex(&self, option: &'arg str) -> Value<'arg, regex::Regex> {
   1011         self.parse("Pattern", option)
   1012     }
   1013 
   1014     /** Access option as json object */
   1015     pub fn json<'de, T: serde::Deserialize<'de>>(&'de self, option: &'arg str) -> Value<'arg, T> {
   1016         self.value("json", option, |it| serde_json::from_str(it))
   1017     }
   1018 }
   1019 
   1020 pub struct Value<'arg, T> {
   1021     value: Result<Option<T>, CfgErr>,
   1022     option: &'arg str,
   1023     ty: &'arg str,
   1024     section: &'arg str,
   1025 }
   1026 
   1027 impl<T> Value<'_, T> {
   1028     pub fn opt(self) -> Result<Option<T>, CfgErr> {
   1029         self.value
   1030     }
   1031 
   1032     /** Converted value of default if missing */
   1033     pub fn default(self, default: T) -> Result<T, CfgErr> {
   1034         Ok(self.value?.unwrap_or(default))
   1035     }
   1036 
   1037     /** Converted value or throw if missing */
   1038     pub fn require(self) -> Result<T, CfgErr> {
   1039         self.value?.ok_or_else(|| CfgErr::Missing {
   1040             ty: self.ty.to_owned(),
   1041             section: self.section.to_lowercase(),
   1042             option: self.option.to_uppercase(),
   1043         })
   1044     }
   1045 }
   1046 
   1047 #[cfg(test)]
   1048 mod test {
   1049     use std::{
   1050         fmt::{Debug, Display},
   1051         fs::{File, Permissions},
   1052         os::unix::fs::PermissionsExt,
   1053     };
   1054 
   1055     use tracing::error;
   1056 
   1057     use super::{Config, Section, Value};
   1058     use crate::{
   1059         config::parser::ConfigSource,
   1060         types::amount::{self, Currency},
   1061     };
   1062 
   1063     const SOURCE: ConfigSource = ConfigSource::new("test", "test", "test");
   1064 
   1065     #[track_caller]
   1066     fn check_err<T: Debug, E: Display>(err: impl AsRef<str>, lambda: Result<T, E>) {
   1067         let failure = lambda.unwrap_err();
   1068         let fmt = failure.to_string();
   1069         assert_eq!(err.as_ref(), fmt);
   1070     }
   1071 
   1072     /// [`check_err`] for messages with an environment-dependent middle: only
   1073     /// the head and the tail are compared.
   1074     #[track_caller]
   1075     fn check_err_loose<T: Debug, E: Display>(
   1076         head: impl AsRef<str>,
   1077         tail: impl AsRef<str>,
   1078         lambda: Result<T, E>,
   1079     ) {
   1080         let failure = lambda.unwrap_err();
   1081         let fmt = failure.to_string();
   1082         let (head, tail) = (head.as_ref(), tail.as_ref());
   1083         assert!(
   1084             fmt.starts_with(head) && fmt.ends_with(tail),
   1085             "expected an error starting with '{head}' and ending with '{tail}', got '{fmt}'"
   1086         );
   1087     }
   1088 
   1089     #[test]
   1090     fn fs() {
   1091         let dir = tempfile::tempdir().unwrap();
   1092         let config_path = dir.path().join("test-conf.conf");
   1093         let second_path = dir.path().join("test-second-conf.conf");
   1094 
   1095         let config_path_fmt = config_path.to_string_lossy();
   1096         let second_path_fmt = second_path.to_string_lossy();
   1097 
   1098         let check_err = |err: String| check_err(err, Config::load(SOURCE, Some(&config_path)));
   1099         let check_ok = || Config::load(SOURCE, Some(&config_path)).unwrap();
   1100 
   1101         check_err(format!(
   1102             "could not read config at '{config_path_fmt}': entity not found"
   1103         ));
   1104 
   1105         let config_file = std::fs::File::create_new(&config_path).unwrap();
   1106         config_file
   1107             .set_permissions(Permissions::from_mode(0o222))
   1108             .unwrap();
   1109         if File::open(&config_path).is_ok() {
   1110             error!("Cannot finish this test if root");
   1111             return;
   1112         }
   1113         check_err(format!(
   1114             "could not read config at '{config_path_fmt}': permission denied"
   1115         ));
   1116 
   1117         config_file
   1118             .set_permissions(Permissions::from_mode(0o666))
   1119             .unwrap();
   1120         check_ok();
   1121         std::fs::write(&config_path, "@inline@ test-second-conf.conf").unwrap();
   1122         check_err(format!(
   1123             "could not read config at '{second_path_fmt}': entity not found"
   1124         ));
   1125 
   1126         let second_file = std::fs::File::create_new(&second_path).unwrap();
   1127         second_file
   1128             .set_permissions(Permissions::from_mode(0o222))
   1129             .unwrap();
   1130         check_err(format!(
   1131             "could not read config at '{second_path_fmt}': permission denied"
   1132         ));
   1133 
   1134         std::fs::write(&config_path, "@inline-matching@[*").unwrap();
   1135         // glob reports the offset of the '[' within the *expanded* pattern
   1136         // (<tempdir>/[*), so the position moves with the length of $TMPDIR.
   1137         check_err_loose(
   1138             format!(
   1139                 "malformed glob regex at '{config_path_fmt}:1': Pattern syntax error near position "
   1140             ),
   1141             ": invalid range pattern",
   1142             Config::load(SOURCE, Some(&config_path)),
   1143         );
   1144 
   1145         std::fs::write(&config_path, "@inline-matching@*second-conf.conf").unwrap();
   1146         check_err(format!(
   1147             "could not read config at '{second_path_fmt}': permission denied"
   1148         ));
   1149 
   1150         std::fs::write(&config_path, "\n@inline-matching@*.conf").unwrap();
   1151         check_err(format!(
   1152             "recursion limit in config inlining at '{config_path_fmt}:2'"
   1153         ));
   1154         std::fs::write(&config_path, "\n\n@inline-matching@ *.conf").unwrap();
   1155         check_err(format!(
   1156             "recursion limit in config inlining at '{config_path_fmt}:3'"
   1157         ));
   1158 
   1159         std::fs::write(&config_path, "@inline-secret@ secret test-second-conf.conf").unwrap();
   1160         check_ok();
   1161     }
   1162 
   1163     #[test]
   1164     fn parsing() {
   1165         let check = |err: &str, content: &str| check_err(err, Config::from_mem(content));
   1166 
   1167         check(
   1168             "expected section header, option assignment or directive at 'mem:1'",
   1169             "syntax error",
   1170         );
   1171         check(
   1172             "expected section header or directive at 'mem:1'",
   1173             "key=value",
   1174         );
   1175         check(
   1176             "expected section header, option assignment or directive at 'mem:2'",
   1177             "[section]\nbad-line",
   1178         );
   1179 
   1180         let cfg = Config::from_mem(
   1181             r#"
   1182 
   1183         [section-a]
   1184 
   1185         bar = baz
   1186 
   1187         [section-b]
   1188 
   1189         first_value = 1
   1190         second_value = "test"
   1191 
   1192         "#,
   1193         )
   1194         .unwrap();
   1195 
   1196         // Missing section
   1197         check_err(
   1198             "missing string option VALUE in section [unknown]",
   1199             cfg.section("unknown").str("value").require(),
   1200         );
   1201 
   1202         // Missing value
   1203         check_err(
   1204             "missing string option VALUE in section [section-a]",
   1205             cfg.section("section-a").str("value").require(),
   1206         );
   1207     }
   1208 
   1209     #[allow(clippy::type_complexity)]
   1210     fn routine<T: Debug + Eq>(
   1211         ty: &str,
   1212         mut lambda: impl for<'cfg, 'arg> FnMut(&Section<'cfg, 'arg>, &'arg str) -> Value<'arg, T>,
   1213         wellformed: &[(&[&str], T)],
   1214         malformed: &[(&[&str], fn(&str) -> String)],
   1215     ) {
   1216         let conf = |content: &str| {
   1217             Config::from_mem(&format!(
   1218                 "[PATHS]\nDATADIR=mydir\nRECURSIVE=$RECURSIVE\n{content}"
   1219             ))
   1220             .unwrap()
   1221         };
   1222 
   1223         // Check missing msg
   1224         let cfg = conf("");
   1225         check_err(
   1226             format!("missing {ty} option VALUE in section [section]"),
   1227             lambda(&cfg.section("section"), "value").require(),
   1228         );
   1229 
   1230         // Check wellformed options are properly parsed
   1231         for (raws, expected) in wellformed {
   1232             for raw in *raws {
   1233                 let cfg = conf(&format!("[section]\nvalue={raw}"));
   1234                 dbg!(&cfg);
   1235                 assert_eq!(
   1236                     *expected,
   1237                     lambda(&cfg.section("section"), "value").require().unwrap()
   1238                 );
   1239             }
   1240         }
   1241 
   1242         // Check malformed options have proper error message
   1243         for (raws, error_fmt) in malformed {
   1244             for raw in *raws {
   1245                 let cfg = conf(&format!("[section]\nvalue={raw}"));
   1246                 check_err(
   1247                     format!(
   1248                         "invalid {ty} option VALUE in section [section]: {}",
   1249                         error_fmt(raw)
   1250                     ),
   1251                     lambda(&cfg.section("section"), "value").require(),
   1252                 )
   1253             }
   1254         }
   1255     }
   1256 
   1257     #[test]
   1258     fn string() {
   1259         routine(
   1260             "string",
   1261             |sect, value| sect.str(value),
   1262             &[
   1263                 (&["1", "\"1\""], "1".to_owned()),
   1264                 (&["test", "\"test\""], "test".to_owned()),
   1265                 (&["\""], "\"".to_owned()),
   1266             ],
   1267             &[],
   1268         );
   1269     }
   1270 
   1271     #[test]
   1272     fn path() {
   1273         routine(
   1274             "path",
   1275             |sect, value| sect.path(value),
   1276             &[
   1277                 (&["path"], "path".to_owned()),
   1278                 (
   1279                     &["foo/$DATADIR/bar", "foo/${DATADIR}/bar"],
   1280                     "foo/mydir/bar".to_owned(),
   1281                 ),
   1282                 (
   1283                     &["foo/$DATADIR$DATADIR/bar"],
   1284                     "foo/mydirmydir/bar".to_owned(),
   1285                 ),
   1286                 (
   1287                     &["foo/pre_$DATADIR/bar", "foo/pre_${DATADIR}/bar"],
   1288                     "foo/pre_mydir/bar".to_owned(),
   1289                 ),
   1290                 (
   1291                     &[
   1292                         "foo/${DATADIR}_next/bar",
   1293                         "foo/${UNKNOWN:-$DATADIR}_next/bar",
   1294                     ],
   1295                     "foo/mydir_next/bar".to_owned(),
   1296                 ),
   1297                 (
   1298                     &[
   1299                         "foo/${UNKNOWN:-default}_next/bar",
   1300                         "foo/${UNKNOWN:-${UNKNOWN:-default}}_next/bar",
   1301                     ],
   1302                     "foo/default_next/bar".to_owned(),
   1303                 ),
   1304                 (
   1305                     &["foo/${UNKNOWN:-pre_${UNKNOWN:-default}_next}_next/bar"],
   1306                     "foo/pre_default_next_next/bar".to_owned(),
   1307                 ),
   1308             ],
   1309             &[
   1310                 (&["foo/${A/bar"], |_| "bad substitution '/bar'".to_owned()),
   1311                 (&["foo/${A:-pre_${B}/bar"], |_| {
   1312                     "unbalanced variable expression 'pre_${B}/bar'".to_owned()
   1313                 }),
   1314                 (&["foo/${A:-${B${C}/bar"], |_| {
   1315                     "unbalanced variable expression '${B${C}/bar'".to_owned()
   1316                 }),
   1317                 (&["foo/$UNKNOWN/bar", "foo/${UNKNOWN}/bar"], |_| {
   1318                     "unbound variable 'UNKNOWN'".to_owned()
   1319                 }),
   1320                 (&["foo/$RECURSIVE/bar"], |_| {
   1321                     "recursion limit in path substitution exceeded for '$RECURSIVE'".to_owned()
   1322                 }),
   1323             ],
   1324         )
   1325     }
   1326 
   1327     #[test]
   1328     fn number() {
   1329         routine(
   1330             "number",
   1331             |sect, value| sect.number(value),
   1332             &[(&["1"], 1), (&["42"], 42)],
   1333             &[(&["true", "YES"], |it| format!("'{it}' not a valid number"))],
   1334         );
   1335     }
   1336 
   1337     #[test]
   1338     fn boolean() {
   1339         routine(
   1340             "boolean",
   1341             |sect, value| sect.boolean(value),
   1342             &[(&["yes", "YES", "Yes"], true), (&["no", "NO", "No"], false)],
   1343             &[(&["true", "1"], |it| {
   1344                 format!("expected 'YES' or 'NO' got '{it}'")
   1345             })],
   1346         );
   1347     }
   1348 
   1349     #[test]
   1350     fn amount() {
   1351         routine(
   1352             "amount",
   1353             |sect, value| sect.amount(value, &Currency::KUDOS),
   1354             &[(
   1355                 &["KUDOS:12", "KUDOS:12.0", "KUDOS:012.0"],
   1356                 amount::amount("KUDOS:12"),
   1357             )],
   1358             &[
   1359                 (&["test", "42"], |it| {
   1360                     format!("amount '{it}' invalid format")
   1361                 }),
   1362                 (&["KUDOS:0.3ABC"], |it| {
   1363                     format!("amount '{it}' invalid fraction (invalid digit found in string)")
   1364                 }),
   1365                 (&["KUDOS:999999999999999999"], |it| {
   1366                     format!("amount '{it}' value overflow (must be <= 4503599627370496)")
   1367                 }),
   1368                 (&["EUR:12"], |_| {
   1369                     "expected currency KUDOS got EUR".to_owned()
   1370                 }),
   1371             ],
   1372         )
   1373     }
   1374 
   1375     #[test]
   1376     fn map() {
   1377         #[derive(Debug, PartialEq, Eq)]
   1378         enum Mode {
   1379             Tcp(u16),
   1380             Unix,
   1381             Systemd,
   1382         }
   1383 
   1384         fn parse<'cfg, 'arg>(sect: &Section<'cfg, 'arg>, value: &'arg str) -> Value<'arg, Mode> {
   1385             map_config!(sect, "mode", value,
   1386                 "tcp" => { Mode::Tcp(sect.number("PORT").require()?) },
   1387                 "unix" => { Mode::Unix },
   1388                 "systemd" => { Mode::Systemd },
   1389             )
   1390         }
   1391 
   1392         routine(
   1393             "mode",
   1394             parse,
   1395             &[(&["unix"], Mode::Unix), (&["systemd"], Mode::Systemd)],
   1396             &[(&["udp", "TCP"], |it| {
   1397                 format!("expected 'tcp', 'unix' or 'systemd' got '{it}'")
   1398             })],
   1399         );
   1400 
   1401         let cfg = Config::from_mem("[section]\nvalue=tcp").unwrap();
   1402 
   1403         check_err(
   1404             "missing number option PORT in section [section]",
   1405             parse(&cfg.section("section"), "value").require(),
   1406         );
   1407 
   1408         check_err(
   1409             "invalid mode option VALUE in section [section]: expected 'unix' got 'tcp'",
   1410             map_config!(&cfg.section("section"), "mode", "value",
   1411                 "unix" => { Mode::Unix },
   1412             )
   1413             .require(),
   1414         );
   1415     }
   1416 }