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