diff --git a/.github/workflows/nix.yml b/.github/workflows/nix.yml new file mode 100644 index 0000000..72743bb --- /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 + + - name: Check dev shell + run: nix develop --command rustc --version diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index 3519ae9..2d96d0b 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -77,7 +77,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 @@ -107,4 +107,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 85bb492..fea999c 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; @@ -61,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 @@ -124,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 effb029..3547e9a 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,6 +86,105 @@ impl CrateDownloader { ) } + /// 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!("Fetching available versions for {name} 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")?; + + // 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()?; + let yanked = v["yanked"].as_bool().unwrap_or(false); + if yanked { + return None; + } + Version::parse(num).ok() + }) + .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 = "Available versions (sorted by semver, newest first):\n".to_string(); + 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.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}" + ) + } + } + } + /// Download or copy a crate from the specified source pub async fn download_or_copy_crate( &self, @@ -213,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 {name}-{version}: HTTP {status}"); } // Save to a temporary file first - make path unique to avoid concurrent conflicts @@ -752,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() @@ -980,4 +1088,159 @@ mod tests { "rollback must not reach outside storage's source_path" ); } + + #[test] + 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] + #[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") + .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] + #[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") + .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] + #[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") + .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] + #[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") + .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 c56b6e5..3ddefd8 100644 --- a/rust-docs-mcp/src/cache/service.rs +++ b/rust-docs-mcp/src/cache/service.rs @@ -65,6 +65,37 @@ impl CrateCache { }) } + /// 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 + } + + /// 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, @@ -216,13 +247,20 @@ 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, version: &str, member: Option<&str>, ) -> Result> { + let version = self.resolve_version(name, version).await?; + let version = version.as_str(); + // If member is specified, use workspace member logic if let Some(member_path) = member { return self @@ -830,6 +868,21 @@ impl CrateCache { let (crate_name, version, members, source_str, update, features) = 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, diff --git a/rust-docs-mcp/src/deps/tools.rs b/rust-docs-mcp/src/deps/tools.rs index a23c70c..ff5a0e3 100644 --- a/rust-docs-mcp/src/deps/tools.rs +++ b/rust-docs-mcp/src/deps/tools.rs @@ -44,28 +44,25 @@ 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, - 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, ¶ms.version) - .await - { + match cache.load_dependencies(¶ms.crate_name, &version).await { Ok(metadata) => { // Process the metadata to extract dependency information match process_cargo_metadata( &metadata, ¶ms.crate_name, - ¶ms.version, + &version, params.include_tree.unwrap_or(false), params.filter.as_deref(), ) { @@ -97,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/docs/tools.rs b/rust-docs-mcp/src/docs/tools.rs index 4e5f350..dcdc40d 100644 --- a/rust-docs-mcp/src/docs/tools.rs +++ b/rust-docs-mcp/src/docs/tools.rs @@ -150,12 +150,12 @@ 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, - params.member.as_deref(), - ) + .ensure_crate_or_member_docs(¶ms.crate_name, &version, params.member.as_deref()) .await { Ok(crate_data) => { @@ -202,12 +202,12 @@ 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, - params.member.as_deref(), - ) + .ensure_crate_or_member_docs(¶ms.crate_name, &version, params.member.as_deref()) .await { Ok(crate_data) => { @@ -302,12 +302,12 @@ 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, - params.member.as_deref(), - ) + .ensure_crate_or_member_docs(¶ms.crate_name, &version, params.member.as_deref()) .await { Ok(crate_data) => { @@ -379,12 +379,19 @@ 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, - params.member.as_deref(), - ) + .ensure_crate_or_member_docs(¶ms.crate_name, &version, params.member.as_deref()) .await { Ok(crate_data) => { @@ -467,12 +474,12 @@ 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, - params.member.as_deref(), - ) + .ensure_crate_or_member_docs(¶ms.crate_name, &version, params.member.as_deref()) .await { Ok(crate_data) => { @@ -500,7 +507,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 { @@ -510,11 +528,7 @@ impl DocsTools { }; match cache - .ensure_crate_or_member_docs( - ¶ms.crate_name, - ¶ms.version, - params.member.as_deref(), - ) + .ensure_crate_or_member_docs(¶ms.crate_name, &version, params.member.as_deref()) .await { Ok(crate_data) => { diff --git a/rust-docs-mcp/src/rustdoc.rs b/rust-docs-mcp/src/rustdoc.rs index a2fde6e..5f9f184 100644 --- a/rust-docs-mcp/src/rustdoc.rs +++ b/rust-docs-mcp/src/rustdoc.rs @@ -88,6 +88,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 \ @@ -123,12 +133,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) @@ -195,8 +223,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", @@ -245,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}"))?; @@ -328,6 +362,7 @@ impl std::fmt::Display for 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")) } @@ -441,7 +476,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 { @@ -675,6 +714,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"; 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, }) } diff --git a/rust-docs-mcp/tests/integration_tests.rs b/rust-docs-mcp/tests/integration_tests.rs index 6efba16..b7ab030 100644 --- a/rust-docs-mcp/tests/integration_tests.rs +++ b/rust-docs-mcp/tests/integration_tests.rs @@ -205,6 +205,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()?; @@ -256,6 +257,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()?; @@ -304,6 +306,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() @@ -348,6 +351,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()?; @@ -495,6 +499,7 @@ serde = {{ workspace = true }} } #[tokio::test] +#[ignore = "requires network access"] async fn test_cache_update() -> Result<()> { let (service, _temp_dir) = create_test_service()?; @@ -630,6 +635,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); @@ -831,6 +837,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?; @@ -874,6 +881,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?; @@ -930,6 +938,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?; @@ -964,6 +973,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?; @@ -1000,6 +1010,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?; @@ -1059,6 +1070,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?; @@ -1111,6 +1123,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?; @@ -1180,6 +1193,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?; @@ -1247,6 +1261,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?; @@ -1287,6 +1302,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?; @@ -1338,6 +1354,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?; @@ -1393,13 +1410,92 @@ 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, + features: 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 @@ -1470,6 +1566,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()?;