Skip to main content

shuck_run/
types.rs

1use std::cmp::Ordering;
2use std::collections::BTreeMap;
3use std::fmt;
4use std::path::{Path, PathBuf};
5
6use anyhow::{Result, anyhow, bail};
7use serde::Deserialize;
8
9/// Supported shell families for `shuck run`.
10#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
11pub enum Shell {
12    /// GNU Bash.
13    Bash,
14    /// Homebrew-style GNU Bash alias.
15    Gbash,
16    /// Bashkit-managed Bash.
17    Bashkit,
18    /// Z shell.
19    Zsh,
20    /// Debian Almquist shell or POSIX `sh`.
21    Dash,
22    /// MirBSD Korn shell.
23    Mksh,
24    /// BusyBox shell applet.
25    Busybox,
26}
27
28impl Shell {
29    /// Return the canonical config and CLI spelling for this shell.
30    pub fn as_str(self) -> &'static str {
31        match self {
32            Self::Bash => "bash",
33            Self::Gbash => "gbash",
34            Self::Bashkit => "bashkit",
35            Self::Zsh => "zsh",
36            Self::Dash => "dash",
37            Self::Mksh => "mksh",
38            Self::Busybox => "busybox",
39        }
40    }
41
42    /// Parse a shell name or common alias.
43    pub fn from_name(name: &str) -> Option<Self> {
44        match name.trim().to_ascii_lowercase().as_str() {
45            "bash" => Some(Self::Bash),
46            "gbash" => Some(Self::Gbash),
47            "bashkit" => Some(Self::Bashkit),
48            "zsh" => Some(Self::Zsh),
49            "dash" | "sh" => Some(Self::Dash),
50            "mksh" | "ksh" => Some(Self::Mksh),
51            "busybox" => Some(Self::Busybox),
52            _ => None,
53        }
54    }
55
56    pub(crate) fn ensure_supported_on_current_platform(self) -> Result<()> {
57        if self != Self::Busybox || std::env::consts::OS == "linux" {
58            return Ok(());
59        }
60
61        bail!("busybox is only supported on Linux");
62    }
63
64    pub(crate) fn infer(source: &str, path: Option<&Path>) -> Option<Self> {
65        Self::infer_from_shebang(source).or_else(|| {
66            path.and_then(|path| {
67                let ext = path.extension()?.to_str()?;
68                Self::from_name(ext)
69            })
70        })
71    }
72
73    fn infer_from_shebang(source: &str) -> Option<Self> {
74        let interpreter = shuck_parser::shebang::interpreter_name(source.lines().next()?)?;
75        Self::from_name(interpreter)
76    }
77}
78
79impl fmt::Display for Shell {
80    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
81        f.write_str(self.as_str())
82    }
83}
84
85/// Parsed shell version with ordering semantics for registry selection.
86#[derive(Debug, Clone)]
87pub struct Version {
88    raw: String,
89    tokens: Vec<VersionToken>,
90    segment_count: usize,
91    prefix_match: bool,
92}
93
94impl Version {
95    /// Parse a version string accepted by Shuck's shell registry.
96    pub fn parse(raw: &str) -> Result<Self> {
97        let raw = raw.trim();
98        if raw.is_empty() {
99            bail!("version cannot be empty");
100        }
101        if !raw
102            .chars()
103            .all(|ch| ch.is_ascii_alphanumeric() || ch == '.')
104        {
105            bail!("invalid version `{raw}`");
106        }
107        if raw.split('.').any(|segment| segment.is_empty()) {
108            bail!("invalid version `{raw}`");
109        }
110
111        let tokens = tokenize_version(raw)?;
112        if tokens.is_empty() {
113            bail!("invalid version `{raw}`");
114        }
115
116        let segment_count = raw.split('.').count();
117        let prefix_match = should_treat_as_prefix(raw, &tokens, segment_count);
118
119        Ok(Self {
120            raw: raw.to_owned(),
121            tokens,
122            segment_count,
123            prefix_match,
124        })
125    }
126
127    /// Return the original version string.
128    pub fn as_str(&self) -> &str {
129        &self.raw
130    }
131
132    fn matches_prefix(&self, other: &Self) -> bool {
133        if !self.prefix_match {
134            return self == other;
135        }
136
137        let mut matched_segments = 0usize;
138        let mut left = self.tokens.iter().peekable();
139        let mut right = other.tokens.iter().peekable();
140        while let Some(left_token) = left.next() {
141            let Some(right_token) = right.next() else {
142                return false;
143            };
144            if left_token != right_token {
145                return false;
146            }
147            if matches!(left_token, VersionToken::Numeric(_) | VersionToken::Text(_))
148                && (left.peek().is_none()
149                    || matches!(
150                        left.peek(),
151                        Some(VersionToken::Numeric(_) | VersionToken::Text(_))
152                    ))
153            {
154                matched_segments += 1;
155            }
156        }
157
158        matched_segments >= self.segment_count
159    }
160}
161
162impl PartialEq for Version {
163    fn eq(&self, other: &Self) -> bool {
164        self.cmp(other) == Ordering::Equal
165    }
166}
167
168impl Eq for Version {}
169
170impl fmt::Display for Version {
171    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
172        f.write_str(&self.raw)
173    }
174}
175
176impl PartialOrd for Version {
177    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
178        Some(self.cmp(other))
179    }
180}
181
182impl Ord for Version {
183    fn cmp(&self, other: &Self) -> Ordering {
184        let mut left = self.tokens.iter();
185        let mut right = other.tokens.iter();
186        loop {
187            match (left.next(), right.next()) {
188                (Some(a), Some(b)) => {
189                    let ordering = a.cmp(b);
190                    if ordering != Ordering::Equal {
191                        return ordering;
192                    }
193                }
194                (Some(_), None) => return Ordering::Greater,
195                (None, Some(_)) => return Ordering::Less,
196                (None, None) => return Ordering::Equal,
197            }
198        }
199    }
200}
201
202#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
203enum VersionToken {
204    Numeric(u64),
205    Text(String),
206}
207
208fn tokenize_version(raw: &str) -> Result<Vec<VersionToken>> {
209    let mut tokens = Vec::new();
210    let mut chars = raw.chars().peekable();
211    while let Some(ch) = chars.peek().copied() {
212        if ch.is_ascii_digit() {
213            let mut digits = String::new();
214            while let Some(next) = chars.peek().copied() {
215                if next.is_ascii_digit() {
216                    digits.push(next);
217                    chars.next();
218                } else {
219                    break;
220                }
221            }
222            let value = digits
223                .parse::<u64>()
224                .map_err(|_| anyhow!("invalid version `{raw}`"))?;
225            tokens.push(VersionToken::Numeric(value));
226            continue;
227        }
228
229        if ch.is_ascii_alphabetic() {
230            let mut text = String::new();
231            while let Some(next) = chars.peek().copied() {
232                if next.is_ascii_alphabetic() {
233                    text.push(next.to_ascii_lowercase());
234                    chars.next();
235                } else {
236                    break;
237                }
238            }
239            tokens.push(VersionToken::Text(text));
240            continue;
241        }
242
243        if ch == '.' {
244            chars.next();
245            continue;
246        }
247
248        break;
249    }
250
251    Ok(tokens)
252}
253
254fn should_treat_as_prefix(raw: &str, tokens: &[VersionToken], segment_count: usize) -> bool {
255    raw.chars().all(|ch| ch.is_ascii_digit() || ch == '.')
256        && segment_count == 2
257        && tokens
258            .iter()
259            .all(|token| matches!(token, VersionToken::Numeric(_)))
260}
261
262/// Version requirement used when resolving or installing a shell.
263#[derive(Debug, Clone, PartialEq, Eq)]
264pub enum VersionConstraint {
265    /// Select the newest available version.
266    Latest,
267    /// Select exactly this version.
268    Exact(Version),
269    /// Select versions matching this dotted numeric prefix.
270    ExactPrefix(Version),
271    /// Select versions satisfying every predicate.
272    Range(Vec<VersionPredicate>),
273}
274
275impl VersionConstraint {
276    /// Parse `latest`, an exact version, or a comma-separated predicate range.
277    pub fn parse(raw: &str) -> Result<Self> {
278        let raw = raw.trim();
279        if raw.eq_ignore_ascii_case("latest") {
280            return Ok(Self::Latest);
281        }
282
283        if raw.contains('>') || raw.contains('<') || raw.contains('=') {
284            let mut predicates = Vec::new();
285            for item in raw.split(',') {
286                let item = item.trim();
287                if item.is_empty() {
288                    bail!("invalid version constraint `{raw}`");
289                }
290                predicates.push(VersionPredicate::parse(item)?);
291            }
292            return Ok(Self::Range(predicates));
293        }
294
295        let version = Version::parse(raw)?;
296        if version.prefix_match {
297            Ok(Self::ExactPrefix(version))
298        } else {
299            Ok(Self::Exact(version))
300        }
301    }
302
303    /// Return whether `version` satisfies this constraint.
304    pub fn matches(&self, version: &Version) -> bool {
305        match self {
306            Self::Latest => true,
307            Self::Exact(expected) => expected == version,
308            Self::ExactPrefix(prefix) => prefix.matches_prefix(version),
309            Self::Range(predicates) => predicates
310                .iter()
311                .all(|predicate| predicate.matches(version)),
312        }
313    }
314
315    pub(crate) fn describe(&self) -> String {
316        match self {
317            Self::Latest => "latest".to_owned(),
318            Self::Exact(version) | Self::ExactPrefix(version) => version.as_str().to_owned(),
319            Self::Range(predicates) => predicates
320                .iter()
321                .map(VersionPredicate::to_string)
322                .collect::<Vec<_>>()
323                .join(","),
324        }
325    }
326}
327
328/// Managed versions available for one shell family.
329#[derive(Debug, Clone, PartialEq, Eq)]
330pub struct AvailableShell {
331    /// Shell family.
332    pub shell: Shell,
333    /// Available versions for that shell.
334    pub versions: Vec<Version>,
335}
336
337/// Runtime shell settings loaded from Shuck config.
338#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
339#[serde(default, rename_all = "kebab-case")]
340pub struct RunConfig {
341    /// Default shell name.
342    pub shell: Option<String>,
343    /// Default shell version constraint.
344    pub shell_version: Option<String>,
345    /// Per-shell version constraints keyed by shell name.
346    pub shells: BTreeMap<String, String>,
347}
348
349/// Concrete interpreter selected for a run.
350#[derive(Debug, Clone, PartialEq, Eq)]
351pub struct ResolvedInterpreter {
352    /// Shell family that was resolved.
353    pub shell: Shell,
354    /// Shell version selected for execution.
355    pub version: Version,
356    /// Filesystem path to the executable interpreter.
357    pub path: PathBuf,
358    /// Where the interpreter was found.
359    pub source: ResolutionSource,
360}
361
362/// Source used to resolve an interpreter.
363#[derive(Debug, Clone, Copy, PartialEq, Eq)]
364pub enum ResolutionSource {
365    /// Interpreter came from Shuck's managed shell cache.
366    Managed,
367    /// Interpreter came from the host system.
368    System,
369}
370
371/// Options controlling interpreter resolution.
372#[derive(Debug, Clone)]
373pub struct ResolveOptions<'a> {
374    /// Requested shell family.
375    pub shell: Option<Shell>,
376    /// Requested shell version constraint.
377    pub version: Option<VersionConstraint>,
378    /// Whether to require a system interpreter.
379    pub system: bool,
380    /// Whether managed resolution may fall back to the system interpreter.
381    pub implicit_system_fallback: bool,
382    /// Optional script path used for shell inference.
383    pub script: Option<&'a Path>,
384    /// Optional config values used as defaults.
385    pub config: Option<&'a RunConfig>,
386    /// Whether resolution should print progress details.
387    pub verbose: bool,
388    /// Whether to refresh the registry before resolving.
389    pub refresh_registry: bool,
390}
391
392impl<'a> ResolveOptions<'a> {
393    /// Create resolution options from the common CLI inputs.
394    pub fn new(
395        shell: Option<Shell>,
396        version: Option<VersionConstraint>,
397        system: bool,
398        script: Option<&'a Path>,
399        config: Option<&'a RunConfig>,
400    ) -> Self {
401        Self {
402            shell,
403            version,
404            system,
405            implicit_system_fallback: false,
406            script,
407            config,
408            verbose: false,
409            refresh_registry: false,
410        }
411    }
412}
413
414pub(crate) fn parse_shell_name(raw: &str) -> Result<Shell> {
415    Shell::from_name(raw).ok_or_else(|| {
416        anyhow!(
417            "unsupported shell `{raw}`; expected one of: bash, gbash, bashkit, zsh, dash, mksh, busybox"
418        )
419    })
420}
421
422/// A single comparison predicate inside a version range.
423#[derive(Debug, Clone, PartialEq, Eq)]
424pub struct VersionPredicate {
425    operator: VersionOperator,
426    version: Version,
427}
428
429impl VersionPredicate {
430    fn parse(raw: &str) -> Result<Self> {
431        let (operator, version) = if let Some(version) = raw.strip_prefix(">=") {
432            (VersionOperator::GreaterOrEqual, version)
433        } else if let Some(version) = raw.strip_prefix("<=") {
434            (VersionOperator::LessOrEqual, version)
435        } else if let Some(version) = raw.strip_prefix('>') {
436            (VersionOperator::Greater, version)
437        } else if let Some(version) = raw.strip_prefix('<') {
438            (VersionOperator::Less, version)
439        } else if let Some(version) = raw.strip_prefix('=') {
440            (VersionOperator::Equal, version)
441        } else {
442            bail!("invalid version predicate `{raw}`");
443        };
444
445        Ok(Self {
446            operator,
447            version: Version::parse(version)?,
448        })
449    }
450
451    fn matches(&self, version: &Version) -> bool {
452        match self.operator {
453            VersionOperator::Greater => version > &self.version,
454            VersionOperator::GreaterOrEqual => version >= &self.version,
455            VersionOperator::Less => version < &self.version,
456            VersionOperator::LessOrEqual => version <= &self.version,
457            VersionOperator::Equal => version == &self.version,
458        }
459    }
460}
461
462impl fmt::Display for VersionPredicate {
463    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
464        write!(f, "{}{}", self.operator, self.version)
465    }
466}
467
468#[derive(Debug, Clone, Copy, PartialEq, Eq)]
469enum VersionOperator {
470    Greater,
471    GreaterOrEqual,
472    Less,
473    LessOrEqual,
474    Equal,
475}
476
477impl fmt::Display for VersionOperator {
478    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
479        let symbol = match self {
480            Self::Greater => ">",
481            Self::GreaterOrEqual => ">=",
482            Self::Less => "<",
483            Self::LessOrEqual => "<=",
484            Self::Equal => "=",
485        };
486        f.write_str(symbol)
487    }
488}