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 const CANONICAL: &[ArgType] = &[
38 ArgType::String,
39 ArgType::Path,
40 ArgType::Int,
41 ArgType::Duration,
42 ArgType::Var,
43 ArgType::KeyValue,
44 ];
45
46 pub fn label(&self) -> String {
48 match self {
49 ArgType::String => "string".to_string(),
50 ArgType::Path => "path".to_string(),
51 ArgType::Int => "int".to_string(),
52 ArgType::Duration => "duration".to_string(),
53 ArgType::Var => "$var".to_string(),
54 ArgType::KeyValue => "KEY=value".to_string(),
55 ArgType::OneOf(options) => options.join("|"),
56 ArgType::Rest(inner) => format!("{}...", inner.label()),
57 }
58 }
59
60 pub fn anchor(&self) -> Option<&'static str> {
63 match self {
64 ArgType::String => Some("value-type-string"),
65 ArgType::Path => Some("value-type-path"),
66 ArgType::Int => Some("value-type-int"),
67 ArgType::Duration => Some("value-type-duration"),
68 ArgType::Var => Some("value-type-var"),
69 ArgType::KeyValue => Some("value-type-keyvalue"),
72 ArgType::OneOf(_) => None,
73 ArgType::Rest(inner) => inner.anchor(),
74 }
75 }
76
77 pub fn doc(&self) -> Option<(&'static str, &'static str)> {
79 match self {
80 ArgType::String => Some((
81 "Value type: string",
82 "Arbitrary text under the unified string-value rules: quotes keep exact bytes, a lone `$var` evaluates, and `{{ ... }}` placeholders interpolate.",
83 )),
84 ArgType::Path => Some((
85 "Value type: path",
86 "Workspace path, resolved against the current working directory and guarded against escaping the workspace.",
87 )),
88 ArgType::Int => Some(("Value type: int", "Integer, e.g. an exit code.")),
89 ArgType::Duration => Some((
90 "Value type: duration",
91 "Positive time span: a number with an `ms`, `s`, `m`, or `h` suffix — a bare number means seconds — e.g. `500ms`, `10s`, `2m`.",
92 )),
93 ArgType::Var => Some((
94 "Value type: $var",
95 "Script variable reference. The `$` sigil is mandatory.",
96 )),
97 ArgType::KeyValue => Some((
98 "Value type: KEY=value",
99 "`KEY=value` assignment splitting on the first `=` (`KEY=a=b` stores `a=b`). Values follow the unified string-value rules.",
100 )),
101 ArgType::OneOf(_) | ArgType::Rest(_) => None,
102 }
103 }
104
105 pub fn validate_literal(&self, literal: &str) -> Result<()> {
108 match self {
109 ArgType::String | ArgType::Path => Ok(()),
110 ArgType::Int => literal
111 .parse::<i32>()
112 .map(|_| ())
113 .map_err(|_| anyhow!("expected int, got {literal:?}")),
114 ArgType::Duration => parse_duration(literal).map(|_| ()),
115 ArgType::Var => {
116 if literal.starts_with('$') {
117 Ok(())
118 } else {
119 bail!("expected $var, got {literal:?}")
120 }
121 }
122 ArgType::KeyValue => match split_assignment(literal)? {
123 Some(_) => Ok(()),
124 None => bail!("expected KEY=value, got {literal:?}"),
125 },
126 ArgType::OneOf(options) => {
127 if options
130 .iter()
131 .any(|o| *o == literal || o.to_lowercase() == literal)
132 {
133 Ok(())
134 } else {
135 bail!("expected one of {}, got {literal:?}", options.join("|"))
136 }
137 }
138 ArgType::Rest(inner) => inner.validate_literal(literal),
139 }
140 }
141
142 pub fn check_arg(&self, arg: &Arg) -> Result<CheckOutcome> {
147 match arg {
148 Arg::String(s, _) if !s.contains("{{") => {
149 self.validate_literal(s)?;
150 Ok(CheckOutcome::Static)
151 }
152 Arg::String(_, _) => Ok(CheckOutcome::Deferred),
153 Arg::Parts(_) => Ok(CheckOutcome::Deferred),
154 Arg::Expr(Expr::Var(_)) => {
155 if *self == ArgType::Var {
156 Ok(CheckOutcome::Static)
157 } else {
158 Ok(CheckOutcome::Deferred)
159 }
160 }
161 Arg::Expr(_) => {
162 if *self == ArgType::Var {
163 bail!("expected $var, got expression {}", arg.render())
164 } else {
165 Ok(CheckOutcome::Deferred)
166 }
167 }
168 }
169 }
170}
171
172#[derive(Debug, Clone, Copy, PartialEq, Eq)]
174pub enum CheckOutcome {
175 Static,
177 Deferred,
180}
181
182pub fn validate_positionals_against_meta(
188 cmd_name: &str,
189 specs: &[ArgSpec],
190 args: &[Arg],
191) -> Result<()> {
192 let has_rest = specs
193 .last()
194 .is_some_and(|s| matches!(s.arg_type, ArgType::Rest(_)));
195
196 if !has_rest && args.len() > specs.len() {
197 bail!(
198 "invalid syntax for command {cmd_name}: expects at most {} positional argument(s), got {}",
199 specs.len(),
200 args.len()
201 );
202 }
203
204 for spec in specs {
205 if let ArgType::Rest(inner) = spec.arg_type {
206 let tail = args.get(spec.index..).unwrap_or(&[]);
209 if tail.is_empty() && spec.required {
210 bail!(
211 "invalid syntax for command {cmd_name}: requires argument `{}`",
212 spec.name
213 )
214 }
215 for arg in tail {
216 check_one(cmd_name, spec, inner, arg)?;
217 }
218 return Ok(());
219 }
220 match args.get(spec.index) {
221 Some(arg) => check_one(cmd_name, spec, &spec.arg_type, arg)?,
222 None if spec.required => {
223 bail!(
224 "invalid syntax for command {cmd_name}: requires argument `{}`",
225 spec.name
226 )
227 }
228 None => {}
229 }
230 }
231 Ok(())
232}
233
234fn check_one(cmd_name: &str, spec: &ArgSpec, arg_type: &ArgType, arg: &Arg) -> Result<()> {
235 match arg_type.check_arg(arg) {
236 Ok(_) => Ok(()),
237 Err(e) => bail!(
238 "invalid syntax for command {cmd_name}: argument `{}` got {} — {e:#}",
239 spec.name,
240 arg.render()
241 ),
242 }
243}
244
245pub fn strip_surrounding_quotes(value: &str) -> &str {
247 value
248 .strip_prefix('"')
249 .and_then(|s| s.strip_suffix('"'))
250 .or_else(|| value.strip_prefix('\'').and_then(|s| s.strip_suffix('\'')))
251 .unwrap_or(value)
252}
253
254pub fn split_assignment(text: &str) -> Result<Option<(String, Arg)>> {
259 let Some((key, raw)) = text.split_once('=') else {
260 return Ok(None);
261 };
262 if key.is_empty() {
263 bail!("assignment requires KEY=value format");
264 }
265 Ok(Some((
266 key.to_string(),
267 Arg::String(strip_surrounding_quotes(raw).to_string(), false),
268 )))
269}
270
271pub fn parse_duration(s: &str) -> Result<std::time::Duration> {
274 let (digits, unit_ms): (&str, u64) = if let Some(v) = s.strip_suffix("ms") {
275 (v, 1)
276 } else if let Some(v) = s.strip_suffix('s') {
277 (v, 1_000)
278 } else if let Some(v) = s.strip_suffix('m') {
279 (v, 60_000)
280 } else if let Some(v) = s.strip_suffix('h') {
281 (v, 3_600_000)
282 } else {
283 (s, 1_000)
284 };
285 let n: u64 = digits
286 .parse()
287 .map_err(|_| anyhow!("invalid TIMEOUT duration: {s}"))?;
288 let millis = n
289 .checked_mul(unit_ms)
290 .ok_or_else(|| anyhow!("TIMEOUT duration out of range: {s}"))?;
291 if millis == 0 {
292 bail!("TIMEOUT duration must be positive, got: {s}");
293 }
294 Ok(std::time::Duration::from_millis(millis))
295}
296
297pub fn format_duration(d: &std::time::Duration) -> String {
301 let millis = d.as_millis();
302 if millis.is_multiple_of(3_600_000) {
303 format!("{}h", millis / 3_600_000)
304 } else if millis.is_multiple_of(60_000) {
305 format!("{}m", millis / 60_000)
306 } else if millis.is_multiple_of(1_000) {
307 format!("{}s", millis / 1_000)
308 } else {
309 format!("{millis}ms")
310 }
311}
312
313#[derive(Debug, Clone, Copy, PartialEq, Eq)]
315pub enum IoDirection {
316 Read,
317 Write,
318}
319
320#[derive(Debug, Clone, Copy, PartialEq, Eq)]
322pub enum Stream {
323 Stdin,
324 Stdout,
325 Stderr,
326}
327
328pub struct FlagSpec {
330 pub name: &'static str,
331 pub long: &'static str,
332 pub value_type: FlagValueType,
333 pub required: bool,
334 pub description: &'static str,
335}
336
337#[derive(Debug, Clone, Copy, PartialEq, Eq)]
339pub enum FlagValueType {
340 Flag,
342 String,
344 Int,
346}
347
348pub struct CommandMeta {
350 pub name: &'static str,
351 pub syntax: &'static str,
352 pub summary: &'static str,
353 pub description: &'static str,
354 pub args: &'static [ArgSpec],
355 pub flags: &'static [FlagSpec],
356 pub default_output: Option<Stream>,
357 pub examples: &'static [Example],
358}
359
360pub struct Example {
362 pub name: &'static str,
363 pub fence_meta: Option<&'static str>,
364 pub code: &'static str,
365}
366
367pub trait CommandSpec {
373 const NAME: &'static str;
374
375 fn metadata() -> CommandMeta;
376 fn lower(flags: Vec<(String, Arg)>, args: Vec<Arg>) -> Result<StepKind>;
377}
378
379#[cfg(test)]
380mod tests {
381 use super::*;
382 use crate::ast::Expr;
383
384 fn lit(text: &str) -> Arg {
385 Arg::String(text.to_string(), false)
386 }
387
388 fn var(name: &str) -> Arg {
389 Arg::Expr(Expr::Var(name.to_string()))
390 }
391
392 #[test]
393 fn validate_literal_covers_each_variant() {
394 ArgType::String.check_arg(&lit("anything at all")).unwrap();
395 ArgType::Path.check_arg(&lit("a/b/../c")).unwrap();
396 ArgType::Int.check_arg(&lit("3")).unwrap();
397 assert!(ArgType::Int.check_arg(&lit("banana")).is_err());
398 ArgType::Duration.check_arg(&lit("10s")).unwrap();
399 ArgType::Duration.check_arg(&lit("30")).unwrap();
400 assert!(ArgType::Duration.check_arg(&lit("banana")).is_err());
401 assert!(ArgType::Duration.check_arg(&lit("0s")).is_err());
402 ArgType::Var.check_arg(&lit("$x")).unwrap();
403 assert!(ArgType::Var.check_arg(&lit("x")).is_err());
404 ArgType::KeyValue.check_arg(&lit("K=v")).unwrap();
405 ArgType::KeyValue.check_arg(&lit("K=a=b")).unwrap();
406 assert!(ArgType::KeyValue.check_arg(&lit("no-equals")).is_err());
407 assert!(ArgType::KeyValue.check_arg(&lit("=v")).is_err());
408 ArgType::OneOf(&["SNAPSHOT", "LOCAL"])
409 .check_arg(&lit("LOCAL"))
410 .unwrap();
411 ArgType::OneOf(&["SNAPSHOT", "LOCAL"])
413 .check_arg(&lit("local"))
414 .unwrap();
415 assert!(
416 ArgType::OneOf(&["SNAPSHOT", "LOCAL"])
417 .check_arg(&lit("REMOTE"))
418 .is_err()
419 );
420 }
421
422 #[test]
423 fn check_arg_defers_dynamics_and_enforces_var() {
424 assert_eq!(
426 ArgType::Duration.check_arg(&lit("{{ $d }}")).unwrap(),
427 CheckOutcome::Deferred
428 );
429 assert_eq!(
431 ArgType::Var.check_arg(&var("x")).unwrap(),
432 CheckOutcome::Static
433 );
434 assert_eq!(
435 ArgType::Duration.check_arg(&var("d")).unwrap(),
436 CheckOutcome::Deferred
437 );
438 let list = Arg::Expr(Expr::List(vec![]));
440 assert!(ArgType::Var.check_arg(&list).is_err());
441 assert_eq!(
442 ArgType::String.check_arg(&list).unwrap(),
443 CheckOutcome::Deferred
444 );
445 }
446
447 fn spec(index: usize, required: bool, arg_type: ArgType) -> ArgSpec {
448 ArgSpec {
449 name: "p",
450 arg_type,
451 description: "",
452 io: IoDirection::Write,
453 index,
454 required,
455 fallback_stream: None,
456 }
457 }
458
459 #[test]
460 fn positionals_enforce_required_and_rest_tails() {
461 let specs = [spec(0, true, ArgType::Int)];
462 assert!(validate_positionals_against_meta("T", &specs, &[]).is_err());
463 assert!(validate_positionals_against_meta("T", &specs, &[lit("3")]).is_ok());
464 assert!(validate_positionals_against_meta("T", &specs, &[lit("banana")]).is_err());
465
466 let specs = [ArgSpec {
468 arg_type: ArgType::Rest(&ArgType::Int),
469 ..spec(0, true, ArgType::Int)
470 }];
471 assert!(
472 validate_positionals_against_meta("T", &specs, &[lit("1"), lit("2"), lit("banana")])
473 .is_err()
474 );
475 assert!(validate_positionals_against_meta("T", &specs, &[lit("1"), lit("2")]).is_ok());
476 let specs = [spec(0, true, ArgType::Int)];
478 let err = validate_positionals_against_meta("T", &specs, &[lit("1"), lit("extra")])
479 .expect_err("extras must fail");
480 assert!(
481 err.to_string().contains("at most 1"),
482 "unexpected error: {err:#}"
483 );
484 }
485}