1use anyhow::{Result, bail};
13
14#[derive(Debug, Clone, PartialEq, Eq)]
16pub struct PackageSpec {
17 pub arg_index: usize,
19 pub name: String,
21 pub version: Option<String>,
24}
25
26impl PackageSpec {
27 pub fn floats(&self) -> bool {
30 self.version.is_none()
31 }
32
33 pub fn to_arg(&self) -> String {
35 match &self.version {
36 Some(v) => format!("{}@{v}", self.name),
37 None => self.name.clone(),
38 }
39 }
40}
41
42fn runner_kind(command: &str) -> Option<Runner> {
44 let first = command.split_whitespace().next().unwrap_or(command);
45 let stem = std::path::Path::new(first)
46 .file_stem()
47 .and_then(|s| s.to_str())
48 .unwrap_or(first)
49 .to_ascii_lowercase();
50 match stem.as_str() {
51 "npx" | "bunx" => Some(Runner::Npm),
52 "uvx" => Some(Runner::Python),
53 _ => None,
54 }
55}
56
57#[derive(Debug, Clone, Copy, PartialEq, Eq)]
58pub enum Runner {
59 Npm,
60 Python,
61}
62
63pub fn parse_spec(command: &str, args: &[String]) -> Option<PackageSpec> {
70 runner_kind(command)?;
71 let mut idx = 0usize;
72 while idx < args.len() {
73 let a = &args[idx];
74 if matches!(a.as_str(), "-p" | "--package" | "-c" | "--call") {
77 return None;
78 }
79 if a.starts_with('-') {
80 idx += 1; continue;
82 }
83 return Some(split_spec(idx, a));
84 }
85 None
86}
87
88fn split_spec(arg_index: usize, spec: &str) -> PackageSpec {
90 let search_from = usize::from(spec.starts_with('@'));
92 match spec[search_from..].rfind('@') {
93 Some(rel) => {
94 let at = search_from + rel;
95 PackageSpec {
96 arg_index,
97 name: spec[..at].to_string(),
98 version: Some(spec[at + 1..].to_string()),
99 }
100 }
101 None => PackageSpec {
102 arg_index,
103 name: spec.to_string(),
104 version: None,
105 },
106 }
107}
108
109pub fn resolve_current_version(runner: Runner, name: &str) -> Result<String> {
114 match runner {
115 Runner::Npm => {
116 let out = std::process::Command::new("npm")
117 .args(["view", name, "version"])
118 .output()
119 .map_err(|e| anyhow::anyhow!("run `npm view {name} version`: {e}"))?;
120 if !out.status.success() {
121 bail!(
122 "`npm view {name} version` failed: {}",
123 String::from_utf8_lossy(&out.stderr).trim(),
124 );
125 }
126 let v = String::from_utf8_lossy(&out.stdout).trim().to_string();
127 if v.is_empty() {
128 bail!("`npm view {name} version` returned nothing");
129 }
130 Ok(v)
131 }
132 Runner::Python => {
138 let dir = tempfile::tempdir().map_err(|e| anyhow::anyhow!("temp dir: {e}"))?;
139 let req_in = dir.path().join("req.in");
140 std::fs::write(&req_in, format!("{name}\n"))
141 .map_err(|e| anyhow::anyhow!("write {}: {e}", req_in.display()))?;
142 let out = std::process::Command::new("uv")
143 .args(["pip", "compile", "req.in", "-o", "req.lock"])
144 .current_dir(dir.path())
145 .output()
146 .map_err(|e| anyhow::anyhow!("run uv pip compile: {e} (is uv on PATH?)"))?;
147 if !out.status.success() {
148 bail!(
149 "`uv pip compile` could not resolve `{name}`: {}",
150 String::from_utf8_lossy(&out.stderr).trim(),
151 );
152 }
153 let body = std::fs::read_to_string(dir.path().join("req.lock"))
154 .map_err(|e| anyhow::anyhow!("read resolved lockfile: {e}"))?;
155 pinned_version_of(&body, name)
156 .ok_or_else(|| anyhow::anyhow!("`{name}` did not appear in uv's resolution"))
157 }
158 }
159}
160
161pub fn pinned_version_of(lockfile: &str, name: &str) -> Option<String> {
167 let want = normalize_dist_name(name);
168 for line in lockfile.lines() {
169 let line = line.trim();
170 if line.is_empty() || line.starts_with('#') || line.starts_with("--") {
175 continue;
176 }
177 let Some(spec) = line.split_whitespace().next() else {
178 continue;
179 };
180 let Some((pkg, version)) = spec.split_once("==") else {
181 continue;
182 };
183 if normalize_dist_name(pkg) == want {
184 return Some(version.trim_end_matches('\\').trim().to_string());
185 }
186 }
187 None
188}
189
190fn normalize_dist_name(name: &str) -> String {
192 let mut out = String::with_capacity(name.len());
193 let mut last_dash = false;
194 for c in name.chars() {
195 if matches!(c, '-' | '_' | '.') {
196 if !last_dash {
197 out.push('-');
198 last_dash = true;
199 }
200 } else {
201 out.extend(c.to_lowercase());
202 last_dash = false;
203 }
204 }
205 out
206}
207
208pub fn runner_for(command: &str) -> Option<Runner> {
210 runner_kind(command)
211}
212
213#[cfg(test)]
214mod tests {
215 use super::*;
216
217 fn args(v: &[&str]) -> Vec<String> {
218 v.iter().map(|s| s.to_string()).collect()
219 }
220
221 #[test]
222 fn finds_a_floating_scoped_package() {
223 let s = parse_spec("npx", &args(&["@yawlabs/fetch-mcp"])).unwrap();
224 assert_eq!(s.name, "@yawlabs/fetch-mcp");
225 assert_eq!(s.version, None);
226 assert!(s.floats(), "no version means npx resolves it every start");
227 assert_eq!(s.arg_index, 0);
228 }
229
230 #[test]
231 fn a_scope_prefix_is_not_a_version_separator() {
232 let s = parse_spec("npx", &args(&["@scope/pkg@1.2.3"])).unwrap();
233 assert_eq!(s.name, "@scope/pkg");
234 assert_eq!(s.version.as_deref(), Some("1.2.3"));
235 assert!(!s.floats());
236 assert_eq!(s.to_arg(), "@scope/pkg@1.2.3");
237 }
238
239 #[test]
240 fn handles_unscoped_and_valueless_flags() {
241 let s = parse_spec("npx", &args(&["-y", "--quiet", "some-mcp@0.4.0"])).unwrap();
242 assert_eq!(s.name, "some-mcp");
243 assert_eq!(s.version.as_deref(), Some("0.4.0"));
244 assert_eq!(
245 s.arg_index, 2,
246 "index must point at the spec, not the flags"
247 );
248 }
249
250 #[test]
253 fn declines_ambiguous_and_non_runner_shapes() {
254 assert!(parse_spec("npx", &args(&["-p", "typescript", "tsc"])).is_none());
255 assert!(parse_spec("npx", &args(&["--package", "a", "b"])).is_none());
256 assert!(parse_spec("node", &args(&["server.js"])).is_none());
257 assert!(parse_spec("python3", &args(&["-m", "pkg"])).is_none());
258 assert!(parse_spec("mur-mcp-server", &args(&[])).is_none());
259 assert!(parse_spec("npx", &args(&["-y"])).is_none(), "flags only");
260 assert!(parse_spec("npx", &args(&[])).is_none());
261 }
262
263 #[test]
264 fn recognises_runners_by_path_and_case() {
265 assert_eq!(runner_for("/opt/homebrew/bin/npx"), Some(Runner::Npm));
266 assert_eq!(runner_for("BUNX"), Some(Runner::Npm));
267 assert_eq!(runner_for("uvx"), Some(Runner::Python));
268 assert_eq!(runner_for("node"), None);
269 }
270
271 #[test]
272 fn to_arg_round_trips_what_was_parsed() {
273 for raw in ["@scope/pkg@1.2.3", "@scope/pkg", "pkg@2.0.0-beta.1", "pkg"] {
274 let s = split_spec(0, raw);
275 assert_eq!(s.to_arg(), raw);
276 }
277 }
278
279 const UV_LOCK: &str = r#"
284# This file was autogenerated by uv via the following command:
285# uv pip compile req.in --generate-hashes -o req.lock
286annotated-types==0.8.0 \
287 --hash=sha256:13b2beaad985e05e2d6407ee4c4f35590b11f8d693a258a561055cac8f64cab7
288 # via pydantic
289mcp-server-time==0.6.2 \
290 --hash=sha256:5d38af6cd620f2ae3849fb44fd4879e0890aa1febe8d47eb355fb45d93fe6a5b
291 # via -r req.in
292"#;
293
294 #[test]
295 fn reads_the_pinned_version_out_of_a_uv_lockfile() {
296 assert_eq!(
297 pinned_version_of(UV_LOCK, "mcp-server-time").as_deref(),
298 Some("0.6.2"),
299 );
300 assert_eq!(
301 pinned_version_of(UV_LOCK, "annotated-types").as_deref(),
302 Some("0.8.0"),
303 "transitive deps are pinned in the same file",
304 );
305 assert_eq!(pinned_version_of(UV_LOCK, "absent-pkg"), None);
306 }
307
308 #[test]
311 fn distribution_names_match_across_spelling() {
312 for spelling in ["mcp_server_time", "MCP-Server-Time", "mcp.server.time"] {
313 assert_eq!(
314 pinned_version_of(UV_LOCK, spelling).as_deref(),
315 Some("0.6.2"),
316 "`{spelling}` names the same project",
317 );
318 }
319 }
320
321 #[test]
322 fn comments_are_never_mistaken_for_a_pin() {
323 let lock = "# uv pip compile foo==1.0.0\nbar==2.0.0\n";
324 assert_eq!(pinned_version_of(lock, "foo"), None, "that was a comment");
325 assert_eq!(pinned_version_of(lock, "bar").as_deref(), Some("2.0.0"));
326 }
327}