boomack-cli 0.1.0

CLI client for Boomack
use std::io::{Read, stdin};
use std::path::{Path};
use std::fs::{read_to_string};
use yaml_rust::{YamlLoader, Yaml, ScanError};
use regex::{Regex};
use boomack::client::json::*;

const KEY_VALUE_PAIR_PATTERN: &str = r"^([a-zA-Z0-9_-]+)\s*=\s*(.+)$";
const STDIN_INDICATOR: &str = "STDIN";
const MAX_FILEPATH_LENGTH: usize = 1023;

fn value_to_json_value(v: &str) -> JVal {
    // TODO recognize quotes around a string
    //      for bypassing null, bool, and number parsing
    let vlo: Option<String> = if v.len() <= 5 { Some(v.to_lowercase()) } else { None };
    let vc = vlo.as_deref().unwrap_or(v);
    match vc {
        "null" => JVal::Null,
        "true" | "on" | "yes" => JVal::Bool(true),
        "false" | "off" | "no" => JVal::Bool(false),
        _ => f64_str_to_json(v).unwrap_or(JVal::String(String::from(v)))
    }
}

fn key_value_pair(s: &str) -> Option<(String, JVal)>
{
    let p = Regex::new(KEY_VALUE_PAIR_PATTERN).unwrap();
    match p.captures(s) {
        Some(caps) => Some((
            String::from(&caps[1]),
            value_to_json_value(&caps[2])
        )),
        None => None
    }
}

pub fn parse_kvp_lines(s: &str) -> Option<JsonMap> {
    let mut result = JsonMap::new();
    for line in s.lines() {
        if line.len() == 0 || line.starts_with("#") { continue; }
        match key_value_pair(line) {
            Some((k, v)) => { result.insert(k, v); },
            None => { panic!("Failed to parse key-value-pair: {}", line); },
        }
    }
    Some(result)
}

fn is_stdin_indicator(s: &str) -> bool {
    s.len() == STDIN_INDICATOR.len() && s.to_ascii_uppercase() == STDIN_INDICATOR
}

fn yaml_to_json(yaml: Yaml) -> Option<JsonMap> {
    fn y2j(y: Yaml) -> JVal {
        match y {
            Yaml::Null | Yaml::BadValue | Yaml::Alias(_) => JVal::Null,
            Yaml::Boolean(v) => JVal::Bool(v),
            Yaml::Real(v) => f64_str_to_json(&v).unwrap_or(JVal::Null),
            Yaml::Integer(v) => i64_to_json(v).unwrap_or(JVal::Null),
            Yaml::String(s) => JVal::String(s),
            Yaml::Array(xs) => JVal::Array(xs.into_iter().map(y2j).collect()),
            Yaml::Hash(h) => {
                let mut map = JsonMap::new();
                for (k, v) in h {
                    let ko = match k {
                        Yaml::String(s) => Some(s),
                        Yaml::Real(v) => Some(v),
                        Yaml::Integer(v) => Some(v.to_string()),
                        Yaml::Boolean(v) => Some(v.to_string()),
                        _ => None
                    };
                    if let Some(k_str) = ko {
                        map.insert(k_str, y2j(v));
                    }
                }
                JVal::Object(map)
            }
        }
    }

    let jv = y2j(yaml);
    if let JVal::Object(map) = jv { Some(map) } else { None }
}

fn yaml_docs_to_json(yaml_docs: Vec<Yaml>) -> impl Iterator<Item=JsonMap>
{
    yaml_docs.into_iter().filter_map(yaml_to_json)
}

pub fn parse_structure<F>(s: &str,
    allow_stdin: bool, stdin_consumed: &mut bool,
    parser: F
) -> JsonMap
    where F: Fn(&str) -> Option<JsonMap>
{
    if s.len() == 0 { return JsonMap::new(); }

    let mut ext_input: Option<String> = None;
    if allow_stdin && is_stdin_indicator(s) {
        if !*stdin_consumed {
            let mut stdin_str = String::new();
            match stdin().read_to_string(&mut stdin_str) {
                Err(err) => {
                    eprintln!("{:?}", err);
                    panic!("Failed to read from STDIN");
                },
                _ => {
                    ext_input = Some(stdin_str);
                    *stdin_consumed = true;
                }
            }
        } else {
            panic!("Can not read from STDIN, because STDIN was already consumed!")
        }
    } else if s.len() <= MAX_FILEPATH_LENGTH {
        let path = Path::new(s);
        if path.exists() {
            match read_to_string(path) {
                Ok(file_content) => {
                    ext_input = Some(file_content);
                },
                Err(err) => {
                    eprintln!("{:?}", err);
                    panic!("Failed to read file: {:?}", path);
                }
            };
        }
    }

    fn is_just_a_string(yaml_docs: &Vec<Yaml>) -> bool {
        yaml_docs.len() == 1 && matches!(yaml_docs[0], Yaml::String(_))
    }

    fn contains_bad_result(yaml_docs: &Vec<Yaml>) -> bool {
        yaml_docs.iter().any(|doc| matches!(doc, Yaml::BadValue))
    }

    let input = ext_input.as_deref().unwrap_or(s);

    let fallback_from_yaml = |err: Option<ScanError>| {
        if let Some(parsed) = parser(input) {
            return parsed
        } else if let Some(err) = err {
            eprintln!("{:?}", err);
        }
        panic!("Failed to parse YAML/JSON");
    };

    match YamlLoader::load_from_str(input) {
        Ok(yaml_data) if contains_bad_result(&yaml_data) => panic!("Invalid YAML/JSON"),
        Ok(yaml_data) if is_just_a_string(&yaml_data) => fallback_from_yaml(None),
        Ok(yaml_data) => merge_layers(yaml_docs_to_json(yaml_data)),
        Err(err) => fallback_from_yaml(Some(err)),
    }
}

pub fn load_structure(s: &str,
    allow_stdin: bool, stdin_consumed: &mut bool
) -> JsonMap {
    parse_structure(s, allow_stdin, stdin_consumed, |_| None)
}

pub fn merge_layers<T>(layers: T) -> JsonMap
    where T : IntoIterator<Item=JsonMap>
{
    let mut result = JsonMap::new();
    for layer in layers {
        for (k, v) in layer {
            result.insert(k, v);
        }
    }
    result
}