use std::collections::BTreeMap;
use std::ffi::OsString;
use anyhow::{bail, Result};
use serde::Deserialize;
use crate::RootSpec;
#[derive(Debug, Deserialize, Clone, Default, PartialEq, Eq)]
pub struct InputDef {
#[serde(default)]
pub description: String,
#[serde(default)]
pub required: bool,
pub default: Option<String>,
}
impl InputDef {
pub fn validate_name(name: &str) -> Result<()> {
let mut chars = name.chars();
let Some(first) = chars.next() else {
bail!("input name must not be empty");
};
if !(first.is_ascii_alphabetic() || first == '_') {
bail!("input `{name}` must start with a letter or `_`");
}
if !chars.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-') {
bail!("input `{name}` may only contain letters, digits, `_`, and `-`");
}
Ok(())
}
}
pub fn collect_chain_inputs(chain: &[String], spec: &RootSpec) -> BTreeMap<String, InputDef> {
let mut out = BTreeMap::new();
let mut map = &spec.commands;
for seg in chain {
let Some(node) = map.get(seg) else { break };
for (name, def) in &node.inputs {
out.insert(name.clone(), def.clone());
}
map = &node.commands;
}
out
}
pub fn resolve_inputs(
defs: &BTreeMap<String, InputDef>,
trailing: &[OsString],
) -> Result<(BTreeMap<String, String>, Vec<OsString>)> {
for name in defs.keys() {
InputDef::validate_name(name)?;
}
if defs.is_empty() {
return Ok((BTreeMap::new(), trailing.to_vec()));
}
let mut provided: BTreeMap<String, String> = BTreeMap::new();
let mut rest: Vec<OsString> = Vec::new();
let mut i = 0usize;
while i < trailing.len() {
let raw = trailing[i].to_string_lossy();
if raw == "--" {
rest.extend_from_slice(&trailing[i + 1..]);
break;
}
if let Some(body) = raw.strip_prefix("--") {
if body.is_empty() {
rest.push(trailing[i].clone());
i += 1;
continue;
}
let (name, value_opt) = if let Some((n, v)) = body.split_once('=') {
(n.to_string(), Some(v.to_string()))
} else {
(body.to_string(), None)
};
if !defs.contains_key(&name) {
rest.push(trailing[i].clone());
i += 1;
continue;
}
let value = match value_opt {
Some(v) => v,
None => {
i += 1;
let Some(next) = trailing.get(i) else {
bail!("missing value for input flag `--{name}`");
};
let next_s = next.to_string_lossy();
if next_s.starts_with('-') && next_s != "-" {
bail!("missing value for input flag `--{name}`");
}
next_s.into_owned()
}
};
provided.insert(name, value);
i += 1;
continue;
}
rest.push(trailing[i].clone());
i += 1;
}
let mut resolved = BTreeMap::new();
let mut missing = Vec::new();
for (name, def) in defs {
if let Some(v) = provided.get(name) {
resolved.insert(name.clone(), v.clone());
continue;
}
if let Some(default) = &def.default {
resolved.insert(name.clone(), default.clone());
continue;
}
if def.required {
missing.push(format!("--{name}"));
}
}
if !missing.is_empty() {
bail!(
"missing required input(s): {} (see `--help` on this command)",
missing.join(", ")
);
}
Ok((resolved, rest))
}
pub fn interpolate(template: &str, inputs: &BTreeMap<String, String>) -> Result<String> {
let mut out = String::with_capacity(template.len());
let mut rest = template;
while !rest.is_empty() {
if let Some(start) = rest.find("${{") {
out.push_str(&rest[..start]);
let after = &rest[start + 3..];
let Some(end) = after.find("}}") else {
bail!("unclosed `${{{{` expression in `{template}`");
};
let inner = after[..end].trim();
out.push_str(&eval_expr(inner, inputs)?);
rest = &after[end + 2..];
} else {
out.push_str(rest);
break;
}
}
Ok(out)
}
fn eval_expr(inner: &str, inputs: &BTreeMap<String, String>) -> Result<String> {
let Some(name) = inner.strip_prefix("inputs.") else {
bail!(
"unsupported expression `${{{{ {inner} }}}}` \
(only `inputs.<name>` is supported)"
);
};
let name = name.trim();
InputDef::validate_name(name)?;
inputs.get(name).cloned().ok_or_else(|| {
anyhow::anyhow!(
"expression refers to unknown or unset input `{name}` \
(declare it under `inputs:` and pass `--{name}` or set a default)"
)
})
}
pub fn format_inputs_help(defs: &BTreeMap<String, InputDef>) -> String {
if defs.is_empty() {
return String::new();
}
let mut out = String::from("Inputs:\n");
for (name, def) in defs {
let mut line = format!(" --{name} <VALUE>");
let mut notes = Vec::new();
if def.required && def.default.is_none() {
notes.push("required".to_string());
}
if let Some(d) = &def.default {
notes.push(format!("default: {d}"));
}
if !def.description.trim().is_empty() {
if notes.is_empty() {
line.push_str(&format!(" {}", def.description.trim()));
} else {
line.push_str(&format!(
" {} ({})",
def.description.trim(),
notes.join("; ")
));
}
} else if !notes.is_empty() {
line.push_str(&format!(" ({})", notes.join("; ")));
}
out.push_str(&line);
out.push('\n');
}
out.push('\n');
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn resolve_required_and_default() {
let defs = BTreeMap::from([
(
"path".into(),
InputDef {
description: "src".into(),
required: true,
default: None,
},
),
(
"dest".into(),
InputDef {
description: String::new(),
required: false,
default: Some("~/Backups".into()),
},
),
]);
let trailing = vec![OsString::from("--path"), OsString::from("/tmp/a")];
let (vals, rest) = resolve_inputs(&defs, &trailing).unwrap();
assert!(rest.is_empty());
assert_eq!(vals.get("path").map(String::as_str), Some("/tmp/a"));
assert_eq!(vals.get("dest").map(String::as_str), Some("~/Backups"));
}
#[test]
fn resolve_equals_and_passthrough_rest() {
let defs = BTreeMap::from([(
"path".into(),
InputDef {
required: true,
..Default::default()
},
)]);
let trailing = vec![
OsString::from("--path=/tmp"),
OsString::from("--"),
OsString::from("--verbose"),
];
let (vals, rest) = resolve_inputs(&defs, &trailing).unwrap();
assert_eq!(vals["path"], "/tmp");
assert_eq!(rest, vec![OsString::from("--verbose")]);
}
#[test]
fn interpolate_inputs() {
let inputs = BTreeMap::from([("path".into(), "/data".into())]);
assert_eq!(
interpolate("rsync ${{ inputs.path }}/", &inputs).unwrap(),
"rsync /data/"
);
}
#[test]
fn missing_required_errors() {
let defs = BTreeMap::from([(
"path".into(),
InputDef {
required: true,
..Default::default()
},
)]);
let err = resolve_inputs(&defs, &[]).unwrap_err();
assert!(err.to_string().contains("missing required input"));
}
}