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
103 .iter()
104 .any(|o| *o == literal || o.to_lowercase() == literal)
105 {
106 Ok(())
107 } else {
108 bail!("expected one of {}, got {literal:?}", options.join("|"))
109 }
110 }
111 ArgType::Rest(inner) => inner.validate_literal(literal),
112 }
113 }
114
115 pub fn check_arg(&self, arg: &Arg) -> Result<CheckOutcome> {
120 match arg {
121 Arg::String(s, _) if !s.contains("{{") => {
122 self.validate_literal(s)?;
123 Ok(CheckOutcome::Static)
124 }
125 Arg::String(_, _) => Ok(CheckOutcome::Deferred),
126 Arg::Parts(_) => Ok(CheckOutcome::Deferred),
127 Arg::Expr(Expr::Var(_)) => {
128 if matches!(*self, ArgType::List) {
129 Ok(CheckOutcome::Static)
130 } else {
131 Ok(CheckOutcome::Deferred)
132 }
133 }
134 Arg::Expr(_) => {
135 if matches!(*self, ArgType::List) {
136 bail!("expected $var, got expression {}", arg.render())
137 } else {
138 Ok(CheckOutcome::Deferred)
139 }
140 }
141 }
142 }
143}
144
145#[derive(Debug, Clone, Copy, PartialEq, Eq)]
147pub enum CheckOutcome {
148 Static,
150 Deferred,
153}
154
155pub fn validate_positionals_against_meta(
161 cmd_name: &str,
162 specs: &[ArgSpec],
163 args: &[Arg],
164) -> Result<(), ParseError> {
165 let has_rest = specs
166 .last()
167 .is_some_and(|s| matches!(s.arg_type, ArgType::Rest(_)));
168
169 if !has_rest && args.len() > specs.len() {
170 return Err(ParseError::validation(
171 cmd_name,
172 format!(
173 "invalid syntax for command {cmd_name}: expects at most {} positional argument(s), got {}",
174 specs.len(),
175 args.len()
176 ),
177 &SpanContext::line_only(0),
178 ));
179 }
180
181 for spec in specs {
182 if let ArgType::Rest(inner) = spec.arg_type {
183 let tail = args.get(spec.index..).unwrap_or(&[]);
186 if tail.is_empty() && spec.required {
187 return Err(ParseError::validation(
188 cmd_name,
189 format!(
190 "invalid syntax for command {cmd_name}: requires argument `{}`",
191 spec.name
192 ),
193 &SpanContext::line_only(0),
194 ));
195 }
196 for arg in tail {
197 check_one(cmd_name, spec, inner, arg)?;
198 }
199 return Ok(());
200 }
201 match args.get(spec.index) {
202 Some(arg) => check_one(cmd_name, spec, &spec.arg_type, arg)?,
203 None if spec.required => {
204 return Err(ParseError::validation(
205 cmd_name,
206 format!(
207 "invalid syntax for command {cmd_name}: requires argument `{}`",
208 spec.name
209 ),
210 &SpanContext::line_only(0),
211 ));
212 }
213 None => {}
214 }
215 }
216 Ok(())
217}
218
219fn check_one(
220 cmd_name: &str,
221 spec: &ArgSpec,
222 arg_type: &ArgType,
223 arg: &Arg,
224) -> Result<(), ParseError> {
225 match arg_type.check_arg(arg) {
226 Ok(_) => Ok(()),
227 Err(e) => Err(ParseError::validation(
228 cmd_name,
229 format!(
230 "invalid syntax for command {cmd_name}: argument `{}` got {} — {e:#}",
231 spec.name,
232 arg.render()
233 ),
234 &SpanContext::line_only(0),
235 )),
236 }
237}
238
239pub fn strip_surrounding_quotes(value: &str) -> &str {
241 value
242 .strip_prefix('"')
243 .and_then(|s| s.strip_suffix('"'))
244 .or_else(|| value.strip_prefix('\'').and_then(|s| s.strip_suffix('\'')))
245 .unwrap_or(value)
246}
247
248pub fn split_assignment(text: &str) -> Result<Option<(String, Arg)>> {
253 let Some((key, raw)) = text.split_once('=') else {
254 return Ok(None);
255 };
256 if key.is_empty() {
257 bail!("assignment requires KEY=value format");
258 }
259 Ok(Some((
260 key.to_string(),
261 Arg::String(strip_surrounding_quotes(raw).to_string(), false),
262 )))
263}
264
265pub fn parse_duration(s: &str) -> Result<std::time::Duration> {
268 let (digits, unit_ms): (&str, u64) = if let Some(v) = s.strip_suffix("ms") {
269 (v, 1)
270 } else if let Some(v) = s.strip_suffix('s') {
271 (v, 1_000)
272 } else if let Some(v) = s.strip_suffix('m') {
273 (v, 60_000)
274 } else if let Some(v) = s.strip_suffix('h') {
275 (v, 3_600_000)
276 } else {
277 (s, 1_000)
278 };
279 let n: u64 = digits
280 .parse()
281 .map_err(|_| anyhow!("invalid TIMEOUT duration: {s}"))?;
282 let millis = n
283 .checked_mul(unit_ms)
284 .ok_or_else(|| anyhow!("TIMEOUT duration out of range: {s}"))?;
285 if millis == 0 {
286 bail!("TIMEOUT duration must be positive, got: {s}");
287 }
288 Ok(std::time::Duration::from_millis(millis))
289}
290
291pub fn format_duration(d: &std::time::Duration) -> String {
295 let millis = d.as_millis();
296 if millis.is_multiple_of(3_600_000) {
297 format!("{}h", millis / 3_600_000)
298 } else if millis.is_multiple_of(60_000) {
299 format!("{}m", millis / 60_000)
300 } else if millis.is_multiple_of(1_000) {
301 format!("{}s", millis / 1_000)
302 } else {
303 format!("{millis}ms")
304 }
305}
306
307#[derive(Debug, Clone, Copy, PartialEq, Eq)]
309pub enum IoDirection {
310 Read,
311 Write,
312}
313
314#[derive(Debug, Clone, Copy, PartialEq, Eq)]
316pub enum Stream {
317 Stdin,
318 Stdout,
319 Stderr,
320}
321
322pub struct FlagSpec {
324 pub name: &'static str,
325 pub long: &'static str,
326 pub value_type: FlagValueType,
327 pub required: bool,
328 pub description: &'static str,
329}
330
331#[derive(Debug, Clone, Copy, PartialEq, Eq)]
333pub enum FlagValueType {
334 Flag,
336 String,
338 Int,
340}
341
342impl FlagValueType {
343 pub fn label(&self) -> &'static str {
346 match self {
347 FlagValueType::Flag => "BOOL",
348 FlagValueType::String => "STRING",
349 FlagValueType::Int => "INT",
350 }
351 }
352}
353
354pub struct CommandMeta {
356 pub name: &'static str,
357 pub syntax: &'static str,
358 pub summary: &'static str,
359 pub description: &'static str,
360 pub args: &'static [ArgSpec],
361 pub flags: &'static [FlagSpec],
362 pub default_output: Option<Stream>,
363 pub examples: &'static [Example],
364}
365
366pub struct Example {
368 pub name: &'static str,
369 pub fence_meta: Option<&'static str>,
370 pub code: &'static str,
371}
372
373pub trait CommandSpec {
379 const NAME: &'static str;
380
381 fn metadata() -> CommandMeta;
382 fn lower(flags: Vec<(String, Arg)>, args: Vec<Arg>) -> Result<StepKind>;
383}
384
385#[cfg(test)]
386mod tests {
387 use super::*;
388 use crate::ast::Expr;
389
390 fn lit(text: &str) -> Arg {
391 Arg::String(text.to_string(), false)
392 }
393
394 fn var(name: &str) -> Arg {
395 Arg::Expr(Expr::Var(name.to_string()))
396 }
397
398 #[test]
399 fn validate_literal_covers_each_variant() {
400 ArgType::String.check_arg(&lit("anything at all")).unwrap();
401 ArgType::Path.check_arg(&lit("a/b/../c")).unwrap();
402 ArgType::Int.check_arg(&lit("3")).unwrap();
403 assert!(ArgType::Int.check_arg(&lit("banana")).is_err());
404 ArgType::Duration.check_arg(&lit("10s")).unwrap();
405 ArgType::Duration.check_arg(&lit("30")).unwrap();
406 assert!(ArgType::Duration.check_arg(&lit("banana")).is_err());
407 assert!(ArgType::Duration.check_arg(&lit("0s")).is_err());
408 ArgType::List.check_arg(&lit("$x")).unwrap();
409 assert!(ArgType::List.check_arg(&lit("x")).is_err());
410 assert_eq!(ArgType::Any.label(), "<any>");
412 ArgType::OneOf(&["SNAPSHOT", "LOCAL"])
413 .check_arg(&lit("LOCAL"))
414 .unwrap();
415 ArgType::OneOf(&["SNAPSHOT", "LOCAL"])
417 .check_arg(&lit("local"))
418 .unwrap();
419 assert!(
420 ArgType::OneOf(&["SNAPSHOT", "LOCAL"])
421 .check_arg(&lit("REMOTE"))
422 .is_err()
423 );
424 }
425
426 #[test]
427 fn check_arg_defers_dynamics_and_enforces_var() {
428 assert_eq!(
430 ArgType::Duration.check_arg(&lit("{{ $d }}")).unwrap(),
431 CheckOutcome::Deferred
432 );
433 assert_eq!(
435 ArgType::List.check_arg(&var("x")).unwrap(),
436 CheckOutcome::Static
437 );
438 assert_eq!(
439 ArgType::Duration.check_arg(&var("d")).unwrap(),
440 CheckOutcome::Deferred
441 );
442 let list = Arg::Expr(Expr::List(vec![]));
444 assert!(ArgType::List.check_arg(&list).is_err());
445 assert_eq!(
446 ArgType::String.check_arg(&list).unwrap(),
447 CheckOutcome::Deferred
448 );
449 assert_eq!(ArgType::List.label(), "LIST");
450 assert_eq!(
453 ArgType::String.check_arg(&lit("x")).unwrap(),
454 CheckOutcome::Static
455 );
456 }
457
458 fn spec(index: usize, required: bool, arg_type: ArgType) -> ArgSpec {
459 ArgSpec {
460 name: "p",
461 arg_type,
462 description: "",
463 io: IoDirection::Write,
464 index,
465 required,
466 fallback_stream: None,
467 }
468 }
469
470 #[test]
471 fn positionals_enforce_required_and_rest_tails() {
472 let specs = [spec(0, true, ArgType::Int)];
473 assert!(validate_positionals_against_meta("T", &specs, &[]).is_err());
474 assert!(validate_positionals_against_meta("T", &specs, &[lit("3")]).is_ok());
475 assert!(validate_positionals_against_meta("T", &specs, &[lit("banana")]).is_err());
476
477 let specs = [ArgSpec {
479 arg_type: ArgType::Rest(&ArgType::Int),
480 ..spec(0, true, ArgType::Int)
481 }];
482 assert!(
483 validate_positionals_against_meta("T", &specs, &[lit("1"), lit("2"), lit("banana")])
484 .is_err()
485 );
486 assert!(validate_positionals_against_meta("T", &specs, &[lit("1"), lit("2")]).is_ok());
487 let specs = [spec(0, true, ArgType::Int)];
489 let err = validate_positionals_against_meta("T", &specs, &[lit("1"), lit("extra")])
490 .expect_err("extras must fail");
491 assert!(
492 err.to_string().contains("at most 1"),
493 "unexpected error: {err:#}"
494 );
495 }
496}