gitig 0.3.0

A cli utility to manage gitignore files easily
//! This module contains structs for working with the github templates

use log::{debug, trace};
use reqwest::blocking::Client;
use reqwest::header::{ACCEPT, CONTENT_TYPE, USER_AGENT};
use serde::{Deserialize, Serialize};
use std::fs::OpenOptions;
use std::io::{BufWriter, Write};

use std::path::PathBuf;

use crate::errors::*;

/// Default user agent string used for api requests
pub const DEFAULT_USER_AGENT: &str = "gitig";

/// Response objects from github api
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
#[allow(clippy::missing_docs_in_private_items)]
pub struct Template {
    pub name:         String,
    pub path:         String,
    pub sha:          String,
    pub size:         i64,
    pub url:          String,
    #[serde(rename = "html_url")]
    pub html_url:     String,
    #[serde(rename = "git_url")]
    pub git_url:      String,
    #[serde(rename = "download_url")]
    pub download_url: Option<String>,
    #[serde(rename = "type")]
    pub type_field:   String,
    #[serde(rename = "_links")]
    pub links:        Links,
    pub content:      Option<String>,
}

/// Part of github api response
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
#[allow(clippy::missing_docs_in_private_items)]
pub struct Links {
    #[serde(rename = "self")]
    pub self_field: String,
    pub git:        String,
    pub html:       String,
}

impl Template {
    /// Checks if this template file is a gitignore template file
    pub fn is_gitignore_template(&self) -> bool { self.name.ends_with(".gitignore") }

    /// Returns the name of the file without the .gitignore ending
    ///
    /// if `!self.is_gitignore_template()` the whole `self.name` is returned
    pub fn pretty_name(&self) -> &str {
        if let Some(dot_index) = self.name.rfind('.') {
            return &self.name.get(0..dot_index).unwrap_or_else(|| self.name.as_str());
        }
        self.name.as_str()
    }

    /// Loads the template content from github
    pub fn load_content(&mut self) -> Result<()> {
        let url = self
            .download_url
            .as_ref()
            .ok_or_else(|| ErrorKind::TemplateNoDownloadUrl(self.pretty_name().into()))?;
        debug!("Loading template content for {} from {}", self.pretty_name(), url);
        let client = Client::new();
        let res = client
            .get(url)
            .header(ACCEPT, "text/html")
            .header(USER_AGENT, format!("{} {}", DEFAULT_USER_AGENT, env!("CARGO_PKG_VERSION")))
            .send()
            .chain_err(|| {
                format!("Error while getting content of template {}", self.pretty_name())
            })?;

        debug!(
            "Got a response from the server ({} B)",
            res.content_length().map_or_else(|| "?".to_string(), |v| v.to_string())
        );

        let body: String =
            res.text().chain_err(|| "Error while parsing body from template response")?;
        self.content = Some(body);
        debug!("Set content for template {}", self.pretty_name());

        Ok(())
    }

    /// Writes the content of this template to the given path
    ///
    /// Creates the file if it does not exist or trunceates an already existing one.
    ///
    /// # Errors
    ///
    /// Returns `ErrorKind::TemplateNoContent` if this templates has no content (i.e.
    /// `load_content` has not yet been called.
    /// Other reasons for `Err` can be io related errors.
    #[allow(clippy::option_expect_used)]
    pub fn write_to(&self, path: &PathBuf, append: bool) -> Result<()> {
        debug!(
            "Writing contents of {} to {} (append: {})",
            self.pretty_name(),
            path.to_string_lossy(),
            append
        );
        if self.content.is_none() {
            return Err(ErrorKind::TemplateNoContent.into());
        }
        let file = OpenOptions::new()
            .write(true)
            .append(append)
            .create(true)
            .open(path)
            .chain_err(|| "Error while opening gitignore file to write template")?;
        let mut writer = BufWriter::new(file);

        writer.write_all(b"# template downloaded with gitig (https://git.schneider-hosting.de/schneider/gitig) from https://github.com/github/gitignore\n")?;
        writer.write_all(self.content.as_ref().expect("checked before to be some").as_bytes())?;
        trace!("Wrote all content");
        Ok(())
    }
}

/// This struct holds the information about the templates available at github
#[derive(Serialize, Deserialize)]
pub struct GithubTemplates {
    /// The templates
    templates: Vec<Template>,
}

impl GithubTemplates {
    /// Loads the templates from the github api
    fn from_server() -> Result<GithubTemplates> {
        trace!("Loading templates from github api");
        let client = Client::new();
        let res = client
            .get("https://api.github.com/repos/github/gitignore/contents//")
            .header(ACCEPT, "application/jsonapplication/vnd.github.v3+json")
            .header(CONTENT_TYPE, "application/json")
            .header(USER_AGENT, format!("{} {}", DEFAULT_USER_AGENT, env!("CARGO_PKG_VERSION")))
            .send()
            .chain_err(|| "Error while sending request to query all templates")?;

        let body: Vec<Template> = res.json().chain_err(|| "json")?;
        debug!("Received and deserialized {} templates", body.len());

        Ok(GithubTemplates { templates: body })
    }

    /// Creates a new struct with templates
    pub fn new() -> Result<GithubTemplates> { Self::from_server() }

    /// Returns a list of the template names
    pub fn list_names(&self) -> Vec<&str> {
        self.templates
            .iter()
            .filter(|t| t.is_gitignore_template())
            .map(Template::pretty_name)
            .collect()
    }

    /// Returns the template for the given name, if found
    pub fn get(&self, name: &str) -> Option<&Template> {
        // names have all a .gitignore suffix
        let name = format!("{}.gitignore", name);
        self.templates.iter().find(|t| t.name.eq_ignore_ascii_case(&name))
    }
}