use crate::ast::{Arg, Expr, StepKind};
use crate::error::{ParseError, SpanContext};
use anyhow::{Result, anyhow, bail};
pub struct ArgSpec {
pub name: &'static str,
pub arg_type: ArgType,
pub description: &'static str,
pub io: IoDirection,
pub index: usize,
pub required: bool,
pub fallback_stream: Option<Stream>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ArgType {
String,
Path,
Int,
Duration,
List,
Any,
OneOf(&'static [&'static str]),
Rest(&'static ArgType),
}
impl ArgType {
pub fn label(&self) -> String {
match self {
ArgType::String => "STRING".to_string(),
ArgType::Path => "PATH".to_string(),
ArgType::Int => "INT".to_string(),
ArgType::Duration => "DURATION".to_string(),
ArgType::List => "LIST".to_string(),
ArgType::Any => "<any>".to_string(),
ArgType::OneOf(options) => options.join("|"),
ArgType::Rest(inner) => format!("{}...", inner.label()),
}
}
pub fn anchor(&self) -> Option<String> {
match self {
ArgType::String => Some(crate::value::type_anchor("STRING")),
ArgType::Path => Some(crate::value::type_anchor("PATH")),
ArgType::Int => Some(crate::value::type_anchor("INT")),
ArgType::Duration => Some(crate::value::type_anchor("DURATION")),
ArgType::List => Some(crate::value::type_anchor("LIST")),
ArgType::Any => None,
ArgType::OneOf(_) => None,
ArgType::Rest(inner) => inner.anchor(),
}
}
pub fn validate_literal(&self, literal: &str) -> Result<()> {
match self {
ArgType::String | ArgType::Path | ArgType::Any => Ok(()),
ArgType::Int => strip_digit_separators(literal)
.parse::<i64>()
.map(|_| ())
.map_err(|_| anyhow!("expected int, got {literal:?}")),
ArgType::Duration => parse_duration(literal).map(|_| ()),
ArgType::List => {
if literal.starts_with('$') {
Ok(())
} else {
bail!("expected $var, got {literal:?}")
}
}
ArgType::OneOf(options) => {
if options.contains(&literal) {
Ok(())
} else {
bail!("expected one of {}, got {literal:?}", options.join("|"))
}
}
ArgType::Rest(inner) => inner.validate_literal(literal),
}
}
pub fn check_arg(&self, arg: &Arg) -> Result<CheckOutcome> {
match arg {
Arg::String(s, _) if !s.contains("{{") => {
self.validate_literal(s)?;
Ok(CheckOutcome::Static)
}
Arg::String(_, _) => Ok(CheckOutcome::Deferred),
Arg::Parts(_) => Ok(CheckOutcome::Deferred),
Arg::Expr(Expr::Var(_)) => {
if matches!(*self, ArgType::List) {
Ok(CheckOutcome::Static)
} else {
Ok(CheckOutcome::Deferred)
}
}
Arg::Expr(_) => {
if matches!(*self, ArgType::List) {
bail!("expected $var, got expression {}", arg.render())
} else {
Ok(CheckOutcome::Deferred)
}
}
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CheckOutcome {
Static,
Deferred,
}
pub fn validate_positionals_against_meta(
cmd_name: &str,
specs: &[ArgSpec],
args: &[Arg],
) -> Result<(), ParseError> {
let has_rest = specs
.last()
.is_some_and(|s| matches!(s.arg_type, ArgType::Rest(_)));
if !has_rest && args.len() > specs.len() {
return Err(ParseError::validation(
cmd_name,
format!(
"invalid syntax for command {cmd_name}: expects at most {} positional argument(s), got {}",
specs.len(),
args.len()
),
&SpanContext::line_only(0),
));
}
for spec in specs {
if let ArgType::Rest(inner) = spec.arg_type {
let tail = args.get(spec.index..).unwrap_or(&[]);
if tail.is_empty() && spec.required {
return Err(ParseError::validation(
cmd_name,
format!(
"invalid syntax for command {cmd_name}: requires argument `{}`",
spec.name
),
&SpanContext::line_only(0),
));
}
for arg in tail {
check_one(cmd_name, spec, inner, arg)?;
}
return Ok(());
}
match args.get(spec.index) {
Some(arg) => check_one(cmd_name, spec, &spec.arg_type, arg)?,
None if spec.required => {
return Err(ParseError::validation(
cmd_name,
format!(
"invalid syntax for command {cmd_name}: requires argument `{}`",
spec.name
),
&SpanContext::line_only(0),
));
}
None => {}
}
}
Ok(())
}
fn check_one(
cmd_name: &str,
spec: &ArgSpec,
arg_type: &ArgType,
arg: &Arg,
) -> Result<(), ParseError> {
match arg_type.check_arg(arg) {
Ok(_) => Ok(()),
Err(e) => Err(ParseError::validation(
cmd_name,
format!(
"invalid syntax for command {cmd_name}: argument `{}` got {} — {e:#}",
spec.name,
arg.render()
),
&SpanContext::line_only(0),
)),
}
}
pub fn strip_surrounding_quotes(value: &str) -> &str {
value
.strip_prefix('"')
.and_then(|s| s.strip_suffix('"'))
.or_else(|| value.strip_prefix('\'').and_then(|s| s.strip_suffix('\'')))
.unwrap_or(value)
}
pub fn split_assignment(text: &str) -> Result<Option<(String, Arg)>> {
let Some((key, raw)) = text.split_once('=') else {
return Ok(None);
};
if key.is_empty() {
bail!("assignment requires KEY=value format");
}
Ok(Some((
key.to_string(),
Arg::String(strip_surrounding_quotes(raw).to_string(), false),
)))
}
pub fn parse_duration(s: &str) -> Result<std::time::Duration> {
let s = join_single_space(s)?;
let (digits, unit_ms): (&str, u64) = if let Some(v) = s.strip_suffix("ms") {
(v, 1)
} else if let Some(v) = s.strip_suffix('s') {
(v, 1_000)
} else if let Some(v) = s.strip_suffix('m') {
(v, 60_000)
} else if let Some(v) = s.strip_suffix('h') {
(v, 3_600_000)
} else {
(s.as_str(), 1_000)
};
let n: u64 = digits
.parse()
.map_err(|_| anyhow!("invalid TIMEOUT duration: {s}"))?;
let millis = n
.checked_mul(unit_ms)
.ok_or_else(|| anyhow!("TIMEOUT duration out of range: {s}"))?;
if millis == 0 {
bail!("TIMEOUT duration must be positive, got: {s}");
}
Ok(std::time::Duration::from_millis(millis))
}
pub fn format_duration(d: &std::time::Duration) -> String {
let millis = d.as_millis();
if millis.is_multiple_of(3_600_000) {
format!("{}h", millis / 3_600_000)
} else if millis.is_multiple_of(60_000) {
format!("{}m", millis / 60_000)
} else if millis.is_multiple_of(1_000) {
format!("{}s", millis / 1_000)
} else {
format!("{millis}ms")
}
}
pub fn strip_digit_separators(text: &str) -> String {
let body = text
.strip_prefix('+')
.or_else(|| text.strip_prefix('-'))
.unwrap_or(text);
let bytes = body.as_bytes();
let well_formed = !body.is_empty()
&& bytes[0].is_ascii_digit()
&& bytes[bytes.len() - 1].is_ascii_digit()
&& bytes
.iter()
.all(|b| b.is_ascii_digit() || *b == b'_' || *b == b'.')
&& !body.contains("__")
&& body.bytes().filter(|b| *b == b'.').count() <= 1;
if well_formed {
text.replace('_', "")
} else {
text.to_string()
}
}
fn join_single_space(s: &str) -> Result<String> {
let mut parts = s.split_whitespace();
let (first, second) = (parts.next(), parts.next());
if parts.next().is_some() {
bail!("invalid numeric token: {s}");
}
match (first, second) {
(Some(digits), None) => Ok(digits.to_string()),
(Some(digits), Some(unit))
if digits
.strip_prefix('+')
.unwrap_or(digits)
.bytes()
.all(|b| b.is_ascii_digit() || b == b'_')
&& unit.bytes().all(|b| b.is_ascii_alphabetic()) =>
{
Ok(format!("{digits}{unit}"))
}
_ => bail!("invalid numeric token: {s}"),
}
}
pub fn parse_byte_count(s: &str) -> Result<u64> {
let compact = join_single_space(s)?;
let s = compact.as_str();
let lower = s.to_ascii_lowercase();
let (digits, unit): (&str, u64) = if let Some(v) = lower.strip_suffix("kib") {
(v, 1 << 10)
} else if let Some(v) = lower.strip_suffix("mib") {
(v, 1 << 20)
} else if let Some(v) = lower.strip_suffix("gib") {
(v, 1 << 30)
} else if let Some(v) = lower.strip_suffix("tib") {
(v, 1 << 40)
} else if let Some(v) = lower.strip_suffix("kb") {
(v, 1_000)
} else if let Some(v) = lower.strip_suffix("mb") {
(v, 1_000_000)
} else if let Some(v) = lower.strip_suffix("gb") {
(v, 1_000_000_000)
} else if let Some(v) = lower.strip_suffix("tb") {
(v, 1_000_000_000_000)
} else if let Some(v) = lower.strip_suffix('b') {
(v, 1)
} else {
(lower.as_str(), 1)
};
let digits = strip_digit_separators(&s[..digits.len()]);
let digits = digits.strip_prefix('+').unwrap_or(&digits);
let n: u64 = digits
.parse()
.map_err(|_| anyhow!("invalid byte count: {s}"))?;
let bytes = n
.checked_mul(unit)
.ok_or_else(|| anyhow!("byte count out of range: {s}"))?;
if bytes == 0 {
bail!("byte count must be positive, got: {s}");
}
Ok(bytes)
}
pub fn format_byte_count(n: u64) -> String {
const TIB: u64 = 1 << 40;
const TB: u64 = 1_000_000_000_000;
const GIB: u64 = 1 << 30;
const GB: u64 = 1_000_000_000;
const MIB: u64 = 1 << 20;
const MB: u64 = 1_000_000;
const KIB: u64 = 1 << 10;
const KB: u64 = 1_000;
if n >= TIB && n.is_multiple_of(TIB) {
format!("{}TiB", n / TIB)
} else if n >= TB && n.is_multiple_of(TB) {
format!("{}TB", n / TB)
} else if n >= GIB && n.is_multiple_of(GIB) {
format!("{}GiB", n / GIB)
} else if n >= GB && n.is_multiple_of(GB) {
format!("{}GB", n / GB)
} else if n >= MIB && n.is_multiple_of(MIB) {
format!("{}MiB", n / MIB)
} else if n >= MB && n.is_multiple_of(MB) {
format!("{}MB", n / MB)
} else if n >= KIB && n.is_multiple_of(KIB) {
format!("{}KiB", n / KIB)
} else if n >= KB && n.is_multiple_of(KB) {
format!("{}KB", n / KB)
} else {
format!("{n}B")
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum IoDirection {
Read,
Write,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Stream {
Stdin,
Stdout,
Stderr,
}
pub struct FlagSpec {
pub name: &'static str,
pub long: &'static str,
pub value_type: FlagValueType,
pub required: bool,
pub description: &'static str,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FlagValueType {
Flag,
String,
Int,
}
impl FlagValueType {
pub fn label(&self) -> &'static str {
match self {
FlagValueType::Flag => "BOOL",
FlagValueType::String => "STRING",
FlagValueType::Int => "INT",
}
}
}
pub struct CommandMeta {
pub name: &'static str,
pub syntax: &'static str,
pub summary: &'static str,
pub description: &'static str,
pub args: &'static [ArgSpec],
pub flags: &'static [FlagSpec],
pub default_output: Option<Stream>,
pub examples: &'static [Example],
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Example {
pub name: &'static str,
pub fence_meta: Option<&'static str>,
pub code: &'static str,
}
pub trait CommandSpec {
const NAME: &'static str;
fn metadata() -> CommandMeta;
fn lower(flags: Vec<(String, Arg)>, args: Vec<Arg>) -> Result<StepKind>;
}
#[cfg(test)]
mod tests {
use super::*;
use crate::ast::Expr;
fn lit(text: &str) -> Arg {
Arg::String(text.to_string(), false)
}
fn var(name: &str) -> Arg {
Arg::Expr(Expr::Var(name.to_string()))
}
#[test]
fn validate_literal_covers_each_variant() {
ArgType::String.check_arg(&lit("anything at all")).unwrap();
ArgType::Path.check_arg(&lit("a/b/../c")).unwrap();
ArgType::Int.check_arg(&lit("3")).unwrap();
assert!(ArgType::Int.check_arg(&lit("banana")).is_err());
ArgType::Duration.check_arg(&lit("10s")).unwrap();
ArgType::Duration.check_arg(&lit("30")).unwrap();
assert!(ArgType::Duration.check_arg(&lit("banana")).is_err());
assert!(ArgType::Duration.check_arg(&lit("0s")).is_err());
ArgType::List.check_arg(&lit("$x")).unwrap();
assert!(ArgType::List.check_arg(&lit("x")).is_err());
assert_eq!(ArgType::Any.label(), "<any>");
ArgType::OneOf(&["SNAPSHOT", "LOCAL"])
.check_arg(&lit("LOCAL"))
.unwrap();
assert!(
ArgType::OneOf(&["SNAPSHOT", "LOCAL"])
.check_arg(&lit("local"))
.is_err()
);
assert!(
ArgType::OneOf(&["SNAPSHOT", "LOCAL"])
.check_arg(&lit("REMOTE"))
.is_err()
);
}
#[test]
fn check_arg_defers_dynamics_and_enforces_var() {
assert_eq!(
ArgType::Duration.check_arg(&lit("{{ $d }}")).unwrap(),
CheckOutcome::Deferred
);
assert_eq!(
ArgType::List.check_arg(&var("x")).unwrap(),
CheckOutcome::Static
);
assert_eq!(
ArgType::Duration.check_arg(&var("d")).unwrap(),
CheckOutcome::Deferred
);
let list = Arg::Expr(Expr::List(vec![]));
assert!(ArgType::List.check_arg(&list).is_err());
assert_eq!(
ArgType::String.check_arg(&list).unwrap(),
CheckOutcome::Deferred
);
assert_eq!(ArgType::List.label(), "LIST");
assert_eq!(
ArgType::String.check_arg(&lit("x")).unwrap(),
CheckOutcome::Static
);
}
fn spec(index: usize, required: bool, arg_type: ArgType) -> ArgSpec {
ArgSpec {
name: "p",
arg_type,
description: "",
io: IoDirection::Write,
index,
required,
fallback_stream: None,
}
}
#[test]
fn positionals_enforce_required_and_rest_tails() {
let specs = [spec(0, true, ArgType::Int)];
assert!(validate_positionals_against_meta("T", &specs, &[]).is_err());
assert!(validate_positionals_against_meta("T", &specs, &[lit("3")]).is_ok());
assert!(validate_positionals_against_meta("T", &specs, &[lit("banana")]).is_err());
let specs = [ArgSpec {
arg_type: ArgType::Rest(&ArgType::Int),
..spec(0, true, ArgType::Int)
}];
assert!(
validate_positionals_against_meta("T", &specs, &[lit("1"), lit("2"), lit("banana")])
.is_err()
);
assert!(validate_positionals_against_meta("T", &specs, &[lit("1"), lit("2")]).is_ok());
let specs = [spec(0, true, ArgType::Int)];
let err = validate_positionals_against_meta("T", &specs, &[lit("1"), lit("extra")])
.expect_err("extras must fail");
assert!(
err.to_string().contains("at most 1"),
"unexpected error: {err:#}"
);
}
}