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 => strip_digit_separators(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> {
267 let s = join_single_space(s)?;
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.as_str(), 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
307pub fn strip_digit_separators(text: &str) -> String {
318 let body = text
319 .strip_prefix('+')
320 .or_else(|| text.strip_prefix('-'))
321 .unwrap_or(text);
322 let bytes = body.as_bytes();
323 let well_formed = !body.is_empty()
324 && bytes[0].is_ascii_digit()
325 && bytes[bytes.len() - 1].is_ascii_digit()
326 && bytes
327 .iter()
328 .all(|b| b.is_ascii_digit() || *b == b'_' || *b == b'.')
329 && !body.contains("__")
330 && body.bytes().filter(|b| *b == b'.').count() <= 1;
331 if well_formed {
332 text.replace('_', "")
333 } else {
334 text.to_string()
335 }
336}
337
338fn join_single_space(s: &str) -> Result<String> {
345 let mut parts = s.split_whitespace();
346 let (first, second) = (parts.next(), parts.next());
347 if parts.next().is_some() {
348 bail!("invalid numeric token: {s}");
349 }
350 match (first, second) {
351 (Some(digits), None) => Ok(digits.to_string()),
352 (Some(digits), Some(unit))
353 if digits
354 .strip_prefix('+')
355 .unwrap_or(digits)
356 .bytes()
357 .all(|b| b.is_ascii_digit() || b == b'_')
358 && unit.bytes().all(|b| b.is_ascii_alphabetic()) =>
359 {
360 Ok(format!("{digits}{unit}"))
361 }
362 _ => bail!("invalid numeric token: {s}"),
363 }
364}
365
366pub fn parse_byte_count(s: &str) -> Result<u64> {
379 let compact = join_single_space(s)?;
380 let s = compact.as_str();
381 let lower = s.to_ascii_lowercase();
382 let (digits, unit): (&str, u64) = if let Some(v) = lower.strip_suffix("kib") {
383 (v, 1 << 10)
384 } else if let Some(v) = lower.strip_suffix("mib") {
385 (v, 1 << 20)
386 } else if let Some(v) = lower.strip_suffix("gib") {
387 (v, 1 << 30)
388 } else if let Some(v) = lower.strip_suffix("tib") {
389 (v, 1 << 40)
390 } else if let Some(v) = lower.strip_suffix("kb") {
391 (v, 1_000)
392 } else if let Some(v) = lower.strip_suffix("mb") {
393 (v, 1_000_000)
394 } else if let Some(v) = lower.strip_suffix("gb") {
395 (v, 1_000_000_000)
396 } else if let Some(v) = lower.strip_suffix("tb") {
397 (v, 1_000_000_000_000)
398 } else if let Some(v) = lower.strip_suffix('b') {
399 (v, 1)
400 } else {
401 (lower.as_str(), 1)
402 };
403 let digits = strip_digit_separators(&s[..digits.len()]);
409 let digits = digits.strip_prefix('+').unwrap_or(&digits);
410 let n: u64 = digits
411 .parse()
412 .map_err(|_| anyhow!("invalid byte count: {s}"))?;
413 let bytes = n
414 .checked_mul(unit)
415 .ok_or_else(|| anyhow!("byte count out of range: {s}"))?;
416 if bytes == 0 {
417 bail!("byte count must be positive, got: {s}");
418 }
419 Ok(bytes)
420}
421
422pub fn format_byte_count(n: u64) -> String {
426 const TIB: u64 = 1 << 40;
427 const TB: u64 = 1_000_000_000_000;
428 const GIB: u64 = 1 << 30;
429 const GB: u64 = 1_000_000_000;
430 const MIB: u64 = 1 << 20;
431 const MB: u64 = 1_000_000;
432 const KIB: u64 = 1 << 10;
433 const KB: u64 = 1_000;
434 if n >= TIB && n.is_multiple_of(TIB) {
435 format!("{}TiB", n / TIB)
436 } else if n >= TB && n.is_multiple_of(TB) {
437 format!("{}TB", n / TB)
438 } else if n >= GIB && n.is_multiple_of(GIB) {
439 format!("{}GiB", n / GIB)
440 } else if n >= GB && n.is_multiple_of(GB) {
441 format!("{}GB", n / GB)
442 } else if n >= MIB && n.is_multiple_of(MIB) {
443 format!("{}MiB", n / MIB)
444 } else if n >= MB && n.is_multiple_of(MB) {
445 format!("{}MB", n / MB)
446 } else if n >= KIB && n.is_multiple_of(KIB) {
447 format!("{}KiB", n / KIB)
448 } else if n >= KB && n.is_multiple_of(KB) {
449 format!("{}KB", n / KB)
450 } else {
451 format!("{n}B")
452 }
453}
454
455#[derive(Debug, Clone, Copy, PartialEq, Eq)]
457pub enum IoDirection {
458 Read,
459 Write,
460}
461
462#[derive(Debug, Clone, Copy, PartialEq, Eq)]
464pub enum Stream {
465 Stdin,
466 Stdout,
467 Stderr,
468}
469
470pub struct FlagSpec {
472 pub name: &'static str,
473 pub long: &'static str,
474 pub value_type: FlagValueType,
475 pub required: bool,
476 pub description: &'static str,
477}
478
479#[derive(Debug, Clone, Copy, PartialEq, Eq)]
481pub enum FlagValueType {
482 Flag,
484 String,
486 Int,
488}
489
490impl FlagValueType {
491 pub fn label(&self) -> &'static str {
494 match self {
495 FlagValueType::Flag => "BOOL",
496 FlagValueType::String => "STRING",
497 FlagValueType::Int => "INT",
498 }
499 }
500}
501
502pub struct CommandMeta {
504 pub name: &'static str,
505 pub syntax: &'static str,
506 pub summary: &'static str,
507 pub description: &'static str,
508 pub args: &'static [ArgSpec],
509 pub flags: &'static [FlagSpec],
510 pub default_output: Option<Stream>,
511 pub examples: &'static [Example],
512}
513
514pub struct Example {
516 pub name: &'static str,
517 pub fence_meta: Option<&'static str>,
518 pub code: &'static str,
519}
520
521pub trait CommandSpec {
527 const NAME: &'static str;
528
529 fn metadata() -> CommandMeta;
530 fn lower(flags: Vec<(String, Arg)>, args: Vec<Arg>) -> Result<StepKind>;
531}
532
533#[cfg(test)]
534mod tests {
535 use super::*;
536 use crate::ast::Expr;
537
538 fn lit(text: &str) -> Arg {
539 Arg::String(text.to_string(), false)
540 }
541
542 fn var(name: &str) -> Arg {
543 Arg::Expr(Expr::Var(name.to_string()))
544 }
545
546 #[test]
547 fn validate_literal_covers_each_variant() {
548 ArgType::String.check_arg(&lit("anything at all")).unwrap();
549 ArgType::Path.check_arg(&lit("a/b/../c")).unwrap();
550 ArgType::Int.check_arg(&lit("3")).unwrap();
551 assert!(ArgType::Int.check_arg(&lit("banana")).is_err());
552 ArgType::Duration.check_arg(&lit("10s")).unwrap();
553 ArgType::Duration.check_arg(&lit("30")).unwrap();
554 assert!(ArgType::Duration.check_arg(&lit("banana")).is_err());
555 assert!(ArgType::Duration.check_arg(&lit("0s")).is_err());
556 ArgType::List.check_arg(&lit("$x")).unwrap();
557 assert!(ArgType::List.check_arg(&lit("x")).is_err());
558 assert_eq!(ArgType::Any.label(), "<any>");
560 ArgType::OneOf(&["SNAPSHOT", "LOCAL"])
561 .check_arg(&lit("LOCAL"))
562 .unwrap();
563 assert!(
565 ArgType::OneOf(&["SNAPSHOT", "LOCAL"])
566 .check_arg(&lit("local"))
567 .is_err()
568 );
569 assert!(
570 ArgType::OneOf(&["SNAPSHOT", "LOCAL"])
571 .check_arg(&lit("REMOTE"))
572 .is_err()
573 );
574 }
575
576 #[test]
577 fn check_arg_defers_dynamics_and_enforces_var() {
578 assert_eq!(
580 ArgType::Duration.check_arg(&lit("{{ $d }}")).unwrap(),
581 CheckOutcome::Deferred
582 );
583 assert_eq!(
585 ArgType::List.check_arg(&var("x")).unwrap(),
586 CheckOutcome::Static
587 );
588 assert_eq!(
589 ArgType::Duration.check_arg(&var("d")).unwrap(),
590 CheckOutcome::Deferred
591 );
592 let list = Arg::Expr(Expr::List(vec![]));
594 assert!(ArgType::List.check_arg(&list).is_err());
595 assert_eq!(
596 ArgType::String.check_arg(&list).unwrap(),
597 CheckOutcome::Deferred
598 );
599 assert_eq!(ArgType::List.label(), "LIST");
600 assert_eq!(
603 ArgType::String.check_arg(&lit("x")).unwrap(),
604 CheckOutcome::Static
605 );
606 }
607
608 fn spec(index: usize, required: bool, arg_type: ArgType) -> ArgSpec {
609 ArgSpec {
610 name: "p",
611 arg_type,
612 description: "",
613 io: IoDirection::Write,
614 index,
615 required,
616 fallback_stream: None,
617 }
618 }
619
620 #[test]
621 fn positionals_enforce_required_and_rest_tails() {
622 let specs = [spec(0, true, ArgType::Int)];
623 assert!(validate_positionals_against_meta("T", &specs, &[]).is_err());
624 assert!(validate_positionals_against_meta("T", &specs, &[lit("3")]).is_ok());
625 assert!(validate_positionals_against_meta("T", &specs, &[lit("banana")]).is_err());
626
627 let specs = [ArgSpec {
629 arg_type: ArgType::Rest(&ArgType::Int),
630 ..spec(0, true, ArgType::Int)
631 }];
632 assert!(
633 validate_positionals_against_meta("T", &specs, &[lit("1"), lit("2"), lit("banana")])
634 .is_err()
635 );
636 assert!(validate_positionals_against_meta("T", &specs, &[lit("1"), lit("2")]).is_ok());
637 let specs = [spec(0, true, ArgType::Int)];
639 let err = validate_positionals_against_meta("T", &specs, &[lit("1"), lit("extra")])
640 .expect_err("extras must fail");
641 assert!(
642 err.to_string().contains("at most 1"),
643 "unexpected error: {err:#}"
644 );
645 }
646}