From 240a3f82d44b1b48e7bc76228ee04b519cb48234 Mon Sep 17 00:00:00 2001 From: Christian Blades Date: Tue, 17 Mar 2026 19:46:53 -0400 Subject: [PATCH 01/18] Fix toolchain invocation for Nix and resolve partial crate versions Use `rustup run ` instead of the `+toolchain` syntax for cargo/rustdoc invocations, since the latter only works when the binaries are rustup proxies (not the case in Nix environments). Add version resolution for crates.io so partial versions like "9" are resolved to the latest matching release (e.g. "9.3.1") via the crates.io API, instead of causing HTTP 400 errors. Co-Authored-By: Claude Opus 4.6 --- rust-docs-mcp/src/cache/downloader.rs | 70 +++++++++++++++++++++++++++ rust-docs-mcp/src/cache/service.rs | 21 ++++++++ 2 files changed, 91 insertions(+) diff --git a/rust-docs-mcp/src/cache/downloader.rs b/rust-docs-mcp/src/cache/downloader.rs index effb029..e069b73 100644 --- a/rust-docs-mcp/src/cache/downloader.rs +++ b/rust-docs-mcp/src/cache/downloader.rs @@ -85,6 +85,76 @@ impl CrateDownloader { ) } + /// Resolve a potentially partial version (e.g. "9" or "9.3") to the latest + /// matching exact version on crates.io. If the version already contains two + /// dots (looks like full semver), it is returned as-is. + pub async fn resolve_crates_io_version( + &self, + name: &str, + version: &str, + ) -> Result { + // If it already looks like a full semver version (has two dots), return as-is + if version.matches('.').count() >= 2 { + return Ok(version.to_string()); + } + + let url = format!("https://crates.io/api/v1/crates/{name}"); + tracing::debug!("Resolving version for {name} prefix={version} via {url}"); + + let response = self + .client + .get(&url) + .send() + .await + .with_context(|| format!("Failed to query crates.io for {name}"))?; + + if !response.status().is_success() { + bail!( + "Failed to look up crate {name} on crates.io: HTTP {}", + response.status() + ); + } + + let body: serde_json::Value = response + .json() + .await + .context("Failed to parse crates.io response")?; + + let versions = body["versions"] + .as_array() + .context("Unexpected crates.io response format")?; + + // Find the latest non-yanked version that starts with the given prefix + let prefix_dot = format!("{version}."); + let matching_version = versions + .iter() + .filter_map(|v| { + let num = v["num"].as_str()?; + let yanked = v["yanked"].as_bool().unwrap_or(false); + if yanked { + return None; + } + // Match if version equals the prefix exactly or starts with "prefix." + if num == version || num.starts_with(&prefix_dot) { + Some(num.to_string()) + } else { + None + } + }) + .next(); // crates.io returns versions newest-first + + match matching_version { + Some(resolved) => { + tracing::info!("Resolved {name} version {version} → {resolved}"); + Ok(resolved) + } + None => bail!( + "No matching version found for {name} with prefix '{version}'. \ + Try specifying an exact version (e.g. '9.3.1')." + ), + } + } + /// Download or copy a crate from the specified source pub async fn download_or_copy_crate( &self, diff --git a/rust-docs-mcp/src/cache/service.rs b/rust-docs-mcp/src/cache/service.rs index 37081b0..0170732 100644 --- a/rust-docs-mcp/src/cache/service.rs +++ b/rust-docs-mcp/src/cache/service.rs @@ -58,6 +58,13 @@ impl CrateCache { } /// Ensure a crate's documentation is available, downloading and generating if necessary + /// Resolve a potentially partial crate version to an exact version via crates.io + pub async fn resolve_crates_io_version(&self, name: &str, version: &str) -> Result { + self.downloader + .resolve_crates_io_version(name, version) + .await + } + pub async fn ensure_crate_docs( &self, name: &str, @@ -66,6 +73,20 @@ impl CrateCache { ) -> Result> { tracing::info!("ensure_crate_docs called for {}-{}", name, version); + // Resolve partial versions (e.g. "9" → "9.3.1") for crates.io sources + let version = if source.is_none() { + match self.resolve_crates_io_version(name, version).await { + Ok(v) => v, + Err(e) => { + tracing::warn!("Version resolution failed, using as-is: {e}"); + version.to_string() + } + } + } else { + version.to_string() + }; + let version = version.as_str(); + // Check if docs already exist if self.storage.has_docs(name, version, None) { tracing::info!( From dbd78f25ce2b7c4366d8b953fd2e0b04df6a5267 Mon Sep 17 00:00:00 2001 From: Christian Blades Date: Sun, 12 Apr 2026 21:24:21 -0400 Subject: [PATCH 02/18] Return semver-sorted version list when crate version not found Instead of returning a generic error when a version is not found on crates.io, fetch all available versions, parse and sort them with the semver crate, and include the list in the error response. This lets agents dynamically pick a valid version rather than guessing. Also handle 403 responses from crates.io (returned for nonexistent versions) in addition to 404. Co-Authored-By: Claude Opus 4.6 --- rust-docs-mcp/src/cache/downloader.rs | 257 ++++++++++++++++++++++---- rust-docs-mcp/src/cache/service.rs | 6 + 2 files changed, 228 insertions(+), 35 deletions(-) diff --git a/rust-docs-mcp/src/cache/downloader.rs b/rust-docs-mcp/src/cache/downloader.rs index e069b73..aa42156 100644 --- a/rust-docs-mcp/src/cache/downloader.rs +++ b/rust-docs-mcp/src/cache/downloader.rs @@ -14,6 +14,7 @@ use anyhow::{Context, Result, bail}; use flate2::read::GzDecoder; use futures::StreamExt; use git2::{Cred, FetchOptions, RemoteCallbacks}; +use semver::Version; use std::env; use std::fs::{self, File}; use std::io::Write; @@ -85,21 +86,12 @@ impl CrateDownloader { ) } - /// Resolve a potentially partial version (e.g. "9" or "9.3") to the latest - /// matching exact version on crates.io. If the version already contains two - /// dots (looks like full semver), it is returned as-is. - pub async fn resolve_crates_io_version( - &self, - name: &str, - version: &str, - ) -> Result { - // If it already looks like a full semver version (has two dots), return as-is - if version.matches('.').count() >= 2 { - return Ok(version.to_string()); - } - + /// Fetch all available versions for a crate from crates.io, sorted by semver descending. + /// + /// Returns a list of non-yanked version strings parsed and sorted using semver. + pub async fn fetch_crates_io_versions(&self, name: &str) -> Result> { let url = format!("https://crates.io/api/v1/crates/{name}"); - tracing::debug!("Resolving version for {name} prefix={version} via {url}"); + tracing::debug!("Fetching available versions for {name} via {url}"); let response = self .client @@ -124,9 +116,8 @@ impl CrateDownloader { .as_array() .context("Unexpected crates.io response format")?; - // Find the latest non-yanked version that starts with the given prefix - let prefix_dot = format!("{version}."); - let matching_version = versions + // Collect non-yanked versions, parse with semver, sort descending + let mut parsed_versions: Vec = versions .iter() .filter_map(|v| { let num = v["num"].as_str()?; @@ -134,24 +125,63 @@ impl CrateDownloader { if yanked { return None; } - // Match if version equals the prefix exactly or starts with "prefix." - if num == version || num.starts_with(&prefix_dot) { - Some(num.to_string()) - } else { - None - } + Version::parse(num).ok() }) - .next(); // crates.io returns versions newest-first + .collect(); + + parsed_versions.sort_by(|a, b| b.cmp(a)); // newest first + + Ok(parsed_versions.iter().map(|v| v.to_string()).collect()) + } + + /// Format available versions into a readable string for error messages + fn format_available_versions(versions: &[String]) -> String { + if versions.is_empty() { + return "No versions available.".to_string(); + } + + let display: Vec<&str> = versions.iter().take(20).map(|s| s.as_str()).collect(); + let mut result = format!("Available versions (sorted by semver, newest first):\n"); + for v in &display { + result.push_str(&format!(" - {v}\n")); + } + if versions.len() > 20 { + result.push_str(&format!(" ... and {} more\n", versions.len() - 20)); + } + result + } + + /// Resolve a potentially partial version (e.g. "9" or "9.3") to the latest + /// matching exact version on crates.io. If the version already contains two + /// dots (looks like full semver), it is returned as-is. + /// + /// When no matching version is found, returns a semver-sorted list of all + /// available versions so agents can select dynamically. + pub async fn resolve_crates_io_version(&self, name: &str, version: &str) -> Result { + // If it already looks like a full semver version (has two dots), return as-is + if version.matches('.').count() >= 2 { + return Ok(version.to_string()); + } + + let available_versions = self.fetch_crates_io_versions(name).await?; + + // Find the latest non-yanked version that starts with the given prefix + let prefix_dot = format!("{version}."); + let matching_version = available_versions + .iter() + .find(|num| *num == version || num.starts_with(&prefix_dot)); match matching_version { Some(resolved) => { tracing::info!("Resolved {name} version {version} → {resolved}"); - Ok(resolved) + Ok(resolved.clone()) + } + None => { + let versions_list = Self::format_available_versions(&available_versions); + bail!( + "No matching version found for '{name}' with prefix '{version}'.\n\n{versions_list}" + ) } - None => bail!( - "No matching version found for {name} with prefix '{version}'. \ - Try specifying an exact version (e.g. '9.3.1')." - ), } } @@ -283,12 +313,19 @@ impl CrateDownloader { .with_context(|| format!("Failed to download {name}-{version}"))?; if !response.status().is_success() { - bail!( - "Failed to download {}-{}: HTTP {}", - name, - version, - response.status() - ); + let status = response.status(); + // On 404/403 (crates.io returns 403 for nonexistent versions), fetch and return available versions + if status == reqwest::StatusCode::NOT_FOUND || status == reqwest::StatusCode::FORBIDDEN + { + let versions_msg = match self.fetch_crates_io_versions(name).await { + Ok(versions) => Self::format_available_versions(&versions), + Err(_) => String::new(), + }; + bail!( + "Version '{version}' not found for crate '{name}' on crates.io (HTTP {status}).\n\n{versions_msg}" + ); + } + bail!("Failed to download {}-{}: HTTP {}", name, version, status); } // Save to a temporary file first - make path unique to avoid concurrent conflicts @@ -1050,4 +1087,154 @@ mod tests { "rollback must not reach outside storage's source_path" ); } + + fn test_format_available_versions_empty() { + let versions: Vec = vec![]; + let result = CrateDownloader::format_available_versions(&versions); + assert_eq!(result, "No versions available."); + } + + #[test] + fn test_format_available_versions_few() { + let versions = vec![ + "2.0.0".to_string(), + "1.1.0".to_string(), + "1.0.0".to_string(), + ]; + let result = CrateDownloader::format_available_versions(&versions); + assert!(result.contains("Available versions (sorted by semver, newest first):")); + assert!(result.contains(" - 2.0.0")); + assert!(result.contains(" - 1.1.0")); + assert!(result.contains(" - 1.0.0")); + assert!(!result.contains("... and")); + } + + #[test] + fn test_format_available_versions_truncated() { + let versions: Vec = (0..25).rev().map(|i| format!("1.0.{i}")).collect(); + let result = CrateDownloader::format_available_versions(&versions); + assert!(result.contains(" - 1.0.24")); // first shown + assert!(result.contains(" - 1.0.5")); // last shown (20th) + assert!(!result.contains(" - 1.0.4")); // 21st, not shown + assert!(result.contains("... and 5 more")); + } + + #[tokio::test] + async fn test_fetch_crates_io_versions_returns_semver_sorted() { + let _ = tracing_subscriber::fmt() + .with_env_filter("rust_docs_mcp=debug") + .try_init(); + + let temp_dir = TempDir::new().unwrap(); + let storage = CacheStorage::new(Some(temp_dir.path().to_path_buf())).unwrap(); + let downloader = CrateDownloader::new(storage); + + // serde is a well-known crate with many versions + let versions = downloader.fetch_crates_io_versions("serde").await.unwrap(); + + // Should have many versions + assert!(!versions.is_empty(), "serde should have versions"); + + // Should be sorted descending (newest first) + for window in versions.windows(2) { + let a = Version::parse(&window[0]).unwrap(); + let b = Version::parse(&window[1]).unwrap(); + assert!( + a >= b, + "Expected {a} >= {b}, versions not sorted descending" + ); + } + + // Should contain known versions + assert!( + versions.contains(&"1.0.0".to_string()), + "serde should have version 1.0.0" + ); + } + + #[tokio::test] + async fn test_fetch_crates_io_versions_nonexistent_crate() { + let temp_dir = TempDir::new().unwrap(); + let storage = CacheStorage::new(Some(temp_dir.path().to_path_buf())).unwrap(); + let downloader = CrateDownloader::new(storage); + + let result = downloader + .fetch_crates_io_versions("this-crate-definitely-does-not-exist-xyz-999") + .await; + assert!(result.is_err()); + } + + #[tokio::test] + async fn test_resolve_version_not_found_includes_available_versions() { + let _ = tracing_subscriber::fmt() + .with_env_filter("rust_docs_mcp=debug") + .try_init(); + + let temp_dir = TempDir::new().unwrap(); + let storage = CacheStorage::new(Some(temp_dir.path().to_path_buf())).unwrap(); + let downloader = CrateDownloader::new(storage); + + // Use a prefix that won't match any serde version + let result = downloader.resolve_crates_io_version("serde", "999").await; + + assert!(result.is_err()); + let err_msg = result.unwrap_err().to_string(); + assert!( + err_msg.contains("Available versions"), + "Error should include available versions list, got: {err_msg}" + ); + assert!( + err_msg.contains("999"), + "Error should mention the requested prefix" + ); + } + + #[tokio::test] + async fn test_resolve_version_partial_prefix_works() { + let _ = tracing_subscriber::fmt() + .with_env_filter("rust_docs_mcp=debug") + .try_init(); + + let temp_dir = TempDir::new().unwrap(); + let storage = CacheStorage::new(Some(temp_dir.path().to_path_buf())).unwrap(); + let downloader = CrateDownloader::new(storage); + + // "1" should resolve to latest 1.x.x for serde + let result = downloader.resolve_crates_io_version("serde", "1").await; + + assert!(result.is_ok(), "Should resolve prefix '1': {:?}", result); + let resolved = result.unwrap(); + assert!( + resolved.starts_with("1."), + "Resolved version should start with '1.', got: {resolved}" + ); + // Should be a full semver version + assert!( + resolved.matches('.').count() >= 2, + "Resolved version should be full semver, got: {resolved}" + ); + } + + #[tokio::test] + async fn test_download_nonexistent_version_includes_available_versions() { + let _ = tracing_subscriber::fmt() + .with_env_filter("rust_docs_mcp=debug") + .try_init(); + + let temp_dir = TempDir::new().unwrap(); + let storage = CacheStorage::new(Some(temp_dir.path().to_path_buf())).unwrap(); + let downloader = CrateDownloader::new(storage); + + // Try downloading a version that doesn't exist (full semver, bypasses resolve) + let result = downloader + .download_crate("serde", "999.999.999", None) + .await; + + assert!(result.is_err()); + let err_msg = result.unwrap_err().to_string(); + assert!( + err_msg.contains("Available versions"), + "404 error should include available versions list, got: {err_msg}" + ); + } } diff --git a/rust-docs-mcp/src/cache/service.rs b/rust-docs-mcp/src/cache/service.rs index 0170732..a9049f2 100644 --- a/rust-docs-mcp/src/cache/service.rs +++ b/rust-docs-mcp/src/cache/service.rs @@ -78,6 +78,12 @@ impl CrateCache { match self.resolve_crates_io_version(name, version).await { Ok(v) => v, Err(e) => { + // If the error contains available versions, propagate it + // so agents can see the version list and choose + let err_msg = e.to_string(); + if err_msg.contains("Available versions") { + return Err(e); + } tracing::warn!("Version resolution failed, using as-is: {e}"); version.to_string() } From 427d0991f6c80567a843412c8fb238f409077223 Mon Sep 17 00:00:00 2001 From: Christian Blades Date: Sun, 12 Apr 2026 23:16:17 -0400 Subject: [PATCH 03/18] Apply version resolution earlier in cache and docs paths Address review notes from Devin: version resolution was only applied inside ensure_crate_docs, meaning the workspace member path in ensure_crate_or_member_docs and the download path in cache_crate_with_source both bypassed it. Partial versions like "1" or "1.0" would hit crates.io unresolved and fail with 403/404. Move resolution into ensure_crate_or_member_docs before the member/non-member branch so both paths benefit, and add resolution in cache_crate_with_source for crates.io sources before any download or cache-check logic runs. Co-Authored-By: Claude Opus 4.6 --- rust-docs-mcp/src/cache/service.rs | 54 ++++++++++++++++++------------ 1 file changed, 33 insertions(+), 21 deletions(-) diff --git a/rust-docs-mcp/src/cache/service.rs b/rust-docs-mcp/src/cache/service.rs index a9049f2..2912d10 100644 --- a/rust-docs-mcp/src/cache/service.rs +++ b/rust-docs-mcp/src/cache/service.rs @@ -57,7 +57,6 @@ impl CrateCache { }) } - /// Ensure a crate's documentation is available, downloading and generating if necessary /// Resolve a potentially partial crate version to an exact version via crates.io pub async fn resolve_crates_io_version(&self, name: &str, version: &str) -> Result { self.downloader @@ -65,6 +64,7 @@ impl CrateCache { .await } + /// Ensure a crate's documentation is available, downloading and generating if necessary pub async fn ensure_crate_docs( &self, name: &str, @@ -73,26 +73,6 @@ impl CrateCache { ) -> Result> { tracing::info!("ensure_crate_docs called for {}-{}", name, version); - // Resolve partial versions (e.g. "9" → "9.3.1") for crates.io sources - let version = if source.is_none() { - match self.resolve_crates_io_version(name, version).await { - Ok(v) => v, - Err(e) => { - // If the error contains available versions, propagate it - // so agents can see the version list and choose - let err_msg = e.to_string(); - if err_msg.contains("Available versions") { - return Err(e); - } - tracing::warn!("Version resolution failed, using as-is: {e}"); - version.to_string() - } - } - } else { - version.to_string() - }; - let version = version.as_str(); - // Check if docs already exist if self.storage.has_docs(name, version, None) { tracing::info!( @@ -240,6 +220,22 @@ impl CrateCache { version: &str, member: Option<&str>, ) -> Result> { + // Resolve partial versions (e.g. "9" → "9.3.1") for crates.io sources + // before branching into member/non-member paths so both benefit + let version = match self.resolve_crates_io_version(name, version).await { + Ok(v) => v, + Err(e) => { + // If the error contains available versions, propagate it + // so agents can see the version list and choose + if e.to_string().contains("Available versions") { + return Err(e); + } + tracing::warn!("Version resolution failed, using as-is: {e}"); + version.to_string() + } + }; + let version = version.as_str(); + // If member is specified, use workspace member logic if let Some(member_path) = member { return self @@ -837,6 +833,22 @@ impl CrateCache { let (crate_name, version, members, source_str, update) = self.extract_source_params(&source); + // Resolve partial versions for crates.io sources before any download/cache checks + let version = if matches!(&source, CrateSource::CratesIO(_)) { + match self.resolve_crates_io_version(&crate_name, &version).await { + Ok(v) => v, + Err(e) => { + return CacheResponse::error(format!( + "Version resolution failed for '{}': {}", + crate_name, e + )) + .to_json(); + } + } + } else { + version + }; + tracing::info!( "cache_crate_with_source: starting for {}-{}, update={}, members={:?}", crate_name, From 8c7bf987f23c1b501b94fa1ab0ed7eac5d36d858 Mon Sep 17 00:00:00 2001 From: Christian Blades Date: Sun, 12 Apr 2026 22:42:02 -0400 Subject: [PATCH 04/18] Use rolling nightly in flake instead of pinned toolchain date MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Switch from fromToolchainFile (which pins to the exact dated nightly in rust-toolchain.toml and requires a manual sha256 update on every bump) to fenix's rolling latest nightly with explicit components. This aligns the flake with the relaxed toolchain selection introduced in a175ca4 — the runtime's resolve_toolchain probe validates compatibility at startup. Co-Authored-By: Claude Opus 4.6 --- flake.nix | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/flake.nix b/flake.nix index 85bb492..4c1bc34 100644 --- a/flake.nix +++ b/flake.nix @@ -29,14 +29,17 @@ pkgs, ... }: let - # Use rust-toolchain.toml for the exact nightly version - rustToolchain = inputs'.fenix.packages.fromToolchainFile { - file = ./rust-toolchain.toml; - sha256 = ""; - }; - - # Nightly toolchain for runtime (used by the binary to fetch docs) - rustNightly = rustToolchain; + # Use fenix's rolling latest nightly so the flake doesn't break every + # time the preferred dated nightly in rust-toolchain.toml changes. + # The runtime's toolchain probe (resolve_toolchain) validates + # compatibility at startup anyway. + rustNightly = inputs'.fenix.packages.latest.withComponents [ + "cargo" + "clippy" + "rustc" + "rustfmt" + "rust-src" + ]; craneLib = inputs.crane.mkLib pkgs; From 3d4fcfcc2dd9fb782dfae4e3be234edbbab8efac Mon Sep 17 00:00:00 2001 From: Christian Blades Date: Mon, 13 Apr 2026 13:23:54 -0400 Subject: [PATCH 05/18] Resolve version upfront and use that version for subsequent calls Addresses review from Devin --- rust-docs-mcp/src/cache/service.rs | 30 ++++++++++++++++- rust-docs-mcp/src/deps/tools.rs | 8 +++-- rust-docs-mcp/src/docs/tools.rs | 52 ++++++++++++++++++++++++++---- rust-docs-mcp/src/search/tools.rs | 19 +++++++++-- 4 files changed, 97 insertions(+), 12 deletions(-) diff --git a/rust-docs-mcp/src/cache/service.rs b/rust-docs-mcp/src/cache/service.rs index 2912d10..81a2a57 100644 --- a/rust-docs-mcp/src/cache/service.rs +++ b/rust-docs-mcp/src/cache/service.rs @@ -64,6 +64,30 @@ impl CrateCache { .await } + /// Resolve a partial version string if needed, with guards to skip resolution + /// for already-cached crates or versions that already look like full semver. + /// + /// This should be called by tool methods before passing the version to + /// `ensure_crate_or_member_docs` and any other version-dependent operations + /// (e.g. `get_source_path`, `load_dependencies`, search index ops). + pub async fn resolve_version(&self, name: &str, version: &str) -> Result { + if !self.storage.is_cached(name, version) && version.matches('.').count() < 2 { + match self.resolve_crates_io_version(name, version).await { + Ok(v) => Ok(v), + Err(e) => { + if e.to_string().contains("Available versions") { + Err(e) + } else { + tracing::warn!("Version resolution failed, using as-is: {e}"); + Ok(version.to_string()) + } + } + } + } else { + Ok(version.to_string()) + } + } + /// Ensure a crate's documentation is available, downloading and generating if necessary pub async fn ensure_crate_docs( &self, @@ -213,7 +237,11 @@ impl CrateCache { self.load_docs(name, version, Some(member_path)).await } - /// Ensure documentation is available for a crate or workspace member + /// Ensure documentation is available for a crate or workspace member. + /// + /// **Important**: Callers should resolve partial versions via [`resolve_version`] + /// *before* calling this method, and use the resolved version for all subsequent + /// operations (e.g. `get_source_path`, `load_dependencies`, search index ops). pub async fn ensure_crate_or_member_docs( &self, name: &str, diff --git a/rust-docs-mcp/src/deps/tools.rs b/rust-docs-mcp/src/deps/tools.rs index a23c70c..3297bd3 100644 --- a/rust-docs-mcp/src/deps/tools.rs +++ b/rust-docs-mcp/src/deps/tools.rs @@ -44,12 +44,16 @@ impl DepsTools { params: GetDependenciesParams, ) -> Result { let cache = self.cache.write().await; + let version = cache + .resolve_version(¶ms.crate_name, ¶ms.version) + .await + .map_err(|e| DepsErrorOutput::new(format!("Failed to resolve version: {e}")))?; // First ensure the crate is cached match cache .ensure_crate_or_member_docs( ¶ms.crate_name, - ¶ms.version, + &version, params.member.as_deref(), ) .await @@ -57,7 +61,7 @@ impl DepsTools { Ok(_) => { // Load the dependency metadata match cache - .load_dependencies(¶ms.crate_name, ¶ms.version) + .load_dependencies(¶ms.crate_name, &version) .await { Ok(metadata) => { diff --git a/rust-docs-mcp/src/docs/tools.rs b/rust-docs-mcp/src/docs/tools.rs index 4e5f350..3be7391 100644 --- a/rust-docs-mcp/src/docs/tools.rs +++ b/rust-docs-mcp/src/docs/tools.rs @@ -150,10 +150,14 @@ impl DocsTools { params: ListItemsParams, ) -> Result { let cache = self.cache.write().await; + let version = cache + .resolve_version(¶ms.crate_name, ¶ms.version) + .await + .map_err(|e| DocsErrorOutput::new(format!("Failed to resolve version: {e}")))?; match cache .ensure_crate_or_member_docs( ¶ms.crate_name, - ¶ms.version, + &version, params.member.as_deref(), ) .await @@ -202,10 +206,14 @@ impl DocsTools { params: SearchItemsParams, ) -> Result { let cache = self.cache.write().await; + let version = cache + .resolve_version(¶ms.crate_name, ¶ms.version) + .await + .map_err(|e| DocsErrorOutput::new(format!("Failed to resolve version: {e}")))?; match cache .ensure_crate_or_member_docs( ¶ms.crate_name, - ¶ms.version, + &version, params.member.as_deref(), ) .await @@ -302,10 +310,14 @@ impl DocsTools { params: SearchItemsPreviewParams, ) -> Result { let cache = self.cache.write().await; + let version = cache + .resolve_version(¶ms.crate_name, ¶ms.version) + .await + .map_err(|e| DocsErrorOutput::new(format!("Failed to resolve version: {e}")))?; match cache .ensure_crate_or_member_docs( ¶ms.crate_name, - ¶ms.version, + &version, params.member.as_deref(), ) .await @@ -379,10 +391,21 @@ impl DocsTools { pub async fn get_item_details(&self, params: GetItemDetailsParams) -> GetItemDetailsOutput { let cache = self.cache.write().await; + let version = match cache + .resolve_version(¶ms.crate_name, ¶ms.version) + .await + { + Ok(v) => v, + Err(e) => { + return GetItemDetailsOutput::Error { + error: format!("Failed to resolve version: {e}"), + }; + } + }; match cache .ensure_crate_or_member_docs( ¶ms.crate_name, - ¶ms.version, + &version, params.member.as_deref(), ) .await @@ -467,10 +490,14 @@ impl DocsTools { params: GetItemDocsParams, ) -> Result { let cache = self.cache.write().await; + let version = cache + .resolve_version(¶ms.crate_name, ¶ms.version) + .await + .map_err(|e| DocsErrorOutput::new(format!("Failed to resolve version: {e}")))?; match cache .ensure_crate_or_member_docs( ¶ms.crate_name, - ¶ms.version, + &version, params.member.as_deref(), ) .await @@ -500,7 +527,18 @@ impl DocsTools { pub async fn get_item_source(&self, params: GetItemSourceParams) -> GetItemSourceOutput { let cache = self.cache.write().await; - let source_base_path = match cache.get_source_path(¶ms.crate_name, ¶ms.version) { + let version = match cache + .resolve_version(¶ms.crate_name, ¶ms.version) + .await + { + Ok(v) => v, + Err(e) => { + return GetItemSourceOutput::Error { + error: format!("Failed to resolve version: {e}"), + }; + } + }; + let source_base_path = match cache.get_source_path(¶ms.crate_name, &version) { Ok(path) => path, Err(e) => { return GetItemSourceOutput::Error { @@ -512,7 +550,7 @@ impl DocsTools { match cache .ensure_crate_or_member_docs( ¶ms.crate_name, - ¶ms.version, + &version, params.member.as_deref(), ) .await diff --git a/rust-docs-mcp/src/search/tools.rs b/rust-docs-mcp/src/search/tools.rs index aae5440..a98345d 100644 --- a/rust-docs-mcp/src/search/tools.rs +++ b/rust-docs-mcp/src/search/tools.rs @@ -144,11 +144,26 @@ impl SearchTools { &self, params: SearchItemsFuzzyParams, ) -> Result { + // Resolve version up front so all operations use the same resolved version + let resolved_version = { + let cache = self.cache.read().await; + cache + .resolve_version(¶ms.crate_name, ¶ms.version) + .await + .map_err(|e| SearchErrorOutput::new(format!("Failed to resolve version: {e}")))? + }; + let query = params.query.clone(); let fuzzy_enabled = params.fuzzy_enabled.unwrap_or(true); let crate_name = params.crate_name.clone(); - let version = params.version.clone(); let member = params.member.clone(); + + // Rebuild params with the resolved version for all downstream operations + let params = SearchItemsFuzzyParams { + version: resolved_version.clone(), + ..params + }; + let result = async { // First check with read lock if docs already exist { @@ -249,7 +264,7 @@ impl SearchTools { total_results: total, fuzzy_enabled, crate_name, - version, + version: resolved_version, member, }) } From bcbe758d0056f75cc5a1e908979c2211f9da6d55 Mon Sep 17 00:00:00 2001 From: Christian Blades Date: Mon, 13 Apr 2026 15:29:45 -0400 Subject: [PATCH 06/18] fixup! use resolved version in get_dependencies Devin caught this, using the param here would cause a mismatch if resolved version differs from the input (ie "1" vs "1.0.0") --- rust-docs-mcp/src/deps/tools.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/rust-docs-mcp/src/deps/tools.rs b/rust-docs-mcp/src/deps/tools.rs index 3297bd3..f350c7c 100644 --- a/rust-docs-mcp/src/deps/tools.rs +++ b/rust-docs-mcp/src/deps/tools.rs @@ -69,7 +69,7 @@ impl DepsTools { match process_cargo_metadata( &metadata, ¶ms.crate_name, - ¶ms.version, + &version, params.include_tree.unwrap_or(false), params.filter.as_deref(), ) { From 23950daee72563aaa535f17dab2f8da169209d2e Mon Sep 17 00:00:00 2001 From: Christian Blades Date: Mon, 13 Apr 2026 22:48:23 -0400 Subject: [PATCH 07/18] fixup! isLinux, test attribute --- flake.nix | 4 ++-- rust-docs-mcp/src/cache/downloader.rs | 1 + 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/flake.nix b/flake.nix index 4c1bc34..4c89224 100644 --- a/flake.nix +++ b/flake.nix @@ -64,12 +64,12 @@ darwin.apple_sdk.frameworks.Security darwin.apple_sdk.frameworks.SystemConfiguration ] - ++ pkgs.lib.optional pkgs.lib.isLinux gcc.cc.lib; + ++ pkgs.lib.optional pkgs.stdenv.hostPlatform.isLinux gcc.cc.lib; nativeBuildInputs = with pkgs; [ pkg-config ] - ++ pkgs.lib.optional pkgs.lib.isLinux autoPatchelfHook; + ++ pkgs.lib.optional pkgs.stdenv.hostPlatform.isLinux autoPatchelfHook; }; # Build dependencies only diff --git a/rust-docs-mcp/src/cache/downloader.rs b/rust-docs-mcp/src/cache/downloader.rs index aa42156..99a3a83 100644 --- a/rust-docs-mcp/src/cache/downloader.rs +++ b/rust-docs-mcp/src/cache/downloader.rs @@ -1088,6 +1088,7 @@ mod tests { ); } + #[test] fn test_format_available_versions_empty() { let versions: Vec = vec![]; let result = CrateDownloader::format_available_versions(&versions); From 4a2531ddd3ad620c1b05edd504547cf79391be5f Mon Sep 17 00:00:00 2001 From: Christian Blades Date: Mon, 13 Apr 2026 22:51:01 -0400 Subject: [PATCH 08/18] cargo fmt --all --- rust-docs-mcp/src/deps/tools.rs | 11 ++-------- rust-docs-mcp/src/docs/tools.rs | 36 ++++++--------------------------- 2 files changed, 8 insertions(+), 39 deletions(-) diff --git a/rust-docs-mcp/src/deps/tools.rs b/rust-docs-mcp/src/deps/tools.rs index f350c7c..5faa4ea 100644 --- a/rust-docs-mcp/src/deps/tools.rs +++ b/rust-docs-mcp/src/deps/tools.rs @@ -51,19 +51,12 @@ impl DepsTools { // First ensure the crate is cached match cache - .ensure_crate_or_member_docs( - ¶ms.crate_name, - &version, - params.member.as_deref(), - ) + .ensure_crate_or_member_docs(¶ms.crate_name, &version, params.member.as_deref()) .await { Ok(_) => { // Load the dependency metadata - match cache - .load_dependencies(¶ms.crate_name, &version) - .await - { + match cache.load_dependencies(¶ms.crate_name, &version).await { Ok(metadata) => { // Process the metadata to extract dependency information match process_cargo_metadata( diff --git a/rust-docs-mcp/src/docs/tools.rs b/rust-docs-mcp/src/docs/tools.rs index 3be7391..dcdc40d 100644 --- a/rust-docs-mcp/src/docs/tools.rs +++ b/rust-docs-mcp/src/docs/tools.rs @@ -155,11 +155,7 @@ impl DocsTools { .await .map_err(|e| DocsErrorOutput::new(format!("Failed to resolve version: {e}")))?; match cache - .ensure_crate_or_member_docs( - ¶ms.crate_name, - &version, - params.member.as_deref(), - ) + .ensure_crate_or_member_docs(¶ms.crate_name, &version, params.member.as_deref()) .await { Ok(crate_data) => { @@ -211,11 +207,7 @@ impl DocsTools { .await .map_err(|e| DocsErrorOutput::new(format!("Failed to resolve version: {e}")))?; match cache - .ensure_crate_or_member_docs( - ¶ms.crate_name, - &version, - params.member.as_deref(), - ) + .ensure_crate_or_member_docs(¶ms.crate_name, &version, params.member.as_deref()) .await { Ok(crate_data) => { @@ -315,11 +307,7 @@ impl DocsTools { .await .map_err(|e| DocsErrorOutput::new(format!("Failed to resolve version: {e}")))?; match cache - .ensure_crate_or_member_docs( - ¶ms.crate_name, - &version, - params.member.as_deref(), - ) + .ensure_crate_or_member_docs(¶ms.crate_name, &version, params.member.as_deref()) .await { Ok(crate_data) => { @@ -403,11 +391,7 @@ impl DocsTools { } }; match cache - .ensure_crate_or_member_docs( - ¶ms.crate_name, - &version, - params.member.as_deref(), - ) + .ensure_crate_or_member_docs(¶ms.crate_name, &version, params.member.as_deref()) .await { Ok(crate_data) => { @@ -495,11 +479,7 @@ impl DocsTools { .await .map_err(|e| DocsErrorOutput::new(format!("Failed to resolve version: {e}")))?; match cache - .ensure_crate_or_member_docs( - ¶ms.crate_name, - &version, - params.member.as_deref(), - ) + .ensure_crate_or_member_docs(¶ms.crate_name, &version, params.member.as_deref()) .await { Ok(crate_data) => { @@ -548,11 +528,7 @@ impl DocsTools { }; match cache - .ensure_crate_or_member_docs( - ¶ms.crate_name, - &version, - params.member.as_deref(), - ) + .ensure_crate_or_member_docs(¶ms.crate_name, &version, params.member.as_deref()) .await { Ok(crate_data) => { From 4117aafb48b2060b84d789e65000287b2532b929 Mon Sep 17 00:00:00 2001 From: Christian Blades Date: Mon, 13 Apr 2026 23:49:40 -0400 Subject: [PATCH 09/18] lint! address clippy --- rust-docs-mcp/src/cache/downloader.rs | 6 +++--- rust-docs-mcp/src/cache/service.rs | 3 +-- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/rust-docs-mcp/src/cache/downloader.rs b/rust-docs-mcp/src/cache/downloader.rs index 99a3a83..1eaab74 100644 --- a/rust-docs-mcp/src/cache/downloader.rs +++ b/rust-docs-mcp/src/cache/downloader.rs @@ -141,7 +141,7 @@ impl CrateDownloader { } let display: Vec<&str> = versions.iter().take(20).map(|s| s.as_str()).collect(); - let mut result = format!("Available versions (sorted by semver, newest first):\n"); + let mut result = "Available versions (sorted by semver, newest first):\n".to_string(); for v in &display { result.push_str(&format!(" - {v}\n")); } @@ -325,7 +325,7 @@ impl CrateDownloader { "Version '{version}' not found for crate '{name}' on crates.io (HTTP {status}).\n\n{versions_msg}" ); } - bail!("Failed to download {}-{}: HTTP {}", name, version, status); + bail!("Failed to download {name}-{version}: HTTP {status}"); } // Save to a temporary file first - make path unique to avoid concurrent conflicts @@ -1203,7 +1203,7 @@ mod tests { // "1" should resolve to latest 1.x.x for serde let result = downloader.resolve_crates_io_version("serde", "1").await; - assert!(result.is_ok(), "Should resolve prefix '1': {:?}", result); + assert!(result.is_ok(), "Should resolve prefix '1': {result:?}"); let resolved = result.unwrap(); assert!( resolved.starts_with("1."), diff --git a/rust-docs-mcp/src/cache/service.rs b/rust-docs-mcp/src/cache/service.rs index 81a2a57..41eb0ae 100644 --- a/rust-docs-mcp/src/cache/service.rs +++ b/rust-docs-mcp/src/cache/service.rs @@ -867,8 +867,7 @@ impl CrateCache { Ok(v) => v, Err(e) => { return CacheResponse::error(format!( - "Version resolution failed for '{}': {}", - crate_name, e + "Version resolution failed for '{crate_name}': {e}", )) .to_json(); } From 92751a71cdbfecbe675446173ae13787b7a37cdc Mon Sep 17 00:00:00 2001 From: Christian Blades Date: Tue, 14 Apr 2026 14:12:47 -0400 Subject: [PATCH 10/18] Replace redundant resolve_crates_io_version with resolve_version in ensure_crate_or_member_docs The internal resolve_crates_io_version call bypassed the is_cached guard, causing unnecessary HTTP requests for already-cached or non-crates.io crates. Replace with resolve_version which handles all cases correctly. Co-Authored-By: Claude Sonnet 4.6 --- rust-docs-mcp/src/cache/service.rs | 15 +-------------- 1 file changed, 1 insertion(+), 14 deletions(-) diff --git a/rust-docs-mcp/src/cache/service.rs b/rust-docs-mcp/src/cache/service.rs index 41eb0ae..f55c4ee 100644 --- a/rust-docs-mcp/src/cache/service.rs +++ b/rust-docs-mcp/src/cache/service.rs @@ -248,20 +248,7 @@ impl CrateCache { version: &str, member: Option<&str>, ) -> Result> { - // Resolve partial versions (e.g. "9" → "9.3.1") for crates.io sources - // before branching into member/non-member paths so both benefit - let version = match self.resolve_crates_io_version(name, version).await { - Ok(v) => v, - Err(e) => { - // If the error contains available versions, propagate it - // so agents can see the version list and choose - if e.to_string().contains("Available versions") { - return Err(e); - } - tracing::warn!("Version resolution failed, using as-is: {e}"); - version.to_string() - } - }; + let version = self.resolve_version(name, version).await?; let version = version.as_str(); // If member is specified, use workspace member logic From fe4150575303d580afb666ec0e25897daa541cf3 Mon Sep 17 00:00:00 2001 From: Christian Blades Date: Mon, 13 Apr 2026 23:29:25 -0400 Subject: [PATCH 11/18] Add nix flake check CI workflow Runs nix flake check (build, clippy, fmt, nextest) and a dev shell smoke test on every push/PR to main. `nix flake check` executes tests in a sandbox that doesn't have network access. Here we add an ignore attribute to tests that need network access. We include these tests in the CI workflows since we know those environments have network access. Co-Authored-By: Claude Sonnet 4.6 --- .github/workflows/nix.yml | 29 ++++++++++++++++++++++++ .github/workflows/rust.yml | 4 ++-- flake.nix | 6 +++++ rust-docs-mcp/src/cache/downloader.rs | 5 ++++ rust-docs-mcp/tests/integration_tests.rs | 18 +++++++++++++++ 5 files changed, 60 insertions(+), 2 deletions(-) create mode 100644 .github/workflows/nix.yml diff --git a/.github/workflows/nix.yml b/.github/workflows/nix.yml new file mode 100644 index 0000000..25c9474 --- /dev/null +++ b/.github/workflows/nix.yml @@ -0,0 +1,29 @@ +name: Nix + +permissions: + contents: read + +on: + push: + branches: [ "main" ] + pull_request: + branches: [ "main" ] + +jobs: + check: + name: nix flake check + runs-on: ubuntu-latest + timeout-minutes: 60 + + steps: + - uses: actions/checkout@v4 + + - uses: DeterminateSystems/nix-installer-action@main + + - uses: DeterminateSystems/magic-nix-cache-action@main + + - name: Check flake + run: nix flake check --all-systems + + - name: Check dev shell + run: nix develop --command rustc --version diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index 38d57a4..992f15e 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -73,7 +73,7 @@ jobs: ${{ runner.os }}-cargo- - name: Run unit tests - run: cargo test --workspace --lib --bins --verbose + run: cargo test --workspace --lib --bins --verbose -- --include-ignored - name: Run doc tests run: cargo test --workspace --doc --verbose @@ -101,4 +101,4 @@ jobs: ${{ runner.os }}-cargo- - name: Run integration tests - run: cargo test --workspace --test integration_tests --verbose + run: cargo test --workspace --test integration_tests --verbose -- --include-ignored diff --git a/flake.nix b/flake.nix index 4c89224..fea999c 100644 --- a/flake.nix +++ b/flake.nix @@ -127,6 +127,12 @@ inherit cargoArtifacts; partitions = 1; partitionType = "count"; + } + // pkgs.lib.optionalAttrs pkgs.stdenv.hostPlatform.isLinux { + # autoPatchelfHook only runs during fixupPhase, but nextest + # executes test binaries during checkPhase — set LD_LIBRARY_PATH + # so they can find libssl and other shared libs. + LD_LIBRARY_PATH = pkgs.lib.makeLibraryPath [pkgs.openssl]; }); }; }; diff --git a/rust-docs-mcp/src/cache/downloader.rs b/rust-docs-mcp/src/cache/downloader.rs index 1eaab74..3547e9a 100644 --- a/rust-docs-mcp/src/cache/downloader.rs +++ b/rust-docs-mcp/src/cache/downloader.rs @@ -859,6 +859,7 @@ mod tests { } #[tokio::test] + #[ignore = "requires network access (hits crates.io)"] async fn test_problematic_crate_download() { // Initialize logging for the test let _ = tracing_subscriber::fmt() @@ -1121,6 +1122,7 @@ mod tests { } #[tokio::test] + #[ignore = "requires network access (hits crates.io)"] async fn test_fetch_crates_io_versions_returns_semver_sorted() { let _ = tracing_subscriber::fmt() .with_env_filter("rust_docs_mcp=debug") @@ -1166,6 +1168,7 @@ mod tests { } #[tokio::test] + #[ignore = "requires network access (hits crates.io)"] async fn test_resolve_version_not_found_includes_available_versions() { let _ = tracing_subscriber::fmt() .with_env_filter("rust_docs_mcp=debug") @@ -1191,6 +1194,7 @@ mod tests { } #[tokio::test] + #[ignore = "requires network access (hits crates.io)"] async fn test_resolve_version_partial_prefix_works() { let _ = tracing_subscriber::fmt() .with_env_filter("rust_docs_mcp=debug") @@ -1217,6 +1221,7 @@ mod tests { } #[tokio::test] + #[ignore = "requires network access (hits crates.io)"] async fn test_download_nonexistent_version_includes_available_versions() { let _ = tracing_subscriber::fmt() .with_env_filter("rust_docs_mcp=debug") diff --git a/rust-docs-mcp/tests/integration_tests.rs b/rust-docs-mcp/tests/integration_tests.rs index 6db8039..9c5439a 100644 --- a/rust-docs-mcp/tests/integration_tests.rs +++ b/rust-docs-mcp/tests/integration_tests.rs @@ -203,6 +203,7 @@ async fn get_test_item_id(service: &RustDocsService) -> Result { } #[tokio::test] +#[ignore = "requires network access"] async fn test_cache_from_crates_io() -> Result<()> { let (service, _temp_dir) = create_test_service()?; @@ -253,6 +254,7 @@ async fn test_cache_from_crates_io() -> Result<()> { } #[tokio::test] +#[ignore = "requires network access"] async fn test_cache_from_github() -> Result<()> { let (service, _temp_dir) = create_test_service()?; @@ -300,6 +302,7 @@ async fn test_cache_from_github() -> Result<()> { } #[tokio::test] +#[ignore = "requires network access"] async fn test_cache_from_github_branch() -> Result<()> { // Initialize tracing for this test let _ = tracing_subscriber::fmt() @@ -343,6 +346,7 @@ async fn test_cache_from_github_branch() -> Result<()> { } #[tokio::test] +#[ignore = "requires network access"] async fn test_cache_from_local_path() -> Result<()> { let (service, _temp_dir) = create_test_service()?; @@ -488,6 +492,7 @@ serde = {{ workspace = true }} } #[tokio::test] +#[ignore = "requires network access"] async fn test_cache_update() -> Result<()> { let (service, _temp_dir) = create_test_service()?; @@ -618,6 +623,7 @@ async fn test_invalid_inputs() -> Result<()> { } #[tokio::test] +#[ignore = "requires network access"] async fn test_concurrent_caching() -> Result<()> { let (service, _temp_dir) = create_test_service()?; let service = std::sync::Arc::new(service); @@ -815,6 +821,7 @@ edition = "2021" // ===== DOCUMENTATION TOOLS TESTS ===== #[tokio::test] +#[ignore = "requires network access"] async fn test_list_crate_items() -> Result<()> { let (service, _temp_dir) = create_test_service()?; setup_test_crate(&service).await?; @@ -858,6 +865,7 @@ async fn test_list_crate_items() -> Result<()> { } #[tokio::test] +#[ignore = "requires network access"] async fn test_search_items_preview() -> Result<()> { let (service, _temp_dir) = create_test_service()?; setup_test_crate(&service).await?; @@ -914,6 +922,7 @@ async fn test_search_items_preview() -> Result<()> { } #[tokio::test] +#[ignore = "requires network access"] async fn test_search_items_full() -> Result<()> { let (service, _temp_dir) = create_test_service()?; setup_test_crate(&service).await?; @@ -948,6 +957,7 @@ async fn test_search_items_full() -> Result<()> { } #[tokio::test] +#[ignore = "requires network access"] async fn test_get_item_details() -> Result<()> { let (service, _temp_dir) = create_test_service()?; setup_test_crate(&service).await?; @@ -984,6 +994,7 @@ async fn test_get_item_details() -> Result<()> { } #[tokio::test] +#[ignore = "requires network access"] async fn test_get_item_docs_and_source() -> Result<()> { let (service, _temp_dir) = create_test_service()?; setup_test_crate(&service).await?; @@ -1043,6 +1054,7 @@ async fn test_get_item_docs_and_source() -> Result<()> { // ===== SEARCH TOOLS TESTS ===== #[tokio::test] +#[ignore = "requires network access"] async fn test_search_items_fuzzy() -> Result<()> { let (service, _temp_dir) = create_test_service()?; setup_test_crate(&service).await?; @@ -1095,6 +1107,7 @@ async fn test_search_items_fuzzy() -> Result<()> { // ===== ANALYSIS TOOLS TESTS ===== #[tokio::test] +#[ignore = "requires network access"] async fn test_structure() -> Result<()> { let (service, _temp_dir) = create_test_service()?; setup_test_crate(&service).await?; @@ -1164,6 +1177,7 @@ async fn test_structure() -> Result<()> { // ===== DEPENDENCY TOOLS TESTS ===== #[tokio::test] +#[ignore = "requires network access"] async fn test_get_dependencies() -> Result<()> { let (service, _temp_dir) = create_test_service()?; setup_test_crate(&service).await?; @@ -1231,6 +1245,7 @@ async fn test_get_dependencies() -> Result<()> { // ===== METADATA TOOLS TESTS ===== #[tokio::test] +#[ignore = "requires network access"] async fn test_get_crates_metadata() -> Result<()> { let (service, _temp_dir) = create_test_service()?; setup_test_crate(&service).await?; @@ -1271,6 +1286,7 @@ async fn test_get_crates_metadata() -> Result<()> { // ===== EDGE CASES TESTS ===== #[tokio::test] +#[ignore = "requires network access"] async fn test_invalid_item_ids() -> Result<()> { let (service, _temp_dir) = create_test_service()?; setup_test_crate(&service).await?; @@ -1322,6 +1338,7 @@ async fn test_invalid_item_ids() -> Result<()> { } #[tokio::test] +#[ignore = "requires network access"] async fn test_empty_search_results() -> Result<()> { let (service, _temp_dir) = create_test_service()?; setup_test_crate(&service).await?; @@ -1453,6 +1470,7 @@ async fn test_cache_bevy_with_feature_fallback() -> Result<()> { // ===== PROGRESS TRACKING TESTS ===== #[tokio::test] +#[ignore = "requires network access"] async fn test_step_tracking() -> Result<()> { let (service, _temp_dir) = create_test_service()?; From d160f82bd5d652c2d1a815388546725198430253 Mon Sep 17 00:00:00 2001 From: Christian Blades Date: Tue, 14 Apr 2026 14:24:48 -0400 Subject: [PATCH 12/18] limit flake check systems --- .github/workflows/nix.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/nix.yml b/.github/workflows/nix.yml index 25c9474..72743bb 100644 --- a/.github/workflows/nix.yml +++ b/.github/workflows/nix.yml @@ -23,7 +23,7 @@ jobs: - uses: DeterminateSystems/magic-nix-cache-action@main - name: Check flake - run: nix flake check --all-systems + run: nix flake check - name: Check dev shell run: nix develop --command rustc --version From fe0ba35c349c6deaf258a95eb736a4cb7b5b9b7c Mon Sep 17 00:00:00 2001 From: Christian Blades Date: Thu, 16 Apr 2026 10:42:59 -0400 Subject: [PATCH 13/18] Fix toolchain detection and feature fallback for Nix/non-rustup environments - Use `cargo +toolchain --version` instead of `rustdoc +toolchain --version` for the availability probe. Rustdoc exits 0 for --version even with an unrecognized +toolchain arg (false positive); cargo correctly errors. - Add native nightly fallback for Nix/fenix environments where the nightly toolchain is in PATH directly without rustup. Detects via `rustdoc --version` containing "nightly" and uses empty toolchain string (no +prefix in commands). - Add "error: could not document" to is_compilation_error so cargo rustdoc failures (which say "could not document" not "could not compile") trigger the feature fallback strategy. Co-Authored-By: Claude Sonnet 4.6 --- rust-docs-mcp/src/rustdoc.rs | 53 ++++++++++++++++++++++++++++++++---- 1 file changed, 48 insertions(+), 5 deletions(-) diff --git a/rust-docs-mcp/src/rustdoc.rs b/rust-docs-mcp/src/rustdoc.rs index c16ce62..b247880 100644 --- a/rust-docs-mcp/src/rustdoc.rs +++ b/rust-docs-mcp/src/rustdoc.rs @@ -87,6 +87,16 @@ with rustdoc JSON format version {}: {reason}", return Ok(FALLBACK_TOOLCHAIN.to_string()); } + // Final fallback: nightly rustdoc/cargo directly in PATH without +toolchain (Nix/fenix). + // We skip the JSON format-version probe here because in Nix environments the rustdoc in + // PATH is always the same nightly the binary was compiled against — they are in lockstep. + if probe_native_nightly() { + tracing::warn!( + "No rustup toolchain found; using rustdoc/cargo directly from PATH (Nix/non-rustup environment)" + ); + return Ok(String::new()); + } + let preferred_reason = match preferred { ToolchainProbe::Missing => format!( "{PREFERRED_TOOLCHAIN} is not installed. Install it with: rustup toolchain install \ @@ -122,12 +132,30 @@ You can also set {TOOLCHAIN_ENV_VAR} to a specific compatible nightly." ) } +/// Check whether the `rustdoc` on PATH is a nightly build without using rustup. +/// Used as a last resort for Nix/fenix environments where the nightly toolchain is +/// placed directly in PATH rather than managed by rustup. +fn probe_native_nightly() -> bool { + Command::new("rustdoc") + .arg("--version") + .output() + .ok() + .filter(|o| o.status.success()) + .and_then(|o| String::from_utf8(o.stdout).ok()) + .map(|v| v.contains("nightly")) + .unwrap_or(false) +} + fn probe_toolchain(toolchain: &str) -> Result { - let version_output = Command::new("rustdoc") + // Use `cargo +toolchain --version` rather than `rustdoc +toolchain --version`. + // Rustdoc treats `+toolchain` as a file argument, and `--version` overrides all other + // processing so it exits 0 even without rustup — a false positive. Cargo correctly + // errors with "no such subcommand" when rustup is absent, giving an accurate result. + let version_output = Command::new("cargo") .arg(format!("+{toolchain}")) .arg("--version") .output() - .with_context(|| format!("Failed to run rustdoc --version for toolchain {toolchain}"))?; + .with_context(|| format!("Failed to run cargo --version for toolchain {toolchain}"))?; if !version_output.status.success() { let stderr = String::from_utf8_lossy(&version_output.stderr) @@ -194,8 +222,11 @@ fn generate_probe_json(toolchain: &str) -> Result { .context("Failed to create probe source file")?; std::fs::create_dir(&output_dir).context("Failed to create probe output directory")?; - let output = Command::new("rustdoc") - .arg(format!("+{toolchain}")) + let mut probe_cmd = Command::new("rustdoc"); + if !toolchain.is_empty() { + probe_cmd.arg(format!("+{toolchain}")); + } + let output = probe_cmd .args([ "-Z", "unstable-options", @@ -303,6 +334,7 @@ impl FeatureStrategy { fn is_compilation_error(stderr: &str) -> bool { stderr.contains("error[E") || stderr.contains("error: could not compile") + || stderr.contains("error: could not document") || stderr.contains("error: aborting due to") || (stderr.contains("error:") && stderr.contains("failed to compile")) } @@ -415,7 +447,11 @@ pub async fn run_cargo_rustdoc_json( }; tracing::debug!("{}", log_msg); - let mut base_args = vec![format!("+{}", toolchain), "rustdoc".to_string()]; + let mut base_args = if toolchain.is_empty() { + vec!["rustdoc".to_string()] + } else { + vec![format!("+{}", toolchain), "rustdoc".to_string()] + }; // Add package-specific arguments if provided if let Some(pkg) = package { @@ -648,6 +684,13 @@ mod tests { assert!(is_compilation_error(stderr)); } + #[test] + fn test_is_compilation_error_with_could_not_document() { + // `cargo rustdoc` emits "could not document" rather than "could not compile" + let stderr = "error: could not document `my-crate` due to previous error"; + assert!(is_compilation_error(stderr)); + } + #[test] fn test_is_compilation_error_with_aborting_due_to() { let stderr = "error: aborting due to 3 previous errors"; From 9e19f4e005fcec98a476cc5aa614008aa31775e9 Mon Sep 17 00:00:00 2001 From: Christian Blades Date: Thu, 16 Apr 2026 10:43:20 -0400 Subject: [PATCH 14/18] Add cross-platform feature fallback integration test; fix bevy test scoping Replace #[cfg_attr(not(target_os = "macos"), ignore)] on the bevy test with #[cfg(target_os = "macos")] so it is compile-excluded on Linux and cannot be reached by --include-ignored in CI. Add test_feature_fallback_with_broken_feature: creates a local crate fixture with a `broken` feature that uses compile_error!, caches it via the service, and asserts the fallback strategy succeeds. No network access required; works in the Nix sandbox. Co-Authored-By: Claude Sonnet 4.6 --- rust-docs-mcp/tests/integration_tests.rs | 88 ++++++++++++++++++++++-- 1 file changed, 83 insertions(+), 5 deletions(-) diff --git a/rust-docs-mcp/tests/integration_tests.rs b/rust-docs-mcp/tests/integration_tests.rs index 9c5439a..bbb28de 100644 --- a/rust-docs-mcp/tests/integration_tests.rs +++ b/rust-docs-mcp/tests/integration_tests.rs @@ -5,7 +5,7 @@ //! - GitHub //! - Local paths -use anyhow::{Context, Result}; +use anyhow::Result; use rmcp::handler::server::wrapper::Parameters; use rust_docs_mcp::RustDocsService; use rust_docs_mcp::analysis::outputs::StructureOutput; @@ -1394,13 +1394,91 @@ async fn test_empty_search_results() -> Result<()> { Ok(()) } +// ===== FEATURE FALLBACK TESTS ===== + +/// Tests the feature fallback strategy (--all-features → default → --no-default-features) +/// using a local crate that intentionally fails to compile with --all-features. +/// This test requires no network access and works on all platforms. +#[tokio::test] +async fn test_feature_fallback_with_broken_feature() -> Result<()> { + let _ = tracing_subscriber::fmt() + .with_env_filter("rust_docs_mcp=debug") + .try_init(); + + let (service, _temp_dir) = create_test_service()?; + + // Create a local crate whose `broken` feature triggers a compile_error. + // --all-features will fail; default features (which exclude `broken`) will succeed. + let crate_dir = TempDir::new()?; + std::fs::write( + crate_dir.path().join("Cargo.toml"), + r#"[package] +name = "feature-fallback-fixture" +version = "0.1.0" +edition = "2021" + +[features] +broken = [] +"#, + )?; + let src_dir = crate_dir.path().join("src"); + std::fs::create_dir(&src_dir)?; + std::fs::write( + src_dir.join("lib.rs"), + r#"//! Feature fallback fixture crate. +//! The `broken` feature intentionally fails to compile to exercise the +//! feature fallback strategy in rustdoc generation. + +#[cfg(feature = "broken")] +compile_error!("The `broken` feature is intentionally uncompilable (fallback test fixture)"); + +/// A function that always works. +pub fn always_works() -> &'static str { + "ok" +} +"#, + )?; + + let params = CacheCrateParams { + crate_name: "feature-fallback-fixture".to_string(), + source_type: "local".to_string(), + version: Some("0.1.0".to_string()), + github_url: None, + branch: None, + tag: None, + path: Some(crate_dir.path().to_str().unwrap().to_string()), + members: None, + update: None, + }; + + let response = service.cache_crate(Parameters(params)).await; + let task = parse_cache_task_started(&response)?; + let result = wait_for_task_completion(&service, &task.task_id, TEST_TIMEOUT).await?; + + assert!( + matches!(result, TaskResult::Success), + "Feature fallback should have succeeded by skipping the `broken` feature: {result:?}" + ); + + // Verify the crate is actually queryable + let versions_response = service + .list_crate_versions(Parameters(ListCrateVersionsParams { + crate_name: "feature-fallback-fixture".to_string(), + })) + .await; + assert!( + versions_response.contains("0.1.0"), + "Cached version not found: {versions_response}" + ); + + Ok(()) +} + // ===== PLATFORM-SPECIFIC COMPILATION TESTS ===== #[tokio::test] -#[cfg_attr( - not(target_os = "macos"), - ignore = "Test designed for macOS platform-specific compilation issues" -)] +#[cfg(target_os = "macos")] +#[ignore = "requires network access"] async fn test_cache_bevy_with_feature_fallback() -> Result<()> { // NOTE: This test depends on external resources and may fail due to: // - Network connectivity issues From 106f036e2b8e58d400249f06bab6114176302616 Mon Sep 17 00:00:00 2001 From: Michael Assaf Date: Thu, 9 Jul 2026 11:21:32 -0400 Subject: [PATCH 15/18] Merge main and fix integration test after features field addition Rebase onto main and add missing features field plus anyhow::Context import so test_feature_fallback_with_broken_feature compiles and passes. --- rust-docs-mcp/tests/integration_tests.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/rust-docs-mcp/tests/integration_tests.rs b/rust-docs-mcp/tests/integration_tests.rs index a852e0b..b7ab030 100644 --- a/rust-docs-mcp/tests/integration_tests.rs +++ b/rust-docs-mcp/tests/integration_tests.rs @@ -5,7 +5,7 @@ //! - GitHub //! - Local paths -use anyhow::Result; +use anyhow::{Context, Result}; use rmcp::handler::server::wrapper::Parameters; use rust_docs_mcp::RustDocsService; use rust_docs_mcp::analysis::outputs::StructureOutput; @@ -1465,6 +1465,7 @@ pub fn always_works() -> &'static str { path: Some(crate_dir.path().to_str().unwrap().to_string()), members: None, update: None, + features: None, }; let response = service.cache_crate(Parameters(params)).await; From c71c918dd6500999082b19dd5ff7c211c8440a85 Mon Sep 17 00:00:00 2001 From: Michael Assaf Date: Thu, 9 Jul 2026 11:23:37 -0400 Subject: [PATCH 16/18] Address review feedback on version errors and Nix rustdoc invocation Use resolved version in dependency error messages and skip +toolchain when running rustdoc --version in Nix/non-rustup environments. --- rust-docs-mcp/src/deps/tools.rs | 2 +- rust-docs-mcp/src/rustdoc.rs | 7 +++++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/rust-docs-mcp/src/deps/tools.rs b/rust-docs-mcp/src/deps/tools.rs index 5faa4ea..ff5a0e3 100644 --- a/rust-docs-mcp/src/deps/tools.rs +++ b/rust-docs-mcp/src/deps/tools.rs @@ -94,7 +94,7 @@ impl DepsTools { } Err(e) => Err(DepsErrorOutput::new(format!( "Dependencies not available for {}-{}. Error: {}", - params.crate_name, params.version, e + params.crate_name, version, e ))), } } diff --git a/rust-docs-mcp/src/rustdoc.rs b/rust-docs-mcp/src/rustdoc.rs index ffab861..5f9f184 100644 --- a/rust-docs-mcp/src/rustdoc.rs +++ b/rust-docs-mcp/src/rustdoc.rs @@ -276,8 +276,11 @@ pub async fn get_rustdoc_version() -> Result { /// Get rustdoc version information for a specific toolchain. pub fn get_rustdoc_version_for_toolchain(toolchain: &str) -> Result { - let output = Command::new("rustdoc") - .arg(format!("+{toolchain}")) + let mut cmd = Command::new("rustdoc"); + if !toolchain.is_empty() { + cmd.arg(format!("+{toolchain}")); + } + let output = cmd .arg("--version") .output() .with_context(|| format!("Failed to run rustdoc --version for toolchain {toolchain}"))?; From 400b2b30c790eac71c8e6b1759d32dda3a88a29f Mon Sep 17 00:00:00 2001 From: Michael Assaf Date: Thu, 9 Jul 2026 11:27:07 -0400 Subject: [PATCH 17/18] Fix unused Context import on non-macOS CI targets Scope anyhow::Context to the macOS-only bevy integration test so clippy does not fail on Linux runners. --- rust-docs-mcp/tests/integration_tests.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/rust-docs-mcp/tests/integration_tests.rs b/rust-docs-mcp/tests/integration_tests.rs index b7ab030..0fba025 100644 --- a/rust-docs-mcp/tests/integration_tests.rs +++ b/rust-docs-mcp/tests/integration_tests.rs @@ -5,7 +5,7 @@ //! - GitHub //! - Local paths -use anyhow::{Context, Result}; +use anyhow::Result; use rmcp::handler::server::wrapper::Parameters; use rust_docs_mcp::RustDocsService; use rust_docs_mcp::analysis::outputs::StructureOutput; @@ -1497,6 +1497,7 @@ pub fn always_works() -> &'static str { #[cfg(target_os = "macos")] #[ignore = "requires network access"] async fn test_cache_bevy_with_feature_fallback() -> Result<()> { + use anyhow::Context; // NOTE: This test depends on external resources and may fail due to: // - Network connectivity issues // - crates.io downtime or rate limiting From 15d4f382057c10555f1ebb5a573a3ad9f215e8f3 Mon Sep 17 00:00:00 2001 From: Michael Assaf Date: Thu, 9 Jul 2026 11:29:37 -0400 Subject: [PATCH 18/18] Restore anyhow::Context import for merged CLI integration tests The main merge brought in CLI tests that use with_context on all platforms; keep the trait in scope globally. --- rust-docs-mcp/tests/integration_tests.rs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/rust-docs-mcp/tests/integration_tests.rs b/rust-docs-mcp/tests/integration_tests.rs index 0fba025..b7ab030 100644 --- a/rust-docs-mcp/tests/integration_tests.rs +++ b/rust-docs-mcp/tests/integration_tests.rs @@ -5,7 +5,7 @@ //! - GitHub //! - Local paths -use anyhow::Result; +use anyhow::{Context, Result}; use rmcp::handler::server::wrapper::Parameters; use rust_docs_mcp::RustDocsService; use rust_docs_mcp::analysis::outputs::StructureOutput; @@ -1497,7 +1497,6 @@ pub fn always_works() -> &'static str { #[cfg(target_os = "macos")] #[ignore = "requires network access"] async fn test_cache_bevy_with_feature_fallback() -> Result<()> { - use anyhow::Context; // NOTE: This test depends on external resources and may fail due to: // - Network connectivity issues // - crates.io downtime or rate limiting