diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 80e1085..2e72ad2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -4,109 +4,308 @@ on: push: branches: [main] pull_request: - branches: [main] + +permissions: {} env: CARGO_TERM_COLOR: always - RUST_BACKTRACE: 1 + RUSTFLAGS: "-D warnings" -permissions: {} +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true jobs: - check: - name: Check + detect-changes: + name: Detect Changes runs-on: ubuntu-latest - permissions: - contents: read + timeout-minutes: 2 + outputs: + run-full-ci: ${{ steps.classify.outputs.run-full-ci }} steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # actions/checkout v6 - - uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # dtolnay/rust-toolchain stable - - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # Swatinem/rust-cache v2 - - run: cargo check --all-targets --all-features + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + - name: Filter changed paths + uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4.0.1 + id: filter + with: + filters: | + code: + - 'crates/**' + - 'Cargo.toml' + - 'Cargo.lock' + workflows: + - '.github/workflows/**' + - '.cargo/**' + - name: Classify changes + id: classify + run: | + CODE="${{ steps.filter.outputs.code }}" + WORKFLOWS="${{ steps.filter.outputs.workflows }}" + + if [[ "$CODE" == "true" || "$WORKFLOWS" == "true" ]]; then + echo "run-full-ci=true" >> "$GITHUB_OUTPUT" + else + echo "run-full-ci=false" >> "$GITHUB_OUTPUT" + fi fmt: - name: Format + name: Lint (fmt) + needs: detect-changes + if: needs.detect-changes.outputs.run-full-ci == 'true' runs-on: ubuntu-latest + timeout-minutes: 5 permissions: contents: read steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # actions/checkout v6 - - run: rustup toolchain install nightly --component rustfmt --allow-downgrade - - run: cargo +nightly fmt --all -- --check + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + - uses: dtolnay/rust-toolchain@5b842231ba77f5c045dba54ac5560fed2db780e2 # nightly + with: + toolchain: nightly + components: rustfmt + - name: Check formatting + run: cargo +nightly fmt --all -- --check clippy: - name: Clippy + name: Lint (clippy) + needs: detect-changes + if: needs.detect-changes.outputs.run-full-ci == 'true' runs-on: ubuntu-latest + timeout-minutes: 10 permissions: contents: read steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # actions/checkout v6 - - uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # dtolnay/rust-toolchain stable + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + - uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable with: + toolchain: stable components: clippy - - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # Swatinem/rust-cache v2 - - run: cargo clippy --all-targets --all-features -- -D warnings + - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 + with: + shared-key: "ci" + - name: Clippy + run: cargo clippy --all-targets --all-features --workspace -- -D warnings + + build-tests: + name: Build Tests (${{ matrix.os }}) + needs: [detect-changes] + if: needs.detect-changes.outputs.run-full-ci == 'true' + runs-on: ${{ matrix.os }} + timeout-minutes: 25 + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + permissions: + contents: read + env: + RUSTC_WRAPPER: sccache + SCCACHE_GHA_ENABLED: "true" + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + - uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable + with: + toolchain: stable + - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 + with: + cache-targets: "false" + shared-key: "ci" + - uses: mozilla-actions/sccache-action@7d986dd989559c6ecdb630a3fd2557667be217ad # v0.0.9 + - uses: taiki-e/install-action@dee540ee3f3ff5c6a0665fed9996875d0ba04ca2 # nextest + - name: Build and archive tests + run: cargo nextest archive --workspace --all-features --lib --bins --tests --archive-file nextest-archive.tar.zst + - name: Upload test archive + uses: actions/upload-artifact@v4 + with: + name: nextest-archive-${{ matrix.os }} + path: nextest-archive.tar.zst + retention-days: 1 + + build: + name: Build Binary (${{ matrix.os }}) + needs: [detect-changes] + if: needs.detect-changes.outputs.run-full-ci == 'true' + runs-on: ${{ matrix.os }} + timeout-minutes: 15 + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + permissions: + contents: read + env: + RUSTC_WRAPPER: sccache + SCCACHE_GHA_ENABLED: "true" + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + - uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable + with: + toolchain: stable + - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 + with: + cache-targets: "false" + shared-key: "build" + - uses: mozilla-actions/sccache-action@7d986dd989559c6ecdb630a3fd2557667be217ad # v0.0.9 + - name: Build binary + run: cargo build --bin mcpls + - name: Upload binary + uses: actions/upload-artifact@v4 + with: + name: mcpls-binary-${{ matrix.os }} + path: target/debug/mcpls${{ matrix.os == 'windows-latest' && '.exe' || '' }} + retention-days: 1 test: - name: Test + name: Test (unit) (${{ matrix.os }}) + needs: [detect-changes, fmt, clippy, build-tests] + if: needs.detect-changes.outputs.run-full-ci == 'true' runs-on: ${{ matrix.os }} + timeout-minutes: 10 + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] permissions: contents: read + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + - uses: taiki-e/install-action@dee540ee3f3ff5c6a0665fed9996875d0ba04ca2 # nextest + - name: Download test archive + uses: actions/download-artifact@v4 + with: + name: nextest-archive-${{ matrix.os }} + - name: Run unit tests + run: cargo nextest run --archive-file nextest-archive.tar.zst --workspace-remap . -E 'kind(lib) or kind(bin)' + + test-integration: + name: Test (integration) (${{ matrix.os }}) + needs: [detect-changes, fmt, clippy, build-tests] + if: needs.detect-changes.outputs.run-full-ci == 'true' + runs-on: ${{ matrix.os }} + timeout-minutes: 15 strategy: fail-fast: false matrix: os: [ubuntu-latest, macos-latest, windows-latest] - rust: [stable] - include: - - os: ubuntu-latest - rust: beta - - os: ubuntu-latest - rust: "1.88" # MSRV + permissions: + contents: read steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # actions/checkout v6 - - uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # dtolnay/rust-toolchain master + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + - uses: taiki-e/install-action@dee540ee3f3ff5c6a0665fed9996875d0ba04ca2 # nextest + - name: Download test archive + uses: actions/download-artifact@v4 with: - toolchain: ${{ matrix.rust }} - - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # Swatinem/rust-cache v2 - - uses: taiki-e/install-action@dee540ee3f3ff5c6a0665fed9996875d0ba04ca2 # taiki-e/install-action nextest - - run: cargo nextest run --all-features + name: nextest-archive-${{ matrix.os }} + - name: Run integration tests + run: cargo nextest run --archive-file nextest-archive.tar.zst --workspace-remap . -E 'kind(test) and not test(e2e)' + + test-e2e: + name: Test (e2e) (${{ matrix.os }}) + needs: [detect-changes, fmt, clippy, build-tests, build] + if: needs.detect-changes.outputs.run-full-ci == 'true' + runs-on: ${{ matrix.os }} + timeout-minutes: 15 + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + permissions: + contents: read + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + - uses: taiki-e/install-action@dee540ee3f3ff5c6a0665fed9996875d0ba04ca2 # nextest + - name: Download test archive + uses: actions/download-artifact@v4 + with: + name: nextest-archive-${{ matrix.os }} + - name: Download binary + uses: actions/download-artifact@v4 + with: + name: mcpls-binary-${{ matrix.os }} + path: target/debug/ + - name: Make binary executable + if: matrix.os != 'windows-latest' + run: chmod +x target/debug/mcpls + - name: Run e2e tests + run: cargo nextest run --archive-file nextest-archive.tar.zst --workspace-remap . --run-ignored ignored-only -E 'kind(test) and test(e2e)' + + msrv: + name: MSRV check (1.88) + needs: detect-changes + if: needs.detect-changes.outputs.run-full-ci == 'true' + runs-on: ubuntu-latest + timeout-minutes: 15 + permissions: + contents: read + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + - uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable + with: + toolchain: "1.88" + - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 + with: + shared-key: "msrv" + - name: cargo check (MSRV) + run: cargo check --workspace --all-targets --all-features --locked docs: - name: Documentation + name: Rustdoc + needs: detect-changes + if: needs.detect-changes.outputs.run-full-ci == 'true' runs-on: ubuntu-latest + timeout-minutes: 10 permissions: contents: read + env: + RUSTDOCFLAGS: "-D warnings" steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # actions/checkout v6 - - uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # dtolnay/rust-toolchain stable - - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # Swatinem/rust-cache v2 - - run: cargo doc --no-deps --all-features - env: - RUSTDOCFLAGS: -D warnings + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + - uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable + with: + toolchain: stable + - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 + with: + shared-key: "ci" + - name: Build docs + run: cargo doc --no-deps --all-features + - name: Doc-tests + run: cargo test --doc --workspace --all-features security: name: Security Audit runs-on: ubuntu-latest + timeout-minutes: 5 permissions: contents: read steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # actions/checkout v6 - - uses: EmbarkStudios/cargo-deny-action@91bf2b620e09e18d6eb78b92e7861937469acedb # EmbarkStudios/cargo-deny-action v2 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + - uses: EmbarkStudios/cargo-deny-action@91bf2b620e09e18d6eb78b92e7861937469acedb # v2 coverage: name: Coverage + if: github.event_name == 'push' && github.ref == 'refs/heads/main' && needs.detect-changes.outputs.run-full-ci == 'true' + needs: [detect-changes, fmt, clippy] runs-on: ubuntu-latest + timeout-minutes: 20 permissions: contents: read + # RUSTFLAGS reset: -C instrument-coverage conflicts with -D warnings from the + # global env; linting is enforced by the dedicated clippy job. + env: + RUSTFLAGS: "" steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # actions/checkout v6 - - uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # dtolnay/rust-toolchain stable + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + - uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable with: - components: llvm-tools-preview - - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # Swatinem/rust-cache v2 - - uses: taiki-e/install-action@caf4aedf2bfe5bfb679703b29290921f4711b2f3 # taiki-e/install-action cargo-llvm-cov - - run: cargo llvm-cov --all-features --lcov --output-path lcov.info - - uses: codecov/codecov-action@57e3a136b779b570ffcdbf80b3bdc90e7fab3de2 # codecov/codecov-action v6 + toolchain: stable + - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 + with: + shared-key: "coverage" + - uses: taiki-e/install-action@caf4aedf2bfe5bfb679703b29290921f4711b2f3 # cargo-llvm-cov + - uses: taiki-e/install-action@dee540ee3f3ff5c6a0665fed9996875d0ba04ca2 # nextest + - name: Generate coverage + run: cargo llvm-cov nextest --workspace --all-features --lib --bins --lcov --output-path lcov.info + - name: Upload coverage + uses: codecov/codecov-action@57e3a136b779b570ffcdbf80b3bdc90e7fab3de2 # v6 with: token: ${{ secrets.CODECOV_TOKEN }} files: lcov.info @@ -115,11 +314,29 @@ jobs: ci-gate: name: CI Gate if: always() - needs: [check, fmt, clippy, test, docs, security, coverage] + needs: [fmt, clippy, build-tests, build, test, test-integration, test-e2e, msrv, docs, security, coverage] runs-on: ubuntu-latest + timeout-minutes: 1 steps: - - run: | - if [[ "${{ contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled') }}" == "true" ]]; then - echo "::error::One or more CI jobs failed or were cancelled" - exit 1 - fi + - name: Check all jobs + run: | + results=( + "${{ needs.fmt.result }}" + "${{ needs.clippy.result }}" + "${{ needs.build-tests.result }}" + "${{ needs.build.result }}" + "${{ needs.test.result }}" + "${{ needs.test-integration.result }}" + "${{ needs.test-e2e.result }}" + "${{ needs.msrv.result }}" + "${{ needs.docs.result }}" + "${{ needs.security.result }}" + "${{ needs.coverage.result }}" + ) + for r in "${results[@]}"; do + if [[ "$r" != "success" && "$r" != "skipped" ]]; then + echo "::error::One or more CI jobs failed or were cancelled" + exit 1 + fi + done + echo "All jobs passed" diff --git a/crates/mcpls-core/src/bridge/translator.rs b/crates/mcpls-core/src/bridge/translator.rs index 0a66b08..e334b2b 100644 --- a/crates/mcpls-core/src/bridge/translator.rs +++ b/crates/mcpls-core/src/bridge/translator.rs @@ -2782,13 +2782,14 @@ mod tests { assert_eq!(extension_map.get("nu"), Some(&"nushell".to_string())); assert_eq!(extension_map.get("rs"), Some(&"rust".to_string())); + // serve() starts in protocol-only mode when no LSP servers are configured; + // it may return a transport error but must not return NoServersAvailable. let result = crate::serve(config).await; - assert!(result.is_err()); - - if let Err(crate::error::Error::NoServersAvailable(msg)) = result { - assert!(msg.contains("none configured")); - } else { - panic!("Expected NoServersAvailable error for empty server config"); + if let Err(ref err) = result { + assert!( + !matches!(err, crate::error::Error::NoServersAvailable(_)), + "serve() must not return NoServersAvailable for empty lsp_servers config" + ); } } diff --git a/crates/mcpls-core/src/lib.rs b/crates/mcpls-core/src/lib.rs index edea924..dfd246d 100644 --- a/crates/mcpls-core/src/lib.rs +++ b/crates/mcpls-core/src/lib.rs @@ -147,45 +147,42 @@ pub async fn serve(config: ServerConfig) -> Result<(), Error> { applicable_configs.len() ); - // Spawn all servers with graceful degradation - let result = LspServer::spawn_batch(&applicable_configs).await; - - // Handle the three possible outcomes - if result.all_failed() { - return Err(Error::AllServersFailedToInit { - count: result.failure_count(), - failures: result.failures, - }); - } + if applicable_configs.is_empty() { + warn!("No applicable LSP servers configured — starting in protocol-only mode"); + } else { + // Spawn all servers with graceful degradation + let result = LspServer::spawn_batch(&applicable_configs).await; + + // Handle the three possible outcomes + if result.all_failed() { + return Err(Error::AllServersFailedToInit { + count: result.failure_count(), + failures: result.failures, + }); + } - if result.partial_success() { - warn!( - "Partial server initialization: {} succeeded, {} failed", - result.server_count(), - result.failure_count() - ); - for failure in &result.failures { - error!("Server initialization failed: {}", failure); + if result.partial_success() { + warn!( + "Partial server initialization: {} succeeded, {} failed", + result.server_count(), + result.failure_count() + ); + for failure in &result.failures { + error!("Server initialization failed: {}", failure); + } } - } - // Check if at least one server successfully initialized - if !result.has_servers() { - return Err(Error::NoServersAvailable( - "none configured or all failed to initialize".to_string(), - )); - } + // Register all successfully initialized servers + let server_count = result.server_count(); + for (language_id, server) in result.servers { + let client = server.client().clone(); + translator.register_client(language_id.clone(), client); + translator.register_server(language_id.clone(), server); + } - // Register all successfully initialized servers - let server_count = result.server_count(); - for (language_id, server) in result.servers { - let client = server.client().clone(); - translator.register_client(language_id.clone(), client); - translator.register_server(language_id.clone(), server); + info!("Proceeding with {} LSP server(s)", server_count); } - info!("Proceeding with {} LSP server(s)", server_count); - let translator = Arc::new(Mutex::new(translator)); info!("Starting MCP server with rmcp..."); @@ -502,10 +499,12 @@ mod tests { } #[tokio::test] - async fn test_serve_fails_with_empty_config() { + async fn test_serve_starts_with_empty_config() { use crate::config::WorkspaceConfig; - // Create a config with no servers + // Server starts in protocol-only mode when no LSP servers are configured. + // serve() blocks until the MCP transport closes, so it will error with a + // connection/transport error — not NoServersAvailable. let config = ServerConfig { workspace: WorkspaceConfig { roots: vec![PathBuf::from("/tmp/test-workspace")], @@ -518,17 +517,13 @@ mod tests { let result = serve(config).await; - assert!(result.is_err()); - let err = result.unwrap_err(); - - // Should return NoServersAvailable because no servers were configured - assert!( - matches!(err, Error::NoServersAvailable(_)), - "Expected NoServersAvailable error, got: {err:?}" - ); - - if let Error::NoServersAvailable(msg) = err { - assert!(msg.contains("none configured")); + // serve() may succeed or fail with a transport error, but must NOT + // return NoServersAvailable when the config simply has no servers. + if let Err(ref err) = result { + assert!( + !matches!(err, Error::NoServersAvailable(_)), + "serve() must not return NoServersAvailable for empty lsp_servers config" + ); } } } diff --git a/crates/mcpls-core/tests/e2e/protocol_tests.rs b/crates/mcpls-core/tests/e2e/protocol_tests.rs index b2a22b8..9ef8814 100644 --- a/crates/mcpls-core/tests/e2e/protocol_tests.rs +++ b/crates/mcpls-core/tests/e2e/protocol_tests.rs @@ -50,7 +50,7 @@ fn test_e2e_initialize_handshake() -> Result<()> { /// Test listing all available MCP tools. /// /// Validates that: -/// - tools/list returns an array of 8 tools +/// - tools/list returns an array of 16 tools /// - All expected tool names are present #[test] #[ignore = "Requires mcpls binary built"] @@ -64,42 +64,30 @@ fn test_e2e_list_tools() -> Result<()> { .as_array() .unwrap_or_else(|| panic!("tools should be an array")); - assert_eq!(tools.len(), 8, "Should have exactly 8 tools"); + assert_eq!(tools.len(), 16, "Should have exactly 16 tools"); let tool_names: Vec<&str> = tools.iter().filter_map(|t| t["name"].as_str()).collect(); - assert!( - tool_names.contains(&"get_hover"), - "Should have get_hover tool" - ); - assert!( - tool_names.contains(&"get_definition"), - "Should have get_definition tool" - ); - assert!( - tool_names.contains(&"get_references"), - "Should have get_references tool" - ); - assert!( - tool_names.contains(&"get_diagnostics"), - "Should have get_diagnostics tool" - ); - assert!( - tool_names.contains(&"rename_symbol"), - "Should have rename_symbol tool" - ); - assert!( - tool_names.contains(&"get_completions"), - "Should have get_completions tool" - ); - assert!( - tool_names.contains(&"get_document_symbols"), - "Should have get_document_symbols tool" - ); - assert!( - tool_names.contains(&"format_document"), - "Should have format_document tool" - ); + for expected in &[ + "get_hover", + "get_definition", + "get_references", + "get_diagnostics", + "rename_symbol", + "get_completions", + "get_document_symbols", + "format_document", + "workspace_symbol_search", + "get_code_actions", + "prepare_call_hierarchy", + "get_incoming_calls", + "get_outgoing_calls", + "get_cached_diagnostics", + "get_server_logs", + "get_server_messages", + ] { + assert!(tool_names.contains(expected), "Should have {expected} tool"); + } Ok(()) }