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> {
match runner {
Runner::Npm => {
let out = std::process::Command::new("npm")
.args(["view", name, "version"])
.output()
.map_err(|e| anyhow::anyhow!("run `npm view {name} version`: {e}"))?;
if !out.status.success() {
bail!(
"`npm 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!("`npm view {name} version` returned nothing");
}
Ok(v)
}
Runner::Python => {
let dir = tempfile::tempdir().map_err(|e| anyhow::anyhow!("temp dir: {e}"))?;
let req_in = dir.path().join("req.in");
std::fs::write(&req_in, format!("{name}\n"))
.map_err(|e| anyhow::anyhow!("write {}: {e}", req_in.display()))?;
let out = std::process::Command::new("uv")
.args(["pip", "compile", "req.in", "-o", "req.lock"])
.current_dir(dir.path())
.output()
.map_err(|e| anyhow::anyhow!("run uv pip compile: {e} (is uv on PATH?)"))?;
if !out.status.success() {
bail!(
"`uv pip compile` could not resolve `{name}`: {}",
String::from_utf8_lossy(&out.stderr).trim(),
);
}
let body = std::fs::read_to_string(dir.path().join("req.lock"))
.map_err(|e| anyhow::anyhow!("read resolved lockfile: {e}"))?;
pinned_version_of(&body, name)
.ok_or_else(|| anyhow::anyhow!("`{name}` did not appear in uv's resolution"))
}
}
}
pub fn pinned_version_of(lockfile: &str, name: &str) -> Option<String> {
let want = normalize_dist_name(name);
for line in lockfile.lines() {
let line = line.trim();
if line.is_empty() || line.starts_with('#') || line.starts_with("--") {
continue;
}
let Some(spec) = line.split_whitespace().next() else {
continue;
};
let Some((pkg, version)) = spec.split_once("==") else {
continue;
};
if normalize_dist_name(pkg) == want {
return Some(version.trim_end_matches('\\').trim().to_string());
}
}
None
}
fn normalize_dist_name(name: &str) -> String {
let mut out = String::with_capacity(name.len());
let mut last_dash = false;
for c in name.chars() {
if matches!(c, '-' | '_' | '.') {
if !last_dash {
out.push('-');
last_dash = true;
}
} else {
out.extend(c.to_lowercase());
last_dash = false;
}
}
out
}
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);
}
}
const UV_LOCK: &str = r#"
# This file was autogenerated by uv via the following command:
# uv pip compile req.in --generate-hashes -o req.lock
annotated-types==0.8.0 \
--hash=sha256:13b2beaad985e05e2d6407ee4c4f35590b11f8d693a258a561055cac8f64cab7
# via pydantic
mcp-server-time==0.6.2 \
--hash=sha256:5d38af6cd620f2ae3849fb44fd4879e0890aa1febe8d47eb355fb45d93fe6a5b
# via -r req.in
"#;
#[test]
fn reads_the_pinned_version_out_of_a_uv_lockfile() {
assert_eq!(
pinned_version_of(UV_LOCK, "mcp-server-time").as_deref(),
Some("0.6.2"),
);
assert_eq!(
pinned_version_of(UV_LOCK, "annotated-types").as_deref(),
Some("0.8.0"),
"transitive deps are pinned in the same file",
);
assert_eq!(pinned_version_of(UV_LOCK, "absent-pkg"), None);
}
#[test]
fn distribution_names_match_across_spelling() {
for spelling in ["mcp_server_time", "MCP-Server-Time", "mcp.server.time"] {
assert_eq!(
pinned_version_of(UV_LOCK, spelling).as_deref(),
Some("0.6.2"),
"`{spelling}` names the same project",
);
}
}
#[test]
fn comments_are_never_mistaken_for_a_pin() {
let lock = "# uv pip compile foo==1.0.0\nbar==2.0.0\n";
assert_eq!(pinned_version_of(lock, "foo"), None, "that was a comment");
assert_eq!(pinned_version_of(lock, "bar").as_deref(), Some("2.0.0"));
}
}