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