header-config 0.1.7

Runtime parser for hierarchical configurations using Markdown-style headers
Documentation
#![deny(missing_docs)]

/*!
This library parses hierarchic configuration files in a markdown inspired format.
Therefore the `parse_config` function is provided.
**/

use header_parsing::{Line, Parser};
use indexmap::IndexMap;
use thiserror::Error;

use std::{
    fs::File,
    io::{BufRead, BufReader},
    path::Path,
};

/// Error if parsing fails.
#[derive(Debug, Error)]
pub enum Error {
    /// Opening file failed.
    #[error("Failed to open the configuration file")]
    OpeningFile,
    /// A subheader doesn't have a header of the right hierarchy level.
    #[error("Subheader found without a corresponding header")]
    SubheaderWithoutHeader,
    /// A key appears multiple times.
    #[error("Multiple values found for key: {0}")]
    MultipleKeys(Box<str>),
    /// Some input issue occured while reading the file.
    #[error("Input issues")]
    Input,
}

impl Error {
    /// Prints an error message suitable for the specified path.
    pub fn print_message(&self, path: &Path) {
        use Error::*;
        match self {
            OpeningFile => eprintln!("Opening file \"{}\" failed", path.display()),
            SubheaderWithoutHeader => {
                eprintln!(
                    "File \"{}\" has a subheader without a header",
                    path.display()
                )
            }
            MultipleKeys(key) => {
                eprintln!("Path \"{}\" has a duplicate of key {key}", path.display())
            }
            Input => eprintln!("Input error while reading \"{}\"", path.display()),
        }
    }
}

/// Parses the configuration file.
pub fn parse_config(path: &Path) -> Result<IndexMap<Box<str>, Box<str>>, Error> {
    let Ok(file) = File::open(path) else {
        return Err(Error::OpeningFile);
    };
    let reader = BufReader::new(file);

    let mut map = IndexMap::new();
    let mut parser = Parser::new();
    let mut prefix = String::new();

    for line in reader.lines() {
        let Ok(line) = line else {
            return Err(Error::Input);
        };
        parse_line(&mut map, &mut parser, &mut prefix, &line)?;
    }

    Ok(map)
}

/// Parses configuration held in a string, sharing the core with
/// [`parse_config`] for callers that already have the text in memory (for
/// example a `# Meta` block extracted from a larger file).
pub fn parse_config_str(text: &str) -> Result<IndexMap<Box<str>, Box<str>>, Error> {
    let mut map = IndexMap::new();
    let mut parser = Parser::new();
    let mut prefix = String::new();

    for line in text.lines() {
        parse_line(&mut map, &mut parser, &mut prefix, line)?;
    }

    Ok(map)
}

fn parse_line(
    map: &mut IndexMap<Box<str>, Box<str>>,
    parser: &mut Parser,
    prefix: &mut String,
    line: &str,
) -> Result<(), Error> {
    let Ok(parsed) = parser.line(line) else {
        return Err(Error::SubheaderWithoutHeader);
    };
    if let Line::Header { .. } = parsed {
        prefix.clear();
        for header in parser.path() {
            prefix.push_str(header);
            prefix.push(':');
        }
        return Ok(());
    }

    let line = line.trim();
    if line.is_empty() {
        return Ok(());
    }

    let (key, value) = line
        .split_once(char::is_whitespace)
        .map_or_else(|| (line, ""), |(key, value)| (key, value.trim_start()));

    let full_key = format!("{prefix}{key}").into();
    if map.contains_key(&full_key) {
        return Err(Error::MultipleKeys(full_key));
    }
    map.insert(full_key, value.into());
    Ok(())
}

#[cfg(test)]
mod tests {
    #![allow(clippy::unwrap_used)]

    use super::{Error, parse_config, parse_config_str};
    use std::fs::write;

    fn parse(name: &str, content: &str) -> Result<super::IndexMap<Box<str>, Box<str>>, Error> {
        let path = std::env::temp_dir().join(name);
        write(&path, content).unwrap();
        let result = parse_config(&path);
        let _ = std::fs::remove_file(path);
        result
    }

    #[test]
    fn flattens_nested_headers() {
        let map = parse(
            "header_config_nested.hc",
            "root value\n\n# A\n\nkey 1\n\n# B\n\nkey 2\n\n## Sub\n\ndeep 3\nempty\n",
        )
        .unwrap();

        assert_eq!(map.get("root").map(AsRef::as_ref), Some("value"));
        assert_eq!(map.get("A:key").map(AsRef::as_ref), Some("1"));
        assert_eq!(map.get("B:key").map(AsRef::as_ref), Some("2"));
        assert_eq!(map.get("B:Sub:deep").map(AsRef::as_ref), Some("3"));
        assert_eq!(map.get("B:Sub:empty").map(AsRef::as_ref), Some(""));
    }

    #[test]
    fn rejects_duplicate_keys() {
        let result = parse("header_config_dup.hc", "# A\n\nkey 1\nkey 2\n");
        assert!(matches!(result, Err(Error::MultipleKeys(key)) if &*key == "A:key"));
    }

    #[test]
    fn string_entry_point_matches_file() {
        let content = "root value\n\n# A\n\nkey 1\n\n## Sub\n\ndeep 3\nempty\n";
        let from_file = parse("header_config_str.hc", content).unwrap();
        let from_str = parse_config_str(content).unwrap();
        assert_eq!(from_file, from_str);
        assert_eq!(from_str.get("A:Sub:deep").map(AsRef::as_ref), Some("3"));
        assert_eq!(from_str.get("A:Sub:empty").map(AsRef::as_ref), Some(""));
    }

    #[test]
    fn rejects_orphan_subheader() {
        let result = parse("header_config_orphan.hc", "## Sub\n\nkey 1\n");
        assert!(matches!(result, Err(Error::SubheaderWithoutHeader)));
    }
}