pkgs-cli 0.2.0

A simple cli tool to manage packages.
Documentation
use std::fs;
use std::io;
use std::path::{Path, PathBuf};

use serde_yaml_ng::Error as YamlDeError;
use thiserror::Error;
use toml::de::Error as TomlDeError;

use super::Config;

#[derive(Debug, Error)]
pub enum ConfigError {
    #[error(transparent)]
    Io(#[from] io::Error),

    #[error("TOML parse error: {0}")]
    TomlParse(#[from] TomlDeError),

    #[error("YAML parse error: {0}")]
    YamlParse(#[from] YamlDeError),

    #[error("unsupported file format: {0}")]
    UnsupportedFileFormat(PathBuf),
}

impl Config {
    pub fn read(path: &Path) -> Result<Self, ConfigError> {
        let content = fs::read_to_string(path)?;

        match path.extension().and_then(|s| s.to_str()) {
            Some("toml") => Self::from_toml(&content).map_err(ConfigError::TomlParse),
            Some("yaml") | Some("yml") => Self::from_yaml(&content).map_err(ConfigError::YamlParse),
            _ => Err(ConfigError::UnsupportedFileFormat(path.to_path_buf())),
        }
    }

    pub fn from_toml(content: &str) -> Result<Self, TomlDeError> {
        toml::from_str(content)
    }

    pub fn from_yaml(content: &str) -> Result<Self, YamlDeError> {
        serde_yaml_ng::from_str(content)
    }
}

#[cfg(test)]
mod tests {
    use indoc::indoc;

    use super::*;
    use crate::test_utils::prelude::*;

    const TOML_CONTENT: &str = indoc! {r#"
        [vars]
        CONFIG_DIR = "${HOME}/.config"
        DESKTOP_DIR = "${HOME}/.local/share/applications"
        NU_AUTOLOAD = "${HOME}/.config/nu/autoload"
        A_VAR = "a value with ${CONFIG} inside"

        [packages.yazi]
        kind = "local"

        [packages.yazi.maps]
        yazi = "${CONFIG_DIR}/yazi"
        "yazi.nu" = "${NU_AUTOLOAD}/yazi.nu"

        [packages.kitty.maps]
        kitty = "${CONFIG_DIR}/kitty"
        "kitty.desktop" = "${DESKTOP_DIR}/kitty.desktop"

        [packages."empty maps"]
    "#};

    const YAML_CONTENT: &str = indoc! {r#"
        vars:
          CONFIG_DIR: ${HOME}/.config
          DESKTOP_DIR: ${HOME}/.local/share/applications
          NU_AUTOLOAD: ${HOME}/.config/nu/autoload
          A_VAR: a value with ${CONFIG} inside

        packages:
          yazi:
            kind: local
            maps:
              yazi: ${CONFIG_DIR}/yazi
              "yazi.nu": ${NU_AUTOLOAD}/yazi.nu

          kitty:
            maps:
              kitty: ${CONFIG_DIR}/kitty
              kitty.desktop: ${DESKTOP_DIR}/kitty.desktop

          empty maps: {}
    "#};

    mod parse {
        use super::*;

        #[gtest]
        fn toml_parse() {
            let config: Config = Config::from_toml(TOML_CONTENT).unwrap();
            validate_config(config);
        }

        #[gtest]
        fn yaml_parse() {
            let config: Config = Config::from_yaml(YAML_CONTENT).unwrap();
            validate_config(config);
        }

        #[gtest]
        fn deny_unknown_fields_toml() {
            let content = indoc! {r#"
                [packages.yazi]
                type = "local"
            "#};

            let err = Config::from_toml(content).unwrap_err();
            expect_that!(err, pat!(TomlDeError { .. }));
        }

        fn validate_config(config: Config) {
            let vars = config.vars;
            expect_eq!(
                vars,
                [
                    ("CONFIG_DIR".into(), "${HOME}/.config".into()),
                    (
                        "DESKTOP_DIR".into(),
                        "${HOME}/.local/share/applications".into()
                    ),
                    ("NU_AUTOLOAD".into(), "${HOME}/.config/nu/autoload".into()),
                    ("A_VAR".into(), "a value with ${CONFIG} inside".into()), // preserve order
                ]
            );

            expect_eq!(config.packages.len(), 3);

            expect_eq!(config.packages["yazi"].kind, PackageType::Local);
            expect_eq!(
                config.packages["yazi"].maps,
                [
                    ("yazi".into(), "${CONFIG_DIR}/yazi".into()),
                    ("yazi.nu".into(), "${NU_AUTOLOAD}/yazi.nu".into())
                ]
            );

            expect_eq!(config.packages["kitty"].kind, PackageType::Local);
            expect_eq!(
                config.packages["kitty"].maps,
                [
                    ("kitty".into(), "${CONFIG_DIR}/kitty".into()),
                    (
                        "kitty.desktop".into(),
                        "${DESKTOP_DIR}/kitty.desktop".into()
                    )
                ]
            );

            expect_eq!(config.packages["empty maps"].kind, PackageType::Local);
            expect_that!(config.packages["empty maps"].maps, is_empty());
        }
    }

    mod read {
        use tempfile::NamedTempFile;

        use super::*;

        fn setup(suffix: &str, content: &str) -> NamedTempFile {
            let file = NamedTempFile::with_suffix(suffix).unwrap();
            fs::write(file.path(), content).unwrap();
            file
        }

        #[gtest]
        fn read_toml() {
            let file = setup(".toml", TOML_CONTENT);
            let config = Config::read(file.path()).unwrap();
            expect_eq!(config.packages.len(), 3);
        }

        #[gtest]
        fn read_yaml() {
            let file = setup(".yaml", YAML_CONTENT);
            let config = Config::read(file.path()).unwrap();
            expect_eq!(config.packages.len(), 3);
        }

        #[gtest]
        fn read_yml() {
            let file = setup(".yml", YAML_CONTENT);
            let config = Config::read(file.path()).unwrap();
            expect_eq!(config.packages.len(), 3);
        }

        #[gtest]
        fn parse_error() {
            let file = setup(".toml", "invalid toml content");

            let err = Config::read(file.path()).unwrap_err();
            expect_that!(err, pat!(ConfigError::TomlParse(_)));
        }

        #[gtest]
        fn unsupported_file_format() {
            let file = setup(".ini", "");

            let err = Config::read(file.path()).unwrap_err();
            expect_that!(err, pat!(ConfigError::UnsupportedFileFormat(_)));
        }

        #[gtest]
        fn invalid_path() {
            let file = NamedTempFile::new().unwrap();
            let path = file.path().to_path_buf();
            drop(file);

            let err = Config::read(&path).unwrap_err();
            expect_that!(err, pat!(ConfigError::Io(_)));
        }
    }
}