robin_cli_tool 1.3.0

A CLI tool to run scripts for any project
Documentation
use anyhow::{Result, anyhow};
use regex::Regex;
use serde_json;

pub fn split_command_and_args(args: &[String]) -> (String, Vec<String>) {
    if args.is_empty() {
        return (String::new(), vec![]);
    }

    let mut command_parts = Vec::new();
    let mut var_args = Vec::new();
    let mut found_args = false;

    for arg in args {
        if arg.starts_with("--") {
            found_args = true;
            var_args.push(arg.clone());
        } else if !found_args {
            command_parts.push(arg.clone());
        } else {
            var_args.push(arg.clone());
        }
    }

    (command_parts.join(" "), var_args)
}

pub fn replace_variables(script: &serde_json::Value, args: &[String]) -> Result<serde_json::Value> {
    match script {
        serde_json::Value::String(cmd) => {
            let replaced = replace_variables_in_string(cmd, args)?;
            Ok(serde_json::Value::String(replaced))
        }
        serde_json::Value::Array(commands) => {
            let mut replaced_commands = Vec::new();
            for cmd in commands {
                if let Some(cmd_str) = cmd.as_str() {
                    let replaced = replace_variables_in_string(cmd_str, args)?;
                    replaced_commands.push(serde_json::Value::String(replaced));
                } else {
                    replaced_commands.push(cmd.clone());
                }
            }
            Ok(serde_json::Value::Array(replaced_commands))
        }
        _ => Ok(script.clone()),
    }
}

/// Replaces Docker Compose-style environment-variable defaults.
///
/// Supports two forms (defaults only):
///   - `${VAR:-default}` -> use `$VAR` if set and non-empty, otherwise `default`.
///   - `${VAR-default}`  -> use `$VAR` if set (even if empty), otherwise `default`.
///
/// Bare `${VAR}` (no operator) is intentionally left untouched so it is still
/// expanded by the shell at run time.
fn replace_env_variables_in_string(script: &str) -> String {
    let env_regex = Regex::new(r"\$\{([A-Za-z_][A-Za-z0-9_]*)(:?-)([^}]*)\}").unwrap();
    let mut result = script.to_string();

    for capture in env_regex.captures_iter(script) {
        let full_match = &capture[0];
        let var_name = &capture[1];
        let operator = &capture[2];
        let default = &capture[3];

        let env_value = std::env::var(var_name).ok();
        let value = match operator {
            ":-" => match env_value {
                Some(v) if !v.is_empty() => v,
                _ => default.to_string(),
            },
            // "-": use the env value whenever the variable is set, even if empty.
            _ => env_value.unwrap_or_else(|| default.to_string()),
        };

        result = result.replace(full_match, &value);
    }

    result
}

fn replace_variables_in_string(script: &str, args: &[String]) -> Result<String> {
    let script = replace_env_variables_in_string(script);
    let var_regex = Regex::new(r"\{\{(\w+)(?:=([^}]+|\[[^\]]+\]))?\}\}").unwrap();
    let mut result = script.clone();

    for capture in var_regex.captures_iter(&script) {
        let full_match = &capture[0];
        let var_name = &capture[1];
        let default_or_enum = capture.get(2).map(|m| m.as_str()).unwrap_or("");
        let var_pattern = format!("--{}=", var_name);

        if default_or_enum.starts_with('[') && default_or_enum.ends_with(']') {
            let allowed_values: Vec<&str> = default_or_enum[1..default_or_enum.len() - 1]
                .split(',')
                .map(|s| s.trim())
                .collect();

            let value = args
                .iter()
                .find(|arg| arg.starts_with(&var_pattern))
                .map(|arg| arg.trim_start_matches(&var_pattern))
                .ok_or_else(|| anyhow!("Missing required variable: {}", var_name))?;

            if !allowed_values.contains(&value) {
                return Err(anyhow!(
                    "Value '{}' for {} must be one of: {}",
                    value,
                    var_name,
                    allowed_values.join(", ")
                ));
            }

            result = result.replace(full_match, value);
        } else {
            let value = args
                .iter()
                .find(|arg| arg.starts_with(&var_pattern))
                .map(|arg| arg.trim_start_matches(&var_pattern))
                .or(if default_or_enum.is_empty() {
                    None
                } else {
                    Some(default_or_enum)
                })
                .ok_or_else(|| anyhow!("Missing required variable: {}", var_name))?;

            result = result.replace(full_match, value);
        }
    }

    Ok(result)
}