devrc 0.6.0

devrc is an easy to use task runner tool on steroids for developers
Documentation
use std::{convert::TryFrom, env, fs, path::PathBuf};

use reqwest::header::{HeaderMap, HeaderName, HeaderValue};
use serde::Deserialize;
use sha256::digest;
use url::Url;

use crate::{
    de::deserialize_some,
    environment::Environment,
    errors::{DevrcError, DevrcResult},
    loader::LoadingConfig,
    resolver::{Location, PathResolve},
    utils,
};

pub(crate) fn get_default_skip_on_error() -> bool {
    false
}

#[derive(Debug, Deserialize, Clone, Default)]
pub struct LocalFileImport {
    pub file: PathBuf,

    #[serde(default = "get_default_skip_on_error")]
    pub ignore_errors: bool,

    #[serde(default)]
    pub path_resolve: PathResolve,

    #[serde(default, deserialize_with = "deserialize_some")]
    pub checksum: Option<String>,
}

#[derive(Debug, Deserialize, Clone)]
pub struct UrlImport {
    pub url: String,

    #[serde(default = "get_default_skip_on_error")]
    pub ignore_errors: bool,

    pub checksum: String,

    #[serde(default)]
    pub headers: indexmap::IndexMap<String, String>,
}

#[derive(Debug, Deserialize, Clone, Default)]
#[serde(untagged)]
pub enum EnvFilesInclude {
    #[default]
    Empty,
    Simple(PathBuf),
    File(LocalFileImport),
    Url(UrlImport),
    List(Vec<EnvFilesInclude>),
}

impl From<&PathBuf> for LocalFileImport {
    fn from(source: &PathBuf) -> Self {
        Self {
            file: (*source).clone(),
            ..Default::default()
        }
    }
}

impl EnvFilesInclude {
    pub fn load(
        &self,
        location: Location,
        config: LoadingConfig,
    ) -> DevrcResult<Environment<String>> {
        match self {
            EnvFilesInclude::Empty => Ok(Default::default()),
            EnvFilesInclude::Simple(path) => LocalFileImport::from(path).load(location, config),
            EnvFilesInclude::File(file_include) => file_include.load(location, config),
            EnvFilesInclude::Url(remote_file) => remote_file.load(location, config),
            EnvFilesInclude::List(list) => {
                let mut env: Environment<String> = Default::default();
                for include in list {
                    for (key, value) in include.load(location.clone(), config.clone())? {
                        env.insert(key, value);
                    }
                }

                Ok(env)
            }
        }
    }
}

#[derive(Debug, Deserialize, Clone)]
pub struct EnvFilesWrapper(pub EnvFilesInclude);

pub trait Loader {
    fn load(&self, location: Location, config: LoadingConfig) -> DevrcResult<Environment<String>>;
}

pub fn read_env_from_string(input: &str) -> DevrcResult<Environment<String>> {
    let mut environment = Environment::default();
    for item in dotenvy::Iter::new(input.as_bytes()) {
        let (key, value) = item.map_err(DevrcError::Dotenv)?;
        environment.insert(key, value);
    }
    Ok(environment)
}

impl Loader for LocalFileImport {
    fn load(&self, location: Location, config: LoadingConfig) -> DevrcResult<Environment<String>> {
        let environment = if self.ignore_errors {
            match self.get_content(location, config) {
                Ok(content) => read_env_from_string(&content).unwrap_or_default(),
                Err(_error) => Environment::default(),
            }
        } else {
            read_env_from_string(&self.get_content(location, config)?)?
        };
        Ok(environment)
    }
}

impl LocalFileImport {
    fn get_content(&self, location: Location, config: LoadingConfig) -> DevrcResult<String> {
        let loading_location = self.get_loading_location(location, &config)?;

        config.log_level.debug(
            &format!("\n==> Loading ENV FILE: `{:}` ...", &loading_location),
            &config.designer.banner(),
        );

        match loading_location {
            Location::LocalFile(path) => fs::read_to_string(path).map_err(DevrcError::IoError),
            Location::Remote { url, auth } => {
                if let Some(cache_ttl) = config.cache_ttl {
                    if let Some(content) = crate::cache::load(&url, &config, None, &cache_ttl) {
                        config.log_level.debug(
                            &format!("\n==> Loading ENV URL CACHE: `{}` ...", &url),
                            &config.designer.banner(),
                        );
                        return Ok(content);
                    }
                }

                let client = reqwest::blocking::Client::new();
                let mut headers_map: HeaderMap = HeaderMap::new();

                if let Some((key, value)) = auth.get_header() {
                    headers_map.insert(
                        HeaderName::try_from(key.clone()).map_err(|_| {
                            DevrcError::UrlImportHeadersError {
                                name: key.clone(),
                                value: value.clone(),
                            }
                        })?,
                        HeaderValue::try_from(value.clone()).map_err(|_| {
                            DevrcError::UrlImportHeadersError {
                                name: key.clone(),
                                value: value.clone(),
                            }
                        })?,
                    );
                }

                match client.get(url.as_str()).headers(headers_map).send() {
                    Ok(response) if response.status() == 200 => {
                        let content = response.text().map_err(|_| DevrcError::RuntimeError)?;

                        if let Some(control_checksum) = self.checksum.clone() {
                            let content_checksum = digest(content.as_str());

                            if control_checksum != content_checksum {
                                return Err(DevrcError::UrlImportChecksumError {
                                    url: url.as_str().to_string(),
                                    control_checksum,
                                    content_checksum,
                                });
                            }
                        }

                        Ok(content)
                    }
                    Ok(response) => {
                        config.log_level.debug(
                            &format!(
                                "Loadin ENV FILE error: invalid status code `{:}` ...",
                                response.status()
                            ),
                            &config.designer.banner(),
                        );
                        Err(DevrcError::EnvfileUrlImportStatusError {
                            url: url.as_str().to_string(),
                            status: response.status(),
                        })
                    }
                    Err(error) => {
                        config.log_level.debug(
                            &format!("Error: `{:}` ...", &error),
                            &config.designer.banner(),
                        );
                        Err(DevrcError::RuntimeError)
                    }
                }
            }
            _ => Ok("".to_string()),
        }
    }

    pub fn get_loading_location(
        &self,
        location: Location,
        _config: &LoadingConfig,
    ) -> DevrcResult<Location> {
        if self.file.is_absolute() {
            return Ok(Location::LocalFile(self.file.clone()));
        }

        match location {
            Location::None | Location::StdIn => {
                let path = utils::get_absolute_path(&self.file, None)?;
                Ok(Location::LocalFile(path))
            }
            Location::LocalFile(file) => match self.path_resolve {
                PathResolve::Relative => {
                    let path = utils::get_absolute_path(&self.file, Some(&file))?;
                    Ok(Location::LocalFile(path))
                }
                PathResolve::Pwd => {
                    let path =
                        utils::get_absolute_path(&self.file, env::current_dir().ok().as_ref())?;
                    Ok(Location::LocalFile(path))
                }
            },
            Location::Remote { url, auth } => match self.path_resolve {
                PathResolve::Relative => {
                    let path = self
                        .file
                        .clone()
                        .into_os_string()
                        .into_string()
                        .map_err(|_| DevrcError::RuntimeError)?;
                    let include_url = url.join(&path).map_err(|_| DevrcError::RuntimeError)?;
                    Ok(Location::Remote {
                        url: include_url,
                        auth,
                    })
                }
                PathResolve::Pwd => {
                    let path =
                        utils::get_absolute_path(&self.file, env::current_dir().ok().as_ref())?;
                    Ok(Location::LocalFile(path))
                }
            },
        }
    }
}

impl Loader for UrlImport {
    fn load(&self, location: Location, config: LoadingConfig) -> DevrcResult<Environment<String>> {
        let environment = if self.ignore_errors {
            match self.get_content(location, config) {
                Ok(content) => read_env_from_string(&content).unwrap_or_default(),
                Err(_) => Environment::default(),
            }
        } else {
            read_env_from_string(&self.get_content(location, config)?)?
        };
        Ok(environment)
    }
}

impl UrlImport {
    pub fn get_content(&self, _location: Location, config: LoadingConfig) -> DevrcResult<String> {
        let parsed_url =
            Url::parse(&self.url).map_err(|_| DevrcError::InvalidIncludeUrl(self.url.clone()))?;

        if let Some(cache_ttl) = config.cache_ttl {
            if let Some(content) =
                crate::cache::load(&parsed_url, &config, Some(&self.checksum), &cache_ttl)
            {
                config.log_level.debug(
                    &format!("\n==> Loading ENV URL CACHE: `{}` ...", &parsed_url),
                    &config.designer.banner(),
                );
                return Ok(content);
            }
        }

        config.log_level.debug(
            &format!("\n==> Loading ENV FILE: `{:}` ...", &parsed_url),
            &config.designer.banner(),
        );

        match reqwest::blocking::get(parsed_url.as_str()) {
            Ok(response) if response.status() == 200 => {
                let content = response.text().map_err(|_| DevrcError::RuntimeError)?;

                let content_checksum = digest(content.as_str());

                if self.checksum != content_checksum {
                    return Err(DevrcError::UrlImportChecksumError {
                        url: parsed_url.as_str().to_string(),
                        control_checksum: self.checksum.to_string(),
                        content_checksum,
                    });
                }

                if config.cache_ttl.is_some() {
                    crate::cache::save(&parsed_url, &content)?;
                }

                Ok(content)
            }
            Ok(response) => {
                config.log_level.debug(
                    &format!(
                        "Loadin ENV FILE error: invalid status code `{:}` ...",
                        response.status()
                    ),
                    &config.designer.banner(),
                );
                Err(DevrcError::EnvfileUrlImportStatusError {
                    url: parsed_url.as_str().to_string(),
                    status: response.status(),
                })
            }
            Err(error) => {
                config.log_level.debug(
                    &format!("Error: `{:}` ...", &error),
                    &config.designer.banner(),
                );
                Err(DevrcError::EnvfileUrlImportError {
                    url: parsed_url.as_str().to_string(),
                    inner: error,
                })
            }
        }
    }
}