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
514#[derive(Debug, Clone, Copy, PartialEq, Eq)]
516pub struct Example {
517 pub name: &'static str,
518 pub fence_meta: Option<&'static str>,
519 pub code: &'static str,
520}
521
522pub trait CommandSpec {
528 const NAME: &'static str;
529
530 fn metadata() -> CommandMeta;
531 fn lower(flags: Vec<(String, Arg)>, args: Vec<Arg>) -> Result<StepKind>;
532}
533
534#[cfg(test)]
535mod tests {
536 use super::*;
537 use crate::ast::Expr;
538
539 fn lit(text: &str) -> Arg {
540 Arg::String(text.to_string(), false)
541 }
542
543 fn var(name: &str) -> Arg {
544 Arg::Expr(Expr::Var(name.to_string()))
545 }
546
547 #[test]
548 fn validate_literal_covers_each_variant() {
549 ArgType::String.check_arg(&lit("anything at all")).unwrap();
550 ArgType::Path.check_arg(&lit("a/b/../c")).unwrap();
551 ArgType::Int.check_arg(&lit("3")).unwrap();
552 assert!(ArgType::Int.check_arg(&lit("banana")).is_err());
553 ArgType::Duration.check_arg(&lit("10s")).unwrap();
554 ArgType::Duration.check_arg(&lit("30")).unwrap();
555 assert!(ArgType::Duration.check_arg(&lit("banana")).is_err());
556 assert!(ArgType::Duration.check_arg(&lit("0s")).is_err());
557 ArgType::List.check_arg(&lit("$x")).unwrap();
558 assert!(ArgType::List.check_arg(&lit("x")).is_err());
559 assert_eq!(ArgType::Any.label(), "<any>");
561 ArgType::OneOf(&["SNAPSHOT", "LOCAL"])
562 .check_arg(&lit("LOCAL"))
563 .unwrap();
564 assert!(
566 ArgType::OneOf(&["SNAPSHOT", "LOCAL"])
567 .check_arg(&lit("local"))
568 .is_err()
569 );
570 assert!(
571 ArgType::OneOf(&["SNAPSHOT", "LOCAL"])
572 .check_arg(&lit("REMOTE"))
573 .is_err()
574 );
575 }
576
577 #[test]
578 fn check_arg_defers_dynamics_and_enforces_var() {
579 assert_eq!(
581 ArgType::Duration.check_arg(&lit("{{ $d }}")).unwrap(),
582 CheckOutcome::Deferred
583 );
584 assert_eq!(
586 ArgType::List.check_arg(&var("x")).unwrap(),
587 CheckOutcome::Static
588 );
589 assert_eq!(
590 ArgType::Duration.check_arg(&var("d")).unwrap(),
591 CheckOutcome::Deferred
592 );
593 let list = Arg::Expr(Expr::List(vec![]));
595 assert!(ArgType::List.check_arg(&list).is_err());
596 assert_eq!(
597 ArgType::String.check_arg(&list).unwrap(),
598 CheckOutcome::Deferred
599 );
600 assert_eq!(ArgType::List.label(), "LIST");
601 assert_eq!(
604 ArgType::String.check_arg(&lit("x")).unwrap(),
605 CheckOutcome::Static
606 );
607 }
608
609 fn spec(index: usize, required: bool, arg_type: ArgType) -> ArgSpec {
610 ArgSpec {
611 name: "p",
612 arg_type,
613 description: "",
614 io: IoDirection::Write,
615 index,
616 required,
617 fallback_stream: None,
618 }
619 }
620
621 #[test]
622 fn positionals_enforce_required_and_rest_tails() {
623 let specs = [spec(0, true, ArgType::Int)];
624 assert!(validate_positionals_against_meta("T", &specs, &[]).is_err());
625 assert!(validate_positionals_against_meta("T", &specs, &[lit("3")]).is_ok());
626 assert!(validate_positionals_against_meta("T", &specs, &[lit("banana")]).is_err());
627
628 let specs = [ArgSpec {
630 arg_type: ArgType::Rest(&ArgType::Int),
631 ..spec(0, true, ArgType::Int)
632 }];
633 assert!(
634 validate_positionals_against_meta("T", &specs, &[lit("1"), lit("2"), lit("banana")])
635 .is_err()
636 );
637 assert!(validate_positionals_against_meta("T", &specs, &[lit("1"), lit("2")]).is_ok());
638 let specs = [spec(0, true, ArgType::Int)];
640 let err = validate_positionals_against_meta("T", &specs, &[lit("1"), lit("extra")])
641 .expect_err("extras must fail");
642 assert!(
643 err.to_string().contains("at most 1"),
644 "unexpected error: {err:#}"
645 );
646 }
647}