use crate::error::{OverrideError, Result};
use raz_common::env::{EnvParser, is_valid_env_var_name};
use raz_config::{CommandOverride, OverrideMode};
use raz_validation::{ValidationConfig, ValidationEngine};
use std::collections::HashMap;
#[derive(Debug, Clone, PartialEq)]
pub enum OptionValue {
Flag(bool),
Single(String),
Multiple(Vec<String>),
}
#[derive(Debug, Clone)]
pub struct ParsedOverride {
pub env: HashMap<String, String>,
pub env_removals: Vec<String>,
pub options: HashMap<String, OptionValue>,
pub option_removals: Vec<String>,
pub extra_args: Vec<String>,
pub arg_removals: Vec<String>,
pub raw_tokens: Vec<String>,
pub reset_all: bool,
pub reset_env: bool,
pub reset_options: bool,
pub reset_args: bool,
}
impl Default for ParsedOverride {
fn default() -> Self {
Self::new()
}
}
impl ParsedOverride {
pub fn new() -> Self {
Self {
env: HashMap::new(),
env_removals: Vec::new(),
options: HashMap::new(),
option_removals: Vec::new(),
extra_args: Vec::new(),
arg_removals: Vec::new(),
raw_tokens: Vec::new(),
reset_all: false,
reset_env: false,
reset_options: false,
reset_args: false,
}
}
pub fn to_command_override(&self, append_mode: bool) -> CommandOverride {
let mut override_cmd = CommandOverride::new(String::new());
override_cmd.mode = if append_mode {
OverrideMode::Append
} else {
OverrideMode::Replace
};
for (key, value) in &self.env {
override_cmd.env.insert(key.clone(), value.clone());
}
for (key, value) in &self.options {
let option_str = match value {
OptionValue::Flag(true) => key.clone(),
OptionValue::Flag(false) => continue, OptionValue::Single(val) => format!("{key} {val}"),
OptionValue::Multiple(vals) => format!("{} {}", key, vals.join(" ")),
};
if key.starts_with("--") || key.starts_with("-") {
override_cmd.cargo_options.push(option_str);
}
}
override_cmd.args = self.extra_args.clone();
override_cmd
}
}
pub struct OverrideParser {
command: String,
validation_engine: ValidationEngine,
}
impl OverrideParser {
pub fn new(command: &str) -> Self {
Self {
command: command.to_string(),
validation_engine: ValidationEngine::new(),
}
}
pub fn with_validation_config(command: &str, config: ValidationConfig) -> Self {
Self {
command: command.to_string(),
validation_engine: ValidationEngine::with_config(config),
}
}
pub fn parse(&self, input: &str) -> Result<ParsedOverride> {
self.parse_with_mode(input).map(|(parsed, _)| parsed)
}
pub fn parse_with_mode(&self, input: &str) -> Result<(ParsedOverride, bool)> {
let mut result = ParsedOverride::new();
let (append_mode, input) = if let Some(stripped) = input.strip_prefix("+ ") {
(true, stripped)
} else {
(false, input)
};
let tokens = self.tokenize(input)?;
result.raw_tokens = tokens.clone();
let mut i = 0;
let mut after_double_dash = false;
while i < tokens.len() {
let token = &tokens[i];
if token == "--" {
after_double_dash = true;
i += 1;
continue;
}
if self.handle_reset_command(token, &mut result) {
i += 1;
continue;
}
if after_double_dash {
self.handle_arg_token(token, &mut result)?;
} else {
if let Some(consumed) = self.handle_env_token(token, &mut result)? {
i += consumed - 1;
} else if let Some(consumed) = self.handle_option_token(&tokens, i, &mut result)? {
i += consumed - 1;
} else {
result.extra_args.push(token.clone());
}
}
i += 1;
}
Ok((result, append_mode))
}
fn tokenize(&self, input: &str) -> Result<Vec<String>> {
let mut tokens = Vec::new();
let mut current = String::new();
let mut in_quote = false;
let mut quote_char = ' ';
let mut chars = input.chars().peekable();
while let Some(ch) = chars.next() {
match ch {
'"' | '\'' if !in_quote => {
in_quote = true;
quote_char = ch;
}
'"' | '\'' if in_quote && ch == quote_char => {
in_quote = false;
if !current.is_empty() {
tokens.push(current.clone());
current.clear();
}
}
'\\' if chars.peek().is_some() => {
if let Some(next) = chars.next() {
current.push(next);
}
}
' ' | '\t' if !in_quote => {
if !current.is_empty() {
tokens.push(current.clone());
current.clear();
}
}
_ => {
current.push(ch);
}
}
}
if in_quote {
return Err(OverrideError::ParseError(format!(
"Unclosed quote in input: {input}"
)));
}
if !current.is_empty() {
tokens.push(current);
}
Ok(tokens)
}
fn is_env_var(&self, token: &str) -> bool {
if !token.contains('=') || token.starts_with('-') {
return false;
}
let var_name = token.split('=').next().unwrap();
is_valid_env_var_name(var_name)
&& var_name
.chars()
.all(|c| c.is_ascii_uppercase() || c.is_ascii_digit() || c == '_')
}
fn parse_env_var(&self, token: &str) -> Result<(String, String)> {
EnvParser::parse_assignment(token).map_err(|e| OverrideError::ParseError(e.to_string()))
}
fn parse_option(
&self,
tokens: &[String],
index: usize,
) -> Result<(String, OptionValue, usize)> {
let option = &tokens[index];
if index + 1 < tokens.len() && !tokens[index + 1].starts_with('-') {
if tokens[index + 1].contains(',') {
let values: Vec<String> = tokens[index + 1]
.split(',')
.map(|s| s.trim().to_string())
.collect();
Ok((option.to_string(), OptionValue::Multiple(values), 2))
} else {
Ok((
option.to_string(),
OptionValue::Single(tokens[index + 1].clone()),
2,
))
}
} else {
Ok((option.to_string(), OptionValue::Flag(true), 1))
}
}
fn handle_reset_command(&self, token: &str, result: &mut ParsedOverride) -> bool {
match token {
"!!" => {
result.reset_all = true;
true
}
"!e" | "!env" => {
result.reset_env = true;
true
}
"!o" | "!options" => {
result.reset_options = true;
true
}
"!a" | "!args" => {
result.reset_args = true;
true
}
_ => {
if token.starts_with('!') && token.len() > 1 {
let removal_part = &token[1..];
if removal_part.starts_with("--") {
result.option_removals.push(removal_part.to_string());
return true;
} else if removal_part.contains('=') {
let env_name = removal_part.split('=').next().unwrap();
if env_name
.chars()
.all(|c| c.is_ascii_uppercase() || c.is_ascii_digit() || c == '_')
{
result.env_removals.push(env_name.to_string());
return true;
}
} else if removal_part
.chars()
.all(|c| c.is_ascii_uppercase() || c.is_ascii_digit() || c == '_')
{
result.env_removals.push(removal_part.to_string());
return true;
}
}
false
}
}
}
fn handle_env_token(&self, token: &str, result: &mut ParsedOverride) -> Result<Option<usize>> {
if token.starts_with('+') && self.is_env_var(&token[1..]) {
let (key, value) = self.parse_env_var(&token[1..])?;
result.env.insert(key, value);
Ok(Some(1))
} else if token.starts_with('-') && !token.starts_with("--") {
let env_part = &token[1..];
if env_part.contains('=') {
let (key, _) = self.parse_env_var(env_part)?;
result.env_removals.push(key);
} else if env_part
.chars()
.all(|c| c.is_ascii_uppercase() || c.is_ascii_digit() || c == '_')
{
result.env_removals.push(env_part.to_string());
} else {
return Ok(None); }
Ok(Some(1))
} else if self.is_env_var(token) {
let (key, value) = self.parse_env_var(token)?;
result.env.insert(key, value);
Ok(Some(1))
} else {
Ok(None)
}
}
fn handle_option_token(
&self,
tokens: &[String],
index: usize,
result: &mut ParsedOverride,
) -> Result<Option<usize>> {
let token = &tokens[index];
if token.starts_with('+') && token.len() > 3 && token[1..].starts_with("--") {
let option_part = &token[1..];
let (option, value, consumed) =
self.parse_option_at_index(tokens, index, option_part)?;
result.options.insert(option, value);
Ok(Some(consumed))
} else if token.starts_with("---") && token.len() > 3 {
let option_to_remove = &token[1..]; result.option_removals.push(option_to_remove.to_string());
Ok(Some(1))
} else if token.starts_with("--") {
let (option, value, consumed) = self.parse_option(tokens, index)?;
result.options.insert(option, value);
Ok(Some(consumed))
} else {
Ok(None)
}
}
fn handle_arg_token(&self, token: &str, result: &mut ParsedOverride) -> Result<()> {
if let Some(stripped) = token.strip_prefix('+') {
result.extra_args.push(stripped.to_string());
} else if let Some(stripped) = token.strip_prefix('-') {
if !token.starts_with("--") {
result.arg_removals.push(stripped.to_string());
} else {
result.extra_args.push(token.to_string());
}
} else {
result.extra_args.push(token.to_string());
}
Ok(())
}
fn parse_option_at_index(
&self,
tokens: &[String],
index: usize,
option: &str,
) -> Result<(String, OptionValue, usize)> {
if index + 1 < tokens.len() && !tokens[index + 1].starts_with('-') {
if tokens[index + 1].contains(',') {
let values: Vec<String> = tokens[index + 1]
.split(',')
.map(|s| s.trim().to_string())
.collect();
Ok((option.to_string(), OptionValue::Multiple(values), 2))
} else {
Ok((
option.to_string(),
OptionValue::Single(tokens[index + 1].clone()),
2,
))
}
} else {
Ok((option.to_string(), OptionValue::Flag(true), 1))
}
}
pub fn validate(&self, parsed: &ParsedOverride) -> Result<()> {
for (option, value) in &parsed.options {
let joined_values;
let option_value = match value {
OptionValue::Flag(_) => None,
OptionValue::Single(val) => Some(val.as_str()),
OptionValue::Multiple(vals) => {
joined_values = vals.join(",");
Some(joined_values.as_str())
}
};
if let Err(validation_error) =
self.validation_engine
.validate_option(&self.command, option, option_value)
{
return Err(OverrideError::ValidationError(validation_error.to_string()));
}
}
let option_map: HashMap<String, Option<String>> = parsed
.options
.iter()
.map(|(key, value)| {
let val = match value {
OptionValue::Flag(_) => None,
OptionValue::Single(val) => Some(val.clone()),
OptionValue::Multiple(vals) => Some(vals.join(",")),
};
(key.clone(), val)
})
.collect();
if let Err(validation_error) = self
.validation_engine
.validate_options(&self.command, &option_map)
{
return Err(OverrideError::ValidationError(validation_error.to_string()));
}
Ok(())
}
pub fn suggest_option(&self, option: &str) -> Vec<String> {
self.validation_engine.suggest_option(&self.command, option)
}
}
pub fn parse_override(command: &str, input: &str) -> Result<ParsedOverride> {
let parser = OverrideParser::new(command);
let parsed = parser.parse(input)?;
parser.validate(&parsed)?;
Ok(parsed)
}
pub fn parse_override_to_command(command: &str, input: &str) -> Result<CommandOverride> {
let parser = OverrideParser::new(command);
let (parsed, append_mode) = parser.parse_with_mode(input)?;
parser.validate(&parsed)?;
Ok(parsed.to_command_override(append_mode))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_tokenize() {
let parser = OverrideParser::new("test");
let tokens = parser.tokenize("--features foo bar").unwrap();
assert_eq!(tokens, vec!["--features", "foo", "bar"]);
let tokens = parser.tokenize("--features \"foo bar\" baz").unwrap();
assert_eq!(tokens, vec!["--features", "foo bar", "baz"]);
let tokens = parser
.tokenize("--name 'my app' --desc \"hello world\"")
.unwrap();
assert_eq!(tokens, vec!["--name", "my app", "--desc", "hello world"]);
let tokens = parser
.tokenize(r#"--path /home/user\ with\ spaces"#)
.unwrap();
assert_eq!(tokens, vec!["--path", "/home/user with spaces"]);
}
#[test]
fn test_parse_env_vars() {
let parser = OverrideParser::new("run");
let parsed = parser
.parse("RUST_LOG=debug FOO_BAR=bar SOME123=value --release")
.unwrap();
assert_eq!(parsed.env.get("RUST_LOG"), Some(&"debug".to_string()));
assert_eq!(parsed.env.get("FOO_BAR"), Some(&"bar".to_string()));
assert_eq!(parsed.env.get("SOME123"), Some(&"value".to_string()));
assert!(parsed.options.contains_key("--release"));
let parsed = parser
.parse("lowercase=value MixedCase=value 123INVALID=value")
.unwrap();
assert!(parsed.env.is_empty());
assert_eq!(parsed.extra_args.len(), 3);
}
#[test]
fn test_parse_args_after_double_dash() {
let parser = OverrideParser::new("test");
let parsed = parser
.parse("--release --features foo -- --nocapture --test-threads=1")
.unwrap();
assert!(parsed.options.contains_key("--release"));
assert!(parsed.options.contains_key("--features"));
if let Some(OptionValue::Single(val)) = parsed.options.get("--features") {
assert_eq!(val, "foo");
} else {
panic!("Expected --features to have value 'foo'");
}
assert_eq!(parsed.extra_args, vec!["--nocapture", "--test-threads=1"]);
let parsed = parser.parse("--release --nocapture").unwrap();
assert!(parsed.options.contains_key("--release"));
assert!(parsed.options.contains_key("--nocapture"));
assert!(parsed.extra_args.is_empty());
}
#[test]
fn test_parse_mixed_content_with_double_dash() {
let parser = OverrideParser::new("test");
let parsed = parser
.parse("RUST_LOG=debug --release --bin myapp -- --nocapture arg1 arg2")
.unwrap();
assert_eq!(parsed.env.get("RUST_LOG"), Some(&"debug".to_string()));
assert!(parsed.options.contains_key("--release"));
assert!(parsed.options.contains_key("--bin"));
if let Some(OptionValue::Single(val)) = parsed.options.get("--bin") {
assert_eq!(val, "myapp");
}
assert_eq!(parsed.extra_args, vec!["--nocapture", "arg1", "arg2"]);
}
}