diff --git a/README.md b/README.md index a273c50..c4b2941 100644 --- a/README.md +++ b/README.md @@ -86,10 +86,12 @@ ones released yesterday. - **For cratesio**: Provide `version` (e.g., `{crate_name: "serde", source_type: "cratesio", version: "1.0.215"}`) - **For github**: Provide `github_url` and either `branch` OR `tag` (e.g., `{crate_name: "my-crate", source_type: "github", github_url: "https://github.com/user/repo", tag: "v1.0.0"}`) - **For local**: Provide `path`, optional `version` (e.g., `{crate_name: "my-crate", source_type: "local", path: "~/projects/my-crate"}`) + - **Optional `features`**: Specific features to enable instead of `--all-features`. Use for crates with mutually exclusive features (e.g., `{crate_name: "leptos-use", source_type: "cratesio", version: "0.15.8", features: ["axum"]}`) - `remove_crate` - Remove cached crate versions to free disk space - `list_cached_crates` - View all cached crates with versions and sizes - `list_crate_versions` - List cached versions for a specific crate - `get_crates_metadata` - Batch metadata queries for multiple crates +- `cache_operations` - Manage and monitor background caching operations (list, status, cancel, clear) ### Documentation Queries diff --git a/install.sh b/install.sh index 781f313..c793044 100755 --- a/install.sh +++ b/install.sh @@ -192,13 +192,12 @@ main() { echo -e "${BLUE}(~/.claude/settings.json or your project/local settings)${NC}" echo echo -e "${GREEN} - \"mcp__rust-docs__cache_crate_from_cratesio\", - \"mcp__rust-docs__cache_crate_from_github\", - \"mcp__rust-docs__cache_crate_from_local\", + \"mcp__rust-docs__cache_crate\", \"mcp__rust-docs__remove_crate\", \"mcp__rust-docs__list_cached_crates\", \"mcp__rust-docs__list_crate_versions\", \"mcp__rust-docs__get_crates_metadata\", + \"mcp__rust-docs__cache_operations\", \"mcp__rust-docs__list_crate_items\", \"mcp__rust-docs__search_items\", \"mcp__rust-docs__search_items_preview\", diff --git a/rust-docs-mcp/benches/indexing.rs b/rust-docs-mcp/benches/indexing.rs index 93c8bc5..3544d20 100644 --- a/rust-docs-mcp/benches/indexing.rs +++ b/rust-docs-mcp/benches/indexing.rs @@ -104,7 +104,7 @@ impl BenchFixture { eprintln!("Fixture not found; generating (this may take several minutes)..."); runtime.block_on(async { cache - .ensure_crate_docs(&name, &version, None) + .ensure_crate_docs(&name, &version, None, None) .await .expect("ensure_crate_docs"); }); diff --git a/rust-docs-mcp/src/cache/docgen.rs b/rust-docs-mcp/src/cache/docgen.rs index 248c467..c6f778d 100644 --- a/rust-docs-mcp/src/cache/docgen.rs +++ b/rust-docs-mcp/src/cache/docgen.rs @@ -111,6 +111,7 @@ impl DocGenerator { name: &str, version: &str, progress_callback: Option, + features: Option>, ) -> Result { tracing::info!( "DocGenerator::generate_docs starting for {}-{}", @@ -146,7 +147,7 @@ impl DocGenerator { } // Run cargo rustdoc with JSON output using unified function - rustdoc::run_cargo_rustdoc_json(&source_path, None, None).await?; + rustdoc::run_cargo_rustdoc_json(&source_path, None, None, features).await?; // Rustdoc complete - report 70% if let Some(ref callback) = progress_callback { @@ -199,6 +200,7 @@ impl DocGenerator { version: &str, member_path: &str, progress_callback: Option, + features: Option>, ) -> Result { let source_path = self.storage.source_path(name, version)?; let member_full_path = source_path.join(member_path); @@ -248,6 +250,7 @@ impl DocGenerator { &source_path, Some(&package_name), Some(&member_target_dir), + features, ) .await?; diff --git a/rust-docs-mcp/src/cache/service.rs b/rust-docs-mcp/src/cache/service.rs index 37081b0..c56b6e5 100644 --- a/rust-docs-mcp/src/cache/service.rs +++ b/rust-docs-mcp/src/cache/service.rs @@ -13,6 +13,14 @@ use std::sync::Arc; /// Cache key for the in-memory LRU of parsed `rustdoc_types::Crate` objects. type DocsCacheKey = (String, String, Option); +type SourceParams = ( + String, + String, + Option>, + Option, + bool, + Option>, +); /// Service for managing crate caching and documentation generation pub struct CrateCache { @@ -63,6 +71,7 @@ impl CrateCache { name: &str, version: &str, source: Option<&str>, + features: Option>, ) -> Result> { tracing::info!("ensure_crate_docs called for {}-{}", name, version); @@ -138,7 +147,7 @@ impl CrateCache { // Note: progress_callback is None here because this method is called from // various places. The progress-aware path goes through cache_crate_with_source // which passes progress callbacks directly to generate_docs. - match self.generate_docs(name, version, None).await { + match self.generate_docs(name, version, None, features).await { Ok(_) => { // Load and return the generated docs self.load_docs(name, version, None).await @@ -162,6 +171,7 @@ impl CrateCache { version: &str, source: Option<&str>, member_path: &str, + features: Option>, ) -> Result> { // Check if docs already exist for this member if self.storage.has_docs(name, version, Some(member_path)) { @@ -175,7 +185,7 @@ impl CrateCache { } // Generate documentation for the specific workspace member - self.generate_workspace_member_docs(name, version, member_path, None) + self.generate_workspace_member_docs(name, version, member_path, None, features) .await?; // Get package name for the member @@ -216,7 +226,7 @@ impl CrateCache { // If member is specified, use workspace member logic if let Some(member_path) = member { return self - .ensure_workspace_member_docs(name, version, None, member_path) + .ensure_workspace_member_docs(name, version, None, member_path, None) .await; } @@ -240,7 +250,7 @@ impl CrateCache { } // Regular crate, use normal flow - self.ensure_crate_docs(name, version, None).await + self.ensure_crate_docs(name, version, None, None).await } /// Download or copy a crate based on source type @@ -262,9 +272,10 @@ impl CrateCache { name: &str, version: &str, progress_callback: Option, + features: Option>, ) -> Result { self.doc_generator - .generate_docs(name, version, progress_callback) + .generate_docs(name, version, progress_callback, features) .await } @@ -275,9 +286,10 @@ impl CrateCache { version: &str, member_path: &str, progress_callback: Option, + features: Option>, ) -> Result { self.doc_generator - .generate_workspace_member_docs(name, version, member_path, progress_callback) + .generate_workspace_member_docs(name, version, member_path, progress_callback, features) .await } @@ -472,11 +484,12 @@ impl CrateCache { members: &Option>, source_str: Option<&str>, source: &CrateSource, + features: Option>, ) -> Result { // If members are specified, cache those specific workspace members if let Some(members) = members { let response = self - .cache_workspace_members(crate_name, version, members, source_str, true) + .cache_workspace_members(crate_name, version, members, source_str, features, true) .await; // Check if all failed for proper error handling @@ -504,7 +517,7 @@ impl CrateCache { Ok(self.generate_workspace_response(crate_name, version, members, source, true)) } else { // Not a workspace, proceed with normal caching - self.ensure_crate_docs(crate_name, version, source_str) + self.ensure_crate_docs(crate_name, version, source_str, features) .await?; Ok(CacheResponse::success_updated(crate_name, version)) @@ -512,10 +525,7 @@ impl CrateCache { } /// Extract source parameters from CrateSource enum - fn extract_source_params( - &self, - source: &CrateSource, - ) -> (String, String, Option>, Option, bool) { + fn extract_source_params(&self, source: &CrateSource) -> SourceParams { match source { CrateSource::CratesIO(params) => ( params.crate_name.clone(), @@ -523,6 +533,7 @@ impl CrateCache { params.members.clone(), None, params.update.unwrap_or(false), + params.features.clone(), ), CrateSource::GitHub(params) => { let version = if let Some(branch) = ¶ms.branch { @@ -548,6 +559,7 @@ impl CrateCache { params.members.clone(), source_str, params.update.unwrap_or(false), + params.features.clone(), ) } CrateSource::LocalPath(params) => ( @@ -559,6 +571,7 @@ impl CrateCache { params.members.clone(), Some(params.path.clone()), params.update.unwrap_or(false), + params.features.clone(), ), } } @@ -577,6 +590,7 @@ impl CrateCache { version: &str, members: &[String], source_str: Option<&str>, + features: Option>, updated: bool, ) -> CacheResponse { use futures::future::join_all; @@ -596,6 +610,7 @@ impl CrateCache { .iter() .map(|member| { let member_clone = member.clone(); + let features = features.clone(); let sem = std::sync::Arc::clone(&sem); async move { let _permit = sem.acquire().await.expect("semaphore closed"); @@ -605,6 +620,7 @@ impl CrateCache { version, source_str, &member_clone, + features, ) .await; (member_clone, result) @@ -669,6 +685,7 @@ impl CrateCache { members: &Option>, source_str: Option<&str>, source: &CrateSource, + features: Option>, ) -> String { // Create transaction for safe update let mut transaction = CacheTransaction::new(&self.storage, crate_name, version); @@ -684,7 +701,9 @@ impl CrateCache { // Try to re-cache the crate let update_result = self - .cache_crate_with_update_impl(crate_name, version, members, source_str, source) + .cache_crate_with_update_impl( + crate_name, version, members, source_str, source, features, + ) .await; // Check if update was successful @@ -713,9 +732,10 @@ impl CrateCache { version: &str, members: &[String], source_str: Option<&str>, + features: Option>, updated: bool, ) -> CacheResponse { - self.cache_workspace_members(crate_name, version, members, source_str, updated) + self.cache_workspace_members(crate_name, version, members, source_str, features, updated) .await } @@ -807,7 +827,7 @@ impl CrateCache { }; // Extract parameters from source - let (crate_name, version, members, source_str, update) = + let (crate_name, version, members, source_str, update, features) = self.extract_source_params(&source); tracing::info!( @@ -836,6 +856,7 @@ impl CrateCache { &members, source_str.as_deref(), &source, + features.clone(), ) .await; } @@ -853,6 +874,7 @@ impl CrateCache { &version, &members, source_str.as_deref(), + features.clone(), false, ) .await; @@ -942,7 +964,10 @@ impl CrateCache { tm.update_step(tid, 1, "Running cargo rustdoc").await; } - match self.generate_docs(&crate_name, &version, None).await { + match self + .generate_docs(&crate_name, &version, None, features) + .await + { Ok(_) => { // Update to indexing stage if let (Some(tm), Some(tid)) = (&task_manager, &task_id) { diff --git a/rust-docs-mcp/src/cache/tools.rs b/rust-docs-mcp/src/cache/tools.rs index 3cc271b..ba89732 100644 --- a/rust-docs-mcp/src/cache/tools.rs +++ b/rust-docs-mcp/src/cache/tools.rs @@ -65,6 +65,10 @@ pub struct CacheCrateParams { description = "Force re-download and re-cache the crate even if it already exists. Defaults to false. The existing cache is preserved until the update succeeds." )] pub update: Option, + #[schemars( + description = "Specific features to enable instead of --all-features. Use this for crates with mutually exclusive features (e.g., leptos-use has conflicting 'actix' and 'axum' features). When provided, uses --no-default-features --features=a,b,c. When omitted, uses --all-features with automatic fallback." + )] + pub features: Option>, } #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] @@ -81,6 +85,10 @@ pub struct CacheCrateFromCratesIOParams { description = "Force re-download and re-cache the crate even if it already exists. Defaults to false. The existing cache is preserved until the update succeeds." )] pub update: Option, + #[schemars( + description = "Specific features to enable instead of --all-features. When provided, uses --no-default-features --features=a,b,c." + )] + pub features: Option>, } #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] @@ -105,6 +113,10 @@ pub struct CacheCrateFromGitHubParams { description = "Force re-download and re-cache the crate even if it already exists. Defaults to false. The existing cache is preserved until the update succeeds." )] pub update: Option, + #[schemars( + description = "Specific features to enable instead of --all-features. When provided, uses --no-default-features --features=a,b,c." + )] + pub features: Option>, } #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] @@ -127,6 +139,10 @@ pub struct CacheCrateFromLocalParams { description = "Force re-download and re-cache the crate even if it already exists. Defaults to false. The existing cache is preserved until the update succeeds." )] pub update: Option, + #[schemars( + description = "Specific features to enable instead of --all-features. When provided, uses --no-default-features --features=a,b,c." + )] + pub features: Option>, } #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] @@ -766,6 +782,7 @@ impl CacheTools { version: params.version.clone().unwrap(), members: params.members.clone(), update: params.update, + features: params.features.clone(), }), "github" => CrateSource::GitHub(CacheCrateFromGitHubParams { crate_name: params.crate_name.clone(), @@ -774,6 +791,7 @@ impl CacheTools { tag: params.tag.clone(), members: params.members.clone(), update: params.update, + features: params.features.clone(), }), "local" => CrateSource::LocalPath(CacheCrateFromLocalParams { crate_name: params.crate_name.clone(), @@ -781,6 +799,7 @@ impl CacheTools { path: params.path.clone().unwrap(), members: params.members.clone(), update: params.update, + features: params.features.clone(), }), _ => unreachable!("Invalid source type should have been caught earlier"), } diff --git a/rust-docs-mcp/src/rustdoc.rs b/rust-docs-mcp/src/rustdoc.rs index c16ce62..09eeb8f 100644 --- a/rust-docs-mcp/src/rustdoc.rs +++ b/rust-docs-mcp/src/rustdoc.rs @@ -268,7 +268,7 @@ pub fn get_rustdoc_version_for_toolchain(toolchain: &str) -> Result { /// /// The recommended order is: [`AllFeatures`](Self::AllFeatures) → /// [`DefaultFeatures`](Self::DefaultFeatures) → [`NoDefaultFeatures`](Self::NoDefaultFeatures) -#[derive(Debug, Clone, Copy)] +#[derive(Debug, Clone)] #[allow(clippy::enum_variant_names)] enum FeatureStrategy { /// Use --all-features (enables all feature flags) @@ -277,6 +277,8 @@ enum FeatureStrategy { DefaultFeatures, /// Use --no-default-features (minimal) NoDefaultFeatures, + /// Use --no-default-features --features=a,b,c (specific features only) + Specific(Vec), } impl FeatureStrategy { @@ -286,15 +288,37 @@ impl FeatureStrategy { Self::AllFeatures => vec!["--all-features".to_string()], Self::DefaultFeatures => vec![], Self::NoDefaultFeatures => vec!["--no-default-features".to_string()], + Self::Specific(features) => { + let mut args = vec!["--no-default-features".to_string()]; + if !features.is_empty() { + args.push("--features".to_string()); + args.push(features.join(",")); + } + args + } } } +} - /// Get a description of this strategy for logging - fn description(&self) -> &str { +impl std::fmt::Display for FeatureStrategy { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { - Self::AllFeatures => "all features enabled", - Self::DefaultFeatures => "default features only", - Self::NoDefaultFeatures => "no default features", + Self::AllFeatures => f.write_str("all features enabled"), + Self::DefaultFeatures => f.write_str("default features only"), + Self::NoDefaultFeatures => f.write_str("no default features"), + Self::Specific(features) if features.is_empty() => { + f.write_str("specific features (none)") + } + Self::Specific(features) => { + f.write_str("specific features: ")?; + for (i, feat) in features.iter().enumerate() { + if i > 0 { + f.write_str(", ")?; + } + f.write_str(feat)?; + } + Ok(()) + } } } } @@ -383,6 +407,7 @@ pub async fn run_cargo_rustdoc_json( source_path: &Path, package: Option<&str>, target_dir: Option<&Path>, + features: Option>, ) -> Result<()> { let toolchain = resolve_toolchain()?; @@ -423,20 +448,23 @@ pub async fn run_cargo_rustdoc_json( base_args.push(pkg.to_string()); } - // Try different feature strategies in order - let strategies = [ + // Try different feature strategies in order. When specific features are + // requested, prepend them so the user-supplied set is tried first; the + // fallback chain (AllFeatures -> DefaultFeatures -> NoDefaultFeatures) + // still runs afterward if compilation fails. + let mut strategies = vec![ FeatureStrategy::AllFeatures, FeatureStrategy::DefaultFeatures, FeatureStrategy::NoDefaultFeatures, ]; + if let Some(feats) = features { + strategies.insert(0, FeatureStrategy::Specific(feats)); + } let mut failed_attempts = Vec::new(); for (i, strategy) in strategies.iter().enumerate() { - tracing::debug!( - "Attempting documentation generation with {}", - strategy.description() - ); + tracing::debug!("Attempting documentation generation with {strategy}"); // Build args with current feature strategy let feature_args = strategy.args(); @@ -498,57 +526,36 @@ pub async fn run_cargo_rustdoc_json( // Check if this is a compilation error if is_compilation_error(&stderr_with_lib) && i < strategies.len() - 1 { tracing::warn!( - "Compilation failed with {}, will try next strategy", - strategy.description() + "Compilation failed with {strategy}, will try next strategy" ); failed_attempts.push(FailedAttempt::new( - strategy.description().to_string(), + strategy.to_string(), stderr_with_lib.to_string(), )); continue; // Try next strategy } - bail!( - "Failed to generate documentation with {}: {}", - strategy.description(), - stderr_with_lib - ); + bail!("Failed to generate documentation with {strategy}: {stderr_with_lib}"); } // Success with --lib - tracing::info!( - "Successfully generated documentation with {}", - strategy.description() - ); + tracing::info!("Successfully generated documentation with {strategy}"); return Ok(()); } // Check if this is a compilation error that we should retry if is_compilation_error(&stderr) && i < strategies.len() - 1 { - tracing::warn!( - "Compilation failed with {}, will try next strategy", - strategy.description() - ); - failed_attempts.push(FailedAttempt::new( - strategy.description().to_string(), - stderr.to_string(), - )); + tracing::warn!("Compilation failed with {strategy}, will try next strategy"); + failed_attempts.push(FailedAttempt::new(strategy.to_string(), stderr.to_string())); continue; // Try next strategy } // Other errors or last strategy failed - bail!( - "Failed to generate documentation with {}: {}", - strategy.description(), - stderr - ); + bail!("Failed to generate documentation with {strategy}: {stderr}"); } // Success - tracing::info!( - "Successfully generated documentation with {}", - strategy.description() - ); + tracing::info!("Successfully generated documentation with {strategy}"); return Ok(()); } @@ -618,22 +625,42 @@ mod tests { FeatureStrategy::NoDefaultFeatures.args(), vec!["--no-default-features".to_string()] ); + assert_eq!( + FeatureStrategy::Specific(vec!["axum".to_string()]).args(), + vec![ + "--no-default-features".to_string(), + "--features".to_string(), + "axum".to_string(), + ] + ); + assert_eq!( + FeatureStrategy::Specific(vec![]).args(), + vec!["--no-default-features".to_string()] + ); } #[test] - fn test_feature_strategy_description() { + fn test_feature_strategy_display() { assert_eq!( - FeatureStrategy::AllFeatures.description(), + FeatureStrategy::AllFeatures.to_string(), "all features enabled" ); assert_eq!( - FeatureStrategy::DefaultFeatures.description(), + FeatureStrategy::DefaultFeatures.to_string(), "default features only" ); assert_eq!( - FeatureStrategy::NoDefaultFeatures.description(), + FeatureStrategy::NoDefaultFeatures.to_string(), "no default features" ); + assert_eq!( + FeatureStrategy::Specific(vec!["axum".to_string(), "ssr".to_string()]).to_string(), + "specific features: axum, ssr" + ); + assert_eq!( + FeatureStrategy::Specific(vec![]).to_string(), + "specific features (none)" + ); } #[test] diff --git a/rust-docs-mcp/src/service.rs b/rust-docs-mcp/src/service.rs index d448497..0c8e884 100644 --- a/rust-docs-mcp/src/service.rs +++ b/rust-docs-mcp/src/service.rs @@ -108,6 +108,9 @@ REQUIRED PARAMETERS BY SOURCE TYPE: OPTIONAL PARAMETERS (all source types): - members: List of workspace members to cache (e.g., ['crates/core', 'crates/macros']) - update: Force re-cache even if already cached (default: false) +- features: Specific features to enable instead of --all-features. Use for crates with mutually exclusive features. + Example: {crate_name: 'leptos-use', source_type: 'cratesio', version: '0.15.8', features: ['axum']} + When provided, uses --no-default-features --features=a,b,c. When omitted, uses --all-features with automatic fallback. MONITORING: Use cache_operations tool to monitor progress, cancel, or check status of caching operations." )] diff --git a/rust-docs-mcp/tests/cargo_registry_reuse.rs b/rust-docs-mcp/tests/cargo_registry_reuse.rs index 064f3af..e756b5c 100644 --- a/rust-docs-mcp/tests/cargo_registry_reuse.rs +++ b/rust-docs-mcp/tests/cargo_registry_reuse.rs @@ -154,6 +154,7 @@ async fn test_cache_crate_reuses_cargo_registry_source() -> Result<()> { path: None, members: None, update: None, + features: None, }; let response = service.cache_crate(Parameters(params)).await; diff --git a/rust-docs-mcp/tests/integration_tests.rs b/rust-docs-mcp/tests/integration_tests.rs index 6db8039..ea827f5 100644 --- a/rust-docs-mcp/tests/integration_tests.rs +++ b/rust-docs-mcp/tests/integration_tests.rs @@ -35,6 +35,7 @@ use tempfile::TempDir; // Test constants const TEST_TIMEOUT: Duration = Duration::from_secs(30); const LARGE_CRATE_TEST_TIMEOUT: Duration = Duration::from_secs(120); +const HEAVY_NETWORK_TEST_TIMEOUT: Duration = Duration::from_secs(600); const SEMVER_VERSION: &str = "1.0.0"; const SERDE_VERSION: &str = "v1.0.136"; const SERDE_GITHUB_URL: &str = "https://github.com/serde-rs/serde"; @@ -162,6 +163,7 @@ async fn setup_test_crate(service: &RustDocsService) -> Result<()> { path: None, members: None, update: None, + features: None, }; // Start the async caching operation @@ -217,6 +219,7 @@ async fn test_cache_from_crates_io() -> Result<()> { path: None, members: None, update: None, + features: None, }; // Start async caching operation @@ -267,6 +270,7 @@ async fn test_cache_from_github() -> Result<()> { path: None, members: None, update: None, + features: None, }; let response = service.cache_crate(Parameters(params)).await; @@ -319,6 +323,7 @@ async fn test_cache_from_github_branch() -> Result<()> { path: None, members: None, update: None, + features: None, }; let response = service.cache_crate(Parameters(params)).await; @@ -380,6 +385,7 @@ edition = "2021" path: Some(test_crate_dir.path().to_str().unwrap().to_string()), members: None, update: None, + features: None, }; let response = service.cache_crate(Parameters(params)).await; @@ -460,6 +466,7 @@ serde = {{ workspace = true }} path: Some(workspace_dir.path().to_str().unwrap().to_string()), members: None, // Should detect workspace and return member list update: None, + features: None, }; let response = service.cache_crate(Parameters(params)).await; @@ -502,6 +509,7 @@ async fn test_cache_update() -> Result<()> { path: None, members: None, update: None, + features: None, }; let response1 = service.cache_crate(Parameters(params1)).await; @@ -523,6 +531,7 @@ async fn test_cache_update() -> Result<()> { path: None, members: None, update: Some(true), + features: None, }; let response2 = service.cache_crate(Parameters(params2)).await; @@ -551,6 +560,7 @@ async fn test_invalid_inputs() -> Result<()> { path: None, members: None, update: None, + features: None, }; let response = service.cache_crate(Parameters(params)).await; @@ -574,6 +584,7 @@ async fn test_invalid_inputs() -> Result<()> { path: None, members: None, update: None, + features: None, }; let response = service.cache_crate(Parameters(params)).await; @@ -605,6 +616,7 @@ async fn test_invalid_inputs() -> Result<()> { path: Some("/this/path/does/not/exist".to_string()), members: None, update: None, + features: None, }; let response = service.cache_crate(Parameters(params)).await; @@ -643,6 +655,7 @@ async fn test_concurrent_caching() -> Result<()> { path: None, members: None, update: None, + features: None, }; let start = std::time::Instant::now(); let response = service.cache_crate(Parameters(params)).await; @@ -705,6 +718,7 @@ async fn test_concurrent_caching() -> Result<()> { path: None, members: None, update: Some(false), // Should not re-download if already cached + features: None, }; let response = service.cache_crate(Parameters(params)).await; let task = parse_cache_task_started(&response)?; @@ -768,6 +782,7 @@ edition = "2021" path: Some(workspace_dir.path().to_str().unwrap().to_string()), members: None, update: None, + features: None, }; let response1 = service.cache_crate(Parameters(params1)).await; @@ -798,6 +813,7 @@ edition = "2021" path: Some(workspace_dir.path().to_str().unwrap().to_string()), members: Some(vec!["lib-a".to_string(), "lib-b".to_string()]), update: None, + features: None, }; let response2 = service.cache_crate(Parameters(params2)).await; @@ -1415,6 +1431,7 @@ async fn test_cache_bevy_with_feature_fallback() -> Result<()> { path: None, members: None, update: None, + features: None, }; // Use a longer timeout for bevy as it's a large crate @@ -1467,6 +1484,7 @@ async fn test_step_tracking() -> Result<()> { path: None, members: None, update: None, + features: None, }; let response = service.cache_crate(Parameters(params)).await; @@ -1553,3 +1571,322 @@ async fn test_step_tracking() -> Result<()> { Ok(()) } + +// Integration tests for the `features` parameter + +/// Write a Cargo.toml + src/lib.rs for a crate that gates two public symbols +/// behind the `axum` and `actix` features. Both features are non-default, so the +/// presence of each symbol in the generated docs reflects which features were +/// enabled at `cargo rustdoc` time. +fn write_features_crate_sources(dir: &std::path::Path, package_name: &str) -> Result<()> { + std::fs::write( + dir.join("Cargo.toml"), + format!( + r#"[package] +name = "{package_name}" +version = "0.1.0" +edition = "2021" + +[features] +default = [] +axum = [] +actix = [] +"# + ), + )?; + let src_dir = dir.join("src"); + std::fs::create_dir(&src_dir)?; + std::fs::write( + src_dir.join("lib.rs"), + r#"//! Test crate gating symbols behind axum/actix features. + +#[cfg(feature = "axum")] +pub mod axum_module { + pub fn axum_handler() {} +} + +#[cfg(feature = "actix")] +pub mod actix_module { + pub fn actix_handler() {} +} +"#, + )?; + Ok(()) +} + +#[tokio::test] +async fn test_cache_with_specific_features() -> Result<()> { + let (service, _temp_dir) = create_test_service()?; + + let fixture_dir = TempDir::new()?; + write_features_crate_sources(fixture_dir.path(), "test-features-crate")?; + + let params = CacheCrateParams { + crate_name: "test-features-crate".to_string(), + source_type: "local".to_string(), + version: Some("0.1.0".to_string()), + github_url: None, + branch: None, + tag: None, + path: Some(fixture_dir.path().to_str().unwrap().to_string()), + members: None, + update: None, + features: Some(vec!["axum".to_string()]), + }; + + let response = service.cache_crate(Parameters(params)).await; + let task_output = parse_cache_task_started(&response)?; + let result = wait_for_task_completion(&service, &task_output.task_id, TEST_TIMEOUT).await?; + assert!( + matches!(result, TaskResult::Success), + "Failed to cache with features=[axum]: {result:?}" + ); + + let search_axum = service + .search_items_preview(Parameters(SearchItemsPreviewParams { + crate_name: "test-features-crate".to_string(), + version: "0.1.0".to_string(), + pattern: "axum_handler".to_string(), + limit: Some(10), + offset: None, + kind_filter: None, + path_filter: None, + member: None, + })) + .await; + let axum_output: SearchItemsPreviewOutput = serde_json::from_str(&search_axum)?; + assert!( + !axum_output.items.is_empty(), + "axum_handler not found in docs although features=[axum] was requested: {search_axum}" + ); + + let search_actix = service + .search_items_preview(Parameters(SearchItemsPreviewParams { + crate_name: "test-features-crate".to_string(), + version: "0.1.0".to_string(), + pattern: "actix_handler".to_string(), + limit: Some(10), + offset: None, + kind_filter: None, + path_filter: None, + member: None, + })) + .await; + let actix_output: SearchItemsPreviewOutput = serde_json::from_str(&search_actix)?; + assert!( + actix_output.items.is_empty(), + "actix_handler visible in docs, but only features=[axum] was requested: {search_actix}" + ); + + Ok(()) +} + +#[tokio::test] +async fn test_cache_workspace_member_with_features() -> Result<()> { + let (service, _temp_dir) = create_test_service()?; + + // Create a minimal workspace with a single member that has the features fixture + let workspace_dir = TempDir::new()?; + std::fs::write( + workspace_dir.path().join("Cargo.toml"), + r#"[workspace] +members = ["member-a"] +resolver = "2" +"#, + )?; + let member_dir = workspace_dir.path().join("member-a"); + std::fs::create_dir(&member_dir)?; + write_features_crate_sources(&member_dir, "member-a")?; + + let params = CacheCrateParams { + crate_name: "test-features-workspace".to_string(), + source_type: "local".to_string(), + version: Some("0.1.0".to_string()), + github_url: None, + branch: None, + tag: None, + path: Some(workspace_dir.path().to_str().unwrap().to_string()), + members: Some(vec!["member-a".to_string()]), + update: None, + features: Some(vec!["axum".to_string()]), + }; + + let response = service.cache_crate(Parameters(params)).await; + let task_output = parse_cache_task_started(&response)?; + let result = wait_for_task_completion(&service, &task_output.task_id, TEST_TIMEOUT).await?; + assert!( + matches!(result, TaskResult::Success), + "Failed to cache workspace member with features=[axum]: {result:?}" + ); + + let search_axum = service + .search_items_preview(Parameters(SearchItemsPreviewParams { + crate_name: "test-features-workspace".to_string(), + version: "0.1.0".to_string(), + pattern: "axum_handler".to_string(), + limit: Some(10), + offset: None, + kind_filter: None, + path_filter: None, + member: Some("member-a".to_string()), + })) + .await; + let axum_output: SearchItemsPreviewOutput = serde_json::from_str(&search_axum)?; + assert!( + !axum_output.items.is_empty(), + "axum_handler not found in workspace member docs: {search_axum}" + ); + + let search_actix = service + .search_items_preview(Parameters(SearchItemsPreviewParams { + crate_name: "test-features-workspace".to_string(), + version: "0.1.0".to_string(), + pattern: "actix_handler".to_string(), + limit: Some(10), + offset: None, + kind_filter: None, + path_filter: None, + member: Some("member-a".to_string()), + })) + .await; + let actix_output: SearchItemsPreviewOutput = serde_json::from_str(&search_actix)?; + assert!( + actix_output.items.is_empty(), + "actix_handler visible in workspace member docs although only features=[axum] was requested: {search_actix}" + ); + + Ok(()) +} + +#[tokio::test] +async fn test_cache_leptos_use_with_axum_feature() -> Result<()> { + // End-to-end reproduction of the real-world scenario that motivated PR #57: + // a crate with mutually exclusive features (axum vs actix) that cannot be + // cached with --all-features. Takes ~80s locally (mostly cargo compile). + let (service, _temp_dir) = create_test_service()?; + + let params = CacheCrateParams { + crate_name: "leptos-use".to_string(), + source_type: "cratesio".to_string(), + version: Some("0.18.3".to_string()), + github_url: None, + branch: None, + tag: None, + path: None, + members: None, + update: None, + features: Some(vec!["axum".to_string()]), + }; + + let response = service.cache_crate(Parameters(params)).await; + let task_output = parse_cache_task_started(&response)?; + let result = + wait_for_task_completion(&service, &task_output.task_id, HEAVY_NETWORK_TEST_TIMEOUT) + .await?; + assert!( + matches!(result, TaskResult::Success), + "Failed to cache leptos-use@0.18.3 with features=[axum]: {result:?}" + ); + + Ok(()) +} + +#[tokio::test] +#[ignore = "Documents a pre-existing cache-key bug (features not part of cache identity). Expected to FAIL today; will pass once the cache key includes a features fingerprint."] +async fn test_cache_respects_feature_change() -> Result<()> { + // This test documents a known, pre-existing bug that is NOT in scope of PR #57 + // but became user-reachable once features are honored: the on-disk cache is + // keyed only by (name, version), so a second cache_crate call with a different + // feature set short-circuits on has_docs() and returns the first call's docs. + // The user sees success but gets the wrong feature set's docs. + // + // Once the cache key includes a features fingerprint (or features-differing + // calls trigger invalidation), this test will pass and #[ignore] can be removed. + let (service, _temp_dir) = create_test_service()?; + + let fixture_dir = TempDir::new()?; + write_features_crate_sources(fixture_dir.path(), "test-features-cachekey")?; + + // First cache with features=["axum"] + let params_axum = CacheCrateParams { + crate_name: "test-features-cachekey".to_string(), + source_type: "local".to_string(), + version: Some("0.1.0".to_string()), + github_url: None, + branch: None, + tag: None, + path: Some(fixture_dir.path().to_str().unwrap().to_string()), + members: None, + update: None, + features: Some(vec!["axum".to_string()]), + }; + let response = service.cache_crate(Parameters(params_axum)).await; + let task_output = parse_cache_task_started(&response)?; + let result = wait_for_task_completion(&service, &task_output.task_id, TEST_TIMEOUT).await?; + assert!( + matches!(result, TaskResult::Success), + "First cache (features=[axum]) failed: {result:?}" + ); + + // Second cache of same (name, version) but features=["actix"] — no update flag + let params_actix = CacheCrateParams { + crate_name: "test-features-cachekey".to_string(), + source_type: "local".to_string(), + version: Some("0.1.0".to_string()), + github_url: None, + branch: None, + tag: None, + path: Some(fixture_dir.path().to_str().unwrap().to_string()), + members: None, + update: None, + features: Some(vec!["actix".to_string()]), + }; + let response = service.cache_crate(Parameters(params_actix)).await; + let task_output = parse_cache_task_started(&response)?; + let result = wait_for_task_completion(&service, &task_output.task_id, TEST_TIMEOUT).await?; + assert!( + matches!(result, TaskResult::Success), + "Second cache (features=[actix]) failed: {result:?}" + ); + + // These two assertions FAIL today because the second call short-circuits on + // has_docs() without regenerating. They should pass once the cache key is + // feature-aware. + let search_actix = service + .search_items_preview(Parameters(SearchItemsPreviewParams { + crate_name: "test-features-cachekey".to_string(), + version: "0.1.0".to_string(), + pattern: "actix_handler".to_string(), + limit: Some(10), + offset: None, + kind_filter: None, + path_filter: None, + member: None, + })) + .await; + let actix_output: SearchItemsPreviewOutput = serde_json::from_str(&search_actix)?; + assert!( + !actix_output.items.is_empty(), + "actix_handler NOT visible after features=[actix] was requested — the cache returned stale docs from the first features=[axum] call" + ); + + let search_axum = service + .search_items_preview(Parameters(SearchItemsPreviewParams { + crate_name: "test-features-cachekey".to_string(), + version: "0.1.0".to_string(), + pattern: "axum_handler".to_string(), + limit: Some(10), + offset: None, + kind_filter: None, + path_filter: None, + member: None, + })) + .await; + let axum_output: SearchItemsPreviewOutput = serde_json::from_str(&search_axum)?; + assert!( + axum_output.items.is_empty(), + "axum_handler still visible after re-cache with features=[actix] — stale docs from the first call were not invalidated" + ); + + Ok(()) +}