1use crate::ast::{Arg, Expr, StepKind};
2use anyhow::{Result, anyhow, bail};
3
4pub struct ArgSpec {
6 pub name: &'static str,
7 pub arg_type: ArgType,
8 pub description: &'static str,
9 pub io: IoDirection,
10 pub index: usize,
11 pub required: bool,
12 pub fallback_stream: Option<Stream>,
13}
14
15#[derive(Debug, Clone, Copy, PartialEq, Eq)]
21pub enum ArgType {
22 String,
23 Path,
24 Int,
25 Duration,
26 Var,
27 KeyValue,
28 Any,
31 OneOf(&'static [&'static str]),
34 Rest(&'static ArgType),
36}
37
38impl ArgType {
39 pub fn label(&self) -> String {
47 use crate::ast::TypeKind;
48 match self {
49 ArgType::String => TypeKind::String.label().to_string(),
50 ArgType::Path => TypeKind::Path.label().to_string(),
51 ArgType::Int => TypeKind::Int.label().to_string(),
52 ArgType::Duration => TypeKind::Duration.label().to_string(),
53 ArgType::Var => TypeKind::String.label().to_string(),
54 ArgType::KeyValue => TypeKind::String.label().to_string(),
55 ArgType::Any => "ANY".to_string(),
56 ArgType::OneOf(options) => options.join("|"),
57 ArgType::Rest(inner) => format!("{}...", inner.label()),
58 }
59 }
60
61 pub fn anchor(&self) -> Option<String> {
65 use crate::ast::TypeKind;
66 match self {
67 ArgType::String => Some(TypeKind::String.anchor()),
68 ArgType::Path => Some(TypeKind::Path.anchor()),
69 ArgType::Int => Some(TypeKind::Int.anchor()),
70 ArgType::Duration => Some(TypeKind::Duration.anchor()),
71 ArgType::Var => None,
72 ArgType::KeyValue => None,
73 ArgType::Any => None,
74 ArgType::OneOf(_) => None,
75 ArgType::Rest(inner) => inner.anchor(),
76 }
77 }
78
79 pub fn validate_literal(&self, literal: &str) -> Result<()> {
82 match self {
83 ArgType::String | ArgType::Path | ArgType::Any => Ok(()),
84 ArgType::Int => literal
85 .parse::<i32>()
86 .map(|_| ())
87 .map_err(|_| anyhow!("expected int, got {literal:?}")),
88 ArgType::Duration => parse_duration(literal).map(|_| ()),
89 ArgType::Var => {
90 if literal.starts_with('$') {
91 Ok(())
92 } else {
93 bail!("expected $var, got {literal:?}")
94 }
95 }
96 ArgType::KeyValue => match split_assignment(literal)? {
97 Some(_) => Ok(()),
98 None => bail!("expected KEY=value, got {literal:?}"),
99 },
100 ArgType::OneOf(options) => {
101 if options
104 .iter()
105 .any(|o| *o == literal || o.to_lowercase() == literal)
106 {
107 Ok(())
108 } else {
109 bail!("expected one of {}, got {literal:?}", options.join("|"))
110 }
111 }
112 ArgType::Rest(inner) => inner.validate_literal(literal),
113 }
114 }
115
116 pub fn check_arg(&self, arg: &Arg) -> Result<CheckOutcome> {
121 match arg {
122 Arg::String(s, _) if !s.contains("{{") => {
123 self.validate_literal(s)?;
124 Ok(CheckOutcome::Static)
125 }
126 Arg::String(_, _) => Ok(CheckOutcome::Deferred),
127 Arg::Parts(_) => Ok(CheckOutcome::Deferred),
128 Arg::Expr(Expr::Var(_)) => {
129 if *self == ArgType::Var {
130 Ok(CheckOutcome::Static)
131 } else {
132 Ok(CheckOutcome::Deferred)
133 }
134 }
135 Arg::Expr(_) => {
136 if *self == ArgType::Var {
137 bail!("expected $var, got expression {}", arg.render())
138 } else {
139 Ok(CheckOutcome::Deferred)
140 }
141 }
142 }
143 }
144}
145
146#[derive(Debug, Clone, Copy, PartialEq, Eq)]
148pub enum CheckOutcome {
149 Static,
151 Deferred,
154}
155
156pub fn validate_positionals_against_meta(
162 cmd_name: &str,
163 specs: &[ArgSpec],
164 args: &[Arg],
165) -> Result<()> {
166 let has_rest = specs
167 .last()
168 .is_some_and(|s| matches!(s.arg_type, ArgType::Rest(_)));
169
170 if !has_rest && args.len() > specs.len() {
171 bail!(
172 "invalid syntax for command {cmd_name}: expects at most {} positional argument(s), got {}",
173 specs.len(),
174 args.len()
175 );
176 }
177
178 for spec in specs {
179 if let ArgType::Rest(inner) = spec.arg_type {
180 let tail = args.get(spec.index..).unwrap_or(&[]);
183 if tail.is_empty() && spec.required {
184 bail!(
185 "invalid syntax for command {cmd_name}: requires argument `{}`",
186 spec.name
187 )
188 }
189 for arg in tail {
190 check_one(cmd_name, spec, inner, arg)?;
191 }
192 return Ok(());
193 }
194 match args.get(spec.index) {
195 Some(arg) => check_one(cmd_name, spec, &spec.arg_type, arg)?,
196 None if spec.required => {
197 bail!(
198 "invalid syntax for command {cmd_name}: requires argument `{}`",
199 spec.name
200 )
201 }
202 None => {}
203 }
204 }
205 Ok(())
206}
207
208fn check_one(cmd_name: &str, spec: &ArgSpec, arg_type: &ArgType, arg: &Arg) -> Result<()> {
209 match arg_type.check_arg(arg) {
210 Ok(_) => Ok(()),
211 Err(e) => bail!(
212 "invalid syntax for command {cmd_name}: argument `{}` got {} — {e:#}",
213 spec.name,
214 arg.render()
215 ),
216 }
217}
218
219pub fn strip_surrounding_quotes(value: &str) -> &str {
221 value
222 .strip_prefix('"')
223 .and_then(|s| s.strip_suffix('"'))
224 .or_else(|| value.strip_prefix('\'').and_then(|s| s.strip_suffix('\'')))
225 .unwrap_or(value)
226}
227
228pub fn split_assignment(text: &str) -> Result<Option<(String, Arg)>> {
233 let Some((key, raw)) = text.split_once('=') else {
234 return Ok(None);
235 };
236 if key.is_empty() {
237 bail!("assignment requires KEY=value format");
238 }
239 Ok(Some((
240 key.to_string(),
241 Arg::String(strip_surrounding_quotes(raw).to_string(), false),
242 )))
243}
244
245pub fn parse_duration(s: &str) -> Result<std::time::Duration> {
248 let (digits, unit_ms): (&str, u64) = if let Some(v) = s.strip_suffix("ms") {
249 (v, 1)
250 } else if let Some(v) = s.strip_suffix('s') {
251 (v, 1_000)
252 } else if let Some(v) = s.strip_suffix('m') {
253 (v, 60_000)
254 } else if let Some(v) = s.strip_suffix('h') {
255 (v, 3_600_000)
256 } else {
257 (s, 1_000)
258 };
259 let n: u64 = digits
260 .parse()
261 .map_err(|_| anyhow!("invalid TIMEOUT duration: {s}"))?;
262 let millis = n
263 .checked_mul(unit_ms)
264 .ok_or_else(|| anyhow!("TIMEOUT duration out of range: {s}"))?;
265 if millis == 0 {
266 bail!("TIMEOUT duration must be positive, got: {s}");
267 }
268 Ok(std::time::Duration::from_millis(millis))
269}
270
271pub fn format_duration(d: &std::time::Duration) -> String {
275 let millis = d.as_millis();
276 if millis.is_multiple_of(3_600_000) {
277 format!("{}h", millis / 3_600_000)
278 } else if millis.is_multiple_of(60_000) {
279 format!("{}m", millis / 60_000)
280 } else if millis.is_multiple_of(1_000) {
281 format!("{}s", millis / 1_000)
282 } else {
283 format!("{millis}ms")
284 }
285}
286
287#[derive(Debug, Clone, Copy, PartialEq, Eq)]
289pub enum IoDirection {
290 Read,
291 Write,
292}
293
294#[derive(Debug, Clone, Copy, PartialEq, Eq)]
296pub enum Stream {
297 Stdin,
298 Stdout,
299 Stderr,
300}
301
302pub struct FlagSpec {
304 pub name: &'static str,
305 pub long: &'static str,
306 pub value_type: FlagValueType,
307 pub required: bool,
308 pub description: &'static str,
309}
310
311#[derive(Debug, Clone, Copy, PartialEq, Eq)]
313pub enum FlagValueType {
314 Flag,
316 String,
318 Int,
320}
321
322impl FlagValueType {
323 pub fn label(&self) -> &'static str {
326 use crate::ast::TypeKind;
327 match self {
328 FlagValueType::Flag => TypeKind::Bool.label(),
329 FlagValueType::String => TypeKind::String.label(),
330 FlagValueType::Int => TypeKind::Int.label(),
331 }
332 }
333}
334
335pub struct CommandMeta {
337 pub name: &'static str,
338 pub syntax: &'static str,
339 pub summary: &'static str,
340 pub description: &'static str,
341 pub args: &'static [ArgSpec],
342 pub flags: &'static [FlagSpec],
343 pub default_output: Option<Stream>,
344 pub examples: &'static [Example],
345}
346
347pub struct Example {
349 pub name: &'static str,
350 pub fence_meta: Option<&'static str>,
351 pub code: &'static str,
352}
353
354pub trait CommandSpec {
360 const NAME: &'static str;
361
362 fn metadata() -> CommandMeta;
363 fn lower(flags: Vec<(String, Arg)>, args: Vec<Arg>) -> Result<StepKind>;
364}
365
366#[cfg(test)]
367mod tests {
368 use super::*;
369 use crate::ast::Expr;
370
371 fn lit(text: &str) -> Arg {
372 Arg::String(text.to_string(), false)
373 }
374
375 fn var(name: &str) -> Arg {
376 Arg::Expr(Expr::Var(name.to_string()))
377 }
378
379 #[test]
380 fn validate_literal_covers_each_variant() {
381 ArgType::String.check_arg(&lit("anything at all")).unwrap();
382 ArgType::Path.check_arg(&lit("a/b/../c")).unwrap();
383 ArgType::Int.check_arg(&lit("3")).unwrap();
384 assert!(ArgType::Int.check_arg(&lit("banana")).is_err());
385 ArgType::Duration.check_arg(&lit("10s")).unwrap();
386 ArgType::Duration.check_arg(&lit("30")).unwrap();
387 assert!(ArgType::Duration.check_arg(&lit("banana")).is_err());
388 assert!(ArgType::Duration.check_arg(&lit("0s")).is_err());
389 ArgType::Var.check_arg(&lit("$x")).unwrap();
390 assert!(ArgType::Var.check_arg(&lit("x")).is_err());
391 ArgType::KeyValue.check_arg(&lit("K=v")).unwrap();
392 ArgType::KeyValue.check_arg(&lit("K=a=b")).unwrap();
393 assert!(ArgType::KeyValue.check_arg(&lit("no-equals")).is_err());
394 assert!(ArgType::KeyValue.check_arg(&lit("=v")).is_err());
395 ArgType::OneOf(&["SNAPSHOT", "LOCAL"])
396 .check_arg(&lit("LOCAL"))
397 .unwrap();
398 ArgType::OneOf(&["SNAPSHOT", "LOCAL"])
400 .check_arg(&lit("local"))
401 .unwrap();
402 assert!(
403 ArgType::OneOf(&["SNAPSHOT", "LOCAL"])
404 .check_arg(&lit("REMOTE"))
405 .is_err()
406 );
407 }
408
409 #[test]
410 fn check_arg_defers_dynamics_and_enforces_var() {
411 assert_eq!(
413 ArgType::Duration.check_arg(&lit("{{ $d }}")).unwrap(),
414 CheckOutcome::Deferred
415 );
416 assert_eq!(
418 ArgType::Var.check_arg(&var("x")).unwrap(),
419 CheckOutcome::Static
420 );
421 assert_eq!(
422 ArgType::Duration.check_arg(&var("d")).unwrap(),
423 CheckOutcome::Deferred
424 );
425 let list = Arg::Expr(Expr::List(vec![]));
427 assert!(ArgType::Var.check_arg(&list).is_err());
428 assert_eq!(
429 ArgType::String.check_arg(&list).unwrap(),
430 CheckOutcome::Deferred
431 );
432 }
433
434 fn spec(index: usize, required: bool, arg_type: ArgType) -> ArgSpec {
435 ArgSpec {
436 name: "p",
437 arg_type,
438 description: "",
439 io: IoDirection::Write,
440 index,
441 required,
442 fallback_stream: None,
443 }
444 }
445
446 #[test]
447 fn positionals_enforce_required_and_rest_tails() {
448 let specs = [spec(0, true, ArgType::Int)];
449 assert!(validate_positionals_against_meta("T", &specs, &[]).is_err());
450 assert!(validate_positionals_against_meta("T", &specs, &[lit("3")]).is_ok());
451 assert!(validate_positionals_against_meta("T", &specs, &[lit("banana")]).is_err());
452
453 let specs = [ArgSpec {
455 arg_type: ArgType::Rest(&ArgType::Int),
456 ..spec(0, true, ArgType::Int)
457 }];
458 assert!(
459 validate_positionals_against_meta("T", &specs, &[lit("1"), lit("2"), lit("banana")])
460 .is_err()
461 );
462 assert!(validate_positionals_against_meta("T", &specs, &[lit("1"), lit("2")]).is_ok());
463 let specs = [spec(0, true, ArgType::Int)];
465 let err = validate_positionals_against_meta("T", &specs, &[lit("1"), lit("extra")])
466 .expect_err("extras must fail");
467 assert!(
468 err.to_string().contains("at most 1"),
469 "unexpected error: {err:#}"
470 );
471 }
472}