use std::collections::HashMap;
use cuenv_core::Result;
use cuenv_core::tasks::{ResolvedArgs, Task, TaskParams};
pub fn looks_like_flag(arg: &str) -> bool {
if !arg.starts_with('-') {
return false;
}
let rest = &arg[1..];
if rest.is_empty() {
return false;
}
rest.parse::<f64>().is_err()
}
pub fn parse_task_args(
args: &[String],
params: Option<&TaskParams>,
) -> (Vec<String>, HashMap<String, String>) {
let mut positional = Vec::new();
let mut named = HashMap::new();
let mut flags_ended = false;
let short_to_long: HashMap<String, String> = params
.map(|p| {
p.named
.iter()
.filter_map(|(name, def)| def.short.as_ref().map(|s| (s.clone(), name.clone())))
.collect()
})
.unwrap_or_default();
let mut i = 0;
while i < args.len() {
let arg = &args[i];
if arg == "--" {
flags_ended = true;
i += 1;
continue;
}
if flags_ended {
positional.push(arg.clone());
} else if arg.starts_with("--") {
let key = arg.strip_prefix("--").unwrap_or(arg);
if let Some((k, v)) = key.split_once('=') {
named.insert(k.to_string(), v.to_string());
} else if i + 1 < args.len() && !looks_like_flag(&args[i + 1]) {
named.insert(key.to_string(), args[i + 1].clone());
i += 1;
} else {
named.insert(key.to_string(), "true".to_string());
}
} else if let Some(short_key) = arg.strip_prefix('-') {
if short_key.len() == 1 && !short_key.chars().next().unwrap_or('0').is_ascii_digit() {
let long_key = short_to_long
.get(short_key)
.cloned()
.unwrap_or_else(|| short_key.to_string());
if i + 1 < args.len() && !looks_like_flag(&args[i + 1]) {
named.insert(long_key, args[i + 1].clone());
i += 1;
} else {
named.insert(long_key, "true".to_string());
}
} else {
positional.push(arg.clone());
}
} else {
positional.push(arg.clone());
}
i += 1;
}
(positional, named)
}
pub fn resolve_task_args(params: Option<&TaskParams>, cli_args: &[String]) -> Result<ResolvedArgs> {
let (positional_values, named_values) = parse_task_args(cli_args, params);
let mut resolved = ResolvedArgs::new();
if let Some(params) = params {
let max_positional = params.positional.len();
if positional_values.len() > max_positional {
return Err(cuenv_core::Error::configuration(format!(
"Too many positional arguments: expected at most {}, got {}",
max_positional,
positional_values.len()
)));
}
let unknown_flags: Vec<String> = named_values
.keys()
.filter(|k| !params.named.contains_key(*k))
.map(|k| format!("--{k}"))
.collect();
if !unknown_flags.is_empty() {
return Err(cuenv_core::Error::configuration(format!(
"Unknown argument(s): {}",
unknown_flags.join(", ")
)));
}
for (i, param_def) in params.positional.iter().enumerate() {
if let Some(value) = positional_values.get(i) {
resolved.positional.push(value.clone());
} else if let Some(default) = ¶m_def.default {
resolved.positional.push(default.clone());
} else if param_def.required {
let default_desc = format!("positional argument {i}");
let desc = param_def.description.as_deref().unwrap_or(&default_desc);
return Err(cuenv_core::Error::configuration(format!(
"Missing required argument: {desc}"
)));
} else {
resolved.positional.push(String::new());
}
}
for (name, param_def) in ¶ms.named {
if let Some(value) = named_values.get(name) {
resolved.named.insert(name.clone(), value.clone());
} else if let Some(default) = ¶m_def.default {
resolved.named.insert(name.clone(), default.clone());
} else if param_def.required {
return Err(cuenv_core::Error::configuration(format!(
"Missing required argument: --{name}"
)));
}
}
} else {
resolved.positional = positional_values;
resolved.named = named_values;
}
Ok(resolved)
}
pub fn apply_args_to_task(task: &Task, resolved_args: &ResolvedArgs) -> Task {
let mut new_task = task.clone();
new_task.command = resolved_args.interpolate(&task.command);
new_task.args = resolved_args.interpolate_args(&task.args);
new_task
}
#[cfg(test)]
mod tests {
use super::*;
use cuenv_core::tasks::ParamDef;
use std::collections::HashMap;
#[test]
fn test_looks_like_flag() {
assert!(looks_like_flag("-v"));
assert!(looks_like_flag("--verbose"));
assert!(looks_like_flag("-abc"));
assert!(!looks_like_flag("file.txt"));
assert!(!looks_like_flag("-1"));
assert!(!looks_like_flag("-3.14"));
assert!(!looks_like_flag("-"));
}
#[test]
fn test_parse_task_args_simple() {
let args: Vec<String> = vec!["pos1".into(), "--flag".into(), "value".into()];
let (positional, named) = parse_task_args(&args, None);
assert_eq!(positional, vec!["pos1"]);
assert_eq!(named.get("flag"), Some(&"value".to_string()));
}
#[test]
fn test_parse_task_args_double_dash() {
let args: Vec<String> = vec![
"--flag".into(),
"value".into(),
"--".into(),
"--not-a-flag".into(),
];
let (positional, named) = parse_task_args(&args, None);
assert_eq!(positional, vec!["--not-a-flag"]);
assert_eq!(named.get("flag"), Some(&"value".to_string()));
}
#[test]
fn test_parse_task_args_equals_syntax() {
let args: Vec<String> = vec!["--key=value".into()];
let (positional, named) = parse_task_args(&args, None);
assert!(positional.is_empty());
assert_eq!(named.get("key"), Some(&"value".to_string()));
}
#[test]
fn test_parse_task_args_short_flags() {
let mut named_params = HashMap::new();
named_params.insert(
"verbose".to_string(),
ParamDef {
short: Some("v".to_string()),
..Default::default()
},
);
let params = TaskParams {
positional: vec![],
named: named_params,
};
let args: Vec<String> = vec!["-v".into()];
let (_, named) = parse_task_args(&args, Some(¶ms));
assert_eq!(named.get("verbose"), Some(&"true".to_string()));
}
#[test]
fn test_parse_task_args_boolean_flag_at_end() {
let args: Vec<String> = vec!["--verbose".into()];
let (positional, named) = parse_task_args(&args, None);
assert!(positional.is_empty());
assert_eq!(named.get("verbose"), Some(&"true".to_string()));
}
#[test]
fn test_parse_task_args_short_flag_with_value() {
let mut named_params = HashMap::new();
named_params.insert(
"file".to_string(),
ParamDef {
short: Some("f".to_string()),
..Default::default()
},
);
let params = TaskParams {
positional: vec![],
named: named_params,
};
let args: Vec<String> = vec!["-f".into(), "test.txt".into()];
let (_, named) = parse_task_args(&args, Some(¶ms));
assert_eq!(named.get("file"), Some(&"test.txt".to_string()));
}
#[test]
fn test_parse_task_args_negative_number_as_positional() {
let args: Vec<String> = vec!["-5".into()];
let (positional, named) = parse_task_args(&args, None);
assert_eq!(positional, vec!["-5"]);
assert!(named.is_empty());
}
#[test]
fn test_parse_task_args_multi_char_short_as_positional() {
let args: Vec<String> = vec!["-abc".into()];
let (positional, named) = parse_task_args(&args, None);
assert_eq!(positional, vec!["-abc"]);
assert!(named.is_empty());
}
#[test]
fn test_resolve_task_args_no_params() {
let args: Vec<String> = vec!["pos1".into(), "--flag".into(), "value".into()];
let resolved = resolve_task_args(None, &args).unwrap();
assert_eq!(resolved.positional, vec!["pos1"]);
assert_eq!(resolved.named.get("flag"), Some(&"value".to_string()));
}
#[test]
fn test_resolve_task_args_too_many_positional() {
let params = TaskParams {
positional: vec![ParamDef::default()], named: HashMap::new(),
};
let args: Vec<String> = vec!["pos1".into(), "pos2".into()];
let result = resolve_task_args(Some(¶ms), &args);
assert!(result.is_err());
assert!(
result
.unwrap_err()
.to_string()
.contains("Too many positional")
);
}
#[test]
fn test_resolve_task_args_unknown_flag() {
let params = TaskParams {
positional: vec![],
named: HashMap::new(),
};
let args: Vec<String> = vec!["--unknown".into(), "value".into()];
let result = resolve_task_args(Some(¶ms), &args);
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("Unknown argument"));
}
#[test]
fn test_resolve_task_args_missing_required_positional() {
let mut positional = vec![ParamDef::default()];
positional[0].required = true;
let params = TaskParams {
positional,
named: HashMap::new(),
};
let args: Vec<String> = vec![];
let result = resolve_task_args(Some(¶ms), &args);
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("Missing required"));
}
#[test]
fn test_resolve_task_args_missing_required_named() {
let mut named_params = HashMap::new();
named_params.insert(
"input".to_string(),
ParamDef {
required: true,
..Default::default()
},
);
let params = TaskParams {
positional: vec![],
named: named_params,
};
let args: Vec<String> = vec![];
let result = resolve_task_args(Some(¶ms), &args);
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("--input"));
}
#[test]
fn test_resolve_task_args_default_values() {
let mut positional = vec![ParamDef::default()];
positional[0].default = Some("default_pos".to_string());
let mut named_params = HashMap::new();
named_params.insert(
"flag".to_string(),
ParamDef {
default: Some("default_val".to_string()),
..Default::default()
},
);
let params = TaskParams {
positional,
named: named_params,
};
let args: Vec<String> = vec![];
let resolved = resolve_task_args(Some(¶ms), &args).unwrap();
assert_eq!(resolved.positional, vec!["default_pos"]);
assert_eq!(resolved.named.get("flag"), Some(&"default_val".to_string()));
}
#[test]
fn test_resolve_task_args_optional_without_default() {
let mut positional = vec![ParamDef::default()];
positional[0].required = false;
let params = TaskParams {
positional,
named: HashMap::new(),
};
let args: Vec<String> = vec![];
let resolved = resolve_task_args(Some(¶ms), &args).unwrap();
assert_eq!(resolved.positional, vec![""]);
}
#[test]
fn test_apply_args_to_task() {
let task = Task {
command: "echo".to_string(),
args: vec![
"{{0}}".to_string(),
"--name".to_string(),
"{{name}}".to_string(),
],
..Default::default()
};
let mut resolved = ResolvedArgs::new();
resolved.positional.push("hello".to_string());
resolved
.named
.insert("name".to_string(), "world".to_string());
let new_task = apply_args_to_task(&task, &resolved);
assert_eq!(new_task.args, vec!["hello", "--name", "world"]);
}
}