workspace 0.4.1

a command-line project manager
use crate::exit::Exit;
use crate::tilde::Tilde;
use crate::VERBOSE;

use std::env;
use std::fs;
use std::io::{self, Read, Write};
use std::path::PathBuf;
use std::process::{self, Stdio};

use colored::Colorize;
use failure::Fail;
use serde_derive::{Deserialize, Serialize};

#[serde(deny_unknown_fields)]
#[derive(Serialize, Deserialize, Debug)]
pub struct Workspace {
    pub path: PathBuf,
    #[serde(default, skip_serializing_if = "is_default")]
    pub tabs: Vec<String>,
    #[serde(default, skip_serializing_if = "is_default")]
    pub commands: Commands,
}

#[derive(Serialize, Deserialize, Debug, Default, PartialEq)]
pub struct Commands {
    #[serde(default, skip_serializing_if = "is_default")]
    pub local: Vec<String>,
    #[serde(default, skip_serializing_if = "is_default")]
    pub external: Vec<String>,
    #[serde(default, skip_serializing_if = "is_default")]
    pub background: Vec<String>,
}

impl Workspace {
    pub fn open(&self, dir_only: bool) {
        run!("cd {}", self.path.display());
        if dir_only {
            return;
        }

        for command in &self.commands.local {
            run!("{}", command);
        }

        if !self.commands.external.is_empty() {
            if let Ok(terminal) = env::var("TERMINAL") {
                for command in &self.commands.external {
                    let result = process::Command::new(&terminal)
                        .arg(command)
                        .current_dir(&self.path)
                        .stdin(Stdio::null())
                        .stdout(Stdio::null())
                        .stderr(Stdio::null())
                        .spawn();

                    if result.is_err() {
                        error!("Could not run command: {}", command);
                        log!("{}", result.unwrap_err());
                    }
                }
            } else {
                error!("Please set $TERMINAL to run external commands");
            }
        }

        if !&self.commands.background.is_empty() {
            if let Ok(shell) = env::var("SHELL") {
                for command in &self.commands.background {
                    let result = process::Command::new(&shell)
                        .arg("-c")
                        .arg(command)
                        .current_dir(&self.path)
                        .stdin(Stdio::null())
                        .stdout(Stdio::null())
                        .stderr(Stdio::null())
                        .spawn();

                    if result.is_err() {
                        error!("Could not run command: {}", command);
                        log!("{}", result.unwrap_err());
                    }
                }
            } else {
                error!("Please set $SHELL to run commands in the background.");
            }
        }

        if !self.tabs.is_empty() {
            if let Ok(browser) = env::var("BROWSER") {
                for tab in &self.tabs {
                    let result = process::Command::new(&browser)
                        .arg(tab)
                        .stdin(Stdio::null())
                        .stdout(Stdio::null())
                        .stderr(Stdio::null())
                        .spawn();

                    if result.is_err() {
                        error!("Could not open tab: {}", tab);
                        log!("{}", result.unwrap_err())
                    }
                }
            } else {
                error!("Please set $BROWSER to open browser tabs")
            }
        }
    }

    pub fn write(&self, name: &str) {
        const ERR_MESSAGE: &str = "Could not write workspace data";

        let path = Self::file_path(name);
        let mut file = fs::OpenOptions::new()
            .read(false)
            .write(true)
            .create(true)
            .open(path)
            .unwrap_or_exit(ERR_MESSAGE);

        let serialized = serde_yaml::to_string(self).unwrap();
        file.write_fmt(format_args!("{}", serialized))
            .unwrap_or_exit(ERR_MESSAGE);
    }

    pub fn edit(name: &str) {
        let path = Self::file_path(name);
        let editor = env::var("EDITOR").unwrap_or_else(|_| {
            env::var("VISUAL").unwrap_or_exit("Please set $EDITOR or $VISUAL to edit workspaces")
        });
        run!("{} {}", editor, path.display());
    }

    pub fn delete(name: &str) {
        let path = Self::file_path(name);
        fs::remove_file(path).unwrap_or_exit("Could not delete workspace data");
    }

    pub fn exists(name: &str) -> bool {
        Self::file_path(name).exists()
    }

    pub fn get(name: &str) -> Option<Result<Workspace, Error>> {
        let path = Self::file_path(name);
        if path.exists() {
            Some(Self::parse(&path))
        } else {
            None
        }
    }

    pub fn all() -> Vec<(Option<String>, Result<Workspace, Error>)> {
        Self::paths()
            .into_iter()
            .map(|path| {
                // Safe to unwrap here, because paths() cannot contain a file without a stem
                let name = path.file_stem().unwrap().to_str().map(str::to_owned);
                (name, path)
            })
            .map(|(name, path)| (name, Self::parse(&path)))
            .collect()
    }

    fn parse(path: &PathBuf) -> Result<Workspace, Error> {
        let content: String = Self::read(&path)?;
        let ws: Workspace = serde_yaml::from_str(&content)?;
        Ok(ws)
    }

    fn read(path: &PathBuf) -> io::Result<String> {
        let mut content: String = String::new();

        fs::OpenOptions::new()
            .read(true)
            .open(&path)?
            .read_to_string(&mut content)?;

        Ok(content)
    }

    fn paths() -> Vec<PathBuf> {
        let entries =
            fs::read_dir(Self::folder_path()).unwrap_or_exit("Could not find workspace data");
        let mut paths: Vec<PathBuf> = Vec::new();

        for entry in entries {
            skip_err!(entry);
            let entry = entry.unwrap();
            let path = entry.path();

            skip_err!(entry.file_type());
            let file_type = entry.file_type().unwrap();
            skip!(
                !file_type.is_file(),
                format!("Skipping {} because it's not a file", path.tilde_format())
            );

            skip_none!(
                path.extension(),
                format!(
                    "Skipping {} because it has no file extension",
                    path.tilde_format()
                )
            );
            let extension = path.extension().unwrap();
            skip!(
                extension.to_string_lossy() != "yaml",
                format!(
                    "Skipping {} because it's not a YAML file",
                    path.tilde_format()
                )
            );

            paths.push(entry.path());
        }

        paths
    }

    pub fn file_path(name: &str) -> PathBuf {
        let mut path = Self::folder_path();
        path.push(name);
        path.set_extension("yaml");
        path
    }

    fn folder_path() -> PathBuf {
        let mut path = dirs::config_dir().unwrap_or_exit("Could not find configuration directory");
        path.push("workspace");

        if !path.exists() {
            fs::create_dir(&path).unwrap_or_exit(&format!(
                "Could not create directory {}",
                path.tilde_format()
            ));
        }

        path
    }
}

#[derive(Fail, Debug)]
pub enum Error {
    #[fail(display = "Could not read workspace data")]
    Read(#[cause] io::Error),
    #[fail(display = "Could not parse workspace data")]
    Parse(#[cause] serde_yaml::Error),
}

impl From<io::Error> for Error {
    fn from(cause: io::Error) -> Error {
        Error::Read(cause)
    }
}

impl From<serde_yaml::Error> for Error {
    fn from(cause: serde_yaml::Error) -> Error {
        Error::Parse(cause)
    }
}

fn is_default<T: Default + PartialEq>(t: &T) -> bool {
    t == &T::default()
}