use anyhow::{Result, bail};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PackageSpec {
pub arg_index: usize,
pub name: String,
pub version: Option<String>,
}
impl PackageSpec {
pub fn floats(&self) -> bool {
self.version.is_none()
}
pub fn to_arg(&self) -> String {
match &self.version {
Some(v) => format!("{}@{v}", self.name),
None => self.name.clone(),
}
}
}
fn runner_kind(command: &str) -> Option<Runner> {
let first = command.split_whitespace().next().unwrap_or(command);
let stem = std::path::Path::new(first)
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or(first)
.to_ascii_lowercase();
match stem.as_str() {
"npx" | "bunx" => Some(Runner::Npm),
"uvx" => Some(Runner::Python),
_ => None,
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Runner {
Npm,
Python,
}
pub fn parse_spec(command: &str, args: &[String]) -> Option<PackageSpec> {
runner_kind(command)?;
let mut idx = 0usize;
while idx < args.len() {
let a = &args[idx];
if matches!(a.as_str(), "-p" | "--package" | "-c" | "--call") {
return None;
}
if a.starts_with('-') {
idx += 1; continue;
}
return Some(split_spec(idx, a));
}
None
}
fn split_spec(arg_index: usize, spec: &str) -> PackageSpec {
let search_from = usize::from(spec.starts_with('@'));
match spec[search_from..].rfind('@') {
Some(rel) => {
let at = search_from + rel;
PackageSpec {
arg_index,
name: spec[..at].to_string(),
version: Some(spec[at + 1..].to_string()),
}
}
None => PackageSpec {
arg_index,
name: spec.to_string(),
version: None,
},
}
}
pub fn resolve_current_version(runner: Runner, name: &str) -> Result<String> {
let (program, args) = match runner {
Runner::Npm => (
"npm",
vec!["view".to_string(), name.to_string(), "version".to_string()],
),
Runner::Python => bail!(
"resolving a current version for uvx packages is not implemented; \
pass an explicit `{name}==<version>` in args"
),
};
let out = std::process::Command::new(program)
.args(&args)
.output()
.map_err(|e| anyhow::anyhow!("run `{program} view {name} version`: {e}"))?;
if !out.status.success() {
bail!(
"`{program} view {name} version` failed: {}",
String::from_utf8_lossy(&out.stderr).trim(),
);
}
let v = String::from_utf8_lossy(&out.stdout).trim().to_string();
if v.is_empty() {
bail!("`{program} view {name} version` returned nothing");
}
Ok(v)
}
pub fn runner_for(command: &str) -> Option<Runner> {
runner_kind(command)
}
#[cfg(test)]
mod tests {
use super::*;
fn args(v: &[&str]) -> Vec<String> {
v.iter().map(|s| s.to_string()).collect()
}
#[test]
fn finds_a_floating_scoped_package() {
let s = parse_spec("npx", &args(&["@yawlabs/fetch-mcp"])).unwrap();
assert_eq!(s.name, "@yawlabs/fetch-mcp");
assert_eq!(s.version, None);
assert!(s.floats(), "no version means npx resolves it every start");
assert_eq!(s.arg_index, 0);
}
#[test]
fn a_scope_prefix_is_not_a_version_separator() {
let s = parse_spec("npx", &args(&["@scope/pkg@1.2.3"])).unwrap();
assert_eq!(s.name, "@scope/pkg");
assert_eq!(s.version.as_deref(), Some("1.2.3"));
assert!(!s.floats());
assert_eq!(s.to_arg(), "@scope/pkg@1.2.3");
}
#[test]
fn handles_unscoped_and_valueless_flags() {
let s = parse_spec("npx", &args(&["-y", "--quiet", "some-mcp@0.4.0"])).unwrap();
assert_eq!(s.name, "some-mcp");
assert_eq!(s.version.as_deref(), Some("0.4.0"));
assert_eq!(
s.arg_index, 2,
"index must point at the spec, not the flags"
);
}
#[test]
fn declines_ambiguous_and_non_runner_shapes() {
assert!(parse_spec("npx", &args(&["-p", "typescript", "tsc"])).is_none());
assert!(parse_spec("npx", &args(&["--package", "a", "b"])).is_none());
assert!(parse_spec("node", &args(&["server.js"])).is_none());
assert!(parse_spec("python3", &args(&["-m", "pkg"])).is_none());
assert!(parse_spec("mur-mcp-server", &args(&[])).is_none());
assert!(parse_spec("npx", &args(&["-y"])).is_none(), "flags only");
assert!(parse_spec("npx", &args(&[])).is_none());
}
#[test]
fn recognises_runners_by_path_and_case() {
assert_eq!(runner_for("/opt/homebrew/bin/npx"), Some(Runner::Npm));
assert_eq!(runner_for("BUNX"), Some(Runner::Npm));
assert_eq!(runner_for("uvx"), Some(Runner::Python));
assert_eq!(runner_for("node"), None);
}
#[test]
fn to_arg_round_trips_what_was_parsed() {
for raw in ["@scope/pkg@1.2.3", "@scope/pkg", "pkg@2.0.0-beta.1", "pkg"] {
let s = split_spec(0, raw);
assert_eq!(s.to_arg(), raw);
}
}
}