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)]
26pub enum ArgType {
27 String,
28 Path,
29 Int,
30 Duration,
31 List,
34 Any,
38 OneOf(&'static [&'static str]),
41 Rest(&'static ArgType),
43}
44
45impl ArgType {
46 pub fn label(&self) -> String {
54 match self {
55 ArgType::String => "STRING".to_string(),
56 ArgType::Path => "PATH".to_string(),
57 ArgType::Int => "INT".to_string(),
58 ArgType::Duration => "DURATION".to_string(),
59 ArgType::List => "LIST".to_string(),
60 ArgType::Any => "<any>".to_string(),
61 ArgType::OneOf(options) => options.join("|"),
62 ArgType::Rest(inner) => format!("{}...", inner.label()),
63 }
64 }
65
66 pub fn anchor(&self) -> Option<String> {
70 match self {
71 ArgType::String => Some(crate::value::type_anchor("STRING")),
72 ArgType::Path => Some(crate::value::type_anchor("PATH")),
73 ArgType::Int => Some(crate::value::type_anchor("INT")),
74 ArgType::Duration => Some(crate::value::type_anchor("DURATION")),
75 ArgType::List => Some(crate::value::type_anchor("LIST")),
76 ArgType::Any => None,
77 ArgType::OneOf(_) => None,
78 ArgType::Rest(inner) => inner.anchor(),
79 }
80 }
81
82 pub fn validate_literal(&self, literal: &str) -> Result<()> {
85 match self {
86 ArgType::String | ArgType::Path | ArgType::Any => Ok(()),
87 ArgType::Int => literal
88 .parse::<i64>()
89 .map(|_| ())
90 .map_err(|_| anyhow!("expected int, got {literal:?}")),
91 ArgType::Duration => parse_duration(literal).map(|_| ()),
92 ArgType::List => {
93 if literal.starts_with('$') {
94 Ok(())
95 } else {
96 bail!("expected $var, got {literal:?}")
97 }
98 }
99 ArgType::OneOf(options) => {
100 if options.contains(&literal) {
103 Ok(())
104 } else {
105 bail!("expected one of {}, got {literal:?}", options.join("|"))
106 }
107 }
108 ArgType::Rest(inner) => inner.validate_literal(literal),
109 }
110 }
111
112 pub fn check_arg(&self, arg: &Arg) -> Result<CheckOutcome> {
117 match arg {
118 Arg::String(s, _) if !s.contains("{{") => {
119 self.validate_literal(s)?;
120 Ok(CheckOutcome::Static)
121 }
122 Arg::String(_, _) => Ok(CheckOutcome::Deferred),
123 Arg::Parts(_) => Ok(CheckOutcome::Deferred),
124 Arg::Expr(Expr::Var(_)) => {
125 if matches!(*self, ArgType::List) {
126 Ok(CheckOutcome::Static)
127 } else {
128 Ok(CheckOutcome::Deferred)
129 }
130 }
131 Arg::Expr(_) => {
132 if matches!(*self, ArgType::List) {
133 bail!("expected $var, got expression {}", arg.render())
134 } else {
135 Ok(CheckOutcome::Deferred)
136 }
137 }
138 }
139 }
140}
141
142#[derive(Debug, Clone, Copy, PartialEq, Eq)]
144pub enum CheckOutcome {
145 Static,
147 Deferred,
150}
151
152pub fn validate_positionals_against_meta(
158 cmd_name: &str,
159 specs: &[ArgSpec],
160 args: &[Arg],
161) -> Result<(), ParseError> {
162 let has_rest = specs
163 .last()
164 .is_some_and(|s| matches!(s.arg_type, ArgType::Rest(_)));
165
166 if !has_rest && args.len() > specs.len() {
167 return Err(ParseError::validation(
168 cmd_name,
169 format!(
170 "invalid syntax for command {cmd_name}: expects at most {} positional argument(s), got {}",
171 specs.len(),
172 args.len()
173 ),
174 &SpanContext::line_only(0),
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 return Err(ParseError::validation(
185 cmd_name,
186 format!(
187 "invalid syntax for command {cmd_name}: requires argument `{}`",
188 spec.name
189 ),
190 &SpanContext::line_only(0),
191 ));
192 }
193 for arg in tail {
194 check_one(cmd_name, spec, inner, arg)?;
195 }
196 return Ok(());
197 }
198 match args.get(spec.index) {
199 Some(arg) => check_one(cmd_name, spec, &spec.arg_type, arg)?,
200 None if spec.required => {
201 return Err(ParseError::validation(
202 cmd_name,
203 format!(
204 "invalid syntax for command {cmd_name}: requires argument `{}`",
205 spec.name
206 ),
207 &SpanContext::line_only(0),
208 ));
209 }
210 None => {}
211 }
212 }
213 Ok(())
214}
215
216fn check_one(
217 cmd_name: &str,
218 spec: &ArgSpec,
219 arg_type: &ArgType,
220 arg: &Arg,
221) -> Result<(), ParseError> {
222 match arg_type.check_arg(arg) {
223 Ok(_) => Ok(()),
224 Err(e) => Err(ParseError::validation(
225 cmd_name,
226 format!(
227 "invalid syntax for command {cmd_name}: argument `{}` got {} — {e:#}",
228 spec.name,
229 arg.render()
230 ),
231 &SpanContext::line_only(0),
232 )),
233 }
234}
235
236pub fn strip_surrounding_quotes(value: &str) -> &str {
238 value
239 .strip_prefix('"')
240 .and_then(|s| s.strip_suffix('"'))
241 .or_else(|| value.strip_prefix('\'').and_then(|s| s.strip_suffix('\'')))
242 .unwrap_or(value)
243}
244
245pub fn split_assignment(text: &str) -> Result<Option<(String, Arg)>> {
250 let Some((key, raw)) = text.split_once('=') else {
251 return Ok(None);
252 };
253 if key.is_empty() {
254 bail!("assignment requires KEY=value format");
255 }
256 Ok(Some((
257 key.to_string(),
258 Arg::String(strip_surrounding_quotes(raw).to_string(), false),
259 )))
260}
261
262pub fn parse_duration(s: &str) -> Result<std::time::Duration> {
265 let (digits, unit_ms): (&str, u64) = if let Some(v) = s.strip_suffix("ms") {
266 (v, 1)
267 } else if let Some(v) = s.strip_suffix('s') {
268 (v, 1_000)
269 } else if let Some(v) = s.strip_suffix('m') {
270 (v, 60_000)
271 } else if let Some(v) = s.strip_suffix('h') {
272 (v, 3_600_000)
273 } else {
274 (s, 1_000)
275 };
276 let n: u64 = digits
277 .parse()
278 .map_err(|_| anyhow!("invalid TIMEOUT duration: {s}"))?;
279 let millis = n
280 .checked_mul(unit_ms)
281 .ok_or_else(|| anyhow!("TIMEOUT duration out of range: {s}"))?;
282 if millis == 0 {
283 bail!("TIMEOUT duration must be positive, got: {s}");
284 }
285 Ok(std::time::Duration::from_millis(millis))
286}
287
288pub fn format_duration(d: &std::time::Duration) -> String {
292 let millis = d.as_millis();
293 if millis.is_multiple_of(3_600_000) {
294 format!("{}h", millis / 3_600_000)
295 } else if millis.is_multiple_of(60_000) {
296 format!("{}m", millis / 60_000)
297 } else if millis.is_multiple_of(1_000) {
298 format!("{}s", millis / 1_000)
299 } else {
300 format!("{millis}ms")
301 }
302}
303
304#[derive(Debug, Clone, Copy, PartialEq, Eq)]
306pub enum IoDirection {
307 Read,
308 Write,
309}
310
311#[derive(Debug, Clone, Copy, PartialEq, Eq)]
313pub enum Stream {
314 Stdin,
315 Stdout,
316 Stderr,
317}
318
319pub struct FlagSpec {
321 pub name: &'static str,
322 pub long: &'static str,
323 pub value_type: FlagValueType,
324 pub required: bool,
325 pub description: &'static str,
326}
327
328#[derive(Debug, Clone, Copy, PartialEq, Eq)]
330pub enum FlagValueType {
331 Flag,
333 String,
335 Int,
337}
338
339impl FlagValueType {
340 pub fn label(&self) -> &'static str {
343 match self {
344 FlagValueType::Flag => "BOOL",
345 FlagValueType::String => "STRING",
346 FlagValueType::Int => "INT",
347 }
348 }
349}
350
351pub struct CommandMeta {
353 pub name: &'static str,
354 pub syntax: &'static str,
355 pub summary: &'static str,
356 pub description: &'static str,
357 pub args: &'static [ArgSpec],
358 pub flags: &'static [FlagSpec],
359 pub default_output: Option<Stream>,
360 pub examples: &'static [Example],
361}
362
363pub struct Example {
365 pub name: &'static str,
366 pub fence_meta: Option<&'static str>,
367 pub code: &'static str,
368}
369
370pub trait CommandSpec {
376 const NAME: &'static str;
377
378 fn metadata() -> CommandMeta;
379 fn lower(flags: Vec<(String, Arg)>, args: Vec<Arg>) -> Result<StepKind>;
380}
381
382#[cfg(test)]
383mod tests {
384 use super::*;
385 use crate::ast::Expr;
386
387 fn lit(text: &str) -> Arg {
388 Arg::String(text.to_string(), false)
389 }
390
391 fn var(name: &str) -> Arg {
392 Arg::Expr(Expr::Var(name.to_string()))
393 }
394
395 #[test]
396 fn validate_literal_covers_each_variant() {
397 ArgType::String.check_arg(&lit("anything at all")).unwrap();
398 ArgType::Path.check_arg(&lit("a/b/../c")).unwrap();
399 ArgType::Int.check_arg(&lit("3")).unwrap();
400 assert!(ArgType::Int.check_arg(&lit("banana")).is_err());
401 ArgType::Duration.check_arg(&lit("10s")).unwrap();
402 ArgType::Duration.check_arg(&lit("30")).unwrap();
403 assert!(ArgType::Duration.check_arg(&lit("banana")).is_err());
404 assert!(ArgType::Duration.check_arg(&lit("0s")).is_err());
405 ArgType::List.check_arg(&lit("$x")).unwrap();
406 assert!(ArgType::List.check_arg(&lit("x")).is_err());
407 assert_eq!(ArgType::Any.label(), "<any>");
409 ArgType::OneOf(&["SNAPSHOT", "LOCAL"])
410 .check_arg(&lit("LOCAL"))
411 .unwrap();
412 assert!(
414 ArgType::OneOf(&["SNAPSHOT", "LOCAL"])
415 .check_arg(&lit("local"))
416 .is_err()
417 );
418 assert!(
419 ArgType::OneOf(&["SNAPSHOT", "LOCAL"])
420 .check_arg(&lit("REMOTE"))
421 .is_err()
422 );
423 }
424
425 #[test]
426 fn check_arg_defers_dynamics_and_enforces_var() {
427 assert_eq!(
429 ArgType::Duration.check_arg(&lit("{{ $d }}")).unwrap(),
430 CheckOutcome::Deferred
431 );
432 assert_eq!(
434 ArgType::List.check_arg(&var("x")).unwrap(),
435 CheckOutcome::Static
436 );
437 assert_eq!(
438 ArgType::Duration.check_arg(&var("d")).unwrap(),
439 CheckOutcome::Deferred
440 );
441 let list = Arg::Expr(Expr::List(vec![]));
443 assert!(ArgType::List.check_arg(&list).is_err());
444 assert_eq!(
445 ArgType::String.check_arg(&list).unwrap(),
446 CheckOutcome::Deferred
447 );
448 assert_eq!(ArgType::List.label(), "LIST");
449 assert_eq!(
452 ArgType::String.check_arg(&lit("x")).unwrap(),
453 CheckOutcome::Static
454 );
455 }
456
457 fn spec(index: usize, required: bool, arg_type: ArgType) -> ArgSpec {
458 ArgSpec {
459 name: "p",
460 arg_type,
461 description: "",
462 io: IoDirection::Write,
463 index,
464 required,
465 fallback_stream: None,
466 }
467 }
468
469 #[test]
470 fn positionals_enforce_required_and_rest_tails() {
471 let specs = [spec(0, true, ArgType::Int)];
472 assert!(validate_positionals_against_meta("T", &specs, &[]).is_err());
473 assert!(validate_positionals_against_meta("T", &specs, &[lit("3")]).is_ok());
474 assert!(validate_positionals_against_meta("T", &specs, &[lit("banana")]).is_err());
475
476 let specs = [ArgSpec {
478 arg_type: ArgType::Rest(&ArgType::Int),
479 ..spec(0, true, ArgType::Int)
480 }];
481 assert!(
482 validate_positionals_against_meta("T", &specs, &[lit("1"), lit("2"), lit("banana")])
483 .is_err()
484 );
485 assert!(validate_positionals_against_meta("T", &specs, &[lit("1"), lit("2")]).is_ok());
486 let specs = [spec(0, true, ArgType::Int)];
488 let err = validate_positionals_against_meta("T", &specs, &[lit("1"), lit("extra")])
489 .expect_err("extras must fail");
490 assert!(
491 err.to_string().contains("at most 1"),
492 "unexpected error: {err:#}"
493 );
494 }
495}