tool-sync 0.2.0

tool-sync is a CLI tool that manages installation of other CLI tools from GitHub Releases by downloading binaries and storing them in a local directory.
Documentation
use std::collections::BTreeMap;
use std::fmt::{Display, Formatter};
use std::fs;
use std::path::PathBuf;
use toml::{map::Map, Value};

use crate::config::schema::{Config, ConfigAsset};
use crate::infra::err;
use crate::model::asset_name::AssetName;

#[derive(Debug, PartialEq, Eq)]
pub enum TomlError {
    IO(String),
    Parse(toml::de::Error),
    Decode,
}

impl Display for TomlError {
    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
        match self {
            TomlError::IO(e) => write!(f, "[IO Error] {}", e),
            TomlError::Parse(e) => write!(f, "[Parsing Error] {}", e),
            TomlError::Decode => write!(f, "[Decode Error]"),
        }
    }
}

pub fn with_parsed_file<F: FnOnce(Config)>(config_path: PathBuf, on_success: F) {
    match parse_file(&config_path) {
        Ok(config) => {
            on_success(config);
        }
        Err(e) => {
            err::abort_with(format!(
                "Error parsing configuration at path {}: {}",
                config_path.display(),
                e
            ));
        }
    }
}

fn parse_file(config_path: &PathBuf) -> Result<Config, TomlError> {
    let contents = fs::read_to_string(config_path).map_err(|e| TomlError::IO(format!("{}", e)))?;

    parse_string(&contents)
}

fn parse_string(contents: &str) -> Result<Config, TomlError> {
    contents
        .parse::<Value>()
        .map_err(TomlError::Parse)
        .and_then(|toml| match decode_config(toml) {
            None => Err(TomlError::Decode),
            Some(config) => Ok(config),
        })
}

fn decode_config(toml: Value) -> Option<Config> {
    let str_store_directory = toml.get("store_directory")?.as_str()?;
    let store_directory = String::from(str_store_directory);

    let mut tools = BTreeMap::new();

    for (key, val) in toml.as_table()?.iter() {
        if let Value::Table(table) = val {
            tools.insert(key.clone(), decode_config_asset(table));
        }
    }

    Some(Config {
        store_directory,
        tools,
    })
}

fn decode_config_asset(table: &Map<String, Value>) -> ConfigAsset {
    let owner = str_by_key(table, "owner");
    let repo = str_by_key(table, "repo");
    let exe_name = str_by_key(table, "exe_name");
    let asset_name = decode_asset_name(table);
    let tag = str_by_key(table, "tag");

    ConfigAsset {
        owner,
        repo,
        exe_name,
        asset_name,
        tag,
    }
}

fn decode_asset_name(table: &Map<String, Value>) -> AssetName {
    match table.get("asset_name").and_then(|t| t.as_table()) {
        None => AssetName {
            linux: None,
            macos: None,
            windows: None,
        },

        Some(table) => {
            let linux = str_by_key(table, "linux");
            let macos = str_by_key(table, "macos");
            let windows = str_by_key(table, "windows");

            AssetName {
                linux,
                macos,
                windows,
            }
        }
    }
}

fn str_by_key(table: &Map<String, Value>, key: &str) -> Option<String> {
    table.get(key).and_then(|v| v.as_str()).map(String::from)
}

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

    #[test]
    fn test_toml_error_display_io() {
        let toml_error = TomlError::IO(String::from("some file error!"));

        assert_eq!(
            String::from("[IO Error] some file error!"),
            toml_error.to_string()
        );
    }

    #[test]
    fn test_toml_error_display_parse() {
        let broken_toml_str: String = "broken toml".into();
        match parse_string(&broken_toml_str) {
            Err(error) => {
                assert_eq!(
                    String::from(
                        "[Parsing Error] expected an equals, found an identifier at line 1 column 8"
                    ),
                    error.to_string()
                );
            }
            Ok(_) => unreachable!(),
        };
    }

    #[test]
    fn test_toml_error_display_decode() {
        let toml_error = TomlError::Decode;
        assert_eq!(String::from("[Decode Error]"), toml_error.to_string());
    }

    #[test]
    fn test_parse_file_correct_output() {
        let result = std::panic::catch_unwind(|| {
            let test_config_path = PathBuf::from("tests/sync-full.toml");
            parse_file(&test_config_path).expect("This should not fail")
        });

        if let Ok(config) = result {
            assert_eq!(String::from("sync-full"), config.store_directory);
        };
    }

    #[test]
    fn test_parse_file_error() {
        let test_config_path = PathBuf::from("src/main.rs");
        match parse_file(&test_config_path) {
            Ok(_) => {
                assert!(false, "Unexpected succces")
            }
            Err(_) => {
                assert!(true, "Exepected a parsing error")
            }
        };
    }

    #[test]
    fn empty_file() {
        let toml = "";
        let res = parse_string(toml);

        assert_eq!(res, Err(TomlError::Decode));
    }

    #[test]
    fn store_directory_is_dotted() {
        let toml = "store.directory = \"pancake\"";
        let res = parse_string(toml);

        assert_eq!(res, Err(TomlError::Decode));
    }

    #[test]
    fn store_directory_is_a_number() {
        let toml = "store_directory = 42";
        let res = parse_string(toml);

        assert_eq!(res, Err(TomlError::Decode));
    }

    #[test]
    fn only_store_directory() {
        let toml = "store_directory = \"pancake\"";
        let res = parse_string(toml);

        let cfg = Config {
            store_directory: String::from("pancake"),
            tools: BTreeMap::new(),
        };

        assert_eq!(res, Ok(cfg));
    }

    #[test]
    fn single_empty_tool() {
        let toml = r#"
            store_directory = "pancake"

            [ripgrep]
        "#;

        let res = parse_string(toml);

        let cfg = Config {
            store_directory: String::from("pancake"),
            tools: BTreeMap::from([(
                "ripgrep".to_owned(),
                ConfigAsset {
                    owner: None,
                    repo: None,
                    exe_name: None,
                    asset_name: AssetName {
                        linux: None,
                        macos: None,
                        windows: None,
                    },
                    tag: None,
                },
            )]),
        };

        assert_eq!(res, Ok(cfg));
    }

    #[test]
    fn two_empty_tools() {
        let toml = r#"
            store_directory = "pancake"

            [ripgrep]
            [bat]
        "#;

        let res = parse_string(toml);

        let cfg = Config {
            store_directory: String::from("pancake"),
            tools: BTreeMap::from([
                (
                    "ripgrep".to_owned(),
                    ConfigAsset {
                        owner: None,
                        repo: None,
                        exe_name: None,
                        asset_name: AssetName {
                            linux: None,
                            macos: None,
                            windows: None,
                        },
                        tag: None,
                    },
                ),
                (
                    "bat".to_owned(),
                    ConfigAsset {
                        owner: None,
                        repo: None,
                        exe_name: None,
                        asset_name: AssetName {
                            linux: None,
                            macos: None,
                            windows: None,
                        },
                        tag: None,
                    },
                ),
            ]),
        };

        assert_eq!(res, Ok(cfg));
    }

    #[test]
    fn single_partial_tool() {
        let toml = r#"
            store_directory = "pancake"

            [ripgrep]
            owner = "me"
            asset_name.linux = "R2D2"
        "#;

        let res = parse_string(toml);

        let cfg = Config {
            store_directory: String::from("pancake"),
            tools: BTreeMap::from([(
                "ripgrep".to_owned(),
                ConfigAsset {
                    owner: Some("me".to_owned()),
                    repo: None,
                    exe_name: None,
                    asset_name: AssetName {
                        linux: Some("R2D2".to_owned()),
                        macos: None,
                        windows: None,
                    },
                    tag: None,
                },
            )]),
        };

        assert_eq!(res, Ok(cfg));
    }

    #[test]
    fn single_full_tool() {
        let toml = r#"
            store_directory = "pancake"

            [ripgrep]
            owner = "me"
            repo = "some_repo"
            exe_name = "rg"
            asset_name.linux = "R2D2"
            asset_name.macos = "C3-PO"
            asset_name.windows = "IG-88"
            tag = "4.2.0"
        "#;

        let res = parse_string(toml);

        let cfg = Config {
            store_directory: String::from("pancake"),
            tools: BTreeMap::from([(
                "ripgrep".to_owned(),
                ConfigAsset {
                    owner: Some("me".to_owned()),
                    repo: Some("some_repo".to_owned()),
                    exe_name: Some("rg".to_owned()),
                    asset_name: AssetName {
                        linux: Some("R2D2".to_owned()),
                        macos: Some("C3-PO".to_owned()),
                        windows: Some("IG-88".to_owned()),
                    },
                    tag: Some("4.2.0".to_owned()),
                },
            )]),
        };

        assert_eq!(res, Ok(cfg));
    }
}