rusht 1.1.0

Shell commands written in Rust
Documentation
use ::std::fs::create_dir_all;
use ::std::fs::remove_file;
use ::std::fs::File;
use ::std::fs::OpenOptions;
use ::std::io::BufReader;
use ::std::io::BufWriter;
use ::std::io::Write;
use ::std::path::Path;
use ::std::path::PathBuf;
use ::std::time::SystemTime;
use ::std::time::UNIX_EPOCH;

use ::log::debug;
use ::memoize::memoize;
use ::regex::Regex;

use crate::cmd::cmd_type::TaskStack;
use crate::cmd::cmd_type::DATA_VERSION;
use crate::common::fail;

pub fn read(namespace: String) -> TaskStack {
    debug!("going to read commands for namespace '{}'", &namespace);
    let pth = stack_pth(namespace);
    if !pth.exists() {
        debug!("no commands file at '{}'", pth.to_string_lossy());
        return TaskStack::empty();
    }
    let reader = BufReader::new(open_file(&pth, false));
    match serde_json::from_reader::<_, TaskStack>(reader) {
        Ok(tasks) => {
            debug!(
                "successfully read {} commands from '{}'",
                tasks.len(),
                pth.to_string_lossy()
            );
            tasks
        }
        Err(err) => fail(&format!(
            "failed to parse commands in '{}', error: {}",
            pth.to_string_lossy(),
            err
        )),
    }
}

pub fn write(namespace: String, tasks: &TaskStack) {
    debug!("going to write commands for namespace '{}'", &namespace);
    let pth = stack_pth(namespace);
    if tasks.is_empty() {
        if pth.exists() {
            debug!(
                "commands stack is empty, deleting commands file at '{}'",
                pth.to_string_lossy()
            );
            if let Err(err) = remove_file(&pth) {
                fail(&format!(
                    "failed to remove commands in '{}', error: {}",
                    pth.to_string_lossy(),
                    err
                ));
            }
        } else {
            debug!(
                "commands stack is empty, there is no commands file at '{}', doing nothing",
                pth.to_string_lossy()
            );
        }
    } else {
        let mut writer = BufWriter::new(open_file(&pth, true));
        if let Err(err) = serde_json::to_writer_pretty(&mut writer, tasks) {
            fail(&format!(
                "failed to write commands in {}, error: {}",
                pth.to_string_lossy(),
                err
            ));
        }
        writer.write_all(&[b'\n']).unwrap();
        debug!(
            "wrote updated commands file with {} commands to '{}'",
            tasks.len(),
            pth.to_string_lossy()
        );
    }
}

#[memoize]
pub fn stack_pth(namespace: String) -> PathBuf {
    let mut pth = make_app_dir();
    let filename = make_filename(namespace.clone());
    debug!("commands file for namespace '{}' and version {} is called '{}' inside cache directory '{}'",
            &namespace, DATA_VERSION, &filename, pth.to_string_lossy());
    pth.push(&filename);
    pth
}

fn make_app_dir() -> PathBuf {
    let mut pth = match dirs::cache_dir() {
        Some(pth) => pth,
        None => fail("failed to find cache directory"),
    };
    pth.push("cmdstack");
    if let Err(err) = create_dir_all(&pth) {
        fail(format!(
            "failed to create directory {}, error {}",
            pth.to_string_lossy(),
            err
        ))
    }
    pth
}

fn make_filename(namespace: String) -> String {
    if namespace.is_empty() {
        return format!("cmd_stack_v{}.json", DATA_VERSION);
    }
    let re = Regex::new("^([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9_-]*[a-zA-Z0-9])$").unwrap();
    if !re.is_match(&namespace) {
        fail("namespace should only contains alphanumeric characters, dashes and underscores, starting and ending with alphanumeric");
    }
    format!(
        "cmd_stack_{}_v{}.json",
        namespace.to_lowercase(),
        DATA_VERSION
    )
}

fn open_file(pth: &Path, write: bool) -> File {
    let mut opts = OpenOptions::new();
    if write {
        opts.write(true).truncate(true).create(true)
    } else {
        opts.read(true)
    };
    match opts.open(pth) {
        Ok(file) => file,
        Err(err) => {
            fail(&format!(
                "failed to open commands file at '{}' with options {:?}, error {}",
                pth.to_string_lossy(),
                &opts,
                err
            ));
        }
    }
}

pub fn current_time_s() -> u32 {
    SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .expect("Time went backwards")
        .as_secs() as u32
    //TODO @mverleg: shouldn't this be bigger than u32?
}

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

    #[test]
    fn default_stack_pth() {
        assert_eq!(
            stack_pth("".to_owned()).file_name().unwrap(),
            format!("cmd_stack_v{}.json", DATA_VERSION).as_str()
        );
    }

    #[test]
    fn namespaced_stack_pth() {
        assert_eq!(
            stack_pth("1".to_owned()).file_name().unwrap(),
            format!("cmd_stack_1_v{}.json", DATA_VERSION).as_str()
        );
    }
}