use super::*;
pub(crate) const RUN_FLAG_NAMES: &[&str] = &[
"jobs",
"keep-going",
"workdir",
"target",
"module",
"retry",
"timeout",
"log-file",
"resume-failed",
"profile",
"max-threads",
"max-memory",
"max-submitted",
"skip-env-setup",
"skip-ref-build",
"cache-dir",
"provenance",
"bundle",
"yes",
"arg",
"ai-recover",
"ai-max-retries",
"samples",
"rerun",
"no-report-snapshot",
"background",
"json",
"quiet",
"verbose",
"no-color",
"help",
"version",
];
pub(crate) const RUN_SHORT_FLAGS: &[&str] = &["j", "k", "d", "t", "r", "h", "V"];
pub(crate) const DRY_RUN_SHARED_FLAGS: &[&str] = &["cache-dir"];
pub(crate) const REMOVED_RUN_FLAGS: &[(&str, &str)] = &[("sample", "--samples")];
pub(crate) fn validate_config_value(
name: &str,
value: &str,
def: &oxo_flow_core::config::ConfigDef,
from_cli: bool,
) -> anyhow::Result<()> {
if let Some(ref choices) = def.choices
&& !choices.iter().any(|c| c == value)
{
anyhow::bail!(
"invalid value '{}' for config '{}'. Allowed: [{}]\n {}",
value,
name,
choices.join(", "),
def.help.as_deref().unwrap_or("")
);
}
if let Some(ref type_) = def.type_ {
match type_.as_str() {
"int" => {
value.parse::<i64>().map_err(|_| {
anyhow::anyhow!(
"config '{}' expects an integer, got '{}'\n {}",
name,
value,
def.help.as_deref().unwrap_or("")
)
})?;
}
"float" => {
value.parse::<f64>().map_err(|_| {
anyhow::anyhow!(
"config '{}' expects a float, got '{}'\n {}",
name,
value,
def.help.as_deref().unwrap_or("")
)
})?;
}
"bool" => {
if value != "true" && value != "false" {
anyhow::bail!(
"config '{}' expects a boolean (true/false), got '{}'\n {}",
name,
value,
def.help.as_deref().unwrap_or("")
);
}
}
"path" => {
if value.is_empty() && from_cli {
return Ok(());
}
if value.is_empty() {
anyhow::bail!(
"config '{}' expects a path, got empty string\n {}",
name,
def.help.as_deref().unwrap_or("")
);
}
if def.must_exist {
let p = std::path::Path::new(value);
if !p.exists() {
anyhow::bail!(
"config '{}' path does not exist: '{}'\n {}",
name,
value,
def.help.as_deref().unwrap_or("")
);
}
}
}
_ => { }
}
}
if let Some(ref range) = def.range
&& let Some((min_str, max_str)) = range.split_once("..")
{
let min: f64 = min_str.trim().parse().map_err(|_| {
anyhow::anyhow!("invalid range min '{}' for config '{}'", min_str, name)
})?;
let max: f64 = max_str.trim().parse().map_err(|_| {
anyhow::anyhow!("invalid range max '{}' for config '{}'", max_str, name)
})?;
let val: f64 = value.parse().map_err(|_| {
anyhow::anyhow!(
"config '{}' has range '{range}' but value '{}' is not numeric\n {}",
name,
value,
def.help.as_deref().unwrap_or("")
)
})?;
if val < min || val > max {
anyhow::bail!(
"config '{}' value {} is outside range {}..{}\n {}",
name,
val,
min,
max,
def.help.as_deref().unwrap_or("")
);
}
}
Ok(())
}
pub(crate) fn cleanup_cache_dir(cache_dir: &std::path::Path, max_age_days: u64) -> usize {
let max_age = std::time::Duration::from_secs(max_age_days * 24 * 3600);
let now = std::time::SystemTime::now();
let mut removed = 0;
let mut stack = vec![cache_dir.to_path_buf()];
while let Some(dir) = stack.pop() {
let Ok(entries) = std::fs::read_dir(&dir) else {
continue;
};
for entry in entries.flatten() {
let path = entry.path();
let is_file = path.is_file();
if !is_file && !path.is_dir() {
continue;
}
let Ok(metadata) = std::fs::metadata(&path) else {
continue;
};
let Ok(modified) = metadata.modified() else {
continue;
};
let Ok(age) = now.duration_since(modified) else {
continue;
};
if age <= max_age {
if !is_file {
stack.push(path);
}
continue;
}
let ok = if is_file {
std::fs::remove_file(&path).is_ok()
} else {
std::fs::remove_dir_all(&path).is_ok()
};
if ok {
removed += 1;
}
}
}
removed
}
pub(crate) fn ai_attempts(cli_max_retries: Option<u32>) -> u32 {
cli_max_retries.unwrap_or(1).max(1)
}
pub(crate) fn failure_exit_code(failure_reason: &str) -> i32 {
failure_reason
.lines()
.next()
.and_then(|line| line.split("exit code ").nth(1))
.and_then(|rest| {
rest.split_whitespace()
.next()
.and_then(|tok| tok.parse().ok())
})
.unwrap_or(-1)
}
#[allow(clippy::too_many_arguments)]
pub(crate) fn parse_cli_overrides(
cli_args: Vec<String>,
declared_config_keys: &std::collections::HashSet<String>,
command: &str,
) -> anyhow::Result<std::collections::HashMap<String, String>> {
let mut cli_arg_values: std::collections::HashMap<String, String> =
std::collections::HashMap::new();
let mut cli_args_iter = cli_args.into_iter().peekable();
while let Some(arg_str) = cli_args_iter.next() {
let swallowed_flag = arg_str
.strip_prefix("--")
.map(|flag| flag.split('=').next().unwrap_or(flag))
.filter(|name| RUN_FLAG_NAMES.contains(name))
.map(|name| format!("--{name}"))
.or_else(|| {
arg_str
.strip_prefix('-')
.filter(|rest| !rest.starts_with('-'))
.map(|flag| flag.split('=').next().unwrap_or(flag))
.filter(|name| RUN_SHORT_FLAGS.contains(name))
.map(|name| format!("-{name}"))
});
if let Some(flag) = swallowed_flag {
let flag_name = flag.trim_start_matches('-');
let is_run_flag = RUN_FLAG_NAMES.contains(&flag_name)
|| (flag_name.len() == 2 && RUN_SHORT_FLAGS.contains(&&flag_name[1..]));
let hint = if command == "run" {
format!(
"'{flag}' is a command flag, not a config override.\n \
Command flags must come before KEY=VALUE overrides, e.g.:\n \
oxo-flow run <workflow.oxoflow> --json min_quality=30\n \
For a config key that itself starts with dashes, use --arg KEY=VALUE\n \
(also placed before positional overrides)."
)
} else if is_run_flag {
let shared = command == "dry-run" && DRY_RUN_SHARED_FLAGS.contains(&flag_name);
if shared {
format!(
"'{flag}' is a command flag, not a config override.\n \
Command flags must come before KEY=VALUE overrides, e.g.:\n \
oxo-flow {command} <workflow.oxoflow> --cache-dir .env-cache min_quality=30"
)
} else {
format!(
"'{flag}' is a run flag and is not supported by {command}.\n \
Run flags only work on `oxo-flow run`; {command} accepts its own \
flags before KEY=VALUE overrides, e.g.:\n \
oxo-flow {command} <workflow.oxoflow> --target my_rule min_quality=30"
)
}
} else {
format!(
"'{flag}' is not supported by {command} and is not a config override.\n \
Config overrides take KEY=VALUE, e.g.:\n \
oxo-flow {command} <workflow.oxoflow> min_quality=30"
)
};
anyhow::bail!("{hint}");
}
let (mut k, v) = if let Some(eq) = arg_str.find('=') {
let k = arg_str[..eq].trim_start_matches('-').to_string();
(k, arg_str[eq + 1..].to_string())
} else if let Some(k) = arg_str.strip_prefix("--") {
let space_form_key = if declared_config_keys.contains(k) {
Some(k.to_string())
} else {
canonicalize_override_key(k, declared_config_keys)
};
if let Some(k) = space_form_key {
if let Some(next) = cli_args_iter.peek()
&& next.starts_with("--")
{
anyhow::bail!(
"config flag '--{k}' expects a value, but the next argument '{next}' \
is a command flag.\n Use the '=' form (--{k}=<value>) or place the \
override after the command flags as {k}=<value>."
);
}
let v = cli_args_iter.next().with_context(|| {
format!(
"invalid config flag: '{arg_str}' — expected --KEY=VALUE or --KEY VALUE"
)
})?;
(k.to_string(), v)
} else {
let rename = REMOVED_RUN_FLAGS
.iter()
.find(|(removed, _)| *removed == k)
.map(|(removed, replacement)| {
format!("\n '--{removed}' was renamed to {replacement}.")
})
.unwrap_or_default();
anyhow::bail!(
"unknown argument '{arg_str}' — did you mean KEY=VALUE overrides?{rename}\n \
Config overrides take KEY=VALUE (e.g. threads=8) or --KEY=VALUE, e.g.:\n \
oxo-flow {command} <workflow.oxoflow> threads=8; \
for a config key that itself starts with dashes, use --arg KEY=VALUE"
);
}
} else {
anyhow::bail!(
"invalid config value format: '{arg_str}' — expected KEY=VALUE, --KEY=VALUE, or --KEY VALUE"
);
};
if k.is_empty() {
anyhow::bail!("invalid config value format: '{arg_str}' — KEY must be non-empty");
}
if !declared_config_keys.contains(&k)
&& let Some(canonical) = canonicalize_override_key(&k, declared_config_keys)
{
k = canonical;
}
cli_arg_values.insert(k, v);
}
Ok(cli_arg_values)
}
pub(crate) fn canonicalize_override_key(
key: &str,
declared_config_keys: &std::collections::HashSet<String>,
) -> Option<String> {
if !key.contains('-') {
return None;
}
let normalized = key.replace('-', "_");
if declared_config_keys.contains(&normalized) {
return Some(normalized);
}
let matches: Vec<&String> = declared_config_keys
.iter()
.filter(|d| d.replace('-', "_") == normalized)
.collect();
match matches.as_slice() {
[only] => Some((*only).clone()),
_ => None,
}
}
pub(crate) fn apply_cli_overrides(
config: &mut WorkflowConfig,
cli_arg_values: &std::collections::HashMap<String, String>,
) -> anyhow::Result<()> {
for (name, cfg_def) in &config.config_meta {
let effective_value = if let Some(val) = cli_arg_values.get(name) {
validate_config_value(name, val, cfg_def, true)?;
config
.config
.insert(name.clone(), toml::Value::String(val.clone()));
val.clone()
} else if let Some(ref default) = cfg_def.default {
validate_config_value(name, default, cfg_def, false)?;
config
.config
.entry(name.clone())
.or_insert_with(|| toml::Value::String(default.clone()));
default.clone()
} else if cfg_def.required {
let help_suffix = cfg_def
.help
.as_deref()
.map(|h| format!("\n {h}"))
.unwrap_or_default();
let display_name = if cfg_def.sensitive {
format!("{name} (sensitive)")
} else {
name.clone()
};
anyhow::bail!(
"required config '{display_name}' not set. Use --{name} <value>{help_suffix}"
);
} else {
continue;
};
if cfg_def.sensitive {
tracing::info!("config '{}' = **** (sensitive)", name);
} else {
tracing::debug!("config '{}' = '{}'", name, effective_value);
}
}
for (k, v) in cli_arg_values {
config
.config
.insert(k.clone(), toml::Value::String(v.clone()));
}
Ok(())
}
pub(crate) fn warn_unknown_override_keys(
overrides: &HashMap<String, String>,
declared: &HashSet<String>,
workflow: &Path,
) {
let unknown: Vec<&String> = overrides
.keys()
.filter(|key| !declared.contains(*key))
.collect();
if unknown.is_empty() {
return;
}
let text = std::fs::read_to_string(workflow).unwrap_or_default();
for key in unknown {
if text.contains(&format!("config.{key}")) {
continue;
}
let hint = closest_declared_key(key, declared)
.map(|candidate| format!(" — did you mean '{candidate}'?"))
.unwrap_or_default();
eprintln!(
"{} override '{key}' is not a declared [config] key and the workflow never \
references {{config.{key}}}{hint} It changes nothing.",
"Warning:".bold().yellow(),
);
}
}
pub(crate) fn closest_declared_key<'a>(
key: &str,
declared: &'a HashSet<String>,
) -> Option<&'a str> {
let lowered = key.to_ascii_lowercase();
declared
.iter()
.map(|candidate| {
(
edit_distance(&lowered, &candidate.to_ascii_lowercase(), 2),
candidate,
)
})
.filter(|(distance, _)| *distance <= 2)
.min_by_key(|(distance, candidate)| (*distance, candidate.as_str()))
.map(|(_, candidate)| candidate.as_str())
}
pub(crate) fn edit_distance(a: &str, b: &str, cap: usize) -> usize {
let a: Vec<char> = a.chars().collect();
let b: Vec<char> = b.chars().collect();
if a.len().abs_diff(b.len()) > cap {
return cap + 1;
}
let mut prev: Vec<usize> = (0..=b.len()).collect();
let mut cur = vec![0usize; b.len() + 1];
for (i, ca) in a.iter().enumerate() {
cur[0] = i + 1;
for (j, cb) in b.iter().enumerate() {
let cost = usize::from(ca != cb);
cur[j + 1] = (prev[j + 1] + 1).min(cur[j] + 1).min(prev[j] + cost);
}
std::mem::swap(&mut prev, &mut cur);
}
prev[b.len()]
}
pub(crate) fn nearest_rule_names(name: &str, candidates: &[&str], limit: usize) -> Vec<String> {
let lowered = name.to_ascii_lowercase();
let mut scored: Vec<(usize, &str)> = candidates
.iter()
.filter(|candidate| **candidate != name)
.filter_map(|candidate| {
let cand = candidate.to_ascii_lowercase();
if cand.starts_with(&lowered) || lowered.starts_with(&cand) {
Some((0, *candidate))
} else {
let d = edit_distance(&lowered, &cand, 3);
(d <= 3).then_some((d, *candidate))
}
})
.collect();
scored.sort_unstable_by(|a, b| a.0.cmp(&b.0).then(a.1.cmp(b.1)));
scored.truncate(limit);
scored
.into_iter()
.map(|(_, name)| name.to_string())
.collect()
}