marg 0.4.1

Meta config for apps from args
Documentation
mod feature;
pub mod token;
pub mod key;
pub mod opts;

use std::collections::HashMap;
use std::fs::File;
use std::io::{BufRead, BufReader};
use std::path::Path;
use uuid::Uuid;
use crate::feature::{featured, SupportedDb};
use crate::key::KeyFile;
use crate::opts::Opt;

const CMD_FILE: &str = "file";
const CMD_DB: &str = "db";
const CMD_TBL: &str = "config";
const CMD_TOKEN: &str = "token";
const CMD_TTL: &str = "ttl";
const CMD_KEY: &str = "key";
const CMD_SECRET: &str = "secret";
const CMD_PASS: &str = "PASSPHRASE";
/// self host or node id
const CMD_UUID: &str = "uuid";


/// App startup args:
/// - db connection url: usually the first arg
///   - or prefix with: '--db '
///     (optional, default postgres:// , trying to identify by prefix)
///
/// - config table name in format: schema.table, usually the second arg trying to identify with a dot in the middle.
///   - or prefix with: '--config '
///     (optional, default public.{the_appname})
///
/// - UUID this app instance to use as a node id or config recognition. trying to auto identify a UUID formatted string.
///   - or prefix with '--uuid '
///
/// - token (db pwd) script name usually the third arg (required feature 'token')
///   - or prefix with '--token '
///
/// - token live in minutes, usually the forth arg (required feature 'token')
///   - or prefix with '--ttl '
///
/// - (Private) Key text file name to use with RSA OR AES encryption (required feature 'rsa')
///   - or prefix with '--key '
///
/// - cipher secret for AES taken from env \[SECRET\] (more secure) OR cml --secret (not recommended)
///  <br>Also: compile with keep_env_secret feature to not remove from env
///
/// Alternative configuration:
/// - file name, usually the first arg
///   - or prefix with '--file '
///
/// File format:
///  - db: OR db=
///  - config: OR config=
///  - uuid: OR uuid=
///  - token: OR token=
///  - ttl: OR ttl=
///  - pk: OR pk=
///
/// params passed in cmd line override params loaded from file & env.
///
/// env:
/// - PGPASSWORD, in case of postgres db url, use to connect to the DB
/// - PASSPHRASE, in case of RSA private key required a passphrase
/// - SECRET
///
#[derive(Debug, Clone)]
pub struct ArgConfig {
    /// instance ID
    pub uuid: Uuid,
    /// indicate the instance uuid was set or autogenerated on (every) start
    pub uuid_gen: bool,
    /// database connection string
    pub db_url: String,

    /// Format: schema.table
    pub table: String,
    /// key=value loaded from file if present
    pub cfg: HashMap<String, String>,
    /// token (i.e. db pwd) script name usually the third arg (required feature 'token')
    pub token: token::Token,
    /// RSA private key file name to use with RSA OR AES encryption (required feature 'rsa')
    pub pk: Option<KeyFile>,
    /// cipher secret for AES taken from env \[SECRET\] OR cml --secret
    /// use keep_env_secret feature to not remove from env
    pub secret: Option<String>,
    /// -v / --verbose / --debug
    pub verbose: Opt,
    /// -q / --quiet / --batch
    pub quiet: Opt,
    /// -h / --help / --info
    pub help: Opt,
}


impl ArgConfig {

    /// backward compatibility but not correct info!
    /// Use: ArgConfig::from_args2(env!("CARGO_PKG_NAME"), env!("CARGO_PKG_VERSION"))
    pub fn from_args() -> Result<ArgConfig, String> {
        ArgConfig::from_args2(env!("CARGO_PKG_NAME"), env!("CARGO_PKG_VERSION"))
    }
    /// `app_name` / `app_version` should be the *caller's* crate identity, e.g.
    /// `ArgConfig::from_args(env!("CARGO_PKG_NAME"), env!("CARGO_PKG_VERSION"))`.
    /// They can't default to marg's own `env!()` here since that macro resolves
    /// at compile time against whichever crate the source lives in (marg itself),
    /// not against whatever binary ends up calling this function.
    pub fn from_args2(app_name: &str, app_version: &str) -> Result<ArgConfig, String> {
        let user = match std::env::var_os("USER") {
            Some(a) => a.to_str().unwrap_or("postgres").to_string(),
            _ => "postgres".to_string(),
        };
        let f = featured();
        let pwd = if !f.env_pwd().is_empty() {
            match std::env::var_os(f.env_pwd().as_str()).map(|v| v) {
                Some(a) => a.to_str().map(|v| v.to_string()),
                _ => None,
            }
        } else {
            None
        };
        let input: Vec<String> = std::env::args_os().map(|e| e.to_string_lossy().to_string()).collect();

        for arg in input.iter().skip(1) {
            if arg == "--version" || arg == "-V" {
                println!("{app_name} {app_version}");
                std::process::exit(0);
            }
        }

        ArgConfig::new(input, f, user, pwd)
    }

    // first arg is an app name itself
    fn new(input: Vec<String>, feature: SupportedDb, user: String, pwd: Option<String>) -> Result<Self, String> {
        let mut cfg = HashMap::new();
        let mut db: Option<String> = None;
        let mut tbl: Option<String>  = None;
        let mut token: Option<String>  = None;

        let mut ttl: Option<String>  = None;
        let mut pk: Option<String>  = None;
        let mut secret: Option<String>  = None;
        let mut uuid: Option<Uuid> = None;
        let mut ignore_next = false;
        let mut verbose = Opt::new("verbose", "debug", "v", "false");
        let mut quiet = Opt::new("quiet", "batch", "q", "false");
        let mut help = Opt::new("help", "info", "h", "false");
        for i in 1..input.len() {
            if ignore_next { ignore_next = false; continue }
            ignore_next = false;
            if verbose.apply(&input[i]) | quiet.apply(&input[i]) | help.apply(&input[i]) {
                continue;
            }
            if input[i].starts_with("--") {
                if i < input.len() - 1 {
                    let v = &input[i].as_str()[2..];
                    if v == CMD_FILE {
                        let _ = load(v, &mut cfg)?;
                        ignore_next = true;
                    } else if v == CMD_DB {
                        db = Some(input[i + 1].to_string());
                        ignore_next = true;
                    } else if v == CMD_TBL {
                        tbl = Some(input[i + 1].to_string());
                        ignore_next = true;
                    } else if v == CMD_TOKEN {
                        token = Some(input[i + 1].to_string());
                        ignore_next = true;
                    } else if v == CMD_TTL {
                        ttl = Some(input[i + 1].to_string());
                        ignore_next = true;
                    } else if v == CMD_KEY {
                        let file = input[i + 1].to_string();
                        if key::is_key_file(&file) {
                            pk = Some(file);
                            ignore_next = true;
                        }
                    } else if v == CMD_UUID {
                        uuid = Uuid::parse_str(input[i + 1].as_str()).ok();
                        ignore_next = true;
                    } else if v == CMD_SECRET {
                        secret = Some(input[i + 1].to_string());
                        ignore_next = true;
                    }
                }
            } else {
            }
        }
        // first was a check by tag names, then try to guess
        for i in &input {
            if i.starts_with("-") {
                continue
            }
            // first - trying to detect db url
            if db.is_none() && feature.is_valid_url(i) {
                db = Some(i.to_string());
                continue
            }
            // second - trying to detect config table name
            if tbl.is_none() && is_sound_schema_table(i) {
                tbl = Some(i.to_string());
                continue
            }
            // third - trying to detect token script name
            if uuid.is_none() {
                uuid = Uuid::parse_str(i).ok();
                if uuid.is_some() {
                    continue
                }
            };
            // then - trying to detect token script
            if token.is_none() && i.len() > 1 {
                token = Some(i.to_string());
                continue
            }
            if ttl.is_none() && i.parse::<u16>().is_ok() {
                ttl = Some(i.to_string());
            }
        }
        if uuid.is_none() {
            uuid  = Uuid::parse_str(get_env_or_cfg(CMD_UUID, &cfg, "").as_str()).ok();
        }

        if let Some(a) = std::env::var_os(CMD_SECRET.to_uppercase()).map(|v| v) {
            secret = Some(a.to_str().map(|v| v.to_string()).unwrap_or("".to_string()));
            #[cfg(not(feature="keep_env_secret"))]
            unsafe {
                std::env::remove_var(CMD_SECRET);
            }
        }

        Ok(ArgConfig {
            uuid_gen: uuid.is_none(),
            uuid: uuid.unwrap_or(Uuid::new_v4()),
            db_url: link_db_user(db.unwrap_or(get_env_or_cfg(CMD_DB, &cfg, feature.default_url(&user).as_str())), user),
            table: tbl.unwrap_or(get_env_or_cfg(CMD_TBL, &cfg, get_exec_name("public.", input[0].as_str()).as_str())),
            token: token::Token::new(
                token.unwrap_or(get_env_or_cfg(CMD_TOKEN, &cfg, "")),
                ttl.unwrap_or(get_env_or_cfg(CMD_TTL, &cfg, "1")),
                pwd
            )?,
            pk: KeyFile::new(
                pk.unwrap_or(get_env_or_cfg(CMD_KEY, &cfg, &"")),
                std::env::var_os(CMD_PASS).map(|p| p.to_string_lossy().to_string()),
            )?,
            cfg,
            secret,
            verbose,
            quiet,
            help,
        })
    }

    /// append with password if $PWD present in 'db'
    pub fn db_url(&self) -> String {
        let url = self.db_url.clone();
        if let Some(i) = url.find(":$P") {
            if let Some(y) = url.find("@") {
                let pwd = url.as_str()[i+1..y].to_owned();
                return url.replace(
                    pwd.as_str(),
                    self.token.value.clone().unwrap_or("".into()).as_str()).to_string();
            }
        }
        url
    }
}

#[inline]
fn link_db_user(url: String, user: String) -> String {
    url.replace("$USER", user.as_str())
}

#[inline]
fn get_env_or_cfg(input: &str, cfg: &HashMap<String, String>, def: &str) -> String {
    match std::env::var_os(input).map(|v| v) {
        Some(a) => a.to_str().map(|v| v.to_string()).unwrap_or(def.to_string()),
        None => cfg.get(input).unwrap_or(&def.to_string()).into()
    }
}

/// Safe taking value
#[inline]
fn get_exec_name(schema: &str, input: &str) -> String {
    if input.is_empty() {
        return "".to_string();
    }
    let e = Path::new(input);
    let name = e.file_name().map(|f|f.to_str().unwrap_or("")).unwrap_or("").to_string();
    #[cfg(windows)]
    let name = name.replace(".exe", "");
    let name = if let Some(i) = name.rfind(std::path::MAIN_SEPARATOR_STR) {
        name[i+1..].to_string()
    } else {
        name
    };
    format!("{}{}", schema, name)
}

#[inline]
fn is_sound_schema_table(input: &str) -> bool {
    let y = input.contains(".");
    #[cfg(windows)]
    let y = y && !input.ends_with(".exe");
    y
}

#[inline]
fn load(file: &str, cfg: &mut HashMap<String, String>) -> Result<(), String> {
    let f = File::open(file).map_err(|e| e.to_string())?;
    let reader = BufReader::new(f);
    for line in reader.lines() {
        if let Ok(l) = line {
            if let Some(i) = l.chars().position(|c| c == '=' || c == ':' || c == '#' || c == ';' || c == '/' || c == '[') {
                if l.as_bytes()[i] != b'#' && l.as_bytes()[i] != b';' && l.as_bytes()[i] != b'/' && l.as_bytes()[i] != b'[' {
                    let key = l[..i].trim().to_lowercase();
                    let value = l[i + 1..].trim().to_string();
                    cfg.insert(key, value);
                }
            }
        }
    }
    Ok(())
}

#[cfg(test)]
mod tests;