diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..c7c57ea --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,52 @@ +{ + "hooks": { + "PostToolUse": [ + { + "hooks": [ + { + "command": "\"/Users/rabii/Projects/Repositories/scryer/target/release/bundle/macos/scryer.app/Contents/MacOS/scryer-mcp\" hook", + "timeout": 10, + "type": "command" + } + ], + "matcher": "Read" + }, + { + "hooks": [ + { + "command": "\"/Users/rabii/Projects/Repositories/scryer/target/release/bundle/macos/scryer.app/Contents/MacOS/scryer-mcp\" hook", + "timeout": 10, + "type": "command" + } + ], + "matcher": "Edit|Write|NotebookEdit" + } + ], + "SessionStart": [ + { + "hooks": [ + { + "command": "\"/Users/rabii/Projects/Repositories/scryer/target/release/bundle/macos/scryer.app/Contents/MacOS/scryer-mcp\" hook", + "timeout": 10, + "type": "command" + } + ] + } + ], + "Stop": [ + { + "hooks": [ + { + "command": "\"/Users/rabii/Projects/Repositories/scryer/target/release/bundle/macos/scryer.app/Contents/MacOS/scryer-mcp\" hook", + "timeout": 15, + "type": "command" + } + ] + } + ] + }, + "statusLine": { + "command": "\"/Users/rabii/Projects/Repositories/scryer/target/release/bundle/macos/scryer.app/Contents/MacOS/scryer-mcp\" statusline", + "type": "command" + } +} \ No newline at end of file diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 150f0f8..b56e3d1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -41,8 +41,8 @@ jobs: include: - os: ubuntu-latest rustflags: "" - prebuilt_lbug: false - lbug_archive: "" + prebuilt_lbug: true + lbug_archive: liblbug-static-linux-x86_64-compat.tar.gz test_args: "" - os: macos-latest rustflags: "" @@ -51,14 +51,27 @@ jobs: test_args: "" - os: windows-2022 rustflags: "" - prebuilt_lbug: false - lbug_archive: "" + prebuilt_lbug: true + lbug_archive: liblbug-static-windows-x86_64.zip test_args: "--release" steps: - name: Check out repository uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: fetch-depth: 0 + - name: Prepare OpenSSL static libraries + if: runner.os == 'Windows' + shell: pwsh + run: | + $triplet = 'x64-windows-static' + vcpkg install "openssl:$triplet" + $vcpkgRoot = if ($env:VCPKG_INSTALLATION_ROOT) { $env:VCPKG_INSTALLATION_ROOT } else { 'C:\vcpkg' } + $libDir = Join-Path $vcpkgRoot "installed\$triplet\lib" + $linkDir = Join-Path $env:RUNNER_TEMP 'lbug-openssl' + New-Item -ItemType Directory -Force -Path $linkDir | Out-Null + Copy-Item (Join-Path $libDir 'libssl.lib') (Join-Path $linkDir 'ssl.lib') + Copy-Item (Join-Path $libDir 'libcrypto.lib') (Join-Path $linkDir 'crypto.lib') + "LIB=$linkDir;$env:LIB" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append - name: Prepare prebuilt liblbug if: ${{ matrix.prebuilt_lbug }} shell: bash @@ -71,12 +84,28 @@ jobs: curl --retry 5 --retry-all-errors --connect-timeout 20 -fSL \ "https://github.com/LadybugDB/ladybug/releases/download/v${lbug_version}/${archive}" \ -o "$RUNNER_TEMP/$archive" - tar xzf "$RUNNER_TEMP/$archive" -C "$lib_dir" - test -f "$lib_dir/liblbug.a" + case "$archive" in + *.tar.gz) + tar xzf "$RUNNER_TEMP/$archive" -C "$lib_dir" + test -f "$lib_dir/liblbug.a" + ;; + *.zip) + unzip -o "$RUNNER_TEMP/$archive" -d "$lib_dir" + test -f "$lib_dir/lbug.lib" + ;; + *) + echo "Unsupported liblbug archive: $archive" >&2 + exit 1 + ;; + esac test -f "$lib_dir/lbug.h" + lib_dir_env="$lib_dir" + if [ "${RUNNER_OS:-}" = "Windows" ]; then + lib_dir_env="$(cygpath -m "$lib_dir")" + fi { - echo "LBUG_LIBRARY_DIR=$lib_dir" - echo "LBUG_INCLUDE_DIR=$lib_dir" + echo "LBUG_LIBRARY_DIR=$lib_dir_env" + echo "LBUG_INCLUDE_DIR=$lib_dir_env" } >> "$GITHUB_ENV" - name: Run tests env: @@ -132,8 +161,8 @@ jobs: - os: ubuntu-latest binary: codebase-graph archive: codebase-graph-linux-x86_64.tar.gz - prebuilt_lbug: false - lbug_archive: "" + prebuilt_lbug: true + lbug_archive: liblbug-static-linux-x86_64-compat.tar.gz rustflags: "" - os: macos-latest binary: codebase-graph @@ -150,14 +179,27 @@ jobs: - os: windows-2022 binary: codebase-graph.exe archive: codebase-graph-windows-x86_64.tar.gz - prebuilt_lbug: false - lbug_archive: "" + prebuilt_lbug: true + lbug_archive: liblbug-static-windows-x86_64.zip rustflags: "" steps: - name: Check out repository uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: fetch-depth: 0 + - name: Prepare OpenSSL static libraries + if: runner.os == 'Windows' + shell: pwsh + run: | + $triplet = 'x64-windows-static' + vcpkg install "openssl:$triplet" + $vcpkgRoot = if ($env:VCPKG_INSTALLATION_ROOT) { $env:VCPKG_INSTALLATION_ROOT } else { 'C:\vcpkg' } + $libDir = Join-Path $vcpkgRoot "installed\$triplet\lib" + $linkDir = Join-Path $env:RUNNER_TEMP 'lbug-openssl' + New-Item -ItemType Directory -Force -Path $linkDir | Out-Null + Copy-Item (Join-Path $libDir 'libssl.lib') (Join-Path $linkDir 'ssl.lib') + Copy-Item (Join-Path $libDir 'libcrypto.lib') (Join-Path $linkDir 'crypto.lib') + "LIB=$linkDir;$env:LIB" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append - name: Prepare prebuilt liblbug if: ${{ matrix.prebuilt_lbug }} shell: bash @@ -170,12 +212,28 @@ jobs: curl --retry 5 --retry-all-errors --connect-timeout 20 -fSL \ "https://github.com/LadybugDB/ladybug/releases/download/v${lbug_version}/${archive}" \ -o "$RUNNER_TEMP/$archive" - tar xzf "$RUNNER_TEMP/$archive" -C "$lib_dir" - test -f "$lib_dir/liblbug.a" + case "$archive" in + *.tar.gz) + tar xzf "$RUNNER_TEMP/$archive" -C "$lib_dir" + test -f "$lib_dir/liblbug.a" + ;; + *.zip) + unzip -o "$RUNNER_TEMP/$archive" -d "$lib_dir" + test -f "$lib_dir/lbug.lib" + ;; + *) + echo "Unsupported liblbug archive: $archive" >&2 + exit 1 + ;; + esac test -f "$lib_dir/lbug.h" + lib_dir_env="$lib_dir" + if [ "${RUNNER_OS:-}" = "Windows" ]; then + lib_dir_env="$(cygpath -m "$lib_dir")" + fi { - echo "LBUG_LIBRARY_DIR=$lib_dir" - echo "LBUG_INCLUDE_DIR=$lib_dir" + echo "LBUG_LIBRARY_DIR=$lib_dir_env" + echo "LBUG_INCLUDE_DIR=$lib_dir_env" } >> "$GITHUB_ENV" - name: Build Rust production binary env: diff --git a/.omx/plans/api-adapter-boundary-centralization.md b/.omx/plans/api-adapter-boundary-centralization.md new file mode 100644 index 0000000..a77b39a --- /dev/null +++ b/.omx/plans/api-adapter-boundary-centralization.md @@ -0,0 +1,217 @@ +# API and Adapter Boundary Centralization + +## Scryer change + +- Change: `chg-1` +- Intent: centralize request normalization, repository runtime resolution, + materialization preparation, and refresh policy under the transport-neutral + API; reduce CLI and MCP adapters to transport parsing and response framing. +- Model status: structurally valid; implementation remains pending. + +## Scope + +### In scope + +- Add canonical request defaulting and semantic validation under `src/api/**`. +- Resolve one repository runtime context per public operation under + `src/api/**`. +- Prepare materialization inputs, configuration, and manifest persistence under + `src/api/**`. +- Share refresh batching, filtering, retry/backoff, watcher selection, and + refresh state between CLI and MCP under `src/api/**`. +- Remove reverse dependencies from `src/api/**` into `src/cli/**`. +- Keep CLI and MCP modules responsible for transport syntax, invocation, and + output/protocol framing. +- Preserve existing CLI flags, MCP tool names and schemas, result shapes, error + codes, and observable refresh behavior. + +### Out of scope + +- Changing graph query or materialization engine semantics. +- Renaming CLI commands, flags, or MCP tools. +- Changing storage schemas or graph database formats. +- Introducing a new dependency. +- Redesigning user-facing output. + +## Target ownership + +| Concern | API owner | Adapter responsibility | +| --- | --- | --- | +| Request defaults and semantic validation | `Request Normalizer` in `src/api/normalization.rs` | Parse CLI arguments or MCP JSON into public request contracts | +| Repository paths and execution context | `Repository Runtime Resolver` in `src/api/context.rs` | Pass repository selection from the transport | +| Materialization input preparation and manifest persistence | `Materialization API` in `src/api/materialization.rs` | Parse build/plan inputs and frame results | +| Refresh filtering, batching, retries, watcher choice, and state | `Repository Refresh Service` in `src/api/refresh.rs` | Parse watch/refresh inputs and frame status/events | +| Public result formatting primitives | Existing API presenter/catalog components | Render terminal or MCP protocol envelopes only | + +## Requirements + +1. Every public operation enters the facade as a public request contract, then + receives canonical defaults and semantic validation exactly once. +2. Runtime resolution produces one canonical source root, graph path, + configuration, and manifest context for the selected repository. +3. Materialization preparation reads and converts request/configuration inputs + once, and both build and dry-run plan execution consume that prepared input. +4. CLI watch and MCP auto-refresh use the same event filtering, batch bounds, + retry classification, backoff, watcher selection, and refresh state model. +5. `src/api/**` has no dependency on `src/cli/**`; adapter modules do not call + graph, materialization, or refresh implementation internals directly. +6. Transport adapters retain only syntax/protocol parsing, invocation of the + public API, and response framing. + +## Acceptance criteria + +- `rg -n "crate::cli" src/api` returns no matches. +- Boundary tests fail if API modules import CLI modules or if CLI/MCP transport + modules bypass the facade to invoke graph, materialization, or refresh + internals. +- Equivalent CLI and MCP requests normalize to the same public request and + produce the same `ApiError` code for invalid semantic input. +- MCP generated schemas and runtime validation agree on required fields and + accepted defaults. +- Repository selection is resolved once per operation; graph reads, + materialization, health, and refresh use the same resolved graph and manifest + paths. +- Materialization preparation does not duplicate configuration reads, source + scans, include/exclude resolution, graph builds, or manifest writes. +- CLI watch and MCP refresh pass the same tests for event filtering, + coalescing, transient retry/backoff, permanent failures, and status + transitions. +- Existing CLI output snapshots and MCP protocol/result tests remain unchanged + unless an existing test encodes the architectural defect itself. +- `cargo fmt --check`, `cargo clippy --all-targets --all-features -- -D warnings`, + and `cargo test --all-targets --all-features` pass. +- Scryer `chg-1` is folded only with source anchors and genuine tests attached + to each conditional symbol claim. + +## Implementation sequence + +### 1. Lock the transport-neutral behavior + +Add or strengthen regression coverage before moving ownership: + +- Extend `src/api/boundary_tests.rs` with both dependency directions: + API must not import CLI, and CLI/MCP must not bypass the public facade. +- Extend `src/cli/tests/graph.rs` and `src/cli/tests/mcp.rs` with equivalent + request/error cases. +- Extend `src/cli/tests/dispatch_materialize.rs` with build-versus-plan + preparation parity and single-preparation assertions. +- Extend `src/cli/tests/watch.rs` with shared policy cases for filtering, + batching, retry, and state transitions. +- Preserve repository auto-detection coverage in `tests/auto_repo_root.rs`. + +These tests establish the compatibility baseline for the ownership move. + +### 2. Centralize request normalization + +- Create `src/api/normalization.rs` implementing the planned + `normalize_request` and `validate_request` symbols over public contracts from + `src/api/contracts.rs`. +- Route all facade/core dispatch through normalization in `src/api/core.rs` and + `src/api/facade.rs`. +- Keep only syntactic extraction in `src/cli/graph/options.rs`, + `src/cli/watch/options.rs`, and `src/cli/mcp/tools.rs`. +- Generate or validate MCP schemas from the same public contract rules so + required fields cannot diverge from runtime behavior. +- Remove adapter-local semantic defaults and validation after parity tests pass. + +### 3. Make runtime resolution canonical + +- Expand `src/api/context.rs` into the planned Repository Runtime Resolver, + owning `RepoRuntime` and `resolve_runtime`. +- Move repository source, graph, configuration, and manifest path selection out + of CLI helpers. +- Delete the separate + `src/cli/graph/health.rs::resolve_health_runtime` path and route health through + the same resolver. +- Update materialization and refresh entry points to accept the resolved API + context rather than recomputing paths. +- Verify explicit repository selection and auto-detected repository selection + resolve identically across CLI and MCP. + +### 4. Move materialization preparation into the API + +- Consolidate request conversion, configuration preparation, source metadata, + and manifest persistence in `src/api/materialization.rs`. +- Move the responsibilities currently implemented by + `src/cli/materialization/request.rs` and + `src/cli/materialization/manifest.rs` behind public API inputs and outputs. +- Keep `src/cli/materialization/command.rs` limited to parsing build/plan + transport input, invoking the facade, and selecting an output frame. +- Keep `src/cli/materialization/output.rs` limited to terminal presentation. +- Ensure build and dry-run plan share one preparation path and neither performs + a hidden second graph build or configuration read. + +### 5. Introduce the shared refresh service + +- Create `src/api/refresh.rs` for the planned Repository Refresh Service. +- Move event filtering, batch collection, polling/native watcher coordination, + retry classification/backoff, and refresh state from `src/cli/watch/**` and + `src/cli/mcp/refresh.rs`. +- Expose transport-neutral one-shot and continuous refresh operations through + public contracts and the facade. +- Reduce `src/cli/watch/command.rs` to watch-command parsing, API invocation, and + event/status framing. +- Reduce `src/cli/mcp/refresh.rs` to MCP lifecycle wiring and protocol framing; + it must not own a separate refresh algorithm or policy. +- Preserve cancellation, shutdown, and read/write coordination semantics with + focused concurrency tests. + +### 6. Remove the remaining reverse dependencies + +- Move any reusable catalog or presentation implementation still imported from + `src/cli/**` behind `src/api/catalog.rs` and `src/api/presenter.rs`. +- Re-export the new API services through `src/api/mod.rs`. +- Delete obsolete adapter wrappers and duplicate helpers only after callers and + regression tests have migrated. +- Run the boundary checks before each deletion to avoid replacing direct + duplication with a dependency cycle. + +### 7. Verify and close the Scryer change + +Run, in order: + +```text +cargo fmt --check +cargo clippy --all-targets --all-features -- -D warnings +cargo test --all-targets --all-features +rg -n "crate::cli" src/api +``` + +Then: + +- Re-run Scryer structural validation. +- Inspect drift for every moved symbol. +- Fold `chg-1` responsibility-by-responsibility with exact source anchors. +- Attach tests for request rejection, runtime resolution, refresh batch + coalescing, and transient refresh retry to their conditional claims. +- Confirm the pending queue for `chg-1` is empty and the committed model is + healthy. + +## Risks and mitigations + +- **Transport behavior drift:** lock CLI snapshots and MCP result/error tests + before moving code. +- **Over-generalized normalization:** normalize public contracts by operation; + do not build a stringly typed transport abstraction. +- **Runtime path divergence:** make resolved runtime context immutable for the + duration of an operation and pass it downward. +- **Refresh lifecycle regressions:** preserve cancellation and guard ownership + with deterministic state-transition and concurrency tests. +- **Materialization double work:** instrument preparation in tests and assert a + single preparation/build path. +- **Dependency-cycle substitution:** enforce both API-to-adapter and + adapter-to-internal boundary tests continuously. + +## Scryer model delta + +The plan adds three API components: + +- `Request Normalizer` (`node-142`) +- `Repository Runtime Resolver` (`node-143`) +- `Repository Refresh Service` (`node-144`) + +It moves runtime, materialization-preparation, and refresh symbols to those API +owners, rewords CLI/MCP adapter responsibilities around parsing and framing, +deletes the duplicate health runtime resolver, and links both adapters to the +shared refresh service. The pending implementation queue is intentionally kept +under `chg-1`. diff --git a/.omx/plans/api-architecture-redesign.md b/.omx/plans/api-architecture-redesign.md new file mode 100644 index 0000000..8dc000f --- /dev/null +++ b/.omx/plans/api-architecture-redesign.md @@ -0,0 +1,201 @@ +# API and Architecture Redesign + +Scryer change: `chg-1` + +Rationale: Centralize every CLI, MCP, and library operation behind a stable Public API and a linear materialization pipeline. + +## Requirements Summary + +- Developers may use the CLI or embed the Public API directly. +- Developers using MCP interact through an MCP Host, which invokes the MCP Server Adapter. +- The CLI and MCP adapters must invoke the Public API Facade before any product operation. +- The Public API Facade must delegate transport-neutral execution to the Unified API Core. +- The Unified API Core must own operation registration, repository runtime resolution, error normalization, graph reads, catalogs, response presentation, repository lifecycle operations, and materialization dispatch. +- Typed responses and compact block responses are equally supported public formats. Block output is not a legacy or compatibility-only format. +- The materialization engine must be linear: + + `Materialization API -> Source Scanner -> Execution Planner -> Semantic Enricher -> Graph Writer -> Graph Store` + +- The Execution Planner must receive scanned source snapshots in its input and carry all source data required by later phases. No later phase may rescan the repository. +- Existing CLI flags, MCP tool names, exit codes, graph semantics, and block output must remain stable unless separately approved. + +## Planned Architecture + +The Scryer plan keeps one `Graph Runtime` container because the executable and embedded library are one runtime boundary. Components are organized into three modules: + +- `Transport Adapters`: CLI Adapter, Repository Lifecycle Adapter, CLI Materialization Adapter, Repository Refresh Adapter, MCP Server Adapter. +- `Unified Public API`: Public API Facade, Public API Contracts, Unified API Core, Graph Read Service, Catalog Provider, Response Presenter, Repository Lifecycle Service. +- `Materialization Engine`: Materialization API, Source Scanner, Execution Planner, Semantic Enricher, Graph Writer. + +`Graph Store` remains the persistence boundary used by Graph Read Service and Graph Writer. + +## Acceptance Criteria + +1. CLI graph, lifecycle, refresh, and materialization commands call `CodebaseGraphApi::execute_operation` and do not import graph-read, materialization, catalog, or persistence services directly. +2. MCP tool calls enter through `mcp_call_tool_result`, map to an `OperationRequest`, invoke the Public API Facade, and map only the returned response or error into MCP protocol types. +3. `OperationRegistry` is the single authoritative operation catalog. Adding an operation requires one descriptor and one handler registration; MCP tool metadata is generated from that catalog. +4. Public request and response types contain no CLI or MCP protocol types. +5. Every supported operation can return typed output or compact block output through `OutputFormat`. +6. Search, context, query, metadata, and materialization block outputs remain byte-for-byte compatible with existing snapshots. +7. `ApiError.code` values are stable and transport-neutral; CLI exit codes and MCP failures are mapped only in their adapters. +8. Repository selection is resolved once into `RepoRuntime` before an operation handler runs. +9. Materialization passes a self-contained plan from Source Scanner to Execution Planner to Semantic Enricher to Graph Writer. +10. No filesystem source read occurs after Source Scanner completes. +11. Only Graph Writer submits materialized graph updates to Graph Store. +12. Existing full and incremental materialization tests produce the same nodes, relationships, manifests, diagnostics, and deterministic ordering. +13. Scryer validation is clean and all implemented `chg-1` claims are folded with source anchors and attached tests. + +## Implementation Steps + +### 1. Establish Public API Contracts + +Create `src/api/contracts.rs` and define the planned Scryer symbols: + +- `RepoSelector`, `NodeRef`, and `RepoRuntime` selection inputs. +- `OperationRequest` with Health, Search, Context, Query, Materialize, Catalog, Setup, Reinstall, Uninstall, and Refresh variants. +- Typed request records for search, context, query, materialization, lifecycle, and refresh operations. +- `OutputFormat::{Typed, Block}`. +- `OperationResponse` and `ApiError`. + +Move engine-only materialization types out of the public contract. The current starting declarations are `NativeSyntaxMaterializationRequest` in `src/protocol.rs:7` and `NativeSyntaxMaterializationResponse` in `src/protocol.rs:151`. + +Verification: + +- Serialization round trips for every public data type. +- Exhaustive operation and output-format matching. +- Compile-time checks that public contracts do not depend on `src/cli/**`. + +### 2. Build the Unified API Core + +Create `src/api/core.rs` and `src/api/context.rs`: + +- Implement `OperationDescriptor`, `OperationRegistry`, `register_operations`, `resolve_operation`, and `dispatch_operation`. +- Implement `resolve_runtime` so repository, graph, and manifest paths are selected once. +- Register graph reads, catalog reads, lifecycle operations, refresh, planning, and materialization behind transport-neutral handlers. +- Reject duplicate operation identifiers and unsupported surface exposure. + +The first graph-read handlers wrap the existing operations at `src/cli/graph/search.rs:16`, `src/cli/graph/search.rs:88`, and `src/cli/graph/query.rs:114`. + +Verification: + +- Registry uniqueness and deterministic ordering tests. +- Runtime selection tests for repository defaults and explicit graph paths. +- Unknown operation and invalid request tests with stable `ApiError.code` values. + +### 3. Add the Public API Facade + +Create `src/api/facade.rs`: + +- Implement `CodebaseGraphApi`. +- Implement `execute_operation` as the only public execution entry point. +- Validate the public request, delegate once to Unified API Core, and return `OperationResponse`. +- Keep transport formatting out of the facade. + +Verification: + +- An injected core spy observes exactly one dispatch per facade call. +- Public API integration tests cover every operation variant and both output formats. + +### 4. Separate Catalog and Response Presentation + +Create `src/api/catalog.rs` and `src/api/presenter.rs`: + +- Move catalog loading from `metadata_payload` at `src/cli/format/metadata.rs:4`. +- Keep schema and query catalogs transport-neutral. +- Move typed and block presentation behind `present_operation_response`. +- Retain the existing compact serializers, beginning with `serialize_search_block` at `src/cli/format/blocks.rs:111`. +- Do not introduce a legacy payload mapper. Block output remains a first-class presentation strategy. + +Verification: + +- Existing block snapshots remain byte-for-byte unchanged. +- Typed results preserve all fields represented in block output. +- Catalog output is stable and operation descriptors expose allowed surfaces and formats. + +### 5. Convert CLI and MCP into Adapters + +Update the current command and MCP dispatch paths: + +- CLI command routing remains in `src/cli/dispatch.rs`, but product operations call the Public API Facade. +- `materialize_request` in `src/cli/build/command.rs:51` becomes a facade call and command-output mapping. +- `mcp_call_tool_result` in `src/cli/mcp/tools.rs:18` maps MCP arguments to public requests and public responses back to MCP content. +- Implement `generate_mcp_specs` from public operation metadata. +- Implement `map_error_to_transport` for MCP errors; keep CLI exit-code mapping in the CLI adapter. +- Preserve MCP server startup and transport negotiation as adapter responsibilities. + +Verification: + +- Dependency tests fail if CLI or MCP modules directly import graph-read, materialization, catalog, or persistence modules. +- Existing CLI dispatch and MCP negotiation suites pass unchanged. +- Tool names, schemas, and protocol error behavior remain stable. + +### 6. Extract Repository Lifecycle Service + +Create `src/api/lifecycle.rs`: + +- Move installation behavior behind `setup_repository`, `reinstall_repository`, and `uninstall_repository`. +- Keep client-specific MCP registration and configuration rendering in the Repository Lifecycle Adapter. +- Route lifecycle commands through the Public API Facade. + +Verification: + +- Setup, reinstall, and uninstall behavior matches current repository-state tests. +- Reinstall preserves all recoverable data covered by existing tests. +- Client configuration tests remain adapter-level tests. + +### 7. Linearize Materialization + +Refactor from the current orchestration in `src/execution/run.rs:12`: + +- `Materialization API`: rename the public engine entry points to `execute_materialization_pipeline` and `plan_materialization`; invoke only Source Scanner. +- `Source Scanner`: rename `scan_source_state` at `src/scan.rs:16` to `scan_sources`; return source snapshots, manifest diff, selected profiles, and immutable execution inputs. +- `Execution Planner`: consolidate parsing, syntax graph construction, and partition planning. Rename `build_partitions` at `src/execution/parallel.rs:18` to `build_execution_plan`. +- `Semantic Enricher`: rename `enrich_partitions` at `src/semantic_enrichment/mod.rs:37` to `enrich_semantics`; consume only the execution plan. +- `Graph Writer`: assemble deterministic rows, connectors, manifests, and Graph Store writes through `write_graph_rows`. +- `Graph Store`: retain database concurrency, deletion, schema, and write mechanics. + +Remove the old Source Parser and Graph Row Model component boundaries after their symbols are rehomed under Execution Planner. + +Verification: + +- A test removes or makes the source repository unreadable after scanning; planning, enrichment, and writing still complete from the scanned payload. +- Call-graph and dependency checks show one forward edge between each adjacent stage and no backward or skip-stage edge. +- Sequential and parallel plans produce identical ordered output. +- Full, incremental, deletion, semantic-enrichment, connector, and database-write suites pass. + +### 8. Complete the Migration + +- Remove obsolete direct links and imports after every adapter uses the facade. +- Preserve temporary internal wrappers only while callers are migrated; do not expose them as a second public API. +- Update crate exports and user-facing API documentation. +- Attach unit tests to every conditional symbol-level Scryer claim. +- Fold `chg-1` incrementally with `mark_implemented`, anchors, and tests. + +Recommended fold order: + +1. Public API Contracts, Unified API Core, and Public API Facade. +2. Graph Read Service, Catalog Provider, Response Presenter, and Repository Lifecycle Service. +3. CLI and MCP adapters. +4. Materialization API and each linear materialization stage. +5. Obsolete component and link deletions. + +## Risks and Mitigations + +- Public API becomes a generic untyped dispatcher: keep operation-specific request structs and typed handler registration. +- Registry centralization couples core to transports: descriptors declare neutral metadata and allowed surfaces; adapters generate transport schemas. +- Block output regresses during presenter extraction: retain byte-for-byte snapshot tests before moving serializers. +- Materialization plan grows too large: carry immutable shared source buffers and measure peak memory against the current pipeline. +- Lifecycle extraction changes filesystem side effects: lock existing setup, reinstall, and uninstall behavior with regression tests before moving logic. +- A second internal execution path survives migration: enforce import-boundary tests and remove direct adapter-to-service links before folding the Scryer change. + +## Verification Commands + +Run the repository's established formatting, lint, test, and architecture checks. At minimum: + +```text +cargo fmt --check +cargo clippy --all-targets --all-features -- -D warnings +cargo test --all-targets --all-features +``` + +Then run Scryer validation, inspect `get_pending { change: "chg-1" }`, and fold only claims whose code and tests are complete. diff --git a/.omx/plans/interface-api-facade-enforcement.md b/.omx/plans/interface-api-facade-enforcement.md new file mode 100644 index 0000000..26455c7 --- /dev/null +++ b/.omx/plans/interface-api-facade-enforcement.md @@ -0,0 +1,52 @@ +# Interface API Facade Enforcement + +## Requirements Summary + +- Every production CLI and MCP adapter must invoke product behavior through `CodebaseGraphApi`. +- Adapters may consume public request, response, configuration, and observer contracts re-exported from `crate::api`, but must not import API implementation modules or legacy protocol types directly. +- CLI and MCP transports must remain peer adapters; process-level transport selection belongs outside both adapters. +- Command subadapters must not import sibling behavior; shared input translation belongs to a neutral command request mapper. +- The Scryer change `chg-1` is the authoritative architecture plan and must be folded only after code and tests satisfy it. + +## Acceptance Criteria + +1. Production files under `src/adapters/` contain no imports from `crate::api::refresh`, `crate::api::materialization`, `crate::api::normalization`, or `crate::protocol`. +2. CLI watch execution in `src/adapters/cli/watch/command.rs` invokes a `CodebaseGraphApi` facade method and receives only public refresh summary data. +3. MCP auto-refresh in `src/adapters/mcp/refresh.rs` obtains a refresh-enabled `CodebaseGraphApi` instead of a `RefreshState`. +4. MCP tool schema generation in `src/adapters/mcp/tools.rs` derives required fields from operation metadata returned by `CodebaseGraphApi`. +5. CLI materialization owns its command-line option shape and constructs a public `MaterializationRequest` without importing `api::materialization`. +6. Process transport routing moves out of `src/adapters/cli/dispatch.rs`; the CLI and MCP adapters no longer import one another. +7. Repository boundary tests fail on any new direct adapter dependency on API internals, protocol data, product execution services, a peer transport adapter, or sibling command behavior. +8. Existing CLI watch, materialization, MCP stdio/HTTP, lifecycle, and API boundary tests pass. +9. Scryer reports a structurally clean model, no pending work for `chg-1`, no drift, and no broken or untested anchors. + +## Implementation Steps + +1. Extend the public contracts in `src/api/contracts.rs:151` with refresh watch configuration, observer, and summary types. Re-export them from `src/api/mod.rs:8`. +2. Extend `CodebaseGraphApi` in `src/api/facade.rs:23` with facade methods for watch execution, refresh-enabled API construction, and shared interface metadata. +3. Refactor `src/api/refresh.rs:480` to consume public watch contracts and translate native materialization results into public summaries before notifying adapters. +4. Move CLI materialization option ownership from `src/api/materialization.rs:16` to `src/adapters/cli/materialization_input.rs`, and build `MaterializationRequest` in a neutral command request mapper shared by build and watch parsing. +5. Refactor `src/adapters/cli/watch/command.rs:5`, `src/adapters/mcp/refresh.rs:2`, and `src/adapters/mcp/tools.rs:2` so behavior flows only through `CodebaseGraphApi`. +6. Move process transport selection from `src/adapters/cli/dispatch.rs:30` into `src/bootstrap.rs`, and update `src/bin/codebase-graph.rs:1`. +7. Strengthen `src/api/boundary_tests.rs:74` to enforce facade-only adapter behavior, peer transport isolation, and sibling command-adapter isolation. +8. Run formatting, targeted regression tests, the full test suite, and Scryer validation; then fold `chg-1` with implementation and test anchors. + +## Risks and Mitigations + +- Moving watch contracts can accidentally change CLI output. Preserve the existing output fields and verify existing watch integration tests byte-for-byte. +- Replacing refresh state with a refresh-enabled facade can lose health synchronization. Keep the same shared `Arc` inside `ApiCore` and verify MCP health output. +- Moving CLI option ownership can change defaults. Copy existing defaults and parsing behavior before deleting adapter imports. +- Transport bootstrap movement can alter blocking stdio/HTTP behavior. Preserve command dispatch branches and run MCP stdio and HTTP tests. +- Scryer's dependency audit may infer false direct links from ambiguous Rust call resolution. Do not add skip-stage links unless source imports or resolved calls confirm them. + +## Verification + +- `cargo fmt --check` +- `cargo test api::boundary_tests` +- `cargo test watch_once_runs_single_refresh_and_exits` +- `cargo test watch_auto_backend_refreshes_after_probe_resolution` +- `cargo test mcp_stdio_serves_tools_and_tool_errors` +- `cargo test mcp_http_handles_initialize_list_call_and_protocol_errors` +- `cargo test materialize_empty_project_from_native_request` +- `cargo test` +- Scryer `validate_model`, `get_pending`, `get_drift`, and `get_health` diff --git a/.omx/plans/okf-wiki-full-implementation.md b/.omx/plans/okf-wiki-full-implementation.md new file mode 100644 index 0000000..e75ca8f --- /dev/null +++ b/.omx/plans/okf-wiki-full-implementation.md @@ -0,0 +1,1053 @@ +# OKF Wiki Subsystem — Full Implementation Plan + +## Plan status + +- Scryer changes: `chg-1` (wiki subsystem), `chg-2` (controlled MCP authoring) +- Scryer scope: `codebaseGraph / Knowledge Wiki` (`node-164`) +- Model validation: structurally clean +- Implementation status: not started +- Sign-off status: awaiting user approval + +This plan turns the OKF v0.1 study into an implementable subsystem while +preserving the existing graph runtime and public API contracts. Production code +must not be changed until this plan is approved. + +## Decision summary + +Build a new workspace package at `crates/k-wiki/` as a separately deployable +Rust wiki service. It owns OKF parsing, conformance, compilation, controlled +source authoring, projection storage, search, rendering, refresh, HTTP, CLI, +and agent-tool behavior. Its MCP surface supports knowledge discovery plus +bounded creation and population of OKF bundles and pages. It consumes +source-graph context exclusively through `CodebaseGraphApi`. + +The package must not reinterpret `DocumentationSource` or +`DocumentationChunk` as OKF concepts. The existing Markdown materializer only +captures documents and heading chunks (`src/parser/markdown.rs:8-173`, +`src/profiles.rs:69-82`), while the wiki requires lossless frontmatter, +reserved-file semantics, path-derived concept identity, links, backlinks, +citations, history, and permissive conformance. + +## Scope + +### Included + +- One or more OKF v0.1 bundles under configured repository roots. +- Bundle discovery and stable namespacing. +- Lossless YAML frontmatter and Markdown-body parsing. +- `index.md` and `log.md` reserved-file semantics. +- Root `index.md` `okf_version` frontmatter exception. +- Consume, conformant, and recommended validation profiles. +- Stable concept IDs derived from bundle-relative paths. +- Absolute, relative, parent-relative, fragment, external, broken, and unsafe + link handling. +- Directory navigation, synthetic indexes, backlinks, citations, scoped + history, diagnostics, type facets, and tag facets. +- Versioned normalized projection artifacts stored outside `.codebaseGraph`. +- Deterministic full builds and content-hash incremental builds. +- Concept-aware full-text search. +- Static site generation and a local read-only HTTP preview/API server. +- CLI commands for validation, building, serving, inspection, and link checks. +- Read-only MCP tools for bundles, directories, concepts, search, backlinks, + neighborhoods, diagnostics, and recent changes. +- Controlled MCP tools for creating bundles, creating pages, and populating + page content. +- Optional source-code context through the existing graph public API. +- Accessibility, content sanitization, safe path handling, security headers, + observability, release packaging, and performance verification. + +### Excluded + +- Browser-based authoring. +- Arbitrary source mutation outside configured OKF bundle roots and declared + authoring operations. +- Authentication-backed multi-user editing. +- Pull-request creation. +- Dataplex or other catalog synchronization. +- Service-backed distributed search. +- Replacing the existing Markdown materializer or graph documentation nodes. +- Changing existing graph `OperationRequest` variants or current MCP tool names. + +MCP authoring is intentionally narrower than a general editor: it accepts typed +bundle and page operations, validates the resulting OKF content, enforces +configured repository roots, detects stale writes, and publishes each source +change atomically. Interactive editing, arbitrary file writes, Git operations, +and multi-user conflict resolution remain outside this subsystem. + +## Existing constraints + +1. The workspace currently contains the root package and `crates/xtask`; + `crates/k-wiki` must be added as a third member + (`Cargo.toml:15-17`). +2. `CodebaseGraphApi::execute_operation` is the stable embedded graph entry + point (`src/api/facade.rs:20-75`). +3. Public graph requests are transport-neutral and already distinguish typed + and block outputs (`src/api/contracts.rs:18-104`). +4. The graph operation registry is authoritative for graph operations + (`src/api/core.rs:75-202`). +5. Existing boundary tests prevent transport adapters from bypassing the public + facade (`src/api/boundary_tests.rs:1-138`). +6. Graph MCP schemas are generated from operation metadata + (`src/adapters/mcp/tools.rs:9-43`). +7. The graph refresh service already implements filtering, filesystem event + normalization, and locking behavior (`src/api/refresh.rs:34-118`); the wiki + may reuse public refresh behavior but must not import graph refresh + internals. +8. Remote serving is local-first. Any remote listener requires an explicit + security design rather than inheriting incomplete HTTP assumptions + (`SECURITY.md:21-31`). +9. CI requires workspace formatting, tests, Clippy, advisory scanning, package + dry-runs, and artifact smoke tests (`docs/release.md:22-31`). +10. New dependencies require explicit approval. Approval of this plan + authorizes only the minimal dependency set recorded in Phase 0. + +## Scryer architecture + +```mermaid +flowchart LR + D["Developer or Agent"] --> SYS + H["MCP Host"] --> SYS + SYS --> S["Source Repository"] + + subgraph SYS["codebaseGraph"] + G["Graph Runtime public API"] + + subgraph W["Knowledge Wiki"] + CLI["Wiki CLI Adapter"] --> API["Wiki Public API"] + HTTP["Wiki HTTP Server"] --> API + MCP["Wiki Agent Adapter"] --> API + API --> REF["Refresh Coordinator"] + API --> STORE["Projection Store"] + API --> SEARCH["Concept Search"] + API --> CTX["Graph Context Adapter"] + API --> RENDER["Wiki Renderer"] + API --> AUTHOR["Bundle Authoring"] + AUTHOR --> READ + AUTHOR --> VALID + REF --> COMP["Knowledge Compiler"] + COMP --> READ["Bundle Reader"] + COMP --> VALID["Conformance Validator"] + COMP --> STORE + SEARCH --> STORE + RENDER --> STORE + end + end + + H --> W + W --> S + W --> G + READ --> S + REF --> S + AUTHOR --> S + REF --> G + CTX --> G +``` + +### Scryer nodes + +| Node | Scryer ID | Accountability | +| --- | --- | --- | +| Developer or Agent | `person-developer` | Uses repository graph and wiki capabilities | +| codebaseGraph | `system-codebase-graph` | Exposes repository knowledge to developers and agents | +| Knowledge Wiki | `node-164` | OKF publishing and controlled authoring boundary | +| Wiki Public API | `node-165` | Transport-neutral read and authoring operations | +| Bundle Reader | `node-166` | Bundle discovery and lossless document reading | +| Conformance Validator | `node-167` | Normative and advisory OKF checks | +| Bundle Authoring | `node-177` | Controlled bundle and page source writes | +| Knowledge Compiler | `node-168` | Concepts, directories, relationships, and history | +| Projection Store | `node-169` | Atomic versioned wiki artifacts | +| Concept Search | `node-170` | Search ranking, facets, and snippets | +| Graph Context Adapter | `node-171` | Optional `CodebaseGraphApi` composition | +| Wiki Renderer | `node-172` | Secure accessible static/browser presentation | +| Refresh Coordinator | `node-173` | Incremental rebuilds and refresh coordination | +| Wiki CLI Adapter | `node-174` | Command transport | +| Wiki HTTP Server | `node-175` | Local browser and JSON transport | +| Wiki Agent Adapter | `node-176` | Read/write MCP server displayed as `Knowledge Wiki` | + +## Package and module layout + +```text +crates/k-wiki/ + Cargo.toml + src/ + lib.rs + model.rs + diagnostic.rs + api/ + mod.rs + contracts.rs + registry.rs + facade.rs + bundle/ + mod.rs + discover.rs + document.rs + reserved.rs + conformance.rs + authoring/ + mod.rs + bundle.rs + page.rs + write.rs + compiler/ + mod.rs + identity.rs + links.rs + navigation.rs + citations.rs + history.rs + projection/ + mod.rs + manifest.rs + store.rs + cache.rs + search/ + mod.rs + index.rs + ranking.rs + graph_context.rs + render/ + mod.rs + markdown.rs + routes.rs + templates.rs + refresh.rs + adapters/ + mod.rs + cli.rs + http.rs + mcp.rs + bin/ + okf-wiki.rs + templates/ + assets/ + tests/ + fixtures/ + minimal/ + comprehensive/ + malformed/ + malicious/ + multi_bundle/ + bundle_reader.rs + conformance.rs + authoring.rs + compilation.rs + projection.rs + search.rs + graph_context.rs + rendering.rs + transports.rs + refresh.rs + end_to_end.rs +``` + +The final module names may change only when implementation evidence shows a +different cohesion boundary. Any such change must update Scryer before code is +kept. + +## Normalized model + +The public projection schema is versioned independently from OKF: + +```text +WikiProjection + schema_version + generated_at + source_revision + bundles[] + +Bundle + id + root_path + okf_version + title + source_revision + directories[] + concepts[] + diagnostics[] + +Directory + path + title + description + index_source(authored|synthetic) + body_markdown + child_directories[] + concept_ids[] + log_entries[] + +Concept + id + bundle_id + source_path + type + title + description + resource + tags[] + timestamp + extensions + body_markdown + headings[] + outbound_links[] + backlinks[] + citations[] + +Link + source_id + raw_href + normalized_target_id + fragment + status(resolved|broken|external|rejected) + context + +Diagnostic + severity(error|warning|info) + code + source_path + line + message + +LogEntry + scope_path + date + category + text + links[] +``` + +Rules: + +- Source concepts do not gain an explicit required ID; identity remains the + path without `.md`. +- Bundle IDs namespace concept IDs but do not alter their within-bundle value. +- Unknown frontmatter is stored losslessly in `extensions`. +- Broken links remain in the projection and are not build-fatal under the + consume profile. +- Rendered HTML is derived output, not canonical source data. +- Source paths are display-safe relative paths; absolute filesystem paths do + not enter public responses. + +## Wiki public API + +Create a parallel `OkfWikiApi`; do not add wiki variants to the graph +`OperationRequest` in `src/api/contracts.rs:18-37`. + +### Requests + +```text +WikiOperationRequest + Health + ValidateBundle + CreateBundle + CreatePage + PopulatePage + BuildProjection + ListBundles + GetDirectory + GetConcept + SearchConcepts + GetBacklinks + GetNeighborhood + GetDiagnostics + GetRecentChanges + RenderSite +``` + +Every request carries a typed record. Repository/bundle selectors must be +resolved once before dispatch. The wiki operation registry is the single source +for CLI, HTTP, and agent-tool metadata, mirroring the graph registry pattern at +`src/api/core.rs:75-202` without importing it. + +### Responses and errors + +- `WikiOperationResponse` is a typed enum, not an unstructured transport value. +- JSON adapters serialize the typed response at the boundary. +- `WikiApiError` contains a stable code, safe message, optional structured + details, and retryability. +- Public errors must not contain absolute paths, source contents, secrets, or + raw parser failures. +- Stable error codes include: + `invalid_request`, `bundle_not_found`, `bundle_exists`, + `concept_not_found`, `concept_exists`, `path_outside_repository`, + `invalid_frontmatter`, `write_conflict`, `projection_unavailable`, + `build_in_progress`, `render_failed`, and `graph_context_unavailable`. + +### Authoring contract + +- `CreateBundle` initializes a bundle only under a configured repository root + and writes its root `index.md` with the selected OKF version. +- `CreatePage` creates one concept from a validated bundle-relative path and + fails rather than overwriting an existing source file. +- `PopulatePage` writes typed frontmatter and Markdown content to an existing + page while preserving unknown frontmatter fields unless explicitly replaced. +- Populate requests carry the source revision or content hash observed by the + caller; a mismatch returns `write_conflict`. +- All authoring paths are resolved beneath the selected bundle without + following escaping symlinks. +- Content is validated before publication and written through a same-directory + temporary file plus atomic replacement. +- A successful write enters the normal refresh pipeline; write tools do not + edit projection artifacts directly. + +### Graph composition + +`GraphContextAdapter` may construct only public `SearchRequest`, +`ContextRequest`, and `HealthRequest` values and call +`CodebaseGraphApi::execute_operation` (`src/api/facade.rs:69-75`). + +- End-user routes must not issue arbitrary graph `QueryRequest`. +- Graph results are translated into bounded wiki summaries. +- Graph failure never blocks OKF concept rendering. +- No wiki component imports `src/api/core.rs`, graph storage, CLI adapters, or + MCP adapters. +- Add boundary tests equivalent to + `transport_adapters_use_only_the_public_api_facade` + (`src/api/boundary_tests.rs:94-138`). + +## Storage and artifact contract + +Use a separate repository-local state root: + +```text +.kWiki/ + manifest.json + projections/ + .json + search/ + .idx + cache/ + .json + site/ + ... + diagnostics.json +``` + +The manifest records schema version, OKF version, bundle roots, source +revision, content hashes, dependency edges, output hashes, build duration, and +the last successful generation. + +Publication protocol: + +1. Build into a uniquely named staging directory under `.kWiki`. +2. Validate all declared artifacts and route manifests. +3. Atomically replace the published manifest and projection pointer. +4. Remove obsolete staging data only after the new generation is visible. +5. Preserve the last valid generation after parse, render, or graph failures. + +No existing `.codebaseGraph` database or manifest migration is required. + +## Routes + +```text +/ +/b/:bundle/ +/b/:bundle/d/:directory-path +/b/:bundle/c/:concept-id +/b/:bundle/type/:type +/b/:bundle/tag/:tag +/b/:bundle/search +/b/:bundle/graph +/b/:bundle/changes +/b/:bundle/diagnostics +/api/v1/health +/api/v1/bundles +/api/v1/bundles/:bundle/directories/:path +/api/v1/bundles/:bundle/concepts/:id +/api/v1/bundles/:bundle/search +/api/v1/bundles/:bundle/diagnostics +``` + +Route segments use reversible percent-encoding. Path normalization must not +permit escaping a bundle root, and concept URLs remain stable across releases. + +## Implementation sequence + +### Phase 0 — Contracts, fixtures, workspace, and dependency gate + +Files: + +- `Cargo.toml` +- `Cargo.lock` +- `crates/k-wiki/Cargo.toml` +- `crates/k-wiki/src/lib.rs` +- `crates/k-wiki/src/model.rs` +- `crates/k-wiki/src/diagnostic.rs` +- `crates/k-wiki/tests/fixtures/**` + +Work: + +1. Add `crates/k-wiki` to the workspace. +2. Add library and `okf-wiki` binary targets. +3. Define the normalized data model, diagnostics, schema version, and fixture + corpus. +4. Record the attached OKF v0.1 draft as the conformance source. +5. Select the minimal maintained dependencies for: + - YAML parsing, + - CommonMark/GFM events, + - HTML sanitization, + - compile-time templates, + - async local HTTP, + - CLI parsing, + - static search. +6. Require an explicit dependency review before adding packages. Prefer + pure-Rust, memory-safe, cross-platform libraries and reuse workspace + dependencies where suitable. + +Exit criteria: + +- `cargo metadata --no-deps` resolves the new package on all supported targets. +- Fixture coverage maps every normative OKF v0.1 rule to at least one test + case. +- Public projection serialization is deterministic and round-trips. +- No production graph behavior changes. + +### Phase 1 — Bundle Reader and Conformance Validator + +Scryer responsibilities: + +- Bundle Reader: `resp-168` through `resp-172` +- Conformance Validator: `resp-173` through `resp-176` + +Files: + +- `src/bundle/**` +- `src/conformance.rs` +- `tests/bundle_reader.rs` +- `tests/conformance.rs` + +Work: + +1. Discover configured bundle roots without following escaping symlinks. +2. Decode UTF-8, split frontmatter from body, and parse safe YAML values. +3. Parse required `type`, optional standard fields, and lossless extensions. +4. Classify normal concepts, `index.md`, and `log.md`. +5. Allow only bundle-root `index.md` to carry version metadata. +6. Implement `consume`, `conformant`, and `recommended` profiles. +7. Emit line-aware stable diagnostics. + +Exit criteria: + +- Every non-reserved Markdown file is classified exactly once. +- Unknown types and keys remain consumable. +- Only missing/invalid required semantics fail the conformant profile. +- Traversal and symlink escape fixtures are rejected. + +Scryer close: + +- Add symbol nodes only for implemented public definitions and data shapes. +- Attach every condition-shaped test to its matching responsibility. +- Fold only `resp-168` through `resp-176`. + +### Phase 2 — Controlled bundle and page authoring + +Scryer responsibilities: `resp-210` through `resp-215` + +Files: + +- `src/authoring/**` +- `tests/authoring.rs` + +Work: + +1. Resolve every write against an explicitly configured repository and bundle + root. +2. Implement `CreateBundle` with a conformant root index and fail-if-present + semantics. +3. Implement `CreatePage` with bundle-relative identity validation and + fail-if-present semantics. +4. Implement `PopulatePage` with typed frontmatter, Markdown content, extension + preservation, and an expected source revision or content hash. +5. Validate authored content before replacing its destination. +6. Write through a same-directory temporary file, flush it, and atomically + replace the destination. +7. Reject absolute paths, traversal, escaping symlinks, invalid reserved-file + targets, stale revisions, and writes outside permitted roots. +8. Notify the refresh coordinator only after a successful source write. + +Exit criteria: + +- Agents cannot write outside configured OKF roots or target arbitrary + repository files. +- Creating an existing bundle or page fails without modifying it. +- A stale populate request returns `write_conflict` without losing either + version. +- Failure injection never leaves truncated or partially written Markdown. +- Successful writes become readable through the normal projection pipeline. + +Scryer close: fold `resp-210` through `resp-215` with path-safety, conflict, +validation, and atomic-write tests attached. + +### Phase 3 — Knowledge Compiler + +Scryer responsibilities: `resp-177` through `resp-181` + +Files: + +- `src/compiler/**` +- `tests/compilation.rs` + +Work: + +1. Derive concept IDs and namespaced route identities. +2. Build directory trees and authored/synthetic indexes. +3. Resolve bundle-absolute, document-relative, parent-relative, and fragment + links with traversal prevention. +4. Build outbound links and backlinks. +5. Retain unresolved links as diagnostics and visible graph edges. +6. Extract headings and numbered citation sections. +7. Parse ISO date-grouped `log.md` entries and aggregate scoped history. +8. Produce deterministically ordered normalized projections. + +Exit criteria: + +- Repeated compilation produces byte-identical normalized JSON when source and + build metadata are fixed. +- Link and backlink tests cover add, change, remove, broken, fragment, and + escaping targets. +- Synthetic navigation is stable. + +Scryer close: fold `resp-177` through `resp-181` with unit and integration test +attachments. + +### Phase 4 — Projection Store and incremental invalidation + +Scryer responsibilities: + +- Projection Store: `resp-182` through `resp-185` +- Refresh Coordinator foundation: `resp-198`, `resp-200`, `resp-201` + +Files: + +- `src/projection/**` +- `src/refresh.rs` +- `tests/projection.rs` +- `tests/refresh.rs` + +Work: + +1. Define `.kWiki` manifest and generation schemas. +2. Add content-hash cache keys and dependency-aware invalidation. +3. Publish complete generations atomically. +4. Preserve the last valid generation after failure. +5. Serialize competing builds so stale work cannot overwrite newer state. +6. Invalidate: + - one concept page, its search document, outgoing edges, and affected + backlink pages after concept changes; + - the scoped directory and dependent ancestors after `index.md` changes; + - scoped and aggregate history after `log.md` changes. + +Exit criteria: + +- Kill/failure injection never exposes a partial generation. +- An unchanged rebuild is a cache hit. +- Concurrent build tests prove the newest source generation wins. +- `.codebaseGraph` state remains untouched. + +Scryer close: fold the implemented store responsibilities and only the refresh +responsibilities completed in this phase. + +### Phase 5 — Concept Search + +Scryer responsibilities: `resp-186` through `resp-189` + +Files: + +- `src/search/**` +- `tests/search.rs` + +Work: + +1. Index concept ID, title, type, tags, description, headings, body, + citations, and selected scalar extensions. +2. Rank exact title and concept-ID matches above prefix, metadata, heading, and + body matches. +3. Add bundle, type, and tag filters. +4. Return bounded highlighted snippets and deterministic tie-breaking. +5. Keep graph search separate from concept search. + +Exit criteria: + +- Golden ranking cases pass. +- Search output order is deterministic. +- A 10,000-concept fixture stays within the agreed memory and build budgets. + +Scryer close: fold `resp-186` through `resp-189`. + +### Phase 6 — Wiki Public API and graph context + +Scryer responsibilities: + +- Wiki Public API: `resp-165` through `resp-167` +- Graph Context Adapter: `resp-190` through `resp-192` + +Files: + +- `src/api/**` +- `src/graph_context.rs` +- `tests/graph_context.rs` +- API boundary tests + +Work: + +1. Implement typed request, response, selector, descriptor, and error contracts. +2. Implement one registry and one facade execution path. +3. Route authoring, build, read, search, diagnostics, changes, and render + operations. +4. Implement optional source-graph context using only `CodebaseGraphApi`. +5. Bound graph result count, context depth, and exposed fields. +6. Add import-boundary tests preventing direct graph-core/storage access. +7. Verify graph failures degrade context only. + +Exit criteria: + +- A facade spy observes one dispatch per wiki request, matching the graph facade + invariant at `src/api/facade.rs:105-124`. +- Every operation serializes and validates. +- No wiki module imports graph internals or transport adapters. +- Existing graph API and MCP contract snapshots remain unchanged. + +Scryer close: fold `resp-165` through `resp-167` and `resp-190` through +`resp-192`. + +### Phase 7 — Secure renderer and developer/agent experience + +Scryer responsibilities: `resp-193` through `resp-197` + +Files: + +- `src/render/**` +- `templates/**` +- `assets/**` +- `tests/rendering.rs` + +Work: + +1. Render all declared routes from normalized projections. +2. Sanitize raw HTML, event handlers, scripts, unsafe SVG, and unsafe URL + schemes. +3. Add breadcrumbs, table of contents, metadata, body, backlinks, citations, + source links, related graph context, and diagnostics. +4. Render hierarchy, type, tag, search, graph-neighborhood, change, and + diagnostic views. +5. Provide server-rendered navigation and reading without JavaScript. +6. Add progressive enhancement for search and graph exploration. +7. Meet WCAG 2.2 AA for core reading and navigation. +8. Use stable versioned route helpers rather than template-local URL logic. + +Exit criteria: + +- Malicious fixtures cannot execute active content. +- Keyboard-only journeys cover home, directory, concept, search, and + diagnostics. +- Automated accessibility checks report no critical or serious violations. +- Generated internal links all resolve or appear as explicit broken-link + diagnostics. + +Scryer visual gate: + +- Capture and accept representative desktop and narrow-width fixtures for the + visual `Wiki Renderer` component before folding appearance. + +Scryer close: fold `resp-193` through `resp-197` with security, accessibility, +and route tests attached. + +### Phase 8 — CLI, HTTP, and agent adapters + +Scryer responsibilities: + +- Wiki CLI Adapter: `resp-202`, `resp-203` +- Wiki HTTP Server: `resp-204` through `resp-207` +- Wiki Agent Adapter: `resp-208`, `resp-209` + +Files: + +- `src/adapters/**` +- `src/bin/okf-wiki.rs` +- `tests/transports.rs` + +CLI: + +```text +okf-wiki validate [--profile consume|conformant|recommended] [--json] +okf-wiki build --out [--base-url ] +okf-wiki serve [--host 127.0.0.1] [--port 4321] +okf-wiki inspect --concept +okf-wiki check-links [--include-external] +``` + +Publicly advertised MCP tools: + +| Tool identifier | Display name | Access | +| --- | --- | --- | +| `wiki_list_bundles` | `List Bundles` | Read | +| `wiki_list_directory` | `List Directory` | Read | +| `wiki_get_concept` | `Get Concept` | Read | +| `wiki_search_concepts` | `Search Concepts` | Read | +| `wiki_get_backlinks` | `Get Backlinks` | Read | +| `wiki_get_neighborhood` | `Get Neighborhood` | Read | +| `wiki_get_diagnostics` | `Get Diagnostics` | Read | +| `wiki_get_recent_changes` | `Get Recent Changes` | Read | +| `wiki_create_bundle` | `Create Bundle` | Write | +| `wiki_create_page` | `Create Page` | Write | +| `wiki_populate_page` | `Populate Page` | Write | + +The MCP initialization metadata must use the exact user-facing server display +name `Knowledge Wiki`. Internal package, binary, and tool identifiers do not +alter this display name. + +Work: + +1. Generate transport schemas from the wiki operation registry. +2. Keep CLI, HTTP, and MCP mappings thin and peer-level. +3. Bind HTTP to localhost by default. +4. Require a separate approved security change before remote binding. +5. Apply CSP, `nosniff`, referrer, framing, and cache headers. +6. Return safe error bodies without absolute paths. +7. Preserve error codes across transports. +8. Mark the eight discovery tools as read-only and the three authoring tools as + write operations in MCP metadata. +9. Route every authoring tool through `OkfWikiApi` and `Bundle Authoring`; + adapters must never write repository files directly. + +Exit criteria: + +- Every adapter dispatches exactly once through `OkfWikiApi`. +- Registry metadata advertises exactly the eleven identifiers, display names, + and access modes declared above. +- MCP clients display the server name exactly as `Knowledge Wiki`. +- Write-tool tests cover allowed roots, traversal, symlink escape, existing + targets, stale revisions, validation failures, and atomic replacement. +- HTTP and agent protocol conformance tests pass. +- Remote binding is impossible without an explicit configuration gate. + +Scryer close: fold `resp-202` through `resp-209`. + +### Phase 9 — Coordinated refresh + +Scryer responsibility: `resp-199` plus any remaining `resp-198` through +`resp-201` + +Files: + +- `src/refresh.rs` +- graph/watcher composition tests + +Work: + +1. Reuse public graph refresh operations rather than importing + `src/api/refresh.rs`. +2. Debounce repository changes once when both graph and wiki refresh are + enabled. +3. Partition changed paths for graph, wiki, or both. +4. Keep graph and wiki locks independent. +5. Surface per-consumer status and retryability. +6. Continue serving the last valid wiki projection during graph or wiki + refresh failures. + +Exit criteria: + +- One file event produces at most one wiki build generation and one graph + refresh request. +- Graph failure does not block wiki publication. +- Wiki failure does not block graph reads. +- Burst, rename, deletion, and shutdown tests pass. + +Scryer close: fold remaining refresh responsibilities. + +### Phase 10 — Packaging, release, performance, and end-to-end proof + +Files: + +- `README.md` +- `SECURITY.md` +- `docs/okf-wiki.md` +- `docs/release.md` +- `.github/workflows/**` +- `crates/xtask/src/main.rs` +- artifact smoke fixtures + +Work: + +1. Include templates and assets in release archives. +2. Add isolated package smoke tests. +3. Extend workspace CI and release gates. +4. Document state, commands, routes, validation profiles, threat boundaries, + rollback, and troubleshooting. +5. Benchmark 1,000, 10,000, and 50,000 concept bundles. +6. Emit build timings, document/edge counts, broken-link counts, cache hits, + generation IDs, and artifact sizes. +7. Run end-to-end static build, local server, HTTP, agent-tool, security, and + accessibility journeys. + +Exit criteria: + +- The release package builds and serves a package-owned fixture without source + tree assets. +- Workspace CI remains green on Linux, macOS, and Windows. +- A 10,000-concept full build completes in under two minutes on the selected CI + runner; an unchanged incremental rebuild completes in under ten seconds. +- Static reading remains functional when graph context, search enhancement, + and the preview server are unavailable. + +Final Scryer close: + +- Fold container responsibilities `resp-161` through `resp-164`. +- Fold actor responsibility `resp-1`. +- Fold the broadened system responsibility `resp-5`. +- Fold the restored actor-to-system relationship `link-developer-system`. +- Fold all links whose endpoints are implemented. +- Attach system-level end-to-end tests. +- Run `validate_model`, `get_health(node-164)`, `get_pending(chg-1)`, and + `get_pending(chg-2)`. +- Close `chg-1` and `chg-2` only when both queues are empty and tests are + attached to every implemented condition-shaped claim. + +## Acceptance criteria + +1. Every non-reserved `.md` file appears exactly once as a concept. +2. Concept IDs equal bundle-relative paths without `.md`. +3. `index.md` and `log.md` never appear as concepts. +4. Root `index.md` accepts `okf_version`; nested indexes reject frontmatter + under the conformant profile. +5. A document containing only non-empty `type` plus a body is conformant. +6. Missing optional fields never prevent consume-mode rendering. +7. Unknown types render generically and remain searchable. +8. Unknown frontmatter keys survive parse and serialization round trips. +9. Absolute, relative, parent-relative, and fragment links resolve to canonical + routes. +10. Broken internal links remain visible and generate diagnostics. +11. Paths and symlinks cannot escape configured repository roots. +12. Backlinks update after link addition, change, removal, or target rename. +13. Authored indexes render; missing indexes receive deterministic synthesized + navigation. +14. Scoped logs aggregate newest-first without becoming concepts. +15. Search ranks exact title and concept-ID matches above body-only matches and + supports bundle/type/tag filters. +16. Duplicate within-bundle IDs fail deterministically; identical concept IDs + across bundles remain namespaced. +17. A failed compile or render leaves the last valid projection readable. +18. Repeated builds are deterministic apart from explicitly declared volatile + metadata. +19. Malicious Markdown, HTML, links, YAML tags, and SVG cannot execute code or + escape the output root. +20. Core reading and navigation pass WCAG 2.2 AA checks and keyboard-only + journeys. +21. HTTP binds locally by default and emits the required security headers. +22. CLI, HTTP, and agent adapters return the same semantic result and error + codes for equivalent requests. +23. Graph context uses only `CodebaseGraphApi` and degrades without blocking + concept delivery. +24. Existing graph public requests, MCP names, block snapshots, and + `.codebaseGraph` state remain backward compatible. +25. Full and incremental performance budgets pass on the agreed CI runner. +26. `wiki_create_bundle` creates a conformant bundle only beneath a configured + repository root and never overwrites an existing bundle. +27. `wiki_create_page` creates one valid concept at a bundle-relative identity + and never overwrites an existing page. +28. `wiki_populate_page` atomically updates validated frontmatter and Markdown + while preserving unknown fields. +29. Write tools reject traversal, absolute paths, escaping symlinks, invalid + reserved-file targets, and destinations outside permitted bundle roots. +30. Stale populate requests return `write_conflict` without changing source + content. +31. MCP initialization advertises the exact server name, tool identifiers, + display names, and read/write access modes declared in Phase 8. + +## Test strategy + +### Unit + +- Frontmatter boundaries, YAML values, timestamp parsing, extension + preservation. +- Reserved-file classification and root-index exception. +- Concept ID and route encoding. +- Link normalization, fragments, traversal, safe schemes. +- Conformance severity and profile behavior. +- Log and citation parsing. +- Search tokenization, field boosts, filtering, snippets, tie-breaking. +- Cache keys and invalidation sets. +- HTML sanitization and header construction. +- Authoring path resolution, reserved-file rules, revision preconditions, and + temporary-file publication. + +### Integration + +- Bundle discovery through normalized projection. +- Projection persistence and atomic rollback. +- Incremental backlink and search updates. +- Multi-bundle identity isolation. +- Wiki public API registry and dispatch. +- Bundle creation, page creation, page population, conflict rejection, and + post-write refresh. +- Graph public API composition and degraded mode. +- Static route manifest and output verification. +- Transport parity across CLI, HTTP, and MCP. + +### End to end + +- Validate, build, browse, search, follow links, inspect backlinks, view graph + context, view changes, and inspect diagnostics. +- Create a bundle and page through MCP, populate it, then read and search the + resulting concept through MCP. +- Repeat with graph runtime unavailable. +- Repeat with malformed and malicious fixtures. +- Run on Windows path fixtures and symlink-capable Unix fixtures. +- Package, extract, build a fixture, start preview, and issue HTTP/agent calls. + +### Observability + +- Full and incremental build duration by stage. +- Files scanned, parsed, reused, and rejected. +- Concepts, directories, links, backlinks, citations, logs, and diagnostics. +- Cache hit ratio and output bytes. +- Refresh generation, queue depth, coalesced event count, and last success. +- Graph context latency and degraded-call count. +- Authoring attempts, successful writes, validation failures, and write + conflicts without recording source content. + +## Required verification commands + +```bash +cargo fmt --check +cargo clippy --workspace --all-targets --all-features --locked -- -D warnings +cargo test --workspace --locked +cargo test -p okf-wiki --all-features --locked +cargo run -p okf-wiki -- validate crates/k-wiki/tests/fixtures/comprehensive +cargo run -p okf-wiki -- build crates/k-wiki/tests/fixtures/comprehensive --out /tmp/okf-wiki-site +cargo run -p xtask -- release-gate +``` + +Security, accessibility, determinism, isolated-package, and performance +commands must be added to the package as stable scripts or `xtask` operations +rather than remaining undocumented one-off commands. + +## Risks and mitigations + +| Risk | Mitigation | +| --- | --- | +| OKF 0.1 changes while implementation is underway | Version the projection and validator; isolate spec rules in fixtures | +| YAML/Markdown libraries expand the attack surface | Minimal maintained dependencies, safe modes, sanitizer, advisory scan | +| Existing graph API leaks into wiki domain contracts | Dedicated adapter and import-boundary tests | +| Two refresh loops race or duplicate work | One debounced event stream, independent locks, generation tokens | +| Static search payload grows too large | Separate compressed indexes, lazy loading, measured escalation threshold | +| Broken or partial builds replace valid output | Staging generation plus atomic publish | +| Unknown extensions are lost | Lossless value map and round-trip tests | +| URLs change after refactors | Central route contract and snapshot tests | +| Graph outages make wiki unreadable | Optional bounded context and explicit degraded responses | +| Agent writes overwrite repository knowledge | Permitted roots, fail-if-present creation, revision preconditions, validation, and atomic replacement | +| Remote HTTP is mistaken for multi-user security | Localhost default; separate approved security change for remote mode | +| New package breaks release portability | Pure-Rust dependencies, cross-platform CI, isolated artifact smoke | +| UI becomes the source of truth | Read-only UI; generated output remains disposable | + +## Sign-off gate + +Approval of this plan authorizes implementation against Scryer changes `chg-1` +and `chg-2`, phase by phase. It authorizes only the declared MCP bundle/page +source writes beneath configured roots; it does not authorize browser +authoring, arbitrary file writes, Git operations, remote multi-user serving, or +catalog synchronization. + +After approval, implementation must start with Phase 0 and must update Scryer +with concrete symbols, anchors, and attached tests as each responsibility is +built. No unimplemented responsibility may be anchored or folded. diff --git a/.omx/plans/okf-wiki-implementation-study.md b/.omx/plans/okf-wiki-implementation-study.md new file mode 100644 index 0000000..537d6f8 --- /dev/null +++ b/.omx/plans/okf-wiki-implementation-study.md @@ -0,0 +1,594 @@ +# OKF Wiki Implementation Study + +## Summary + +This repository already exposes a stable, transport-neutral public API for graph +health, search, context, read-only query, materialization, and lifecycle +operations through `CodebaseGraphApi::execute_operation` +(`src/api/mod.rs:1-26`, `src/api/facade.rs:16-39`, `src/api/core.rs:57-205`). +That API is a strong substrate for repository and code-graph retrieval, but it +is not yet an OKF consumer: current Markdown ingestion only produces +`DocumentationSource` and `DocumentationChunk` nodes from whole documents and +heading sections, with no YAML frontmatter model, no OKF reserved-file +semantics, and no markdown-link or citation extraction +(`src/profiles.rs:69-82`, `src/parser/markdown.rs:8-173`, +`src/syntax_materializer/relations.rs:103-115`). + +The recommended direction is to build an adjacent OKF-specific compiler and wiki +service that uses the existing `codebaseGraph` public API for repository/code +graph features, while keeping OKF parsing, conformance, bundle navigation, and +wiki page projection in a separate bounded module or package. Pushing OKF +semantics directly into the existing documentation-node model would create +avoidable coupling with a code-search-oriented graph schema and search surface. + +## Confirmed Current State + +### 1. Public API boundary and exposure + +Confirmed facts: + +- The repository defines a stable API entry point intended for embedded + consumers, and the same facade is used by CLI and MCP adapters + (`src/api/mod.rs:1-26`). +- The public facade is `CodebaseGraphApi`, which executes an + `OperationRequest` via `execute_operation` + (`src/api/facade.rs:16-39`). +- Public request contracts currently cover `Health`, `Search`, `Context`, + `Query`, `Materialize`, `Plan`, `Catalog`, `Setup`, `Reinstall`, + `Uninstall`, and `Refresh`; there is no OKF- or wiki-specific request variant + today (`src/api/contracts.rs:18-157`, `src/api/contracts.rs:159-180`). +- The authoritative operation registry exposes only twelve operations, and only + `health`, `search`, `context`, `query`, `schema`, `query-helpers`, and + `architecture-queries` are MCP-exposed (`src/api/core.rs:57-163`). +- Request normalization, validation, runtime resolution, dispatch, and response + presentation are centralized in `ApiCore::execute` + (`src/api/core.rs:172-205`). +- Boundary tests enforce that `src/api/**` does not import transport adapters + and that CLI/MCP modules do not bypass the public facade + (`src/api/boundary_tests.rs:1-88`). + +Implication: + +- There is already a viable public API boundary to reuse, but any OKF wiki + capability would need either new public contracts or a separate service that + composes this API rather than trying to tunnel through existing graph-search + requests. + +### 2. Current Markdown ingestion model + +Confirmed facts: + +- Markdown and MDX are recognized as one language profile with only two capture + mappings: `doc.source -> DocumentationSource` and + `doc.chunk -> DocumentationChunk` (`src/profiles.rs:69-82`). +- The markdown parser emits one `DocumentationSource` node for the whole file + and additional `DocumentationChunk` nodes for heading-delimited sections + (`src/parser/markdown.rs:8-59`). +- The parser discovers sections only by markdown headings. It does not parse + YAML frontmatter, markdown links, citations, reserved filenames, or any OKF + structure (`src/parser/markdown.rs:119-173`). +- Node labels default to named fields when present, otherwise to the raw text + content of the node (`src/syntax_materializer/capture.rs:16-49`). +- For documentation nodes, the materializer stores the full captured text as the + `summary` field (`src/syntax_materializer/builder/nodes.rs:43-99`). +- Documentation nodes emit only `Documents` and `EvidencedBy` relations to + owners and parser evidence (`src/syntax_materializer/builder/semantic.rs:499-515`). +- The default relation allowlist permits `Documents` from documentation nodes to + `Repository`, `File`, `Module`, or declaration nodes, not to OKF concept/link + targets (`src/syntax_materializer/relations.rs:55-117`). +- The graph schema defines `DocumentationSource` and `DocumentationChunk` with + generic graph fields such as `label`, `path`, `summary`, and `metadata`; it + does not define OKF-specific fields such as `type`, `resource`, `tags`, + `timestamp`, `bundle_id`, or `concept_id` + (`assets/graph_schema.json:3580-3698`). +- The docs search index is `idx_docs` over `label`, `path`, and `summary` for + those two node types (`assets/graph_schema.json:6551-6620`). + +Implication: + +- The current graph can retrieve documentation text, but only as generic + document and section nodes. It does not preserve the semantic units that OKF + requires. + +### 3. Search, context, and query behavior + +Confirmed facts: + +- `execute_graph_search` runs across all configured search indexes and returns + ranked node payloads with optional context (`src/api/graph_read.rs:35-105`). +- Context traversal is relation-profile-driven and returns neighboring graph + nodes, not a document-oriented render model (`src/api/graph_read.rs:107-180`). +- Search results in `slim` mode contain `id`, `type`, `label`, optional `path`, + optional `span`, and optional `summary`; standard mode adds + `qualified_name`, score details, and `context` + (`src/api/graph_read.rs:600-653`). +- Search ranking is optimized for generic code/entity retrieval, with + `DocumentationSource` and `DocumentationChunk` treated as lower-priority than + code declarations (`src/api/graph_read.rs:656-700`). +- `graph_query` is explicitly read-only; both keyword blocking and prepared + statement read-only checks are enforced (`src/api/graph_read.rs:220-245`, + `src/api/core.rs:587-616`, `SECURITY.md:21-31`). + +Implication: + +- The existing public API is good for retrieval and introspection, but it is + not a page-delivery API for an OKF wiki. It has no concept-specific route or + bundle projection surface. + +### 4. Storage, refresh, and state + +Confirmed facts: + +- Repository runtime resolution derives `.codebaseGraph/config.json`, + `.codebaseGraph/manifest.json`, and `.codebaseGraph/_graph.ldb` under + the repository root (`src/api/context.rs:13-67`). +- Materialization persists manifests and graph state through a dedicated + pipeline and manifest diff model (`src/api/materialization.rs:104-240`, + `src/protocol.rs:72-170`). +- The source snapshot model tracks content hashes and changed paths, which is + useful if an OKF compiler wants similar incremental behavior + (`src/protocol.rs:126-170`). +- The README documents automatic freshness through `mcp start` or `watch`, and + treats `build` as an explicit manual rebuild (`README.md:32-39`, + `README.md:82-105`). + +Implication: + +- `codebaseGraph` already solves incremental graph state management for source + files, but its persisted state is specific to syntax-materialized graph rows, + not OKF bundle/page projections. + +### 5. Transport and security posture + +Confirmed facts: + +- MCP specs are generated from the public API operation descriptors + (`src/cli/mcp/tools.rs:14-48`). +- MCP tool handling maps only the graph operations listed in the registry and + supports either block text or structured JSON payloads + (`src/cli/mcp/tools.rs:63-220`). +- The README documents only graph-oriented MCP tools today + (`README.md:54-80`). +- The documented security boundary is local-first; remote HTTP remains weakly + scoped and `graph_query` must remain read-only (`README.md:62-70`, + `SECURITY.md:21-31`). + +Implication: + +- Any wiki service exposed beyond local use needs its own security model; it + should not inherit assumptions from the current local-first graph transport. + +## OKF v0.1 Requirement Mapping + +The table below compares the attached OKF v0.1 draft to the confirmed current +state of this repository. + +| OKF draft requirement | Current `codebaseGraph` support | Status | Evidence | +| --- | --- | --- | --- | +| Bundle is a directory tree of markdown files | Markdown files are scanned and materialized | Partial | `README.md:3-5`, `src/profiles.rs:69-82` | +| Every concept has YAML frontmatter with required `type` | No frontmatter parser or typed frontmatter fields exist | Missing | `src/parser/markdown.rs:8-173`, `assets/graph_schema.json:3580-3698` | +| Optional `title`, `description`, `resource`, `tags`, `timestamp`, and unknown extensions must be preserved | No OKF metadata model exists in the graph schema | Missing | `assets/graph_schema.json:3580-3698` | +| Concept ID is bundle-relative path without `.md` | No concept identity rule exists; current IDs are graph IDs derived from capture/materialization keys | Missing | `src/syntax_materializer/builder/nodes.rs:52-97` | +| `index.md` and `log.md` are reserved and not concepts | Current markdown ingestion treats all `.md` files generically | Missing | `src/profiles.rs:69-82`, `src/parser/markdown.rs:8-173` | +| Exception: root `index.md` may contain `okf_version` frontmatter | No special root-index handling exists | Missing | `src/parser/markdown.rs:8-173` | +| Standard markdown body should remain readable and structured | Whole-file and heading-level markdown text is captured | Partial | `src/parser/markdown.rs:20-59` | +| Internal markdown links represent relationships and may be broken | Links are not extracted from markdown bodies today | Missing | `src/parser/markdown.rs:8-173`, `src/syntax_materializer/relations.rs:103-115` | +| Citations should be representable under `# Citations` | Headings are captured, but citations are not parsed as first-class data | Partial | `src/parser/markdown.rs:35-59`, `src/parser/markdown.rs:119-173` | +| Consumers must tolerate unknown types and broken links | No OKF consumer exists yet; current docs model avoids this by not parsing those semantics | Missing | `src/api/contracts.rs:18-157`, `src/parser/markdown.rs:8-173` | +| Index browsing and progressive disclosure | Graph search/context can retrieve docs, but no directory/index projection exists | Partial | `src/api/core.rs:57-163`, `src/api/graph_read.rs:35-180` | +| Optional logs for scoped history | No log-file parsing exists | Missing | `src/parser/markdown.rs:8-173` | +| Version declaration via root `index.md` frontmatter | No version parsing exists | Missing | `src/parser/markdown.rs:8-173` | +| Best-effort consumption rather than hard rejection | The graph API is permissive at graph-read time, but there is no OKF conformance layer | Partial | `src/api/core.rs:172-205`, `src/api/graph_read.rs:35-105` | + +## Public API Capability Matrix + +This matrix evaluates the already-written public API specifically for an OKF +wiki service. + +| Public surface | What it does now | Useful for OKF wiki | Gap for OKF wiki | +| --- | --- | --- | --- | +| `CodebaseGraphApi::execute_operation` | Stable library entry point over public requests | Yes | Needs composition by a wiki service or extension with OKF-specific operations | +| `Health` | Confirms graph DB/manifest readability | Yes | Does not validate OKF bundle state | +| `Search` | FTS search over graph node types | Yes, for code/document retrieval sidebars | No concept-aware OKF search ranking or filtering | +| `Context` | Relation-based graph neighborhoods | Yes, for code-neighborhood panels | No concept page model or backlinks from markdown links | +| `Query` | Read-only graph queries | Yes, for admin/debugging | Unsafe to treat as end-user wiki contract; not concept-oriented | +| `Materialize` / `Plan` | Incremental graph build/plan for source files | Indirectly yes | Does not parse OKF semantics or produce wiki artifacts | +| `Catalog` (`schema`, `query-helpers`, `architecture-queries`) | Exposes graph metadata and canned queries | Indirectly yes | No OKF schema/catalog or bundle contracts | +| `Refresh` / `watch` | Keeps graph state fresh | Yes | No OKF projection refresh pipeline | +| MCP generated from descriptors | Makes graph tools consumable by agents | Yes, for codebaseGraph operations | No OKF-specific read/write tools | + +## Prioritized Gap Analysis + +### Priority 1: No OKF semantic model + +Root problem: + +- The current Markdown pipeline captures documents and heading chunks, but it + never parses the semantic payload that OKF standardizes: frontmatter, + concept-type metadata, reserved files, root-index exception, citations, or + markdown cross-links (`src/parser/markdown.rs:8-173`, + `assets/graph_schema.json:3580-3698`). + +Why it matters: + +- Without an OKF semantic model, a wiki cannot reliably answer basic questions + such as “what concepts exist?”, “what type is this concept?”, “what tags does + it have?”, “what links point here?”, or “is this `index.md` a directory page + or a concept?” + +### Priority 2: Public API is graph-oriented, not page-oriented + +Root problem: + +- The public API returns graph-search hits, graph neighborhoods, or raw query + rows. It has no operation that returns a fully normalized concept page, + directory listing, diagnostics set, or bundle manifest + (`src/api/contracts.rs:18-157`, `src/api/core.rs:57-163`, + `src/api/graph_read.rs:600-653`). + +Why it matters: + +- An OKF wiki needs stable concept/directory contracts, not just search hits. + +### Priority 3: Search and ranking are wrong for OKF navigation + +Root problem: + +- The docs search index is generic (`label`, `path`, `summary`) and search + ranking favors code entities over documentation nodes + (`assets/graph_schema.json:6609-6620`, `src/api/graph_read.rs:656-700`). + +Why it matters: + +- OKF search should boost concept titles, concept IDs, tags, types, and + descriptions. None of those are available in the current graph model. + +### Priority 4: No reserved-file or history semantics + +Root problem: + +- `index.md` and `log.md` are not treated specially in current ingestion + (`src/parser/markdown.rs:8-173`). + +Why it matters: + +- OKF requires both files to carry semantic meaning, and `index.md` at the + bundle root has a special versioning exception that current parsing would miss. + +### Priority 5: Security and transport assumptions differ + +Root problem: + +- `codebaseGraph` is explicitly local-first, and its HTTP mode is not positioned + as a multi-user or production-grade remote API (`README.md:62-70`, + `SECURITY.md:21-31`). + +Why it matters: + +- A wiki service may need browser access, sanitization, authentication, and a + stronger remote boundary than the current graph transport. + +## Recommended Target Architecture + +### Recommendation + +Build the OKF wiki as an adjacent subsystem that composes `codebaseGraph` +rather than modifying the current documentation graph model in place. + +### Proposed boundaries + +1. `codebaseGraph` remains the repository/code graph engine. + - Ownership: syntax graph, incremental source scanning, read-only graph + retrieval, architecture queries. + - Interface used by the wiki: `CodebaseGraphApi` and existing MCP/CLI + surfaces (`src/api/mod.rs:1-26`, `src/api/facade.rs:16-39`). + +2. New `okf_wiki` library/service owns OKF semantics. + - OKF bundle discovery. + - Frontmatter parsing and lossless extension preservation. + - Concept ID derivation. + - Reserved-file handling for `index.md` and `log.md`. + - Internal-link, backlink, and citation extraction. + - Conformance diagnostics against the OKF draft. + - Directory and concept page projection. + +3. Wiki backend/API composes both. + - Primary page data comes from `okf_wiki`. + - Optional “code context” or “related implementation” panels use + `CodebaseGraphApi::Search`, `Context`, and `Query`. + +4. Wiki UI stays thin. + - Render pre-normalized concept/directory payloads. + - Do not make the browser responsible for interpreting raw OKF rules. + +### Why this split is preferred + +Pros: + +- Preserves the existing graph API and schema contract. +- Avoids forcing OKF concepts into a code-search-first node taxonomy. +- Lets the OKF implementation evolve independently from syntax graph internals. +- Keeps repository/code retrieval reusable for non-OKF consumers. + +Cons: + +- Introduces another projection layer and state artifact. +- Requires coordination between two refresh paths if both code graph and OKF + projection are kept hot. + +### Rejected alternative + +Do not treat `DocumentationSource`/`DocumentationChunk` as the canonical OKF +model. + +Reason: + +- Those nodes are too generic and too lossy. Retrofitting them would require + stretching existing fields and search indexes far beyond their current + meaning, with higher compatibility risk than a bounded adjacent module. + +## Data Model, Storage, and Indexing Implications + +### Recommended OKF data model + +Add a normalized model owned by the wiki subsystem: + +- `Bundle { id, root_path, okf_version, diagnostics[] }` +- `Directory { path, title, description, authored_index, children[], concepts[], logs[] }` +- `Concept { id, source_path, type, title, description, resource, tags[], timestamp, extensions, body_markdown, headings[], citations[], outbound_links[], backlinks[] }` +- `Link { raw_href, target_id?, fragment?, status }` +- `LogEntry { scope_path, date, category?, text, links[] }` + +### Recommended storage + +Recommended initial choice: + +- Keep codebaseGraph state in `.codebaseGraph/` unchanged. +- Write OKF projection artifacts to a separate state root, for example + `.okfWiki/`, to avoid accidental coupling to graph manifests or DB schema. + +Rationale: + +- `RepoPaths::derive` and runtime resolution are explicitly tied to + `.codebaseGraph` and graph-specific files (`src/api/context.rs:13-67`). +- Materialization manifests are keyed to source snapshots and graph rows, not to + OKF concepts (`src/protocol.rs:72-170`). + +### Recommended indexing + +- Build an OKF search index over `concept_id`, `title`, `type`, `tags`, + `description`, headings, and body text. +- Keep backlinks and link diagnostics as first-class indexed data. +- Optionally mirror selected OKF concept summaries into `codebaseGraph` later, + but do not make that a day-one dependency. + +### Migration implications + +- No migration to existing `codebaseGraph` graph schema is required for an + initial release. +- If later integration into LadyBugDB is desired, add a versioned import path + rather than mutating `DocumentationSource` semantics in place. + +## APIs and Contracts + +### Reuse existing public API as-is for + +- Repository health checks. +- Code or document discovery by path/content terms. +- Graph neighborhood views for implementation cross-links. +- Read-only ad hoc inspection by maintainers. + +### New OKF-facing contracts needed + +Recommended library/service contracts: + +- `LoadBundleRequest { root_path }` +- `ValidateBundleRequest { root_path, profile }` +- `BuildWikiProjectionRequest { root_path, incremental }` +- `GetConceptRequest { bundle_id, concept_id }` +- `GetDirectoryRequest { bundle_id, directory_path }` +- `SearchConceptsRequest { bundle_id, query, filters }` +- `GetDiagnosticsRequest { bundle_id, severity? }` +- `GetRecentChangesRequest { bundle_id, scope? }` + +### Contract boundary recommendation + +- Do not overload `OperationRequest::Search` or `OperationRequest::Context` + with OKF semantics. +- Either: + 1. create a parallel `OkfWikiApi`, or + 2. add a separate top-level product surface after the OKF model stabilizes. + +Option 1 is lower risk for the first implementation. + +## Security, Concurrency, and Operational Considerations + +### Security + +Confirmed constraints: + +- Current remote HTTP graph transport is intentionally minimal and not a + production multi-user security model (`README.md:62-70`, + `SECURITY.md:21-31`). + +Recommendations for the wiki: + +- Parse YAML with safe loaders only. +- Sanitize rendered markdown HTML. +- Treat all links and `resource` URIs as untrusted. +- If browser-served, add explicit authn/authz and do not rely on current MCP + HTTP assumptions. + +### Concurrency and refresh + +Confirmed constraints: + +- The graph system already has refresh/build state and locking concerns + (`README.md:93-105`, `src/cli/mcp/tools.rs:141-153`). + +Recommendations: + +- Make OKF projection refresh independent from graph DB locking. +- If both systems watch the same repository, use one debounced source event + stream feeding two consumers instead of two unrelated recursive watchers. +- Fail independently: a graph refresh failure should not make the OKF wiki + unreadable, and an OKF parse failure should not stop code graph reads. + +## Phased Implementation Plan + +### Phase 0: Specification alignment + +- Freeze the OKF v0.1 requirements set used for implementation. +- Encode the special root `index.md` frontmatter exception for `okf_version`. +- Write fixtures that cover concept docs, `index.md`, `log.md`, broken links, + citations, and unknown extension keys. + +Exit criteria: + +- Requirement mapping is complete and test fixtures reflect every normative OKF + rule used by the implementation. + +### Phase 1: OKF core parser and model + +- Implement bundle scanning. +- Parse YAML frontmatter and markdown body separately. +- Derive concept IDs from bundle-relative paths. +- Preserve unknown frontmatter keys. +- Distinguish concept docs from `index.md` and `log.md`. + +Exit criteria: + +- Bundle loading produces normalized concepts/directories/logs with diagnostics. + +### Phase 2: Link, citation, and diagnostics layer + +- Extract internal markdown links and fragments. +- Resolve absolute and relative links. +- Record broken links without rejecting the bundle. +- Extract citation sections and scoped log entries. + +Exit criteria: + +- The model supports backlinks, unresolved-link diagnostics, and changes views. + +### Phase 3: Wiki API and projection + +- Build concept/directory/search endpoints or library methods. +- Add concept-aware search ranking. +- Provide machine-readable diagnostics and recent-changes outputs. + +Exit criteria: + +- A consumer can request a concept page or directory listing without directly + reading files. + +### Phase 4: Adjacent composition with `codebaseGraph` + +- Add optional “related code”, “implementation neighborhood”, or “architecture + query” panels powered by `CodebaseGraphApi`. +- Keep this integration optional and additive. + +Exit criteria: + +- The wiki can show implementation context without changing OKF page semantics. + +### Phase 5: Delivery and hardening + +- Add browser UI or static rendering. +- Add authn/authz if remote-serving is required. +- Add watch/refresh integration and deployment packaging. + +Exit criteria: + +- The service is operationally independent, testable, and deployable. + +## Testing and Verification Plan + +### Unit tests + +- Frontmatter parsing and extension preservation. +- Concept ID derivation. +- Reserved-file classification. +- Absolute and relative link resolution. +- Root `index.md` version exception handling. +- Citation and `log.md` parsing. + +### Integration tests + +- End-to-end bundle load and page projection. +- Broken-link diagnostics without hard failure. +- Search ranking over titles, concept IDs, and tags. +- Backlink recomputation on file changes. + +### Composition tests + +- `okf_wiki` uses `CodebaseGraphApi` only for optional implementation context. +- A failing graph query does not prevent concept rendering. +- A malformed OKF bundle does not corrupt `.codebaseGraph` state. + +### Non-functional verification + +- Incremental rebuild correctness. +- HTML sanitization and link-scheme safety. +- Watch/refresh coalescing behavior. +- Concurrency tests if remote service mode is introduced. + +## Acceptance Criteria + +1. Every non-reserved markdown file becomes exactly one OKF concept with the + correct path-derived concept ID. +2. `index.md` and `log.md` are never treated as normal concepts. +3. Bundle-root `index.md` may declare `okf_version` without breaking index + semantics. +4. Unknown frontmatter keys survive parse and serialization. +5. Missing optional OKF fields do not block best-effort consumption. +6. Internal links produce backlinks and diagnostics for unresolved targets. +7. Search supports concept-aware ranking and filters. +8. `CodebaseGraphApi` integration remains optional and additive. +9. Existing `codebaseGraph` public API behavior remains backward compatible. +10. No change to `.codebaseGraph` schema or current MCP tool names is required + for the first OKF wiki release. + +## Rollout and Compatibility Risks + +| Risk | Impact | Mitigation | +| --- | --- | --- | +| Overloading current documentation nodes with OKF semantics | Breaks existing graph consumers and search expectations | Keep OKF model adjacent and separate | +| Reusing current search ranking for wiki navigation | Poor concept discovery quality | Build dedicated OKF search index | +| Sharing transport/security assumptions with current MCP HTTP mode | Weak remote security posture | Give the wiki its own service boundary and auth model | +| Coupling OKF state to `.codebaseGraph` manifest/DB | Hard-to-reason upgrades and migration risk | Use separate projection state root | +| Dual watchers over the same repo | Duplicate work and race conditions | Centralize source change detection or coordinate refresh | + +## Assumptions and Open Questions + +Assumptions: + +- The attached OKF v0.1 draft is the governing format for the first wiki + implementation. +- The wiki service is intended to exist alongside `codebaseGraph`, not replace + it. +- Backward compatibility of the current `codebaseGraph` public API matters more + than collapsing everything into one surface immediately. + +Open questions: + +1. Should the first release be a library-only projection, a local HTTP service, + or static artifact generation? +2. Does the wiki need authoring/editing in scope, or is read-only sufficient for + the first release? +3. Should OKF projection state be persisted as JSON artifacts, SQLite, or a new + LadyBugDB graph import? +4. Does search need to span only OKF concepts, or unified OKF plus code graph + results? +5. Are OKF bundles expected to live inside the same repository as code, or in + separate repositories that the wiki aggregates? + +## Bottom Line + +Confirmed current state: this repository already has a strong public API and a +working incremental graph engine, but its Markdown support is intentionally too +generic to serve as an OKF consumer out of the box. + +Recommendation: implement OKF semantics in a dedicated adjacent subsystem, +compose `CodebaseGraphApi` for code-graph retrieval, and keep the initial OKF +wiki release independent from existing graph schema changes. diff --git a/build.rs b/build.rs index a22c5c9..8ba6df5 100644 --- a/build.rs +++ b/build.rs @@ -2,4 +2,11 @@ fn main() { if !cfg!(windows) && std::env::var_os("LBUG_SHARED").is_none() { println!("cargo:rustc-link-arg=-rdynamic"); } + + // lbug's prebuilt static library includes the HTTPFS extension, which + // depends on OpenSSL without exporting its linker requirements. + if cfg!(target_os = "linux") || cfg!(windows) { + println!("cargo:rustc-link-lib=dylib=ssl"); + println!("cargo:rustc-link-lib=dylib=crypto"); + } } diff --git a/crates/xtask/src/main.rs b/crates/xtask/src/main.rs index 039f939..c5a263e 100644 --- a/crates/xtask/src/main.rs +++ b/crates/xtask/src/main.rs @@ -229,7 +229,7 @@ fn check_no_legacy_surfaces(issues: &mut Vec) { for path in [ "Cargo.toml", "src/lib.rs", - "src/cli/mod.rs", + "src/adapters/cli/mod.rs", "src/ladybug_writer.rs", ] { let text = fs::read_to_string(path).unwrap_or_default(); diff --git a/src/adapters/cli/dispatch.rs b/src/adapters/cli/dispatch.rs new file mode 100644 index 0000000..a4e1ba6 --- /dev/null +++ b/src/adapters/cli/dispatch.rs @@ -0,0 +1,295 @@ +use super::{ + format::{ + graph_architecture_queries_help, graph_context_help, graph_health_help, graph_query_help, + graph_query_helpers_help, graph_schema_help, graph_search_help, top_level_help, + }, + graph::{ + ArchitectureQueryOptions, GraphContextOptions, GraphQueryOptions, GraphSearchOptions, + HealthOptions, MetadataOutputOptions, + }, + materialization::{run_materialize, run_plan}, + mcp_command::run_mcp_command, + reinstall::run_reinstall, + setup::run_setup, + uninstall::run_uninstall, + watch::run_watch, +}; +use crate::api::{ + CodebaseGraphApi, ContextRequest, HealthRequest, OperationRequest, OperationResponse, + OutputFormat, QueryRequest, RepoSelector, SearchRequest, +}; +use std::io::Write; + +pub fn run(args: I, stdout: &mut W) -> Result<(), String> +where + I: IntoIterator, + S: Into, + W: Write, +{ + let args: Vec = args.into_iter().map(Into::into).collect(); + match args.first().map(String::as_str) { + Some("-h" | "--help") => { + writeln!(stdout, "{}", top_level_help()).map_err(|error| error.to_string())?; + Ok(()) + } + Some("install") => run_setup(&args[1..], stdout), + Some("reinstall") => run_reinstall(&args[1..], stdout), + Some("uninstall") => run_uninstall(&args[1..], stdout), + Some("build") => run_materialize(&args[1..], stdout), + Some("plan") => run_plan(&args[1..], stdout), + Some("watch") => run_watch(&args[1..], stdout), + Some("check-health") => run_graph_health(&args[1..], stdout), + Some("schema") => run_graph_schema(&args[1..], stdout), + Some("query-helpers") => run_graph_query_helpers(&args[1..], stdout), + Some("codebase-architecture-queries") => run_graph_architecture_queries(&args[1..], stdout), + Some("codebase-search") => run_graph_search(&args[1..], stdout), + Some("codebase-context") => run_graph_context(&args[1..], stdout), + Some("graph-query") => run_graph_query(&args[1..], stdout), + Some("mcp") => run_mcp_command(&args[1..], stdout), + Some(command) => Err(format!( + "unknown command: {command}\n\n{}", + top_level_help() + )), + None => { + writeln!(stdout, "{}", top_level_help()).map_err(|error| error.to_string())?; + Ok(()) + } + } +} + +fn run_graph_health(args: &[String], stdout: &mut W) -> Result<(), String> { + let options = HealthOptions::parse(args)?; + if options.help { + writeln!(stdout, "{}", graph_health_help()).map_err(|error| error.to_string())?; + return Ok(()); + } + let response = execute_api_request(OperationRequest::Health(HealthRequest { + repo: repo_selector_from_options( + options.repo_root, + options.config, + options.db, + options.manifest, + ), + refresh_status: None, + output_format: output_format(options.json), + }))?; + write_api_response(stdout, &response.payload, &options.json, false) +} + +fn run_graph_schema(args: &[String], stdout: &mut W) -> Result<(), String> { + let options = MetadataOutputOptions::parse(args, "schema")?; + if options.help { + writeln!(stdout, "{}", graph_schema_help()).map_err(|error| error.to_string())?; + return Ok(()); + } + let response = execute_api_request(OperationRequest::Catalog { + kind: "schema".to_string(), + group: None, + output_format: output_format(options.format == "json"), + })?; + write_api_response( + stdout, + &response.payload, + &options.format.eq("json"), + options.pretty, + ) +} + +fn run_graph_query_helpers(args: &[String], stdout: &mut W) -> Result<(), String> { + let options = MetadataOutputOptions::parse(args, "query-helpers")?; + if options.help { + writeln!(stdout, "{}", graph_query_helpers_help()).map_err(|error| error.to_string())?; + return Ok(()); + } + let response = execute_api_request(OperationRequest::Catalog { + kind: "query-helpers".to_string(), + group: None, + output_format: output_format(options.format == "json"), + })?; + write_api_response( + stdout, + &response.payload, + &options.format.eq("json"), + options.pretty, + ) +} + +fn run_graph_architecture_queries(args: &[String], stdout: &mut W) -> Result<(), String> { + let options = ArchitectureQueryOptions::parse(args)?; + if options.output.help { + writeln!(stdout, "{}", graph_architecture_queries_help()) + .map_err(|error| error.to_string())?; + return Ok(()); + } + let response = execute_api_request(OperationRequest::Catalog { + kind: "architecture-queries".to_string(), + group: options.group, + output_format: output_format(options.output.format == "json"), + })?; + write_api_response( + stdout, + &response.payload, + &options.output.format.eq("json"), + options.output.pretty, + ) +} + +fn run_graph_search(args: &[String], stdout: &mut W) -> Result<(), String> { + let options = GraphSearchOptions::parse(args)?; + if options.output.help { + writeln!(stdout, "{}", graph_search_help()).map_err(|error| error.to_string())?; + return Ok(()); + } + let response = execute_api_request(OperationRequest::Search(SearchRequest { + repo: repo_selector_from_options( + options.repo_root, + options.config, + options.db, + options.manifest, + ), + query: options.query, + profile: options.profile, + limit: options.limit, + budget: options.budget, + context_limit: options.context_limit, + max_depth: options.max_depth, + detail: options.detail, + output_format: output_format(options.output.format == "json"), + }))?; + write_api_response( + stdout, + &response.payload, + &options.output.format.eq("json"), + options.output.pretty, + ) +} + +fn run_graph_context(args: &[String], stdout: &mut W) -> Result<(), String> { + let options = GraphContextOptions::parse(args)?; + if options.search.output.help { + writeln!(stdout, "{}", graph_context_help()).map_err(|error| error.to_string())?; + return Ok(()); + } + let response = execute_api_request(OperationRequest::Context(ContextRequest { + repo: repo_selector_from_options( + options.search.repo_root, + options.search.config, + options.search.db, + options.search.manifest, + ), + query: if options.search.query.trim().is_empty() { + None + } else { + Some(options.search.query) + }, + profile: options.search.profile, + limit: options.search.limit, + budget: options.search.budget, + context_limit: options.search.context_limit, + max_depth: options.search.max_depth, + detail: options.search.detail, + node_id: options.node_id, + node_type: options.node_type, + output_format: output_format(options.search.output.format == "json"), + }))?; + write_api_response( + stdout, + &response.payload, + &options.search.output.format.eq("json"), + options.search.output.pretty, + ) +} + +fn run_graph_query(args: &[String], stdout: &mut W) -> Result<(), String> { + let options = GraphQueryOptions::parse(args)?; + if options.help { + writeln!(stdout, "{}", graph_query_help()).map_err(|error| error.to_string())?; + return Ok(()); + } + let response = execute_api_request(OperationRequest::Query(QueryRequest { + repo: repo_selector_from_options( + options.repo_root, + options.config, + options.db, + options.manifest, + ), + statement: options.statement, + parameters: serde_json::Value::Object(options.parameters), + limit: options.limit, + output_format: output_format(options.json), + }))?; + write_api_response(stdout, &response.payload, &options.json, false) +} + +fn execute_api_request(request: OperationRequest) -> Result { + CodebaseGraphApi::new() + .execute_operation(&request) + .map_err(|error| error.message) +} + +fn output_format(is_json: bool) -> OutputFormat { + if is_json { + OutputFormat::Typed + } else { + OutputFormat::Block + } +} + +fn write_api_response( + stdout: &mut W, + payload: &serde_json::Value, + is_json: &bool, + pretty: bool, +) -> Result<(), String> { + if *is_json { + let text = if pretty { + serde_json::to_string_pretty(payload).map_err(|error| error.to_string())? + } else { + serde_json::to_string(payload).map_err(|error| error.to_string())? + }; + writeln!(stdout, "{text}").map_err(|error| error.to_string()) + } else { + let text = payload + .get("text") + .and_then(serde_json::Value::as_str) + .ok_or_else(|| "block response did not contain text".to_string())?; + write!(stdout, "{text}").map_err(|error| error.to_string()) + } +} + +fn repo_selector_from_options( + repo_root: Option, + config_path: Option, + db_path: Option, + manifest_path: Option, +) -> RepoSelector { + RepoSelector { + repo_root, + config_path, + db_path, + manifest_path, + } +} + +pub fn error_exit_code(error: &str) -> i32 { + if error.starts_with("graph_query is read-only; blocked keyword:") + || error.starts_with("graph_query accepts one read-only statement at a time") + || error.starts_with("graph_query requires a non-empty statement") + || error.starts_with("graph_query parameters must be a JSON object") + || error.starts_with("graph-query --parameters must be a JSON object") + || error.starts_with("failed to resolve repo root") + || error.starts_with("Repository root may not be inside") + || error.starts_with("unknown install option:") + || error.starts_with("unknown reinstall option:") + || error.starts_with("--mcp-client must be") + || error.starts_with("--mcp-client requires") + || error.starts_with("unsupported MCP client:") + || error.starts_with("MCP install scope must be") + || error.starts_with("--instructions-target must be") + || error.starts_with("--instructions-target requires") + { + 2 + } else { + 1 + } +} diff --git a/src/cli/format/help.rs b/src/adapters/cli/format/help.rs similarity index 93% rename from src/cli/format/help.rs rename to src/adapters/cli/format/help.rs index 0ec8f5c..198d88a 100644 --- a/src/cli/format/help.rs +++ b/src/adapters/cli/format/help.rs @@ -1,64 +1,64 @@ -pub(in crate::cli) fn top_level_help() -> &'static str { +pub(in crate::adapters::cli) fn top_level_help() -> &'static str { "codebase-graph native CLI\n\nUSAGE:\n codebase-graph [options]\n\nCOMMANDS:\n install Perform first-time graph setup and write .codebaseGraph/config.json\n reinstall Remove existing graph state and run install again\n uninstall Remove repo graph state, instructions, and MCP registrations\n build Materialize a graph through the Rust native engine\n plan Preview files that would rebuild, delete, skip, or ignore\n watch Watch for file changes and refresh after a debounce window\n check-health Check whether the native graph database is readable\n schema Return ontology schema, indexes, profiles, and helpers\n query-helpers Return named read-only graph query helpers\n codebase-architecture-queries Return the architecture-discovery query catalog\n codebase-search Search the code graph with compact context\n codebase-context Return compact graph context\n graph-query Execute a restricted read-only graph query\n mcp Serve codebaseGraph MCP over stdio or HTTP\n\nRun `codebase-graph --help` for command options." } -pub(in crate::cli) fn mcp_help() -> &'static str { +pub(crate) fn mcp_help() -> &'static str { "codebase-graph mcp\n\nUSAGE:\n codebase-graph mcp install [--client ] [--scope ] [--config-path ] [--client-config-path ] [--dry-run] [--json]\n codebase-graph mcp start [--repo-root ] [--config ] [--db ] [--manifest ]\n codebase-graph mcp http [--repo-root ] [--config ] [--db ] [--manifest ] [--host ] [--port ] [--path ] [--allow-remote] [--auth-token |--auth-token-env ]\n\nOPTIONS:\n --repo-root Repository root override; auto-detected when omitted\n --config Setup config path; defaults to .codebaseGraph/config.json\n --db Ladybug database path override\n --manifest Manifest path override\n --host HTTP bind host; defaults to 127.0.0.1\n --port HTTP bind port; defaults to 8765\n --path HTTP endpoint path; defaults to /mcp\n --allow-remote Permit non-local HTTP bind when an auth token is supplied\n --auth-token Bearer token required for HTTP requests\n --auth-token-env Environment variable containing the bearer token" } -pub(in crate::cli) fn mcp_install_help() -> &'static str { +pub(in crate::adapters::cli) fn mcp_install_help() -> &'static str { "codebase-graph mcp install\n\nUSAGE:\n codebase-graph mcp install [--client ] [--scope local|user|project] [--name ] [--config-path ] [--client-config-path ] [--repo-root ] [--dry-run] [--verify] [--json]\n\nOPTIONS:\n --client codex, claude, claude-project, lmstudio, github-copilot, hermes, openclaw, generic, copilot-studio, microsoft-copilot, or all\n --scope local, user, or project; defaults to local\n --name MCP server name; defaults to codebase_graph_\n --config-path Path to .codebaseGraph/config.json\n --client-config-path Override the target MCP client config path\n --repo-root Repository root override; auto-detected when omitted\n --dry-run Show install action without writing files or invoking CLIs\n --verify Accepted for compatibility\n --json Emit JSON output" } -pub(in crate::cli) fn materialize_help() -> &'static str { +pub(in crate::adapters::cli) fn materialize_help() -> &'static str { "codebase-graph build\n\nUSAGE:\n codebase-graph build [--source-root |--repo-root ] [--db ] [--manifest ] [--mode full|changed] [--json]\n codebase-graph build --native-request [--manifest ] [--json]\n\nOPTIONS:\n --source-root Repository or source root override; auto-detected when omitted\n --repo-root Alias for --source-root\n --db Ladybug database path; defaults under .codebaseGraph\n --manifest Manifest path; defaults under .codebaseGraph\n --mode full or changed; defaults to changed\n --no-git Disable Git file discovery and scan the filesystem\n --git-diff Materialize files from git diff plus untracked files\n --git-base Revision used by --git-diff; defaults to HEAD\n --include Include only paths matching the glob; repeatable\n --exclude Exclude paths matching the glob; repeatable\n --parallel Parse independent files concurrently; default behavior\n --single-thread Disable concurrent parsing and build partitions serially\n --progress Include progress events in JSON output\n --no-fts Skip FTS extension loading and index creation\n --no-semantic-enrichment Skip semantic enrichment\n --semantic-provider-mode local_only only; provider-backed modes are not supported by Rust-only production\n --native-request JSON NativeSyntaxMaterializationRequest payload\n --json Emit JSON output" } -pub(in crate::cli) fn plan_help() -> &'static str { +pub(in crate::adapters::cli) fn plan_help() -> &'static str { "codebase-graph plan\n\nUSAGE:\n codebase-graph plan [--source-root |--repo-root ] [--manifest ] [--mode full|changed] [--json]\n\nOPTIONS:\n --source-root Repository or source root override; auto-detected when omitted\n --repo-root Alias for --source-root\n --manifest Manifest path; defaults under .codebaseGraph\n --mode full or changed; defaults to changed\n --no-git Disable Git file discovery and scan the filesystem\n --git-diff Plan files from git diff plus untracked files\n --git-base Revision used by --git-diff; defaults to HEAD\n --include Include only paths matching the glob; repeatable\n --exclude Exclude paths matching the glob; repeatable\n --native-request JSON NativeSyntaxMaterializationRequest payload\n --json Emit JSON output" } -pub(in crate::cli) fn watch_help() -> &'static str { +pub(in crate::adapters::cli) fn watch_help() -> &'static str { "codebase-graph watch\n\nUSAGE:\n codebase-graph watch [--source-root |--repo-root ] [--mode full|changed] [--watch-backend auto|native|poll] [--poll-ms ] [--debounce-ms ]\n\nOPTIONS:\n --source-root Repository or source root override; auto-detected when omitted\n --repo-root Alias for --source-root\n --mode full or changed; defaults to changed\n --watch-backend auto, native, or poll; defaults to auto\n --poll-ms Poll interval for poll backend or auto fallback; defaults to 500\n --debounce-ms Quiet-window debounce interval in milliseconds; defaults to 250\n --max-iterations Stop after n refreshes, useful for tests\n --once Run one refresh immediately and exit\n --no-git Disable Git file discovery and scan the filesystem\n --git-diff Refresh files from git diff plus untracked files\n --git-base Revision used by --git-diff; defaults to HEAD\n --include Include only paths matching the glob; repeatable\n --exclude Exclude paths matching the glob; repeatable\n --parallel Parse independent files concurrently; default behavior\n --single-thread Disable concurrent parsing and build partitions serially\n --progress Include progress events in JSON output" } -pub(in crate::cli) fn setup_help() -> &'static str { +pub(in crate::adapters::cli) fn setup_help() -> &'static str { "codebase-graph install\n\nUSAGE:\n codebase-graph install [--mode full|changed] [--mcp-client ] [--mcp-config-path ] [--skip-mcp-config] [--dry-run] [--instructions-target auto|agents|claude|skip] [--json]\n\nOPTIONS:\n --repo-root Repository root override; auto-detected when omitted\n --mode full or changed; defaults to changed\n --mcp-client codex, claude, claude-project, lmstudio, github-copilot, hermes, openclaw, generic, copilot-studio, microsoft-copilot, or none\n --mcp-config-path Override MCP client config path\n --skip-mcp-config Do not write MCP client config\n --dry-run Report install changes without writing repo or client state\n --instructions-target auto, agents, claude, or skip\n --no-fts Skip FTS extension loading and index creation\n --no-semantic-enrichment Skip semantic enrichment\n --semantic-provider-mode local_only only; provider-backed modes are not supported by Rust-only production\n --json Emit JSON output" } -pub(in crate::cli) fn reinstall_help() -> &'static str { +pub(in crate::adapters::cli) fn reinstall_help() -> &'static str { "codebase-graph reinstall\n\nRemove existing graph state and run install again.\n\nUSAGE:\n codebase-graph reinstall [--mode full|changed] [--mcp-client ] [--mcp-config-path ] [--skip-mcp-config] [--dry-run] [--instructions-target auto|agents|claude|skip] [--json]\n\nOPTIONS:\n --repo-root Repository root override; auto-detected when omitted\n --mode full or changed; defaults to changed\n --mcp-client codex, claude, claude-project, lmstudio, github-copilot, hermes, openclaw, generic, copilot-studio, microsoft-copilot, or none\n --mcp-config-path Override MCP client config path\n --skip-mcp-config Do not write MCP client config\n --dry-run Report reinstall changes without writing repo or client state\n --instructions-target auto, agents, claude, or skip\n --no-fts Skip FTS extension loading and index creation\n --no-semantic-enrichment Skip semantic enrichment\n --semantic-provider-mode local_only only; provider-backed modes are not supported by Rust-only production\n --json Emit JSON output" } -pub(in crate::cli) fn uninstall_help() -> &'static str { +pub(in crate::adapters::cli) fn uninstall_help() -> &'static str { "codebase-graph uninstall\n\nUSAGE:\n codebase-graph uninstall [--repo-root ] [--config ] [--mcp-client |all] [--client-config-path ] [--dry-run] [--json]\n\nOPTIONS:\n --repo-root Repository root override; auto-detected when omitted\n --config Setup config path; defaults to .codebaseGraph/config.json\n --mcp-client Client registration to remove; defaults to all supported clients\n --client-config-path Override a single target MCP client config path\n --dry-run Report removals without deleting files or updating configs\n --json Emit JSON output" } -pub(in crate::cli) fn graph_health_help() -> &'static str { +pub(in crate::adapters::cli) fn graph_health_help() -> &'static str { "codebase-graph check-health\n\nUSAGE:\n codebase-graph check-health [--repo-root ] [--config ] [--db ] [--manifest ] [--json]\n\nOPTIONS:\n --repo-root Repository root override; auto-detected when omitted\n --config Setup config path; defaults to .codebaseGraph/config.json\n --db Ladybug database path override\n --manifest Manifest path override\n --json Emit JSON output" } -pub(in crate::cli) fn graph_schema_help() -> &'static str { +pub(in crate::adapters::cli) fn graph_schema_help() -> &'static str { "codebase-graph schema\n\nUSAGE:\n codebase-graph schema [--format json|block] [--json] [--pretty]\n\nOPTIONS:\n --format block or json; defaults to block\n --json Emit compact JSON output\n --pretty Pretty-print JSON output" } -pub(in crate::cli) fn graph_query_helpers_help() -> &'static str { +pub(in crate::adapters::cli) fn graph_query_helpers_help() -> &'static str { "codebase-graph query-helpers\n\nUSAGE:\n codebase-graph query-helpers [--format json|block] [--json] [--pretty]\n\nOPTIONS:\n --format block or json; defaults to block\n --json Emit compact JSON output\n --pretty Pretty-print JSON output" } -pub(in crate::cli) fn graph_architecture_queries_help() -> &'static str { +pub(in crate::adapters::cli) fn graph_architecture_queries_help() -> &'static str { "codebase-graph codebase-architecture-queries\n\nUSAGE:\n codebase-graph codebase-architecture-queries [--group ] [--format json|block] [--json] [--pretty]\n\nOPTIONS:\n --group Optional architecture query group to return\n --format block or json; defaults to block\n --json Emit compact JSON output\n --pretty Pretty-print JSON output" } -pub(in crate::cli) fn graph_search_help() -> &'static str { +pub(in crate::adapters::cli) fn graph_search_help() -> &'static str { "codebase-graph codebase-search\n\nUSAGE:\n codebase-graph codebase-search [--repo-root ] [--config ] [--db ] [--manifest ] [--limit ] [--profile ] [--detail standard|slim] [--format json|block] [--json]\n\nOPTIONS:\n Search query\n --limit Maximum search hits; defaults to 3\n --profile Context profile label; defaults to brief\n --budget Context budget retained in output payload; defaults to 600\n --context-limit Context item limit retained for compatibility\n --detail standard or slim; defaults to standard\n --repo-root Repository root override; auto-detected when omitted\n --config Setup config path; defaults to .codebaseGraph/config.json\n --db Ladybug database path override\n --manifest Manifest path override\n --format block or json; defaults to block\n --json Emit compact JSON output" } -pub(in crate::cli) fn graph_context_help() -> &'static str { +pub(in crate::adapters::cli) fn graph_context_help() -> &'static str { "codebase-graph codebase-context\n\nUSAGE:\n codebase-graph codebase-context [query] [--node-id --node-type ] [--repo-root ] [--config ] [--db ] [--manifest ] [--limit ] [--context-limit ] [--profile ] [--detail standard|slim] [--format json|block] [--json]\n\nOPTIONS:\n [query] Search query used when explicit node lookup is not supplied\n --node-id Explicit graph node id\n --node-type Explicit graph node type\n --limit Maximum search hits in query mode; defaults to 3\n --context-limit Maximum explicit context rows; defaults to 3\n --profile Context profile label; defaults to brief\n --budget Context budget retained in output payload; defaults to 600\n --detail standard or slim; defaults to standard\n --repo-root Repository root override; auto-detected when omitted\n --config Setup config path; defaults to .codebaseGraph/config.json\n --db Ladybug database path override\n --manifest Manifest path override\n --format block or json; defaults to block\n --json Emit compact JSON output" } -pub(in crate::cli) fn metadata_help(command_name: &str) -> &'static str { +pub(in crate::adapters::cli) fn metadata_help(command_name: &str) -> &'static str { match command_name { "schema" => graph_schema_help(), "query-helpers" => graph_query_helpers_help(), @@ -69,6 +69,6 @@ pub(in crate::cli) fn metadata_help(command_name: &str) -> &'static str { } } -pub(in crate::cli) fn graph_query_help() -> &'static str { +pub(in crate::adapters::cli) fn graph_query_help() -> &'static str { "codebase-graph graph-query\n\nUSAGE:\n codebase-graph graph-query [--repo-root ] [--config ] [--db ] [--manifest ] [--limit ] [--parameters ] [--json]\n\nOPTIONS:\n Restricted read-only Cypher statement\n --parameters JSON object with named query parameters\n --limit Maximum rows to return; defaults to 100 and caps at 1000\n --repo-root Repository root override; auto-detected when omitted\n --config Setup config path; defaults to .codebaseGraph/config.json\n --db Ladybug database path override\n --manifest Manifest path override\n --json Emit JSON output" } diff --git a/src/adapters/cli/format/mod.rs b/src/adapters/cli/format/mod.rs new file mode 100644 index 0000000..f0d1482 --- /dev/null +++ b/src/adapters/cli/format/mod.rs @@ -0,0 +1,9 @@ +mod help; + +pub(crate) use help::mcp_help; +pub(in crate::adapters::cli) use help::{ + graph_architecture_queries_help, graph_context_help, graph_health_help, graph_query_help, + graph_query_helpers_help, graph_schema_help, graph_search_help, materialize_help, + mcp_install_help, metadata_help, plan_help, reinstall_help, setup_help, top_level_help, + uninstall_help, watch_help, +}; diff --git a/src/adapters/cli/graph/mod.rs b/src/adapters/cli/graph/mod.rs new file mode 100644 index 0000000..4162259 --- /dev/null +++ b/src/adapters/cli/graph/mod.rs @@ -0,0 +1,7 @@ +mod options; + +pub(crate) use options::HealthOptions; +pub(crate) use options::{ + ArchitectureQueryOptions, GraphContextOptions, GraphQueryOptions, GraphSearchOptions, + MetadataOutputOptions, +}; diff --git a/src/cli/graph/options.rs b/src/adapters/cli/graph/options.rs similarity index 79% rename from src/cli/graph/options.rs rename to src/adapters/cli/graph/options.rs index 2a91b77..65c36cb 100644 --- a/src/cli/graph/options.rs +++ b/src/adapters/cli/graph/options.rs @@ -1,21 +1,24 @@ -use crate::cli::{ - format::{graph_health_help, graph_query_help, graph_search_help, metadata_help}, - util::{parse_usize_arg, required_arg}, +use crate::adapters::{ + cli::{ + format::{graph_health_help, graph_query_help, graph_search_help, metadata_help}, + util::parse_usize_arg, + }, + required_arg, }; use std::path::PathBuf; #[derive(Debug)] -pub(in crate::cli) struct HealthOptions { - pub(in crate::cli) repo_root: Option, - pub(in crate::cli) config: Option, - pub(in crate::cli) db: Option, - pub(in crate::cli) manifest: Option, - pub(in crate::cli) help: bool, - pub(in crate::cli) json: bool, +pub(crate) struct HealthOptions { + pub(crate) repo_root: Option, + pub(crate) config: Option, + pub(crate) db: Option, + pub(crate) manifest: Option, + pub(crate) help: bool, + pub(crate) json: bool, } impl HealthOptions { - pub(in crate::cli) fn parse(args: &[String]) -> Result { + pub(crate) fn parse(args: &[String]) -> Result { let mut options = Self { repo_root: None, config: None, @@ -76,20 +79,20 @@ impl HealthOptions { } #[derive(Debug)] -pub(in crate::cli) struct GraphQueryOptions { - pub(in crate::cli) statement: String, - pub(in crate::cli) parameters: serde_json::Map, - pub(in crate::cli) limit: usize, - pub(in crate::cli) repo_root: Option, - pub(in crate::cli) config: Option, - pub(in crate::cli) db: Option, - pub(in crate::cli) manifest: Option, - pub(in crate::cli) help: bool, - pub(in crate::cli) json: bool, +pub(crate) struct GraphQueryOptions { + pub(crate) statement: String, + pub(crate) parameters: serde_json::Map, + pub(crate) limit: usize, + pub(crate) repo_root: Option, + pub(crate) config: Option, + pub(crate) db: Option, + pub(crate) manifest: Option, + pub(crate) help: bool, + pub(crate) json: bool, } impl GraphQueryOptions { - pub(in crate::cli) fn parse(args: &[String]) -> Result { + pub(crate) fn parse(args: &[String]) -> Result { let mut statement = None; let mut parameters = serde_json::Map::new(); let mut limit = 100_usize; @@ -126,12 +129,6 @@ impl GraphQueryOptions { limit = value .parse::() .map_err(|error| format!("--limit must be an integer: {error}"))?; - if limit == 0 { - return Err("graph-query limit must be greater than zero".to_string()); - } - if limit > 1000 { - return Err("graph-query limit must be 1000 or less".to_string()); - } index += 2; } "--repo-root" | "--source-root" => { @@ -214,10 +211,7 @@ impl GraphQueryOptions { json, }); } - let statement = statement - .map(|value| value.trim().to_string()) - .filter(|value| !value.is_empty()) - .ok_or_else(|| "graph-query requires a non-empty statement".to_string())?; + let statement = statement.unwrap_or_default(); Ok(Self { statement, parameters, @@ -233,14 +227,14 @@ impl GraphQueryOptions { } #[derive(Debug)] -pub(in crate::cli) struct MetadataOutputOptions { - pub(in crate::cli) format: String, - pub(in crate::cli) pretty: bool, - pub(in crate::cli) help: bool, +pub(crate) struct MetadataOutputOptions { + pub(crate) format: String, + pub(crate) pretty: bool, + pub(crate) help: bool, } impl MetadataOutputOptions { - pub(in crate::cli) fn parse(args: &[String], command_name: &str) -> Result { + pub(crate) fn parse(args: &[String], command_name: &str) -> Result { let mut options = Self { format: "block".to_string(), pretty: false, @@ -284,13 +278,13 @@ impl MetadataOutputOptions { } #[derive(Debug)] -pub(in crate::cli) struct ArchitectureQueryOptions { - pub(in crate::cli) output: MetadataOutputOptions, - pub(in crate::cli) group: Option, +pub(crate) struct ArchitectureQueryOptions { + pub(in crate::adapters::cli) output: MetadataOutputOptions, + pub(in crate::adapters::cli) group: Option, } impl ArchitectureQueryOptions { - pub(in crate::cli) fn parse(args: &[String]) -> Result { + pub(in crate::adapters::cli) fn parse(args: &[String]) -> Result { let mut metadata_args = Vec::new(); let mut group = None; let mut index = 0; @@ -317,23 +311,23 @@ impl ArchitectureQueryOptions { } #[derive(Debug)] -pub(in crate::cli) struct GraphSearchOptions { - pub(in crate::cli) query: String, - pub(in crate::cli) limit: usize, - pub(in crate::cli) profile: String, - pub(in crate::cli) budget: usize, - pub(in crate::cli) context_limit: usize, - pub(in crate::cli) max_depth: Option, - pub(in crate::cli) detail: String, - pub(in crate::cli) repo_root: Option, - pub(in crate::cli) config: Option, - pub(in crate::cli) db: Option, - pub(in crate::cli) manifest: Option, - pub(in crate::cli) output: MetadataOutputOptions, +pub(crate) struct GraphSearchOptions { + pub(crate) query: String, + pub(crate) limit: usize, + pub(crate) profile: String, + pub(crate) budget: usize, + pub(crate) context_limit: usize, + pub(crate) max_depth: Option, + pub(crate) detail: String, + pub(crate) repo_root: Option, + pub(crate) config: Option, + pub(crate) db: Option, + pub(crate) manifest: Option, + pub(crate) output: MetadataOutputOptions, } impl GraphSearchOptions { - pub(in crate::cli) fn parse(args: &[String]) -> Result { + pub(crate) fn parse(args: &[String]) -> Result { let mut query = None; let mut limit = 3_usize; let mut profile = "brief".to_string(); @@ -362,9 +356,6 @@ impl GraphSearchOptions { } "--limit" => { limit = parse_usize_arg(args, index, "--limit")?; - if limit == 0 { - return Err("Search limit must be greater than zero".to_string()); - } index += 2; } "--profile" => { @@ -401,9 +392,6 @@ impl GraphSearchOptions { } "--detail" => { let value = required_arg(args, index, "--detail")?; - if value != "standard" && value != "slim" { - return Err("--detail must be standard or slim".to_string()); - } detail = value.to_string(); index += 2; } @@ -462,10 +450,7 @@ impl GraphSearchOptions { output, }); } - let query = query - .map(|value| value.trim().to_string()) - .filter(|value| !value.is_empty()) - .ok_or_else(|| "Search query must not be empty".to_string())?; + let query = query.unwrap_or_default(); Ok(Self { query, limit, @@ -484,14 +469,14 @@ impl GraphSearchOptions { } #[derive(Debug)] -pub(in crate::cli) struct GraphContextOptions { - pub(in crate::cli) search: GraphSearchOptions, - pub(in crate::cli) node_id: Option, - pub(in crate::cli) node_type: Option, +pub(crate) struct GraphContextOptions { + pub(in crate::adapters::cli) search: GraphSearchOptions, + pub(in crate::adapters::cli) node_id: Option, + pub(in crate::adapters::cli) node_type: Option, } impl GraphContextOptions { - pub(in crate::cli) fn parse(args: &[String]) -> Result { + pub(in crate::adapters::cli) fn parse(args: &[String]) -> Result { let mut search_args = Vec::new(); let mut node_id = None; let mut node_type = None; @@ -512,20 +497,9 @@ impl GraphContextOptions { } } } - if node_id.is_some() - && node_type.is_some() - && !search_args.iter().any(|arg| arg == "-h" || arg == "--help") - { - search_args.push("__explicit_context__".to_string()); - } let mut search = GraphSearchOptions::parse(&search_args)?; if node_id.is_some() && node_type.is_some() { search.query.clear(); - } else if node_id.is_some() || node_type.is_some() { - return Err( - "codebase-context explicit lookup requires both --node-id and --node-type" - .to_string(), - ); } Ok(Self { search, diff --git a/src/adapters/cli/install/command.rs b/src/adapters/cli/install/command.rs new file mode 100644 index 0000000..27da992 --- /dev/null +++ b/src/adapters/cli/install/command.rs @@ -0,0 +1,42 @@ +use super::{attach_install_verification, options::McpInstallOptions}; +use crate::adapters::cli::format::mcp_install_help; +use crate::api::{ + CodebaseGraphApi, McpInstallRequest, OperationRequest, OutputFormat, RepoSelector, +}; +use std::io::Write; + +pub(in crate::adapters::cli) fn run_mcp_install( + args: &[String], + stdout: &mut W, +) -> Result<(), String> { + let options = McpInstallOptions::parse(args)?; + if options.help { + writeln!(stdout, "{}", mcp_install_help()).map_err(|error| error.to_string())?; + return Ok(()); + } + let request = OperationRequest::InstallMcp(McpInstallRequest { + repo: RepoSelector { + repo_root: options.repo_root.clone(), + config_path: options.config_path.clone(), + db_path: None, + manifest_path: None, + }, + client: options.client.clone(), + scope: options.scope.clone(), + name: options.name.clone(), + client_config_path: options.client_config_path.clone(), + dry_run: options.dry_run, + output_format: OutputFormat::Typed, + }); + let response = CodebaseGraphApi::new() + .execute_operation(&request) + .map_err(|error| error.message)?; + let payload = attach_install_verification(response.payload, &options); + writeln!( + stdout, + "{}", + serde_json::to_string_pretty(&payload).map_err(|error| error.to_string())? + ) + .map_err(|error| error.to_string())?; + Ok(()) +} diff --git a/src/adapters/cli/install/mod.rs b/src/adapters/cli/install/mod.rs new file mode 100644 index 0000000..cd170d1 --- /dev/null +++ b/src/adapters/cli/install/mod.rs @@ -0,0 +1,7 @@ +mod command; +mod options; +mod verify; + +pub(in crate::adapters::cli) use command::run_mcp_install; +pub(in crate::adapters::cli) use options::McpInstallOptions; +pub(in crate::adapters::cli) use verify::attach_install_verification; diff --git a/src/cli/install/options.rs b/src/adapters/cli/install/options.rs similarity index 60% rename from src/cli/install/options.rs rename to src/adapters/cli/install/options.rs index e982ba9..95a5eaa 100644 --- a/src/cli/install/options.rs +++ b/src/adapters/cli/install/options.rs @@ -1,23 +1,22 @@ -use super::{expand_path, supported_install_clients, supported_install_clients_with_all}; -use crate::cli::{format::mcp_install_help, util::required_arg}; +use crate::adapters::{cli::format::mcp_install_help, required_arg}; use std::path::PathBuf; #[derive(Debug, Clone)] -pub(in crate::cli) struct McpInstallOptions { - pub(in crate::cli) client: String, - pub(in crate::cli) scope: String, - pub(in crate::cli) name: Option, - pub(in crate::cli) config_path: Option, - pub(in crate::cli) client_config_path: Option, - pub(in crate::cli) repo_root: Option, - pub(in crate::cli) dry_run: bool, - pub(in crate::cli) verify: bool, - pub(in crate::cli) json: bool, - pub(in crate::cli) help: bool, +pub(in crate::adapters::cli) struct McpInstallOptions { + pub(in crate::adapters::cli) client: String, + pub(in crate::adapters::cli) scope: String, + pub(in crate::adapters::cli) name: Option, + pub(in crate::adapters::cli) config_path: Option, + pub(in crate::adapters::cli) client_config_path: Option, + pub(in crate::adapters::cli) repo_root: Option, + pub(in crate::adapters::cli) dry_run: bool, + pub(in crate::adapters::cli) verify: bool, + pub(in crate::adapters::cli) json: bool, + pub(in crate::adapters::cli) help: bool, } impl McpInstallOptions { - pub(in crate::cli) fn parse(args: &[String]) -> Result { + pub(in crate::adapters::cli) fn parse(args: &[String]) -> Result { let mut options = Self { client: "codex".to_string(), scope: "local".to_string(), @@ -39,25 +38,10 @@ impl McpInstallOptions { } "--client" => { options.client = required_arg(args, index, "--client")?.to_string(); - if options.client != "all" - && !supported_install_clients().contains(&options.client.as_str()) - { - return Err(format!( - "Unsupported MCP client: {}. Supported clients: {}", - options.client, - supported_install_clients_with_all().join(", ") - )); - } index += 2; } "--scope" => { options.scope = required_arg(args, index, "--scope")?.to_string(); - if !matches!(options.scope.as_str(), "local" | "user" | "project") { - return Err( - "Unsupported MCP install scope: expected local, user, or project" - .to_string(), - ); - } index += 2; } "--name" => { @@ -66,11 +50,11 @@ impl McpInstallOptions { } "--config-path" => { options.config_path = - Some(expand_path(required_arg(args, index, "--config-path")?)); + Some(PathBuf::from(required_arg(args, index, "--config-path")?)); index += 2; } "--client-config-path" => { - options.client_config_path = Some(expand_path(required_arg( + options.client_config_path = Some(PathBuf::from(required_arg( args, index, "--client-config-path", @@ -79,7 +63,7 @@ impl McpInstallOptions { } "--repo-root" => { options.repo_root = - Some(expand_path(required_arg(args, index, "--repo-root")?)); + Some(PathBuf::from(required_arg(args, index, "--repo-root")?)); index += 2; } "--dry-run" => { diff --git a/src/cli/install/verify.rs b/src/adapters/cli/install/verify.rs similarity index 68% rename from src/cli/install/verify.rs rename to src/adapters/cli/install/verify.rs index 6923e8e..e95abae 100644 --- a/src/cli/install/verify.rs +++ b/src/adapters/cli/install/verify.rs @@ -1,29 +1,56 @@ -use super::{executable_in_path, McpInstallOptions, NativeMcpDescriptor}; -use crate::cli::constants::LATEST_PROTOCOL_VERSION; +use super::McpInstallOptions; +use crate::api::CodebaseGraphApi; use serde_json::json; use std::{ collections::{BTreeMap, BTreeSet}, + env, io::Write, + path::Path, process::Command, }; -pub(in crate::cli) fn attach_install_verification( +pub(in crate::adapters::cli) fn attach_install_verification( mut payload: serde_json::Value, - descriptor: &NativeMcpDescriptor, options: &McpInstallOptions, -) -> Result { - if options.verify && !options.dry_run { - payload["verification"] = verify_mcp_install(descriptor, &options.client); +) -> serde_json::Value { + if !options.verify || options.dry_run { + return payload; + } + if let Some(results) = payload + .get_mut("results") + .and_then(serde_json::Value::as_array_mut) + { + for result in results { + attach_result_verification(result); + } + } else { + attach_result_verification(&mut payload); + } + payload +} + +fn attach_result_verification(result: &mut serde_json::Value) { + if result.get("error").is_some() { + return; } - Ok(payload) + let client = result + .get("client") + .and_then(serde_json::Value::as_str) + .unwrap_or("generic"); + let descriptor = result.get("descriptor").cloned().unwrap_or_default(); + result["verification"] = verify_mcp_install(&descriptor, client); } -pub(in crate::cli) fn verify_mcp_install( - descriptor: &NativeMcpDescriptor, +pub(in crate::adapters::cli) fn verify_mcp_install( + descriptor: &serde_json::Value, client: &str, ) -> serde_json::Value { let stdio = verify_stdio(descriptor); - let visibility = verify_client_visibility(client, &descriptor.name); + let server_name = descriptor + .get("name") + .and_then(serde_json::Value::as_str) + .unwrap_or_default(); + let visibility = verify_client_visibility(client, server_name); json!({ "ok": stdio.get("ok").and_then(serde_json::Value::as_bool).unwrap_or(false) && visibility.get("ok").and_then(serde_json::Value::as_bool).unwrap_or(true), @@ -32,12 +59,18 @@ pub(in crate::cli) fn verify_mcp_install( }) } -pub(in crate::cli) fn verify_stdio(descriptor: &NativeMcpDescriptor) -> serde_json::Value { - let command = descriptor_command(descriptor); +pub(in crate::adapters::cli) fn verify_stdio(descriptor: &serde_json::Value) -> serde_json::Value { + let Some(command) = descriptor_command(descriptor) else { + return json!({"ok": false, "error": "install response did not contain a valid descriptor"}); + }; + let descriptor_name = descriptor + .get("name") + .and_then(serde_json::Value::as_str) + .unwrap_or_default(); let payload = [ stdio_json_rpc_message( "initialize", - json!({"protocolVersion": LATEST_PROTOCOL_VERSION}), + json!({"protocolVersion": CodebaseGraphApi::latest_mcp_protocol_version()}), 1, ), stdio_json_rpc_message("tools/list", json!({}), 2), @@ -48,7 +81,7 @@ pub(in crate::cli) fn verify_stdio(descriptor: &NativeMcpDescriptor) -> serde_js ), stdio_json_rpc_message( "tools/call", - json!({"name": "graph_search", "arguments": {"query": descriptor.name, "limit": 1}}), + json!({"name": "graph_search", "arguments": {"query": descriptor_name, "limit": 1}}), 4, ), stdio_json_rpc_message( @@ -97,7 +130,7 @@ pub(in crate::cli) fn verify_stdio(descriptor: &NativeMcpDescriptor) -> serde_js }) } -pub(in crate::cli) fn verify_client_visibility( +pub(in crate::adapters::cli) fn verify_client_visibility( client: &str, server_name: &str, ) -> serde_json::Value { @@ -125,13 +158,22 @@ pub(in crate::cli) fn verify_client_visibility( } } -pub(in crate::cli) fn descriptor_command(descriptor: &NativeMcpDescriptor) -> Vec { - let mut command = vec![descriptor.command.clone()]; - command.extend(descriptor.args.clone()); - command +pub(in crate::adapters::cli) fn descriptor_command( + descriptor: &serde_json::Value, +) -> Option> { + let mut command = vec![descriptor.get("command")?.as_str()?.to_string()]; + command.extend( + descriptor + .get("args")? + .as_array()? + .iter() + .map(|value| value.as_str().map(str::to_string)) + .collect::>>()?, + ); + Some(command) } -pub(in crate::cli) fn stdio_json_rpc_message( +pub(in crate::adapters::cli) fn stdio_json_rpc_message( method: &str, params: serde_json::Value, id: u64, @@ -146,14 +188,16 @@ pub(in crate::cli) fn stdio_json_rpc_message( + "\n" } -pub(in crate::cli) fn parse_stdio_json_lines(data: &[u8]) -> Vec { +pub(in crate::adapters::cli) fn parse_stdio_json_lines(data: &[u8]) -> Vec { String::from_utf8_lossy(data) .lines() .filter_map(|line| serde_json::from_str::(line).ok()) .collect() } -pub(in crate::cli) fn stdio_checks(responses: &[serde_json::Value]) -> BTreeMap { +pub(in crate::adapters::cli) fn stdio_checks( + responses: &[serde_json::Value], +) -> BTreeMap { let mut by_id = BTreeMap::new(); for response in responses { if let Some(id) = response.get("id").and_then(serde_json::Value::as_u64) { @@ -177,7 +221,7 @@ pub(in crate::cli) fn stdio_checks(responses: &[serde_json::Value]) -> BTreeMap< .get(&1) .and_then(|value| value.pointer("/result/protocolVersion")) .and_then(serde_json::Value::as_str) - == Some(LATEST_PROTOCOL_VERSION), + == Some(CodebaseGraphApi::latest_mcp_protocol_version()), ); checks.insert( "tools_list".to_string(), @@ -208,7 +252,7 @@ pub(in crate::cli) fn stdio_checks(responses: &[serde_json::Value]) -> BTreeMap< checks } -pub(in crate::cli) fn visibility_command(client: &str) -> Option> { +pub(in crate::adapters::cli) fn visibility_command(client: &str) -> Option> { match client { "codex" => Some(vec![ "codex".to_string(), @@ -228,3 +272,13 @@ pub(in crate::cli) fn visibility_command(client: &str) -> Option> { _ => None, } } + +fn executable_in_path(executable: &str) -> bool { + let path = Path::new(executable); + if path.components().count() > 1 { + return path.is_file(); + } + env::var_os("PATH") + .map(|paths| env::split_paths(&paths).any(|dir| dir.join(executable).is_file())) + .unwrap_or(false) +} diff --git a/src/adapters/cli/materialization/command.rs b/src/adapters/cli/materialization/command.rs new file mode 100644 index 0000000..e485093 --- /dev/null +++ b/src/adapters/cli/materialization/command.rs @@ -0,0 +1,64 @@ +use crate::adapters::cli::{ + format::{materialize_help, plan_help}, + materialization_input::{materialize_request, MaterializeOptions}, +}; +use crate::api::{CodebaseGraphApi, OperationRequest, OutputFormat}; +use std::io::Write; + +pub(in crate::adapters::cli) fn run_materialize( + args: &[String], + stdout: &mut W, +) -> Result<(), String> { + let options = MaterializeOptions::parse(args)?; + if options.help { + writeln!(stdout, "{}", materialize_help()).map_err(|error| error.to_string())?; + return Ok(()); + } + let api = CodebaseGraphApi::new(); + let request = materialize_request(&options, OutputFormat::Typed); + let response = api + .execute_operation(&OperationRequest::Materialize(request)) + .map_err(|error| error.message)?; + let output = + serde_json::to_string_pretty(&response.payload).map_err(|error| error.to_string())?; + writeln!(stdout, "{output}").map_err(|error| error.to_string())?; + Ok(()) +} + +pub(in crate::adapters::cli) fn run_plan( + args: &[String], + stdout: &mut W, +) -> Result<(), String> { + let options = MaterializeOptions::parse_with_command(args, "plan")?; + if options.help { + writeln!(stdout, "{}", plan_help()).map_err(|error| error.to_string())?; + return Ok(()); + } + let api = CodebaseGraphApi::new(); + let request = materialize_request( + &options, + if options.json_output { + OutputFormat::Typed + } else { + OutputFormat::Block + }, + ); + let response = api + .execute_operation(&OperationRequest::Plan(request)) + .map_err(|error| error.message)?; + let payload = response.payload; + if options.json_output { + writeln!( + stdout, + "{}", + serde_json::to_string_pretty(&payload).map_err(|error| error.to_string())? + ) + .map_err(|error| error.to_string()) + } else { + let text = payload + .get("text") + .and_then(serde_json::Value::as_str) + .ok_or_else(|| "block response did not contain text".to_string())?; + write!(stdout, "{text}").map_err(|error| error.to_string()) + } +} diff --git a/src/adapters/cli/materialization/mod.rs b/src/adapters/cli/materialization/mod.rs new file mode 100644 index 0000000..bd85de8 --- /dev/null +++ b/src/adapters/cli/materialization/mod.rs @@ -0,0 +1,3 @@ +mod command; + +pub(in crate::adapters::cli) use command::{run_materialize, run_plan}; diff --git a/src/adapters/cli/materialization_input.rs b/src/adapters/cli/materialization_input.rs new file mode 100644 index 0000000..485b084 --- /dev/null +++ b/src/adapters/cli/materialization_input.rs @@ -0,0 +1,259 @@ +use crate::adapters::cli::format::{materialize_help, plan_help, watch_help}; +use crate::api::{MaterializationRequest, OutputFormat, RepoSelector}; +use std::path::PathBuf; + +#[derive(Clone, Debug)] +pub(crate) struct MaterializeOptions { + pub(crate) native_request: Option, + pub(crate) source_root: Option, + pub(crate) config: Option, + pub(crate) db: Option, + pub(crate) manifest: Option, + pub(crate) mode: String, + pub(crate) include_fts: bool, + pub(crate) semantic_enrichment: bool, + pub(crate) semantic_provider_mode: String, + pub(crate) use_git: bool, + pub(crate) git_diff: bool, + pub(crate) git_base: Option, + pub(crate) include_patterns: Vec, + pub(crate) exclude_patterns: Vec, + pub(crate) candidate_paths: Vec, + pub(crate) parallel: bool, + pub(crate) progress: bool, + pub(crate) help: bool, + pub(crate) json_output: bool, +} + +impl Default for MaterializeOptions { + fn default() -> Self { + Self { + native_request: None, + source_root: None, + config: None, + db: None, + manifest: None, + mode: String::new(), + include_fts: false, + semantic_enrichment: false, + semantic_provider_mode: String::new(), + use_git: false, + git_diff: false, + git_base: None, + include_patterns: Vec::new(), + exclude_patterns: Vec::new(), + candidate_paths: Vec::new(), + parallel: true, + progress: false, + help: false, + json_output: false, + } + } +} + +impl MaterializeOptions { + pub(crate) fn parse(args: &[String]) -> Result { + Self::parse_with_command(args, "build") + } + + pub(crate) fn parse_with_command(args: &[String], command_name: &str) -> Result { + let mut options = Self { + include_fts: true, + semantic_enrichment: true, + use_git: true, + ..Self::default() + }; + let mut index = 0; + while index < args.len() { + match args[index].as_str() { + "-h" | "--help" => { + options.help = true; + index += 1; + } + "--native-request" => { + let value = args + .get(index + 1) + .ok_or_else(|| "--native-request requires a path".to_string())?; + options.native_request = Some(PathBuf::from(value)); + index += 2; + } + "--source-root" | "--repo-root" => { + let value = args + .get(index + 1) + .ok_or_else(|| format!("{} requires a path", args[index]))?; + options.source_root = Some(PathBuf::from(value)); + index += 2; + } + "--db" => { + let value = args + .get(index + 1) + .ok_or_else(|| "--db requires a path".to_string())?; + options.db = Some(PathBuf::from(value)); + index += 2; + } + "--manifest" => { + let value = args + .get(index + 1) + .ok_or_else(|| "--manifest requires a path".to_string())?; + options.manifest = Some(PathBuf::from(value)); + index += 2; + } + "--mode" => { + let value = args + .get(index + 1) + .ok_or_else(|| "--mode requires a value".to_string())?; + options.mode = value.clone(); + index += 2; + } + "--no-fts" => { + options.include_fts = false; + index += 1; + } + "--no-semantic-enrichment" => { + options.semantic_enrichment = false; + index += 1; + } + "--semantic-provider-mode" => { + let value = args + .get(index + 1) + .ok_or_else(|| "--semantic-provider-mode requires a value".to_string())?; + options.semantic_provider_mode = value.clone(); + index += 2; + } + "--no-git" => { + options.use_git = false; + index += 1; + } + "--git-diff" => { + options.git_diff = true; + index += 1; + } + "--git-base" => { + let value = args + .get(index + 1) + .ok_or_else(|| "--git-base requires a revision".to_string())?; + options.git_base = Some(value.clone()); + options.git_diff = true; + index += 2; + } + "--include" => { + let value = args + .get(index + 1) + .ok_or_else(|| "--include requires a glob pattern".to_string())?; + options.include_patterns.push(value.clone()); + index += 2; + } + "--exclude" => { + let value = args + .get(index + 1) + .ok_or_else(|| "--exclude requires a glob pattern".to_string())?; + options.exclude_patterns.push(value.clone()); + index += 2; + } + "--single-thread" => { + options.parallel = false; + index += 1; + } + "--parallel" => { + options.parallel = true; + index += 1; + } + "--progress" => { + options.progress = true; + index += 1; + } + "--json" => { + options.json_output = true; + index += 1; + } + other => { + return Err(format!( + "unknown {command_name} option: {other}\n\n{}", + materialize_like_help(command_name) + )); + } + } + } + Ok(options) + } +} + +pub(in crate::adapters::cli) fn materialize_like_help(command_name: &str) -> &'static str { + match command_name { + "plan" => plan_help(), + "watch" => watch_help(), + _ => materialize_help(), + } +} + +pub(in crate::adapters::cli) fn materialize_request( + options: &MaterializeOptions, + output_format: OutputFormat, +) -> MaterializationRequest { + MaterializationRequest { + repo: RepoSelector { + repo_root: options.source_root.clone(), + config_path: options.config.clone(), + db_path: options.db.clone(), + manifest_path: options.manifest.clone(), + }, + native_request_path: options.native_request.clone(), + source_root: options + .source_root + .as_ref() + .map(|path| path.to_string_lossy().to_string()), + mode: options.mode.clone(), + include_fts: options.include_fts, + semantic_enrichment: options.semantic_enrichment, + semantic_provider_mode: options.semantic_provider_mode.clone(), + use_git: options.use_git, + git_diff: options.git_diff, + git_base: options.git_base.clone(), + include_patterns: options.include_patterns.clone(), + exclude_patterns: options.exclude_patterns.clone(), + candidate_paths: options.candidate_paths.clone(), + parallel: options.parallel, + progress: options.progress, + output_format, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn materialize_request_maps_command_options_to_public_contract() { + let options = MaterializeOptions::parse(&[ + "--repo-root".to_string(), + "/tmp/project".to_string(), + "--db".to_string(), + "/tmp/project/graph.db".to_string(), + "--mode".to_string(), + "incremental".to_string(), + "--no-fts".to_string(), + "--git-diff".to_string(), + "--include".to_string(), + "src/**/*.rs".to_string(), + "--single-thread".to_string(), + ]) + .expect("command options should parse"); + + let request = materialize_request(&options, OutputFormat::Block); + + assert_eq!( + request.repo.repo_root.as_deref(), + Some(std::path::Path::new("/tmp/project")) + ); + assert_eq!( + request.repo.db_path.as_deref(), + Some(std::path::Path::new("/tmp/project/graph.db")) + ); + assert_eq!(request.mode, "incremental"); + assert!(!request.include_fts); + assert!(request.git_diff); + assert_eq!(request.include_patterns, ["src/**/*.rs"]); + assert!(!request.parallel); + assert_eq!(request.output_format, OutputFormat::Block); + } +} diff --git a/src/cli/mcp/commands.rs b/src/adapters/cli/mcp_command.rs similarity index 86% rename from src/cli/mcp/commands.rs rename to src/adapters/cli/mcp_command.rs index 00a66af..0fda4fe 100644 --- a/src/cli/mcp/commands.rs +++ b/src/adapters/cli/mcp_command.rs @@ -1,7 +1,7 @@ -use crate::cli::{format::mcp_help, install::run_mcp_install}; +use super::{format::mcp_help, install::run_mcp_install}; use std::io::Write; -pub(in crate::cli) fn run_mcp_command( +pub(in crate::adapters::cli) fn run_mcp_command( args: &[String], stdout: &mut W, ) -> Result<(), String> { diff --git a/src/adapters/cli/mod.rs b/src/adapters/cli/mod.rs new file mode 100644 index 0000000..6fbcfd6 --- /dev/null +++ b/src/adapters/cli/mod.rs @@ -0,0 +1,17 @@ +mod dispatch; +pub(crate) mod format; +pub(crate) mod graph; +mod install; +pub(crate) mod materialization; +mod materialization_input; +mod mcp_command; +pub(crate) mod reinstall; +pub(crate) mod setup; +pub(crate) mod uninstall; +mod util; +mod watch; + +pub use dispatch::{error_exit_code, run}; + +#[cfg(test)] +mod tests; diff --git a/src/adapters/cli/reinstall.rs b/src/adapters/cli/reinstall.rs new file mode 100644 index 0000000..059cc88 --- /dev/null +++ b/src/adapters/cli/reinstall.rs @@ -0,0 +1,46 @@ +use super::{format::reinstall_help, watch::SetupOptions}; +use crate::api::{ + CodebaseGraphApi, OperationRequest, OutputFormat, RepoSelector, RepositoryLifecycleRequest, +}; +use std::io::Write; + +pub(in crate::adapters::cli) fn run_reinstall( + args: &[String], + stdout: &mut W, +) -> Result<(), String> { + let options = SetupOptions::parse_with_help(args, "reinstall", reinstall_help())?; + if options.help { + writeln!(stdout, "{}", reinstall_help()).map_err(|error| error.to_string())?; + return Ok(()); + } + let request = RepositoryLifecycleRequest { + repo: RepoSelector { + repo_root: options.repo_root.clone(), + config_path: None, + db_path: None, + manifest_path: None, + }, + action: "reinstall".to_string(), + output_format: OutputFormat::Typed, + dry_run: options.dry_run, + mcp_client: Some(options.mcp_client.clone()), + mcp_config_path: options.mcp_config_path.clone(), + instructions_target: Some(options.instructions_target.clone()), + skip_mcp_config: options.skip_mcp_config, + mode: options.mode.clone(), + include_fts: options.include_fts, + semantic_enrichment: options.semantic_enrichment, + semantic_provider_mode: options.semantic_provider_mode.clone(), + }; + let payload = CodebaseGraphApi::new() + .execute_operation(&OperationRequest::Reinstall(request)) + .map_err(|error| error.message)? + .payload; + writeln!( + stdout, + "{}", + serde_json::to_string_pretty(&payload).map_err(|error| error.to_string())? + ) + .map_err(|error| error.to_string())?; + Ok(()) +} diff --git a/src/adapters/cli/setup.rs b/src/adapters/cli/setup.rs new file mode 100644 index 0000000..c9bf262 --- /dev/null +++ b/src/adapters/cli/setup.rs @@ -0,0 +1,43 @@ +use super::{format::setup_help, watch::SetupOptions}; +use crate::api::{ + CodebaseGraphApi, OperationRequest, OutputFormat, RepoSelector, RepositoryLifecycleRequest, +}; +use std::io::Write; + +pub(super) fn run_setup(args: &[String], stdout: &mut W) -> Result<(), String> { + let options = SetupOptions::parse(args)?; + if options.help { + writeln!(stdout, "{}", setup_help()).map_err(|error| error.to_string())?; + return Ok(()); + } + let request = RepositoryLifecycleRequest { + repo: RepoSelector { + repo_root: options.repo_root.clone(), + config_path: None, + db_path: None, + manifest_path: None, + }, + action: "setup".to_string(), + output_format: OutputFormat::Typed, + dry_run: options.dry_run, + mcp_client: Some(options.mcp_client.clone()), + mcp_config_path: options.mcp_config_path.clone(), + instructions_target: Some(options.instructions_target.clone()), + skip_mcp_config: options.skip_mcp_config, + mode: options.mode.clone(), + include_fts: options.include_fts, + semantic_enrichment: options.semantic_enrichment, + semantic_provider_mode: options.semantic_provider_mode.clone(), + }; + let output = CodebaseGraphApi::new() + .execute_operation(&OperationRequest::Setup(request)) + .map_err(|error| error.message)? + .payload; + writeln!( + stdout, + "{}", + serde_json::to_string_pretty(&output).map_err(|error| error.to_string())? + ) + .map_err(|error| error.to_string())?; + Ok(()) +} diff --git a/src/cli/tests/dispatch_materialize.rs b/src/adapters/cli/tests/dispatch_materialize.rs similarity index 96% rename from src/cli/tests/dispatch_materialize.rs rename to src/adapters/cli/tests/dispatch_materialize.rs index dc7ba64..4cb98b8 100644 --- a/src/cli/tests/dispatch_materialize.rs +++ b/src/adapters/cli/tests/dispatch_materialize.rs @@ -105,24 +105,24 @@ fn reinstall_help_is_product_command_help() { #[test] fn materialize_rejects_provider_backed_semantic_modes() { - let args = vec![ - "--semantic-provider-mode".to_string(), - "provider_first".to_string(), - ]; - let error = MaterializeOptions::parse(&args).unwrap_err(); + let error = run( + ["build", "--semantic-provider-mode", "provider_first"], + &mut Vec::new(), + ) + .unwrap_err(); - assert!(error.contains("--semantic-provider-mode must be local_only")); + assert!(error.contains("materialization semantic provider mode must be local_only")); } #[test] fn setup_rejects_provider_backed_semantic_modes() { - let args = vec![ - "--semantic-provider-mode".to_string(), - "opportunistic".to_string(), - ]; - let error = SetupOptions::parse(&args).unwrap_err(); + let error = run( + ["install", "--semantic-provider-mode", "opportunistic"], + &mut Vec::new(), + ) + .unwrap_err(); - assert!(error.contains("--semantic-provider-mode must be local_only")); + assert!(error.contains("setup semantic provider mode must be local_only")); } #[test] diff --git a/src/cli/tests/fixtures.rs b/src/adapters/cli/tests/fixtures.rs similarity index 96% rename from src/cli/tests/fixtures.rs rename to src/adapters/cli/tests/fixtures.rs index 6ebdda3..2df82eb 100644 --- a/src/cli/tests/fixtures.rs +++ b/src/adapters/cli/tests/fixtures.rs @@ -44,7 +44,7 @@ pub(super) fn test_http_options(root: PathBuf, auth_token: Option<&str>) -> McpH config: None, db: None, manifest: None, - refresh: None, + api: None, }, host: "127.0.0.1".to_string(), port: 8765, @@ -98,7 +98,8 @@ pub(super) fn watch_filter_for(root: &Path, extra_args: &[&str]) -> WatchEventFi ]; args.extend(extra_args.iter().map(|arg| arg.to_string())); let options = MaterializeOptions::parse_with_command(&args, "watch").unwrap(); - WatchEventFilter::from_options(&source_root, &options).unwrap() + let request = materialize_request(&options, crate::api::OutputFormat::Typed); + WatchEventFilter::from_request(&source_root, &request).unwrap() } pub(super) fn watch_test_event(root: &Path, kind: EventKind, paths: &[&str]) -> Event { diff --git a/src/cli/tests/graph.rs b/src/adapters/cli/tests/graph.rs similarity index 100% rename from src/cli/tests/graph.rs rename to src/adapters/cli/tests/graph.rs diff --git a/src/cli/tests/install.rs b/src/adapters/cli/tests/install.rs similarity index 100% rename from src/cli/tests/install.rs rename to src/adapters/cli/tests/install.rs diff --git a/src/cli/tests/mcp.rs b/src/adapters/cli/tests/mcp.rs similarity index 96% rename from src/cli/tests/mcp.rs rename to src/adapters/cli/tests/mcp.rs index c8e9df8..9dd105e 100644 --- a/src/cli/tests/mcp.rs +++ b/src/adapters/cli/tests/mcp.rs @@ -12,7 +12,7 @@ fn mcp_graph_query_binds_json_parameters() { config: None, db: None, manifest: None, - refresh: None, + api: None, }; let result = mcp_call_tool_result( "graph_query", @@ -106,7 +106,7 @@ fn mcp_stdio_serves_tools_and_tool_errors() { config: None, db: None, manifest: None, - refresh: None, + api: None, }; let mut output = Vec::new(); serve_mcp_stdio(&options, std::io::Cursor::new(input), &mut output).unwrap(); @@ -166,16 +166,22 @@ fn mcp_stdio_serves_tools_and_tool_errors() { #[test] fn mcp_http_rejects_remote_bind_without_auth_token() { - let error = McpHttpOptions::parse(&[ - "--host".to_string(), - "0.0.0.0".to_string(), - "--allow-remote".to_string(), - ]) + let error = McpHttpOptions::parse( + &[ + "--host".to_string(), + "0.0.0.0".to_string(), + "--allow-remote".to_string(), + ], + format::mcp_help(), + ) .unwrap_err(); assert!(error.contains("auth token")); - let local_error = - McpHttpOptions::parse(&["--host".to_string(), "0.0.0.0".to_string()]).unwrap_err(); + let local_error = McpHttpOptions::parse( + &["--host".to_string(), "0.0.0.0".to_string()], + format::mcp_help(), + ) + .unwrap_err(); assert!(local_error.contains("localhost")); } diff --git a/src/cli/tests/mod.rs b/src/adapters/cli/tests/mod.rs similarity index 81% rename from src/cli/tests/mod.rs rename to src/adapters/cli/tests/mod.rs index 8f66ada..0fe540a 100644 --- a/src/cli/tests/mod.rs +++ b/src/adapters/cli/tests/mod.rs @@ -1,5 +1,6 @@ use super::*; -use super::{build::*, install::*, mcp::*, watch::*}; +use super::{materialization_input::*, watch::*}; +use crate::adapters::mcp::*; use notify::{Event, EventKind}; use serde_json::json; use std::{ diff --git a/src/cli/tests/watch.rs b/src/adapters/cli/tests/watch.rs similarity index 100% rename from src/cli/tests/watch.rs rename to src/adapters/cli/tests/watch.rs diff --git a/src/adapters/cli/uninstall.rs b/src/adapters/cli/uninstall.rs new file mode 100644 index 0000000..391266a --- /dev/null +++ b/src/adapters/cli/uninstall.rs @@ -0,0 +1,133 @@ +use super::format::uninstall_help; +use crate::adapters::required_arg; +use crate::api::{ + CodebaseGraphApi, OperationRequest, OutputFormat, RepoSelector, RepositoryLifecycleRequest, +}; +use std::{io::Write, path::PathBuf}; + +#[derive(Debug)] +pub(in crate::adapters::cli) struct UninstallOptions { + repo_root: Option, + config: Option, + mcp_client: String, + client_config_path: Option, + dry_run: bool, + json: bool, + help: bool, +} + +impl UninstallOptions { + fn parse(args: &[String]) -> Result { + let mut options = Self { + repo_root: None, + config: None, + mcp_client: "all".to_string(), + client_config_path: None, + dry_run: false, + json: false, + help: false, + }; + let mut index = 0; + while index < args.len() { + match args[index].as_str() { + "-h" | "--help" => { + options.help = true; + index += 1; + } + "--repo-root" | "--source-root" => { + options.repo_root = + Some(PathBuf::from(required_arg(args, index, "--repo-root")?)); + index += 2; + } + "--config" => { + options.config = Some(PathBuf::from(required_arg(args, index, "--config")?)); + index += 2; + } + "--mcp-client" => { + let client = required_arg(args, index, "--mcp-client")?; + options.mcp_client = client.to_string(); + index += 2; + } + "--client-config-path" => { + options.client_config_path = Some(PathBuf::from(required_arg( + args, + index, + "--client-config-path", + )?)); + index += 2; + } + "--dry-run" => { + options.dry_run = true; + index += 1; + } + "--json" => { + options.json = true; + index += 1; + } + other => { + return Err(format!( + "unknown uninstall option: {other}\n\n{}", + uninstall_help() + )); + } + } + } + if options.client_config_path.is_some() && options.mcp_client == "all" { + return Err("--client-config-path requires --mcp-client ".to_string()); + } + Ok(options) + } +} + +pub(in crate::adapters::cli) fn run_uninstall( + args: &[String], + stdout: &mut W, +) -> Result<(), String> { + let options = UninstallOptions::parse(args)?; + if options.help { + writeln!(stdout, "{}", uninstall_help()).map_err(|error| error.to_string())?; + return Ok(()); + } + let request = RepositoryLifecycleRequest { + repo: RepoSelector { + repo_root: options.repo_root.clone(), + config_path: options.config.clone(), + db_path: None, + manifest_path: None, + }, + action: "uninstall".to_string(), + output_format: if options.json { + OutputFormat::Typed + } else { + OutputFormat::Block + }, + dry_run: options.dry_run, + mcp_client: Some(options.mcp_client.clone()), + mcp_config_path: options.client_config_path.clone(), + instructions_target: None, + skip_mcp_config: false, + mode: "changed".to_string(), + include_fts: true, + semantic_enrichment: true, + semantic_provider_mode: "local_only".to_string(), + }; + let payload = CodebaseGraphApi::new() + .execute_operation(&OperationRequest::Uninstall(request)) + .map_err(|error| error.message)? + .payload; + if options.json { + writeln!( + stdout, + "{}", + serde_json::to_string_pretty(&payload).map_err(|error| error.to_string())? + ) + .map_err(|error| error.to_string())?; + } else { + let text = payload + .get("text") + .and_then(serde_json::Value::as_str) + .ok_or_else(|| "block response did not contain text".to_string())?; + write!(stdout, "{text}").map_err(|error| error.to_string())?; + } + Ok(()) +} diff --git a/src/adapters/cli/util.rs b/src/adapters/cli/util.rs new file mode 100644 index 0000000..794cda3 --- /dev/null +++ b/src/adapters/cli/util.rs @@ -0,0 +1,5 @@ +pub(super) fn parse_usize_arg(args: &[String], index: usize, name: &str) -> Result { + crate::adapters::required_arg(args, index, name)? + .parse::() + .map_err(|error| format!("{name} must be an integer: {error}")) +} diff --git a/src/adapters/cli/watch/command.rs b/src/adapters/cli/watch/command.rs new file mode 100644 index 0000000..a46b57d --- /dev/null +++ b/src/adapters/cli/watch/command.rs @@ -0,0 +1,113 @@ +use super::{ + options::{WatchBackend, WatchOptions}, + output::{write_watch_event, write_watch_status}, +}; +use crate::adapters::cli::{format::watch_help, materialization_input::materialize_request}; +use crate::api::{ + CodebaseGraphApi, OutputFormat, RefreshBackend, RefreshLoopConfig, RefreshWatchConfig, + RefreshWatchObserver, RefreshWatchSummary, +}; +use std::{io::Write, time::Duration}; + +pub(in crate::adapters::cli) fn run_watch( + args: &[String], + stdout: &mut W, +) -> Result<(), String> { + let options = WatchOptions::parse(args)?; + if options.help { + writeln!(stdout, "{}", watch_help()).map_err(|error| error.to_string())?; + return Ok(()); + } + let backend = match options.backend { + WatchBackend::Auto => RefreshBackend::Auto, + WatchBackend::Native => RefreshBackend::Native, + WatchBackend::Poll => RefreshBackend::Poll, + }; + let config = RefreshWatchConfig { + backend, + loop_config: RefreshLoopConfig { + poll_interval: Duration::from_millis(options.poll_ms), + debounce: Duration::from_millis(options.debounce_ms), + max_wait: Duration::from_secs(5).max(Duration::from_millis( + options.debounce_ms.saturating_mul(10), + )), + max_iterations: options.max_iterations, + }, + once: options.once, + }; + let request = materialize_request(&options.materialize, OutputFormat::Typed); + CodebaseGraphApi::new() + .watch_repository(&request, config, &mut CliRefreshObserver { stdout }) + .map_err(|error| error.message) +} + +struct CliRefreshObserver<'a, W> { + stdout: &'a mut W, +} + +impl RefreshWatchObserver for CliRefreshObserver<'_, W> { + fn on_success( + &mut self, + backend: Option<&str>, + summary: &RefreshWatchSummary, + event_count: usize, + changed_paths: usize, + ) -> Result<(), String> { + write_watch_event( + self.stdout, + "refreshed", + backend, + event_count, + changed_paths, + summary, + ) + } + + fn on_error( + &mut self, + backend: &str, + error: &str, + retrying: bool, + _event_count: usize, + _changed_paths: usize, + ) -> Result<(), String> { + write_watch_status( + self.stdout, + if retrying { "retrying" } else { "error" }, + backend, + Some(&error_reason(error)), + ) + } + + fn on_fallback(&mut self, backend: &str, reason: &str) -> Result<(), String> { + write_watch_status(self.stdout, "fallback", backend, Some(reason)) + } +} + +fn error_reason(error: &str) -> String { + let reason = error.lines().next().unwrap_or("refresh_failed").trim(); + if reason.is_empty() { + "refresh_failed".to_string() + } else { + reason + .split_whitespace() + .collect::>() + .join("_") + .chars() + .take(160) + .collect() + } +} + +#[cfg(test)] +mod tests { + use super::error_reason; + + #[test] + fn watch_error_reason_compacts_multiline_errors() { + assert_eq!( + error_reason("IO exception: Could not set lock\nSee docs"), + "IO_exception:_Could_not_set_lock" + ); + } +} diff --git a/src/adapters/cli/watch/mod.rs b/src/adapters/cli/watch/mod.rs new file mode 100644 index 0000000..23c4e83 --- /dev/null +++ b/src/adapters/cli/watch/mod.rs @@ -0,0 +1,14 @@ +mod command; +mod options; +mod output; + +pub(in crate::adapters::cli) use command::run_watch; +pub(in crate::adapters::cli) use options::SetupOptions; + +#[cfg(test)] +pub(super) use crate::api::{ + apply_watch_message, collect_poll_batch, collect_watch_batch, probe_native_watcher, + watch_file_snapshot, watch_snapshot_diff, WatchChangeBatch, WatchEventFilter, WatchMessage, +}; +#[cfg(test)] +pub(super) use options::{WatchBackend, WatchOptions}; diff --git a/src/cli/watch/options.rs b/src/adapters/cli/watch/options.rs similarity index 72% rename from src/cli/watch/options.rs rename to src/adapters/cli/watch/options.rs index d1f1fdf..04c0397 100644 --- a/src/cli/watch/options.rs +++ b/src/adapters/cli/watch/options.rs @@ -1,35 +1,26 @@ -use crate::cli::{ - build::MaterializeOptions, format::setup_help, install::supported_install_clients, -}; +use crate::adapters::cli::{format::setup_help, materialization_input::MaterializeOptions}; use std::path::PathBuf; #[derive(Debug)] -pub(in crate::cli) struct WatchOptions { - pub(in crate::cli) materialize: MaterializeOptions, - pub(in crate::cli) backend: WatchBackend, - pub(in crate::cli) poll_ms: u64, - pub(in crate::cli) debounce_ms: u64, - pub(in crate::cli) max_iterations: Option, - pub(in crate::cli) once: bool, - pub(in crate::cli) help: bool, +pub(in crate::adapters::cli) struct WatchOptions { + pub(in crate::adapters::cli) materialize: MaterializeOptions, + pub(in crate::adapters::cli) backend: WatchBackend, + pub(in crate::adapters::cli) poll_ms: u64, + pub(in crate::adapters::cli) debounce_ms: u64, + pub(in crate::adapters::cli) max_iterations: Option, + pub(in crate::adapters::cli) once: bool, + pub(in crate::adapters::cli) help: bool, } #[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub(in crate::cli) enum WatchBackend { +pub(in crate::adapters::cli) enum WatchBackend { Auto, Native, Poll, } -#[derive(Clone, Copy, Debug)] -pub(in crate::cli) struct WatchLoopConfig { - pub(in crate::cli) poll_ms: u64, - pub(in crate::cli) debounce_ms: u64, - pub(in crate::cli) max_iterations: Option, -} - impl WatchBackend { - pub(in crate::cli) fn parse(value: &str) -> Result { + pub(in crate::adapters::cli) fn parse(value: &str) -> Result { match value { "auto" => Ok(Self::Auto), "native" => Ok(Self::Native), @@ -40,7 +31,7 @@ impl WatchBackend { } impl WatchOptions { - pub(in crate::cli) fn parse(args: &[String]) -> Result { + pub(in crate::adapters::cli) fn parse(args: &[String]) -> Result { let mut materialize_args = Vec::new(); let mut backend = WatchBackend::Auto; let mut poll_ms = 500_u64; @@ -112,36 +103,36 @@ impl WatchOptions { } #[derive(Debug)] -pub(in crate::cli) struct SetupOptions { - pub(in crate::cli) repo_root: Option, - pub(in crate::cli) mode: String, - pub(in crate::cli) include_fts: bool, - pub(in crate::cli) semantic_enrichment: bool, - pub(in crate::cli) semantic_provider_mode: String, - pub(in crate::cli) mcp_client: String, - pub(in crate::cli) mcp_config_path: Option, - pub(in crate::cli) skip_mcp_config: bool, - pub(in crate::cli) dry_run: bool, - pub(in crate::cli) instructions_target: String, - pub(in crate::cli) help: bool, +pub(in crate::adapters::cli) struct SetupOptions { + pub(in crate::adapters::cli) repo_root: Option, + pub(in crate::adapters::cli) mode: String, + pub(in crate::adapters::cli) include_fts: bool, + pub(in crate::adapters::cli) semantic_enrichment: bool, + pub(in crate::adapters::cli) semantic_provider_mode: String, + pub(in crate::adapters::cli) mcp_client: String, + pub(in crate::adapters::cli) mcp_config_path: Option, + pub(in crate::adapters::cli) skip_mcp_config: bool, + pub(in crate::adapters::cli) dry_run: bool, + pub(in crate::adapters::cli) instructions_target: String, + pub(in crate::adapters::cli) help: bool, } impl SetupOptions { - pub(in crate::cli) fn parse(args: &[String]) -> Result { + pub(in crate::adapters::cli) fn parse(args: &[String]) -> Result { Self::parse_with_help(args, "install", setup_help()) } - pub(in crate::cli) fn parse_with_help( + pub(in crate::adapters::cli) fn parse_with_help( args: &[String], command_name: &str, help: &str, ) -> Result { let mut options = Self { repo_root: None, - mode: "changed".to_string(), + mode: String::new(), include_fts: true, semantic_enrichment: true, - semantic_provider_mode: "local_only".to_string(), + semantic_provider_mode: String::new(), mcp_client: "codex".to_string(), mcp_config_path: None, skip_mcp_config: false, @@ -166,10 +157,7 @@ impl SetupOptions { "--mode" => { let value = args .get(index + 1) - .ok_or_else(|| "--mode requires full or changed".to_string())?; - if value != "full" && value != "changed" { - return Err("--mode must be full or changed".to_string()); - } + .ok_or_else(|| "--mode requires a value".to_string())?; options.mode = value.clone(); index += 2; } @@ -177,12 +165,6 @@ impl SetupOptions { let value = args .get(index + 1) .ok_or_else(|| "--mcp-client requires a client id".to_string())?; - if value != "none" && !supported_install_clients().contains(&value.as_str()) { - return Err(format!( - "--mcp-client must be none or one of {}", - supported_install_clients().join(", ") - )); - } options.mcp_client = value.clone(); index += 2; } @@ -223,12 +205,9 @@ impl SetupOptions { index += 1; } "--semantic-provider-mode" => { - let value = args.get(index + 1).ok_or_else(|| { - "--semantic-provider-mode requires local_only".to_string() - })?; - if value != "local_only" { - return Err("--semantic-provider-mode must be local_only".to_string()); - } + let value = args + .get(index + 1) + .ok_or_else(|| "--semantic-provider-mode requires a value".to_string())?; options.semantic_provider_mode = value.clone(); index += 2; } diff --git a/src/cli/watch/output.rs b/src/adapters/cli/watch/output.rs similarity index 72% rename from src/cli/watch/output.rs rename to src/adapters/cli/watch/output.rs index 27470c8..2094f67 100644 --- a/src/cli/watch/output.rs +++ b/src/adapters/cli/watch/output.rs @@ -1,13 +1,13 @@ -use crate::protocol::NativeSyntaxMaterializationResponse; +use crate::api::RefreshWatchSummary; use std::io::Write; -pub(in crate::cli) fn write_watch_event( +pub(in crate::adapters::cli) fn write_watch_event( stdout: &mut W, event: &str, backend: Option<&str>, event_count: usize, changed_paths: usize, - response: &NativeSyntaxMaterializationResponse, + summary: &RefreshWatchSummary, ) -> Result<(), String> { let backend = backend .map(|backend| format!(" backend={backend}")) @@ -19,15 +19,15 @@ pub(in crate::cli) fn write_watch_event( backend, event_count, changed_paths, - response.diff.rebuild_paths().len(), - response.diff.deleted.len(), - response.skipped, - response.database_written + summary.rebuilt, + summary.deleted, + summary.skipped, + summary.database_written ) .map_err(|error| error.to_string()) } -pub(in crate::cli) fn write_watch_status( +pub(in crate::adapters::cli) fn write_watch_status( stdout: &mut W, event: &str, backend: &str, diff --git a/src/adapters/mcp/block.rs b/src/adapters/mcp/block.rs new file mode 100644 index 0000000..cf6b63c --- /dev/null +++ b/src/adapters/mcp/block.rs @@ -0,0 +1,28 @@ +pub(super) fn serialize_error_block(payload: &serde_json::Value) -> String { + let error = payload.get("error").unwrap_or(payload); + format!( + "error tool={} type={} message={}\n", + block_value(value_str(error, "tool")), + block_value(value_str(error, "type")), + block_value(value_str(error, "message")) + ) +} + +fn value_str<'a>(payload: &'a serde_json::Value, key: &str) -> &'a str { + payload + .get(key) + .and_then(serde_json::Value::as_str) + .unwrap_or("") +} + +fn block_value(value: &str) -> String { + if value.is_empty() { + "\"\"".to_string() + } else if value.chars().all(|character| { + character.is_ascii_alphanumeric() || matches!(character, '_' | '-' | '.' | '/' | ':') + }) { + value.to_string() + } else { + serde_json::to_string(value).unwrap_or_else(|_| "\"\"".to_string()) + } +} diff --git a/src/cli/mcp/http.rs b/src/adapters/mcp/http.rs similarity index 87% rename from src/cli/mcp/http.rs rename to src/adapters/mcp/http.rs index f167dde..8756588 100644 --- a/src/cli/mcp/http.rs +++ b/src/adapters/mcp/http.rs @@ -2,8 +2,8 @@ use super::{ options::McpHttpOptions, protocol::{handle_mcp_message, is_supported_protocol_version, parse_mcp_payload, rpc_error}, refresh::start_auto_refresh, + state::McpHttpState, }; -use crate::cli::{constants::MAX_HTTP_BODY_BYTES, install::McpHttpState}; use serde_json::json; use std::{ collections::BTreeMap, @@ -11,14 +11,16 @@ use std::{ net::{TcpListener, TcpStream}, }; -pub(in crate::cli) fn serve_mcp_http(options: &McpHttpOptions) -> Result<(), String> { +const MAX_HTTP_BODY_BYTES: usize = 1_000_000; + +pub(crate) fn serve_mcp_http(options: &McpHttpOptions) -> Result<(), String> { let listener = options.bind_listener()?; let mut options = options.clone(); - options.serve.refresh = Some(start_auto_refresh(&options.serve)); + options.serve.api = Some(start_auto_refresh(&options.serve)); serve_mcp_http_listener(&options, listener, None) } -pub(in crate::cli) fn serve_mcp_http_listener( +pub(in crate::adapters) fn serve_mcp_http_listener( options: &McpHttpOptions, listener: TcpListener, max_requests: Option, @@ -45,7 +47,7 @@ pub(in crate::cli) fn serve_mcp_http_listener( Ok(()) } -pub(in crate::cli) fn handle_mcp_http_stream( +pub(in crate::adapters) fn handle_mcp_http_stream( options: &McpHttpOptions, state: &mut McpHttpState, stream: &mut TcpStream, @@ -60,7 +62,7 @@ pub(in crate::cli) fn handle_mcp_http_stream( ) } -pub(in crate::cli) fn handle_mcp_http_request( +pub(in crate::adapters) fn handle_mcp_http_request( options: &McpHttpOptions, state: &mut McpHttpState, request: HttpRequest, @@ -194,14 +196,14 @@ pub(in crate::cli) fn handle_mcp_http_request( } } -pub(in crate::cli) fn valid_http_origin(origin: Option<&str>) -> bool { +pub(in crate::adapters) fn valid_http_origin(origin: Option<&str>) -> bool { match origin.and_then(http_origin_host) { None => true, Some(host) => matches!(host.as_str(), "localhost" | "127.0.0.1" | "::1"), } } -pub(in crate::cli) fn http_origin_host(origin: &str) -> Option { +pub(in crate::adapters) fn http_origin_host(origin: &str) -> Option { let after_scheme = origin .split_once("://") .map(|(_, rest)| rest) @@ -220,7 +222,9 @@ pub(in crate::cli) fn http_origin_host(origin: &str) -> Option { } } -pub(in crate::cli) fn read_http_request(stream: &mut TcpStream) -> Result { +pub(in crate::adapters) fn read_http_request( + stream: &mut TcpStream, +) -> Result { let mut buffer = Vec::new(); let mut chunk = [0_u8; 1024]; let header_end = loop { @@ -292,11 +296,11 @@ pub(in crate::cli) fn read_http_request(stream: &mut TcpStream) -> Result Option { +pub(in crate::adapters) fn find_header_end(buffer: &[u8]) -> Option { buffer.windows(4).position(|window| window == b"\r\n\r\n") } -pub(in crate::cli) fn write_http_json( +pub(in crate::adapters) fn write_http_json( stream: &mut TcpStream, status: u16, payload: &serde_json::Value, @@ -322,7 +326,7 @@ pub(in crate::cli) fn write_http_json( stream.flush().map_err(|error| error.to_string()) } -pub(in crate::cli) fn http_reason(status: u16) -> &'static str { +pub(in crate::adapters) fn http_reason(status: u16) -> &'static str { match status { 200 => "OK", 202 => "Accepted", @@ -337,16 +341,16 @@ pub(in crate::cli) fn http_reason(status: u16) -> &'static str { } #[derive(Debug)] -pub(in crate::cli) struct HttpRequest { - pub(in crate::cli) method: String, - pub(in crate::cli) path: String, - pub(in crate::cli) headers: BTreeMap, - pub(in crate::cli) body: Vec, - pub(in crate::cli) body_too_large: bool, +pub(in crate::adapters) struct HttpRequest { + pub(in crate::adapters) method: String, + pub(in crate::adapters) path: String, + pub(in crate::adapters) headers: BTreeMap, + pub(in crate::adapters) body: Vec, + pub(in crate::adapters) body_too_large: bool, } impl HttpRequest { - pub(in crate::cli) fn header(&self, name: &str) -> Option<&str> { + pub(in crate::adapters) fn header(&self, name: &str) -> Option<&str> { self.headers .get(&name.to_ascii_lowercase()) .map(String::as_str) @@ -354,14 +358,14 @@ impl HttpRequest { } #[derive(Debug)] -pub(in crate::cli) struct HttpResponse { - pub(in crate::cli) status: u16, - pub(in crate::cli) payload: serde_json::Value, - pub(in crate::cli) headers: Vec<(String, String)>, +pub(in crate::adapters) struct HttpResponse { + pub(in crate::adapters) status: u16, + pub(in crate::adapters) payload: serde_json::Value, + pub(in crate::adapters) headers: Vec<(String, String)>, } impl HttpResponse { - pub(in crate::cli) fn json(status: u16, payload: serde_json::Value) -> Self { + pub(in crate::adapters) fn json(status: u16, payload: serde_json::Value) -> Self { Self { status, payload, @@ -370,6 +374,6 @@ impl HttpResponse { } } -pub(in crate::cli) fn is_local_host(host: &str) -> bool { +pub(in crate::adapters) fn is_local_host(host: &str) -> bool { matches!(host, "localhost" | "127.0.0.1" | "::1") } diff --git a/src/adapters/mcp/mod.rs b/src/adapters/mcp/mod.rs new file mode 100644 index 0000000..c209356 --- /dev/null +++ b/src/adapters/mcp/mod.rs @@ -0,0 +1,20 @@ +mod block; +mod http; +mod options; +mod protocol; +mod refresh; +mod state; +mod stdio; +mod tools; + +pub(crate) use http::serve_mcp_http; +pub(crate) use options::{McpHttpOptions, McpServeOptions}; +pub(in crate::adapters) use protocol::McpSession; +pub(crate) use stdio::serve_mcp_stdio; + +#[cfg(test)] +pub(super) use http::{handle_mcp_http_request, HttpRequest}; +#[cfg(test)] +pub(super) use state::McpHttpState; +#[cfg(test)] +pub(super) use tools::mcp_call_tool_result; diff --git a/src/cli/mcp/options.rs b/src/adapters/mcp/options.rs similarity index 74% rename from src/cli/mcp/options.rs rename to src/adapters/mcp/options.rs index cfcde9a..5fb6bff 100644 --- a/src/cli/mcp/options.rs +++ b/src/adapters/mcp/options.rs @@ -1,25 +1,25 @@ use super::http::is_local_host; -use super::refresh::McpRefreshState; -use crate::cli::{format::mcp_help, graph::HealthOptions, util::required_arg}; -use std::{env, net::TcpListener, path::PathBuf, sync::Arc}; +use crate::adapters::required_arg; +use crate::api::{CodebaseGraphApi, RepoSelector}; +use std::{env, net::TcpListener, path::PathBuf}; #[derive(Clone, Debug)] -pub(in crate::cli) struct McpServeOptions { - pub(in crate::cli) repo_root: Option, - pub(in crate::cli) config: Option, - pub(in crate::cli) db: Option, - pub(in crate::cli) manifest: Option, - pub(in crate::cli) refresh: Option>, +pub(crate) struct McpServeOptions { + pub(in crate::adapters) repo_root: Option, + pub(in crate::adapters) config: Option, + pub(in crate::adapters) db: Option, + pub(in crate::adapters) manifest: Option, + pub(in crate::adapters) api: Option, } impl McpServeOptions { - pub(in crate::cli) fn parse(args: &[String]) -> Result { + pub(crate) fn parse(args: &[String], help: &str) -> Result { let mut options = Self { repo_root: None, config: None, db: None, manifest: None, - refresh: None, + api: None, }; let mut index = 0; while index < args.len() { @@ -43,47 +43,42 @@ impl McpServeOptions { index += 2; } other => { - return Err(format!( - "unknown mcp start option: {other}\n\n{}", - mcp_help() - )); + return Err(format!("unknown mcp start option: {other}\n\n{help}")); } } } Ok(options) } - pub(in crate::cli) fn health_options(&self) -> HealthOptions { - HealthOptions { + pub(in crate::adapters) fn repo_selector(&self) -> RepoSelector { + RepoSelector { repo_root: self.repo_root.clone(), - config: self.config.clone(), - db: self.db.clone(), - manifest: self.manifest.clone(), - help: false, - json: false, + config_path: self.config.clone(), + db_path: self.db.clone(), + manifest_path: self.manifest.clone(), } } } #[derive(Clone, Debug)] -pub(in crate::cli) struct McpHttpOptions { - pub(in crate::cli) serve: McpServeOptions, - pub(in crate::cli) host: String, - pub(in crate::cli) port: u16, - pub(in crate::cli) endpoint_path: String, - pub(in crate::cli) allow_remote: bool, - pub(in crate::cli) auth_token: Option, +pub(crate) struct McpHttpOptions { + pub(in crate::adapters) serve: McpServeOptions, + pub(in crate::adapters) host: String, + pub(in crate::adapters) port: u16, + pub(in crate::adapters) endpoint_path: String, + pub(in crate::adapters) allow_remote: bool, + pub(in crate::adapters) auth_token: Option, } impl McpHttpOptions { - pub(in crate::cli) fn parse(args: &[String]) -> Result { + pub(crate) fn parse(args: &[String], help: &str) -> Result { let mut options = Self { serve: McpServeOptions { repo_root: None, config: None, db: None, manifest: None, - refresh: None, + api: None, }, host: "127.0.0.1".to_string(), port: 8765, @@ -148,10 +143,7 @@ impl McpHttpOptions { index += 2; } other => { - return Err(format!( - "unknown mcp http option: {other}\n\n{}", - mcp_help() - )); + return Err(format!("unknown mcp http option: {other}\n\n{help}")); } } } @@ -159,7 +151,7 @@ impl McpHttpOptions { Ok(options) } - pub(in crate::cli) fn validate(&self) -> Result<(), String> { + pub(in crate::adapters) fn validate(&self) -> Result<(), String> { if self .auth_token .as_deref() @@ -179,7 +171,7 @@ impl McpHttpOptions { Ok(()) } - pub(in crate::cli) fn bind_listener(&self) -> Result { + pub(in crate::adapters) fn bind_listener(&self) -> Result { self.validate()?; TcpListener::bind((self.host.as_str(), self.port)) .map_err(|error| format!("failed to bind MCP HTTP server: {error}")) diff --git a/src/cli/mcp/protocol.rs b/src/adapters/mcp/protocol.rs similarity index 82% rename from src/cli/mcp/protocol.rs rename to src/adapters/mcp/protocol.rs index 7c796b8..3523bec 100644 --- a/src/cli/mcp/protocol.rs +++ b/src/adapters/mcp/protocol.rs @@ -1,11 +1,9 @@ +use super::tools::generate_mcp_specs; use super::{options::McpServeOptions, tools::mcp_call_tool_result}; -use crate::cli::{ - constants::{LATEST_PROTOCOL_VERSION, MCP_TOOL_SPECS_JSON}, - format::metadata_payload, -}; +use crate::api::CodebaseGraphApi; use serde_json::json; -pub(in crate::cli) fn handle_mcp_message( +pub(in crate::adapters) fn handle_mcp_message( message: serde_json::Value, session: &mut McpSession, options: &McpServeOptions, @@ -48,7 +46,7 @@ pub(in crate::cli) fn handle_mcp_message( })) } "ping" => Ok(json!({})), - "tools/list" => metadata_payload(MCP_TOOL_SPECS_JSON), + "tools/list" => generate_mcp_specs(), "tools/call" => { let params = message.get("params").cloned().unwrap_or_else(|| json!({})); let tool_name = params @@ -75,7 +73,7 @@ pub(in crate::cli) fn handle_mcp_message( } } -pub(in crate::cli) fn parse_mcp_payload(data: &[u8]) -> Result { +pub(in crate::adapters) fn parse_mcp_payload(data: &[u8]) -> Result { let payload: serde_json::Value = serde_json::from_slice(data).map_err(|error| error.to_string())?; if !payload.is_object() { @@ -84,14 +82,14 @@ pub(in crate::cli) fn parse_mcp_payload(data: &[u8]) -> Result String { +pub(in crate::adapters) fn negotiate_protocol_version(requested: &str) -> String { match requested { "2025-11-25" | "2025-06-18" | "2025-03-26" | "2024-11-05" => requested.to_string(), - _ => LATEST_PROTOCOL_VERSION.to_string(), + _ => CodebaseGraphApi::latest_mcp_protocol_version().to_string(), } } -pub(in crate::cli) fn rpc_error( +pub(in crate::adapters) fn rpc_error( request_id: serde_json::Value, code: i64, message: &str, @@ -106,7 +104,7 @@ pub(in crate::cli) fn rpc_error( }) } -pub(in crate::cli) fn is_supported_protocol_version(version: &str) -> bool { +pub(in crate::adapters) fn is_supported_protocol_version(version: &str) -> bool { matches!( version, "2025-11-25" | "2025-06-18" | "2025-03-26" | "2024-11-05" @@ -114,7 +112,7 @@ pub(in crate::cli) fn is_supported_protocol_version(version: &str) -> bool { } #[derive(Debug, Default)] -pub(in crate::cli) struct McpSession { - pub(in crate::cli) protocol_version: Option, - pub(in crate::cli) initialized: bool, +pub(in crate::adapters) struct McpSession { + pub(in crate::adapters) protocol_version: Option, + pub(in crate::adapters) initialized: bool, } diff --git a/src/adapters/mcp/refresh.rs b/src/adapters/mcp/refresh.rs new file mode 100644 index 0000000..ab036ab --- /dev/null +++ b/src/adapters/mcp/refresh.rs @@ -0,0 +1,6 @@ +use super::options::McpServeOptions; +use crate::api::CodebaseGraphApi; + +pub(in crate::adapters) fn start_auto_refresh(options: &McpServeOptions) -> CodebaseGraphApi { + CodebaseGraphApi::with_auto_refresh(options.repo_selector()) +} diff --git a/src/adapters/mcp/state.rs b/src/adapters/mcp/state.rs new file mode 100644 index 0000000..86301bf --- /dev/null +++ b/src/adapters/mcp/state.rs @@ -0,0 +1,15 @@ +use super::McpSession; +use std::collections::BTreeMap; + +#[derive(Debug, Default)] +pub(in crate::adapters) struct McpHttpState { + pub(in crate::adapters) sessions: BTreeMap, + pub(in crate::adapters) next_session: u64, +} + +impl McpHttpState { + pub(in crate::adapters) fn next_session_id(&mut self) -> String { + self.next_session += 1; + format!("native-http-session-{}", self.next_session) + } +} diff --git a/src/cli/mcp/stdio.rs b/src/adapters/mcp/stdio.rs similarity index 91% rename from src/cli/mcp/stdio.rs rename to src/adapters/mcp/stdio.rs index 550a893..4fb64ed 100644 --- a/src/cli/mcp/stdio.rs +++ b/src/adapters/mcp/stdio.rs @@ -6,13 +6,13 @@ use super::{ use serde_json::json; use std::io::{BufRead, Write}; -pub(in crate::cli) fn serve_mcp_stdio( +pub(crate) fn serve_mcp_stdio( options: &McpServeOptions, mut input: R, output: &mut W, ) -> Result<(), String> { let mut options = options.clone(); - options.refresh = Some(start_auto_refresh(&options)); + options.api = Some(start_auto_refresh(&options)); let mut session = McpSession::default(); while let Some(message) = read_mcp_message(&mut input, output)? { if let Some(response) = handle_mcp_message(message, &mut session, &options) { @@ -22,7 +22,7 @@ pub(in crate::cli) fn serve_mcp_stdio( Ok(()) } -pub(in crate::cli) fn read_mcp_message( +pub(in crate::adapters) fn read_mcp_message( input: &mut R, output: &mut W, ) -> Result, String> { @@ -93,7 +93,7 @@ pub(in crate::cli) fn read_mcp_message( }) } -pub(in crate::cli) fn log_mcp_stdio_parse_error(error: &str) { +pub(in crate::adapters) fn log_mcp_stdio_parse_error(error: &str) { eprintln!( "{}", json!({ @@ -103,7 +103,7 @@ pub(in crate::cli) fn log_mcp_stdio_parse_error(error: &str) { ); } -pub(in crate::cli) fn write_mcp_message( +pub(in crate::adapters) fn write_mcp_message( output: &mut W, message: &serde_json::Value, ) -> Result<(), String> { diff --git a/src/adapters/mcp/tools.rs b/src/adapters/mcp/tools.rs new file mode 100644 index 0000000..074b17a --- /dev/null +++ b/src/adapters/mcp/tools.rs @@ -0,0 +1,210 @@ +use super::{block::serialize_error_block, options::McpServeOptions}; +use crate::api::{ + ApiError, CodebaseGraphApi, OperationDescriptor, OperationInvocation, OperationResponse, + OutputFormat, +}; +use serde_json::json; +use serde_json::Map; + +pub(in crate::adapters) fn generate_mcp_specs() -> Result { + let tools = CodebaseGraphApi::new() + .operation_descriptors() + .into_iter() + .filter(|operation| operation.surfaces.contains(&"mcp")) + .filter_map(operation_spec_from_descriptor) + .collect::>(); + Ok(json!({"tools": tools})) +} + +fn operation_spec_from_descriptor(descriptor: OperationDescriptor) -> Option { + let name = descriptor.mcp_tool_name?; + let properties = (descriptor.request_schema)(); + let mut property_map: Map = + properties.as_object().cloned().unwrap_or_default(); + property_map.insert( + "output_format".to_string(), + json!({"type":"string","enum":["json","block"],"default":"block"}), + ); + property_map.insert( + "include_structured_content".to_string(), + json!({"type":"boolean","default":false,"description":"Include the MCP structuredContent payload alongside the text result."}), + ); + + Some(json!({ + "name": name, + "description": descriptor.summary, + "inputSchema": { + "type": "object", + "additionalProperties": false, + "properties": property_map, + "required": descriptor.required_fields(), + }, + })) +} + +fn map_error_to_transport(tool_name: &str, error: &ApiError) -> serde_json::Value { + json!({ + "error": { + "tool": tool_name, + "type": "ValueError", + "code": error.code, + "message": error.message, + "details": error.details, + "retryable": error.retryable, + } + }) +} + +pub(in crate::adapters) fn mcp_call_tool_result( + tool_name: &str, + arguments: &serde_json::Value, + options: &McpServeOptions, +) -> Result { + let output_format = parse_output_format(arguments).map_err(|error| error.message)?; + let response = mcp_tool_payload(tool_name, arguments, options, output_format); + let include_structured = arguments + .get("include_structured_content") + .and_then(serde_json::Value::as_bool) + .unwrap_or(false); + match response { + Ok(response) => { + let payload = response.payload; + let (text, structured) = if output_format == OutputFormat::Typed { + ( + serde_json::to_string(&payload).map_err(|error| error.to_string())?, + payload.clone(), + ) + } else { + let text = payload + .get("text") + .and_then(serde_json::Value::as_str) + .ok_or_else(|| "block response did not contain text".to_string())? + .to_string(); + let structured = payload + .get("structured") + .cloned() + .unwrap_or(serde_json::Value::Null); + (text, structured) + }; + let mut result = json!({ + "content": [{"type": "text", "text": text}], + "isError": false, + }); + if include_structured { + result["structuredContent"] = structured; + } + Ok(result) + } + Err(error) if tool_name.is_empty() || error.code == "unknown_tool" => Err(error.message), + Err(error) => { + let payload = map_error_to_transport(tool_name, &error); + let text = if output_format == OutputFormat::Typed { + serde_json::to_string(&payload).map_err(|error| error.to_string())? + } else { + serialize_error_block(&payload) + }; + let mut result = json!({ + "content": [{"type": "text", "text": text}], + "isError": true, + }); + if include_structured { + result["structuredContent"] = payload; + } + Ok(result) + } + } +} + +pub(in crate::adapters) fn mcp_tool_payload( + tool_name: &str, + arguments: &serde_json::Value, + options: &McpServeOptions, + output_format: OutputFormat, +) -> Result { + let api = options.api.clone().unwrap_or_default(); + let operation = api.resolve_mcp_operation(tool_name).ok_or_else(|| { + ApiError::new( + "unknown_tool", + format!("Unknown codebaseGraph MCP tool: {tool_name}"), + ) + })?; + api.execute_invocation( + operation.id, + &OperationInvocation { + repo: options.repo_selector(), + arguments: arguments.clone(), + output_format, + }, + ) +} + +fn parse_output_format(arguments: &serde_json::Value) -> Result { + match arguments + .get("output_format") + .and_then(serde_json::Value::as_str) + .unwrap_or("block") + { + "json" => Ok(OutputFormat::Typed), + "block" => Ok(OutputFormat::Block), + _ => Err(ApiError::new( + "invalid_output_format", + "graph tool output_format must be \"json\" or \"block\"", + )), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn public_errors_map_to_mcp_failures_without_changing_codes() { + let error = ApiError::new("graph_busy", "graph is busy") + .with_details(json!({"operation": "query"})) + .retryable(true); + + let payload = map_error_to_transport("graph_query", &error); + + assert_eq!(payload["error"]["tool"], "graph_query"); + assert_eq!(payload["error"]["code"], "graph_busy"); + assert_eq!(payload["error"]["message"], "graph is busy"); + assert_eq!(payload["error"]["details"]["operation"], "query"); + assert_eq!(payload["error"]["retryable"], true); + } + + #[test] + fn mcp_specs_are_generated_from_registered_operation_metadata() { + let specs = generate_mcp_specs().expect("MCP specs should generate"); + let tools = specs["tools"].as_array().expect("tools should be an array"); + let names = tools + .iter() + .filter_map(|tool| tool["name"].as_str()) + .collect::>(); + assert_eq!( + names, + vec![ + "graph_architecture_queries", + "graph_context", + "graph_health", + "graph_query", + "graph_query_helpers", + "graph_schema", + "graph_search", + ] + ); + let search = tools + .iter() + .find(|tool| tool["name"] == "graph_search") + .expect("search tool should be advertised"); + assert_eq!( + search["inputSchema"]["properties"]["detail"]["enum"], + json!(["standard", "slim"]) + ); + assert_eq!( + search["inputSchema"]["properties"]["profile"]["default"], + "brief" + ); + assert_eq!(search["inputSchema"]["properties"]["limit"]["default"], 3); + assert_eq!(search["inputSchema"]["required"], json!(["query"])); + } +} diff --git a/src/adapters/mod.rs b/src/adapters/mod.rs new file mode 100644 index 0000000..14979e0 --- /dev/null +++ b/src/adapters/mod.rs @@ -0,0 +1,12 @@ +pub mod cli; +pub(crate) mod mcp; + +pub(in crate::adapters) fn required_arg<'a>( + args: &'a [String], + index: usize, + name: &str, +) -> Result<&'a str, String> { + args.get(index + 1) + .map(String::as_str) + .ok_or_else(|| format!("{name} requires a value")) +} diff --git a/src/api/boundary_tests.rs b/src/api/boundary_tests.rs new file mode 100644 index 0000000..73e4ec7 --- /dev/null +++ b/src/api/boundary_tests.rs @@ -0,0 +1,275 @@ +#[test] +fn api_does_not_import_transport_adapters() { + let api_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src/api"); + let mut pending = vec![api_root]; + let forbidden = [ + ["crate", "::adapters::cli"].concat(), + ["crate", "::adapters::mcp"].concat(), + ]; + + while let Some(path) = pending.pop() { + for entry in std::fs::read_dir(&path).expect("API directory should be readable") { + let path = entry + .expect("API directory entry should be readable") + .path(); + if path.is_dir() { + pending.push(path); + continue; + } + if path.extension().and_then(std::ffi::OsStr::to_str) != Some("rs") + || path.file_name().and_then(std::ffi::OsStr::to_str) == Some("boundary_tests.rs") + { + continue; + } + let source = std::fs::read_to_string(&path).expect("API source should be readable"); + for adapter in &forbidden { + assert!( + !source.contains(adapter), + "{} imports a transport adapter", + path.display() + ); + } + } + } +} + +#[test] +fn command_and_mcp_transports_are_peer_adapters() { + let source_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src"); + assert!(source_root.join("adapters/cli").is_dir()); + assert!(source_root.join("adapters/mcp").is_dir()); + assert!(!source_root.join("cli").exists()); + + let adapters = [ + ( + "MCP", + source_root.join("adapters/mcp"), + [ + ["crate", "::cli"].concat(), + ["crate", "::adapters::cli"].concat(), + ], + ), + ( + "CLI", + source_root.join("adapters/cli"), + [ + ["crate", "::mcp"].concat(), + ["crate", "::adapters::mcp"].concat(), + ], + ), + ]; + + for (name, root, forbidden) in adapters { + let mut pending = vec![root]; + while let Some(path) = pending.pop() { + for entry in + std::fs::read_dir(&path).expect("transport adapter directory should be readable") + { + let path = entry + .expect("transport adapter directory entry should be readable") + .path(); + if path.is_dir() { + if path.file_name().and_then(std::ffi::OsStr::to_str) != Some("tests") { + pending.push(path); + } + continue; + } + if path.extension().and_then(std::ffi::OsStr::to_str) != Some("rs") { + continue; + } + let source = std::fs::read_to_string(&path) + .expect("transport adapter source should be readable"); + for adapter in &forbidden { + assert!( + !source.contains(adapter), + "{} imports a peer adapter: {name}", + path.display() + ); + } + } + } + } +} + +#[test] +fn transport_adapters_use_only_the_public_api_facade() { + let forbidden = [ + ["crate", "::api::contracts"].concat(), + ["crate", "::api::materialization"].concat(), + ["crate", "::api::normalization"].concat(), + ["crate", "::api::refresh"].concat(), + ["crate", "::protocol"].concat(), + ["crate", "::db_writer"].concat(), + ["crate", "::execution"].concat(), + ["crate", "::scan"].concat(), + ["crate", "::semantic_enrichment"].concat(), + ["crate", "::staging_writer"].concat(), + ]; + let adapters_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src/adapters"); + let mut pending = vec![adapters_root]; + + while let Some(path) = pending.pop() { + for entry in + std::fs::read_dir(&path).expect("transport adapter directory should be readable") + { + let path = entry + .expect("transport adapter directory entry should be readable") + .path(); + if path.is_dir() { + if path.file_name().and_then(std::ffi::OsStr::to_str) != Some("tests") { + pending.push(path); + } + continue; + } + if path.extension().and_then(std::ffi::OsStr::to_str) != Some("rs") { + continue; + } + let source = std::fs::read_to_string(&path) + .expect("transport adapter source should be readable"); + for dependency in &forbidden { + assert!( + !source.contains(dependency), + "{} bypasses CodebaseGraphApi through {dependency}", + path.display() + ); + } + } + } + + for source in [ + include_str!("../adapters/cli/dispatch.rs"), + include_str!("../adapters/cli/setup.rs"), + include_str!("../adapters/cli/reinstall.rs"), + include_str!("../adapters/cli/uninstall.rs"), + include_str!("../adapters/cli/install/command.rs"), + include_str!("../adapters/cli/install/verify.rs"), + include_str!("../adapters/cli/materialization/command.rs"), + include_str!("../adapters/cli/watch/command.rs"), + include_str!("../adapters/mcp/refresh.rs"), + include_str!("../adapters/mcp/tools.rs"), + ] { + assert!( + source.contains("CodebaseGraphApi"), + "product-facing adapter does not use CodebaseGraphApi" + ); + } +} + +#[test] +fn command_subadapters_do_not_import_sibling_behavior() { + let adapters = [ + ( + "watch", + [ + include_str!("../adapters/cli/watch/command.rs"), + include_str!("../adapters/cli/watch/options.rs"), + ], + ["materialization::", "materialization::{"], + ), + ( + "materialization", + [ + include_str!("../adapters/cli/materialization/command.rs"), + include_str!("../adapters/cli/materialization/mod.rs"), + ], + ["watch::", "watch::{"], + ), + ]; + + for (name, sources, forbidden) in adapters { + for source in sources { + for dependency in forbidden { + assert!( + !source.contains(dependency), + "{name} adapter imports sibling behavior through {dependency}" + ); + } + } + } +} + +#[test] +fn transport_adapters_do_not_import_product_execution_services() { + let adapters = [ + ("CLI dispatch", include_str!("../adapters/cli/dispatch.rs")), + ( + "CLI materialization", + include_str!("../adapters/cli/materialization/command.rs"), + ), + ("MCP tools", include_str!("../adapters/mcp/tools.rs")), + ( + "watch command", + include_str!("../adapters/cli/watch/command.rs"), + ), + ("MCP refresh", include_str!("../adapters/mcp/refresh.rs")), + ]; + let forbidden = [ + ["crate", "::db_writer"].concat(), + ["crate", "::scan"].concat(), + ["crate", "::execution"].concat(), + ["crate", "::semantic_enrichment"].concat(), + ["crate", "::staging_writer"].concat(), + ["crate", "::adapters::cli::graph"].concat(), + ["crate", "::adapters::cli::materialization"].concat(), + ["materialize", "_syntax_batch"].concat(), + ["execute", "_materialization_pipeline"].concat(), + ["execute", "_graph_search"].concat(), + ["execute", "_read_only_query"].concat(), + ["execute", "_refresh_operation"].concat(), + ["start", "_native_watcher"].concat(), + ["run", "_poll_watch"].concat(), + ["resolve", "_source_root"].concat(), + ["write", "_database("].concat(), + ]; + + for (name, source) in adapters { + for dependency in &forbidden { + assert!( + !source.contains(dependency), + "{name} bypasses the Public API Facade through {dependency}" + ); + } + } +} + +#[test] +fn mcp_tools_leave_product_request_and_refresh_policy_in_the_api() { + let source = include_str!("../adapters/mcp/tools.rs"); + let forbidden = [ + "SearchRequest", + "ContextRequest", + "QueryRequest", + ".read_guard()", + "\"graph_search\" =>", + "unwrap_or(\"brief\")", + "unwrap_or(\"standard\")", + ]; + + for policy in forbidden { + assert!( + !source.contains(policy), + "MCP tools contain API-owned product policy: {policy}" + ); + } + assert!(source.contains("execute_invocation")); + assert!(source.contains("resolve_mcp_operation")); + assert!(!source.contains("mcp_tool_name == Some(tool_name)")); +} + +#[test] +fn only_graph_writer_submits_database_updates() { + let graph_writer = include_str!("../staging_writer/writer.rs"); + assert!(graph_writer.contains(&["write", "_database("].concat())); + + let other_materialization_modules = [ + include_str!("../execution/run.rs"), + include_str!("../execution/plan.rs"), + include_str!("../execution/parallel.rs"), + include_str!("../semantic_enrichment/mod.rs"), + include_str!("../adapters/cli/materialization/command.rs"), + include_str!("materialization.rs"), + ]; + for source in other_materialization_modules { + assert!(!source.contains(&["write", "_database("].concat())); + } +} diff --git a/src/api/catalog.rs b/src/api/catalog.rs new file mode 100644 index 0000000..7131151 --- /dev/null +++ b/src/api/catalog.rs @@ -0,0 +1,31 @@ +#[path = "catalog_support.rs"] +pub(crate) mod support; + +pub(crate) use self::support::schema_statements_from_copy_statements; +use self::support::{ + filter_architecture_group, metadata_payload, ARCHITECTURE_QUERIES_JSON, GRAPH_SCHEMA_JSON, + QUERY_HELPERS_JSON, +}; + +pub fn load_catalog(kind: &str) -> Result { + let source = match kind { + "schema" => GRAPH_SCHEMA_JSON, + "query-helpers" => QUERY_HELPERS_JSON, + "architecture-queries" => ARCHITECTURE_QUERIES_JSON, + _ => return Err(format!("unknown catalog kind: {kind}")), + }; + metadata_payload(source) + .map_err(|error| format!("failed to parse embedded catalog {kind}: {error}")) +} + +pub fn filter_catalog( + kind: &str, + payload: &mut serde_json::Value, + group: Option<&str>, +) -> Result<(), String> { + match (kind, group) { + ("architecture-queries", Some(group)) => filter_architecture_group(payload, group), + (_, Some(_)) => Err(format!("catalog {kind} does not support group filtering")), + (_, None) => Ok(()), + } +} diff --git a/src/cli/format/schema.rs b/src/api/catalog_support.rs similarity index 74% rename from src/cli/format/schema.rs rename to src/api/catalog_support.rs index 7230b3b..d76887f 100644 --- a/src/cli/format/schema.rs +++ b/src/api/catalog_support.rs @@ -1,8 +1,46 @@ -use super::{metadata_payload, value_array, value_str}; -use crate::cli::{constants::GRAPH_SCHEMA_JSON, graph::cypher_single_quoted}; use std::collections::BTreeSet; -pub(in crate::cli) fn schema_statements_from_copy_statements( +pub(crate) const GRAPH_SCHEMA_JSON: &str = include_str!("../../assets/graph_schema.json"); +pub(crate) const QUERY_HELPERS_JSON: &str = include_str!("../../assets/query_helpers.json"); +pub(crate) const ARCHITECTURE_QUERIES_JSON: &str = + include_str!("../../assets/architecture_queries.json"); + +pub(crate) fn metadata_payload(source: &str) -> Result { + serde_json::from_str(source) + .map_err(|error| format!("failed to parse embedded metadata: {error}")) +} + +pub(crate) fn filter_architecture_group( + payload: &mut serde_json::Value, + group: &str, +) -> Result<(), String> { + let groups = payload + .get("groups") + .and_then(serde_json::Value::as_array) + .cloned() + .unwrap_or_default(); + let selected: Vec = groups + .iter() + .filter(|value| value.get("name").and_then(serde_json::Value::as_str) == Some(group)) + .cloned() + .collect(); + if selected.is_empty() { + let valid = groups + .iter() + .filter_map(|value| value.get("name").and_then(serde_json::Value::as_str)) + .collect::>() + .join(", "); + return Err(format!( + "Unknown architecture query group: {group}. Valid groups: {valid}" + )); + } + if let Some(object) = payload.as_object_mut() { + object.insert("groups".to_string(), serde_json::Value::Array(selected)); + } + Ok(()) +} + +pub(crate) fn schema_statements_from_copy_statements( include_fts: bool, copy_statements: &[String], ) -> Vec { @@ -121,7 +159,7 @@ fn string_array(value: &serde_json::Value, key: &str) -> Vec { .collect() } -pub(in crate::cli) fn fts_index_statements(node_tables: &[String]) -> Vec { +fn fts_index_statements(node_tables: &[String]) -> Vec { let Ok(schema) = metadata_payload(GRAPH_SCHEMA_JSON) else { return Vec::new(); }; @@ -158,7 +196,7 @@ pub(in crate::cli) fn fts_index_statements(node_tables: &[String]) -> Vec BTreeSet { +fn copy_tables(copy_statements: &[String]) -> BTreeSet { copy_statements .iter() .filter_map(|statement| { @@ -170,7 +208,7 @@ pub(in crate::cli) fn copy_tables(copy_statements: &[String]) -> BTreeSet) -> BTreeSet { +fn relation_names(tables: &BTreeSet) -> BTreeSet { let mut relations = BTreeSet::new(); for table in tables { if let Some(name) = table.strip_prefix("FROM_") { @@ -183,10 +221,7 @@ pub(in crate::cli) fn relation_names(tables: &BTreeSet) -> BTreeSet, -) -> String { +fn node_table_sql(table: &str, fields: Vec<(&'static str, &'static str)>) -> String { let columns: Vec = fields .into_iter() .map(|(name, value_type)| { @@ -200,7 +235,7 @@ pub(in crate::cli) fn node_table_sql( ) } -pub(in crate::cli) fn relation_table_sql( +fn relation_table_sql( table: &str, from_tables: &[String], to_tables: &[String], @@ -222,7 +257,7 @@ pub(in crate::cli) fn relation_table_sql( ) } -pub(in crate::cli) fn node_fields(table: &str) -> Vec<(&'static str, &'static str)> { +fn node_fields(table: &str) -> Vec<(&'static str, &'static str)> { let mut fields = common_node_fields(); if table == "File" { fields.push(("content_hash", "STRING")); @@ -230,7 +265,7 @@ pub(in crate::cli) fn node_fields(table: &str) -> Vec<(&'static str, &'static st fields } -pub(in crate::cli) fn common_node_fields() -> Vec<(&'static str, &'static str)> { +fn common_node_fields() -> Vec<(&'static str, &'static str)> { vec![ ("id", "STRING"), ("label", "STRING"), @@ -250,7 +285,7 @@ pub(in crate::cli) fn common_node_fields() -> Vec<(&'static str, &'static str)> ] } -pub(in crate::cli) fn edge_fields() -> Vec<(&'static str, &'static str)> { +fn edge_fields() -> Vec<(&'static str, &'static str)> { vec![ ("id", "STRING"), ("kind", "STRING"), @@ -259,8 +294,28 @@ pub(in crate::cli) fn edge_fields() -> Vec<(&'static str, &'static str)> { ("confidence", "DOUBLE"), ("line_start", "INT64"), ("line_end", "INT64"), - ("byte_start", "INT64"), - ("byte_end", "INT64"), ("metadata", "JSON"), ] } + +pub(crate) fn value_array<'a>( + payload: &'a serde_json::Value, + key: &str, +) -> &'a [serde_json::Value] { + payload + .get(key) + .and_then(serde_json::Value::as_array) + .map(Vec::as_slice) + .unwrap_or(&[]) +} + +pub(crate) fn value_str<'a>(payload: &'a serde_json::Value, key: &str) -> &'a str { + payload + .get(key) + .and_then(serde_json::Value::as_str) + .unwrap_or("") +} + +pub(crate) fn cypher_single_quoted(value: &str) -> String { + value.replace('\\', "\\\\").replace('\'', "\\'") +} diff --git a/src/api/context.rs b/src/api/context.rs new file mode 100644 index 0000000..2529ae1 --- /dev/null +++ b/src/api/context.rs @@ -0,0 +1,224 @@ +use crate::api::contracts::RepoSelector; +use std::fs; +use std::path::{Path, PathBuf}; + +#[derive(Debug, Clone)] +pub(crate) struct RepoRuntime { + pub repo_root: PathBuf, + pub db_path: PathBuf, + pub manifest_path: PathBuf, + pub config_path: Option, +} + +#[derive(Debug, Clone)] +pub(crate) struct RepoPaths { + pub(crate) repo_name: String, + pub(crate) state_dir: PathBuf, + pub(crate) db_path: PathBuf, + pub(crate) manifest_path: PathBuf, + pub(crate) config_path: PathBuf, +} + +impl RepoPaths { + pub(crate) fn derive(repo_root: &Path) -> Self { + let repo_name = safe_name( + repo_root + .file_name() + .and_then(|value| value.to_str()) + .unwrap_or("repository"), + ); + let state_dir = repo_root.join(".codebaseGraph"); + Self { + repo_name: repo_name.clone(), + state_dir: state_dir.clone(), + db_path: state_dir.join(format!("{repo_name}_graph.ldb")), + manifest_path: state_dir.join("manifest.json"), + config_path: state_dir.join("config.json"), + } + } +} + +pub(crate) fn resolve_runtime(selector: &RepoSelector) -> Result { + let repo_root = resolve_repository_root(selector.repo_root.as_deref())?; + let paths = RepoPaths::derive(&repo_root); + let config_path = selector + .config_path + .clone() + .unwrap_or_else(|| paths.config_path.clone()); + let config = if config_path.exists() { + Some(read_json_file(&config_path)?) + } else { + None + }; + Ok(RepoRuntime { + repo_root: repo_root.clone(), + db_path: selector + .db_path + .clone() + .or_else(|| config_path_value(config.as_ref(), "database_path")) + .unwrap_or(paths.db_path), + manifest_path: selector + .manifest_path + .clone() + .or_else(|| config_path_value(config.as_ref(), "manifest_path")) + .unwrap_or(paths.manifest_path), + config_path: Some(config_path), + }) +} + +pub(crate) fn resolve_repository_root(explicit: Option<&Path>) -> Result { + if let Some(path) = explicit { + return path + .canonicalize() + .map_err(|error| format!("failed to resolve repo root: {error}")); + } + let current_dir = std::env::current_dir() + .map_err(|error| format!("failed to read current directory: {error}"))?; + for ancestor in current_dir.ancestors() { + let config_path = ancestor.join(".codebaseGraph").join("config.json"); + if config_path.exists() { + let config = read_json_file(&config_path)?; + if let Some(repo_root) = config.get("repo_root").and_then(serde_json::Value::as_str) { + let repo_root = PathBuf::from(repo_root); + return Ok(repo_root.canonicalize().unwrap_or(repo_root)); + } + return Ok(ancestor.to_path_buf()); + } + } + if let Some(git_root) = current_dir + .ancestors() + .find(|ancestor| ancestor.join(".git").exists()) + { + return Ok(git_root.to_path_buf()); + } + Ok(current_dir) +} + +fn read_json_file(path: &Path) -> Result { + let text = fs::read_to_string(path) + .map_err(|error| format!("failed to read JSON file {}: {error}", path.display()))?; + serde_json::from_str(&text) + .map_err(|error| format!("failed to parse JSON file {}: {error}", path.display())) +} + +fn config_path_value(config: Option<&serde_json::Value>, key: &str) -> Option { + config + .and_then(|value| value.get(key)) + .and_then(serde_json::Value::as_str) + .map(PathBuf::from) +} + +fn safe_name(value: &str) -> String { + let normalized: String = value + .chars() + .map(|character| { + if character.is_alphanumeric() || character == '-' || character == '_' { + character + } else { + '_' + } + }) + .collect(); + let trimmed = normalized.trim_matches(['.', '_', '-']); + if trimmed.is_empty() { + "repository".to_string() + } else { + trimmed.to_string() + } +} + +#[cfg(test)] +mod tests { + use super::{resolve_runtime, RepoPaths}; + use crate::api::contracts::RepoSelector; + use std::fs; + use std::time::{SystemTime, UNIX_EPOCH}; + + fn unique_temp_dir(prefix: &str) -> std::path::PathBuf { + std::env::temp_dir().join(format!( + "{prefix}-{}-{}", + std::process::id(), + SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("clock should be after epoch") + .as_nanos() + )) + } + + #[test] + fn graph_paths_are_deterministic() { + let paths = RepoPaths::derive(&std::path::PathBuf::from("/tmp/demo")); + assert_eq!(paths.repo_name, "demo"); + assert_eq!( + paths.state_dir, + std::path::PathBuf::from("/tmp/demo/.codebaseGraph") + ); + assert!(paths.db_path.to_string_lossy().ends_with("demo_graph.ldb")); + assert_eq!( + paths.manifest_path, + std::path::PathBuf::from("/tmp/demo/.codebaseGraph/manifest.json") + ); + assert_eq!( + paths.config_path, + std::path::PathBuf::from("/tmp/demo/.codebaseGraph/config.json") + ); + } + + #[test] + fn resolve_runtime_selects_configured_graph_and_manifest_paths() { + let root = unique_temp_dir("codebase-graph-api-runtime"); + let state = root.join(".codebaseGraph"); + fs::create_dir_all(&state).expect("state directory should be created"); + let graph_path = state.join("configured.ldb"); + let manifest_path = state.join("configured-manifest.json"); + fs::write( + state.join("config.json"), + serde_json::to_vec(&serde_json::json!({ + "database_path": graph_path, + "manifest_path": manifest_path, + })) + .expect("config should serialize"), + ) + .expect("config should be written"); + + let runtime = resolve_runtime(&RepoSelector { + repo_root: Some(root.clone()), + config_path: None, + db_path: None, + manifest_path: None, + }) + .expect("runtime should resolve"); + + assert_eq!( + runtime.repo_root, + root.canonicalize().expect("root should canonicalize") + ); + assert_eq!(runtime.db_path, graph_path); + assert_eq!(runtime.manifest_path, manifest_path); + assert_eq!( + runtime.config_path, + Some(runtime.repo_root.join(".codebaseGraph").join("config.json")) + ); + let _ = fs::remove_dir_all(root); + } + + #[test] + fn resolve_runtime_prefers_explicit_graph_and_manifest_paths() { + let root = unique_temp_dir("codebase-graph-api-runtime-explicit"); + fs::create_dir_all(&root).expect("repository should be created"); + let graph_path = root.join("explicit.ldb"); + let manifest_path = root.join("explicit-manifest.json"); + + let runtime = resolve_runtime(&RepoSelector { + repo_root: Some(root.clone()), + config_path: None, + db_path: Some(graph_path.clone()), + manifest_path: Some(manifest_path.clone()), + }) + .expect("runtime should resolve"); + + assert_eq!(runtime.db_path, graph_path); + assert_eq!(runtime.manifest_path, manifest_path); + let _ = fs::remove_dir_all(root); + } +} diff --git a/src/api/contracts.rs b/src/api/contracts.rs new file mode 100644 index 0000000..0d0bb5c --- /dev/null +++ b/src/api/contracts.rs @@ -0,0 +1,496 @@ +use serde::{Deserialize, Serialize}; +use std::{path::PathBuf, time::Duration}; + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct RepoSelector { + pub repo_root: Option, + pub config_path: Option, + pub db_path: Option, + pub manifest_path: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct NodeRef { + pub id: String, + pub kind: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "operation", content = "request")] +pub enum OperationRequest { + Health(HealthRequest), + Search(SearchRequest), + Context(ContextRequest), + Query(QueryRequest), + Materialize(MaterializationRequest), + Plan(MaterializationRequest), + Catalog { + kind: String, + group: Option, + output_format: OutputFormat, + }, + Setup(RepositoryLifecycleRequest), + Reinstall(RepositoryLifecycleRequest), + Uninstall(RepositoryLifecycleRequest), + InstallMcp(McpInstallRequest), + Refresh(RefreshRequest), +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct OperationInvocation { + pub repo: RepoSelector, + pub arguments: serde_json::Value, + pub output_format: OutputFormat, +} + +impl OperationRequest { + pub fn operation_name(&self) -> &str { + match self { + Self::Health(_) => "health", + Self::Search(_) => "search", + Self::Context(_) => "context", + Self::Query(_) => "query", + Self::Materialize(_) => "materialize", + Self::Plan(_) => "plan", + Self::Catalog { kind, .. } => kind, + Self::Setup(_) => "setup", + Self::Reinstall(_) => "reinstall", + Self::Uninstall(_) => "uninstall", + Self::InstallMcp(_) => "mcp-install", + Self::Refresh(_) => "refresh", + } + } + + pub fn repo_selector(&self) -> Option<&RepoSelector> { + match self { + Self::Health(request) => Some(&request.repo), + Self::Search(request) => Some(&request.repo), + Self::Context(request) => Some(&request.repo), + Self::Query(request) => Some(&request.repo), + Self::Materialize(request) => Some(&request.repo), + Self::Plan(request) => Some(&request.repo), + Self::Catalog { .. } => None, + Self::Setup(request) => Some(&request.repo), + Self::Reinstall(request) => Some(&request.repo), + Self::Uninstall(request) => Some(&request.repo), + Self::InstallMcp(request) => Some(&request.repo), + Self::Refresh(request) => Some(&request.repo), + } + } + + pub fn output_format(&self) -> OutputFormat { + match self { + Self::Catalog { output_format, .. } + | Self::Health(HealthRequest { output_format, .. }) + | Self::Search(SearchRequest { output_format, .. }) + | Self::Context(ContextRequest { output_format, .. }) + | Self::Query(QueryRequest { output_format, .. }) + | Self::Materialize(MaterializationRequest { output_format, .. }) + | Self::Plan(MaterializationRequest { output_format, .. }) + | Self::Setup(RepositoryLifecycleRequest { output_format, .. }) + | Self::Reinstall(RepositoryLifecycleRequest { output_format, .. }) + | Self::Uninstall(RepositoryLifecycleRequest { output_format, .. }) + | Self::InstallMcp(McpInstallRequest { output_format, .. }) + | Self::Refresh(RefreshRequest { output_format, .. }) => *output_format, + } + } +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +pub enum OutputFormat { + #[default] + Typed, + Block, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct HealthRequest { + pub repo: RepoSelector, + pub refresh_status: Option, + pub output_format: OutputFormat, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SearchRequest { + pub repo: RepoSelector, + pub query: String, + pub profile: String, + pub limit: usize, + pub budget: usize, + pub context_limit: usize, + pub max_depth: Option, + pub detail: String, + pub output_format: OutputFormat, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ContextRequest { + pub repo: RepoSelector, + pub query: Option, + pub profile: String, + pub limit: usize, + pub budget: usize, + pub context_limit: usize, + pub max_depth: Option, + pub detail: String, + pub node_id: Option, + pub node_type: Option, + pub output_format: OutputFormat, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct QueryRequest { + pub repo: RepoSelector, + pub statement: String, + pub parameters: serde_json::Value, + pub limit: usize, + pub output_format: OutputFormat, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct MaterializationRequest { + pub repo: RepoSelector, + pub native_request_path: Option, + pub source_root: Option, + pub mode: String, + pub include_fts: bool, + pub semantic_enrichment: bool, + pub semantic_provider_mode: String, + pub use_git: bool, + pub git_diff: bool, + pub git_base: Option, + pub include_patterns: Vec, + pub exclude_patterns: Vec, + pub candidate_paths: Vec, + pub parallel: bool, + pub progress: bool, + pub output_format: OutputFormat, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RepositoryLifecycleRequest { + pub repo: RepoSelector, + pub action: String, + pub output_format: OutputFormat, + pub dry_run: bool, + pub mcp_client: Option, + pub mcp_config_path: Option, + pub instructions_target: Option, + pub skip_mcp_config: bool, + pub mode: String, + pub include_fts: bool, + pub semantic_enrichment: bool, + pub semantic_provider_mode: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct McpInstallRequest { + pub repo: RepoSelector, + pub client: String, + pub scope: String, + pub name: Option, + pub client_config_path: Option, + pub dry_run: bool, + pub output_format: OutputFormat, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RefreshRequest { + pub repo: RepoSelector, + pub paths: Vec, + pub mode: String, + pub include_fts: bool, + pub semantic_enrichment: bool, + pub semantic_provider_mode: String, + pub parallel: bool, + pub progress: bool, + pub output_format: OutputFormat, +} + +#[derive(Clone, Copy, Debug)] +pub struct RefreshLoopConfig { + pub poll_interval: Duration, + pub debounce: Duration, + pub max_wait: Duration, + pub max_iterations: Option, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum RefreshBackend { + Auto, + Native, + Poll, +} + +#[derive(Clone, Copy, Debug)] +pub struct RefreshWatchConfig { + pub backend: RefreshBackend, + pub loop_config: RefreshLoopConfig, + pub once: bool, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct RefreshWatchSummary { + pub rebuilt: usize, + pub deleted: usize, + pub skipped: bool, + pub database_written: bool, +} + +pub trait RefreshWatchObserver { + fn on_success( + &mut self, + backend: Option<&str>, + summary: &RefreshWatchSummary, + event_count: usize, + changed_paths: usize, + ) -> Result<(), String>; + + fn on_error( + &mut self, + backend: &str, + error: &str, + retrying: bool, + event_count: usize, + changed_paths: usize, + ) -> Result<(), String>; + + fn on_fallback(&mut self, backend: &str, reason: &str) -> Result<(), String>; +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct OperationResponse { + pub operation: String, + pub output_format: OutputFormat, + pub payload: serde_json::Value, + pub diagnostics: Vec, +} + +impl OperationResponse { + pub fn from_payload( + operation: &str, + output_format: OutputFormat, + payload: serde_json::Value, + ) -> Self { + Self { + operation: operation.to_string(), + output_format, + payload, + diagnostics: Vec::new(), + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ApiError { + pub code: String, + pub message: String, + pub details: Option, + pub retryable: bool, +} + +impl ApiError { + pub fn new(code: &str, message: impl Into) -> Self { + Self { + code: code.to_string(), + message: message.into(), + details: None, + retryable: false, + } + } + + pub fn with_details(mut self, details: serde_json::Value) -> Self { + self.details = Some(details); + self + } + + pub fn retryable(mut self, retryable: bool) -> Self { + self.retryable = retryable; + self + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + fn repository() -> RepoSelector { + RepoSelector { + repo_root: Some(PathBuf::from("/tmp/repository")), + config_path: Some(PathBuf::from("/tmp/config.json")), + db_path: Some(PathBuf::from("/tmp/graph.ldb")), + manifest_path: Some(PathBuf::from("/tmp/manifest.json")), + } + } + + fn materialization() -> MaterializationRequest { + MaterializationRequest { + repo: repository(), + native_request_path: None, + source_root: Some("/tmp/repository".to_string()), + mode: "changed".to_string(), + include_fts: true, + semantic_enrichment: true, + semantic_provider_mode: "local_only".to_string(), + use_git: true, + git_diff: false, + git_base: None, + include_patterns: vec!["src/**".to_string()], + exclude_patterns: vec!["target/**".to_string()], + candidate_paths: vec!["src/lib.rs".to_string()], + parallel: true, + progress: false, + output_format: OutputFormat::Typed, + } + } + + fn lifecycle(action: &str) -> RepositoryLifecycleRequest { + RepositoryLifecycleRequest { + repo: repository(), + action: action.to_string(), + output_format: OutputFormat::Typed, + dry_run: false, + mcp_client: Some("none".to_string()), + mcp_config_path: Some(PathBuf::from("/tmp/mcp.json")), + instructions_target: None, + skip_mcp_config: true, + mode: "full".to_string(), + include_fts: true, + semantic_enrichment: false, + semantic_provider_mode: "local_only".to_string(), + } + } + + #[test] + fn public_operation_contracts_round_trip_without_transport_types() { + let requests = vec![ + OperationRequest::Health(HealthRequest { + repo: repository(), + refresh_status: Some(json!({"running": true})), + output_format: OutputFormat::Block, + }), + OperationRequest::Search(SearchRequest { + repo: repository(), + query: "execute operation".to_string(), + profile: "brief".to_string(), + limit: 3, + budget: 600, + context_limit: 2, + max_depth: Some(2), + detail: "slim".to_string(), + output_format: OutputFormat::Typed, + }), + OperationRequest::Context(ContextRequest { + repo: repository(), + query: None, + profile: "dependencies".to_string(), + limit: 3, + budget: 600, + context_limit: 2, + max_depth: None, + detail: "standard".to_string(), + node_id: Some("node-1".to_string()), + node_type: Some("Function".to_string()), + output_format: OutputFormat::Block, + }), + OperationRequest::Query(QueryRequest { + repo: repository(), + statement: "MATCH (n) RETURN n LIMIT 1".to_string(), + parameters: json!({}), + limit: 1, + output_format: OutputFormat::Typed, + }), + OperationRequest::Materialize(materialization()), + OperationRequest::Plan(materialization()), + OperationRequest::Catalog { + kind: "architecture-queries".to_string(), + group: Some("dependencies".to_string()), + output_format: OutputFormat::Block, + }, + OperationRequest::Setup(lifecycle("setup")), + OperationRequest::Reinstall(lifecycle("reinstall")), + OperationRequest::Uninstall(lifecycle("uninstall")), + OperationRequest::InstallMcp(McpInstallRequest { + repo: repository(), + client: "generic".to_string(), + scope: "local".to_string(), + name: Some("codebase_graph".to_string()), + client_config_path: Some(PathBuf::from("/tmp/mcp.json")), + dry_run: true, + output_format: OutputFormat::Typed, + }), + OperationRequest::Refresh(RefreshRequest { + repo: repository(), + paths: vec!["src/lib.rs".to_string()], + mode: "changed".to_string(), + include_fts: true, + semantic_enrichment: true, + semantic_provider_mode: "local_only".to_string(), + parallel: true, + progress: false, + output_format: OutputFormat::Typed, + }), + ]; + + for request in requests { + let encoded = serde_json::to_value(&request).expect("request should serialize"); + let decoded: OperationRequest = + serde_json::from_value(encoded.clone()).expect("request should deserialize"); + assert_eq!( + serde_json::to_value(decoded).expect("decoded request should serialize"), + encoded + ); + } + + let node = NodeRef { + id: "node-1".to_string(), + kind: "Function".to_string(), + }; + let encoded_node = serde_json::to_value(&node).unwrap(); + assert_eq!( + serde_json::to_value(serde_json::from_value::(encoded_node.clone()).unwrap()) + .unwrap(), + encoded_node + ); + + let response = OperationResponse::from_payload("search", OutputFormat::Typed, json!({})); + let encoded_response = serde_json::to_value(&response).unwrap(); + assert_eq!( + serde_json::to_value( + serde_json::from_value::(encoded_response.clone()).unwrap() + ) + .unwrap(), + encoded_response + ); + + let error = ApiError::new("temporary_failure", "try again") + .with_details(json!({"attempt": 1})) + .retryable(true); + let encoded_error = serde_json::to_value(&error).unwrap(); + assert_eq!( + serde_json::to_value( + serde_json::from_value::(encoded_error.clone()).unwrap() + ) + .unwrap(), + encoded_error + ); + + let source = include_str!("contracts.rs"); + assert!(!source.contains(&["crate", "::cli"].concat())); + assert!(!source.contains(&["mcp", "::"].concat())); + + let invocation = OperationInvocation { + repo: repository(), + arguments: json!({"query": "needle"}), + output_format: OutputFormat::Block, + }; + let encoded = serde_json::to_value(&invocation).expect("invocation should serialize"); + let decoded: OperationInvocation = + serde_json::from_value(encoded.clone()).expect("invocation should deserialize"); + assert_eq!( + serde_json::to_value(decoded).expect("decoded invocation should serialize"), + encoded + ); + } +} diff --git a/src/api/core.rs b/src/api/core.rs new file mode 100644 index 0000000..db22b3c --- /dev/null +++ b/src/api/core.rs @@ -0,0 +1,918 @@ +use crate::api::catalog::{filter_catalog, load_catalog}; +use crate::api::context::{resolve_runtime, RepoRuntime}; +use crate::api::contracts::{ + ApiError, ContextRequest, HealthRequest, MaterializationRequest, OperationInvocation, + OperationRequest, OperationResponse, OutputFormat, QueryRequest, SearchRequest, +}; +use crate::api::graph_read::{ + count_graph_nodes, execute_graph_context, execute_graph_search, execute_read_only_query, + validate_read_only_statement, GraphSearchRequest, +}; +use crate::api::lifecycle::{ + install_mcp_client, refresh_repository, reinstall_repository, setup_repository, + uninstall_repository, +}; +use crate::api::materialization::{ + build_request, execute_materialization_request, plan_materialization_payload, read_request, + MaterializeOptions, +}; +use crate::api::normalization::{ + normalize_request, prepare_operation_request, validate_request, DEFAULT_CONTEXT_LIMIT, + DEFAULT_DETAIL, DEFAULT_PROFILE, DEFAULT_QUERY_LIMIT, DEFAULT_SEARCH_BUDGET, + DEFAULT_SEARCH_LIMIT, +}; +use crate::api::presenter::present_operation_response; +use crate::api::refresh::RefreshState; +use crate::error::NativeError; +use crate::protocol::{NativeSyntaxMaterializationRequest, NativeSyntaxMaterializationResponse}; +use serde_json::json; +use std::{collections::BTreeMap, sync::Arc}; + +#[derive(Debug, Clone, Copy)] +pub struct OperationDescriptor { + pub id: &'static str, + pub summary: &'static str, + pub request_schema: fn() -> serde_json::Value, + pub surfaces: &'static [&'static str], + pub supported_outputs: &'static [OutputFormat], + pub mcp_tool_name: Option<&'static str>, +} + +impl OperationDescriptor { + pub fn required_fields(&self) -> &'static [&'static str] { + crate::api::normalization::required_fields(self.id) + } +} + +#[derive(Debug, Clone)] +pub(crate) struct OperationRegistry { + pub(crate) operations: BTreeMap<&'static str, RegisteredOperation>, +} + +type OperationHandler = + fn(&OperationRequest, Option<&RepoRuntime>) -> Result; + +#[derive(Debug, Clone, Copy)] +pub(crate) struct RegisteredOperation { + descriptor: OperationDescriptor, + handler: OperationHandler, +} + +#[derive(Debug, Clone, Default)] +pub struct ApiCore { + refresh: Option>, +} + +impl ApiCore { + pub fn new() -> Self { + Self::default() + } + + pub(crate) fn with_refresh_state(refresh: Option>) -> Self { + Self { refresh } + } + + pub(crate) fn register_operations(&self) -> OperationRegistry { + let descriptors = [ + OperationDescriptor { + id: "health", + summary: "Check codebase graph and manifest availability", + request_schema: empty_request_schema, + surfaces: &["api", "cli", "mcp"], + supported_outputs: &[OutputFormat::Typed, OutputFormat::Block], + mcp_tool_name: Some("graph_health"), + }, + OperationDescriptor { + id: "search", + summary: "Search for code graph entities", + request_schema: search_request_schema, + surfaces: &["api", "cli", "mcp"], + supported_outputs: &[OutputFormat::Typed, OutputFormat::Block], + mcp_tool_name: Some("graph_search"), + }, + OperationDescriptor { + id: "context", + summary: "Return graph context for matches or explicit nodes", + request_schema: context_request_schema, + surfaces: &["api", "cli", "mcp"], + supported_outputs: &[OutputFormat::Typed, OutputFormat::Block], + mcp_tool_name: Some("graph_context"), + }, + OperationDescriptor { + id: "query", + summary: "Execute read-only graph query", + request_schema: query_request_schema, + surfaces: &["api", "cli", "mcp"], + supported_outputs: &[OutputFormat::Typed, OutputFormat::Block], + mcp_tool_name: Some("graph_query"), + }, + OperationDescriptor { + id: "materialize", + summary: "Scan and build full graph materialization", + request_schema: empty_request_schema, + surfaces: &["api", "cli"], + supported_outputs: &[OutputFormat::Typed, OutputFormat::Block], + mcp_tool_name: None, + }, + OperationDescriptor { + id: "plan", + summary: "Plan materialization without database write", + request_schema: empty_request_schema, + surfaces: &["api", "cli"], + supported_outputs: &[OutputFormat::Typed, OutputFormat::Block], + mcp_tool_name: None, + }, + OperationDescriptor { + id: "schema", + summary: "Return graph schema catalog", + request_schema: empty_request_schema, + surfaces: &["api", "cli", "mcp"], + supported_outputs: &[OutputFormat::Typed, OutputFormat::Block], + mcp_tool_name: Some("graph_schema"), + }, + OperationDescriptor { + id: "query-helpers", + summary: "Return query helper catalog", + request_schema: empty_request_schema, + surfaces: &["api", "cli", "mcp"], + supported_outputs: &[OutputFormat::Typed, OutputFormat::Block], + mcp_tool_name: Some("graph_query_helpers"), + }, + OperationDescriptor { + id: "architecture-queries", + summary: "Return architecture query catalog", + request_schema: architecture_queries_request_schema, + surfaces: &["api", "cli", "mcp"], + supported_outputs: &[OutputFormat::Typed, OutputFormat::Block], + mcp_tool_name: Some("graph_architecture_queries"), + }, + OperationDescriptor { + id: "setup", + summary: "Install graph state and optional MCP configuration", + request_schema: empty_request_schema, + surfaces: &["api", "cli"], + supported_outputs: &[OutputFormat::Typed, OutputFormat::Block], + mcp_tool_name: None, + }, + OperationDescriptor { + id: "reinstall", + summary: "Rebuild graph state after backup/restore choreography", + request_schema: empty_request_schema, + surfaces: &["api", "cli"], + supported_outputs: &[OutputFormat::Typed, OutputFormat::Block], + mcp_tool_name: None, + }, + OperationDescriptor { + id: "uninstall", + summary: "Remove graph state and related MCP configuration", + request_schema: empty_request_schema, + surfaces: &["api", "cli"], + supported_outputs: &[OutputFormat::Typed, OutputFormat::Block], + mcp_tool_name: None, + }, + OperationDescriptor { + id: "mcp-install", + summary: "Register the graph tool with a supported client", + request_schema: empty_request_schema, + surfaces: &["api", "cli"], + supported_outputs: &[OutputFormat::Typed, OutputFormat::Block], + mcp_tool_name: None, + }, + OperationDescriptor { + id: "refresh", + summary: "Refresh changed paths via incremental materialization", + request_schema: empty_request_schema, + surfaces: &["api", "cli"], + supported_outputs: &[OutputFormat::Typed, OutputFormat::Block], + mcp_tool_name: None, + }, + ]; + + build_registry(descriptors) + } + + pub(crate) fn resolve_operation(&self, id: &str) -> Option { + self.register_operations().operations.get(id).copied() + } + + pub(crate) fn resolve_mcp_operation(&self, tool_name: &str) -> Option { + self.operations() + .into_iter() + .find(|operation| operation.mcp_tool_name == Some(tool_name)) + } + + pub fn execute(&self, request: &OperationRequest) -> Result { + let mut request = normalize_request(request); + if let (Some(refresh), OperationRequest::Health(health)) = (&self.refresh, &mut request) { + if health.refresh_status.is_none() { + health.refresh_status = Some(refresh.as_json()); + } + } + validate_request(&request)?; + let _refresh_read_guard = if operation_requires_consistent_graph_read(&request) { + self.refresh + .as_ref() + .map(|refresh| refresh.read_guard()) + .transpose() + .map_err(|error| ApiError::new("refresh_lock_failed", error))? + } else { + None + }; + let operation = self + .resolve_operation(request.operation_name()) + .ok_or_else(|| { + ApiError::new( + "unsupported_operation", + format!("unsupported operation: {}", request.operation_name()), + ) + })?; + let runtime = request + .repo_selector() + .map(resolve_runtime) + .transpose() + .map_err(|error| ApiError::new("runtime_resolution_failed", error))?; + let response = dispatch_operation(&request, &operation, runtime.as_ref())?; + Ok(present_operation_response( + response, + request.output_format(), + )) + } + + pub(crate) fn execute_invocation( + &self, + operation_id: &str, + invocation: &OperationInvocation, + ) -> Result { + let request = prepare_operation_request(operation_id, invocation)?; + self.execute(&request) + } + + pub(crate) fn operations(&self) -> Vec { + let mut items: Vec<_> = self + .register_operations() + .operations + .values() + .map(|operation| operation.descriptor) + .collect(); + items.sort_by_key(|descriptor| descriptor.id); + items + } +} + +fn build_registry(descriptors: impl IntoIterator) -> OperationRegistry { + let mut by_id = BTreeMap::new(); + for descriptor in descriptors { + let handler: OperationHandler = match descriptor.id { + "health" => handle_health, + "search" => handle_search, + "context" => handle_context, + "query" => handle_query, + "materialize" => handle_materialize, + "plan" => handle_plan, + "schema" | "query-helpers" | "architecture-queries" => handle_catalog, + "setup" => handle_setup, + "reinstall" => handle_reinstall, + "uninstall" => handle_uninstall, + "mcp-install" => handle_mcp_install, + "refresh" => handle_refresh, + id => panic!("operation {id} does not have a handler registration"), + }; + assert!( + !descriptor.supported_outputs.is_empty(), + "operation {} must expose at least one output format", + descriptor.id + ); + assert_eq!( + descriptor.surfaces.contains(&"mcp"), + descriptor.mcp_tool_name.is_some(), + "operation {} has inconsistent MCP exposure", + descriptor.id + ); + if by_id + .insert( + descriptor.id, + RegisteredOperation { + descriptor, + handler, + }, + ) + .is_some() + { + panic!("duplicate operation registration: {}", descriptor.id); + } + } + OperationRegistry { operations: by_id } +} + +fn empty_request_schema() -> serde_json::Value { + json!({}) +} + +fn search_request_schema() -> serde_json::Value { + json!({ + "query": {"type": "string"}, + "limit": {"type": "integer", "minimum": 1, "default": DEFAULT_SEARCH_LIMIT}, + "profile": {"type": "string", "default": DEFAULT_PROFILE}, + "budget": {"type": "integer", "minimum": 0, "default": DEFAULT_SEARCH_BUDGET}, + "context_limit": {"type": "integer", "minimum": 0, "default": DEFAULT_CONTEXT_LIMIT}, + "max_depth": {"type": "integer", "minimum": 0}, + "detail": {"type": "string", "enum": ["standard", "slim"], "default": DEFAULT_DETAIL}, + }) +} + +fn context_request_schema() -> serde_json::Value { + json!({ + "query": {"type": "string"}, + "node_id": {"type": "string"}, + "node_type": {"type": "string"}, + "limit": {"type": "integer", "minimum": 1, "default": DEFAULT_SEARCH_LIMIT}, + "profile": {"type": "string", "default": DEFAULT_PROFILE}, + "budget": {"type": "integer", "minimum": 0, "default": DEFAULT_SEARCH_BUDGET}, + "context_limit": {"type": "integer", "minimum": 0, "default": DEFAULT_CONTEXT_LIMIT}, + "max_depth": {"type": "integer", "minimum": 0}, + "detail": {"type": "string", "enum": ["standard", "slim"], "default": DEFAULT_DETAIL}, + }) +} + +fn query_request_schema() -> serde_json::Value { + json!({ + "statement": {"type": "string"}, + "parameters": {"type": "object"}, + "query": {"type": "string"}, + "limit": {"type": "integer", "minimum": 1, "maximum": 1000, "default": DEFAULT_QUERY_LIMIT}, + }) +} + +fn architecture_queries_request_schema() -> serde_json::Value { + json!({ + "group": {"type": "string"}, + }) +} + +fn dispatch_operation( + request: &OperationRequest, + operation: &RegisteredOperation, + runtime: Option<&RepoRuntime>, +) -> Result { + (operation.handler)(request, runtime) +} + +fn operation_requires_consistent_graph_read(request: &OperationRequest) -> bool { + matches!( + request, + OperationRequest::Health(_) + | OperationRequest::Search(_) + | OperationRequest::Context(_) + | OperationRequest::Query(_) + ) +} + +fn required_runtime<'a>( + operation: &str, + runtime: Option<&'a RepoRuntime>, +) -> Result<&'a RepoRuntime, ApiError> { + runtime.ok_or_else(|| { + ApiError::new( + "runtime_resolution_failed", + format!("{operation} operation requires a repository selector"), + ) + }) +} + +fn invalid_request(expected: &str, request: &OperationRequest) -> ApiError { + ApiError::new( + "invalid_operation_request", + format!( + "operation handler expected {expected}, received {}", + request.operation_name() + ), + ) +} + +fn handle_health( + request: &OperationRequest, + runtime: Option<&RepoRuntime>, +) -> Result { + let OperationRequest::Health(request) = request else { + return Err(invalid_request("health", request)); + }; + execute_health(request, required_runtime("health", runtime)?) +} + +fn handle_search( + request: &OperationRequest, + runtime: Option<&RepoRuntime>, +) -> Result { + let OperationRequest::Search(request) = request else { + return Err(invalid_request("search", request)); + }; + execute_search(request, required_runtime("search", runtime)?) +} + +fn handle_context( + request: &OperationRequest, + runtime: Option<&RepoRuntime>, +) -> Result { + let OperationRequest::Context(request) = request else { + return Err(invalid_request("context", request)); + }; + execute_context(request, required_runtime("context", runtime)?) +} + +fn handle_query( + request: &OperationRequest, + runtime: Option<&RepoRuntime>, +) -> Result { + let OperationRequest::Query(request) = request else { + return Err(invalid_request("query", request)); + }; + execute_query(request, required_runtime("query", runtime)?) +} + +fn handle_materialize( + request: &OperationRequest, + runtime: Option<&RepoRuntime>, +) -> Result { + let OperationRequest::Materialize(request) = request else { + return Err(invalid_request("materialize", request)); + }; + execute_materialization(request, required_runtime("materialize", runtime)?, false) +} + +fn handle_plan( + request: &OperationRequest, + runtime: Option<&RepoRuntime>, +) -> Result { + let OperationRequest::Plan(request) = request else { + return Err(invalid_request("plan", request)); + }; + execute_materialization(request, required_runtime("plan", runtime)?, true) +} + +fn handle_catalog( + request: &OperationRequest, + _runtime: Option<&RepoRuntime>, +) -> Result { + let OperationRequest::Catalog { kind, group, .. } = request else { + return Err(invalid_request("catalog", request)); + }; + let mut payload = + load_catalog(kind).map_err(|error| ApiError::new("invalid_catalog_kind", error))?; + filter_catalog(kind, &mut payload, group.as_deref()) + .map_err(|error| ApiError::new("invalid_catalog_group", error))?; + Ok(OperationResponse::from_payload( + kind, + OutputFormat::Typed, + payload, + )) +} + +fn handle_setup( + request: &OperationRequest, + runtime: Option<&RepoRuntime>, +) -> Result { + let OperationRequest::Setup(request) = request else { + return Err(invalid_request("setup", request)); + }; + Ok(OperationResponse::from_payload( + "setup", + OutputFormat::Typed, + setup_repository(request, required_runtime("setup", runtime)?)?, + )) +} + +fn handle_reinstall( + request: &OperationRequest, + runtime: Option<&RepoRuntime>, +) -> Result { + let OperationRequest::Reinstall(request) = request else { + return Err(invalid_request("reinstall", request)); + }; + Ok(OperationResponse::from_payload( + "reinstall", + OutputFormat::Typed, + reinstall_repository(request, required_runtime("reinstall", runtime)?)?, + )) +} + +fn handle_uninstall( + request: &OperationRequest, + runtime: Option<&RepoRuntime>, +) -> Result { + let OperationRequest::Uninstall(request) = request else { + return Err(invalid_request("uninstall", request)); + }; + Ok(OperationResponse::from_payload( + "uninstall", + OutputFormat::Typed, + uninstall_repository(request, required_runtime("uninstall", runtime)?)?, + )) +} + +fn handle_mcp_install( + request: &OperationRequest, + runtime: Option<&RepoRuntime>, +) -> Result { + let OperationRequest::InstallMcp(request) = request else { + return Err(invalid_request("mcp-install", request)); + }; + Ok(OperationResponse::from_payload( + "mcp-install", + OutputFormat::Typed, + install_mcp_client(request, required_runtime("mcp-install", runtime)?)?, + )) +} + +fn handle_refresh( + request: &OperationRequest, + runtime: Option<&RepoRuntime>, +) -> Result { + let OperationRequest::Refresh(request) = request else { + return Err(invalid_request("refresh", request)); + }; + Ok(OperationResponse::from_payload( + "refresh", + OutputFormat::Typed, + refresh_repository(request, required_runtime("refresh", runtime)?)?, + )) +} + +fn execute_health( + request: &HealthRequest, + runtime: &RepoRuntime, +) -> Result { + let output_format = request.output_format; + + let mut graph_readable = false; + let mut total_nodes = 0_u64; + let mut error_message = None; + let database_exists = runtime.db_path.exists(); + let manifest_exists = runtime.manifest_path.exists(); + + if database_exists { + match count_graph_nodes(&runtime.db_path) { + Ok(count) => { + graph_readable = true; + total_nodes = count; + } + Err(error) => { + error_message = Some(error); + } + } + } else { + error_message = Some(format!( + "database file does not exist: {}", + runtime.db_path.display() + )); + } + + let payload = json!({ + "ok": database_exists && graph_readable, + "repo_root": runtime.repo_root, + "database_path": runtime.db_path, + "manifest_path": runtime.manifest_path, + "database_exists": database_exists, + "manifest_exists": manifest_exists, + "graph_readable": graph_readable, + "total_nodes": total_nodes, + "error": error_message, + "refresh": request.refresh_status, + }); + Ok(OperationResponse::from_payload( + "health", + output_format, + payload, + )) +} + +fn execute_search( + request: &SearchRequest, + runtime: &RepoRuntime, +) -> Result { + let output_format = request.output_format; + let options = GraphSearchRequest { + query: request.query.clone(), + limit: request.limit, + profile: request.profile.clone(), + budget: request.budget, + context_limit: request.context_limit, + max_depth: request.max_depth, + detail: request.detail.clone(), + }; + let results = execute_graph_search(&runtime.db_path, &options) + .map_err(|error| ApiError::new("search_execution_failed", error.to_string()))?; + let payload = serde_json::json!({ + "query": request.query, + "profile": request.profile, + "limit": request.limit, + "budget": request.budget, + "results": results, + }); + Ok(OperationResponse::from_payload( + "search", + output_format, + payload, + )) +} + +fn execute_context( + request: &ContextRequest, + runtime: &RepoRuntime, +) -> Result { + let output_format = request.output_format; + let search = GraphSearchRequest { + query: request.query.clone().unwrap_or_default(), + profile: request.profile.clone(), + limit: request.limit, + budget: request.budget, + context_limit: request.context_limit, + max_depth: request.max_depth, + detail: request.detail.clone(), + }; + let payload = if let (Some(node_id), Some(node_type)) = + (request.node_id.as_ref(), request.node_type.as_ref()) + { + let context = execute_graph_context(&runtime.db_path, node_id, node_type, &search) + .map_err(|error| ApiError::new("context_execution_failed", error.to_string()))?; + json!({ + "node_id": node_id, + "node_type": node_type, + "profile": request.profile, + "context": context, + }) + } else { + if search.query.is_empty() { + return Err(ApiError::new( + "context_validation_failed", + "query is required", + )); + } + let results = execute_graph_search(&runtime.db_path, &search) + .map_err(|error| ApiError::new("context_execution_failed", error.to_string()))?; + json!({ + "query": request.query, + "profile": request.profile, + "limit": request.limit, + "budget": request.budget, + "results": results, + }) + }; + Ok(OperationResponse::from_payload( + "context", + output_format, + payload, + )) +} + +fn execute_query( + request: &QueryRequest, + runtime: &RepoRuntime, +) -> Result { + validate_read_only_statement(&request.statement) + .map_err(|error| ApiError::new("query_validation_failed", error.to_string()))?; + let output_format = request.output_format; + let parameters = request + .parameters + .as_object() + .expect("normalized query parameters must be an object") + .clone(); + let (rows, truncated) = execute_read_only_query( + &runtime.db_path, + &request.statement, + ¶meters, + request.limit, + ) + .map_err(|error| ApiError::new("query_execution_failed", error.to_string()))?; + let payload = serde_json::json!({ + "statement": request.statement, + "row_count": rows.len(), + "rows": rows, + "truncated": truncated, + }); + Ok(OperationResponse::from_payload( + "query", + output_format, + payload, + )) +} + +fn execute_materialization( + request: &MaterializationRequest, + runtime: &RepoRuntime, + dry_plan: bool, +) -> Result { + let output_format = request.output_format; + let materialize_options = MaterializeOptions::from_request(request, runtime, dry_plan); + + let mut native_request = if let Some(request_path) = request.native_request_path.as_ref() { + read_request(request_path) + .map_err(|error| ApiError::new("materialization_request_failed", error))? + } else { + build_request(&materialize_options) + .map_err(|error| ApiError::new("materialization_build_failed", error))? + }; + + let response = if dry_plan { + native_request.atomic_rebuild = false; + execute_plan_native(&native_request) + .map_err(|error| ApiError::new("materialization_plan_failed", error.to_string()))? + } else { + execute_materialization_request(&materialize_options, native_request) + .map(|(_, response)| response) + .map_err(|error| ApiError::new("materialization_failed", error))? + }; + + let payload = materialization_payload(request, &response, &runtime.manifest_path, dry_plan); + let mut operation_response = OperationResponse::from_payload( + if dry_plan { "plan" } else { "materialize" }, + output_format, + payload, + ); + operation_response.diagnostics = response.diagnostics.clone(); + Ok(operation_response) +} + +fn execute_plan_native( + request: &NativeSyntaxMaterializationRequest, +) -> Result { + crate::plan_materialization(request) +} + +fn materialization_payload( + request: &MaterializationRequest, + response: &NativeSyntaxMaterializationResponse, + manifest_path: &std::path::Path, + dry_plan: bool, +) -> serde_json::Value { + if dry_plan { + plan_materialization_payload(response, &request.mode, manifest_path) + } else { + serde_json::to_value(response).unwrap_or_else(|_| json!({})) + } +} + +#[cfg(test)] +mod tests { + use crate::api::contracts::{McpInstallRequest, OperationRequest, OutputFormat, RepoSelector}; + use crate::api::core::ApiCore; + use serde_json::json; + use std::{fs, path::PathBuf}; + + fn unique_temp_dir(prefix: &str) -> PathBuf { + std::env::temp_dir().join(format!( + "{prefix}-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )) + } + + #[test] + fn operation_registry_is_unique_and_sorted() { + let core = ApiCore::new(); + let operations = core.operations(); + let ids: Vec<_> = operations.iter().map(|entry| entry.id).collect(); + let mut sorted = ids.clone(); + sorted.sort(); + assert_eq!(ids, sorted); + assert!(ids.iter().all(|id| core.resolve_operation(id).is_some())); + } + + #[test] + fn resolve_unknown_operation_is_none() { + let core = ApiCore::new(); + assert!(core.resolve_operation("_missing_").is_none()); + } + + #[test] + fn duplicate_operations_are_rejected_in_registration() { + let core = ApiCore::new(); + let descriptor = core + .operations() + .into_iter() + .find(|descriptor| descriptor.id == "health") + .expect("health descriptor should exist"); + let result = std::panic::catch_unwind(|| super::build_registry([descriptor, descriptor])); + assert!(result.is_err()); + } + + #[test] + fn unknown_operation_request_is_rejected() { + let core = ApiCore::new(); + let unsupported = OperationRequest::Catalog { + kind: "_missing_".to_string(), + group: None, + output_format: crate::api::contracts::OutputFormat::Typed, + }; + let error = core + .execute(&unsupported) + .expect_err("unknown operation should be rejected"); + assert_eq!(error.code, "unsupported_operation"); + assert!(!error.retryable); + } + + #[test] + fn resolve_operation_returns_registered_descriptor_and_handler() { + let core = ApiCore::new(); + let operation = core + .resolve_operation("schema") + .expect("schema should be registered"); + assert_eq!(operation.descriptor.id, "schema"); + let response = (operation.handler)( + &OperationRequest::Catalog { + kind: "schema".to_string(), + group: None, + output_format: crate::api::contracts::OutputFormat::Typed, + }, + None, + ) + .expect("registered schema handler should execute"); + assert_eq!(response.operation, "schema"); + } + + #[test] + fn resolve_mcp_operation_uses_registered_operation_metadata() { + let core = ApiCore::new(); + let operation = core + .resolve_mcp_operation("graph_search") + .expect("registered MCP operation should resolve"); + assert_eq!(operation.id, "search"); + assert!(core.resolve_mcp_operation("graph_missing").is_none()); + } + + #[test] + fn refresh_read_policy_covers_repository_graph_reads() { + let repo = RepoSelector::default(); + let typed = OutputFormat::Typed; + assert!(super::operation_requires_consistent_graph_read( + &OperationRequest::Health(crate::api::contracts::HealthRequest { + repo: repo.clone(), + refresh_status: None, + output_format: typed, + }) + )); + assert!(super::operation_requires_consistent_graph_read( + &OperationRequest::Query(crate::api::contracts::QueryRequest { + repo, + statement: "MATCH (n) RETURN n".to_string(), + parameters: json!({}), + limit: 1, + output_format: typed, + }) + )); + assert!(!super::operation_requires_consistent_graph_read( + &OperationRequest::Catalog { + kind: "schema".to_string(), + group: None, + output_format: typed, + } + )); + } + + #[test] + fn mcp_install_operation_preserves_unrelated_client_configuration() { + let root = unique_temp_dir("codebase-graph-api-mcp-install"); + let client_config = root.join("client").join("mcp.json"); + fs::create_dir_all(client_config.parent().unwrap()).unwrap(); + fs::write( + &client_config, + serde_json::to_string_pretty(&json!({ + "mcpServers": { + "unrelated": {"command": "keep", "args": []} + } + })) + .unwrap(), + ) + .unwrap(); + + let response = ApiCore::new() + .execute(&OperationRequest::InstallMcp(McpInstallRequest { + repo: RepoSelector { + repo_root: Some(root.clone()), + config_path: None, + db_path: None, + manifest_path: None, + }, + client: "generic".to_string(), + scope: "local".to_string(), + name: Some("codebase_graph_test".to_string()), + client_config_path: Some(client_config.clone()), + dry_run: false, + output_format: OutputFormat::Typed, + })) + .expect("API MCP install should succeed"); + + assert_eq!(response.operation, "mcp-install"); + assert_eq!(response.payload["action"], "updated"); + let payload: serde_json::Value = + serde_json::from_str(&fs::read_to_string(&client_config).unwrap()).unwrap(); + assert_eq!(payload["mcpServers"]["unrelated"]["command"], "keep"); + assert_eq!( + payload["mcpServers"]["codebase_graph_test"]["command"], + "codebase-graph" + ); + let _ = fs::remove_dir_all(root); + } +} diff --git a/src/api/facade.rs b/src/api/facade.rs new file mode 100644 index 0000000..3c47d8d --- /dev/null +++ b/src/api/facade.rs @@ -0,0 +1,125 @@ +use crate::api::{ + contracts::{ + ApiError, MaterializationRequest, OperationInvocation, OperationRequest, OperationResponse, + RefreshWatchConfig, RefreshWatchObserver, RepoSelector, + }, + core::{ApiCore, OperationDescriptor}, + refresh::{run_refresh_watch, start_refresh_service}, +}; + +pub trait OperationExecutor { + fn execute(&self, request: &OperationRequest) -> Result; +} + +impl OperationExecutor for ApiCore { + fn execute(&self, request: &OperationRequest) -> Result { + ApiCore::execute(self, request) + } +} + +#[derive(Debug, Clone)] +pub struct CodebaseGraphApi { + core: C, +} + +impl CodebaseGraphApi { + pub fn new() -> Self { + Self { + core: ApiCore::new(), + } + } + + pub fn operation_descriptors(&self) -> Vec { + self.core.operations() + } + + pub fn resolve_mcp_operation(&self, tool_name: &str) -> Option { + self.core.resolve_mcp_operation(tool_name) + } + + pub(crate) fn with_auto_refresh(selector: RepoSelector) -> Self { + Self { + core: ApiCore::with_refresh_state(Some(start_refresh_service(selector))), + } + } + + pub(crate) fn watch_repository( + &self, + request: &MaterializationRequest, + config: RefreshWatchConfig, + observer: &mut impl RefreshWatchObserver, + ) -> Result<(), ApiError> { + run_refresh_watch(request, config, observer) + .map_err(|error| ApiError::new("refresh_watch_failed", error)) + } + + pub(crate) fn latest_mcp_protocol_version() -> &'static str { + "2025-11-25" + } + + pub fn execute_invocation( + &self, + operation_id: &str, + invocation: &OperationInvocation, + ) -> Result { + self.core.execute_invocation(operation_id, invocation) + } +} + +impl CodebaseGraphApi { + pub fn execute_operation( + &self, + request: &OperationRequest, + ) -> Result { + self.core.execute(request) + } +} + +impl Default for CodebaseGraphApi { + fn default() -> Self { + Self::new() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::api::contracts::OutputFormat; + use std::cell::Cell; + + struct SpyCore { + calls: Cell, + } + + impl OperationExecutor for SpyCore { + fn execute(&self, request: &OperationRequest) -> Result { + self.calls.set(self.calls.get() + 1); + Ok(OperationResponse::from_payload( + request.operation_name(), + request.output_format(), + serde_json::json!({"ok": true}), + )) + } + } + + #[test] + fn execute_operation_delegates_exactly_once() { + let api = CodebaseGraphApi { + core: SpyCore { + calls: Cell::new(0), + }, + }; + let request = OperationRequest::Catalog { + kind: "schema".to_string(), + group: None, + output_format: OutputFormat::Typed, + }; + + let response = api + .execute_operation(&request) + .expect("spy operation should succeed"); + + assert_eq!(api.core.calls.get(), 1); + assert_eq!(response.operation, "schema"); + } +} diff --git a/src/cli/graph/search.rs b/src/api/graph_read.rs similarity index 58% rename from src/cli/graph/search.rs rename to src/api/graph_read.rs index a52a122..6d959a3 100644 --- a/src/cli/graph/search.rs +++ b/src/api/graph_read.rs @@ -1,21 +1,40 @@ -use super::{ - options::GraphSearchOptions, - query::{ - cypher_identifier, cypher_single_quoted, span_json, value_to_f64, value_to_i64, - value_to_string, - }, +use crate::api::catalog::support::{metadata_payload, value_array, value_str, GRAPH_SCHEMA_JSON}; +use crate::db_writer::{ + connect_ladybug_database, open_ladybug_database, retry_transient_database, READ_RETRY_POLICY, }; -use crate::cli::{ - constants::GRAPH_SCHEMA_JSON, - format::{metadata_payload, value_array, value_str}, -}; -use lbug::{Connection, Database, SystemConfig}; +use lbug::{Connection, Database, SystemConfig, Value}; use serde_json::json; use std::{collections::BTreeSet, path::Path}; -pub(in crate::cli) fn execute_graph_search( +#[derive(Debug, Clone)] +pub(crate) struct GraphSearchRequest { + pub(crate) query: String, + pub(crate) limit: usize, + pub(crate) profile: String, + pub(crate) budget: usize, + pub(crate) context_limit: usize, + pub(crate) max_depth: Option, + pub(crate) detail: String, +} + +pub(crate) fn count_graph_nodes(db_path: &Path) -> Result { + let db = retry_transient_database(READ_RETRY_POLICY, || open_ladybug_database(db_path, true)) + .map_err(|error| error.to_string())?; + let conn = connect_ladybug_database(&db).map_err(|error| error.to_string())?; + let mut result = conn + .query("MATCH (n) RETURN count(n) AS total_nodes LIMIT 1") + .map_err(|error| format!("failed to query graph health: {error}"))?; + let row = result + .next() + .ok_or_else(|| "graph health query returned no rows".to_string())?; + row.first() + .and_then(value_to_u64) + .ok_or_else(|| "graph health query returned a non-numeric node count".to_string()) +} + +pub(crate) fn execute_graph_search( db_path: &Path, - options: &GraphSearchOptions, + options: &GraphSearchRequest, ) -> Result, String> { let db = Database::new(db_path, SystemConfig::default().read_only(true)).map_err(|error| { format!( @@ -85,11 +104,11 @@ pub(in crate::cli) fn execute_graph_search( Ok(payloads) } -pub(in crate::cli) fn execute_graph_context( +pub(crate) fn execute_graph_context( db_path: &Path, node_id: &str, node_type: &str, - options: &GraphSearchOptions, + options: &GraphSearchRequest, ) -> Result, String> { if options.context_limit == 0 || options.budget == 0 { return Ok(Vec::new()); @@ -168,7 +187,231 @@ pub(in crate::cli) fn execute_graph_context( Ok(context) } -pub(in crate::cli) fn query_relation_neighbors( +pub(crate) fn validate_read_only_statement(statement: &str) -> Result<(), String> { + let stripped = statement.trim().trim_end_matches(';'); + if stripped.contains(';') { + return Err("graph_query accepts one read-only statement at a time".to_string()); + } + for keyword in [ + "ALTER", "ATTACH", "CALL", "COPY", "CREATE", "DELETE", "DETACH", "DROP", "EXPORT", + "IMPORT", "INSERT", "INSTALL", "LOAD", "MERGE", "REMOVE", "RENAME", "SET", "TRUNCATE", + "UPDATE", "USE", + ] { + if contains_keyword(stripped, keyword) { + return Err(format!( + "graph_query is read-only; blocked keyword: {keyword}" + )); + } + } + Ok(()) +} + +pub(crate) fn execute_read_only_query( + db_path: &Path, + statement: &str, + parameters: &serde_json::Map, + limit: usize, +) -> Result<(Vec>, bool), String> { + let db = retry_transient_database(READ_RETRY_POLICY, || open_ladybug_database(db_path, true)) + .map_err(|error| error.to_string())?; + let conn = connect_ladybug_database(&db).map_err(|error| error.to_string())?; + let mut result = if parameters.is_empty() { + conn.query(statement) + .map_err(|error| format!("failed to execute graph query: {error}"))? + } else { + let named_parameters = lbug_query_parameters(parameters)?; + let mut prepared = conn + .prepare(statement) + .map_err(|error| format!("failed to prepare graph query: {error}"))?; + if !prepared.is_read_only() { + return Err("graph-query prepared statement is not read-only".to_string()); + } + let execute_parameters = named_parameters + .iter() + .map(|(name, value)| (name.as_str(), value.clone())) + .collect(); + conn.execute(&mut prepared, execute_parameters) + .map_err(|error| format!("failed to execute graph query: {error}"))? + }; + let mut rows = Vec::new(); + let mut truncated = false; + for row in result.by_ref().take(limit + 1) { + if rows.len() == limit { + truncated = true; + break; + } + rows.push(row.into_iter().map(json_safe_value).collect()); + } + Ok((rows, truncated)) +} + +pub(crate) fn cypher_single_quoted(value: &str) -> String { + value.replace('\\', "\\\\").replace('\'', "\\'") +} + +fn cypher_identifier(value: &str) -> String { + value.replace('`', "``") +} + +fn span_json(line_start: Option, line_end: Option) -> serde_json::Value { + let mut span = serde_json::Map::new(); + if let Some(line_start) = line_start { + span.insert("line_start".to_string(), json!(line_start)); + } + if let Some(line_end) = line_end { + span.insert("line_end".to_string(), json!(line_end)); + } + serde_json::Value::Object(span) +} + +fn value_to_string(value: Option<&Value>) -> String { + match value { + Some(Value::String(value)) => value.clone(), + Some(Value::Int64(value)) => value.to_string(), + Some(Value::UInt64(value)) => value.to_string(), + Some(Value::Int32(value)) => value.to_string(), + Some(Value::UInt32(value)) => value.to_string(), + Some(Value::Null(_)) | None => String::new(), + Some(value) => value.to_string(), + } +} + +fn value_to_i64(value: Option<&Value>) -> Option { + match value { + Some(Value::Int64(value)) => Some(*value), + Some(Value::Int32(value)) => Some(i64::from(*value)), + Some(Value::Int16(value)) => Some(i64::from(*value)), + Some(Value::Int8(value)) => Some(i64::from(*value)), + Some(Value::UInt64(value)) => i64::try_from(*value).ok(), + Some(Value::UInt32(value)) => Some(i64::from(*value)), + Some(Value::UInt16(value)) => Some(i64::from(*value)), + Some(Value::UInt8(value)) => Some(i64::from(*value)), + _ => None, + } +} + +fn value_to_u64(value: &Value) -> Option { + match value { + Value::Int64(value) if *value >= 0 => Some(*value as u64), + Value::Int32(value) if *value >= 0 => Some(*value as u64), + Value::Int16(value) if *value >= 0 => Some(*value as u64), + Value::Int8(value) if *value >= 0 => Some(*value as u64), + Value::UInt64(value) => Some(*value), + Value::UInt32(value) => Some(u64::from(*value)), + Value::UInt16(value) => Some(u64::from(*value)), + Value::UInt8(value) => Some(u64::from(*value)), + _ => None, + } +} + +fn value_to_f64(value: Option<&Value>) -> f64 { + match value { + Some(Value::Double(value)) => *value, + Some(Value::Float(value)) => f64::from(*value), + Some(Value::Int64(value)) => *value as f64, + Some(Value::UInt64(value)) => *value as f64, + Some(Value::Int32(value)) => f64::from(*value), + Some(Value::UInt32(value)) => f64::from(*value), + _ => 0.0, + } +} + +fn contains_keyword(statement: &str, keyword: &str) -> bool { + let uppercase = statement.to_ascii_uppercase(); + let mut search_start = 0; + while let Some(index) = uppercase[search_start..].find(keyword) { + let absolute_index = search_start + index; + let before = uppercase[..absolute_index] + .chars() + .next_back() + .map(is_keyword_char) + .unwrap_or(false); + let after = uppercase[absolute_index + keyword.len()..] + .chars() + .next() + .map(is_keyword_char) + .unwrap_or(false); + if !before && !after { + return true; + } + search_start = absolute_index + keyword.len(); + } + false +} + +fn is_keyword_char(character: char) -> bool { + character.is_ascii_alphanumeric() || character == '_' +} + +fn lbug_query_parameters( + parameters: &serde_json::Map, +) -> Result, String> { + let mut converted = Vec::with_capacity(parameters.len()); + for (name, value) in parameters { + if name.trim().is_empty() { + return Err("graph_query parameter names must not be blank".to_string()); + } + converted.push((name.clone(), json_parameter_to_lbug_value(value)?)); + } + Ok(converted) +} + +fn json_parameter_to_lbug_value(value: &serde_json::Value) -> Result { + match value { + serde_json::Value::Bool(value) => Ok(Value::Bool(*value)), + serde_json::Value::Number(value) => { + if let Some(value) = value.as_i64() { + Ok(Value::Int64(value)) + } else if let Some(value) = value.as_u64() { + Ok(Value::UInt64(value)) + } else if let Some(value) = value.as_f64() { + Ok(Value::Double(value)) + } else { + Err("graph_query numeric parameter is not representable".to_string()) + } + } + serde_json::Value::String(value) => Ok(Value::String(value.clone())), + serde_json::Value::Null | serde_json::Value::Array(_) | serde_json::Value::Object(_) => { + Ok(Value::Json(value.clone())) + } + } +} + +fn json_safe_value(value: Value) -> serde_json::Value { + match value { + Value::Null(_) => serde_json::Value::Null, + Value::Bool(value) => json!(value), + Value::Int64(value) => json!(value), + Value::Int32(value) => json!(value), + Value::Int16(value) => json!(value), + Value::Int8(value) => json!(value), + Value::UInt64(value) => json!(value), + Value::UInt32(value) => json!(value), + Value::UInt16(value) => json!(value), + Value::UInt8(value) => json!(value), + Value::Int128(value) => json!(value.to_string()), + Value::Double(value) => serde_json::Number::from_f64(value) + .map(serde_json::Value::Number) + .unwrap_or_else(|| json!(value.to_string())), + Value::Float(value) => serde_json::Number::from_f64(f64::from(value)) + .map(serde_json::Value::Number) + .unwrap_or_else(|| json!(value.to_string())), + Value::String(value) => json!(value), + Value::Json(value) => value, + Value::List(_, values) | Value::Array(_, values) => { + serde_json::Value::Array(values.into_iter().map(json_safe_value).collect()) + } + Value::Struct(values) => serde_json::Value::Object( + values + .into_iter() + .map(|(key, value)| (key, json_safe_value(value))) + .collect(), + ), + other => json!(other.to_string()), + } +} + +fn query_relation_neighbors( conn: &Connection, schema: &serde_json::Value, node_id: &str, @@ -263,7 +506,7 @@ pub(in crate::cli) fn query_relation_neighbors( Ok(neighbors) } -pub(in crate::cli) fn relation_type<'a>( +fn relation_type<'a>( schema: &'a serde_json::Value, relation: &str, ) -> Option<&'a serde_json::Value> { @@ -272,7 +515,7 @@ pub(in crate::cli) fn relation_type<'a>( .find(|value| value_str(value, "name") == relation) } -pub(in crate::cli) fn neighbor_statement( +fn neighbor_statement( node_type: &str, neighbor_type: &str, relation: &str, @@ -305,7 +548,7 @@ pub(in crate::cli) fn neighbor_statement( } } -pub(in crate::cli) fn search_fts_index( +fn search_fts_index( conn: &Connection, node_type: &str, index_name: &str, @@ -348,29 +591,29 @@ pub(in crate::cli) fn search_fts_index( Ok(rows) } -pub(in crate::cli) fn is_missing_search_target_error(error: &str) -> bool { +fn is_missing_search_target_error(error: &str) -> bool { error.contains("does not exist") || error.contains("doesn't have an index") || error.contains("Index not found") } #[derive(Debug, Clone)] -pub(in crate::cli) struct SearchHitRow { - pub(in crate::cli) id: String, - pub(in crate::cli) node_type: String, - pub(in crate::cli) label: String, - pub(in crate::cli) qualified_name: String, - pub(in crate::cli) path: String, - pub(in crate::cli) line_start: Option, - pub(in crate::cli) line_end: Option, - pub(in crate::cli) summary: String, - pub(in crate::cli) score: f64, - pub(in crate::cli) rank_score: f64, - pub(in crate::cli) index_order: usize, +struct SearchHitRow { + id: String, + node_type: String, + label: String, + qualified_name: String, + path: String, + line_start: Option, + line_end: Option, + summary: String, + score: f64, + rank_score: f64, + index_order: usize, } impl SearchHitRow { - pub(in crate::cli) fn into_json(self, options: &GraphSearchOptions) -> serde_json::Value { + fn into_json(self, options: &GraphSearchRequest) -> serde_json::Value { let span = span_json(self.line_start, self.line_end); if options.detail == "slim" { let mut payload = json!({ @@ -410,7 +653,7 @@ impl SearchHitRow { } } -pub(in crate::cli) fn rank_search_hits(hits: &mut [SearchHitRow], query: &str) { +fn rank_search_hits(hits: &mut [SearchHitRow], query: &str) { let max_score = hits.iter().map(|hit| hit.score).fold(0.0, f64::max); for hit in hits { let fts_score = if max_score > 0.0 { @@ -425,7 +668,7 @@ pub(in crate::cli) fn rank_search_hits(hits: &mut [SearchHitRow], query: &str) { } } -pub(in crate::cli) fn lexical_score(query: &str, hit: &SearchHitRow) -> f64 { +fn lexical_score(query: &str, hit: &SearchHitRow) -> f64 { let normalized_query = query.to_ascii_lowercase(); if normalized_query.is_empty() { return 0.0; @@ -444,7 +687,7 @@ pub(in crate::cli) fn lexical_score(query: &str, hit: &SearchHitRow) -> f64 { } } -pub(in crate::cli) fn entity_priority_score(node_type: &str) -> f64 { +fn entity_priority_score(node_type: &str) -> f64 { match node_type { "Class" | "Function" | "Method" | "Module" | "Variable" | "Parameter" | "Field" | "Enum" | "Interface" | "Trait" | "Struct" => 1.0, @@ -457,6 +700,6 @@ pub(in crate::cli) fn entity_priority_score(node_type: &str) -> f64 { } } -pub(in crate::cli) fn round6(value: f64) -> f64 { +fn round6(value: f64) -> f64 { (value * 1_000_000.0).round() / 1_000_000.0 } diff --git a/src/api/lifecycle.rs b/src/api/lifecycle.rs new file mode 100644 index 0000000..677881b --- /dev/null +++ b/src/api/lifecycle.rs @@ -0,0 +1,2087 @@ +use crate::api::context::RepoRuntime; +use crate::api::contracts::{ + ApiError, McpInstallRequest, RefreshRequest, RepositoryLifecycleRequest, +}; +use crate::api::materialization::{ + build_request, default_excluded_parts, execute_candidate_materialization, + execute_materialization, MaterializeOptions, +}; +use crate::protocol::{NativeSyntaxMaterializationRequest, NativeSyntaxMaterializationResponse}; +use serde_json::json; +use std::env; +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::Command; + +pub(crate) fn is_retryable_refresh_failure(error: &str) -> bool { + crate::db_writer::is_transient_database_error(error) +} + +pub(crate) fn setup_repository( + request: &RepositoryLifecycleRequest, + runtime: &RepoRuntime, +) -> Result { + validate_lifecycle_action(request, "setup")?; + setup_payload_for_request(request, &runtime.repo_root) + .map_err(|error| ApiError::new("setup_failed", error)) +} + +pub(crate) fn reinstall_repository( + request: &RepositoryLifecycleRequest, + runtime: &RepoRuntime, +) -> Result { + validate_lifecycle_action(request, "reinstall")?; + reinstall_payload_for_request(request, &runtime.repo_root) + .map_err(|error| ApiError::new("reinstall_failed", error)) +} + +pub(crate) fn uninstall_repository( + request: &RepositoryLifecycleRequest, + runtime: &RepoRuntime, +) -> Result { + validate_lifecycle_action(request, "uninstall")?; + uninstall_payload_for_request(request, &runtime.repo_root) + .map_err(|error| ApiError::new("uninstall_failed", error)) +} + +pub(crate) fn install_mcp_client( + request: &McpInstallRequest, + runtime: &RepoRuntime, +) -> Result { + let config_path = runtime.config_path.clone(); + let install = |client: &str| { + apply_mcp_client_configuration( + client, + &request.scope, + request.name.clone(), + config_path.clone(), + request.client_config_path.clone(), + Some(runtime.repo_root.clone()), + request.dry_run, + ) + }; + if request.client == "all" { + let results = supported_mcp_clients() + .iter() + .copied() + .map(|client| { + install(client).unwrap_or_else(|error| { + json!({ + "action": "failed", + "client": client, + "scope": install_scope(client, &request.scope), + "server_name": request.name.clone().unwrap_or_else(|| "codebase_graph".to_string()), + "method": serde_json::Value::Null, + "path": serde_json::Value::Null, + "command": serde_json::Value::Null, + "descriptor": {}, + "entry": {}, + "error": error, + }) + }) + }) + .collect::>(); + Ok(json!({ "results": results })) + } else { + install(&request.client).map_err(|error| ApiError::new("mcp_install_failed", error)) + } +} + +fn validate_lifecycle_action( + request: &RepositoryLifecycleRequest, + expected: &str, +) -> Result<(), ApiError> { + if request.action == expected { + Ok(()) + } else { + Err(ApiError::new( + "invalid_lifecycle_action", + format!( + "lifecycle request action {} does not match {expected}", + request.action + ), + )) + } +} + +pub(crate) fn refresh_repository( + request: &RefreshRequest, + runtime: &RepoRuntime, +) -> Result { + let options = MaterializeOptions { + source_root: Some(runtime.repo_root.clone()), + config: runtime.config_path.clone(), + db: Some(runtime.db_path.clone()), + manifest: Some(runtime.manifest_path.clone()), + use_git: false, + mode: request.mode.clone(), + include_fts: request.include_fts, + semantic_enrichment: request.semantic_enrichment, + semantic_provider_mode: request.semantic_provider_mode.clone(), + git_diff: false, + git_base: None, + include_patterns: Vec::new(), + exclude_patterns: Vec::new(), + candidate_paths: request.paths.clone(), + parallel: request.parallel, + progress: request.progress, + plan_only: false, + ..MaterializeOptions::default() + }; + + let (_request, response) = + execute_candidate_materialization(&options, options.candidate_paths.clone()).map_err( + |error| { + let retryable = is_retryable_refresh_failure(&error); + ApiError::new("refresh_failed", error).retryable(retryable) + }, + )?; + Ok(serde_json::to_value(response).unwrap_or_else(|_| serde_json::json!({}))) +} + +#[derive(Debug, Clone)] +struct LifecycleOptions { + mode: String, + include_fts: bool, + semantic_enrichment: bool, + semantic_provider_mode: String, + mcp_client: String, + mcp_config_path: Option, + skip_mcp_config: bool, + dry_run: bool, + instructions_target: String, +} + +impl LifecycleOptions { + fn from_request(request: &RepositoryLifecycleRequest) -> Self { + Self { + mode: request.mode.clone(), + include_fts: request.include_fts, + semantic_enrichment: request.semantic_enrichment, + semantic_provider_mode: request.semantic_provider_mode.clone(), + mcp_client: request + .mcp_client + .clone() + .unwrap_or_else(|| "codex".to_string()), + mcp_config_path: request.mcp_config_path.clone(), + skip_mcp_config: request.skip_mcp_config, + dry_run: request.dry_run, + instructions_target: request + .instructions_target + .clone() + .unwrap_or_else(|| "auto".to_string()), + } + } +} + +fn setup_payload_for_request( + request: &RepositoryLifecycleRequest, + source_root: &Path, +) -> Result { + let options = LifecycleOptions::from_request(request); + setup_payload_for_root(&options, source_root) +} + +fn setup_payload_for_root( + options: &LifecycleOptions, + source_root: &Path, +) -> Result { + let source_root = source_root.to_path_buf(); + let paths = GraphStatePaths::derive(&source_root); + reject_state_dir_root(&source_root)?; + + let materialize_options = MaterializeOptions { + source_root: Some(source_root.clone()), + db: Some(paths.db_path.clone()), + manifest: Some(paths.manifest_path.clone()), + mode: options.mode.clone(), + include_fts: options.include_fts, + semantic_enrichment: options.semantic_enrichment, + semantic_provider_mode: options.semantic_provider_mode.clone(), + use_git: true, + ..MaterializeOptions::default() + }; + let config_payload = setup_config_payload(&paths, &source_root); + let instructions_path = instruction_target_path(&source_root, &options.instructions_target)?; + let state_dir_existed = paths.state_dir.exists(); + let graph_state_existed = + paths.config_path.exists() && paths.db_path.exists() && paths.manifest_path.exists(); + let previous_config = snapshot_file(&paths.config_path)?; + let previous_instructions = match instructions_path.as_ref() { + Some(path) => Some((path.clone(), snapshot_file(path)?)), + None => None, + }; + + let (config_action, instructions, mcp_config, materialization) = if options.dry_run { + let request = materialization_request(&materialize_options)?; + let materialization = dry_run_materialization_payload(&request, &paths); + let config_action = if json_file_would_change(&paths.config_path, &config_payload)? { + "dry_run" + } else { + "unchanged" + }; + let instructions = json!({ + "action": if instructions_path.is_some() { "dry_run" } else { "skipped" }, + "path": instructions_path.as_ref().map(|path| path.to_string_lossy().to_string()), + }); + let mcp_config = setup_mcp_config(options, &paths, true)?; + ( + config_action.to_string(), + instructions, + mcp_config, + materialization, + ) + } else { + fs::create_dir_all(&paths.state_dir).map_err(|error| { + format!( + "failed to create state directory {}: {error}", + paths.state_dir.display() + ) + })?; + let result = (|| { + let config_action = write_setup_config(&paths, &source_root)?; + let instructions = upsert_instruction_block( + &source_root, + &options.instructions_target, + &paths.config_path, + )?; + let materialization = if graph_state_existed { + existing_graph_materialization_payload(&materialize_options.mode, &paths) + } else { + let (_, response) = execute_materialization(&materialize_options)?; + materialization_payload(&response, &materialize_options.mode, &paths) + }; + let mcp_config = setup_mcp_config(options, &paths, false)?; + Ok::<_, String>(( + config_action.to_string(), + instructions, + mcp_config, + materialization, + )) + })(); + match result { + Ok(result) => result, + Err(error) => { + restore_file(&paths.config_path, previous_config.as_deref())?; + if let Some((path, previous)) = previous_instructions.as_ref() { + restore_file(path, previous.as_deref())?; + } + if !state_dir_existed { + let _ = fs::remove_dir_all(&paths.state_dir); + } + return Err(error); + } + } + }; + + Ok(json!({ + "ok": true, + "repo_root": source_root, + "repo_name": paths.repo_name, + "state_dir": paths.state_dir, + "db_path": paths.db_path, + "database_path": paths.db_path, + "manifest_path": paths.manifest_path, + "config_path": paths.config_path, + "config_action": config_action, + "mcp_config": mcp_config, + "instructions": instructions, + "materialization": materialization, + "database_written": materialization.get("database_written").cloned().unwrap_or(json!(false)), + "skipped": materialization.get("skipped").cloned().unwrap_or(json!(0)), + "node_rows": materialization.get("node_rows").cloned().unwrap_or(json!(0)), + "edge_rows": materialization.get("edge_rows").cloned().unwrap_or(json!(0)), + "connector_rows": materialization.get("connector_rows").cloned().unwrap_or(json!(0)), + "diagnostics": materialization.get("diagnostics").cloned().unwrap_or(json!([])), + })) +} + +fn reinstall_payload_for_request( + request: &RepositoryLifecycleRequest, + repo_root: &Path, +) -> Result { + let options = LifecycleOptions::from_request(request); + let repo_root = repo_root.to_path_buf(); + reject_state_dir_root(&repo_root)?; + + let paths = GraphStatePaths::derive(&repo_root); + let state = reinstall_state(&paths, options.dry_run)?; + let install = if options.dry_run { + setup_payload_for_root(&options, &repo_root)? + } else { + match setup_payload_for_root(&options, &repo_root) { + Ok(payload) => { + remove_backup(state.backup_path.as_deref())?; + payload + } + Err(error) => { + restore_backup(&paths.state_dir, state.backup_path.as_deref())?; + return Err(error); + } + } + }; + + Ok(json!({ + "ok": true, + "repo_root": repo_root, + "dry_run": options.dry_run, + "state": state.payload, + "install": install, + })) +} + +fn uninstall_payload_for_request( + request: &RepositoryLifecycleRequest, + repo_root: &Path, +) -> Result { + let repo_root = repo_root.to_path_buf(); + let paths = GraphStatePaths::derive(&repo_root); + let config_path = request + .repo + .config_path + .clone() + .unwrap_or_else(|| paths.config_path.clone()); + let mcp_client = request + .mcp_client + .clone() + .unwrap_or_else(|| "all".to_string()); + let server_name = uninstall_server_name(&repo_root, &config_path)?; + let state = uninstall_state_dir(&paths.state_dir, request.dry_run)?; + let instructions = uninstall_instruction_blocks(&repo_root, request.dry_run)?; + let mcp_clients = uninstall_mcp_clients( + &mcp_client, + request.mcp_config_path.as_deref(), + &repo_root, + &config_path, + &server_name, + request.dry_run, + )?; + + Ok(json!({ + "ok": true, + "repo_root": repo_root, + "config_path": config_path, + "server_name": server_name, + "dry_run": request.dry_run, + "state": state, + "instructions": instructions, + "mcp_clients": mcp_clients, + })) +} + +fn reject_state_dir_root(repo_root: &Path) -> Result<(), String> { + if repo_root + .components() + .any(|component| component.as_os_str() == ".codebaseGraph") + { + Err(format!( + "Repository root may not be inside a .codebaseGraph state directory: {}", + repo_root.display() + )) + } else { + Ok(()) + } +} + +fn existing_graph_materialization_payload( + mode: &str, + paths: &GraphStatePaths, +) -> serde_json::Value { + json!({ + "mode": mode, + "database_path": paths.db_path, + "manifest_path": paths.manifest_path, + "database_written": false, + "skipped": true, + "skip_reason": "existing_graph_state", + "rebuilt": 0, + "deleted": 0, + "node_rows": 0, + "edge_rows": 0, + "connector_rows": 0, + "diagnostics": [], + "phase_timings": {}, + }) +} + +fn materialization_request( + options: &MaterializeOptions, +) -> Result { + build_request(options) +} + +fn materialization_payload( + response: &NativeSyntaxMaterializationResponse, + mode: &str, + paths: &GraphStatePaths, +) -> serde_json::Value { + let rebuilt_paths = response.diff.rebuild_paths(); + let skipped_paths = response + .snapshots + .iter() + .filter_map(|(path, snapshot)| { + if snapshot.language.is_none() { + Some(path.clone()) + } else { + None + } + }) + .collect::>(); + let ignored_paths = response + .diagnostics + .iter() + .filter_map(|diagnostic| diagnostic.strip_prefix("Ignored file: ")) + .map(str::to_string) + .collect::>(); + json!({ + "mode": mode, + "scanned": response.snapshots.len(), + "rebuilt": rebuilt_paths.len(), + "skipped": skipped_paths.len(), + "ignored": ignored_paths.len(), + "deleted": response.diff.deleted.len(), + "diagnostics": response.diagnostics, + "manifest_path": paths.manifest_path, + "rebuilt_paths": rebuilt_paths, + "skipped_paths": skipped_paths.clone(), + "ignored_paths": ignored_paths, + "deleted_paths": response.diff.deleted.clone(), + "would_rebuild": response.diff.rebuild_paths(), + "would_delete": response.diff.deleted, + "would_skip": skipped_paths, + "graph_summary": response.graph_summary, + "node_rows": response.node_rows, + "edge_rows": response.edge_rows, + "connector_rows": response.connector_rows, + "database_written": response.database_written, + "progress_events": response.progress_events, + "phase_timings": response.phase_timings, + }) +} + +fn dry_run_materialization_payload( + request: &NativeSyntaxMaterializationRequest, + paths: &GraphStatePaths, +) -> serde_json::Value { + let snapshots = scan_source_snapshots(Path::new(&request.source_root)); + let scanned = snapshots.len(); + let skipped_paths = snapshots + .into_iter() + .filter_map(|(path, language)| if language.is_none() { Some(path) } else { None }) + .collect::>(); + json!({ + "mode": "dry_run", + "scanned": scanned, + "rebuilt": 0, + "skipped": skipped_paths.len(), + "deleted": 0, + "diagnostics": [], + "manifest_path": paths.manifest_path, + "rebuilt_paths": [], + "skipped_paths": skipped_paths, + "deleted_paths": [], + "graph_summary": {}, + }) +} + +fn scan_source_snapshots(root: &Path) -> Vec<(String, Option<&'static str>)> { + let mut snapshots = Vec::new(); + scan_source_snapshots_inner(root, root, &mut snapshots); + snapshots.sort_by(|left, right| left.0.cmp(&right.0)); + snapshots +} + +fn scan_source_snapshots_inner( + root: &Path, + directory: &Path, + snapshots: &mut Vec<(String, Option<&'static str>)>, +) { + let Ok(entries) = fs::read_dir(directory) else { + return; + }; + for entry in entries.flatten() { + let path = entry.path(); + let name = path + .file_name() + .and_then(|value| value.to_str()) + .unwrap_or(""); + if default_excluded_parts().iter().any(|part| part == name) { + continue; + } + if path.is_dir() { + scan_source_snapshots_inner(root, &path, snapshots); + } else if path.is_file() { + let relative = path.strip_prefix(root).unwrap_or(&path).to_string_lossy(); + snapshots.push((relative.to_string(), language_for_path(&path))); + } + } +} + +fn language_for_path(path: &Path) -> Option<&'static str> { + match path.extension().and_then(|value| value.to_str()) { + Some("py") => Some("python"), + Some("rs") => Some("rust"), + Some("go") => Some("go"), + Some("c") | Some("h") => Some("c"), + Some("cc") | Some("cpp") | Some("cxx") | Some("hpp") | Some("hh") => Some("cpp"), + Some("f") | Some("f90") | Some("f95") | Some("for") => Some("fortran"), + _ => None, + } +} + +fn setup_mcp_config( + options: &LifecycleOptions, + paths: &GraphStatePaths, + dry_run: bool, +) -> Result { + let descriptor = build_mcp_descriptor( + "generic", + "local", + Some("codebase_graph".to_string()), + Some(paths.config_path.clone()), + None, + Some( + paths + .state_dir + .parent() + .map(Path::to_path_buf) + .unwrap_or_else(|| PathBuf::from(".")), + ), + )?; + if options.skip_mcp_config || options.mcp_client == "none" { + return Ok(json!({ + "action": "skipped", + "client": options.mcp_client, + "scope": "local", + "server_name": descriptor.name, + "method": serde_json::Value::Null, + "path": serde_json::Value::Null, + "command": serde_json::Value::Null, + "descriptor": descriptor.as_json(), + "entry": descriptor.stdio_entry(false, true), + })); + } + + apply_mcp_client_configuration( + &options.mcp_client, + if options.mcp_client == "claude-project" { + "project" + } else { + "local" + }, + Some("codebase_graph".to_string()), + Some(paths.config_path.clone()), + options.mcp_config_path.clone(), + Some( + paths + .state_dir + .parent() + .map(Path::to_path_buf) + .unwrap_or_else(|| PathBuf::from(".")), + ), + dry_run, + ) +} + +fn uninstall_server_name(repo_root: &Path, config_path: &Path) -> Result { + if config_path.exists() { + let config = read_json_file(config_path)?; + if let Some(name) = config + .pointer("/mcp/server_name") + .and_then(serde_json::Value::as_str) + .filter(|value| !value.trim().is_empty()) + { + return Ok(name.to_string()); + } + } + Ok(build_mcp_descriptor( + "generic", + "local", + None, + Some(config_path.to_path_buf()), + None, + Some(repo_root.to_path_buf()), + )? + .name) +} + +fn uninstall_state_dir(path: &Path, dry_run: bool) -> Result { + if !path.exists() { + return Ok(json!({"action": "unchanged", "path": path})); + } + if !dry_run { + fs::remove_dir_all(path).map_err(|error| { + format!( + "failed to remove state directory {}: {error}", + path.display() + ) + })?; + } + Ok(json!({"action": if dry_run { "dry_run" } else { "removed" }, "path": path})) +} + +fn uninstall_instruction_blocks( + repo_root: &Path, + dry_run: bool, +) -> Result, String> { + ["AGENTS.md", "CLAUDE.md"] + .into_iter() + .map(|file_name| uninstall_instruction_file(&repo_root.join(file_name), dry_run)) + .collect() +} + +fn uninstall_instruction_file(path: &Path, dry_run: bool) -> Result { + let Ok(existing) = fs::read_to_string(path) else { + return Ok(json!({"action": "unchanged", "path": path})); + }; + let (next, removed) = remove_instruction_text(&existing); + if !removed { + return Ok(json!({"action": "unchanged", "path": path})); + } + if !dry_run { + fs::write(path, next).map_err(|error| { + format!("failed to update instructions {}: {error}", path.display()) + })?; + } + Ok(json!({"action": if dry_run { "dry_run" } else { "removed" }, "path": path})) +} + +fn uninstall_mcp_clients( + mcp_client: &str, + client_config_path: Option<&Path>, + repo_root: &Path, + config_path: &Path, + server_name: &str, + dry_run: bool, +) -> Result, String> { + let clients = if mcp_client == "all" { + supported_mcp_clients() + .iter() + .copied() + .map(str::to_string) + .collect::>() + } else { + vec![mcp_client.to_string()] + }; + + Ok(clients + .into_iter() + .map(|client| { + uninstall_mcp_client( + &client, + client_config_path, + repo_root, + config_path, + server_name, + dry_run, + ) + .unwrap_or_else(|error| { + json!({ + "action": "failed", + "client": client, + "server_name": server_name, + "error": error, + }) + }) + }) + .collect()) +} + +fn uninstall_mcp_client( + client: &str, + client_config_path: Option<&Path>, + repo_root: &Path, + config_path: &Path, + server_name: &str, + dry_run: bool, +) -> Result { + if matches!(client, "copilot-studio" | "microsoft-copilot") { + return Ok(json!({ + "action": "skipped", + "reason": "manual_metadata", + "client": client, + "server_name": server_name, + })); + } + + let scope = if client == "claude-project" { + "project" + } else { + "local" + }; + let descriptor = build_mcp_descriptor( + client, + scope, + Some(server_name.to_string()), + Some(config_path.to_path_buf()), + client_config_path.map(Path::to_path_buf), + Some(repo_root.to_path_buf()), + )?; + let normalized_scope = install_scope(client, scope); + let path = client_config_path + .map(Path::to_path_buf) + .unwrap_or_else(|| default_client_config_path(client, &normalized_scope, &descriptor)); + let existing = fs::read_to_string(&path).ok(); + let removed = + remove_client_config(client, &normalized_scope, existing.as_deref(), server_name)?; + if removed.action == "removed" && !dry_run { + write_text_atomic(&path, &removed.text)?; + } + let action = if removed.action == "removed" && dry_run { + "dry_run".to_string() + } else { + removed.action + }; + Ok(json!({ + "action": action, + "client": client, + "scope": normalized_scope, + "server_name": server_name, + "path": path, + "previous": removed.previous, + "payload": removed.payload, + })) +} + +pub(crate) fn remove_instruction_text(existing: &str) -> (String, bool) { + const START: &str = ""; + const END: &str = ""; + let Some(start) = existing.find(START) else { + return (existing.to_string(), false); + }; + let Some(end) = existing[start..].find(END).map(|index| start + index) else { + return (existing.to_string(), false); + }; + let after_end = end + END.len(); + let before = existing[..start].trim_end(); + let after = existing[after_end..].trim_start(); + let text = match (before.is_empty(), after.is_empty()) { + (true, true) => String::new(), + (true, false) => format!("{after}\n"), + (false, true) => format!("{before}\n"), + (false, false) => format!("{before}\n\n{after}"), + }; + (text, true) +} + +pub(crate) struct GraphStatePaths { + pub(crate) repo_name: String, + pub(crate) state_dir: PathBuf, + pub(crate) db_path: PathBuf, + pub(crate) manifest_path: PathBuf, + pub(crate) config_path: PathBuf, +} + +impl GraphStatePaths { + pub(crate) fn derive(repo_root: &Path) -> Self { + let repo_name = safe_name( + repo_root + .file_name() + .and_then(|value| value.to_str()) + .unwrap_or("repository"), + ); + let state_dir = repo_root.join(".codebaseGraph"); + Self { + db_path: state_dir.join(format!("{repo_name}_graph.ldb")), + manifest_path: state_dir.join("manifest.json"), + config_path: state_dir.join("config.json"), + state_dir, + repo_name, + } + } +} + +pub(crate) fn safe_name(value: &str) -> String { + let normalized: String = value + .chars() + .map(|character| { + if character.is_alphanumeric() || character == '-' || character == '_' { + character + } else { + '_' + } + }) + .collect(); + let trimmed = normalized.trim_matches(['.', '_', '-']); + if trimmed.is_empty() { + "repository".to_string() + } else { + trimmed.to_string() + } +} + +fn setup_config_payload(paths: &GraphStatePaths, repo_root: &Path) -> serde_json::Value { + json!({ + "schema_version": 1, + "repo_root": repo_root, + "repo_name": paths.repo_name, + "state_dir": paths.state_dir, + "database_path": paths.db_path, + "manifest_path": paths.manifest_path, + "ontology_version": "code_ontology_v1", + "package_version": env!("CARGO_PKG_VERSION"), + "materialization": { + "include": [], + "exclude": [] + }, + "mcp": { + "server_name": "codebase_graph", + "command": [ + server_command(), + "mcp", + "start", + "--config", + paths.config_path.to_string_lossy() + ] + } + }) +} + +fn write_setup_config(paths: &GraphStatePaths, repo_root: &Path) -> Result<&'static str, String> { + let payload = setup_config_payload(paths, repo_root); + let mut action = "created"; + if paths.config_path.exists() { + let previous = read_json_file(&paths.config_path)?; + if previous == payload { + return Ok("unchanged"); + } + action = "updated"; + } + if let Some(parent) = paths.config_path.parent() { + fs::create_dir_all(parent).map_err(|error| { + format!( + "failed to create config directory {}: {error}", + parent.display() + ) + })?; + } + let text = serde_json::to_string_pretty(&payload).map_err(|error| error.to_string())?; + fs::write(&paths.config_path, format!("{text}\n")).map_err(|error| { + format!( + "failed to write install config {}: {error}", + paths.config_path.display() + ) + })?; + Ok(action) +} + +fn json_file_would_change(path: &Path, payload: &serde_json::Value) -> Result { + if !path.exists() { + return Ok(true); + } + Ok(read_json_file(path)? != *payload) +} + +fn instruction_target_path(repo_root: &Path, target: &str) -> Result, String> { + match target { + "skip" => Ok(None), + "agents" => Ok(Some(repo_root.join("AGENTS.md"))), + "claude" => Ok(Some(repo_root.join("CLAUDE.md"))), + "auto" => { + let agents = repo_root.join("AGENTS.md"); + if agents.exists() { + return Ok(Some(agents)); + } + let claude = repo_root.join("CLAUDE.md"); + if claude.exists() { + return Ok(Some(claude)); + } + Ok(Some(agents)) + } + other => Err(format!("Unsupported instruction target: {other}")), + } +} + +fn upsert_instruction_block( + repo_root: &Path, + target: &str, + config_path: &Path, +) -> Result { + let Some(path) = instruction_target_path(repo_root, target)? else { + return Ok(json!({"action": "skipped", "path": serde_json::Value::Null})); + }; + let existing = fs::read_to_string(&path).unwrap_or_default(); + let created = !path.exists(); + let block = instruction_block(config_path); + let (next_text, action) = upsert_instruction_text(&existing, &block, created); + if next_text == existing { + return Ok(json!({"action": "unchanged", "path": path.to_string_lossy()})); + } + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).map_err(|error| { + format!( + "failed to create instruction directory {}: {error}", + parent.display() + ) + })?; + } + fs::write(&path, next_text) + .map_err(|error| format!("failed to write instructions {}: {error}", path.display()))?; + Ok(json!({"action": action, "path": path.to_string_lossy()})) +} + +fn instruction_block(config_path: &Path) -> String { + format!( + "\n\ +## codebaseGraph workflow\n\ +- Treat the repo-local `.codebaseGraph` graph as the project operating source of truth. It is prohibited to read the code source before you find the target files using the graph.\n\ +- Prefer the `codebase_graph` MCP server tools over shell commands whenever they are exposed in the current agent session.\n\ +- AI agents receive block output by default for graph CLI and MCP tools; request `output_format: \"json\"` or `include_structured_content: true` only for tests, APIs, or explicit structured-payload debugging.\n\ +- Use MCP `graph_search` with `detail: \"slim\"` and `context_limit: 1` before answering repo-structure questions or performing coding tasks.\n\ +- Use MCP `graph_context` with `profile: \"\"`, `detail: \"slim\"`, and `context_limit: 2` when relationships or nearby evidence matter; useful profiles include `definitions`, `dependencies`, `callgraph`, `docs`, `runtime`, and `change_impact`.\n\ +- For architecture orientation, use MCP `graph_architecture_queries`, then execute selected read-only statements with MCP `graph_query`.\n\ +- Use MCP `graph_schema` or `graph_query_helpers` before writing raw graph queries, and keep `graph_query` read-only.\n\ +- If MCP tools are unavailable, fall back to CLI: `{command} codebase-search --no-refresh --detail slim --context-limit 1`, `{command} codebase-context --profile --no-refresh --detail slim --context-limit 2`, `{command} codebase-architecture-queries`, `{command} graph-query \"\"`, `{command} schema`, and `{command} query-helpers`.\n\ +- Do not rerun install to refresh the graph. The MCP server started from this setup config watches the repo and refreshes automatically; use `{command} build --mode full` only for explicit manual rebuilds. Setup config: `{config_path}`.\n\ +\n", + command = server_command(), + config_path = config_path.to_string_lossy(), + ) +} + +fn upsert_instruction_text(existing: &str, block: &str, created: bool) -> (String, &'static str) { + const START: &str = ""; + const END: &str = ""; + if existing.trim().is_empty() { + return (block.to_string(), "created"); + } + let Some(start) = existing.find(START) else { + let separator = if existing.ends_with('\n') { "" } else { "\n" }; + let action = if created { "created" } else { "updated" }; + return ( + format!("{}{separator}\n{}", existing.trim_end(), block), + action, + ); + }; + let Some(end) = existing.find(END) else { + return ( + format!("{}\n\n{}", existing.trim_end(), block), + if created { "created" } else { "updated" }, + ); + }; + if end < start { + return ( + format!("{}\n\n{}", existing.trim_end(), block), + if created { "created" } else { "updated" }, + ); + } + let after_end = end + END.len(); + let text = format!( + "{}\n\n{}\n\n{}", + existing[..start].trim_end(), + block.trim_end(), + existing[after_end..].trim_start() + ) + .trim() + .to_string() + + "\n"; + (text, "updated") +} + +fn snapshot_file(path: &Path) -> Result>, String> { + if !path.exists() { + return Ok(None); + } + fs::read(path) + .map(Some) + .map_err(|error| format!("failed to snapshot {}: {error}", path.display())) +} + +fn restore_file(path: &Path, previous: Option<&[u8]>) -> Result<(), String> { + match previous { + Some(previous) => { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).map_err(|error| { + format!( + "failed to create restore directory {}: {error}", + parent.display() + ) + })?; + } + fs::write(path, previous) + .map_err(|error| format!("failed to restore {}: {error}", path.display())) + } + None => { + if path.exists() { + fs::remove_file(path) + .map_err(|error| format!("failed to remove {}: {error}", path.display()))?; + } + Ok(()) + } + } +} + +struct ReinstallState { + payload: serde_json::Value, + backup_path: Option, +} + +fn reinstall_state(paths: &GraphStatePaths, dry_run: bool) -> Result { + if !paths.state_dir.exists() { + return Ok(ReinstallState { + payload: json!({ + "action": "unchanged", + "path": paths.state_dir, + "backup_path": serde_json::Value::Null, + }), + backup_path: None, + }); + } + let backup_path = next_backup_path(&paths.state_dir)?; + if dry_run { + return Ok(ReinstallState { + payload: json!({ + "action": "dry_run", + "path": paths.state_dir, + "backup_path": backup_path, + }), + backup_path: None, + }); + } + fs::rename(&paths.state_dir, &backup_path).map_err(|error| { + format!( + "failed to move existing graph state {} to {}: {error}", + paths.state_dir.display(), + backup_path.display() + ) + })?; + Ok(ReinstallState { + payload: json!({ + "action": "backed_up", + "path": paths.state_dir, + "backup_path": backup_path, + }), + backup_path: Some(backup_path), + }) +} + +fn next_backup_path(state_dir: &Path) -> Result { + let parent = state_dir.parent().unwrap_or_else(|| Path::new(".")); + let file_name = state_dir + .file_name() + .and_then(|value| value.to_str()) + .unwrap_or(".codebaseGraph"); + for index in 0..1000 { + let suffix = if index == 0 { + "reinstall-backup".to_string() + } else { + format!("reinstall-backup-{index}") + }; + let candidate = parent.join(format!("{file_name}.{suffix}")); + if !candidate.exists() { + return Ok(candidate); + } + } + Err(format!( + "failed to choose backup path for {}", + state_dir.display() + )) +} + +fn remove_backup(path: Option<&Path>) -> Result<(), String> { + let Some(path) = path else { + return Ok(()); + }; + remove_path(path).map_err(|error| { + format!( + "failed to remove reinstall backup {} after successful setup: {error}", + path.display() + ) + }) +} + +fn restore_backup(state_dir: &Path, backup_path: Option<&Path>) -> Result<(), String> { + let Some(backup_path) = backup_path else { + if state_dir.exists() { + remove_path(state_dir).map_err(|error| { + format!( + "failed to remove partial graph state {} after setup failure: {error}", + state_dir.display() + ) + })?; + } + return Ok(()); + }; + if state_dir.exists() { + remove_path(state_dir).map_err(|error| { + format!( + "failed to remove partial graph state {} before restore: {error}", + state_dir.display() + ) + })?; + } + fs::rename(backup_path, state_dir).map_err(|error| { + format!( + "failed to restore graph state backup {} to {}: {error}", + backup_path.display(), + state_dir.display() + ) + }) +} + +fn remove_path(path: &Path) -> std::io::Result<()> { + if path.is_dir() { + fs::remove_dir_all(path) + } else { + fs::remove_file(path) + } +} + +fn read_json_file(path: &Path) -> Result { + let text = fs::read_to_string(path) + .map_err(|error| format!("failed to read JSON file {}: {error}", path.display()))?; + serde_json::from_str(&text) + .map_err(|error| format!("failed to parse JSON file {}: {error}", path.display())) +} + +fn server_command() -> String { + env::var("CODEBASE_GRAPH_SERVER_COMMAND").unwrap_or_else(|_| "codebase-graph".to_string()) +} + +#[derive(Debug, Clone)] +struct NativeMcpDescriptor { + name: String, + command: String, + args: Vec, + setup_config_path: String, + repo_root: String, + timeout: u64, +} + +impl NativeMcpDescriptor { + fn as_json(&self) -> serde_json::Value { + json!({ + "name": self.name, + "transport": "stdio", + "command": self.command, + "args": self.args, + "env": {}, + "cwd": serde_json::Value::Null, + "setup_config_path": self.setup_config_path, + "repo_root": self.repo_root, + "timeout": self.timeout, + "tool_policy": "graph_query_read_only", + }) + } + + fn stdio_entry(&self, include_type: bool, include_timeout: bool) -> serde_json::Value { + let mut entry = serde_json::Map::new(); + entry.insert("command".to_string(), json!(self.command)); + entry.insert("args".to_string(), json!(self.args)); + if include_type { + entry.insert("type".to_string(), json!("stdio")); + } + if include_timeout { + entry.insert("startup_timeout_sec".to_string(), json!(self.timeout)); + } + serde_json::Value::Object(entry) + } +} + +fn build_mcp_descriptor( + client: &str, + scope: &str, + name: Option, + config_path: Option, + client_config_path: Option, + repo_root: Option, +) -> Result { + let resolved_repo_root = repo_root.unwrap_or_else(|| PathBuf::from(".")); + let config_path = config_path + .clone() + .unwrap_or_else(|| GraphStatePaths::derive(&resolved_repo_root).config_path); + let setup_config = if config_path.exists() { + Some(read_json_file(&config_path)?) + } else { + None + }; + let repo_root = setup_config + .as_ref() + .and_then(|payload| payload.get("repo_root")) + .and_then(serde_json::Value::as_str) + .map(expand_path) + .unwrap_or_else(|| { + config_path + .parent() + .and_then(Path::parent) + .map(Path::to_path_buf) + .unwrap_or(resolved_repo_root.clone()) + }); + let repo_name = setup_config + .as_ref() + .and_then(|payload| payload.get("repo_name")) + .and_then(serde_json::Value::as_str) + .map(str::to_string) + .unwrap_or_else(|| { + safe_name( + repo_root + .file_name() + .and_then(|value| value.to_str()) + .unwrap_or("repository"), + ) + }); + let name = name.unwrap_or_else(|| format!("codebase_graph_{}", install_safe_name(&repo_name))); + let command_from_config = setup_config + .as_ref() + .and_then(|payload| payload.pointer("/mcp/command")) + .and_then(serde_json::Value::as_array) + .and_then(|values| { + let command: Option> = values + .iter() + .map(|value| value.as_str().map(str::to_string)) + .collect(); + command.filter(|parts| parts.len() >= 5) + }); + let (command, args) = if let Some(mut parts) = command_from_config { + let command = parts.remove(0); + (command, parts) + } else { + ( + server_command(), + vec![ + "mcp".to_string(), + "start".to_string(), + "--config".to_string(), + config_path.to_string_lossy().to_string(), + ], + ) + }; + let _ = client; + let _ = scope; + let _ = client_config_path; + Ok(NativeMcpDescriptor { + name, + command, + args, + setup_config_path: config_path.to_string_lossy().to_string(), + repo_root: repo_root.to_string_lossy().to_string(), + timeout: 60, + }) +} + +fn apply_mcp_client_configuration( + client: &str, + scope: &str, + name: Option, + config_path: Option, + client_config_path: Option, + repo_root: Option, + dry_run: bool, +) -> Result { + let descriptor = build_mcp_descriptor( + client, + scope, + name, + config_path, + client_config_path.clone(), + repo_root, + )?; + if client == "copilot-studio" || client == "microsoft-copilot" { + let metadata = copilot_studio_metadata(&descriptor); + return Ok(json!({ + "action": if dry_run { "dry_run" } else { "reported" }, + "client": client, + "scope": scope, + "server_name": descriptor.name, + "method": "manual_metadata", + "path": serde_json::Value::Null, + "command": serde_json::Value::Null, + "descriptor": descriptor.as_json(), + "entry": metadata["stdio"].clone(), + "payload": metadata, + })); + } + + let native_command = native_client_command(client, &descriptor, scope); + let native_available = native_command + .as_ref() + .and_then(|command| command.first()) + .is_some_and(|executable| executable_in_path(executable)); + let normalized_scope = install_scope(client, scope); + + if dry_run && client_config_path.is_none() && native_available { + return Ok(json!({ + "action": "dry_run", + "client": client, + "scope": normalized_scope, + "server_name": descriptor.name, + "method": "native_cli", + "path": serde_json::Value::Null, + "command": native_command, + "descriptor": descriptor.as_json(), + "entry": descriptor.stdio_entry(false, false), + })); + } + if !dry_run && client_config_path.is_none() && native_available { + let Some(command) = native_command.clone() else { + return file_adapter_result( + client, + &normalized_scope, + &descriptor, + None, + None, + dry_run, + client_config_path, + ); + }; + let completed = Command::new(&command[0]) + .args(&command[1..]) + .output() + .map_err(|error| format!("failed to run native client installer: {error}"))?; + if completed.status.success() { + return Ok(json!({ + "action": "updated", + "client": client, + "scope": normalized_scope, + "server_name": descriptor.name, + "method": "native_cli", + "path": serde_json::Value::Null, + "command": command, + "descriptor": descriptor.as_json(), + "entry": descriptor.stdio_entry(false, false), + })); + } + let error = subprocess_error(&completed); + return file_adapter_result( + client, + &normalized_scope, + &descriptor, + Some(command), + Some(error), + dry_run, + client_config_path, + ); + } + + let native_error = native_command.as_ref().and_then(|command| { + command.first().and_then(|executable| { + if executable_in_path(executable) { + None + } else { + Some(format!("{executable} executable not found")) + } + }) + }); + file_adapter_result( + client, + &normalized_scope, + &descriptor, + native_command, + native_error, + dry_run, + client_config_path, + ) +} + +fn file_adapter_result( + client: &str, + scope: &str, + descriptor: &NativeMcpDescriptor, + native_command: Option>, + native_error: Option, + dry_run: bool, + client_config_path: Option, +) -> Result { + let path = + client_config_path.unwrap_or_else(|| default_client_config_path(client, scope, descriptor)); + let existing = fs::read_to_string(&path).ok(); + let rendered = render_client_config(client, scope, existing.as_deref(), descriptor)?; + let action = if dry_run { + "dry_run".to_string() + } else { + rendered.action.clone() + }; + if !dry_run { + write_text_atomic(&path, &rendered.text)?; + } + let mut payload = json!({ + "action": action, + "client": client, + "scope": scope, + "server_name": descriptor.name, + "method": "file_adapter", + "path": path.to_string_lossy(), + "command": serde_json::Value::Null, + "descriptor": descriptor.as_json(), + "entry": rendered.entry, + "patch": rendered.patch, + "payload": rendered.payload, + }); + if let Some(command) = native_command { + payload["native_command"] = json!(command); + } + if let Some(error) = native_error { + payload["native_error"] = json!(error); + } + Ok(payload) +} + +fn default_client_config_path( + client: &str, + scope: &str, + descriptor: &NativeMcpDescriptor, +) -> PathBuf { + let home = home_dir(); + match adapter_id(client, scope) { + "codex" => env::var_os("CODEX_HOME") + .map(PathBuf::from) + .unwrap_or_else(|| home.join(".codex")) + .join("config.toml"), + "claude" => { + let mac_path = + home.join("Library/Application Support/Claude/claude_desktop_config.json"); + if mac_path.parent().is_some_and(Path::exists) { + mac_path + } else { + home.join(".config/claude/claude_desktop_config.json") + } + } + "claude-project" => PathBuf::from(&descriptor.repo_root).join(".mcp.json"), + "lmstudio" => home.join(".lmstudio/mcp.json"), + "github-copilot" => PathBuf::from(&descriptor.repo_root).join(".vscode/mcp.json"), + "hermes" => home.join(".hermes/config.yaml"), + "openclaw" => env::var_os("OPENCLAW_HOME") + .map(PathBuf::from) + .unwrap_or_else(|| home.join(".openclaw")) + .join("mcp.json5"), + _ => home.join(".config/mcp/mcp.json"), + } +} + +pub fn supported_mcp_clients() -> &'static [&'static str] { + &[ + "claude", + "claude-project", + "codex", + "copilot-studio", + "generic", + "github-copilot", + "hermes", + "lmstudio", + "microsoft-copilot", + "openclaw", + ] +} + +fn install_scope(client: &str, scope: &str) -> String { + if client == "claude-project" { + "project".to_string() + } else { + scope.to_string() + } +} + +fn adapter_id<'a>(client: &'a str, scope: &str) -> &'a str { + if client == "claude" && scope == "project" { + "claude-project" + } else { + client + } +} + +fn native_client_command( + client: &str, + descriptor: &NativeMcpDescriptor, + scope: &str, +) -> Option> { + match client { + "codex" => Some(vec![ + "codex".to_string(), + "mcp".to_string(), + "add".to_string(), + descriptor.name.clone(), + "--".to_string(), + descriptor.command.clone(), + descriptor.args[0].clone(), + descriptor.args[1].clone(), + descriptor.args[2].clone(), + descriptor.args[3].clone(), + ]), + "claude" | "claude-project" => Some(vec![ + "claude".to_string(), + "mcp".to_string(), + "add".to_string(), + "--transport".to_string(), + "stdio".to_string(), + "--scope".to_string(), + install_scope(client, scope), + descriptor.name.clone(), + "--".to_string(), + descriptor.command.clone(), + descriptor.args[0].clone(), + descriptor.args[1].clone(), + descriptor.args[2].clone(), + descriptor.args[3].clone(), + ]), + "openclaw" => Some(vec![ + "openclaw".to_string(), + "mcp".to_string(), + "set".to_string(), + descriptor.name.clone(), + serde_json::to_string(&descriptor.stdio_entry(true, false)).ok()?, + ]), + _ => None, + } +} + +struct RenderedNativeConfig { + text: String, + action: String, + entry: serde_json::Value, + patch: serde_json::Value, + payload: serde_json::Value, +} + +struct RemovedNativeConfig { + text: String, + action: String, + previous: serde_json::Value, + payload: serde_json::Value, +} + +fn render_client_config( + client: &str, + scope: &str, + existing: Option<&str>, + descriptor: &NativeMcpDescriptor, +) -> Result { + match adapter_id(client, scope) { + "codex" => render_codex_config(existing, descriptor), + "hermes" => render_hermes_config(existing, descriptor), + "claude" | "claude-project" | "lmstudio" | "github-copilot" | "openclaw" | "generic" => { + render_json_config(adapter_id(client, scope), existing, descriptor) + } + other => Err(format!("Unsupported MCP client adapter: {other}")), + } +} + +fn remove_client_config( + client: &str, + scope: &str, + existing: Option<&str>, + server_name: &str, +) -> Result { + match adapter_id(client, scope) { + "codex" => remove_codex_config(existing, server_name), + "hermes" => remove_hermes_config(existing, server_name), + "claude" | "claude-project" | "lmstudio" | "github-copilot" | "openclaw" | "generic" => { + remove_json_config(adapter_id(client, scope), existing, server_name) + } + other => Err(format!("Unsupported MCP client adapter: {other}")), + } +} + +fn render_json_config( + adapter: &str, + existing: Option<&str>, + descriptor: &NativeMcpDescriptor, +) -> Result { + let mut payload = existing + .filter(|text| !text.trim().is_empty()) + .map(serde_json::from_str::) + .transpose() + .map_err(|error| format!("MCP config must contain a JSON object: {error}"))? + .unwrap_or_else(|| json!({})); + if !payload.is_object() { + return Err("MCP config must contain a JSON object".to_string()); + } + let root_path = match adapter { + "github-copilot" => vec!["servers"], + "openclaw" => vec!["mcp", "servers"], + _ => vec!["mcpServers"], + }; + let include_type = !matches!(adapter, "claude" | "generic"); + let entry = descriptor.stdio_entry(include_type, false); + let previous = json_container_mut(&mut payload, &root_path)? + .insert(descriptor.name.clone(), entry.clone()); + let action = action_for_json(previous.as_ref(), &entry, existing.is_some()); + let text = serde_json::to_string_pretty(&payload).map_err(|error| error.to_string())? + "\n"; + let action = if existing == Some(text.as_str()) { + "unchanged".to_string() + } else { + action + }; + Ok(RenderedNativeConfig { + text, + action, + entry, + patch: payload.clone(), + payload, + }) +} + +fn remove_json_config( + adapter: &str, + existing: Option<&str>, + server_name: &str, +) -> Result { + let Some(existing) = existing else { + return Ok(RemovedNativeConfig { + text: String::new(), + action: "unchanged".to_string(), + previous: serde_json::Value::Null, + payload: json!({}), + }); + }; + let mut payload = if existing.trim().is_empty() { + json!({}) + } else { + serde_json::from_str::(existing) + .map_err(|error| format!("MCP config must contain a JSON object: {error}"))? + }; + if !payload.is_object() { + return Err("MCP config must contain a JSON object".to_string()); + } + let root_path = match adapter { + "github-copilot" => vec!["servers"], + "openclaw" => vec!["mcp", "servers"], + _ => vec!["mcpServers"], + }; + let previous = json_container_mut(&mut payload, &root_path)?.remove(server_name); + let action = if previous.is_some() { + "removed" + } else { + "unchanged" + } + .to_string(); + let text = serde_json::to_string_pretty(&payload).map_err(|error| error.to_string())? + "\n"; + Ok(RemovedNativeConfig { + text, + action, + previous: previous.unwrap_or(serde_json::Value::Null), + payload, + }) +} + +fn render_codex_config( + existing: Option<&str>, + descriptor: &NativeMcpDescriptor, +) -> Result { + let entry = descriptor.stdio_entry(false, true); + let patch = codex_toml_block(descriptor); + let (text, previous) = + upsert_toml_block(existing.unwrap_or_default(), &descriptor.name, &patch); + let action = if existing == Some(text.as_str()) { + "unchanged".to_string() + } else if previous.is_none() { + "created".to_string() + } else if previous.as_deref() == Some(patch.trim_end()) { + "unchanged".to_string() + } else { + "updated".to_string() + }; + Ok(RenderedNativeConfig { + text, + action, + entry, + patch: json!(patch), + payload: json!(patch), + }) +} + +fn remove_codex_config( + existing: Option<&str>, + server_name: &str, +) -> Result { + let Some(existing) = existing else { + return Ok(RemovedNativeConfig { + text: String::new(), + action: "unchanged".to_string(), + previous: serde_json::Value::Null, + payload: json!(""), + }); + }; + let (text, previous) = remove_toml_block(existing, server_name); + Ok(RemovedNativeConfig { + text, + action: if previous.is_some() { + "removed".to_string() + } else { + "unchanged".to_string() + }, + previous: previous + .map(serde_json::Value::String) + .unwrap_or(serde_json::Value::Null), + payload: json!(existing), + }) +} + +fn render_hermes_config( + existing: Option<&str>, + descriptor: &NativeMcpDescriptor, +) -> Result { + let entry = descriptor.stdio_entry(true, false); + let patch = hermes_yaml_block(descriptor); + let (text, previous) = upsert_marked_block(existing.unwrap_or_default(), &patch); + let action = if existing == Some(text.as_str()) { + "unchanged".to_string() + } else if previous.is_none() { + "created".to_string() + } else if previous.as_deref() == Some(patch.trim_end()) { + "unchanged".to_string() + } else { + "updated".to_string() + }; + Ok(RenderedNativeConfig { + text, + action, + entry, + patch: json!(patch), + payload: json!(patch), + }) +} + +fn remove_hermes_config( + existing: Option<&str>, + server_name: &str, +) -> Result { + let Some(existing) = existing else { + return Ok(RemovedNativeConfig { + text: String::new(), + action: "unchanged".to_string(), + previous: serde_json::Value::Null, + payload: json!(""), + }); + }; + let (text, previous) = remove_marked_block(existing); + let previous = previous.filter(|block| block.contains(&format!(" {server_name}:"))); + let action = if previous.is_some() { + "removed".to_string() + } else { + "unchanged".to_string() + }; + Ok(RemovedNativeConfig { + text: if previous.is_some() { + text + } else { + existing.to_string() + }, + action, + previous: previous + .map(serde_json::Value::String) + .unwrap_or(serde_json::Value::Null), + payload: json!(existing), + }) +} + +fn json_container_mut<'a>( + payload: &'a mut serde_json::Value, + path: &[&str], +) -> Result<&'a mut serde_json::Map, String> { + let mut cursor = payload + .as_object_mut() + .ok_or_else(|| "MCP config must contain a JSON object".to_string())?; + for key in path { + let next = cursor + .entry((*key).to_string()) + .or_insert_with(|| json!({})); + cursor = next + .as_object_mut() + .ok_or_else(|| format!("MCP config key must contain an object: {}", path.join(".")))?; + } + Ok(cursor) +} + +fn action_for_json( + previous: Option<&serde_json::Value>, + next_value: &serde_json::Value, + file_exists: bool, +) -> String { + if !file_exists { + "created".to_string() + } else if previous == Some(next_value) { + "unchanged".to_string() + } else { + "updated".to_string() + } +} + +fn copilot_studio_metadata(descriptor: &NativeMcpDescriptor) -> serde_json::Value { + json!({ + "kind": "copilot_studio_manual_metadata", + "stdio": descriptor.stdio_entry(true, false), + "http": { + "url": "http://127.0.0.1:8765/mcp", + "start_command": [ + descriptor.command, + "mcp", + "http", + "--config", + descriptor.setup_config_path, + "--host", + "127.0.0.1", + "--port", + "8765", + "--path", + "/mcp" + ], + "host": "127.0.0.1", + "port": 8765, + "path": "/mcp", + }, + "notes": [ + "No local client configuration file is written for Copilot Studio.", + "Remote Copilot Studio use requires user-managed endpoint exposure, bearer-token configuration, and TLS.", + ], + }) +} + +fn codex_toml_block(descriptor: &NativeMcpDescriptor) -> String { + format!( + "[mcp_servers.{}]\ncommand = {}\nargs = {}\nstartup_timeout_sec = {}\n", + descriptor.name, + toml_string(&descriptor.command), + toml_array(&descriptor.args), + descriptor.timeout + ) +} + +fn toml_array(values: &[String]) -> String { + format!( + "[{}]", + values + .iter() + .map(|value| toml_string(value)) + .collect::>() + .join(", ") + ) +} + +fn toml_string(value: &str) -> String { + format!("\"{}\"", value.replace('\\', "\\\\").replace('"', "\\\"")) +} + +fn upsert_toml_block(existing: &str, server_name: &str, block: &str) -> (String, Option) { + let lines = existing.lines().collect::>(); + let header = format!("[mcp_servers.{server_name}]"); + let env_header = format!("[mcp_servers.{server_name}.env]"); + let start = lines + .iter() + .position(|line| line.trim() == header || line.trim() == env_header); + let Some(start) = start else { + let prefix = existing.trim_end(); + let separator = if prefix.is_empty() { "" } else { "\n\n" }; + return (format!("{prefix}{separator}{block}"), None); + }; + let end = lines[start + 1..] + .iter() + .position(|line| { + let trimmed = line.trim(); + trimmed.starts_with('[') + && trimmed.ends_with(']') + && trimmed != header + && trimmed != env_header + }) + .map(|index| start + 1 + index) + .unwrap_or(lines.len()); + let previous = lines[start..end].join("\n").trim_end().to_string(); + let mut next_lines = Vec::new(); + next_lines.extend(lines[..start].iter().map(|value| (*value).to_string())); + next_lines.extend(block.trim_end().lines().map(str::to_string)); + next_lines.extend(lines[end..].iter().map(|value| (*value).to_string())); + ( + next_lines.join("\n").trim_end().to_string() + "\n", + Some(previous), + ) +} + +fn remove_toml_block(existing: &str, server_name: &str) -> (String, Option) { + let lines = existing.lines().collect::>(); + let header = format!("[mcp_servers.{server_name}]"); + let env_header = format!("[mcp_servers.{server_name}.env]"); + let start = lines + .iter() + .position(|line| line.trim() == header || line.trim() == env_header); + let Some(start) = start else { + return (existing.to_string(), None); + }; + let end = lines[start + 1..] + .iter() + .position(|line| { + let trimmed = line.trim(); + trimmed.starts_with('[') + && trimmed.ends_with(']') + && trimmed != header + && trimmed != env_header + }) + .map(|index| start + 1 + index) + .unwrap_or(lines.len()); + let previous = lines[start..end].join("\n").trim_end().to_string(); + let mut next_lines = Vec::new(); + next_lines.extend(lines[..start].iter().map(|value| (*value).to_string())); + next_lines.extend(lines[end..].iter().map(|value| (*value).to_string())); + let text = next_lines.join("\n").trim().to_string(); + let text = if text.is_empty() { + String::new() + } else { + text + "\n" + }; + (text, Some(previous)) +} + +fn hermes_yaml_block(descriptor: &NativeMcpDescriptor) -> String { + let mut lines = vec![ + "# codebaseGraph MCP server start".to_string(), + "mcp_servers:".to_string(), + format!(" {}:", descriptor.name), + " type: stdio".to_string(), + format!(" command: {}", yaml_scalar(&descriptor.command)), + " args:".to_string(), + ]; + for arg in &descriptor.args { + lines.push(format!(" - {}", yaml_scalar(arg))); + } + lines.push("# codebaseGraph MCP server end".to_string()); + lines.join("\n") + "\n" +} + +fn yaml_scalar(value: &str) -> String { + format!("\"{}\"", value.replace('\\', "\\\\").replace('"', "\\\"")) +} + +fn upsert_marked_block(existing: &str, block: &str) -> (String, Option) { + const START: &str = "# codebaseGraph MCP server start"; + const END: &str = "# codebaseGraph MCP server end"; + let Some(start) = existing.find(START) else { + let prefix = existing.trim_end(); + let separator = if prefix.is_empty() { "" } else { "\n\n" }; + return (format!("{prefix}{separator}{block}"), None); + }; + let Some(end) = existing.find(END) else { + let prefix = existing.trim_end(); + let separator = if prefix.is_empty() { "" } else { "\n\n" }; + return (format!("{prefix}{separator}{block}"), None); + }; + if end < start { + let prefix = existing.trim_end(); + let separator = if prefix.is_empty() { "" } else { "\n\n" }; + return (format!("{prefix}{separator}{block}"), None); + } + let after_end = end + END.len(); + let previous = existing[start..after_end].trim_end().to_string(); + let text = format!( + "{}\n\n{}\n\n{}", + existing[..start].trim_end(), + block.trim_end(), + existing[after_end..].trim_start() + ) + .trim() + .to_string() + + "\n"; + (text, Some(previous)) +} + +fn remove_marked_block(existing: &str) -> (String, Option) { + const START: &str = "# codebaseGraph MCP server start"; + const END: &str = "# codebaseGraph MCP server end"; + let Some(start) = existing.find(START) else { + return (existing.to_string(), None); + }; + let Some(end) = existing.find(END) else { + return (existing.to_string(), None); + }; + if end < start { + return (existing.to_string(), None); + } + let after_end = end + END.len(); + let previous = existing[start..after_end].trim_end().to_string(); + let before = existing[..start].trim_end(); + let after = existing[after_end..].trim_start(); + let text = match (before.is_empty(), after.is_empty()) { + (true, true) => String::new(), + (true, false) => format!("{after}\n"), + (false, true) => format!("{before}\n"), + (false, false) => format!("{before}\n\n{after}"), + }; + (text, Some(previous)) +} + +fn write_text_atomic(path: &Path, text: &str) -> Result<(), String> { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).map_err(|error| { + format!( + "failed to create config directory {}: {error}", + parent.display() + ) + })?; + } + let tmp_path = path.with_extension(format!( + "{}.tmp", + path.extension() + .and_then(|value| value.to_str()) + .unwrap_or_default() + )); + fs::write(&tmp_path, text).map_err(|error| { + format!( + "failed to write temporary config {}: {error}", + tmp_path.display() + ) + })?; + fs::rename(&tmp_path, path) + .map_err(|error| format!("failed to replace config {}: {error}", path.display())) +} + +pub(crate) fn expand_path(value: &str) -> PathBuf { + if let Some(rest) = value.strip_prefix("~/") { + return home_dir().join(rest); + } + let path = PathBuf::from(value); + if path.is_absolute() { + path + } else { + env::current_dir() + .unwrap_or_else(|_| PathBuf::from(".")) + .join(path) + } +} + +fn home_dir() -> PathBuf { + env::var_os("HOME") + .map(PathBuf::from) + .unwrap_or_else(|| PathBuf::from(".")) +} + +fn executable_in_path(executable: &str) -> bool { + let path = Path::new(executable); + if path.components().count() > 1 { + return path.is_file(); + } + env::var_os("PATH") + .map(|paths| env::split_paths(&paths).any(|dir| dir.join(executable).is_file())) + .unwrap_or(false) +} + +fn subprocess_error(completed: &std::process::Output) -> String { + let stdout = String::from_utf8_lossy(&completed.stdout) + .trim() + .to_string(); + let stderr = String::from_utf8_lossy(&completed.stderr) + .trim() + .to_string(); + let output = [stdout, stderr] + .into_iter() + .filter(|part| !part.is_empty()) + .collect::>() + .join("\n"); + let code = completed.status.code().unwrap_or(1); + if output.is_empty() { + format!("exit {code}") + } else { + format!("exit {code}: {output}") + } +} + +fn install_safe_name(value: &str) -> String { + let normalized: String = value + .trim() + .chars() + .map(|character| { + if character.is_ascii_alphanumeric() || character == '-' || character == '_' { + character.to_ascii_lowercase() + } else { + '_' + } + }) + .collect(); + normalized.trim_matches(['.', '_', '-']).to_string() +} diff --git a/src/api/materialization.rs b/src/api/materialization.rs new file mode 100644 index 0000000..033d1ad --- /dev/null +++ b/src/api/materialization.rs @@ -0,0 +1,678 @@ +#[cfg(test)] +use crate::api::contracts::{OutputFormat, RepoSelector}; +use crate::api::{ + context::{resolve_repository_root, RepoPaths, RepoRuntime}, + contracts::MaterializationRequest, +}; +use crate::protocol::{ + ManifestDiff, ManifestEntry, NativeManifest, NativeSyntaxMaterializationRequest, + NativeSyntaxMaterializationResponse, +}; +use std::collections::{BTreeMap, BTreeSet}; +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::Command; +use std::time::Instant; + +#[derive(Clone, Debug)] +pub(crate) struct MaterializeOptions { + pub(crate) native_request: Option, + pub(crate) source_root: Option, + pub(crate) config: Option, + pub(crate) db: Option, + pub(crate) manifest: Option, + pub(crate) mode: String, + pub(crate) include_fts: bool, + pub(crate) semantic_enrichment: bool, + pub(crate) semantic_provider_mode: String, + pub(crate) use_git: bool, + pub(crate) git_diff: bool, + pub(crate) git_base: Option, + pub(crate) include_patterns: Vec, + pub(crate) exclude_patterns: Vec, + pub(crate) candidate_paths: Vec, + pub(crate) parallel: bool, + pub(crate) progress: bool, + pub(crate) plan_only: bool, +} + +impl Default for MaterializeOptions { + fn default() -> Self { + Self { + native_request: None, + source_root: None, + config: None, + db: None, + manifest: None, + mode: String::new(), + include_fts: false, + semantic_enrichment: false, + semantic_provider_mode: String::new(), + use_git: false, + git_diff: false, + git_base: None, + include_patterns: Vec::new(), + exclude_patterns: Vec::new(), + candidate_paths: Vec::new(), + parallel: true, + progress: false, + plan_only: false, + } + } +} + +impl MaterializeOptions { + pub(crate) fn from_request( + request: &MaterializationRequest, + runtime: &RepoRuntime, + plan_only: bool, + ) -> Self { + Self { + source_root: Some(runtime.repo_root.clone()), + config: runtime.config_path.clone(), + db: Some(runtime.db_path.clone()), + manifest: Some(runtime.manifest_path.clone()), + mode: request.mode.clone(), + include_fts: request.include_fts, + semantic_enrichment: request.semantic_enrichment, + semantic_provider_mode: request.semantic_provider_mode.clone(), + use_git: request.use_git, + git_diff: request.git_diff, + git_base: request.git_base.clone(), + include_patterns: request.include_patterns.clone(), + exclude_patterns: request.exclude_patterns.clone(), + candidate_paths: request.candidate_paths.clone(), + parallel: request.parallel, + progress: request.progress, + plan_only, + ..Self::default() + } + } +} + +#[derive(Default)] +pub(crate) struct ConfigScanRules { + pub(crate) include_patterns: Vec, + pub(crate) exclude_patterns: Vec, +} + +#[cfg(test)] +pub(crate) fn materialization_request( + options: &MaterializeOptions, + output_format: OutputFormat, +) -> Result { + Ok(MaterializationRequest { + repo: RepoSelector { + repo_root: options.source_root.clone(), + config_path: options.config.clone(), + db_path: options.db.clone(), + manifest_path: options.manifest.clone(), + }, + native_request_path: options.native_request.clone(), + source_root: options + .source_root + .as_ref() + .map(|path| path.to_string_lossy().to_string()), + mode: options.mode.clone(), + include_fts: options.include_fts, + semantic_enrichment: options.semantic_enrichment, + semantic_provider_mode: options.semantic_provider_mode.clone(), + use_git: options.use_git, + git_diff: options.git_diff, + git_base: options.git_base.clone(), + include_patterns: options.include_patterns.clone(), + exclude_patterns: options.exclude_patterns.clone(), + candidate_paths: options.candidate_paths.clone(), + parallel: options.parallel, + progress: options.progress, + output_format, + }) +} + +pub(crate) fn execute_materialization( + options: &MaterializeOptions, +) -> Result< + ( + NativeSyntaxMaterializationRequest, + NativeSyntaxMaterializationResponse, + ), + String, +> { + let request = match options.native_request.as_ref() { + Some(request_path) => read_request(request_path)?, + None => build_request(options)?, + }; + let manifest_path = request_manifest_path(options); + execute_materialization_request_with_manifest(manifest_path.as_deref(), request) +} + +pub(crate) fn execute_candidate_materialization( + options: &MaterializeOptions, + candidate_paths: Vec, +) -> Result< + ( + NativeSyntaxMaterializationRequest, + NativeSyntaxMaterializationResponse, + ), + String, +> { + let mut request = build_request(options)?; + request.candidate_paths = candidate_paths; + request.atomic_rebuild = false; + let manifest_path = request_manifest_path(options); + execute_materialization_request_with_manifest(manifest_path.as_deref(), request) +} + +pub(crate) fn execute_materialization_request( + options: &MaterializeOptions, + request: NativeSyntaxMaterializationRequest, +) -> Result< + ( + NativeSyntaxMaterializationRequest, + NativeSyntaxMaterializationResponse, + ), + String, +> { + let manifest_path = request_manifest_path(options); + execute_materialization_request_with_manifest(manifest_path.as_deref(), request) +} + +pub(crate) fn execute_materialization_request_with_manifest( + manifest_path: Option<&Path>, + request: NativeSyntaxMaterializationRequest, +) -> Result< + ( + NativeSyntaxMaterializationRequest, + NativeSyntaxMaterializationResponse, + ), + String, +> { + let started = Instant::now(); + let final_request = request; + let mut response = crate::execute_materialization_pipeline(&final_request) + .map_err(|error| error.to_string())?; + response.phase_timings.insert( + "native_cli_seconds".to_string(), + started.elapsed().as_secs_f64(), + ); + + if let Some(path) = manifest_path { + write_manifest( + path, + &final_request, + &response.rebuilt_entries, + &response.diff, + )?; + } + + Ok((final_request, response)) +} + +pub(crate) fn plan_materialization_payload( + response: &NativeSyntaxMaterializationResponse, + mode: &str, + manifest_path: &Path, +) -> serde_json::Value { + let rebuilt_paths = response.diff.rebuild_paths(); + let skipped_paths = response + .snapshots + .iter() + .filter(|(_, snapshot)| snapshot.language.is_none()) + .map(|(path, _)| path.clone()) + .collect::>(); + let ignored_paths = response + .diagnostics + .iter() + .filter_map(|diagnostic| diagnostic.strip_prefix("Ignored file: ")) + .map(str::to_string) + .collect::>(); + serde_json::json!({ + "mode": mode, + "scanned": response.snapshots.len(), + "rebuilt": rebuilt_paths.len(), + "skipped": skipped_paths.len(), + "ignored": ignored_paths.len(), + "deleted": response.diff.deleted.len(), + "diagnostics": response.diagnostics, + "manifest_path": manifest_path, + "rebuilt_paths": rebuilt_paths, + "skipped_paths": skipped_paths.clone(), + "ignored_paths": ignored_paths, + "deleted_paths": response.diff.deleted.clone(), + "would_rebuild": response.diff.rebuild_paths(), + "would_delete": response.diff.deleted, + "would_skip": skipped_paths, + "graph_summary": response.graph_summary, + "node_rows": response.node_rows, + "edge_rows": response.edge_rows, + "connector_rows": response.connector_rows, + "database_written": response.database_written, + "progress_events": response.progress_events, + "phase_timings": response.phase_timings, + }) +} + +pub(crate) fn build_request( + options: &MaterializeOptions, +) -> Result { + let source_root = resolved_source_root(options)?; + let paths = RepoPaths::derive(&source_root); + let db_path = options.db.clone().unwrap_or_else(|| paths.db_path.clone()); + let manifest_path = options + .manifest + .clone() + .unwrap_or_else(|| paths.manifest_path.clone()); + let previous_manifest = if manifest_path.exists() { + Some(read_manifest(&manifest_path)?) + } else { + None + }; + let config_path = options + .config + .clone() + .unwrap_or_else(|| paths.config_path.clone()); + let config_rules = read_materialization_config_rules(&config_path)?; + let mut include_patterns = config_rules.include_patterns; + include_patterns.extend(options.include_patterns.clone()); + let mut exclude_patterns = config_rules.exclude_patterns; + exclude_patterns.extend(options.exclude_patterns.clone()); + let ignore_patterns = read_codebase_graph_ignore(&source_root)?; + let candidate_paths = if options.candidate_paths.is_empty() { + git_candidate_paths(&source_root, options)? + } else { + normalized_candidate_paths(&options.candidate_paths) + }; + let staging_dir = paths.state_dir.join("native-staging"); + + Ok(NativeSyntaxMaterializationRequest { + source_root: source_root.to_string_lossy().to_string(), + repository_label: paths.repo_name, + mode: options.mode.clone(), + parser_version: "native-rust-cli-v1".to_string(), + manifest_schema_version: 1, + ontology: "code_ontology_v1".to_string(), + ontology_schema: Default::default(), + previous_manifest, + profiles: Vec::new(), + excluded_parts: default_excluded_parts(), + include_patterns, + exclude_patterns, + ignore_patterns, + candidate_paths, + db_path: db_path.to_string_lossy().to_string(), + include_fts: options.include_fts, + semantic_enrichment: options.semantic_enrichment, + semantic_provider_mode: options.semantic_provider_mode.clone(), + schema_statements: Vec::new(), + staging_dir: staging_dir.to_string_lossy().to_string(), + atomic_rebuild: true, + strict: true, + parallel: options.parallel, + progress: options.progress, + }) +} + +pub(crate) fn read_materialization_config_rules(path: &Path) -> Result { + if !path.exists() { + return Ok(ConfigScanRules::default()); + } + let text = fs::read_to_string(path) + .map_err(|error| format!("failed to read config {}: {error}", path.display()))?; + let value: serde_json::Value = serde_json::from_str(&text) + .map_err(|error| format!("failed to parse config {}: {error}", path.display()))?; + let materialization = value + .get("materialization") + .and_then(serde_json::Value::as_object); + Ok(ConfigScanRules { + include_patterns: materialization + .and_then(|payload| payload.get("include")) + .map(json_string_array) + .unwrap_or_default(), + exclude_patterns: materialization + .and_then(|payload| payload.get("exclude")) + .map(json_string_array) + .unwrap_or_default(), + }) +} + +pub(crate) fn json_string_array(value: &serde_json::Value) -> Vec { + value + .as_array() + .map(|items| { + items + .iter() + .filter_map(serde_json::Value::as_str) + .map(str::to_string) + .collect() + }) + .unwrap_or_default() +} + +pub(crate) fn read_codebase_graph_ignore(source_root: &Path) -> Result, String> { + let path = source_root.join(".codebaseGraphignore"); + if !path.exists() { + return Ok(Vec::new()); + } + let text = fs::read_to_string(&path) + .map_err(|error| format!("failed to read {}: {error}", path.display()))?; + Ok(text + .lines() + .map(str::trim) + .filter(|line| !line.is_empty() && !line.starts_with('#')) + .map(str::to_string) + .collect()) +} + +pub(crate) fn git_candidate_paths( + source_root: &Path, + options: &MaterializeOptions, +) -> Result, String> { + if !options.use_git { + return Ok(Vec::new()); + } + let mut paths = if options.git_diff && options.plan_only { + let base = options.git_base.as_deref().unwrap_or("HEAD"); + git_paths( + source_root, + &["diff", "--name-only", "--diff-filter=ACMRTD", base, "--"], + ) + .unwrap_or_default() + } else { + git_paths( + source_root, + &["ls-files", "--cached", "--others", "--exclude-standard"], + ) + .unwrap_or_default() + }; + if options.git_diff && options.plan_only { + if let Ok(untracked) = + git_paths(source_root, &["ls-files", "--others", "--exclude-standard"]) + { + paths.extend(untracked); + } + } + paths.sort(); + paths.dedup(); + Ok(paths) +} + +pub(crate) fn git_paths(source_root: &Path, args: &[&str]) -> Result, String> { + let output = Command::new("git") + .args(args) + .current_dir(source_root) + .output() + .map_err(|error| format!("failed to run git {}: {error}", args.join(" ")))?; + if !output.status.success() { + return Err(String::from_utf8_lossy(&output.stderr).trim().to_string()); + } + Ok(String::from_utf8_lossy(&output.stdout) + .lines() + .map(str::trim) + .filter(|line| !line.is_empty()) + .map(|line| line.replace('\\', "/")) + .collect()) +} + +pub(crate) fn normalized_candidate_paths(paths: &[String]) -> Vec { + let mut paths = paths + .iter() + .map(|path| path.trim().trim_start_matches("./").replace('\\', "/")) + .filter(|path| !path.is_empty()) + .collect::>(); + paths.sort(); + paths.dedup(); + paths +} + +pub(crate) fn default_excluded_parts() -> Vec { + [ + ".bzr", + ".cache", + ".codebaseGraph", + ".direnv", + ".eggs", + ".git", + ".hg", + ".mypy_cache", + ".nox", + ".svn", + ".tox", + ".venv", + "dist", + "node_modules", + "target", + "venv", + ] + .into_iter() + .map(str::to_string) + .collect() +} + +pub(crate) fn request_manifest_path(options: &MaterializeOptions) -> Option { + if options.native_request.is_some() { + return options.manifest.clone(); + } + let source_root = resolved_source_root(options).unwrap_or_else(|_| PathBuf::from(".")); + Some( + options + .manifest + .clone() + .unwrap_or_else(|| RepoPaths::derive(&source_root).manifest_path), + ) +} + +pub(crate) fn read_manifest(path: &Path) -> Result { + let text = fs::read_to_string(path) + .map_err(|error| format!("failed to read manifest {}: {error}", path.display()))?; + serde_json::from_str(&text) + .map_err(|error| format!("failed to parse manifest {}: {error}", path.display())) +} + +pub(crate) fn read_request(path: &Path) -> Result { + let text = fs::read_to_string(path) + .map_err(|error| format!("failed to read native request {}: {error}", path.display()))?; + serde_json::from_str(&text) + .map_err(|error| format!("failed to parse native request {}: {error}", path.display())) +} + +pub(crate) fn write_manifest( + path: &Path, + request: &NativeSyntaxMaterializationRequest, + rebuilt_entries: &BTreeMap, + diff: &ManifestDiff, +) -> Result<(), String> { + let mut files = if diff.force_rebuild { + BTreeMap::new() + } else { + request + .previous_manifest + .as_ref() + .map(|manifest| manifest.files.clone()) + .unwrap_or_default() + }; + let removed: BTreeSet = diff + .deleted + .iter() + .chain(diff.rebuild_paths().iter()) + .cloned() + .collect(); + files.retain(|path, _| !removed.contains(path)); + files.extend( + rebuilt_entries + .iter() + .map(|(path, entry)| (path.clone(), entry.clone())), + ); + + let manifest = NativeManifest { + schema_version: request.manifest_schema_version, + ontology: request.ontology.clone(), + parser_version: request.parser_version.clone(), + files, + }; + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).map_err(|error| { + format!( + "failed to create manifest directory {}: {error}", + parent.display() + ) + })?; + } + let text = serde_json::to_string_pretty(&manifest).map_err(|error| error.to_string())?; + fs::write(path, format!("{text}\n")) + .map_err(|error| format!("failed to write manifest {}: {error}", path.display())) +} + +fn resolved_source_root(options: &MaterializeOptions) -> Result { + resolve_repository_root(options.source_root.as_deref()) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + use std::time::{SystemTime, UNIX_EPOCH}; + + fn unique_temp_dir(prefix: &str) -> PathBuf { + std::env::temp_dir().join(format!( + "{prefix}-{}-{}", + std::process::id(), + SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("clock should be after epoch") + .as_nanos() + )) + } + + #[test] + fn public_materialization_request_preserves_transport_fields_without_preparation() { + let root = unique_temp_dir("codebase-graph-api-materialization-request"); + fs::create_dir_all(&root).expect("temp root should exist"); + let request_path = root.join("request.json"); + + let request = materialization_request( + &MaterializeOptions { + native_request: Some(request_path.clone()), + source_root: Some(root.clone()), + db: Some(root.join("graph.ldb")), + manifest: Some(root.join("manifest.json")), + mode: "changed".to_string(), + include_fts: true, + semantic_enrichment: false, + semantic_provider_mode: "local_only".to_string(), + use_git: true, + git_diff: true, + git_base: Some("origin/main".to_string()), + include_patterns: vec!["src/**/*.rs".to_string()], + exclude_patterns: vec!["target/**".to_string()], + candidate_paths: vec!["src/lib.rs".to_string()], + parallel: false, + progress: true, + ..MaterializeOptions::default() + }, + OutputFormat::Typed, + ) + .expect("request should be built"); + + assert_eq!(request.native_request_path, Some(request_path)); + assert_eq!(request.repo.repo_root, Some(root.clone())); + assert_eq!(request.repo.db_path, Some(root.join("graph.ldb"))); + assert_eq!(request.include_patterns, vec!["src/**/*.rs"]); + assert_eq!(request.exclude_patterns, vec!["target/**"]); + assert_eq!(request.candidate_paths, vec!["src/lib.rs"]); + assert_eq!(request.git_base.as_deref(), Some("origin/main")); + assert_eq!(request.output_format, OutputFormat::Typed); + let _ = fs::remove_dir_all(root); + } + + #[test] + fn build_request_reads_manifest_and_merges_config_rules() { + let root = unique_temp_dir("codebase-graph-api-build-request"); + let state_dir = root.join(".codebaseGraph"); + fs::create_dir_all(&state_dir).expect("state directory should exist"); + fs::write( + state_dir.join("config.json"), + serde_json::to_vec(&json!({ + "materialization": { + "include": ["src/**/*.rs"], + "exclude": ["target/**"] + } + })) + .expect("config should serialize"), + ) + .expect("config should be written"); + fs::write( + state_dir.join("manifest.json"), + serde_json::to_vec(&json!({ + "schema_version": 1, + "ontology": "code_ontology_v1", + "parser_version": "native-test", + "files": {} + })) + .expect("manifest should serialize"), + ) + .expect("manifest should be written"); + fs::write(root.join(".codebaseGraphignore"), "build/\n# note\n").expect("ignore file"); + + let request = build_request(&MaterializeOptions { + source_root: Some(root.clone()), + mode: "full".to_string(), + include_fts: true, + semantic_enrichment: true, + semantic_provider_mode: "local_only".to_string(), + use_git: false, + include_patterns: vec!["tests/**/*.rs".to_string()], + exclude_patterns: vec!["dist/**".to_string()], + parallel: true, + ..MaterializeOptions::default() + }) + .expect("request should build"); + + assert_eq!( + request.repository_label, + root.file_name() + .and_then(|value| value.to_str()) + .expect("temp root should have a file name") + ); + assert!(request.previous_manifest.is_some()); + assert_eq!( + request.include_patterns, + vec!["src/**/*.rs".to_string(), "tests/**/*.rs".to_string()] + ); + assert_eq!( + request.exclude_patterns, + vec!["target/**".to_string(), "dist/**".to_string()] + ); + assert_eq!(request.ignore_patterns, vec!["build/".to_string()]); + assert!(request.candidate_paths.is_empty()); + let _ = fs::remove_dir_all(root); + } + + #[test] + fn request_manifest_path_uses_override_for_native_requests_only_when_present() { + let root = unique_temp_dir("codebase-graph-api-manifest-path"); + fs::create_dir_all(&root).expect("temp root should exist"); + let override_path = root.join("override-manifest.json"); + + let native = request_manifest_path(&MaterializeOptions { + native_request: Some(root.join("request.json")), + manifest: Some(override_path.clone()), + ..MaterializeOptions::default() + }); + assert_eq!(native, Some(override_path.clone())); + + let direct = request_manifest_path(&MaterializeOptions { + source_root: Some(root.clone()), + ..MaterializeOptions::default() + }); + assert_eq!( + direct, + Some( + root.canonicalize() + .expect("temp root should canonicalize") + .join(".codebaseGraph") + .join("manifest.json") + ) + ); + let _ = fs::remove_dir_all(root); + } +} diff --git a/src/api/mod.rs b/src/api/mod.rs new file mode 100644 index 0000000..8ec264a --- /dev/null +++ b/src/api/mod.rs @@ -0,0 +1,37 @@ +//! Stable, transport-neutral entry point for codebase graph operations. +//! +//! Embedded clients construct an [`OperationRequest`] and submit it through +//! [`CodebaseGraphApi::execute_operation`]. The same facade is used by the CLI +//! and MCP adapters, so validation, runtime selection, errors, and presentation +//! remain consistent across surfaces. + +pub mod catalog; +pub mod context; +pub mod contracts; +pub mod core; +pub mod facade; +pub(crate) mod graph_read; +pub mod lifecycle; +mod materialization; +mod normalization; +pub mod presenter; +mod refresh; + +pub use contracts::{ + ApiError, ContextRequest, HealthRequest, MaterializationRequest, McpInstallRequest, + OperationInvocation, OperationRequest, OperationResponse, OutputFormat, QueryRequest, + RefreshBackend, RefreshLoopConfig, RefreshRequest, RefreshWatchConfig, RefreshWatchObserver, + RefreshWatchSummary, RepoSelector, RepositoryLifecycleRequest, SearchRequest, +}; +pub use core::OperationDescriptor; +pub use facade::{CodebaseGraphApi, OperationExecutor}; +pub use lifecycle::supported_mcp_clients; + +#[cfg(test)] +pub(crate) use refresh::{ + apply_watch_message, collect_poll_batch, collect_watch_batch, probe_native_watcher, + watch_file_snapshot, watch_snapshot_diff, WatchChangeBatch, WatchEventFilter, WatchMessage, +}; + +#[cfg(test)] +mod boundary_tests; diff --git a/src/api/normalization.rs b/src/api/normalization.rs new file mode 100644 index 0000000..4d09321 --- /dev/null +++ b/src/api/normalization.rs @@ -0,0 +1,562 @@ +use crate::api::contracts::{ + ApiError, ContextRequest, HealthRequest, MaterializationRequest, McpInstallRequest, + OperationInvocation, OperationRequest, QueryRequest, RefreshRequest, + RepositoryLifecycleRequest, SearchRequest, +}; +use crate::api::lifecycle::{expand_path, supported_mcp_clients}; +use crate::api::materialization::MaterializeOptions; +use serde_json::json; + +pub(crate) const DEFAULT_PROFILE: &str = "brief"; +pub(crate) const DEFAULT_DETAIL: &str = "standard"; +pub(crate) const DEFAULT_SEARCH_LIMIT: usize = 3; +pub(crate) const DEFAULT_SEARCH_BUDGET: usize = 600; +pub(crate) const DEFAULT_CONTEXT_LIMIT: usize = 3; +pub(crate) const DEFAULT_QUERY_LIMIT: usize = 100; +const DEFAULT_MATERIALIZATION_MODE: &str = "changed"; +const DEFAULT_SEMANTIC_PROVIDER_MODE: &str = "local_only"; + +pub(crate) fn prepare_operation_request( + operation_id: &str, + invocation: &OperationInvocation, +) -> Result { + let arguments = &invocation.arguments; + let request = match operation_id { + "health" => OperationRequest::Health(HealthRequest { + repo: invocation.repo.clone(), + refresh_status: None, + output_format: invocation.output_format, + }), + "search" => OperationRequest::Search(SearchRequest { + repo: invocation.repo.clone(), + query: argument_string(arguments, "query"), + profile: argument_string(arguments, "profile"), + limit: argument_usize(arguments, "limit").unwrap_or(DEFAULT_SEARCH_LIMIT), + budget: argument_usize(arguments, "budget").unwrap_or(DEFAULT_SEARCH_BUDGET), + context_limit: argument_usize(arguments, "context_limit") + .unwrap_or(DEFAULT_CONTEXT_LIMIT), + max_depth: argument_usize(arguments, "max_depth"), + detail: argument_string(arguments, "detail"), + output_format: invocation.output_format, + }), + "context" => OperationRequest::Context(ContextRequest { + repo: invocation.repo.clone(), + query: argument_optional_string(arguments, "query"), + profile: argument_string(arguments, "profile"), + limit: argument_usize(arguments, "limit").unwrap_or(DEFAULT_SEARCH_LIMIT), + budget: argument_usize(arguments, "budget").unwrap_or(DEFAULT_SEARCH_BUDGET), + context_limit: argument_usize(arguments, "context_limit") + .unwrap_or(DEFAULT_CONTEXT_LIMIT), + max_depth: argument_usize(arguments, "max_depth"), + detail: argument_string(arguments, "detail"), + node_id: argument_optional_string(arguments, "node_id"), + node_type: argument_optional_string(arguments, "node_type"), + output_format: invocation.output_format, + }), + "query" => OperationRequest::Query(QueryRequest { + repo: invocation.repo.clone(), + statement: argument_string(arguments, "statement"), + parameters: arguments + .get("parameters") + .cloned() + .unwrap_or_else(|| json!({})), + limit: argument_usize(arguments, "limit").unwrap_or(DEFAULT_QUERY_LIMIT), + output_format: invocation.output_format, + }), + "schema" | "query-helpers" => OperationRequest::Catalog { + kind: operation_id.to_string(), + group: None, + output_format: invocation.output_format, + }, + "architecture-queries" => OperationRequest::Catalog { + kind: operation_id.to_string(), + group: argument_optional_string(arguments, "group"), + output_format: invocation.output_format, + }, + _ => { + return Err(ApiError::new( + "unsupported_operation", + format!("unsupported registered operation input: {operation_id}"), + )) + } + }; + Ok(normalize_request(&request)) +} + +pub(crate) fn normalize_request(request: &OperationRequest) -> OperationRequest { + let mut normalized = request.clone(); + match &mut normalized { + OperationRequest::Search(request) => { + request.query = request.query.trim().to_string(); + default_string(&mut request.profile, DEFAULT_PROFILE); + default_string(&mut request.detail, DEFAULT_DETAIL); + } + OperationRequest::Context(request) => { + default_string(&mut request.profile, DEFAULT_PROFILE); + default_string(&mut request.detail, DEFAULT_DETAIL); + request.query = request + .query + .take() + .map(|query| query.trim().to_string()) + .filter(|query| !query.is_empty()); + request.node_id = normalized_optional_string(request.node_id.take()); + request.node_type = normalized_optional_string(request.node_type.take()); + } + OperationRequest::Query(request) => { + request.statement = request.statement.trim().to_string(); + } + OperationRequest::Materialize(request) | OperationRequest::Plan(request) => { + normalize_materialization(request); + } + OperationRequest::Setup(request) + | OperationRequest::Reinstall(request) + | OperationRequest::Uninstall(request) => { + normalize_lifecycle(request); + } + OperationRequest::InstallMcp(request) => normalize_mcp_install(request), + OperationRequest::Refresh(request) => normalize_refresh(request), + OperationRequest::Catalog { kind, group, .. } => { + *kind = kind.trim().to_string(); + *group = normalized_optional_string(group.take()); + } + OperationRequest::Health(_) => {} + } + normalized +} + +pub(crate) fn normalize_materialize_options(options: &mut MaterializeOptions) { + default_string(&mut options.mode, DEFAULT_MATERIALIZATION_MODE); + default_string( + &mut options.semantic_provider_mode, + DEFAULT_SEMANTIC_PROVIDER_MODE, + ); + normalize_paths(&mut options.candidate_paths); +} + +pub(crate) fn validate_request(request: &OperationRequest) -> Result<(), ApiError> { + match request { + OperationRequest::Search(request) => { + validate_search_fields(&request.query, &request.detail, request.limit) + } + OperationRequest::Context(request) => { + if request.node_id.is_some() != request.node_type.is_some() { + return Err(ApiError::new( + "invalid_node_reference", + "context operation requires both node_id and node_type", + )); + } + if request.query.is_none() && request.node_id.is_none() { + return Err(ApiError::new( + "missing_query", + "context operation requires a query or explicit node reference", + )); + } + validate_detail_and_limit(&request.detail, request.limit) + } + OperationRequest::Query(request) => { + if request.statement.is_empty() { + return Err(ApiError::new( + "missing_query", + "graph_query requires a non-empty statement", + )); + } + if request.limit == 0 || request.limit > 1000 { + return Err(ApiError::new( + "invalid_limit", + "graph_query limit must be between 1 and 1000", + )); + } + if !request.parameters.is_object() { + return Err(ApiError::new( + "query_invalid_parameters", + "query parameters must be an object", + )); + } + Ok(()) + } + OperationRequest::Materialize(request) | OperationRequest::Plan(request) => { + validate_materialization_fields( + &request.mode, + &request.semantic_provider_mode, + "materialization", + ) + } + OperationRequest::Refresh(request) => validate_materialization_fields( + &request.mode, + &request.semantic_provider_mode, + "refresh", + ), + OperationRequest::Setup(request) => validate_lifecycle(request, "setup"), + OperationRequest::Reinstall(request) => validate_lifecycle(request, "reinstall"), + OperationRequest::Uninstall(request) => validate_lifecycle(request, "uninstall"), + OperationRequest::InstallMcp(request) => validate_mcp_install(request), + OperationRequest::Catalog { kind, .. } => { + if kind.is_empty() { + Err(ApiError::new( + "invalid_catalog_kind", + "catalog kind must not be empty", + )) + } else { + Ok(()) + } + } + OperationRequest::Health(_) => Ok(()), + } +} + +pub(crate) fn required_fields(operation_id: &str) -> &'static [&'static str] { + match operation_id { + "search" => &["query"], + "query" => &["statement"], + _ => &[], + } +} + +fn normalize_materialization(request: &mut MaterializationRequest) { + default_string(&mut request.mode, DEFAULT_MATERIALIZATION_MODE); + default_string( + &mut request.semantic_provider_mode, + DEFAULT_SEMANTIC_PROVIDER_MODE, + ); + request.source_root = normalized_optional_string(request.source_root.take()); + if request.repo.repo_root.is_none() { + request.repo.repo_root = request.source_root.as_deref().map(std::path::PathBuf::from); + } + request.git_base = normalized_optional_string(request.git_base.take()); + normalize_paths(&mut request.candidate_paths); +} + +fn normalize_refresh(request: &mut RefreshRequest) { + default_string(&mut request.mode, DEFAULT_MATERIALIZATION_MODE); + default_string( + &mut request.semantic_provider_mode, + DEFAULT_SEMANTIC_PROVIDER_MODE, + ); + normalize_paths(&mut request.paths); +} + +fn normalize_lifecycle(request: &mut RepositoryLifecycleRequest) { + request.action = request.action.trim().to_string(); + default_string(&mut request.mode, DEFAULT_MATERIALIZATION_MODE); + default_string( + &mut request.semantic_provider_mode, + DEFAULT_SEMANTIC_PROVIDER_MODE, + ); + request.mcp_client = normalized_optional_string(request.mcp_client.take()); + request.instructions_target = normalized_optional_string(request.instructions_target.take()); +} + +fn normalize_mcp_install(request: &mut McpInstallRequest) { + request.client = request.client.trim().to_ascii_lowercase(); + request.scope = request.scope.trim().to_ascii_lowercase(); + request.name = normalized_optional_string(request.name.take()); + request.client_config_path = request + .client_config_path + .take() + .map(|path| expand_path(&path.to_string_lossy())); + request.repo.repo_root = request + .repo + .repo_root + .take() + .map(|path| expand_path(&path.to_string_lossy())); + request.repo.config_path = request + .repo + .config_path + .take() + .map(|path| expand_path(&path.to_string_lossy())); +} + +fn validate_search_fields(query: &str, detail: &str, limit: usize) -> Result<(), ApiError> { + if query.trim().is_empty() { + return Err(ApiError::new( + "missing_query", + "Search query must not be empty", + )); + } + validate_detail_and_limit(detail, limit) +} + +fn validate_detail_and_limit(detail: &str, limit: usize) -> Result<(), ApiError> { + if detail != "standard" && detail != "slim" { + return Err(ApiError::new( + "invalid_detail", + "detail must be standard or slim", + )); + } + if limit == 0 { + return Err(ApiError::new( + "invalid_limit", + "search limit must be greater than zero", + )); + } + Ok(()) +} + +fn validate_materialization_fields( + mode: &str, + semantic_provider_mode: &str, + operation: &str, +) -> Result<(), ApiError> { + if mode != "full" && mode != "changed" { + return Err(ApiError::new( + "invalid_materialization_mode", + format!("{operation} mode must be full or changed"), + )); + } + if semantic_provider_mode != "local_only" { + return Err(ApiError::new( + "invalid_semantic_provider_mode", + format!("{operation} semantic provider mode must be local_only"), + )); + } + Ok(()) +} + +fn validate_lifecycle( + request: &RepositoryLifecycleRequest, + expected_action: &str, +) -> Result<(), ApiError> { + if request.action != expected_action { + return Err(ApiError::new( + "invalid_lifecycle_action", + format!( + "lifecycle request action {} does not match {expected_action}", + request.action + ), + )); + } + validate_materialization_fields( + &request.mode, + &request.semantic_provider_mode, + expected_action, + )?; + if let Some(client) = request.mcp_client.as_deref() { + let special_allowed = match expected_action { + "uninstall" => client == "all", + _ => client == "none", + }; + if !special_allowed && !supported_mcp_clients().contains(&client) { + return Err(ApiError::new( + "invalid_mcp_client", + format!("unsupported MCP client: {client}"), + )); + } + } + Ok(()) +} + +fn validate_mcp_install(request: &McpInstallRequest) -> Result<(), ApiError> { + if request.client != "all" && !supported_mcp_clients().contains(&request.client.as_str()) { + return Err(ApiError::new( + "invalid_mcp_client", + format!("unsupported MCP client: {}", request.client), + )); + } + if !matches!(request.scope.as_str(), "local" | "user" | "project") { + return Err(ApiError::new( + "invalid_mcp_scope", + "MCP install scope must be local, user, or project", + )); + } + if request.client == "all" && request.client_config_path.is_some() { + return Err(ApiError::new( + "invalid_mcp_config_path", + "client_config_path requires one selected MCP client", + )); + } + Ok(()) +} + +fn default_string(value: &mut String, default: &str) { + let normalized = value.trim(); + *value = if normalized.is_empty() { + default.to_string() + } else { + normalized.to_string() + }; +} + +fn normalized_optional_string(value: Option) -> Option { + value + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) +} + +fn argument_string(arguments: &serde_json::Value, key: &str) -> String { + arguments + .get(key) + .and_then(serde_json::Value::as_str) + .unwrap_or("") + .to_string() +} + +fn argument_optional_string(arguments: &serde_json::Value, key: &str) -> Option { + arguments + .get(key) + .and_then(serde_json::Value::as_str) + .map(str::to_string) +} + +fn argument_usize(arguments: &serde_json::Value, key: &str) -> Option { + arguments + .get(key) + .and_then(serde_json::Value::as_u64) + .and_then(|value| usize::try_from(value).ok()) +} + +fn normalize_paths(paths: &mut Vec) { + for path in paths.iter_mut() { + *path = path.trim().trim_start_matches("./").replace('\\', "/"); + } + paths.retain(|path| !path.is_empty()); + paths.sort(); + paths.dedup(); +} + +#[cfg(test)] +mod tests { + use super::{ + normalize_request, prepare_operation_request, required_fields, validate_request, + DEFAULT_CONTEXT_LIMIT, DEFAULT_DETAIL, DEFAULT_PROFILE, DEFAULT_QUERY_LIMIT, + DEFAULT_SEARCH_BUDGET, DEFAULT_SEARCH_LIMIT, + }; + use crate::api::contracts::{ + ContextRequest, MaterializationRequest, McpInstallRequest, OperationInvocation, + OperationRequest, OutputFormat, RepoSelector, SearchRequest, + }; + use serde_json::json; + + #[test] + fn normalize_request_applies_canonical_defaults() { + let normalized = normalize_request(&OperationRequest::Search(SearchRequest { + repo: RepoSelector::default(), + query: "needle".to_string(), + profile: " ".to_string(), + limit: 3, + budget: 600, + context_limit: 3, + max_depth: None, + detail: String::new(), + output_format: OutputFormat::Typed, + })); + + let OperationRequest::Search(search) = normalized else { + panic!("search request should remain a search request"); + }; + assert_eq!(search.profile, "brief"); + assert_eq!(search.detail, "standard"); + + let normalized = + normalize_request(&OperationRequest::Materialize(MaterializationRequest { + repo: RepoSelector::default(), + native_request_path: None, + source_root: Some(" ./repository ".to_string()), + mode: String::new(), + include_fts: true, + semantic_enrichment: true, + semantic_provider_mode: String::new(), + use_git: false, + git_diff: false, + git_base: None, + include_patterns: Vec::new(), + exclude_patterns: Vec::new(), + candidate_paths: Vec::new(), + parallel: true, + progress: false, + output_format: OutputFormat::Typed, + })); + let OperationRequest::Materialize(materialization) = normalized else { + panic!("materialization request should remain a materialization request"); + }; + assert_eq!(materialization.mode, "changed"); + assert_eq!(materialization.semantic_provider_mode, "local_only"); + assert_eq!( + materialization.repo.repo_root, + Some(std::path::PathBuf::from("./repository")) + ); + } + + #[test] + fn prepare_operation_request_applies_canonical_registered_input_defaults() { + let invocation = OperationInvocation { + repo: RepoSelector::default(), + arguments: json!({"query": "needle"}), + output_format: OutputFormat::Typed, + }; + + let request = prepare_operation_request("search", &invocation) + .expect("registered search input should prepare"); + let OperationRequest::Search(search) = request else { + panic!("registered search input should produce a search request"); + }; + assert_eq!(search.profile, DEFAULT_PROFILE); + assert_eq!(search.detail, DEFAULT_DETAIL); + assert_eq!(search.limit, DEFAULT_SEARCH_LIMIT); + assert_eq!(search.budget, DEFAULT_SEARCH_BUDGET); + assert_eq!(search.context_limit, DEFAULT_CONTEXT_LIMIT); + + let query = prepare_operation_request( + "query", + &OperationInvocation { + arguments: json!({"statement": "MATCH (n) RETURN n"}), + ..invocation + }, + ) + .expect("registered query input should prepare"); + let OperationRequest::Query(query) = query else { + panic!("registered query input should produce a query request"); + }; + assert_eq!(query.limit, DEFAULT_QUERY_LIMIT); + assert_eq!(query.parameters, json!({})); + } + + #[test] + fn validate_request_rejects_invalid_operation_rules_before_execution() { + let error = validate_request(&OperationRequest::Context(ContextRequest { + repo: RepoSelector::default(), + query: None, + profile: "brief".to_string(), + limit: 3, + budget: 600, + context_limit: 3, + max_depth: None, + detail: "standard".to_string(), + node_id: Some("Function:1".to_string()), + node_type: None, + output_format: OutputFormat::Typed, + })) + .expect_err("partial node reference should be rejected"); + + assert_eq!(error.code, "invalid_node_reference"); + } + + #[test] + fn validate_request_rejects_unsupported_mcp_clients_and_scopes() { + let request = |client: &str, scope: &str| { + OperationRequest::InstallMcp(McpInstallRequest { + repo: RepoSelector::default(), + client: client.to_string(), + scope: scope.to_string(), + name: None, + client_config_path: None, + dry_run: true, + output_format: OutputFormat::Typed, + }) + }; + + let client_error = validate_request(&request("unknown-client", "local")) + .expect_err("unsupported clients should be rejected by the API"); + assert_eq!(client_error.code, "invalid_mcp_client"); + + let scope_error = validate_request(&request("generic", "unknown-scope")) + .expect_err("unsupported scopes should be rejected by the API"); + assert_eq!(scope_error.code, "invalid_mcp_scope"); + } + + #[test] + fn operation_schema_requirements_share_normalization_policy() { + assert_eq!(required_fields("search"), ["query"]); + assert_eq!(required_fields("query"), ["statement"]); + assert!(required_fields("context").is_empty()); + } +} diff --git a/src/cli/format/blocks.rs b/src/api/presentation_support.rs similarity index 75% rename from src/cli/format/blocks.rs rename to src/api/presentation_support.rs index 43100e7..e7e3d8f 100644 --- a/src/cli/format/blocks.rs +++ b/src/api/presentation_support.rs @@ -1,4 +1,4 @@ -pub(in crate::cli) fn serialize_schema_block(payload: &serde_json::Value) -> String { +pub(crate) fn serialize_schema_block(payload: &serde_json::Value) -> String { let node_types = value_array(payload, "node_types"); let relation_types = value_array(payload, "relation_types"); let parser_mappings = value_array(payload, "parser_node_mappings"); @@ -53,7 +53,7 @@ pub(in crate::cli) fn serialize_schema_block(payload: &serde_json::Value) -> Str format!("{}\n", lines.join("\n")) } -pub(in crate::cli) fn serialize_query_helpers_block(payload: &serde_json::Value) -> String { +pub(crate) fn serialize_query_helpers_block(payload: &serde_json::Value) -> String { let helpers = value_array(payload, "query_helpers"); let mut lines = vec![format!("query_helpers count={}", helpers.len())]; for helper in helpers { @@ -62,7 +62,7 @@ pub(in crate::cli) fn serialize_query_helpers_block(payload: &serde_json::Value) format!("{}\n", lines.join("\n")) } -pub(in crate::cli) fn serialize_architecture_queries_block(payload: &serde_json::Value) -> String { +pub(crate) fn serialize_architecture_queries_block(payload: &serde_json::Value) -> String { let groups = value_array(payload, "groups"); let mut lines = vec![format!( "architecture_queries workflow={} execution_tool={} groups={}", @@ -87,7 +87,7 @@ pub(in crate::cli) fn serialize_architecture_queries_block(payload: &serde_json: format!("{}\n", lines.join("\n")) } -pub(in crate::cli) fn serialize_health_block(payload: &serde_json::Value) -> String { +pub(crate) fn serialize_health_block(payload: &serde_json::Value) -> String { let mut lines = vec![format!( "health ok={} database_exists={} manifest_exists={} graph_readable={} total_nodes={}", value_bool(payload, "ok"), @@ -108,7 +108,7 @@ pub(in crate::cli) fn serialize_health_block(payload: &serde_json::Value) -> Str format!("{}\n", lines.join("\n")) } -pub(in crate::cli) fn serialize_search_block(payload: &serde_json::Value) -> String { +pub(crate) fn serialize_search_block(payload: &serde_json::Value) -> String { let results = value_array(payload, "results"); let mut lines = vec![format!("q {}", block_value(value_str(payload, "query")))]; let mut current_path: Option = None; @@ -141,7 +141,7 @@ pub(in crate::cli) fn serialize_search_block(payload: &serde_json::Value) -> Str format!("{}\n", lines.join("\n")) } -pub(in crate::cli) fn serialize_query_block(payload: &serde_json::Value) -> String { +pub(crate) fn serialize_query_block(payload: &serde_json::Value) -> String { let rows = value_array(payload, "rows"); let columns = query_columns(value_str(payload, "statement")); let mut lines = vec![format!( @@ -187,7 +187,151 @@ pub(in crate::cli) fn serialize_query_block(payload: &serde_json::Value) -> Stri format!("{}\n", lines.join("\n")) } -pub(in crate::cli) fn query_columns(statement: &str) -> Vec { +pub(crate) fn serialize_context_block(payload: &serde_json::Value) -> String { + let mut lines = vec![format!( + "context {} id={} profile={}", + value_str(payload, "node_type"), + block_value(value_str(payload, "node_id")), + block_value(value_str(payload, "profile")) + )]; + let mut current_path: Option = None; + for context in value_array(payload, "context") { + let context_path = value_str(context, "path").to_string(); + if current_path.as_deref() != Some(context_path.as_str()) { + if lines.len() > 1 { + lines.push(String::new()); + } + lines.push(format!("file path {}", block_value(&context_path))); + current_path = Some(context_path); + } + let mut parts = vec![ + value_str(context, "direction").to_string(), + value_str(context, "relation").to_string(), + value_str(context, "type").to_string(), + block_value(value_str(context, "label")), + block_span(context.get("span")), + ]; + let summary = value_str(context, "summary"); + if !summary.is_empty() && summary != value_str(context, "label") { + parts.push(format!("summary={}", block_value(summary))); + } + lines.push(parts.join(" ").trim_end().to_string()); + } + format!("{}\n", lines.join("\n")) +} + +pub(crate) fn serialize_plan_block(payload: &serde_json::Value) -> String { + let mut lines = vec![format!( + "plan mode={} scanned={} rebuild={} delete={} skip={} ignored={}", + block_value(value_str(payload, "mode")), + payload + .get("scanned") + .and_then(serde_json::Value::as_u64) + .unwrap_or(0), + payload + .get("rebuilt") + .and_then(serde_json::Value::as_u64) + .unwrap_or(0), + payload + .get("deleted") + .and_then(serde_json::Value::as_u64) + .unwrap_or(0), + payload + .get("skipped") + .and_then(serde_json::Value::as_u64) + .unwrap_or(0), + payload + .get("ignored") + .and_then(serde_json::Value::as_u64) + .unwrap_or(0), + )]; + append_plan_path_lines(&mut lines, "rebuild", value_array(payload, "would_rebuild")); + append_plan_path_lines(&mut lines, "delete", value_array(payload, "would_delete")); + append_plan_path_lines(&mut lines, "skip", value_array(payload, "would_skip")); + append_plan_path_lines(&mut lines, "ignore", value_array(payload, "ignored_paths")); + format!("{}\n", lines.join("\n")) +} + +pub(crate) fn serialize_uninstall_block(output: &serde_json::Value) -> String { + let mut lines = vec![format!( + "uninstall ok={} server_name={}", + output["ok"].as_bool().unwrap_or(false), + output["server_name"].as_str().unwrap_or_default() + )]; + if let Some(state) = output["state"].as_object() { + lines.push(format!( + "state action={} path={}", + state + .get("action") + .and_then(serde_json::Value::as_str) + .unwrap_or("unknown"), + state + .get("path") + .and_then(serde_json::Value::as_str) + .unwrap_or_default() + )); + } + for item in output["instructions"].as_array().into_iter().flatten() { + lines.push(format!( + "instructions action={} path={}", + item["action"].as_str().unwrap_or("unknown"), + item["path"].as_str().unwrap_or_default() + )); + } + for item in output["mcp_clients"].as_array().into_iter().flatten() { + lines.push(format!( + "mcp client={} action={} path={}", + item["client"].as_str().unwrap_or("unknown"), + item["action"].as_str().unwrap_or("unknown"), + item["path"].as_str().unwrap_or_default() + )); + } + lines.join("\n") + "\n" +} + +fn block_span(value: Option<&serde_json::Value>) -> String { + let Some(span) = value.and_then(serde_json::Value::as_object) else { + return String::new(); + }; + let start = span + .get("line_start") + .and_then(serde_json::Value::as_i64) + .unwrap_or_default(); + let end = span + .get("line_end") + .and_then(serde_json::Value::as_i64) + .unwrap_or(start); + if start <= 0 && end <= 0 { + String::new() + } else { + format!("L{start}-L{end}") + } +} + +fn append_query_spec_lines(lines: &mut Vec, query: &serde_json::Value, indent: &str) { + lines.push(format!( + "{indent}query {} description={}", + block_value(value_str(query, "name")), + block_value(value_str(query, "description")) + )); + let parameters = csv_values(query.get("parameters")); + if !parameters.is_empty() { + lines.push(format!("{indent}parameters {parameters}")); + } + let returns = csv_values(query.get("returns")); + if !returns.is_empty() { + lines.push(format!("{indent}returns {returns}")); + } + if let Some(statement) = query + .get("statement") + .or_else(|| query.get("query")) + .and_then(serde_json::Value::as_str) + { + lines.push(format!("{indent}statement {}", block_value(statement))); + } +} + +fn query_columns(statement: &str) -> Vec { let upper = statement.to_ascii_uppercase(); let Some(return_index) = upper.find("RETURN") else { return Vec::new(); @@ -208,7 +352,7 @@ pub(in crate::cli) fn query_columns(statement: &str) -> Vec { .collect() } -pub(in crate::cli) fn split_return_expressions(text: &str) -> Vec { +fn split_return_expressions(text: &str) -> Vec { let mut expressions = Vec::new(); let mut current = String::new(); let mut depth = 0_i32; @@ -239,7 +383,7 @@ pub(in crate::cli) fn split_return_expressions(text: &str) -> Vec { expressions } -pub(in crate::cli) fn query_column_label(expression: &str) -> Option { +fn query_column_label(expression: &str) -> Option { let parts = expression.split_whitespace().collect::>(); for index in 0..parts.len().saturating_sub(1) { if parts[index].eq_ignore_ascii_case("AS") && is_identifier(parts[index + 1]) { @@ -254,7 +398,7 @@ pub(in crate::cli) fn query_column_label(expression: &str) -> Option { } } -pub(in crate::cli) fn is_identifier(value: &str) -> bool { +fn is_identifier(value: &str) -> bool { let mut chars = value.chars(); let Some(first) = chars.next() else { return false; @@ -263,7 +407,7 @@ pub(in crate::cli) fn is_identifier(value: &str) -> bool { && chars.all(|character| character == '_' || character.is_ascii_alphanumeric()) } -pub(in crate::cli) fn block_json_value(value: &serde_json::Value) -> String { +fn block_json_value(value: &serde_json::Value) -> String { match value { serde_json::Value::Null => "null".to_string(), serde_json::Value::Bool(value) => value.to_string(), @@ -273,99 +417,7 @@ pub(in crate::cli) fn block_json_value(value: &serde_json::Value) -> String { } } -pub(in crate::cli) fn serialize_error_block(payload: &serde_json::Value) -> String { - let error = payload.get("error").unwrap_or(payload); - format!( - "error tool={} type={} message={}\n", - block_value(value_str(error, "tool")), - block_value(value_str(error, "type")), - block_value(value_str(error, "message")) - ) -} - -pub(in crate::cli) fn serialize_context_block(payload: &serde_json::Value) -> String { - let mut lines = vec![format!( - "context {} id={} profile={}", - value_str(payload, "node_type"), - block_value(value_str(payload, "node_id")), - block_value(value_str(payload, "profile")) - )]; - let mut current_path: Option = None; - for context in value_array(payload, "context") { - let context_path = value_str(context, "path").to_string(); - if current_path.as_deref() != Some(context_path.as_str()) { - if lines.len() > 1 { - lines.push(String::new()); - } - lines.push(format!("file path {}", block_value(&context_path))); - current_path = Some(context_path); - } - let mut parts = vec![ - value_str(context, "direction").to_string(), - value_str(context, "relation").to_string(), - value_str(context, "type").to_string(), - block_value(value_str(context, "label")), - block_span(context.get("span")), - ]; - let summary = value_str(context, "summary"); - if !summary.is_empty() && summary != value_str(context, "label") { - parts.push(format!("summary={}", block_value(summary))); - } - lines.push(parts.join(" ").trim_end().to_string()); - } - format!("{}\n", lines.join("\n")) -} - -pub(in crate::cli) fn block_span(value: Option<&serde_json::Value>) -> String { - let Some(span) = value.and_then(serde_json::Value::as_object) else { - return String::new(); - }; - let start = span - .get("line_start") - .and_then(serde_json::Value::as_i64) - .unwrap_or_default(); - let end = span - .get("line_end") - .and_then(serde_json::Value::as_i64) - .unwrap_or(start); - if start <= 0 && end <= 0 { - String::new() - } else { - format!("L{start}-L{end}") - } -} - -pub(in crate::cli) fn append_query_spec_lines( - lines: &mut Vec, - query: &serde_json::Value, - indent: &str, -) { - lines.push(format!( - "{indent}query {} description={}", - block_value(value_str(query, "name")), - block_value(value_str(query, "description")) - )); - let parameters = csv_values(query.get("parameters")); - if !parameters.is_empty() { - lines.push(format!("{indent}parameters {parameters}")); - } - let returns = csv_values(query.get("returns")); - if !returns.is_empty() { - lines.push(format!("{indent}returns {returns}")); - } - if let Some(statement) = query - .get("statement") - .or_else(|| query.get("query")) - .and_then(serde_json::Value::as_str) - { - lines.push(format!("{indent}statement {}", block_value(statement))); - } -} - -pub(in crate::cli) fn value_array<'a>( - payload: &'a serde_json::Value, - key: &str, -) -> &'a [serde_json::Value] { +fn value_array<'a>(payload: &'a serde_json::Value, key: &str) -> &'a [serde_json::Value] { payload .get(key) .and_then(serde_json::Value::as_array) @@ -373,21 +425,21 @@ pub(in crate::cli) fn value_array<'a>( .unwrap_or(&[]) } -pub(in crate::cli) fn value_str<'a>(payload: &'a serde_json::Value, key: &str) -> &'a str { +fn value_str<'a>(payload: &'a serde_json::Value, key: &str) -> &'a str { payload .get(key) .and_then(serde_json::Value::as_str) .unwrap_or("") } -pub(in crate::cli) fn value_bool(payload: &serde_json::Value, key: &str) -> bool { +fn value_bool(payload: &serde_json::Value, key: &str) -> bool { payload .get(key) .and_then(serde_json::Value::as_bool) .unwrap_or(false) } -pub(in crate::cli) fn csv_names(values: &[serde_json::Value]) -> String { +fn csv_names(values: &[serde_json::Value]) -> String { values .iter() .filter_map(|value| value.get("name").and_then(serde_json::Value::as_str)) @@ -396,7 +448,7 @@ pub(in crate::cli) fn csv_names(values: &[serde_json::Value]) -> String { .join(",") } -pub(in crate::cli) fn csv_values(value: Option<&serde_json::Value>) -> String { +fn csv_values(value: Option<&serde_json::Value>) -> String { value .and_then(serde_json::Value::as_array) .map(|values| { @@ -410,7 +462,15 @@ pub(in crate::cli) fn csv_values(value: Option<&serde_json::Value>) -> String { .unwrap_or_default() } -pub(in crate::cli) fn block_value(value: &str) -> String { +fn append_plan_path_lines(lines: &mut Vec, label: &str, paths: &[serde_json::Value]) { + for path in paths { + if let Some(path) = path.as_str() { + lines.push(format!("{label} {}", block_value(path))); + } + } +} + +fn block_value(value: &str) -> String { if value.is_empty() { "\"\"".to_string() } else if value.chars().all(|character| { diff --git a/src/api/presenter.rs b/src/api/presenter.rs new file mode 100644 index 0000000..57ce78e --- /dev/null +++ b/src/api/presenter.rs @@ -0,0 +1,77 @@ +use crate::api::contracts::{OperationResponse, OutputFormat}; +#[path = "presentation_support.rs"] +mod support; +use self::support::{ + serialize_architecture_queries_block, serialize_context_block, serialize_health_block, + serialize_plan_block, serialize_query_block, serialize_query_helpers_block, + serialize_schema_block, serialize_search_block, serialize_uninstall_block, +}; + +fn present_block(operation: &str, payload: &serde_json::Value) -> String { + match operation { + "health" => serialize_health_block(payload), + "search" => serialize_search_block(payload), + "context" => { + if payload.get("context").is_some() { + serialize_context_block(payload) + } else { + serialize_search_block(payload) + } + } + "query" => serialize_query_block(payload), + "schema" => serialize_schema_block(payload), + "query-helpers" => serialize_query_helpers_block(payload), + "architecture-queries" => serialize_architecture_queries_block(payload), + "plan" => serialize_plan_block(payload), + "uninstall" => serialize_uninstall_block(payload), + _ => serde_json::to_string_pretty(payload).unwrap_or_else(|_| String::new()), + } +} + +pub fn present_operation_response( + mut response: OperationResponse, + output_format: OutputFormat, +) -> OperationResponse { + response.output_format = output_format; + if output_format == OutputFormat::Block { + let text = present_block(&response.operation, &response.payload); + response.payload = serde_json::json!({ + "text": text, + "structured": response.payload, + }); + } + response +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn selected_output_format_presents_typed_or_block_response() { + let payload = json!({ + "statement": "MATCH (n) RETURN n", + "row_count": 1, + "rows": [{"n": "node-1"}], + "truncated": false, + }); + let typed = present_operation_response( + OperationResponse::from_payload("query", OutputFormat::Typed, payload.clone()), + OutputFormat::Typed, + ); + assert_eq!(typed.payload, payload); + assert_eq!(typed.output_format, OutputFormat::Typed); + + let block = present_operation_response( + OperationResponse::from_payload("query", OutputFormat::Typed, payload.clone()), + OutputFormat::Block, + ); + assert_eq!(block.output_format, OutputFormat::Block); + assert_eq!(block.payload["structured"], payload); + assert!(block.payload["text"] + .as_str() + .expect("block text should be present") + .starts_with("query rows=1")); + } +} diff --git a/src/api/refresh.rs b/src/api/refresh.rs new file mode 100644 index 0000000..ed3bf39 --- /dev/null +++ b/src/api/refresh.rs @@ -0,0 +1,1326 @@ +use crate::{ + api::{ + context::{resolve_runtime, RepoPaths}, + contracts::{ + MaterializationRequest, RefreshBackend, RefreshLoopConfig, RefreshWatchConfig, + RefreshWatchObserver, RefreshWatchSummary, RepoSelector, + }, + lifecycle::is_retryable_refresh_failure, + materialization::{ + default_excluded_parts, execute_candidate_materialization, read_codebase_graph_ignore, + read_materialization_config_rules, MaterializeOptions, + }, + normalization::normalize_materialize_options, + }, + protocol::NativeSyntaxMaterializationResponse, +}; +use notify::{ + event::{AccessKind, AccessMode}, + Event, EventKind, RecursiveMode, Watcher, +}; +use serde_json::json; +use std::{ + collections::{BTreeMap, BTreeSet, VecDeque}, + env, fs, + path::{Path, PathBuf}, + sync::{ + mpsc::{self, Receiver}, + Arc, Mutex, RwLock, RwLockReadGuard, RwLockWriteGuard, + }, + thread, + time::{Duration, Instant, SystemTime, UNIX_EPOCH}, +}; + +#[derive(Debug)] +pub(crate) struct WatchEventFilter { + pub(crate) source_root: PathBuf, + pub(crate) current_dir: PathBuf, + pub(crate) excluded_parts: BTreeSet, + pub(crate) include_patterns: Vec, + pub(crate) exclude_patterns: Vec, + pub(crate) ignore_patterns: Vec, +} + +impl WatchEventFilter { + #[cfg(test)] + pub(crate) fn from_request( + source_root: &Path, + request: &MaterializationRequest, + ) -> Result { + Self::from_patterns( + source_root, + request.repo.config_path.clone(), + request.include_patterns.clone(), + request.exclude_patterns.clone(), + ) + } + + pub(crate) fn from_options( + source_root: &Path, + options: &MaterializeOptions, + ) -> Result { + Self::from_patterns( + source_root, + options.config.clone(), + options.include_patterns.clone(), + options.exclude_patterns.clone(), + ) + } + + fn from_patterns( + source_root: &Path, + config_path: Option, + mut include_patterns: Vec, + mut exclude_patterns: Vec, + ) -> Result { + let config_path = config_path.unwrap_or_else(|| config_path_for(source_root)); + let config_rules = read_materialization_config_rules(&config_path)?; + include_patterns.splice(0..0, config_rules.include_patterns); + exclude_patterns.splice(0..0, config_rules.exclude_patterns); + Ok(Self { + source_root: source_root.to_path_buf(), + current_dir: env::current_dir().unwrap_or_else(|_| source_root.to_path_buf()), + excluded_parts: default_excluded_parts().into_iter().collect(), + include_patterns, + exclude_patterns, + ignore_patterns: read_codebase_graph_ignore(source_root)?, + }) + } + + pub(crate) fn relevant_paths(&self, event: &Event) -> BTreeSet { + if !watch_event_refreshes(event) { + return BTreeSet::new(); + } + event + .paths + .iter() + .filter_map(|path| self.relevant_path(path)) + .collect() + } + + pub(crate) fn relevant_path(&self, path: &Path) -> Option { + let relative = self.relative_event_path(path)?; + if relative.as_os_str().is_empty() { + return None; + } + if relative.components().any(|component| { + self.excluded_parts + .contains(component.as_os_str().to_string_lossy().as_ref()) + }) { + return None; + } + let relative = relative.to_string_lossy().replace('\\', "/"); + if self.ignored_by_patterns(&relative) { + None + } else { + Some(relative) + } + } + + pub(crate) fn relative_event_path(&self, path: &Path) -> Option { + if let Ok(relative) = path.strip_prefix(&self.source_root) { + return Some(relative.to_path_buf()); + } + if path.is_relative() { + let absolute = self.current_dir.join(path); + if let Ok(relative) = absolute.strip_prefix(&self.source_root) { + return Some(relative.to_path_buf()); + } + #[cfg(windows)] + { + let absolute = normalize_windows_verbatim_path(&absolute); + let source_root = normalize_windows_verbatim_path(&self.source_root); + if let Ok(relative) = absolute.strip_prefix(source_root) { + return Some(relative.to_path_buf()); + } + } + return Some(path.to_path_buf()); + } + None + } + + pub(crate) fn ignored_by_patterns(&self, relative_path: &str) -> bool { + if !self.include_patterns.is_empty() + && !watch_matches_any_pattern(relative_path, &self.include_patterns) + { + return true; + } + watch_matches_any_pattern(relative_path, &self.ignore_patterns) + || watch_matches_any_pattern(relative_path, &self.exclude_patterns) + } +} + +#[cfg(windows)] +fn normalize_windows_verbatim_path(path: &Path) -> PathBuf { + let normalized = path.to_string_lossy().replace('\\', "/"); + if let Some(stripped) = normalized.strip_prefix("//?/UNC/") { + PathBuf::from(format!("//{stripped}")) + } else if let Some(stripped) = normalized.strip_prefix("//?/") { + PathBuf::from(stripped) + } else { + PathBuf::from(normalized) + } +} + +fn config_path_for(source_root: &Path) -> PathBuf { + RepoPaths::derive(source_root).config_path +} + +pub(crate) fn watch_event_refreshes(event: &Event) -> bool { + matches!( + event.kind, + EventKind::Any + | EventKind::Create(_) + | EventKind::Modify(_) + | EventKind::Remove(_) + | EventKind::Other + | EventKind::Access(AccessKind::Close(AccessMode::Write)) + ) +} + +#[derive(Debug)] +pub(crate) enum WatchMessage { + Event(Event), + Error(String), +} + +#[derive(Debug, Default, PartialEq, Eq)] +pub(crate) struct WatchChangeBatch { + pub(crate) paths: BTreeSet, + pub(crate) event_count: usize, +} + +#[derive(Debug, Default)] +pub(crate) struct WatchProbeOutcome { + pub(crate) delivered: bool, + pub(crate) queued: VecDeque, + pub(crate) reason: Option, +} + +pub(crate) fn start_native_watcher( + source_root: &Path, +) -> Result<(notify::RecommendedWatcher, Receiver), String> { + let (tx, rx) = mpsc::channel(); + let mut watcher = notify::recommended_watcher(move |result: notify::Result| { + let message = match result { + Ok(event) => WatchMessage::Event(event), + Err(error) => WatchMessage::Error(error.to_string()), + }; + let _ = tx.send(message); + }) + .map_err(|error| format!("failed to start filesystem watcher: {error}"))?; + watcher + .watch(source_root, RecursiveMode::Recursive) + .map_err(|error| format!("failed to watch {}: {error}", source_root.display()))?; + Ok((watcher, rx)) +} + +pub(crate) fn probe_native_watcher( + source_root: &Path, + filter: &WatchEventFilter, + rx: &Receiver, +) -> Result { + let timeout = watch_probe_timeout(); + let probe_dir = source_root.join(".codebaseGraph").join("watch-probe"); + let probe_path = probe_dir.join(format!( + "probe-{}-{}.tmp", + std::process::id(), + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_nanos()) + .unwrap_or(0) + )); + if !watch_probe_skip_write() { + fs::create_dir_all(&probe_dir) + .map_err(|error| format!("failed to create watch probe directory: {error}"))?; + fs::write(&probe_path, b"probe") + .map_err(|error| format!("failed to write watch probe: {error}"))?; + } + + let started = Instant::now(); + let mut outcome = WatchProbeOutcome::default(); + while started.elapsed() < timeout { + let remaining = timeout.saturating_sub(started.elapsed()); + match rx.recv_timeout(remaining) { + Ok(WatchMessage::Event(event)) => { + outcome.delivered = true; + if !watch_event_is_under_dir(&event, &probe_dir, source_root, &filter.current_dir) { + outcome.queued.push_back(WatchMessage::Event(event)); + } + } + Ok(WatchMessage::Error(error)) => { + outcome.reason = Some("watcher_error".to_string()); + outcome.queued.push_back(WatchMessage::Error(error)); + break; + } + Err(mpsc::RecvTimeoutError::Timeout) => break, + Err(mpsc::RecvTimeoutError::Disconnected) => { + return Err("filesystem watcher stopped during health probe".to_string()) + } + } + } + let _ = fs::remove_file(&probe_path); + if !outcome.delivered && outcome.reason.is_none() { + outcome.reason = Some("probe_timeout".to_string()); + } + Ok(outcome) +} + +fn watch_probe_timeout() -> Duration { + env::var("CODEBASE_GRAPH_WATCH_PROBE_TIMEOUT_MS") + .ok() + .and_then(|value| value.parse::().ok()) + .map(Duration::from_millis) + .unwrap_or_else(|| Duration::from_millis(750)) +} + +fn watch_probe_skip_write() -> bool { + env::var("CODEBASE_GRAPH_WATCH_PROBE_SKIP_WRITE").is_ok_and(|value| value == "1") +} + +fn watch_event_is_under_dir( + event: &Event, + directory: &Path, + source_root: &Path, + current_dir: &Path, +) -> bool { + !event.paths.is_empty() + && event + .paths + .iter() + .all(|path| watch_path_is_under_dir(path, directory, source_root, current_dir)) +} + +fn watch_path_is_under_dir( + path: &Path, + directory: &Path, + source_root: &Path, + current_dir: &Path, +) -> bool { + if path.starts_with(directory) { + return true; + } + if path.is_relative() { + return current_dir.join(path).starts_with(directory) + || source_root.join(path).starts_with(directory); + } + false +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) struct WatchFileState { + pub(crate) modified_nanos: u128, + pub(crate) len: u64, +} + +pub(crate) type WatchFileSnapshot = BTreeMap; + +pub(crate) fn apply_watch_message( + message: WatchMessage, + filter: &WatchEventFilter, + batch: &mut WatchChangeBatch, +) -> Result<(), String> { + match message { + WatchMessage::Event(event) => { + let paths = filter.relevant_paths(&event); + if !paths.is_empty() { + batch.event_count += 1; + batch.paths.extend(paths); + } + Ok(()) + } + WatchMessage::Error(error) => Err(format!("filesystem watcher error: {error}")), + } +} + +pub(crate) fn collect_watch_batch( + first: WatchMessage, + rx: &Receiver, + queued: &mut VecDeque, + filter: &WatchEventFilter, + debounce: Duration, + max_wait: Duration, +) -> Result, String> { + let mut batch = WatchChangeBatch::default(); + apply_watch_message(first, filter, &mut batch)?; + if batch.paths.is_empty() { + return Ok(None); + } + + let started = Instant::now(); + let mut last_relevant = started; + loop { + let elapsed = started.elapsed(); + if elapsed >= max_wait { + return Ok(Some(batch)); + } + let quiet_elapsed = last_relevant.elapsed(); + if quiet_elapsed >= debounce { + return Ok(Some(batch)); + } + let timeout = debounce + .saturating_sub(quiet_elapsed) + .min(max_wait.saturating_sub(elapsed)); + let message = match queued.pop_front() { + Some(message) => Ok(message), + None => rx.recv_timeout(timeout), + }; + match message { + Ok(message) => { + let before = batch.paths.len(); + let before_events = batch.event_count; + apply_watch_message(message, filter, &mut batch)?; + if batch.paths.len() != before || batch.event_count != before_events { + last_relevant = Instant::now(); + } + } + Err(mpsc::RecvTimeoutError::Timeout) => return Ok(Some(batch)), + Err(mpsc::RecvTimeoutError::Disconnected) => { + return Err("filesystem watcher stopped".to_string()) + } + } + } +} + +pub(crate) fn watch_file_snapshot(filter: &WatchEventFilter) -> Result { + let mut snapshot = BTreeMap::new(); + watch_file_snapshot_inner(filter, &filter.source_root, &mut snapshot)?; + Ok(snapshot) +} + +fn watch_file_snapshot_inner( + filter: &WatchEventFilter, + directory: &Path, + snapshot: &mut WatchFileSnapshot, +) -> Result<(), String> { + let entries = fs::read_dir(directory) + .map_err(|error| format!("failed to read directory {}: {error}", directory.display()))?; + for entry in entries.flatten() { + let path = entry.path(); + if path.is_dir() { + let name = path + .file_name() + .and_then(|value| value.to_str()) + .unwrap_or(""); + if filter.excluded_parts.contains(name) { + continue; + } + watch_file_snapshot_inner(filter, &path, snapshot)?; + } else if path.is_file() { + let Some(relative_path) = filter.relevant_path(&path) else { + continue; + }; + let metadata = match fs::metadata(&path) { + Ok(metadata) => metadata, + Err(_) => continue, + }; + let modified_nanos = metadata + .modified() + .ok() + .and_then(|modified| { + modified + .duration_since(std::time::UNIX_EPOCH) + .ok() + .map(|duration| duration.as_nanos()) + }) + .unwrap_or(0); + snapshot.insert( + relative_path, + WatchFileState { + modified_nanos, + len: metadata.len(), + }, + ); + } + } + Ok(()) +} + +pub(crate) fn watch_snapshot_diff( + previous: &WatchFileSnapshot, + current: &WatchFileSnapshot, +) -> BTreeSet { + let mut changed_paths = BTreeSet::new(); + for (path, state) in current { + if previous.get(path) != Some(state) { + changed_paths.insert(path.clone()); + } + } + for path in previous.keys() { + if !current.contains_key(path) { + changed_paths.insert(path.clone()); + } + } + changed_paths +} + +pub(crate) fn collect_poll_batch( + filter: &WatchEventFilter, + previous_snapshot: &mut WatchFileSnapshot, + poll_interval: Duration, + debounce: Duration, + max_wait: Duration, +) -> Result { + loop { + thread::sleep(poll_interval); + let current_snapshot = watch_file_snapshot(filter)?; + let changed_paths = watch_snapshot_diff(previous_snapshot, ¤t_snapshot); + *previous_snapshot = current_snapshot; + if changed_paths.is_empty() { + continue; + } + + let started = Instant::now(); + let mut last_relevant = started; + let mut batch = WatchChangeBatch { + paths: changed_paths, + event_count: 1, + }; + loop { + let elapsed = started.elapsed(); + if elapsed >= max_wait { + return Ok(batch); + } + let quiet_elapsed = last_relevant.elapsed(); + if quiet_elapsed >= debounce { + return Ok(batch); + } + let timeout = poll_interval + .min(debounce.saturating_sub(quiet_elapsed)) + .min(max_wait.saturating_sub(elapsed)); + thread::sleep(timeout); + let current_snapshot = watch_file_snapshot(filter)?; + let changed_paths = watch_snapshot_diff(previous_snapshot, ¤t_snapshot); + *previous_snapshot = current_snapshot; + if !changed_paths.is_empty() { + batch.paths.extend(changed_paths); + batch.event_count += 1; + last_relevant = Instant::now(); + } + } + } +} + +pub(crate) fn run_refresh_watch( + request: &MaterializationRequest, + config: RefreshWatchConfig, + observer: &mut impl RefreshWatchObserver, +) -> Result<(), String> { + let runtime = resolve_runtime(&request.repo)?; + let mut materialize_options = MaterializeOptions::from_request(request, &runtime, false); + normalize_materialize_options(&mut materialize_options); + + if config.once { + let response = execute_refresh_operation(&materialize_options, Vec::new())?; + return observer.on_success(None, &refresh_watch_summary(&response), 0, 0); + } + + let filter = WatchEventFilter::from_options(&runtime.repo_root, &materialize_options)?; + match config.backend { + RefreshBackend::Poll => run_poll_watch(config.loop_config, &filter, |batch| { + refresh_watch_batch(observer, "poll", &materialize_options, batch) + }), + RefreshBackend::Native => { + let (watcher, rx) = start_native_watcher(&runtime.repo_root)?; + run_native_watch( + config.loop_config, + &filter, + watcher, + rx, + VecDeque::new(), + |batch| refresh_watch_batch(observer, "native", &materialize_options, batch), + ) + } + RefreshBackend::Auto => match start_native_watcher(&runtime.repo_root) { + Ok((watcher, rx)) => { + let probe = probe_native_watcher(&runtime.repo_root, &filter, &rx)?; + if probe.delivered { + run_native_watch( + config.loop_config, + &filter, + watcher, + rx, + probe.queued, + |batch| { + refresh_watch_batch(observer, "native", &materialize_options, batch) + }, + ) + } else { + drop(watcher); + observer + .on_fallback("poll", probe.reason.as_deref().unwrap_or("probe_failed"))?; + run_poll_watch(config.loop_config, &filter, |batch| { + refresh_watch_batch(observer, "poll", &materialize_options, batch) + }) + } + } + Err(_) => { + observer.on_fallback("poll", "watcher_start_failed")?; + run_poll_watch(config.loop_config, &filter, |batch| { + refresh_watch_batch(observer, "poll", &materialize_options, batch) + }) + } + }, + } +} + +fn refresh_watch_batch( + observer: &mut impl RefreshWatchObserver, + backend: &str, + materialize_options: &MaterializeOptions, + batch: &WatchChangeBatch, +) -> Result { + let mut bound_observer = BoundRefreshWatchObserver { observer, backend }; + execute_refresh_with_policy( + &mut bound_observer, + batch.event_count, + &batch.paths, + RefreshRetryPolicy::default(), + |candidate_paths| execute_refresh_operation(materialize_options, candidate_paths), + ) +} + +struct BoundRefreshWatchObserver<'a, O> { + observer: &'a mut O, + backend: &'a str, +} + +impl RefreshObserver for BoundRefreshWatchObserver<'_, O> { + fn on_success( + &mut self, + response: &NativeSyntaxMaterializationResponse, + event_count: usize, + changed_paths: usize, + ) -> Result<(), String> { + self.observer.on_success( + Some(self.backend), + &refresh_watch_summary(response), + event_count, + changed_paths, + ) + } + + fn on_error( + &mut self, + error: &str, + retrying: bool, + event_count: usize, + changed_paths: usize, + ) -> Result<(), String> { + self.observer + .on_error(self.backend, error, retrying, event_count, changed_paths) + } +} + +fn refresh_watch_summary(response: &NativeSyntaxMaterializationResponse) -> RefreshWatchSummary { + RefreshWatchSummary { + rebuilt: response.diff.rebuild_paths().len(), + deleted: response.diff.deleted.len(), + skipped: response.skipped, + database_written: response.database_written, + } +} + +pub(crate) fn run_poll_watch( + config: RefreshLoopConfig, + filter: &WatchEventFilter, + mut refresh: impl FnMut(&WatchChangeBatch) -> Result, +) -> Result<(), String> { + let mut previous_snapshot = watch_file_snapshot(filter)?; + let mut refreshes = 0_usize; + loop { + let batch = collect_poll_batch( + filter, + &mut previous_snapshot, + config.poll_interval, + config.debounce, + config.max_wait, + )?; + if !refresh(&batch)? { + continue; + } + refreshes += 1; + if config.max_iterations.is_some_and(|max| refreshes >= max) { + return Ok(()); + } + } +} + +pub(crate) fn run_native_watch( + config: RefreshLoopConfig, + filter: &WatchEventFilter, + _watcher: notify::RecommendedWatcher, + rx: Receiver, + mut queued: VecDeque, + mut refresh: impl FnMut(&WatchChangeBatch) -> Result, +) -> Result<(), String> { + let mut refreshes = 0_usize; + loop { + let first = match queued.pop_front() { + Some(message) => message, + None => rx + .recv() + .map_err(|error| format!("filesystem watcher stopped: {error}"))?, + }; + let Some(batch) = collect_watch_batch( + first, + &rx, + &mut queued, + filter, + config.debounce, + config.max_wait, + )? + else { + continue; + }; + if !refresh(&batch)? { + continue; + } + refreshes += 1; + if config.max_iterations.is_some_and(|max| refreshes >= max) { + return Ok(()); + } + } +} + +pub(crate) fn execute_refresh_operation( + options: &MaterializeOptions, + paths: Vec, +) -> Result { + let (_request, response) = execute_candidate_materialization(options, paths)?; + Ok(response) +} + +#[derive(Clone, Copy, Debug)] +pub(crate) struct RefreshRetryPolicy { + pub(crate) initial_delay: Duration, + pub(crate) max_delay: Duration, +} + +impl Default for RefreshRetryPolicy { + fn default() -> Self { + Self { + initial_delay: Duration::from_millis(100), + max_delay: Duration::from_millis(1_000), + } + } +} + +pub(crate) trait RefreshObserver { + fn before_attempt(&mut self, _event_count: usize, _changed_paths: usize) -> Result<(), String> { + Ok(()) + } + + fn on_success( + &mut self, + response: &NativeSyntaxMaterializationResponse, + event_count: usize, + changed_paths: usize, + ) -> Result<(), String>; + + fn on_error( + &mut self, + error: &str, + retrying: bool, + event_count: usize, + changed_paths: usize, + ) -> Result<(), String>; +} + +pub(crate) fn execute_refresh_with_policy( + observer: &mut impl RefreshObserver, + event_count: usize, + paths: &BTreeSet, + policy: RefreshRetryPolicy, + mut refresh: impl FnMut(Vec) -> Result, +) -> Result { + let changed_paths = paths.len(); + if changed_paths == 0 { + return Ok(true); + } + + let candidate_paths = paths.iter().cloned().collect::>(); + let mut delay = policy.initial_delay; + loop { + observer.before_attempt(event_count, changed_paths)?; + match refresh(candidate_paths.clone()) { + Ok(response) => { + observer.on_success(&response, event_count, changed_paths)?; + return Ok(true); + } + Err(error) => { + let retrying = is_retryable_refresh_failure(&error); + observer.on_error(&error, retrying, event_count, changed_paths)?; + if !retrying { + return Ok(false); + } + thread::sleep(delay); + delay = delay.saturating_mul(2).min(policy.max_delay); + } + } + } +} + +#[derive(Clone, Debug)] +pub(crate) struct RefreshStatus { + pub(crate) enabled: bool, + pub(crate) backend: String, + pub(crate) refreshing: bool, + pub(crate) pending: bool, + pub(crate) last_refresh_unix_ms: Option, + pub(crate) last_error: Option, + pub(crate) last_error_count: usize, + pub(crate) last_retry_unix_ms: Option, + pub(crate) last_event_count: usize, + pub(crate) last_changed_paths: usize, + pub(crate) last_rebuilt: usize, + pub(crate) last_deleted: usize, + pub(crate) last_database_written: bool, +} + +impl Default for RefreshStatus { + fn default() -> Self { + Self { + enabled: true, + backend: "starting".to_string(), + refreshing: false, + pending: false, + last_refresh_unix_ms: None, + last_error: None, + last_error_count: 0, + last_retry_unix_ms: None, + last_event_count: 0, + last_changed_paths: 0, + last_rebuilt: 0, + last_deleted: 0, + last_database_written: false, + } + } +} + +#[derive(Debug)] +pub(crate) struct RefreshState { + status: Mutex, + graph_lock: RwLock<()>, +} + +impl RefreshState { + pub(crate) fn new() -> Self { + Self { + status: Mutex::new(RefreshStatus::default()), + graph_lock: RwLock::new(()), + } + } + + pub(crate) fn snapshot(&self) -> RefreshStatus { + self.status + .lock() + .map(|status| status.clone()) + .unwrap_or_else(|_| RefreshStatus { + enabled: false, + backend: "failed".to_string(), + refreshing: false, + pending: false, + last_refresh_unix_ms: None, + last_error: Some("refresh status lock poisoned".to_string()), + last_error_count: 1, + last_retry_unix_ms: None, + last_event_count: 0, + last_changed_paths: 0, + last_rebuilt: 0, + last_deleted: 0, + last_database_written: false, + }) + } + + pub(crate) fn as_json(&self) -> serde_json::Value { + let status = self.snapshot(); + json!({ + "enabled": status.enabled, + "backend": status.backend, + "refreshing": status.refreshing, + "pending": status.pending, + "last_refresh_unix_ms": status.last_refresh_unix_ms, + "last_error": status.last_error, + "last_error_count": status.last_error_count, + "last_retry_unix_ms": status.last_retry_unix_ms, + "last_event_count": status.last_event_count, + "last_changed_paths": status.last_changed_paths, + "last_rebuilt": status.last_rebuilt, + "last_deleted": status.last_deleted, + "last_database_written": status.last_database_written, + }) + } + + pub(crate) fn read_guard(&self) -> Result, String> { + self.graph_lock + .read() + .map_err(|_| "refresh graph read lock poisoned".to_string()) + } + + pub(crate) fn write_guard(&self) -> Result, String> { + self.graph_lock + .write() + .map_err(|_| "refresh graph write lock poisoned".to_string()) + } + + pub(crate) fn set_backend(&self, backend: &str) { + if let Ok(mut status) = self.status.lock() { + status.backend = backend.to_string(); + status.enabled = true; + status.last_error = None; + } + } + + pub(crate) fn set_error(&self, backend: &str, error: String) { + if let Ok(mut status) = self.status.lock() { + status.backend = backend.to_string(); + status.enabled = true; + status.refreshing = false; + status.pending = false; + status.last_error = Some(error); + status.last_error_count = status.last_error_count.saturating_add(1); + } + } + + pub(crate) fn mark_pending(&self) { + if let Ok(mut status) = self.status.lock() { + status.pending = true; + } + } + + pub(crate) fn mark_refreshing(&self, backend: &str) { + if let Ok(mut status) = self.status.lock() { + status.backend = backend.to_string(); + status.refreshing = true; + status.pending = false; + status.last_error = None; + } + } + + pub(crate) fn mark_refresh_error( + &self, + backend: &str, + event_count: usize, + changed_paths: usize, + error: String, + retrying: bool, + ) { + if let Ok(mut status) = self.status.lock() { + status.backend = backend.to_string(); + status.refreshing = false; + status.pending = retrying; + status.last_error = Some(error); + status.last_error_count = status.last_error_count.saturating_add(1); + status.last_retry_unix_ms = retrying.then_some(unix_ms()); + status.last_event_count = event_count; + status.last_changed_paths = changed_paths; + } + } + + pub(crate) fn mark_refreshed( + &self, + backend: &str, + event_count: usize, + changed_paths: usize, + rebuilt: usize, + deleted: usize, + database_written: bool, + ) { + if let Ok(mut status) = self.status.lock() { + status.backend = backend.to_string(); + status.refreshing = false; + status.pending = false; + status.last_refresh_unix_ms = Some(unix_ms()); + status.last_error = None; + status.last_error_count = 0; + status.last_retry_unix_ms = None; + status.last_event_count = event_count; + status.last_changed_paths = changed_paths; + status.last_rebuilt = rebuilt; + status.last_deleted = deleted; + status.last_database_written = database_written; + } + } +} + +pub(crate) fn start_refresh_service(selector: RepoSelector) -> Arc { + let state = Arc::new(RefreshState::new()); + let thread_state = Arc::clone(&state); + thread::spawn(move || { + if let Err(error) = run_refresh_service(selector, &thread_state) { + thread_state.set_error("failed", error.clone()); + eprintln!( + "{}", + json!({"event": "repository.refresh_error", "message": error}) + ); + } + }); + state +} + +fn run_refresh_service(selector: RepoSelector, state: &Arc) -> Result<(), String> { + let runtime = resolve_runtime(&selector)?; + let materialize_options = MaterializeOptions { + source_root: Some(runtime.repo_root.clone()), + config: runtime.config_path, + db: Some(runtime.db_path), + manifest: Some(runtime.manifest_path), + mode: "changed".to_string(), + include_fts: true, + semantic_enrichment: true, + semantic_provider_mode: "local_only".to_string(), + use_git: false, + ..MaterializeOptions::default() + }; + let filter = WatchEventFilter::from_options(&runtime.repo_root, &materialize_options)?; + let loop_config = RefreshLoopConfig { + poll_interval: Duration::from_millis(500), + debounce: Duration::from_millis(250), + max_wait: Duration::from_millis(1_000), + max_iterations: None, + }; + + match start_native_watcher(&runtime.repo_root) { + Ok((watcher, rx)) => { + let probe = probe_native_watcher(&runtime.repo_root, &filter, &rx)?; + if probe.delivered { + state.set_backend("native"); + match run_service_native_loop( + state, + loop_config, + &materialize_options, + &filter, + watcher, + rx, + probe.queued, + ) { + Ok(()) => Ok(()), + Err(error) => { + state.set_error("poll", error); + let filter = WatchEventFilter::from_options( + &runtime.repo_root, + &materialize_options, + )?; + run_service_poll_loop(state, loop_config, &materialize_options, &filter) + } + } + } else { + drop(watcher); + state.set_error( + "poll", + probe + .reason + .unwrap_or_else(|| "native probe failed".to_string()), + ); + run_service_poll_loop(state, loop_config, &materialize_options, &filter) + } + } + Err(error) => { + state.set_error("poll", error); + run_service_poll_loop(state, loop_config, &materialize_options, &filter) + } + } +} + +fn run_service_native_loop( + state: &Arc, + config: RefreshLoopConfig, + materialize_options: &MaterializeOptions, + filter: &WatchEventFilter, + watcher: notify::RecommendedWatcher, + rx: Receiver, + queued: VecDeque, +) -> Result<(), String> { + run_native_watch(config, filter, watcher, rx, queued, |batch| { + refresh_batch_with_state( + state, + "native", + materialize_options, + batch.event_count, + &batch.paths, + ) + }) +} + +fn run_service_poll_loop( + state: &Arc, + config: RefreshLoopConfig, + materialize_options: &MaterializeOptions, + filter: &WatchEventFilter, +) -> Result<(), String> { + state.set_backend("poll"); + run_poll_watch(config, filter, |batch| { + refresh_batch_with_state( + state, + "poll", + materialize_options, + batch.event_count, + &batch.paths, + ) + }) +} + +fn refresh_batch_with_state( + state: &Arc, + backend: &str, + materialize_options: &MaterializeOptions, + event_count: usize, + paths: &BTreeSet, +) -> Result { + let mut observer = StateRefreshObserver::new(state, backend); + execute_refresh_with_policy( + &mut observer, + event_count, + paths, + RefreshRetryPolicy::default(), + |candidate_paths| execute_refresh_operation(materialize_options, candidate_paths), + ) +} + +struct StateRefreshObserver<'a> { + state: &'a Arc, + backend: &'a str, + guard: Option>, +} + +impl<'a> StateRefreshObserver<'a> { + fn new(state: &'a Arc, backend: &'a str) -> Self { + Self { + state, + backend, + guard: None, + } + } +} + +impl RefreshObserver for StateRefreshObserver<'_> { + fn before_attempt(&mut self, _event_count: usize, _changed_paths: usize) -> Result<(), String> { + self.state.mark_pending(); + self.guard = Some(self.state.write_guard()?); + self.state.mark_refreshing(self.backend); + Ok(()) + } + + fn on_success( + &mut self, + response: &NativeSyntaxMaterializationResponse, + event_count: usize, + changed_paths: usize, + ) -> Result<(), String> { + self.guard.take(); + self.state.mark_refreshed( + self.backend, + event_count, + changed_paths, + response.diff.rebuild_paths().len(), + response.diff.deleted.len(), + response.database_written, + ); + Ok(()) + } + + fn on_error( + &mut self, + error: &str, + retrying: bool, + event_count: usize, + changed_paths: usize, + ) -> Result<(), String> { + self.guard.take(); + self.state.mark_refresh_error( + self.backend, + event_count, + changed_paths, + error.to_string(), + retrying, + ); + Ok(()) + } +} + +fn unix_ms() -> u128 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_millis()) + .unwrap_or(0) +} + +fn watch_matches_any_pattern(path: &str, patterns: &[String]) -> bool { + patterns + .iter() + .map(|pattern| pattern.trim()) + .filter(|pattern| !pattern.is_empty() && !pattern.starts_with('#')) + .any(|pattern| watch_glob_matches(path, pattern)) +} + +fn watch_glob_matches(path: &str, pattern: &str) -> bool { + let pattern = watch_normalize_pattern(pattern); + if pattern.ends_with('/') { + return path.starts_with(pattern.trim_end_matches('/')); + } + if !pattern.contains('/') + && watch_wildcard_match(path.rsplit('/').next().unwrap_or(path), &pattern) + { + return true; + } + watch_wildcard_match(path, &pattern) +} + +fn watch_normalize_pattern(pattern: &str) -> String { + pattern + .trim() + .trim_start_matches("./") + .replace('\\', "/") + .to_string() +} + +fn watch_wildcard_match(text: &str, pattern: &str) -> bool { + let (mut text_index, mut pattern_index) = (0_usize, 0_usize); + let mut star_index = None; + let mut match_index = 0_usize; + let text = text.as_bytes(); + let pattern = pattern.as_bytes(); + while text_index < text.len() { + if pattern_index < pattern.len() + && (pattern[pattern_index] == b'?' || pattern[pattern_index] == text[text_index]) + { + text_index += 1; + pattern_index += 1; + } else if pattern_index < pattern.len() && pattern[pattern_index] == b'*' { + star_index = Some(pattern_index); + match_index = text_index; + pattern_index += 1; + } else if let Some(star) = star_index { + pattern_index = star + 1; + match_index += 1; + text_index = match_index; + } else { + return false; + } + } + while pattern_index < pattern.len() && pattern[pattern_index] == b'*' { + pattern_index += 1; + } + pattern_index == pattern.len() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::protocol::ManifestDiff; + use std::{ + sync::atomic::{AtomicUsize, Ordering}, + time::Duration, + }; + + fn skipped_response() -> NativeSyntaxMaterializationResponse { + NativeSyntaxMaterializationResponse::skipped( + BTreeMap::new(), + ManifestDiff { + added: Vec::new(), + modified: Vec::new(), + unchanged: Vec::new(), + deleted: Vec::new(), + force_rebuild: false, + }, + Vec::new(), + Vec::new(), + BTreeMap::new(), + ) + } + + struct RecordingObserver { + retries: Vec<(bool, String, usize, usize)>, + successes: Vec<(usize, usize, usize)>, + } + + impl RecordingObserver { + fn new() -> Self { + Self { + retries: Vec::new(), + successes: Vec::new(), + } + } + } + + impl RefreshObserver for RecordingObserver { + fn on_success( + &mut self, + response: &NativeSyntaxMaterializationResponse, + event_count: usize, + changed_paths: usize, + ) -> Result<(), String> { + self.successes.push(( + event_count, + changed_paths, + response.diff.rebuild_paths().len(), + )); + Ok(()) + } + + fn on_error( + &mut self, + error: &str, + retrying: bool, + event_count: usize, + changed_paths: usize, + ) -> Result<(), String> { + self.retries + .push((retrying, error.to_string(), event_count, changed_paths)); + Ok(()) + } + } + + #[test] + fn refresh_retry_policy_retries_transient_errors_before_success() { + let attempts = AtomicUsize::new(0); + let mut observer = RecordingObserver::new(); + let refreshed = execute_refresh_with_policy( + &mut observer, + 2, + &BTreeSet::from(["src/lib.rs".to_string()]), + RefreshRetryPolicy { + initial_delay: Duration::from_millis(0), + max_delay: Duration::from_millis(0), + }, + |_| { + if attempts.fetch_add(1, Ordering::SeqCst) == 0 { + Err("IO exception: Could not set lock on file".to_string()) + } else { + Ok(skipped_response()) + } + }, + ) + .unwrap(); + + assert!(refreshed); + assert_eq!(attempts.load(Ordering::SeqCst), 2); + assert_eq!(observer.retries.len(), 1); + assert!(observer.retries[0].0); + assert_eq!(observer.successes, vec![(2, 1, 0)]); + } + + #[test] + fn refresh_retry_policy_stops_on_non_transient_errors() { + let mut observer = RecordingObserver::new(); + let refreshed = execute_refresh_with_policy( + &mut observer, + 1, + &BTreeSet::from(["src/lib.rs".to_string()]), + RefreshRetryPolicy { + initial_delay: Duration::from_millis(0), + max_delay: Duration::from_millis(0), + }, + |_| Err("parser exploded".to_string()), + ) + .unwrap(); + + assert!(!refreshed); + assert_eq!( + observer.retries, + vec![(false, "parser exploded".to_string(), 1, 1)] + ); + assert!(observer.successes.is_empty()); + } +} diff --git a/src/bin/codebase-graph.rs b/src/bin/codebase-graph.rs index 23a9fcd..9ce91d0 100644 --- a/src/bin/codebase-graph.rs +++ b/src/bin/codebase-graph.rs @@ -1,6 +1,6 @@ fn main() { - if let Err(error) = codebase_graph::cli::run_from_env() { + if let Err(error) = codebase_graph::run_from_env() { eprintln!("{error}"); - std::process::exit(codebase_graph::cli::error_exit_code(&error)); + std::process::exit(codebase_graph::adapters::cli::error_exit_code(&error)); } } diff --git a/src/bootstrap.rs b/src/bootstrap.rs new file mode 100644 index 0000000..52bfadd --- /dev/null +++ b/src/bootstrap.rs @@ -0,0 +1,33 @@ +use crate::adapters::{ + cli::{format::mcp_help, run}, + mcp::{serve_mcp_http, serve_mcp_stdio, McpHttpOptions, McpServeOptions}, +}; +use std::{ + env, + io::{self}, +}; + +pub fn run_from_env() -> Result<(), String> { + let args: Vec = env::args().skip(1).collect(); + run_process_args(args) +} + +pub(crate) fn run_process_args(args: Vec) -> Result<(), String> { + if args.is_empty() { + return run(args, &mut io::stdout()); + } + if args.first().map(String::as_str) == Some("mcp") { + match args.get(1).map(String::as_str) { + Some("start") => { + let options = McpServeOptions::parse(&args[2..], mcp_help())?; + return serve_mcp_stdio(&options, io::stdin().lock(), &mut io::stdout()); + } + Some("http") => { + let options = McpHttpOptions::parse(&args[2..], mcp_help())?; + return serve_mcp_http(&options); + } + _ => {} + } + } + run(args, &mut io::stdout()) +} diff --git a/src/cli/build/command.rs b/src/cli/build/command.rs deleted file mode 100644 index 394dc45..0000000 --- a/src/cli/build/command.rs +++ /dev/null @@ -1,153 +0,0 @@ -use super::manifest::{read_request, request_manifest_path, write_manifest}; -use super::output::{materialization_payload, serialize_plan_block}; -use super::request::{build_request, MaterializeOptions}; -use crate::cli::format::{materialize_help, plan_help, schema_statements_from_copy_statements}; -use crate::cli::setup::GraphStatePaths; -use crate::db_writer::{write_database, LadybugWriteRequest}; -use crate::protocol::{NativeSyntaxMaterializationRequest, NativeSyntaxMaterializationResponse}; -use std::io::Write; -use std::path::Path; -use std::time::Instant; - -pub(in crate::cli) fn run_materialize( - args: &[String], - stdout: &mut W, -) -> Result<(), String> { - let options = MaterializeOptions::parse(args)?; - if options.help { - writeln!(stdout, "{}", materialize_help()).map_err(|error| error.to_string())?; - return Ok(()); - } - let (_, response) = materialize(&options)?; - let output = serde_json::to_string_pretty(&response).map_err(|error| error.to_string())?; - writeln!(stdout, "{output}").map_err(|error| error.to_string())?; - Ok(()) -} - -pub(in crate::cli) fn materialize( - options: &MaterializeOptions, -) -> Result< - ( - NativeSyntaxMaterializationRequest, - NativeSyntaxMaterializationResponse, - ), - String, -> { - let request = match options.native_request.as_ref() { - Some(request_path) => read_request(request_path)?, - None => build_request(options)?, - }; - materialize_request(options, request) -} - -pub(in crate::cli) fn materialize_candidate_paths( - options: &MaterializeOptions, - candidate_paths: Vec, -) -> Result< - ( - NativeSyntaxMaterializationRequest, - NativeSyntaxMaterializationResponse, - ), - String, -> { - let mut request = build_request(options)?; - request.candidate_paths = candidate_paths; - request.atomic_rebuild = false; - materialize_request(options, request) -} - -pub(in crate::cli) fn materialize_request( - options: &MaterializeOptions, - request: NativeSyntaxMaterializationRequest, -) -> Result< - ( - NativeSyntaxMaterializationRequest, - NativeSyntaxMaterializationResponse, - ), - String, -> { - let started = Instant::now(); - let final_request = request; - let mut response = - crate::materialize_syntax_batch(&final_request).map_err(|error| error.to_string())?; - if !response.skipped { - let database_started = Instant::now(); - write_materialized_database(&final_request, &response)?; - response.phase_timings.insert( - "database_write_seconds".to_string(), - database_started.elapsed().as_secs_f64(), - ); - response.database_written = true; - } - response.phase_timings.insert( - "native_cli_seconds".to_string(), - started.elapsed().as_secs_f64(), - ); - - if let Some(manifest_path) = request_manifest_path(options).as_ref() { - write_manifest( - manifest_path, - &final_request, - &response.rebuilt_entries, - &response.diff, - )?; - } - - Ok((final_request, response)) -} - -fn write_materialized_database( - request: &NativeSyntaxMaterializationRequest, - response: &NativeSyntaxMaterializationResponse, -) -> Result<(), String> { - let schema_statements = if request.schema_statements.is_empty() { - schema_statements_from_copy_statements(request.include_fts, &response.copy_statements) - } else { - request.schema_statements.clone() - }; - let mut delete_statements = crate::db_writer::partition_delete_statements( - request.previous_manifest.as_ref(), - &response.diff, - ); - delete_statements.extend(crate::db_writer::incoming_row_delete_statements( - request.previous_manifest.as_ref(), - &response.diff, - &response.rebuilt_entries, - )); - write_database(LadybugWriteRequest { - db_path: request.db_path.clone(), - include_fts: request.include_fts, - schema_statements, - replace_database: response.diff.force_rebuild, - delete_statements, - copy_statements: response.copy_statements.clone(), - }) - .map_err(|error| error.to_string()) -} - -pub(in crate::cli) fn run_plan(args: &[String], stdout: &mut W) -> Result<(), String> { - let options = MaterializeOptions::parse_with_command(args, "plan")?; - if options.help { - writeln!(stdout, "{}", plan_help()).map_err(|error| error.to_string())?; - return Ok(()); - } - let mut request = match options.native_request.as_ref() { - Some(request_path) => read_request(request_path)?, - None => build_request(&options)?, - }; - request.atomic_rebuild = false; - let response = - crate::plan_syntax_materialization(&request).map_err(|error| error.to_string())?; - let paths = GraphStatePaths::derive(Path::new(&request.source_root)); - let payload = materialization_payload(&response, &request.mode, &paths); - if options.json_output { - writeln!( - stdout, - "{}", - serde_json::to_string_pretty(&payload).map_err(|error| error.to_string())? - ) - .map_err(|error| error.to_string()) - } else { - write!(stdout, "{}", serialize_plan_block(&payload)).map_err(|error| error.to_string()) - } -} diff --git a/src/cli/build/manifest.rs b/src/cli/build/manifest.rs deleted file mode 100644 index cdffd5b..0000000 --- a/src/cli/build/manifest.rs +++ /dev/null @@ -1,83 +0,0 @@ -use super::request::MaterializeOptions; -use crate::cli::{setup::GraphStatePaths, util::resolve_source_root}; -use crate::protocol::{NativeManifest, NativeSyntaxMaterializationRequest}; -use std::collections::{BTreeMap, BTreeSet}; -use std::fs; -use std::path::{Path, PathBuf}; - -pub(in crate::cli) fn read_manifest(path: &Path) -> Result { - let text = fs::read_to_string(path) - .map_err(|error| format!("failed to read manifest {}: {error}", path.display()))?; - serde_json::from_str(&text) - .map_err(|error| format!("failed to parse manifest {}: {error}", path.display())) -} - -pub(in crate::cli) fn request_manifest_path(options: &MaterializeOptions) -> Option { - if options.native_request.is_some() { - return options.manifest.clone(); - } - let source_root = - resolve_source_root(options.source_root.as_deref()).unwrap_or_else(|_| PathBuf::from(".")); - Some( - options - .manifest - .clone() - .unwrap_or_else(|| GraphStatePaths::derive(&source_root).manifest_path), - ) -} - -pub(in crate::cli) fn read_request( - path: &Path, -) -> Result { - let text = fs::read_to_string(path) - .map_err(|error| format!("failed to read native request {}: {error}", path.display()))?; - serde_json::from_str(&text) - .map_err(|error| format!("failed to parse native request {}: {error}", path.display())) -} - -pub(in crate::cli) fn write_manifest( - path: &Path, - request: &NativeSyntaxMaterializationRequest, - rebuilt_entries: &BTreeMap, - diff: &crate::protocol::ManifestDiff, -) -> Result<(), String> { - let mut files = if diff.force_rebuild { - BTreeMap::new() - } else { - request - .previous_manifest - .as_ref() - .map(|manifest| manifest.files.clone()) - .unwrap_or_default() - }; - let removed: BTreeSet = diff - .deleted - .iter() - .chain(diff.rebuild_paths().iter()) - .cloned() - .collect(); - files.retain(|path, _| !removed.contains(path)); - files.extend( - rebuilt_entries - .iter() - .map(|(path, entry)| (path.clone(), entry.clone())), - ); - - let manifest = NativeManifest { - schema_version: request.manifest_schema_version, - ontology: request.ontology.clone(), - parser_version: request.parser_version.clone(), - files, - }; - if let Some(parent) = path.parent() { - fs::create_dir_all(parent).map_err(|error| { - format!( - "failed to create manifest directory {}: {error}", - parent.display() - ) - })?; - } - let text = serde_json::to_string_pretty(&manifest).map_err(|error| error.to_string())?; - fs::write(path, format!("{text}\n")) - .map_err(|error| format!("failed to write manifest {}: {error}", path.display())) -} diff --git a/src/cli/build/mod.rs b/src/cli/build/mod.rs deleted file mode 100644 index 7561b3d..0000000 --- a/src/cli/build/mod.rs +++ /dev/null @@ -1,13 +0,0 @@ -mod command; -mod manifest; -mod output; -mod request; - -pub(in crate::cli) use command::{ - materialize, materialize_candidate_paths, run_materialize, run_plan, -}; -pub(in crate::cli) use output::{dry_run_materialization_payload, materialization_payload}; -pub(in crate::cli) use request::{ - build_request, default_excluded_parts, read_codebase_graph_ignore, - read_materialization_config_rules, MaterializeOptions, -}; diff --git a/src/cli/build/output.rs b/src/cli/build/output.rs deleted file mode 100644 index ace7712..0000000 --- a/src/cli/build/output.rs +++ /dev/null @@ -1,124 +0,0 @@ -use crate::cli::format::{block_value, value_array, value_str}; -use crate::cli::setup::GraphStatePaths; -use crate::cli::watch::scan_source_snapshots; -use crate::protocol::{NativeSyntaxMaterializationRequest, NativeSyntaxMaterializationResponse}; -use serde_json::json; -use std::path::Path; - -pub(in crate::cli) fn materialization_payload( - response: &NativeSyntaxMaterializationResponse, - mode: &str, - paths: &GraphStatePaths, -) -> serde_json::Value { - let rebuilt_paths = response.diff.rebuild_paths(); - let skipped_paths = response - .snapshots - .iter() - .filter_map(|(path, snapshot)| { - if snapshot.language.is_none() { - Some(path.clone()) - } else { - None - } - }) - .collect::>(); - let ignored_paths = response - .diagnostics - .iter() - .filter_map(|diagnostic| diagnostic.strip_prefix("Ignored file: ")) - .map(str::to_string) - .collect::>(); - json!({ - "mode": mode, - "scanned": response.snapshots.len(), - "rebuilt": rebuilt_paths.len(), - "skipped": skipped_paths.len(), - "ignored": ignored_paths.len(), - "deleted": response.diff.deleted.len(), - "diagnostics": response.diagnostics, - "manifest_path": paths.manifest_path, - "rebuilt_paths": rebuilt_paths, - "skipped_paths": skipped_paths.clone(), - "ignored_paths": ignored_paths, - "deleted_paths": response.diff.deleted.clone(), - "would_rebuild": response.diff.rebuild_paths(), - "would_delete": response.diff.deleted, - "would_skip": skipped_paths, - "graph_summary": response.graph_summary, - "node_rows": response.node_rows, - "edge_rows": response.edge_rows, - "connector_rows": response.connector_rows, - "database_written": response.database_written, - "progress_events": response.progress_events, - "phase_timings": response.phase_timings, - }) -} - -pub(in crate::cli) fn dry_run_materialization_payload( - request: &NativeSyntaxMaterializationRequest, - paths: &GraphStatePaths, -) -> serde_json::Value { - let snapshots = scan_source_snapshots(Path::new(&request.source_root)); - let scanned = snapshots.len(); - let skipped_paths = snapshots - .into_iter() - .filter_map(|(path, language)| if language.is_none() { Some(path) } else { None }) - .collect::>(); - json!({ - "mode": "dry_run", - "scanned": scanned, - "rebuilt": 0, - "skipped": skipped_paths.len(), - "deleted": 0, - "diagnostics": [], - "manifest_path": paths.manifest_path, - "rebuilt_paths": [], - "skipped_paths": skipped_paths, - "deleted_paths": [], - "graph_summary": {}, - }) -} - -pub(in crate::cli) fn serialize_plan_block(payload: &serde_json::Value) -> String { - let mut lines = vec![format!( - "plan mode={} scanned={} rebuild={} delete={} skip={} ignored={}", - block_value(value_str(payload, "mode")), - payload - .get("scanned") - .and_then(serde_json::Value::as_u64) - .unwrap_or(0), - payload - .get("rebuilt") - .and_then(serde_json::Value::as_u64) - .unwrap_or(0), - payload - .get("deleted") - .and_then(serde_json::Value::as_u64) - .unwrap_or(0), - payload - .get("skipped") - .and_then(serde_json::Value::as_u64) - .unwrap_or(0), - payload - .get("ignored") - .and_then(serde_json::Value::as_u64) - .unwrap_or(0), - )]; - append_plan_path_lines(&mut lines, "rebuild", value_array(payload, "would_rebuild")); - append_plan_path_lines(&mut lines, "delete", value_array(payload, "would_delete")); - append_plan_path_lines(&mut lines, "skip", value_array(payload, "would_skip")); - append_plan_path_lines(&mut lines, "ignore", value_array(payload, "ignored_paths")); - format!("{}\n", lines.join("\n")) -} - -pub(in crate::cli) fn append_plan_path_lines( - lines: &mut Vec, - label: &str, - paths: &[serde_json::Value], -) { - for path in paths { - if let Some(path) = path.as_str() { - lines.push(format!("{label} {}", block_value(path))); - } - } -} diff --git a/src/cli/build/request.rs b/src/cli/build/request.rs deleted file mode 100644 index ef0ae7b..0000000 --- a/src/cli/build/request.rs +++ /dev/null @@ -1,407 +0,0 @@ -use super::manifest::read_manifest; -use crate::cli::setup::GraphStatePaths; -use crate::cli::{ - format::{materialize_help, plan_help, watch_help}, - util::resolve_source_root, -}; -use crate::protocol::NativeSyntaxMaterializationRequest; -use std::fs; -use std::path::{Path, PathBuf}; -use std::process::Command; - -pub(in crate::cli) fn build_request( - options: &MaterializeOptions, -) -> Result { - let source_root = resolve_source_root(options.source_root.as_deref())?; - let paths = GraphStatePaths::derive(&source_root); - let db_path = options.db.clone().unwrap_or_else(|| paths.db_path.clone()); - let manifest_path = options - .manifest - .clone() - .unwrap_or_else(|| paths.manifest_path.clone()); - let previous_manifest = if manifest_path.exists() { - Some(read_manifest(&manifest_path)?) - } else { - None - }; - let config_rules = read_materialization_config_rules(&paths.config_path)?; - let mut include_patterns = config_rules.include_patterns; - include_patterns.extend(options.include_patterns.clone()); - let mut exclude_patterns = config_rules.exclude_patterns; - exclude_patterns.extend(options.exclude_patterns.clone()); - let ignore_patterns = read_codebase_graph_ignore(&source_root)?; - let candidate_paths = if options.candidate_paths.is_empty() { - git_candidate_paths(&source_root, options)? - } else { - normalized_candidate_paths(&options.candidate_paths) - }; - let staging_dir = paths.state_dir.join("native-staging"); - Ok(NativeSyntaxMaterializationRequest { - source_root: source_root.to_string_lossy().to_string(), - repository_label: paths.repo_name, - mode: options.mode.clone(), - parser_version: "native-rust-cli-v1".to_string(), - manifest_schema_version: 1, - ontology: "code_ontology_v1".to_string(), - ontology_schema: Default::default(), - previous_manifest, - profiles: Vec::new(), - excluded_parts: default_excluded_parts(), - include_patterns, - exclude_patterns, - ignore_patterns, - candidate_paths, - db_path: db_path.to_string_lossy().to_string(), - include_fts: options.include_fts, - semantic_enrichment: options.semantic_enrichment, - semantic_provider_mode: options.semantic_provider_mode.clone(), - schema_statements: Vec::new(), - staging_dir: staging_dir.to_string_lossy().to_string(), - atomic_rebuild: true, - strict: true, - parallel: options.parallel, - progress: options.progress, - }) -} - -#[derive(Default)] -pub(in crate::cli) struct ConfigScanRules { - pub(in crate::cli) include_patterns: Vec, - pub(in crate::cli) exclude_patterns: Vec, -} - -pub(in crate::cli) fn read_materialization_config_rules( - path: &Path, -) -> Result { - if !path.exists() { - return Ok(ConfigScanRules::default()); - } - let text = fs::read_to_string(path) - .map_err(|error| format!("failed to read config {}: {error}", path.display()))?; - let value: serde_json::Value = serde_json::from_str(&text) - .map_err(|error| format!("failed to parse config {}: {error}", path.display()))?; - let materialization = value - .get("materialization") - .and_then(serde_json::Value::as_object); - Ok(ConfigScanRules { - include_patterns: materialization - .and_then(|payload| payload.get("include")) - .map(json_string_array) - .unwrap_or_default(), - exclude_patterns: materialization - .and_then(|payload| payload.get("exclude")) - .map(json_string_array) - .unwrap_or_default(), - }) -} - -pub(in crate::cli) fn json_string_array(value: &serde_json::Value) -> Vec { - value - .as_array() - .map(|items| { - items - .iter() - .filter_map(serde_json::Value::as_str) - .map(str::to_string) - .collect() - }) - .unwrap_or_default() -} - -pub(in crate::cli) fn read_codebase_graph_ignore( - source_root: &Path, -) -> Result, String> { - let path = source_root.join(".codebaseGraphignore"); - if !path.exists() { - return Ok(Vec::new()); - } - let text = fs::read_to_string(&path) - .map_err(|error| format!("failed to read {}: {error}", path.display()))?; - Ok(text - .lines() - .map(str::trim) - .filter(|line| !line.is_empty() && !line.starts_with('#')) - .map(str::to_string) - .collect()) -} - -pub(in crate::cli) fn git_candidate_paths( - source_root: &Path, - options: &MaterializeOptions, -) -> Result, String> { - if !options.use_git { - return Ok(Vec::new()); - } - let mut paths = if options.git_diff && options.plan_only { - let base = options.git_base.as_deref().unwrap_or("HEAD"); - git_paths( - source_root, - &["diff", "--name-only", "--diff-filter=ACMRTD", base, "--"], - ) - .unwrap_or_default() - } else { - git_paths( - source_root, - &["ls-files", "--cached", "--others", "--exclude-standard"], - ) - .unwrap_or_default() - }; - if options.git_diff && options.plan_only { - if let Ok(untracked) = - git_paths(source_root, &["ls-files", "--others", "--exclude-standard"]) - { - paths.extend(untracked); - } - } - paths.sort(); - paths.dedup(); - Ok(paths) -} - -pub(in crate::cli) fn git_paths(source_root: &Path, args: &[&str]) -> Result, String> { - let output = Command::new("git") - .args(args) - .current_dir(source_root) - .output() - .map_err(|error| format!("failed to run git {}: {error}", args.join(" ")))?; - if !output.status.success() { - return Err(String::from_utf8_lossy(&output.stderr).trim().to_string()); - } - Ok(String::from_utf8_lossy(&output.stdout) - .lines() - .map(str::trim) - .filter(|line| !line.is_empty()) - .map(|line| line.replace('\\', "/")) - .collect()) -} - -pub(in crate::cli) fn normalized_candidate_paths(paths: &[String]) -> Vec { - let mut paths = paths - .iter() - .map(|path| path.trim().trim_start_matches("./").replace('\\', "/")) - .filter(|path| !path.is_empty()) - .collect::>(); - paths.sort(); - paths.dedup(); - paths -} - -pub(in crate::cli) fn default_excluded_parts() -> Vec { - [ - ".bzr", - ".cache", - ".codebaseGraph", - ".direnv", - ".eggs", - ".git", - ".hg", - ".mypy_cache", - ".nox", - ".svn", - ".tox", - ".venv", - "dist", - "node_modules", - "target", - "venv", - ] - .into_iter() - .map(str::to_string) - .collect() -} - -#[derive(Clone, Debug)] -pub(in crate::cli) struct MaterializeOptions { - pub(in crate::cli) native_request: Option, - pub(in crate::cli) source_root: Option, - pub(in crate::cli) db: Option, - pub(in crate::cli) manifest: Option, - pub(in crate::cli) mode: String, - pub(in crate::cli) include_fts: bool, - pub(in crate::cli) semantic_enrichment: bool, - pub(in crate::cli) semantic_provider_mode: String, - pub(in crate::cli) use_git: bool, - pub(in crate::cli) git_diff: bool, - pub(in crate::cli) git_base: Option, - pub(in crate::cli) include_patterns: Vec, - pub(in crate::cli) exclude_patterns: Vec, - pub(in crate::cli) candidate_paths: Vec, - pub(in crate::cli) parallel: bool, - pub(in crate::cli) progress: bool, - pub(in crate::cli) plan_only: bool, - pub(in crate::cli) help: bool, - pub(in crate::cli) json_output: bool, -} - -impl Default for MaterializeOptions { - fn default() -> Self { - Self { - native_request: None, - source_root: None, - db: None, - manifest: None, - mode: String::new(), - include_fts: false, - semantic_enrichment: false, - semantic_provider_mode: String::new(), - use_git: false, - git_diff: false, - git_base: None, - include_patterns: Vec::new(), - exclude_patterns: Vec::new(), - candidate_paths: Vec::new(), - parallel: true, - progress: false, - plan_only: false, - help: false, - json_output: false, - } - } -} - -impl MaterializeOptions { - pub(in crate::cli) fn parse(args: &[String]) -> Result { - Self::parse_with_command(args, "build") - } - - pub(in crate::cli) fn parse_with_command( - args: &[String], - command_name: &str, - ) -> Result { - let mut options = Self { - mode: "changed".to_string(), - include_fts: true, - semantic_enrichment: true, - semantic_provider_mode: "local_only".to_string(), - use_git: true, - plan_only: command_name == "plan", - ..Self::default() - }; - let mut index = 0; - while index < args.len() { - match args[index].as_str() { - "-h" | "--help" => { - options.help = true; - index += 1; - } - "--native-request" => { - let value = args - .get(index + 1) - .ok_or_else(|| "--native-request requires a path".to_string())?; - options.native_request = Some(PathBuf::from(value)); - index += 2; - } - "--source-root" | "--repo-root" => { - let value = args - .get(index + 1) - .ok_or_else(|| format!("{} requires a path", args[index]))?; - options.source_root = Some(PathBuf::from(value)); - index += 2; - } - "--db" => { - let value = args - .get(index + 1) - .ok_or_else(|| "--db requires a path".to_string())?; - options.db = Some(PathBuf::from(value)); - index += 2; - } - "--manifest" => { - let value = args - .get(index + 1) - .ok_or_else(|| "--manifest requires a path".to_string())?; - options.manifest = Some(PathBuf::from(value)); - index += 2; - } - "--mode" => { - let value = args - .get(index + 1) - .ok_or_else(|| "--mode requires full or changed".to_string())?; - if value != "full" && value != "changed" { - return Err("--mode must be full or changed".to_string()); - } - options.mode = value.clone(); - index += 2; - } - "--no-fts" => { - options.include_fts = false; - index += 1; - } - "--no-semantic-enrichment" => { - options.semantic_enrichment = false; - index += 1; - } - "--semantic-provider-mode" => { - let value = args.get(index + 1).ok_or_else(|| { - "--semantic-provider-mode requires local_only".to_string() - })?; - if value != "local_only" { - return Err("--semantic-provider-mode must be local_only".to_string()); - } - options.semantic_provider_mode = value.clone(); - index += 2; - } - "--no-git" => { - options.use_git = false; - index += 1; - } - "--git-diff" => { - options.git_diff = true; - index += 1; - } - "--git-base" => { - let value = args - .get(index + 1) - .ok_or_else(|| "--git-base requires a revision".to_string())?; - options.git_base = Some(value.clone()); - options.git_diff = true; - index += 2; - } - "--include" => { - let value = args - .get(index + 1) - .ok_or_else(|| "--include requires a glob pattern".to_string())?; - options.include_patterns.push(value.clone()); - index += 2; - } - "--exclude" => { - let value = args - .get(index + 1) - .ok_or_else(|| "--exclude requires a glob pattern".to_string())?; - options.exclude_patterns.push(value.clone()); - index += 2; - } - "--single-thread" => { - options.parallel = false; - index += 1; - } - "--parallel" => { - options.parallel = true; - index += 1; - } - "--progress" => { - options.progress = true; - index += 1; - } - "--json" => { - options.json_output = true; - index += 1; - } - other => { - return Err(format!( - "unknown {command_name} option: {other}\n\n{}", - materialize_like_help(command_name) - )); - } - } - } - Ok(options) - } -} - -pub(in crate::cli) fn materialize_like_help(command_name: &str) -> &'static str { - match command_name { - "plan" => plan_help(), - "watch" => watch_help(), - _ => materialize_help(), - } -} diff --git a/src/cli/constants.rs b/src/cli/constants.rs deleted file mode 100644 index 8b512e4..0000000 --- a/src/cli/constants.rs +++ /dev/null @@ -1,13 +0,0 @@ -use std::env; - -pub(super) const GRAPH_SCHEMA_JSON: &str = include_str!("../../assets/graph_schema.json"); -pub(super) const QUERY_HELPERS_JSON: &str = include_str!("../../assets/query_helpers.json"); -pub(super) const ARCHITECTURE_QUERIES_JSON: &str = - include_str!("../../assets/architecture_queries.json"); -pub(super) const MCP_TOOL_SPECS_JSON: &str = include_str!("../../assets/mcp_tool_specs.json"); -pub(super) const LATEST_PROTOCOL_VERSION: &str = "2025-11-25"; -pub(super) const MAX_HTTP_BODY_BYTES: usize = 1_000_000; - -pub(super) fn server_command() -> String { - env::var("CODEBASE_GRAPH_SERVER_COMMAND").unwrap_or_else(|_| "codebase-graph".to_string()) -} diff --git a/src/cli/dispatch.rs b/src/cli/dispatch.rs deleted file mode 100644 index fe1c28d..0000000 --- a/src/cli/dispatch.rs +++ /dev/null @@ -1,100 +0,0 @@ -use super::{ - build::{run_materialize, run_plan}, - format::top_level_help, - graph::{ - run_graph_architecture_queries, run_graph_context, run_graph_health, run_graph_query, - run_graph_query_helpers, run_graph_schema, run_graph_search, - }, - mcp::{run_mcp_command, serve_mcp_http, serve_mcp_stdio, McpHttpOptions, McpServeOptions}, - reinstall::run_reinstall, - setup::run_setup, - uninstall::run_uninstall, - watch::run_watch, -}; -use std::{ - env, - io::{self, Write}, -}; - -pub fn run_from_env() -> Result<(), String> { - let args: Vec = env::args().skip(1).collect(); - run_process_args(args) -} - -pub fn run_process_args(args: Vec) -> Result<(), String> { - if args.is_empty() { - return run(args, &mut io::stdout()); - } - if args.first().map(String::as_str) == Some("mcp") { - match args.get(1).map(String::as_str) { - Some("start") => { - let options = McpServeOptions::parse(&args[2..])?; - return serve_mcp_stdio(&options, io::stdin().lock(), &mut io::stdout()); - } - Some("http") => { - let options = McpHttpOptions::parse(&args[2..])?; - return serve_mcp_http(&options); - } - _ => {} - } - } - run(args, &mut io::stdout()) -} - -pub fn run(args: I, stdout: &mut W) -> Result<(), String> -where - I: IntoIterator, - S: Into, - W: Write, -{ - let args: Vec = args.into_iter().map(Into::into).collect(); - match args.first().map(String::as_str) { - Some("-h" | "--help") => { - writeln!(stdout, "{}", top_level_help()).map_err(|error| error.to_string())?; - Ok(()) - } - Some("install") => run_setup(&args[1..], stdout), - Some("reinstall") => run_reinstall(&args[1..], stdout), - Some("uninstall") => run_uninstall(&args[1..], stdout), - Some("build") => run_materialize(&args[1..], stdout), - Some("plan") => run_plan(&args[1..], stdout), - Some("watch") => run_watch(&args[1..], stdout), - Some("check-health") => run_graph_health(&args[1..], stdout), - Some("schema") => run_graph_schema(&args[1..], stdout), - Some("query-helpers") => run_graph_query_helpers(&args[1..], stdout), - Some("codebase-architecture-queries") => run_graph_architecture_queries(&args[1..], stdout), - Some("codebase-search") => run_graph_search(&args[1..], stdout), - Some("codebase-context") => run_graph_context(&args[1..], stdout), - Some("graph-query") => run_graph_query(&args[1..], stdout), - Some("mcp") => run_mcp_command(&args[1..], stdout), - Some(command) => Err(format!( - "unknown command: {command}\n\n{}", - top_level_help() - )), - None => { - writeln!(stdout, "{}", top_level_help()).map_err(|error| error.to_string())?; - Ok(()) - } - } -} - -pub fn error_exit_code(error: &str) -> i32 { - if error.starts_with("graph_query is read-only; blocked keyword:") - || error.starts_with("graph_query accepts one read-only statement at a time") - || error.starts_with("graph_query requires a non-empty statement") - || error.starts_with("graph_query parameters must be a JSON object") - || error.starts_with("graph-query --parameters must be a JSON object") - || error.starts_with("failed to resolve repo root") - || error.starts_with("Repository root may not be inside") - || error.starts_with("unknown install option:") - || error.starts_with("unknown reinstall option:") - || error.starts_with("--mcp-client must be") - || error.starts_with("--mcp-client requires") - || error.starts_with("--instructions-target must be") - || error.starts_with("--instructions-target requires") - { - 2 - } else { - 1 - } -} diff --git a/src/cli/format/metadata.rs b/src/cli/format/metadata.rs deleted file mode 100644 index 14f105f..0000000 --- a/src/cli/format/metadata.rs +++ /dev/null @@ -1,55 +0,0 @@ -use crate::cli::graph::MetadataOutputOptions; -use std::io::Write; - -pub(in crate::cli) fn metadata_payload(source: &str) -> Result { - serde_json::from_str(source) - .map_err(|error| format!("failed to parse embedded metadata: {error}")) -} - -pub(in crate::cli) fn write_metadata_output( - stdout: &mut W, - payload: &serde_json::Value, - options: &MetadataOutputOptions, - block_serializer: fn(&serde_json::Value) -> String, -) -> Result<(), String> { - let text = if options.format == "json" { - if options.pretty { - serde_json::to_string_pretty(payload).map_err(|error| error.to_string())? - } else { - serde_json::to_string(payload).map_err(|error| error.to_string())? - } - } else { - block_serializer(payload) - }; - writeln!(stdout, "{text}").map_err(|error| error.to_string()) -} - -pub(in crate::cli) fn filter_architecture_group( - payload: &mut serde_json::Value, - group: &str, -) -> Result<(), String> { - let groups = payload - .get("groups") - .and_then(serde_json::Value::as_array) - .cloned() - .unwrap_or_default(); - let selected: Vec = groups - .iter() - .filter(|value| value.get("name").and_then(serde_json::Value::as_str) == Some(group)) - .cloned() - .collect(); - if selected.is_empty() { - let valid = groups - .iter() - .filter_map(|value| value.get("name").and_then(serde_json::Value::as_str)) - .collect::>() - .join(", "); - return Err(format!( - "Unknown architecture query group: {group}. Valid groups: {valid}" - )); - } - if let Some(object) = payload.as_object_mut() { - object.insert("groups".to_string(), serde_json::Value::Array(selected)); - } - Ok(()) -} diff --git a/src/cli/format/mod.rs b/src/cli/format/mod.rs deleted file mode 100644 index 1e6b146..0000000 --- a/src/cli/format/mod.rs +++ /dev/null @@ -1,21 +0,0 @@ -mod blocks; -mod help; -mod metadata; -mod schema; - -pub(in crate::cli) use blocks::{ - block_value, serialize_architecture_queries_block, serialize_context_block, - serialize_error_block, serialize_health_block, serialize_query_block, - serialize_query_helpers_block, serialize_schema_block, serialize_search_block, value_array, - value_str, -}; -pub(in crate::cli) use help::{ - graph_architecture_queries_help, graph_context_help, graph_health_help, graph_query_help, - graph_query_helpers_help, graph_schema_help, graph_search_help, materialize_help, mcp_help, - mcp_install_help, metadata_help, plan_help, reinstall_help, setup_help, top_level_help, - uninstall_help, watch_help, -}; -pub(in crate::cli) use metadata::{ - filter_architecture_group, metadata_payload, write_metadata_output, -}; -pub(in crate::cli) use schema::schema_statements_from_copy_statements; diff --git a/src/cli/graph/commands.rs b/src/cli/graph/commands.rs deleted file mode 100644 index 3d3217b..0000000 --- a/src/cli/graph/commands.rs +++ /dev/null @@ -1,268 +0,0 @@ -use super::{ - health::{count_graph_nodes, resolve_health_runtime}, - options::HealthOptions, - options::{ - ArchitectureQueryOptions, GraphContextOptions, GraphQueryOptions, GraphSearchOptions, - MetadataOutputOptions, - }, - query::{execute_read_only_query, validate_read_only_statement}, - search::{execute_graph_context, execute_graph_search}, -}; -use crate::cli::{ - constants::{ARCHITECTURE_QUERIES_JSON, GRAPH_SCHEMA_JSON, QUERY_HELPERS_JSON}, - format::{ - filter_architecture_group, graph_architecture_queries_help, graph_context_help, - graph_health_help, graph_query_help, graph_query_helpers_help, graph_schema_help, - graph_search_help, metadata_payload, serialize_architecture_queries_block, - serialize_context_block, serialize_health_block, serialize_query_block, - serialize_query_helpers_block, serialize_schema_block, serialize_search_block, - write_metadata_output, - }, -}; -use serde_json::json; -use std::io::Write; - -pub(in crate::cli) fn run_graph_health( - args: &[String], - stdout: &mut W, -) -> Result<(), String> { - let options = HealthOptions::parse(args)?; - if options.help { - writeln!(stdout, "{}", graph_health_help()).map_err(|error| error.to_string())?; - return Ok(()); - } - let runtime = resolve_health_runtime(&options)?; - let mut graph_readable = false; - let mut total_nodes = 0_u64; - let mut error_message = None; - let database_exists = runtime.db_path.exists(); - let manifest_exists = runtime.manifest_path.exists(); - - if database_exists { - match count_graph_nodes(&runtime.db_path) { - Ok(count) => { - graph_readable = true; - total_nodes = count; - } - Err(error) => { - error_message = Some(error); - } - } - } else { - error_message = Some(format!( - "database file does not exist: {}", - runtime.db_path.display() - )); - } - - let output = json!({ - "ok": database_exists && graph_readable, - "repo_root": runtime.repo_root, - "database_path": runtime.db_path, - "manifest_path": runtime.manifest_path, - "database_exists": database_exists, - "manifest_exists": manifest_exists, - "graph_readable": graph_readable, - "total_nodes": total_nodes, - "error": error_message, - }); - if options.json { - writeln!( - stdout, - "{}", - serde_json::to_string(&output).map_err(|error| error.to_string())? - ) - .map_err(|error| error.to_string())?; - } else { - write!(stdout, "{}", serialize_health_block(&output)).map_err(|error| error.to_string())?; - } - Ok(()) -} - -pub(in crate::cli) fn run_graph_schema( - args: &[String], - stdout: &mut W, -) -> Result<(), String> { - let options = MetadataOutputOptions::parse(args, "schema")?; - if options.help { - writeln!(stdout, "{}", graph_schema_help()).map_err(|error| error.to_string())?; - return Ok(()); - } - let payload = metadata_payload(GRAPH_SCHEMA_JSON)?; - write_metadata_output(stdout, &payload, &options, serialize_schema_block) -} - -pub(in crate::cli) fn run_graph_query_helpers( - args: &[String], - stdout: &mut W, -) -> Result<(), String> { - let options = MetadataOutputOptions::parse(args, "query-helpers")?; - if options.help { - writeln!(stdout, "{}", graph_query_helpers_help()).map_err(|error| error.to_string())?; - return Ok(()); - } - let payload = metadata_payload(QUERY_HELPERS_JSON)?; - write_metadata_output(stdout, &payload, &options, serialize_query_helpers_block) -} - -pub(in crate::cli) fn run_graph_architecture_queries( - args: &[String], - stdout: &mut W, -) -> Result<(), String> { - let options = ArchitectureQueryOptions::parse(args)?; - if options.output.help { - writeln!(stdout, "{}", graph_architecture_queries_help()) - .map_err(|error| error.to_string())?; - return Ok(()); - } - let mut payload = metadata_payload(ARCHITECTURE_QUERIES_JSON)?; - if let Some(group) = options.group { - filter_architecture_group(&mut payload, &group)?; - } - write_metadata_output( - stdout, - &payload, - &options.output, - serialize_architecture_queries_block, - ) -} - -pub(in crate::cli) fn run_graph_search( - args: &[String], - stdout: &mut W, -) -> Result<(), String> { - let options = GraphSearchOptions::parse(args)?; - if options.output.help { - writeln!(stdout, "{}", graph_search_help()).map_err(|error| error.to_string())?; - return Ok(()); - } - let runtime = resolve_health_runtime(&HealthOptions { - repo_root: options.repo_root.clone(), - config: options.config.clone(), - db: options.db.clone(), - manifest: options.manifest.clone(), - help: false, - json: false, - })?; - let results = execute_graph_search(&runtime.db_path, &options)?; - let payload = json!({ - "query": options.query, - "profile": options.profile, - "limit": options.limit, - "budget": options.budget, - "results": results, - }); - if options.output.format == "json" { - let text = if options.output.pretty { - serde_json::to_string_pretty(&payload).map_err(|error| error.to_string())? - } else { - serde_json::to_string(&payload).map_err(|error| error.to_string())? - }; - writeln!(stdout, "{text}").map_err(|error| error.to_string()) - } else { - writeln!(stdout, "{}", serialize_search_block(&payload)).map_err(|error| error.to_string()) - } -} - -pub(in crate::cli) fn run_graph_context( - args: &[String], - stdout: &mut W, -) -> Result<(), String> { - let options = GraphContextOptions::parse(args)?; - if options.search.output.help { - writeln!(stdout, "{}", graph_context_help()).map_err(|error| error.to_string())?; - return Ok(()); - } - let runtime = resolve_health_runtime(&HealthOptions { - repo_root: options.search.repo_root.clone(), - config: options.search.config.clone(), - db: options.search.db.clone(), - manifest: options.search.manifest.clone(), - help: false, - json: false, - })?; - if let (Some(node_id), Some(node_type)) = (options.node_id.as_ref(), options.node_type.as_ref()) - { - let context = execute_graph_context(&runtime.db_path, node_id, node_type, &options.search)?; - let payload = json!({ - "node_id": node_id, - "node_type": node_type, - "profile": options.search.profile, - "context": context, - }); - if options.search.output.format == "json" { - let text = if options.search.output.pretty { - serde_json::to_string_pretty(&payload).map_err(|error| error.to_string())? - } else { - serde_json::to_string(&payload).map_err(|error| error.to_string())? - }; - writeln!(stdout, "{text}").map_err(|error| error.to_string()) - } else { - writeln!(stdout, "{}", serialize_context_block(&payload)) - .map_err(|error| error.to_string()) - } - } else { - let results = execute_graph_search(&runtime.db_path, &options.search)?; - let payload = json!({ - "query": options.search.query, - "profile": options.search.profile, - "limit": options.search.limit, - "budget": options.search.budget, - "results": results, - }); - if options.search.output.format == "json" { - let text = if options.search.output.pretty { - serde_json::to_string_pretty(&payload).map_err(|error| error.to_string())? - } else { - serde_json::to_string(&payload).map_err(|error| error.to_string())? - }; - writeln!(stdout, "{text}").map_err(|error| error.to_string()) - } else { - writeln!(stdout, "{}", serialize_search_block(&payload)) - .map_err(|error| error.to_string()) - } - } -} - -pub(in crate::cli) fn run_graph_query( - args: &[String], - stdout: &mut W, -) -> Result<(), String> { - let options = GraphQueryOptions::parse(args)?; - if options.help { - writeln!(stdout, "{}", graph_query_help()).map_err(|error| error.to_string())?; - return Ok(()); - } - validate_read_only_statement(&options.statement)?; - let runtime = resolve_health_runtime(&HealthOptions { - repo_root: options.repo_root, - config: options.config, - db: options.db, - manifest: options.manifest, - help: false, - json: false, - })?; - let (rows, truncated) = execute_read_only_query( - &runtime.db_path, - &options.statement, - &options.parameters, - options.limit, - )?; - let output = json!({ - "statement": options.statement, - "row_count": rows.len(), - "rows": rows, - "truncated": truncated, - }); - if options.json { - writeln!( - stdout, - "{}", - serde_json::to_string_pretty(&output).map_err(|error| error.to_string())? - ) - .map_err(|error| error.to_string())?; - } else { - write!(stdout, "{}", serialize_query_block(&output)).map_err(|error| error.to_string())?; - } - Ok(()) -} diff --git a/src/cli/graph/health.rs b/src/cli/graph/health.rs deleted file mode 100644 index 52e0c0a..0000000 --- a/src/cli/graph/health.rs +++ /dev/null @@ -1,88 +0,0 @@ -use super::options::HealthOptions; -use crate::cli::{ - setup::GraphStatePaths, - util::{read_json_file, resolve_repo_root}, -}; -use crate::db_writer::{ - connect_ladybug_database, open_ladybug_database, retry_transient_database, READ_RETRY_POLICY, -}; -use lbug::Value; -use std::path::{Path, PathBuf}; - -#[derive(Debug)] -pub(in crate::cli) struct HealthRuntime { - pub(in crate::cli) repo_root: PathBuf, - pub(in crate::cli) db_path: PathBuf, - pub(in crate::cli) manifest_path: PathBuf, -} - -pub(in crate::cli) fn resolve_health_runtime( - options: &HealthOptions, -) -> Result { - let repo_root = resolve_repo_root(options.repo_root.as_deref())?; - let default_paths = GraphStatePaths::derive(&repo_root); - let config_path = options - .config - .clone() - .unwrap_or_else(|| default_paths.config_path.clone()); - let config = if config_path.exists() { - Some(read_json_file(&config_path)?) - } else { - None - }; - let db_path = options - .db - .clone() - .or_else(|| { - config - .as_ref() - .and_then(|value| value.get("database_path")) - .and_then(serde_json::Value::as_str) - .map(PathBuf::from) - }) - .unwrap_or(default_paths.db_path); - let manifest_path = options - .manifest - .clone() - .or_else(|| { - config - .as_ref() - .and_then(|value| value.get("manifest_path")) - .and_then(serde_json::Value::as_str) - .map(PathBuf::from) - }) - .unwrap_or(default_paths.manifest_path); - Ok(HealthRuntime { - repo_root, - db_path, - manifest_path, - }) -} -pub(in crate::cli) fn count_graph_nodes(db_path: &Path) -> Result { - let db = retry_transient_database(READ_RETRY_POLICY, || open_ladybug_database(db_path, true)) - .map_err(|error| error.to_string())?; - let conn = connect_ladybug_database(&db).map_err(|error| error.to_string())?; - let mut result = conn - .query("MATCH (n) RETURN count(n) AS total_nodes LIMIT 1") - .map_err(|error| format!("failed to query graph health: {error}"))?; - let row = result - .next() - .ok_or_else(|| "graph health query returned no rows".to_string())?; - row.first() - .and_then(value_to_u64) - .ok_or_else(|| "graph health query returned a non-numeric node count".to_string()) -} - -pub(in crate::cli) fn value_to_u64(value: &Value) -> Option { - match value { - Value::Int64(value) if *value >= 0 => Some(*value as u64), - Value::Int32(value) if *value >= 0 => Some(*value as u64), - Value::Int16(value) if *value >= 0 => Some(*value as u64), - Value::Int8(value) if *value >= 0 => Some(*value as u64), - Value::UInt64(value) => Some(*value), - Value::UInt32(value) => Some(u64::from(*value)), - Value::UInt16(value) => Some(u64::from(*value)), - Value::UInt8(value) => Some(u64::from(*value)), - _ => None, - } -} diff --git a/src/cli/graph/mod.rs b/src/cli/graph/mod.rs deleted file mode 100644 index 370b1a5..0000000 --- a/src/cli/graph/mod.rs +++ /dev/null @@ -1,17 +0,0 @@ -mod commands; -mod health; -mod options; -mod query; -mod search; - -pub(in crate::cli) use commands::{ - run_graph_architecture_queries, run_graph_context, run_graph_health, run_graph_query, - run_graph_query_helpers, run_graph_schema, run_graph_search, -}; -pub(in crate::cli) use health::{count_graph_nodes, resolve_health_runtime}; -pub(in crate::cli) use options::HealthOptions; -pub(in crate::cli) use options::{GraphContextOptions, GraphSearchOptions, MetadataOutputOptions}; -pub(in crate::cli) use query::{ - cypher_single_quoted, execute_read_only_query, validate_read_only_statement, -}; -pub(in crate::cli) use search::{execute_graph_context, execute_graph_search}; diff --git a/src/cli/graph/query.rs b/src/cli/graph/query.rs deleted file mode 100644 index dea3fdd..0000000 --- a/src/cli/graph/query.rs +++ /dev/null @@ -1,221 +0,0 @@ -use crate::db_writer::{ - connect_ladybug_database, open_ladybug_database, retry_transient_database, READ_RETRY_POLICY, -}; -use lbug::Value; -use serde_json::json; -use std::path::Path; - -pub(in crate::cli) fn span_json( - line_start: Option, - line_end: Option, -) -> serde_json::Value { - let mut span = serde_json::Map::new(); - if let Some(line_start) = line_start { - span.insert("line_start".to_string(), json!(line_start)); - } - if let Some(line_end) = line_end { - span.insert("line_end".to_string(), json!(line_end)); - } - serde_json::Value::Object(span) -} - -pub(in crate::cli) fn cypher_single_quoted(value: &str) -> String { - value.replace('\\', "\\\\").replace('\'', "\\'") -} - -pub(in crate::cli) fn cypher_identifier(value: &str) -> String { - value.replace('`', "``") -} - -pub(in crate::cli) fn value_to_string(value: Option<&Value>) -> String { - match value { - Some(Value::String(value)) => value.clone(), - Some(Value::Int64(value)) => value.to_string(), - Some(Value::UInt64(value)) => value.to_string(), - Some(Value::Int32(value)) => value.to_string(), - Some(Value::UInt32(value)) => value.to_string(), - Some(Value::Null(_)) | None => String::new(), - Some(value) => value.to_string(), - } -} - -pub(in crate::cli) fn value_to_i64(value: Option<&Value>) -> Option { - match value { - Some(Value::Int64(value)) => Some(*value), - Some(Value::Int32(value)) => Some(i64::from(*value)), - Some(Value::Int16(value)) => Some(i64::from(*value)), - Some(Value::Int8(value)) => Some(i64::from(*value)), - Some(Value::UInt64(value)) => i64::try_from(*value).ok(), - Some(Value::UInt32(value)) => Some(i64::from(*value)), - Some(Value::UInt16(value)) => Some(i64::from(*value)), - Some(Value::UInt8(value)) => Some(i64::from(*value)), - _ => None, - } -} - -pub(in crate::cli) fn value_to_f64(value: Option<&Value>) -> f64 { - match value { - Some(Value::Double(value)) => *value, - Some(Value::Float(value)) => f64::from(*value), - Some(Value::Int64(value)) => *value as f64, - Some(Value::UInt64(value)) => *value as f64, - Some(Value::Int32(value)) => f64::from(*value), - Some(Value::UInt32(value)) => f64::from(*value), - _ => 0.0, - } -} - -pub(in crate::cli) fn validate_read_only_statement(statement: &str) -> Result<(), String> { - let stripped = statement.trim().trim_end_matches(';'); - if stripped.contains(';') { - return Err("graph_query accepts one read-only statement at a time".to_string()); - } - for keyword in [ - "ALTER", "ATTACH", "CALL", "COPY", "CREATE", "DELETE", "DETACH", "DROP", "EXPORT", - "IMPORT", "INSERT", "INSTALL", "LOAD", "MERGE", "REMOVE", "RENAME", "SET", "TRUNCATE", - "UPDATE", "USE", - ] { - if contains_keyword(stripped, keyword) { - return Err(format!( - "graph_query is read-only; blocked keyword: {keyword}" - )); - } - } - Ok(()) -} - -pub(in crate::cli) fn contains_keyword(statement: &str, keyword: &str) -> bool { - let uppercase = statement.to_ascii_uppercase(); - let mut search_start = 0; - while let Some(index) = uppercase[search_start..].find(keyword) { - let absolute_index = search_start + index; - let before = uppercase[..absolute_index] - .chars() - .next_back() - .map(is_keyword_char) - .unwrap_or(false); - let after = uppercase[absolute_index + keyword.len()..] - .chars() - .next() - .map(is_keyword_char) - .unwrap_or(false); - if !before && !after { - return true; - } - search_start = absolute_index + keyword.len(); - } - false -} - -pub(in crate::cli) fn is_keyword_char(character: char) -> bool { - character.is_ascii_alphanumeric() || character == '_' -} - -pub(in crate::cli) fn execute_read_only_query( - db_path: &Path, - statement: &str, - parameters: &serde_json::Map, - limit: usize, -) -> Result<(Vec>, bool), String> { - let db = retry_transient_database(READ_RETRY_POLICY, || open_ladybug_database(db_path, true)) - .map_err(|error| error.to_string())?; - let conn = connect_ladybug_database(&db).map_err(|error| error.to_string())?; - let mut result = if parameters.is_empty() { - conn.query(statement) - .map_err(|error| format!("failed to execute graph query: {error}"))? - } else { - let named_parameters = lbug_query_parameters(parameters)?; - let mut prepared = conn - .prepare(statement) - .map_err(|error| format!("failed to prepare graph query: {error}"))?; - if !prepared.is_read_only() { - return Err("graph-query prepared statement is not read-only".to_string()); - } - let execute_parameters = named_parameters - .iter() - .map(|(name, value)| (name.as_str(), value.clone())) - .collect(); - conn.execute(&mut prepared, execute_parameters) - .map_err(|error| format!("failed to execute graph query: {error}"))? - }; - let mut rows = Vec::new(); - let mut truncated = false; - for row in result.by_ref().take(limit + 1) { - if rows.len() == limit { - truncated = true; - break; - } - rows.push(row.into_iter().map(json_safe_value).collect()); - } - Ok((rows, truncated)) -} - -pub(in crate::cli) fn lbug_query_parameters( - parameters: &serde_json::Map, -) -> Result, String> { - let mut converted = Vec::with_capacity(parameters.len()); - for (name, value) in parameters { - if name.trim().is_empty() { - return Err("graph_query parameter names must not be blank".to_string()); - } - converted.push((name.clone(), json_parameter_to_lbug_value(value)?)); - } - Ok(converted) -} - -pub(in crate::cli) fn json_parameter_to_lbug_value( - value: &serde_json::Value, -) -> Result { - match value { - serde_json::Value::Bool(value) => Ok(Value::Bool(*value)), - serde_json::Value::Number(value) => { - if let Some(value) = value.as_i64() { - Ok(Value::Int64(value)) - } else if let Some(value) = value.as_u64() { - Ok(Value::UInt64(value)) - } else if let Some(value) = value.as_f64() { - Ok(Value::Double(value)) - } else { - Err("graph_query numeric parameter is not representable".to_string()) - } - } - serde_json::Value::String(value) => Ok(Value::String(value.clone())), - serde_json::Value::Null | serde_json::Value::Array(_) | serde_json::Value::Object(_) => { - Ok(Value::Json(value.clone())) - } - } -} - -pub(in crate::cli) fn json_safe_value(value: Value) -> serde_json::Value { - match value { - Value::Null(_) => serde_json::Value::Null, - Value::Bool(value) => json!(value), - Value::Int64(value) => json!(value), - Value::Int32(value) => json!(value), - Value::Int16(value) => json!(value), - Value::Int8(value) => json!(value), - Value::UInt64(value) => json!(value), - Value::UInt32(value) => json!(value), - Value::UInt16(value) => json!(value), - Value::UInt8(value) => json!(value), - Value::Int128(value) => json!(value.to_string()), - Value::Double(value) => serde_json::Number::from_f64(value) - .map(serde_json::Value::Number) - .unwrap_or_else(|| json!(value.to_string())), - Value::Float(value) => serde_json::Number::from_f64(f64::from(value)) - .map(serde_json::Value::Number) - .unwrap_or_else(|| json!(value.to_string())), - Value::String(value) => json!(value), - Value::Json(value) => value, - Value::List(_, values) | Value::Array(_, values) => { - serde_json::Value::Array(values.into_iter().map(json_safe_value).collect()) - } - Value::Struct(values) => serde_json::Value::Object( - values - .into_iter() - .map(|(key, value)| (key, json_safe_value(value))) - .collect(), - ), - other => json!(other.to_string()), - } -} diff --git a/src/cli/install/client.rs b/src/cli/install/client.rs deleted file mode 100644 index 5ba750b..0000000 --- a/src/cli/install/client.rs +++ /dev/null @@ -1,140 +0,0 @@ -use super::{ - attach_install_verification, build_mcp_descriptor, copilot_studio_metadata, - default_client_config_path, executable_in_path, install_scope, native_client_command, - render_client_config, subprocess_error, write_text_atomic, McpInstallOptions, - NativeMcpDescriptor, -}; -use serde_json::json; -use std::{fs, process::Command}; - -pub(in crate::cli) fn install_mcp_client( - options: &McpInstallOptions, -) -> Result { - let descriptor = build_mcp_descriptor(options)?; - if options.client == "copilot-studio" || options.client == "microsoft-copilot" { - let metadata = copilot_studio_metadata(&descriptor); - let payload = json!({ - "action": if options.dry_run { "dry_run" } else { "reported" }, - "client": options.client, - "scope": options.scope, - "server_name": descriptor.name, - "method": "manual_metadata", - "path": serde_json::Value::Null, - "command": serde_json::Value::Null, - "descriptor": descriptor.as_json(), - "entry": metadata["stdio"].clone(), - "payload": metadata, - }); - return attach_install_verification(payload, &descriptor, options); - } - let native_command = native_client_command(&options.client, &descriptor, &options.scope); - let native_available = native_command - .as_ref() - .and_then(|command| command.first()) - .is_some_and(|executable| executable_in_path(executable)); - if options.dry_run && options.client_config_path.is_none() && native_available { - return attach_install_verification( - json!({ - "action": "dry_run", - "client": options.client, - "scope": install_scope(&options.client, &options.scope), - "server_name": descriptor.name, - "method": "native_cli", - "path": serde_json::Value::Null, - "command": native_command, - "descriptor": descriptor.as_json(), - "entry": descriptor.stdio_entry(false, false), - }), - &descriptor, - options, - ); - } - if !options.dry_run && options.client_config_path.is_none() && native_available { - let Some(command) = native_command.clone() else { - return file_adapter_result(options, &descriptor, native_command, None); - }; - let completed = Command::new(&command[0]) - .args(&command[1..]) - .output() - .map_err(|error| format!("failed to run native client installer: {error}"))?; - if completed.status.success() { - return attach_install_verification( - json!({ - "action": "updated", - "client": options.client, - "scope": install_scope(&options.client, &options.scope), - "server_name": descriptor.name, - "method": "native_cli", - "path": serde_json::Value::Null, - "command": command, - "descriptor": descriptor.as_json(), - "entry": descriptor.stdio_entry(false, false), - }), - &descriptor, - options, - ); - } - let error = subprocess_error(&completed); - return file_adapter_result(options, &descriptor, Some(command), Some(error)); - } - let native_error = native_command.as_ref().and_then(|command| { - command.first().and_then(|executable| { - if executable_in_path(executable) { - None - } else { - Some(format!("{executable} executable not found")) - } - }) - }); - file_adapter_result(options, &descriptor, native_command, native_error) -} - -pub(in crate::cli) fn file_adapter_result( - options: &McpInstallOptions, - descriptor: &NativeMcpDescriptor, - native_command: Option>, - native_error: Option, -) -> Result { - let path = options.client_config_path.clone().unwrap_or_else(|| { - default_client_config_path( - &options.client, - &install_scope(&options.client, &options.scope), - descriptor, - ) - }); - let existing = fs::read_to_string(&path).ok(); - let rendered = render_client_config( - &options.client, - &install_scope(&options.client, &options.scope), - existing.as_deref(), - descriptor, - )?; - let action = if options.dry_run { - "dry_run".to_string() - } else { - rendered.action.clone() - }; - if !options.dry_run { - write_text_atomic(&path, &rendered.text)?; - } - let mut payload = json!({ - "action": action, - "client": options.client, - "scope": install_scope(&options.client, &options.scope), - "server_name": descriptor.name, - "method": "file_adapter", - "path": path.to_string_lossy(), - "command": serde_json::Value::Null, - "descriptor": descriptor.as_json(), - "entry": rendered.entry, - "patch": rendered.patch, - "payload": rendered.payload, - }); - if let Some(command) = native_command { - payload["native_command"] = json!(command); - } - if let Some(error) = native_error { - payload["native_error"] = json!(error); - } - attach_install_verification(payload, descriptor, options) -} diff --git a/src/cli/install/command.rs b/src/cli/install/command.rs deleted file mode 100644 index 31def0b..0000000 --- a/src/cli/install/command.rs +++ /dev/null @@ -1,50 +0,0 @@ -use super::{ - install_mcp_client, install_scope, options::McpInstallOptions, supported_install_clients, -}; -use crate::cli::format::mcp_install_help; -use serde_json::json; -use std::io::Write; - -pub(in crate::cli) fn run_mcp_install( - args: &[String], - stdout: &mut W, -) -> Result<(), String> { - let options = McpInstallOptions::parse(args)?; - if options.help { - writeln!(stdout, "{}", mcp_install_help()).map_err(|error| error.to_string())?; - return Ok(()); - } - let payload = if options.client == "all" { - let results = supported_install_clients() - .into_iter() - .map(|client| { - let mut client_options = options.clone(); - client_options.client = client.to_string(); - install_mcp_client(&client_options).unwrap_or_else(|error| { - json!({ - "action": "failed", - "client": client, - "scope": install_scope(client, &client_options.scope), - "server_name": client_options.name.clone().unwrap_or_else(|| "codebase_graph".to_string()), - "method": serde_json::Value::Null, - "path": serde_json::Value::Null, - "command": serde_json::Value::Null, - "descriptor": {}, - "entry": {}, - "error": error, - }) - }) - }) - .collect::>(); - json!({ "results": results }) - } else { - install_mcp_client(&options)? - }; - writeln!( - stdout, - "{}", - serde_json::to_string_pretty(&payload).map_err(|error| error.to_string())? - ) - .map_err(|error| error.to_string())?; - Ok(()) -} diff --git a/src/cli/install/descriptor.rs b/src/cli/install/descriptor.rs deleted file mode 100644 index bbf059a..0000000 --- a/src/cli/install/descriptor.rs +++ /dev/null @@ -1,144 +0,0 @@ -use super::{expand_path, McpInstallOptions}; -use crate::cli::{ - constants::server_command, - setup::{safe_name, GraphStatePaths}, - util::{read_json_file, resolve_repo_root}, -}; -use serde_json::json; -use std::path::Path; - -#[derive(Debug, Clone)] -pub(in crate::cli) struct NativeMcpDescriptor { - pub(in crate::cli) name: String, - pub(in crate::cli) command: String, - pub(in crate::cli) args: Vec, - pub(in crate::cli) setup_config_path: String, - pub(in crate::cli) repo_root: String, - pub(in crate::cli) timeout: u64, -} - -impl NativeMcpDescriptor { - pub(in crate::cli) fn as_json(&self) -> serde_json::Value { - json!({ - "name": self.name, - "transport": "stdio", - "command": self.command, - "args": self.args, - "env": {}, - "cwd": serde_json::Value::Null, - "setup_config_path": self.setup_config_path, - "repo_root": self.repo_root, - "timeout": self.timeout, - "tool_policy": "graph_query_read_only", - }) - } - - pub(in crate::cli) fn stdio_entry( - &self, - include_type: bool, - include_timeout: bool, - ) -> serde_json::Value { - let mut entry = serde_json::Map::new(); - entry.insert("command".to_string(), json!(self.command)); - entry.insert("args".to_string(), json!(self.args)); - if include_type { - entry.insert("type".to_string(), json!("stdio")); - } - if include_timeout { - entry.insert("startup_timeout_sec".to_string(), json!(self.timeout)); - } - serde_json::Value::Object(entry) - } -} - -pub(in crate::cli) fn build_mcp_descriptor( - options: &McpInstallOptions, -) -> Result { - let resolved_repo_root = resolve_repo_root(options.repo_root.as_deref())?; - let config_path = options - .config_path - .clone() - .unwrap_or_else(|| GraphStatePaths::derive(&resolved_repo_root).config_path); - let setup_config = if config_path.exists() { - Some(read_json_file(&config_path)?) - } else { - None - }; - let repo_root = setup_config - .as_ref() - .and_then(|payload| payload.get("repo_root")) - .and_then(serde_json::Value::as_str) - .map(expand_path) - .unwrap_or_else(|| { - config_path - .parent() - .and_then(Path::parent) - .map(Path::to_path_buf) - .unwrap_or_else(|| resolved_repo_root.clone()) - }); - let repo_name = setup_config - .as_ref() - .and_then(|payload| payload.get("repo_name")) - .and_then(serde_json::Value::as_str) - .map(str::to_string) - .unwrap_or_else(|| { - safe_name( - repo_root - .file_name() - .and_then(|value| value.to_str()) - .unwrap_or("repository"), - ) - }); - let name = options - .name - .clone() - .unwrap_or_else(|| format!("codebase_graph_{}", install_safe_name(&repo_name))); - let command_from_config = setup_config - .as_ref() - .and_then(|payload| payload.pointer("/mcp/command")) - .and_then(serde_json::Value::as_array) - .and_then(|values| { - let command: Option> = values - .iter() - .map(|value| value.as_str().map(str::to_string)) - .collect(); - command.filter(|parts| parts.len() >= 5) - }); - let (command, args) = if let Some(mut parts) = command_from_config { - let command = parts.remove(0); - (command, parts) - } else { - ( - server_command(), - vec![ - "mcp".to_string(), - "start".to_string(), - "--config".to_string(), - config_path.to_string_lossy().to_string(), - ], - ) - }; - Ok(NativeMcpDescriptor { - name, - command, - args, - setup_config_path: config_path.to_string_lossy().to_string(), - repo_root: repo_root.to_string_lossy().to_string(), - timeout: 60, - }) -} - -pub(in crate::cli) fn install_safe_name(value: &str) -> String { - let normalized: String = value - .trim() - .chars() - .map(|character| { - if character.is_ascii_alphanumeric() || character == '-' || character == '_' { - character.to_ascii_lowercase() - } else { - '_' - } - }) - .collect(); - normalized.trim_matches(['.', '_', '-']).to_string() -} diff --git a/src/cli/install/fs_util.rs b/src/cli/install/fs_util.rs deleted file mode 100644 index 89130bb..0000000 --- a/src/cli/install/fs_util.rs +++ /dev/null @@ -1,79 +0,0 @@ -use std::{ - env, fs, - path::{Path, PathBuf}, -}; - -pub(in crate::cli) fn write_text_atomic(path: &Path, text: &str) -> Result<(), String> { - if let Some(parent) = path.parent() { - fs::create_dir_all(parent).map_err(|error| { - format!( - "failed to create config directory {}: {error}", - parent.display() - ) - })?; - } - let tmp_path = path.with_extension(format!( - "{}.tmp", - path.extension() - .and_then(|value| value.to_str()) - .unwrap_or_default() - )); - fs::write(&tmp_path, text).map_err(|error| { - format!( - "failed to write temporary config {}: {error}", - tmp_path.display() - ) - })?; - fs::rename(&tmp_path, path) - .map_err(|error| format!("failed to replace config {}: {error}", path.display())) -} - -pub(in crate::cli) fn expand_path(value: &str) -> PathBuf { - if let Some(rest) = value.strip_prefix("~/") { - return home_dir().join(rest); - } - let path = PathBuf::from(value); - if path.is_absolute() { - path - } else { - env::current_dir() - .unwrap_or_else(|_| PathBuf::from(".")) - .join(path) - } -} - -pub(in crate::cli) fn home_dir() -> PathBuf { - env::var_os("HOME") - .map(PathBuf::from) - .unwrap_or_else(|| PathBuf::from(".")) -} - -pub(in crate::cli) fn executable_in_path(executable: &str) -> bool { - let path = Path::new(executable); - if path.components().count() > 1 { - return path.is_file(); - } - env::var_os("PATH") - .map(|paths| env::split_paths(&paths).any(|dir| dir.join(executable).is_file())) - .unwrap_or(false) -} - -pub(in crate::cli) fn subprocess_error(completed: &std::process::Output) -> String { - let stdout = String::from_utf8_lossy(&completed.stdout) - .trim() - .to_string(); - let stderr = String::from_utf8_lossy(&completed.stderr) - .trim() - .to_string(); - let output = [stdout, stderr] - .into_iter() - .filter(|part| !part.is_empty()) - .collect::>() - .join("\n"); - let code = completed.status.code().unwrap_or(1); - if output.is_empty() { - format!("exit {code}") - } else { - format!("exit {code}: {output}") - } -} diff --git a/src/cli/install/metadata.rs b/src/cli/install/metadata.rs deleted file mode 100644 index ec1c684..0000000 --- a/src/cli/install/metadata.rs +++ /dev/null @@ -1,119 +0,0 @@ -use super::{home_dir, NativeMcpDescriptor}; -use std::{ - env, - path::{Path, PathBuf}, -}; - -pub(in crate::cli) fn default_client_config_path( - client: &str, - scope: &str, - descriptor: &NativeMcpDescriptor, -) -> PathBuf { - let home = home_dir(); - match adapter_id(client, scope) { - "codex" => env::var_os("CODEX_HOME") - .map(PathBuf::from) - .unwrap_or_else(|| home.join(".codex")) - .join("config.toml"), - "claude" => { - let mac_path = - home.join("Library/Application Support/Claude/claude_desktop_config.json"); - if mac_path.parent().is_some_and(Path::exists) { - mac_path - } else { - home.join(".config/claude/claude_desktop_config.json") - } - } - "claude-project" => PathBuf::from(&descriptor.repo_root).join(".mcp.json"), - "lmstudio" => home.join(".lmstudio/mcp.json"), - "github-copilot" => PathBuf::from(&descriptor.repo_root).join(".vscode/mcp.json"), - "hermes" => home.join(".hermes/config.yaml"), - "openclaw" => env::var_os("OPENCLAW_HOME") - .map(PathBuf::from) - .unwrap_or_else(|| home.join(".openclaw")) - .join("mcp.json5"), - _ => home.join(".config/mcp/mcp.json"), - } -} - -pub(in crate::cli) fn supported_install_clients() -> Vec<&'static str> { - vec![ - "claude", - "claude-project", - "codex", - "copilot-studio", - "generic", - "github-copilot", - "hermes", - "lmstudio", - "microsoft-copilot", - "openclaw", - ] -} - -pub(in crate::cli) fn supported_install_clients_with_all() -> Vec<&'static str> { - let mut clients = supported_install_clients(); - clients.push("all"); - clients -} - -pub(in crate::cli) fn install_scope(client: &str, scope: &str) -> String { - if client == "claude-project" { - "project".to_string() - } else { - scope.to_string() - } -} - -pub(in crate::cli) fn adapter_id<'a>(client: &'a str, scope: &str) -> &'a str { - if client == "claude" && scope == "project" { - "claude-project" - } else { - client - } -} - -pub(in crate::cli) fn native_client_command( - client: &str, - descriptor: &NativeMcpDescriptor, - scope: &str, -) -> Option> { - match client { - "codex" => Some(vec![ - "codex".to_string(), - "mcp".to_string(), - "add".to_string(), - descriptor.name.clone(), - "--".to_string(), - descriptor.command.clone(), - descriptor.args[0].clone(), - descriptor.args[1].clone(), - descriptor.args[2].clone(), - descriptor.args[3].clone(), - ]), - "claude" | "claude-project" => Some(vec![ - "claude".to_string(), - "mcp".to_string(), - "add".to_string(), - "--transport".to_string(), - "stdio".to_string(), - "--scope".to_string(), - install_scope(client, scope), - descriptor.name.clone(), - "--".to_string(), - descriptor.command.clone(), - descriptor.args[0].clone(), - descriptor.args[1].clone(), - descriptor.args[2].clone(), - descriptor.args[3].clone(), - ]), - "openclaw" => Some(vec![ - "openclaw".to_string(), - "mcp".to_string(), - "set".to_string(), - descriptor.name.clone(), - serde_json::to_string(&descriptor.stdio_entry(true, false)).ok()?, - ]), - _ => None, - } -} diff --git a/src/cli/install/mod.rs b/src/cli/install/mod.rs deleted file mode 100644 index e76bf8b..0000000 --- a/src/cli/install/mod.rs +++ /dev/null @@ -1,26 +0,0 @@ -mod client; -mod command; -mod descriptor; -mod fs_util; -mod metadata; -mod options; -mod render; -mod state; -mod verify; - -pub(in crate::cli) use client::install_mcp_client; -pub(in crate::cli) use command::run_mcp_install; -pub(in crate::cli) use descriptor::{build_mcp_descriptor, NativeMcpDescriptor}; -pub(in crate::cli) use fs_util::{ - executable_in_path, expand_path, home_dir, subprocess_error, write_text_atomic, -}; -pub(in crate::cli) use metadata::{ - adapter_id, default_client_config_path, install_scope, native_client_command, - supported_install_clients, supported_install_clients_with_all, -}; -pub(in crate::cli) use options::McpInstallOptions; -pub(in crate::cli) use render::{ - copilot_studio_metadata, remove_client_config, render_client_config, -}; -pub(in crate::cli) use state::McpHttpState; -pub(in crate::cli) use verify::attach_install_verification; diff --git a/src/cli/install/render.rs b/src/cli/install/render.rs deleted file mode 100644 index 2b0ab32..0000000 --- a/src/cli/install/render.rs +++ /dev/null @@ -1,483 +0,0 @@ -use super::{adapter_id, NativeMcpDescriptor}; -use serde_json::json; - -pub(in crate::cli) struct RenderedNativeConfig { - pub(in crate::cli) text: String, - pub(in crate::cli) action: String, - pub(in crate::cli) entry: serde_json::Value, - pub(in crate::cli) patch: serde_json::Value, - pub(in crate::cli) payload: serde_json::Value, -} - -pub(in crate::cli) struct RemovedNativeConfig { - pub(in crate::cli) text: String, - pub(in crate::cli) action: String, - pub(in crate::cli) previous: serde_json::Value, - pub(in crate::cli) payload: serde_json::Value, -} - -pub(in crate::cli) fn render_client_config( - client: &str, - scope: &str, - existing: Option<&str>, - descriptor: &NativeMcpDescriptor, -) -> Result { - match adapter_id(client, scope) { - "codex" => render_codex_config(existing, descriptor), - "hermes" => render_hermes_config(existing, descriptor), - "claude" | "claude-project" | "lmstudio" | "github-copilot" | "openclaw" | "generic" => { - render_json_config(adapter_id(client, scope), existing, descriptor) - } - other => Err(format!("Unsupported MCP client adapter: {other}")), - } -} - -pub(in crate::cli) fn remove_client_config( - client: &str, - scope: &str, - existing: Option<&str>, - server_name: &str, -) -> Result { - match adapter_id(client, scope) { - "codex" => remove_codex_config(existing, server_name), - "hermes" => remove_hermes_config(existing, server_name), - "claude" | "claude-project" | "lmstudio" | "github-copilot" | "openclaw" | "generic" => { - remove_json_config(adapter_id(client, scope), existing, server_name) - } - other => Err(format!("Unsupported MCP client adapter: {other}")), - } -} - -pub(in crate::cli) fn render_json_config( - adapter: &str, - existing: Option<&str>, - descriptor: &NativeMcpDescriptor, -) -> Result { - let mut payload = existing - .filter(|text| !text.trim().is_empty()) - .map(serde_json::from_str::) - .transpose() - .map_err(|error| format!("MCP config must contain a JSON object: {error}"))? - .unwrap_or_else(|| json!({})); - if !payload.is_object() { - return Err("MCP config must contain a JSON object".to_string()); - } - let root_path = match adapter { - "github-copilot" => vec!["servers"], - "openclaw" => vec!["mcp", "servers"], - _ => vec!["mcpServers"], - }; - let include_type = !matches!(adapter, "claude" | "generic"); - let entry = descriptor.stdio_entry(include_type, false); - let previous = json_container_mut(&mut payload, &root_path)? - .insert(descriptor.name.clone(), entry.clone()); - let action = action_for_json(previous.as_ref(), &entry, existing.is_some()); - let text = serde_json::to_string_pretty(&payload).map_err(|error| error.to_string())? + "\n"; - let action = if existing == Some(text.as_str()) { - "unchanged".to_string() - } else { - action - }; - Ok(RenderedNativeConfig { - text, - action, - entry, - patch: payload.clone(), - payload, - }) -} - -pub(in crate::cli) fn remove_json_config( - adapter: &str, - existing: Option<&str>, - server_name: &str, -) -> Result { - let Some(existing) = existing else { - return Ok(RemovedNativeConfig { - text: String::new(), - action: "unchanged".to_string(), - previous: serde_json::Value::Null, - payload: json!({}), - }); - }; - let mut payload = if existing.trim().is_empty() { - json!({}) - } else { - serde_json::from_str::(existing) - .map_err(|error| format!("MCP config must contain a JSON object: {error}"))? - }; - if !payload.is_object() { - return Err("MCP config must contain a JSON object".to_string()); - } - let root_path = match adapter { - "github-copilot" => vec!["servers"], - "openclaw" => vec!["mcp", "servers"], - _ => vec!["mcpServers"], - }; - let previous = json_container_mut(&mut payload, &root_path)?.remove(server_name); - let action = if previous.is_some() { - "removed" - } else { - "unchanged" - } - .to_string(); - let text = serde_json::to_string_pretty(&payload).map_err(|error| error.to_string())? + "\n"; - Ok(RemovedNativeConfig { - text, - action, - previous: previous.unwrap_or(serde_json::Value::Null), - payload, - }) -} - -pub(in crate::cli) fn render_codex_config( - existing: Option<&str>, - descriptor: &NativeMcpDescriptor, -) -> Result { - let entry = descriptor.stdio_entry(false, true); - let patch = codex_toml_block(descriptor); - let (text, previous) = - upsert_toml_block(existing.unwrap_or_default(), &descriptor.name, &patch); - let action = if existing == Some(text.as_str()) { - "unchanged".to_string() - } else if previous.is_none() { - "created".to_string() - } else if previous.as_deref() == Some(patch.trim_end()) { - "unchanged".to_string() - } else { - "updated".to_string() - }; - Ok(RenderedNativeConfig { - text, - action, - entry, - patch: json!(patch), - payload: json!(patch), - }) -} - -pub(in crate::cli) fn remove_codex_config( - existing: Option<&str>, - server_name: &str, -) -> Result { - let Some(existing) = existing else { - return Ok(RemovedNativeConfig { - text: String::new(), - action: "unchanged".to_string(), - previous: serde_json::Value::Null, - payload: json!(""), - }); - }; - let (text, previous) = remove_toml_block(existing, server_name); - Ok(RemovedNativeConfig { - text, - action: if previous.is_some() { - "removed".to_string() - } else { - "unchanged".to_string() - }, - previous: previous - .map(serde_json::Value::String) - .unwrap_or(serde_json::Value::Null), - payload: json!(existing), - }) -} - -pub(in crate::cli) fn render_hermes_config( - existing: Option<&str>, - descriptor: &NativeMcpDescriptor, -) -> Result { - let entry = descriptor.stdio_entry(true, false); - let patch = hermes_yaml_block(descriptor); - let (text, previous) = upsert_marked_block(existing.unwrap_or_default(), &patch); - let action = if existing == Some(text.as_str()) { - "unchanged".to_string() - } else if previous.is_none() { - "created".to_string() - } else if previous.as_deref() == Some(patch.trim_end()) { - "unchanged".to_string() - } else { - "updated".to_string() - }; - Ok(RenderedNativeConfig { - text, - action, - entry, - patch: json!(patch), - payload: json!(patch), - }) -} - -pub(in crate::cli) fn remove_hermes_config( - existing: Option<&str>, - server_name: &str, -) -> Result { - let Some(existing) = existing else { - return Ok(RemovedNativeConfig { - text: String::new(), - action: "unchanged".to_string(), - previous: serde_json::Value::Null, - payload: json!(""), - }); - }; - let (text, previous) = remove_marked_block(existing); - let previous = previous.filter(|block| block.contains(&format!(" {server_name}:"))); - let action = if previous.is_some() { - "removed".to_string() - } else { - "unchanged".to_string() - }; - Ok(RemovedNativeConfig { - text: if previous.is_some() { - text - } else { - existing.to_string() - }, - action, - previous: previous - .map(serde_json::Value::String) - .unwrap_or(serde_json::Value::Null), - payload: json!(existing), - }) -} - -pub(in crate::cli) fn json_container_mut<'a>( - payload: &'a mut serde_json::Value, - path: &[&str], -) -> Result<&'a mut serde_json::Map, String> { - let mut cursor = payload - .as_object_mut() - .ok_or_else(|| "MCP config must contain a JSON object".to_string())?; - for key in path { - let next = cursor - .entry((*key).to_string()) - .or_insert_with(|| json!({})); - cursor = next - .as_object_mut() - .ok_or_else(|| format!("MCP config key must contain an object: {}", path.join(".")))?; - } - Ok(cursor) -} - -pub(in crate::cli) fn action_for_json( - previous: Option<&serde_json::Value>, - next_value: &serde_json::Value, - file_exists: bool, -) -> String { - if !file_exists { - "created".to_string() - } else if previous == Some(next_value) { - "unchanged".to_string() - } else { - "updated".to_string() - } -} - -pub(in crate::cli) fn copilot_studio_metadata( - descriptor: &NativeMcpDescriptor, -) -> serde_json::Value { - json!({ - "kind": "copilot_studio_manual_metadata", - "stdio": descriptor.stdio_entry(true, false), - "http": { - "url": "http://127.0.0.1:8765/mcp", - "start_command": [ - descriptor.command, - "mcp", - "http", - "--config", - descriptor.setup_config_path, - "--host", - "127.0.0.1", - "--port", - "8765", - "--path", - "/mcp" - ], - "host": "127.0.0.1", - "port": 8765, - "path": "/mcp", - }, - "notes": [ - "No local client configuration file is written for Copilot Studio.", - "Remote Copilot Studio use requires user-managed endpoint exposure, bearer-token configuration, and TLS.", - ], - }) -} - -pub(in crate::cli) fn codex_toml_block(descriptor: &NativeMcpDescriptor) -> String { - format!( - "[mcp_servers.{}]\ncommand = {}\nargs = {}\nstartup_timeout_sec = {}\n", - descriptor.name, - toml_string(&descriptor.command), - toml_array(&descriptor.args), - descriptor.timeout - ) -} - -pub(in crate::cli) fn toml_array(values: &[String]) -> String { - format!( - "[{}]", - values - .iter() - .map(|value| toml_string(value)) - .collect::>() - .join(", ") - ) -} - -pub(in crate::cli) fn toml_string(value: &str) -> String { - format!("\"{}\"", value.replace('\\', "\\\\").replace('"', "\\\"")) -} - -pub(in crate::cli) fn upsert_toml_block( - existing: &str, - server_name: &str, - block: &str, -) -> (String, Option) { - let lines = existing.lines().collect::>(); - let header = format!("[mcp_servers.{server_name}]"); - let env_header = format!("[mcp_servers.{server_name}.env]"); - let start = lines - .iter() - .position(|line| line.trim() == header || line.trim() == env_header); - let Some(start) = start else { - let prefix = existing.trim_end(); - let separator = if prefix.is_empty() { "" } else { "\n\n" }; - return (format!("{prefix}{separator}{block}"), None); - }; - let end = lines[start + 1..] - .iter() - .position(|line| { - let trimmed = line.trim(); - trimmed.starts_with('[') - && trimmed.ends_with(']') - && trimmed != header - && trimmed != env_header - }) - .map(|index| start + 1 + index) - .unwrap_or(lines.len()); - let previous = lines[start..end].join("\n").trim_end().to_string(); - let mut next_lines = Vec::new(); - next_lines.extend(lines[..start].iter().map(|value| (*value).to_string())); - next_lines.extend(block.trim_end().lines().map(str::to_string)); - next_lines.extend(lines[end..].iter().map(|value| (*value).to_string())); - ( - next_lines.join("\n").trim_end().to_string() + "\n", - Some(previous), - ) -} - -pub(in crate::cli) fn remove_toml_block( - existing: &str, - server_name: &str, -) -> (String, Option) { - let lines = existing.lines().collect::>(); - let header = format!("[mcp_servers.{server_name}]"); - let env_header = format!("[mcp_servers.{server_name}.env]"); - let start = lines - .iter() - .position(|line| line.trim() == header || line.trim() == env_header); - let Some(start) = start else { - return (existing.to_string(), None); - }; - let end = lines[start + 1..] - .iter() - .position(|line| { - let trimmed = line.trim(); - trimmed.starts_with('[') - && trimmed.ends_with(']') - && trimmed != header - && trimmed != env_header - }) - .map(|index| start + 1 + index) - .unwrap_or(lines.len()); - let previous = lines[start..end].join("\n").trim_end().to_string(); - let mut next_lines = Vec::new(); - next_lines.extend(lines[..start].iter().map(|value| (*value).to_string())); - next_lines.extend(lines[end..].iter().map(|value| (*value).to_string())); - let text = next_lines.join("\n").trim().to_string(); - let text = if text.is_empty() { - String::new() - } else { - text + "\n" - }; - (text, Some(previous)) -} - -pub(in crate::cli) fn hermes_yaml_block(descriptor: &NativeMcpDescriptor) -> String { - let mut lines = vec![ - "# codebaseGraph MCP server start".to_string(), - "mcp_servers:".to_string(), - format!(" {}:", descriptor.name), - " type: stdio".to_string(), - format!(" command: {}", yaml_scalar(&descriptor.command)), - " args:".to_string(), - ]; - for arg in &descriptor.args { - lines.push(format!(" - {}", yaml_scalar(arg))); - } - lines.push("# codebaseGraph MCP server end".to_string()); - lines.join("\n") + "\n" -} - -pub(in crate::cli) fn yaml_scalar(value: &str) -> String { - format!("\"{}\"", value.replace('\\', "\\\\").replace('"', "\\\"")) -} - -pub(in crate::cli) fn upsert_marked_block(existing: &str, block: &str) -> (String, Option) { - const START: &str = "# codebaseGraph MCP server start"; - const END: &str = "# codebaseGraph MCP server end"; - let Some(start) = existing.find(START) else { - let prefix = existing.trim_end(); - let separator = if prefix.is_empty() { "" } else { "\n\n" }; - return (format!("{prefix}{separator}{block}"), None); - }; - let Some(end) = existing.find(END) else { - let prefix = existing.trim_end(); - let separator = if prefix.is_empty() { "" } else { "\n\n" }; - return (format!("{prefix}{separator}{block}"), None); - }; - if end < start { - let prefix = existing.trim_end(); - let separator = if prefix.is_empty() { "" } else { "\n\n" }; - return (format!("{prefix}{separator}{block}"), None); - } - let after_end = end + END.len(); - let previous = existing[start..after_end].trim_end().to_string(); - let text = format!( - "{}\n\n{}\n\n{}", - existing[..start].trim_end(), - block.trim_end(), - existing[after_end..].trim_start() - ) - .trim() - .to_string() - + "\n"; - (text, Some(previous)) -} - -pub(in crate::cli) fn remove_marked_block(existing: &str) -> (String, Option) { - const START: &str = "# codebaseGraph MCP server start"; - const END: &str = "# codebaseGraph MCP server end"; - let Some(start) = existing.find(START) else { - return (existing.to_string(), None); - }; - let Some(end) = existing.find(END) else { - return (existing.to_string(), None); - }; - if end < start { - return (existing.to_string(), None); - } - let after_end = end + END.len(); - let previous = existing[start..after_end].trim_end().to_string(); - let before = existing[..start].trim_end(); - let after = existing[after_end..].trim_start(); - let text = match (before.is_empty(), after.is_empty()) { - (true, true) => String::new(), - (true, false) => format!("{after}\n"), - (false, true) => format!("{before}\n"), - (false, false) => format!("{before}\n\n{after}"), - }; - (text, Some(previous)) -} diff --git a/src/cli/install/state.rs b/src/cli/install/state.rs deleted file mode 100644 index 5013daa..0000000 --- a/src/cli/install/state.rs +++ /dev/null @@ -1,15 +0,0 @@ -use crate::cli::mcp::McpSession; -use std::collections::BTreeMap; - -#[derive(Debug, Default)] -pub(in crate::cli) struct McpHttpState { - pub(in crate::cli) sessions: BTreeMap, - pub(in crate::cli) next_session: u64, -} - -impl McpHttpState { - pub(in crate::cli) fn next_session_id(&mut self) -> String { - self.next_session += 1; - format!("native-http-session-{}", self.next_session) - } -} diff --git a/src/cli/mcp/mod.rs b/src/cli/mcp/mod.rs deleted file mode 100644 index 1dec691..0000000 --- a/src/cli/mcp/mod.rs +++ /dev/null @@ -1,18 +0,0 @@ -mod commands; -mod http; -mod options; -mod protocol; -mod refresh; -mod stdio; -mod tools; - -pub(in crate::cli) use commands::run_mcp_command; -pub(in crate::cli) use http::serve_mcp_http; -pub(in crate::cli) use options::{McpHttpOptions, McpServeOptions}; -pub(in crate::cli) use protocol::McpSession; -pub(in crate::cli) use stdio::serve_mcp_stdio; - -#[cfg(test)] -pub(super) use http::{handle_mcp_http_request, HttpRequest}; -#[cfg(test)] -pub(super) use tools::mcp_call_tool_result; diff --git a/src/cli/mcp/refresh.rs b/src/cli/mcp/refresh.rs deleted file mode 100644 index 9df00a5..0000000 --- a/src/cli/mcp/refresh.rs +++ /dev/null @@ -1,491 +0,0 @@ -use super::options::McpServeOptions; -use crate::cli::{ - build::{materialize_candidate_paths, MaterializeOptions}, - graph::resolve_health_runtime, - watch::{ - collect_poll_batch, collect_watch_batch, probe_native_watcher, start_native_watcher, - watch_file_snapshot, WatchEventFilter, WatchLoopConfig, WatchMessage, - }, -}; -use crate::db_writer::is_transient_database_error; -use crate::protocol::NativeSyntaxMaterializationResponse; -use serde_json::json; -use std::{ - collections::{BTreeSet, VecDeque}, - sync::{mpsc::Receiver, Arc, Mutex, RwLock, RwLockReadGuard, RwLockWriteGuard}, - thread, - time::{Duration, SystemTime, UNIX_EPOCH}, -}; - -const DEFAULT_POLL_MS: u64 = 500; -const DEFAULT_DEBOUNCE_MS: u64 = 250; -const REFRESH_INITIAL_RETRY_MS: u64 = 100; -const REFRESH_MAX_RETRY_MS: u64 = 1_000; - -#[derive(Debug)] -pub(in crate::cli) struct McpRefreshState { - status: Mutex, - graph_lock: RwLock<()>, -} - -#[derive(Clone, Debug)] -pub(in crate::cli) struct McpRefreshStatus { - pub(in crate::cli) enabled: bool, - pub(in crate::cli) backend: String, - pub(in crate::cli) refreshing: bool, - pub(in crate::cli) pending: bool, - pub(in crate::cli) last_refresh_unix_ms: Option, - pub(in crate::cli) last_error: Option, - pub(in crate::cli) last_error_count: usize, - pub(in crate::cli) last_retry_unix_ms: Option, - pub(in crate::cli) last_event_count: usize, - pub(in crate::cli) last_changed_paths: usize, - pub(in crate::cli) last_rebuilt: usize, - pub(in crate::cli) last_deleted: usize, - pub(in crate::cli) last_database_written: bool, -} - -impl Default for McpRefreshStatus { - fn default() -> Self { - Self { - enabled: true, - backend: "starting".to_string(), - refreshing: false, - pending: false, - last_refresh_unix_ms: None, - last_error: None, - last_error_count: 0, - last_retry_unix_ms: None, - last_event_count: 0, - last_changed_paths: 0, - last_rebuilt: 0, - last_deleted: 0, - last_database_written: false, - } - } -} - -impl McpRefreshState { - pub(in crate::cli) fn new() -> Self { - Self { - status: Mutex::new(McpRefreshStatus::default()), - graph_lock: RwLock::new(()), - } - } - - pub(in crate::cli) fn snapshot(&self) -> McpRefreshStatus { - self.status - .lock() - .map(|status| status.clone()) - .unwrap_or_else(|_| McpRefreshStatus { - enabled: false, - backend: "failed".to_string(), - refreshing: false, - pending: false, - last_refresh_unix_ms: None, - last_error: Some("refresh status lock poisoned".to_string()), - last_error_count: 1, - last_retry_unix_ms: None, - last_event_count: 0, - last_changed_paths: 0, - last_rebuilt: 0, - last_deleted: 0, - last_database_written: false, - }) - } - - pub(in crate::cli) fn as_json(&self) -> serde_json::Value { - let status = self.snapshot(); - json!({ - "enabled": status.enabled, - "backend": status.backend, - "refreshing": status.refreshing, - "pending": status.pending, - "last_refresh_unix_ms": status.last_refresh_unix_ms, - "last_error": status.last_error, - "last_error_count": status.last_error_count, - "last_retry_unix_ms": status.last_retry_unix_ms, - "last_event_count": status.last_event_count, - "last_changed_paths": status.last_changed_paths, - "last_rebuilt": status.last_rebuilt, - "last_deleted": status.last_deleted, - "last_database_written": status.last_database_written, - }) - } - - pub(in crate::cli) fn read_guard(&self) -> Result, String> { - self.graph_lock - .read() - .map_err(|_| "refresh graph read lock poisoned".to_string()) - } - - fn write_guard(&self) -> Result, String> { - self.graph_lock - .write() - .map_err(|_| "refresh graph write lock poisoned".to_string()) - } - - fn set_backend(&self, backend: &str) { - if let Ok(mut status) = self.status.lock() { - status.backend = backend.to_string(); - status.enabled = true; - status.last_error = None; - } - } - - fn set_error(&self, backend: &str, error: String) { - if let Ok(mut status) = self.status.lock() { - status.backend = backend.to_string(); - status.enabled = true; - status.refreshing = false; - status.pending = false; - status.last_error = Some(error); - status.last_error_count = status.last_error_count.saturating_add(1); - } - } - - fn mark_pending(&self) { - if let Ok(mut status) = self.status.lock() { - status.pending = true; - } - } - - fn mark_refreshing(&self, backend: &str) { - if let Ok(mut status) = self.status.lock() { - status.backend = backend.to_string(); - status.refreshing = true; - status.pending = false; - status.last_error = None; - } - } - - fn mark_refresh_error( - &self, - backend: &str, - event_count: usize, - changed_paths: usize, - error: String, - retrying: bool, - ) { - if let Ok(mut status) = self.status.lock() { - status.backend = backend.to_string(); - status.refreshing = false; - status.pending = retrying; - status.last_error = Some(error); - status.last_error_count = status.last_error_count.saturating_add(1); - status.last_retry_unix_ms = retrying.then_some(unix_ms()); - status.last_event_count = event_count; - status.last_changed_paths = changed_paths; - } - } - - fn mark_refreshed( - &self, - backend: &str, - event_count: usize, - changed_paths: usize, - rebuilt: usize, - deleted: usize, - database_written: bool, - ) { - if let Ok(mut status) = self.status.lock() { - status.backend = backend.to_string(); - status.refreshing = false; - status.pending = false; - status.last_refresh_unix_ms = Some(unix_ms()); - status.last_error = None; - status.last_error_count = 0; - status.last_retry_unix_ms = None; - status.last_event_count = event_count; - status.last_changed_paths = changed_paths; - status.last_rebuilt = rebuilt; - status.last_deleted = deleted; - status.last_database_written = database_written; - } - } -} - -pub(in crate::cli) fn start_auto_refresh(options: &McpServeOptions) -> Arc { - let state = Arc::new(McpRefreshState::new()); - let mut refresh_options = options.clone(); - refresh_options.refresh = None; - let thread_state = Arc::clone(&state); - thread::spawn(move || { - if let Err(error) = run_auto_refresh(refresh_options, &thread_state) { - thread_state.set_error("failed", error.clone()); - eprintln!( - "{}", - json!({"event": "mcp.auto_refresh_error", "message": error}) - ); - } - }); - state -} - -fn run_auto_refresh(options: McpServeOptions, state: &Arc) -> Result<(), String> { - let runtime = resolve_health_runtime(&options.health_options())?; - let materialize_options = MaterializeOptions { - source_root: Some(runtime.repo_root.clone()), - db: Some(runtime.db_path.clone()), - manifest: Some(runtime.manifest_path.clone()), - mode: "changed".to_string(), - include_fts: true, - semantic_enrichment: true, - semantic_provider_mode: "local_only".to_string(), - use_git: false, - ..MaterializeOptions::default() - }; - let filter = WatchEventFilter::from_options(&runtime.repo_root, &materialize_options)?; - let loop_config = WatchLoopConfig { - poll_ms: DEFAULT_POLL_MS, - debounce_ms: DEFAULT_DEBOUNCE_MS, - max_iterations: None, - }; - - match start_native_watcher(&runtime.repo_root) { - Ok((watcher, rx)) => { - let probe = probe_native_watcher(&runtime.repo_root, &filter, &rx)?; - if probe.delivered { - state.set_backend("native"); - match run_native_refresh_loop( - state, - loop_config, - materialize_options.clone(), - filter, - watcher, - rx, - probe.queued, - ) { - Ok(()) => Ok(()), - Err(error) => { - state.set_error("poll", error); - let filter = WatchEventFilter::from_options( - &runtime.repo_root, - &materialize_options, - )?; - run_poll_refresh_loop(state, loop_config, materialize_options, filter) - } - } - } else { - drop(watcher); - state.set_error( - "poll", - probe - .reason - .unwrap_or_else(|| "native probe failed".to_string()), - ); - run_poll_refresh_loop(state, loop_config, materialize_options, filter) - } - } - Err(error) => { - state.set_error("poll", error); - run_poll_refresh_loop(state, loop_config, materialize_options, filter) - } - } -} - -fn run_native_refresh_loop( - state: &Arc, - loop_config: WatchLoopConfig, - materialize_options: MaterializeOptions, - filter: WatchEventFilter, - _watcher: notify::RecommendedWatcher, - rx: Receiver, - mut queued: VecDeque, -) -> Result<(), String> { - loop { - let first = match queued.pop_front() { - Some(message) => message, - None => rx - .recv() - .map_err(|error| format!("filesystem watcher stopped: {error}"))?, - }; - let Some(batch) = collect_watch_batch( - first, - &rx, - &mut queued, - &filter, - Duration::from_millis(loop_config.debounce_ms), - Duration::from_millis(loop_config.debounce_ms.saturating_mul(4).max(1)), - )? - else { - continue; - }; - refresh_batch( - state, - "native", - &materialize_options, - batch.event_count, - batch.paths, - )?; - } -} - -fn run_poll_refresh_loop( - state: &Arc, - loop_config: WatchLoopConfig, - materialize_options: MaterializeOptions, - filter: WatchEventFilter, -) -> Result<(), String> { - state.set_backend("poll"); - let mut previous_snapshot = watch_file_snapshot(&filter)?; - loop { - let batch = collect_poll_batch( - &filter, - &mut previous_snapshot, - Duration::from_millis(loop_config.poll_ms), - Duration::from_millis(loop_config.debounce_ms), - Duration::from_millis(loop_config.debounce_ms.saturating_mul(4).max(1)), - )?; - refresh_batch( - state, - "poll", - &materialize_options, - batch.event_count, - batch.paths, - )?; - } -} - -fn refresh_batch( - state: &Arc, - backend: &str, - materialize_options: &MaterializeOptions, - event_count: usize, - paths: BTreeSet, -) -> Result<(), String> { - let changed_paths = paths.len(); - if changed_paths == 0 { - return Ok(()); - } - refresh_batch_with(state, backend, event_count, paths, |candidate_paths| { - materialize_candidate_paths(materialize_options, candidate_paths) - .map(|(_, response)| response) - }) -} - -fn refresh_batch_with( - state: &Arc, - backend: &str, - event_count: usize, - paths: BTreeSet, - mut refresh: impl FnMut(Vec) -> Result, -) -> Result<(), String> { - let changed_paths = paths.len(); - if changed_paths == 0 { - return Ok(()); - } - let candidate_paths = paths.into_iter().collect::>(); - let mut retry_delay = Duration::from_millis(REFRESH_INITIAL_RETRY_MS); - loop { - state.mark_pending(); - let result = { - let _guard = state.write_guard()?; - state.mark_refreshing(backend); - refresh(candidate_paths.clone()) - }; - match result { - Ok(response) => { - state.mark_refreshed( - backend, - event_count, - changed_paths, - response.diff.rebuild_paths().len(), - response.diff.deleted.len(), - response.database_written, - ); - return Ok(()); - } - Err(error) => { - let retrying = is_transient_database_error(&error); - state.mark_refresh_error(backend, event_count, changed_paths, error, retrying); - if !retrying { - return Ok(()); - } - thread::sleep(retry_delay); - retry_delay = retry_delay - .saturating_mul(2) - .min(Duration::from_millis(REFRESH_MAX_RETRY_MS)); - } - } - } -} - -fn unix_ms() -> u128 { - SystemTime::now() - .duration_since(UNIX_EPOCH) - .map(|duration| duration.as_millis()) - .unwrap_or(0) -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::protocol::ManifestDiff; - use std::{ - collections::{BTreeMap, BTreeSet}, - sync::atomic::{AtomicUsize, Ordering}, - }; - - #[test] - fn refresh_batch_retries_transient_errors_without_failing_state() { - let state = Arc::new(McpRefreshState::new()); - let attempts = AtomicUsize::new(0); - refresh_batch_with( - &state, - "poll", - 1, - BTreeSet::from(["src/lib.rs".to_string()]), - |_| { - if attempts.fetch_add(1, Ordering::SeqCst) == 0 { - Err("IO exception: Could not set lock on file".to_string()) - } else { - Ok(NativeSyntaxMaterializationResponse::skipped( - BTreeMap::new(), - ManifestDiff { - added: Vec::new(), - modified: Vec::new(), - unchanged: Vec::new(), - deleted: Vec::new(), - force_rebuild: false, - }, - Vec::new(), - Vec::new(), - BTreeMap::new(), - )) - } - }, - ) - .unwrap(); - - let status = state.snapshot(); - assert_eq!(attempts.load(Ordering::SeqCst), 2); - assert_eq!(status.backend, "poll"); - assert!(!status.refreshing); - assert!(!status.pending); - assert!(status.last_error.is_none()); - assert_eq!(status.last_error_count, 0); - assert!(status.last_refresh_unix_ms.is_some()); - } - - #[test] - fn refresh_batch_records_non_transient_errors_and_keeps_loop_alive() { - let state = Arc::new(McpRefreshState::new()); - refresh_batch_with( - &state, - "native", - 1, - BTreeSet::from(["src/lib.rs".to_string()]), - |_| Err("parser exploded".to_string()), - ) - .unwrap(); - - let status = state.snapshot(); - assert_eq!(status.backend, "native"); - assert!(!status.refreshing); - assert!(!status.pending); - assert_eq!(status.last_error.as_deref(), Some("parser exploded")); - assert_eq!(status.last_error_count, 1); - assert!(status.last_refresh_unix_ms.is_none()); - } -} diff --git a/src/cli/mcp/tools.rs b/src/cli/mcp/tools.rs deleted file mode 100644 index b35eac7..0000000 --- a/src/cli/mcp/tools.rs +++ /dev/null @@ -1,321 +0,0 @@ -use super::options::McpServeOptions; -use crate::cli::{ - constants::{ARCHITECTURE_QUERIES_JSON, GRAPH_SCHEMA_JSON, QUERY_HELPERS_JSON}, - format::{ - filter_architecture_group, metadata_payload, serialize_architecture_queries_block, - serialize_context_block, serialize_error_block, serialize_health_block, - serialize_query_block, serialize_query_helpers_block, serialize_schema_block, - serialize_search_block, - }, - graph::{ - count_graph_nodes, execute_graph_context, execute_graph_search, execute_read_only_query, - resolve_health_runtime, validate_read_only_statement, GraphContextOptions, - GraphSearchOptions, MetadataOutputOptions, - }, -}; -use serde_json::json; - -pub(in crate::cli) fn mcp_call_tool_result( - tool_name: &str, - arguments: &serde_json::Value, - options: &McpServeOptions, -) -> Result { - let payload = mcp_tool_payload(tool_name, arguments, options); - let output_format = arguments - .get("output_format") - .and_then(serde_json::Value::as_str) - .unwrap_or("block"); - let include_structured = arguments - .get("include_structured_content") - .and_then(serde_json::Value::as_bool) - .unwrap_or(false); - match payload { - Ok(payload) => { - let text = if output_format == "json" { - serde_json::to_string(&payload).map_err(|error| error.to_string())? - } else { - mcp_block_text(tool_name, &payload) - }; - let mut result = json!({ - "content": [{"type": "text", "text": text}], - "isError": false, - }); - if include_structured { - result["structuredContent"] = payload; - } - Ok(result) - } - Err(error) - if tool_name.is_empty() || error.starts_with("Unknown codebaseGraph MCP tool") => - { - Err(error) - } - Err(error) => { - let payload = json!({ - "error": { - "tool": tool_name, - "type": "ValueError", - "message": error, - } - }); - let text = if output_format == "json" { - serde_json::to_string(&payload).map_err(|error| error.to_string())? - } else { - serialize_error_block(&payload) - }; - let mut result = json!({ - "content": [{"type": "text", "text": text}], - "isError": true, - }); - if include_structured { - result["structuredContent"] = payload; - } - Ok(result) - } - } -} - -pub(in crate::cli) fn mcp_tool_payload( - tool_name: &str, - arguments: &serde_json::Value, - options: &McpServeOptions, -) -> Result { - let _refresh_read_guard = if matches!( - tool_name, - "graph_health" | "graph_search" | "graph_context" | "graph_query" - ) { - options - .refresh - .as_ref() - .map(|refresh| refresh.read_guard()) - .transpose()? - } else { - None - }; - match tool_name { - "graph_health" => graph_health_payload(options), - "graph_schema" => metadata_payload(GRAPH_SCHEMA_JSON), - "graph_query_helpers" => metadata_payload(QUERY_HELPERS_JSON), - "graph_architecture_queries" => { - let mut payload = metadata_payload(ARCHITECTURE_QUERIES_JSON)?; - if let Some(group) = arguments.get("group").and_then(serde_json::Value::as_str) { - filter_architecture_group(&mut payload, group)?; - } - Ok(payload) - } - "graph_search" => { - let search = graph_search_options_from_mcp(arguments, options, true)?; - let runtime = resolve_health_runtime(&options.health_options())?; - let results = execute_graph_search(&runtime.db_path, &search)?; - Ok(json!({ - "query": search.query, - "profile": search.profile, - "limit": search.limit, - "budget": search.budget, - "results": results, - })) - } - "graph_context" => { - let context = graph_context_options_from_mcp(arguments, options)?; - let runtime = resolve_health_runtime(&options.health_options())?; - if let (Some(node_id), Some(node_type)) = - (context.node_id.as_ref(), context.node_type.as_ref()) - { - let rows = - execute_graph_context(&runtime.db_path, node_id, node_type, &context.search)?; - Ok(json!({ - "node_id": node_id, - "node_type": node_type, - "profile": context.search.profile, - "context": rows, - })) - } else { - let results = execute_graph_search(&runtime.db_path, &context.search)?; - Ok(json!({ - "query": context.search.query, - "profile": context.search.profile, - "limit": context.search.limit, - "budget": context.search.budget, - "results": results, - })) - } - } - "graph_query" => { - let statement = arguments - .get("statement") - .or_else(|| arguments.get("query")) - .and_then(serde_json::Value::as_str) - .unwrap_or("") - .trim(); - if statement.is_empty() { - return Err("graph_query requires a non-empty statement".to_string()); - } - validate_read_only_statement(statement)?; - let parameters = arguments - .get("parameters") - .cloned() - .unwrap_or_else(|| json!({})); - let parameters = parameters - .as_object() - .ok_or_else(|| "graph_query parameters must be a JSON object".to_string())?; - let limit = arguments - .get("limit") - .and_then(serde_json::Value::as_u64) - .unwrap_or(100) as usize; - if limit == 0 || limit > 1000 { - return Err("graph_query limit must be between 1 and 1000".to_string()); - } - let runtime = resolve_health_runtime(&options.health_options())?; - let (rows, truncated) = - execute_read_only_query(&runtime.db_path, statement, parameters, limit)?; - Ok(json!({ - "statement": statement, - "row_count": rows.len(), - "rows": rows, - "truncated": truncated, - })) - } - _ => Err(format!("Unknown codebaseGraph MCP tool: {tool_name}")), - } -} - -pub(in crate::cli) fn graph_health_payload( - options: &McpServeOptions, -) -> Result { - let runtime = resolve_health_runtime(&options.health_options())?; - let database_exists = runtime.db_path.exists(); - let manifest_exists = runtime.manifest_path.exists(); - let mut graph_readable = false; - let mut total_nodes = 0_u64; - let mut error_message = None; - if database_exists { - match count_graph_nodes(&runtime.db_path) { - Ok(count) => { - graph_readable = true; - total_nodes = count; - } - Err(error) => error_message = Some(error), - } - } - Ok(json!({ - "ok": database_exists && graph_readable, - "repo_root": runtime.repo_root, - "database_path": runtime.db_path, - "manifest_path": runtime.manifest_path, - "database_exists": database_exists, - "manifest_exists": manifest_exists, - "graph_readable": graph_readable, - "total_nodes": total_nodes, - "refresh": options.refresh.as_ref().map(|refresh| refresh.as_json()), - "error": error_message, - })) -} - -pub(in crate::cli) fn graph_search_options_from_mcp( - arguments: &serde_json::Value, - options: &McpServeOptions, - require_query: bool, -) -> Result { - let query = arguments - .get("query") - .and_then(serde_json::Value::as_str) - .unwrap_or("") - .to_string(); - if require_query && query.trim().is_empty() { - return Err("Search query must not be empty".to_string()); - } - let detail = arguments - .get("detail") - .and_then(serde_json::Value::as_str) - .unwrap_or("standard"); - if detail != "standard" && detail != "slim" { - return Err("--detail must be standard or slim".to_string()); - } - Ok(GraphSearchOptions { - query, - limit: json_usize(arguments, "limit", 3), - profile: arguments - .get("profile") - .and_then(serde_json::Value::as_str) - .unwrap_or("brief") - .to_string(), - budget: json_usize(arguments, "budget", 600), - context_limit: json_usize(arguments, "context_limit", 3), - max_depth: arguments - .get("max_depth") - .and_then(serde_json::Value::as_u64) - .map(|value| value as usize), - detail: detail.to_string(), - repo_root: options.repo_root.clone(), - config: options.config.clone(), - db: options.db.clone(), - manifest: options.manifest.clone(), - output: MetadataOutputOptions { - format: arguments - .get("output_format") - .and_then(serde_json::Value::as_str) - .unwrap_or("block") - .to_string(), - pretty: false, - help: false, - }, - }) -} - -pub(in crate::cli) fn graph_context_options_from_mcp( - arguments: &serde_json::Value, - options: &McpServeOptions, -) -> Result { - let node_id = arguments - .get("node_id") - .and_then(serde_json::Value::as_str) - .filter(|value| !value.is_empty()) - .map(str::to_string); - let node_type = arguments - .get("node_type") - .and_then(serde_json::Value::as_str) - .filter(|value| !value.is_empty()) - .map(str::to_string); - if node_id.is_some() != node_type.is_some() { - return Err( - "codebase-context explicit lookup requires both --node-id and --node-type".to_string(), - ); - } - let search = graph_search_options_from_mcp(arguments, options, node_id.is_none())?; - Ok(GraphContextOptions { - search, - node_id, - node_type, - }) -} - -pub(in crate::cli) fn json_usize( - arguments: &serde_json::Value, - key: &str, - default: usize, -) -> usize { - arguments - .get(key) - .and_then(serde_json::Value::as_u64) - .map(|value| value as usize) - .unwrap_or(default) -} - -pub(in crate::cli) fn mcp_block_text(tool_name: &str, payload: &serde_json::Value) -> String { - match tool_name { - "graph_health" => serialize_health_block(payload), - "graph_schema" => serialize_schema_block(payload), - "graph_query_helpers" => serialize_query_helpers_block(payload), - "graph_architecture_queries" => serialize_architecture_queries_block(payload), - "graph_search" => serialize_search_block(payload), - "graph_context" => { - if payload.get("context").is_some() { - serialize_context_block(payload) - } else { - serialize_search_block(payload) - } - } - "graph_query" => serialize_query_block(payload), - _ => serde_json::to_string(payload).unwrap_or_default(), - } -} diff --git a/src/cli/mod.rs b/src/cli/mod.rs deleted file mode 100644 index 997a764..0000000 --- a/src/cli/mod.rs +++ /dev/null @@ -1,17 +0,0 @@ -mod build; -mod constants; -mod dispatch; -mod format; -mod graph; -mod install; -mod mcp; -mod reinstall; -mod setup; -mod uninstall; -mod util; -mod watch; - -pub use dispatch::{error_exit_code, run, run_from_env, run_process_args}; - -#[cfg(test)] -mod tests; diff --git a/src/cli/reinstall.rs b/src/cli/reinstall.rs deleted file mode 100644 index 3c7b9cd..0000000 --- a/src/cli/reinstall.rs +++ /dev/null @@ -1,182 +0,0 @@ -use super::{ - format::reinstall_help, - setup::{setup_payload, GraphStatePaths}, - util::resolve_repo_root, - watch::SetupOptions, -}; -use serde_json::json; -use std::{ - fs, - io::Write, - path::{Path, PathBuf}, -}; - -pub(in crate::cli) fn run_reinstall( - args: &[String], - stdout: &mut W, -) -> Result<(), String> { - let options = SetupOptions::parse_with_help(args, "reinstall", reinstall_help())?; - if options.help { - writeln!(stdout, "{}", reinstall_help()).map_err(|error| error.to_string())?; - return Ok(()); - } - - let repo_root = resolve_repo_root(options.repo_root.as_deref())?; - if repo_root - .components() - .any(|component| component.as_os_str() == ".codebaseGraph") - { - return Err(format!( - "Repository root may not be inside a .codebaseGraph state directory: {}", - repo_root.display() - )); - } - - let paths = GraphStatePaths::derive(&repo_root); - let state = reinstall_state(&paths, options.dry_run)?; - let install = if options.dry_run { - setup_payload(&options)? - } else { - match setup_payload(&options) { - Ok(payload) => { - remove_backup(state.backup_path.as_deref())?; - payload - } - Err(error) => { - restore_backup(&paths.state_dir, state.backup_path.as_deref())?; - return Err(error); - } - } - }; - - let output = json!({ - "ok": true, - "repo_root": repo_root, - "dry_run": options.dry_run, - "state": state.payload, - "install": install, - }); - writeln!( - stdout, - "{}", - serde_json::to_string_pretty(&output).map_err(|error| error.to_string())? - ) - .map_err(|error| error.to_string())?; - Ok(()) -} - -struct ReinstallState { - payload: serde_json::Value, - backup_path: Option, -} - -fn reinstall_state(paths: &GraphStatePaths, dry_run: bool) -> Result { - if !paths.state_dir.exists() { - return Ok(ReinstallState { - payload: json!({ - "action": "unchanged", - "path": paths.state_dir, - "backup_path": serde_json::Value::Null, - }), - backup_path: None, - }); - } - let backup_path = next_backup_path(&paths.state_dir)?; - if dry_run { - return Ok(ReinstallState { - payload: json!({ - "action": "dry_run", - "path": paths.state_dir, - "backup_path": backup_path, - }), - backup_path: None, - }); - } - fs::rename(&paths.state_dir, &backup_path).map_err(|error| { - format!( - "failed to move existing graph state {} to {}: {error}", - paths.state_dir.display(), - backup_path.display() - ) - })?; - Ok(ReinstallState { - payload: json!({ - "action": "backed_up", - "path": paths.state_dir, - "backup_path": backup_path, - }), - backup_path: Some(backup_path), - }) -} - -fn next_backup_path(state_dir: &Path) -> Result { - let parent = state_dir.parent().unwrap_or_else(|| Path::new(".")); - let file_name = state_dir - .file_name() - .and_then(|value| value.to_str()) - .unwrap_or(".codebaseGraph"); - for index in 0..1000 { - let suffix = if index == 0 { - "reinstall-backup".to_string() - } else { - format!("reinstall-backup-{index}") - }; - let candidate = parent.join(format!("{file_name}.{suffix}")); - if !candidate.exists() { - return Ok(candidate); - } - } - Err(format!( - "failed to choose backup path for {}", - state_dir.display() - )) -} - -fn remove_backup(path: Option<&Path>) -> Result<(), String> { - let Some(path) = path else { - return Ok(()); - }; - remove_path(path).map_err(|error| { - format!( - "failed to remove reinstall backup {} after successful setup: {error}", - path.display() - ) - }) -} - -fn restore_backup(state_dir: &Path, backup_path: Option<&Path>) -> Result<(), String> { - let Some(backup_path) = backup_path else { - if state_dir.exists() { - remove_path(state_dir).map_err(|error| { - format!( - "failed to remove partial graph state {} after setup failure: {error}", - state_dir.display() - ) - })?; - } - return Ok(()); - }; - if state_dir.exists() { - remove_path(state_dir).map_err(|error| { - format!( - "failed to remove partial graph state {} before restore: {error}", - state_dir.display() - ) - })?; - } - fs::rename(backup_path, state_dir).map_err(|error| { - format!( - "failed to restore graph state backup {} to {}: {error}", - backup_path.display(), - state_dir.display() - ) - }) -} - -fn remove_path(path: &Path) -> std::io::Result<()> { - if path.is_dir() { - fs::remove_dir_all(path) - } else { - fs::remove_file(path) - } -} diff --git a/src/cli/setup.rs b/src/cli/setup.rs deleted file mode 100644 index 95a82bd..0000000 --- a/src/cli/setup.rs +++ /dev/null @@ -1,476 +0,0 @@ -use super::{ - build::{ - build_request, dry_run_materialization_payload, materialization_payload, materialize, - MaterializeOptions, - }, - constants::server_command, - format::setup_help, - install::{build_mcp_descriptor, install_mcp_client, McpInstallOptions}, - util::{read_json_file, resolve_repo_root, restore_file, snapshot_file}, - watch::SetupOptions, -}; -use serde_json::json; -use std::{ - fs, - io::Write, - path::{Path, PathBuf}, -}; - -pub(super) fn run_setup(args: &[String], stdout: &mut W) -> Result<(), String> { - let options = SetupOptions::parse(args)?; - if options.help { - writeln!(stdout, "{}", setup_help()).map_err(|error| error.to_string())?; - return Ok(()); - } - let output = setup_payload(&options)?; - writeln!( - stdout, - "{}", - serde_json::to_string_pretty(&output).map_err(|error| error.to_string())? - ) - .map_err(|error| error.to_string())?; - Ok(()) -} - -pub(in crate::cli) fn setup_payload(options: &SetupOptions) -> Result { - let source_root = resolve_repo_root(options.repo_root.as_deref())?; - let paths = GraphStatePaths::derive(&source_root); - if source_root - .components() - .any(|component| component.as_os_str() == ".codebaseGraph") - { - return Err(format!( - "Repository root may not be inside a .codebaseGraph state directory: {}", - source_root.display() - )); - } - let materialize_options = MaterializeOptions { - source_root: Some(source_root.clone()), - db: Some(paths.db_path.clone()), - manifest: Some(paths.manifest_path.clone()), - mode: options.mode.clone(), - include_fts: options.include_fts, - semantic_enrichment: options.semantic_enrichment, - semantic_provider_mode: options.semantic_provider_mode.clone(), - use_git: true, - ..MaterializeOptions::default() - }; - let config_payload = setup_config_payload(&paths, &source_root); - let instructions_path = instruction_target_path(&source_root, &options.instructions_target)?; - let state_dir_existed = paths.state_dir.exists(); - let graph_state_existed = - paths.config_path.exists() && paths.db_path.exists() && paths.manifest_path.exists(); - let previous_config = snapshot_file(&paths.config_path)?; - let previous_instructions = match instructions_path.as_ref() { - Some(path) => Some((path.clone(), snapshot_file(path)?)), - None => None, - }; - let (config_action, instructions, mcp_config, materialization) = if options.dry_run { - let request = build_request(&materialize_options)?; - let materialization = dry_run_materialization_payload(&request, &paths); - let config_action = if json_file_would_change(&paths.config_path, &config_payload)? { - "dry_run" - } else { - "unchanged" - }; - let instructions = json!({ - "action": if instructions_path.is_some() { "dry_run" } else { "skipped" }, - "path": instructions_path.as_ref().map(|path| path.to_string_lossy().to_string()), - }); - let mcp_config = setup_mcp_config(options, &paths, true)?; - ( - config_action.to_string(), - instructions, - mcp_config, - materialization, - ) - } else { - fs::create_dir_all(&paths.state_dir).map_err(|error| { - format!( - "failed to create state directory {}: {error}", - paths.state_dir.display() - ) - })?; - let result = (|| { - let config_action = write_setup_config(&paths, &source_root)?; - let instructions = upsert_instruction_block( - &source_root, - &options.instructions_target, - &paths.config_path, - )?; - let materialization = if graph_state_existed { - existing_graph_materialization_payload(&materialize_options.mode, &paths) - } else { - let (_, response) = materialize(&materialize_options)?; - materialization_payload(&response, &materialize_options.mode, &paths) - }; - let mcp_config = setup_mcp_config(options, &paths, false)?; - Ok::<_, String>(( - config_action.to_string(), - instructions, - mcp_config, - materialization, - )) - })(); - match result { - Ok(result) => result, - Err(error) => { - restore_file(&paths.config_path, previous_config.as_deref())?; - if let Some((path, previous)) = previous_instructions.as_ref() { - restore_file(path, previous.as_deref())?; - } - if !state_dir_existed { - let _ = fs::remove_dir_all(&paths.state_dir); - } - return Err(error); - } - } - }; - Ok(json!({ - "ok": true, - "repo_root": source_root, - "repo_name": paths.repo_name, - "state_dir": paths.state_dir, - "db_path": paths.db_path, - "database_path": paths.db_path, - "manifest_path": paths.manifest_path, - "config_path": paths.config_path, - "config_action": config_action, - "mcp_config": mcp_config, - "instructions": instructions, - "materialization": materialization, - "database_written": materialization.get("database_written").cloned().unwrap_or(json!(false)), - "skipped": materialization.get("skipped").cloned().unwrap_or(json!(0)), - "node_rows": materialization.get("node_rows").cloned().unwrap_or(json!(0)), - "edge_rows": materialization.get("edge_rows").cloned().unwrap_or(json!(0)), - "connector_rows": materialization.get("connector_rows").cloned().unwrap_or(json!(0)), - "diagnostics": materialization.get("diagnostics").cloned().unwrap_or(json!([])), - })) -} - -fn existing_graph_materialization_payload( - mode: &str, - paths: &GraphStatePaths, -) -> serde_json::Value { - json!({ - "mode": mode, - "database_path": paths.db_path, - "manifest_path": paths.manifest_path, - "database_written": false, - "skipped": true, - "skip_reason": "existing_graph_state", - "rebuilt": 0, - "deleted": 0, - "node_rows": 0, - "edge_rows": 0, - "connector_rows": 0, - "diagnostics": [], - "phase_timings": {}, - }) -} -pub(super) fn setup_mcp_config( - options: &SetupOptions, - paths: &GraphStatePaths, - dry_run: bool, -) -> Result { - let descriptor = build_mcp_descriptor(&McpInstallOptions { - client: "generic".to_string(), - scope: "local".to_string(), - name: Some("codebase_graph".to_string()), - config_path: Some(paths.config_path.clone()), - client_config_path: None, - repo_root: Some( - paths - .state_dir - .parent() - .map(Path::to_path_buf) - .unwrap_or_else(|| PathBuf::from(".")), - ), - dry_run: true, - verify: false, - json: true, - help: false, - })?; - if options.skip_mcp_config || options.mcp_client == "none" { - return Ok(json!({ - "action": "skipped", - "client": options.mcp_client, - "scope": "local", - "server_name": descriptor.name, - "method": serde_json::Value::Null, - "path": serde_json::Value::Null, - "command": serde_json::Value::Null, - "descriptor": descriptor.as_json(), - "entry": descriptor.stdio_entry(false, true), - })); - } - install_mcp_client(&McpInstallOptions { - client: options.mcp_client.clone(), - scope: if options.mcp_client == "claude-project" { - "project".to_string() - } else { - "local".to_string() - }, - name: Some("codebase_graph".to_string()), - config_path: Some(paths.config_path.clone()), - client_config_path: options.mcp_config_path.clone(), - repo_root: Some( - paths - .state_dir - .parent() - .map(Path::to_path_buf) - .unwrap_or_else(|| PathBuf::from(".")), - ), - dry_run, - verify: false, - json: true, - help: false, - }) -} -pub(super) fn setup_config_payload(paths: &GraphStatePaths, repo_root: &Path) -> serde_json::Value { - json!({ - "schema_version": 1, - "repo_root": repo_root, - "repo_name": paths.repo_name, - "state_dir": paths.state_dir, - "database_path": paths.db_path, - "manifest_path": paths.manifest_path, - "ontology_version": "code_ontology_v1", - "package_version": env!("CARGO_PKG_VERSION"), - "materialization": { - "include": [], - "exclude": [] - }, - "mcp": { - "server_name": "codebase_graph", - "command": [ - server_command(), - "mcp", - "start", - "--config", - paths.config_path.to_string_lossy() - ] - } - }) -} - -pub(super) fn write_setup_config( - paths: &GraphStatePaths, - repo_root: &Path, -) -> Result<&'static str, String> { - let payload = setup_config_payload(paths, repo_root); - let mut action = "created"; - if paths.config_path.exists() { - let previous = read_json_file(&paths.config_path)?; - if previous == payload { - return Ok("unchanged"); - } - action = "updated"; - } - if let Some(parent) = paths.config_path.parent() { - fs::create_dir_all(parent).map_err(|error| { - format!( - "failed to create config directory {}: {error}", - parent.display() - ) - })?; - } - let text = serde_json::to_string_pretty(&payload).map_err(|error| error.to_string())?; - fs::write(&paths.config_path, format!("{text}\n")).map_err(|error| { - format!( - "failed to write install config {}: {error}", - paths.config_path.display() - ) - })?; - Ok(action) -} - -pub(super) fn json_file_would_change( - path: &Path, - payload: &serde_json::Value, -) -> Result { - if !path.exists() { - return Ok(true); - } - Ok(read_json_file(path)? != *payload) -} - -pub(super) fn instruction_target_path( - repo_root: &Path, - target: &str, -) -> Result, String> { - match target { - "skip" => Ok(None), - "agents" => Ok(Some(repo_root.join("AGENTS.md"))), - "claude" => Ok(Some(repo_root.join("CLAUDE.md"))), - "auto" => { - let agents = repo_root.join("AGENTS.md"); - if agents.exists() { - return Ok(Some(agents)); - } - let claude = repo_root.join("CLAUDE.md"); - if claude.exists() { - return Ok(Some(claude)); - } - Ok(Some(agents)) - } - other => Err(format!("Unsupported instruction target: {other}")), - } -} - -pub(super) fn upsert_instruction_block( - repo_root: &Path, - target: &str, - config_path: &Path, -) -> Result { - let Some(path) = instruction_target_path(repo_root, target)? else { - return Ok(json!({"action": "skipped", "path": serde_json::Value::Null})); - }; - let existing = fs::read_to_string(&path).unwrap_or_default(); - let created = !path.exists(); - let block = instruction_block(config_path); - let (next_text, action) = upsert_instruction_text(&existing, &block, created); - if next_text == existing { - return Ok(json!({"action": "unchanged", "path": path.to_string_lossy()})); - } - if let Some(parent) = path.parent() { - fs::create_dir_all(parent).map_err(|error| { - format!( - "failed to create instruction directory {}: {error}", - parent.display() - ) - })?; - } - fs::write(&path, next_text) - .map_err(|error| format!("failed to write instructions {}: {error}", path.display()))?; - Ok(json!({"action": action, "path": path.to_string_lossy()})) -} - -pub(super) fn instruction_block(config_path: &Path) -> String { - format!( - "\n\ -## codebaseGraph workflow\n\ -- Treat the repo-local `.codebaseGraph` graph as the project operating source of truth. It is prohibited to read the code source before you find the target files using the graph.\n\ -- Prefer the `codebase_graph` MCP server tools over shell commands whenever they are exposed in the current agent session.\n\ -- AI agents receive block output by default for graph CLI and MCP tools; request `output_format: \"json\"` or `include_structured_content: true` only for tests, APIs, or explicit structured-payload debugging.\n\ -- Use MCP `graph_search` with `detail: \"slim\"` and `context_limit: 1` before answering repo-structure questions or performing coding tasks.\n\ -- Use MCP `graph_context` with `profile: \"\"`, `detail: \"slim\"`, and `context_limit: 2` when relationships or nearby evidence matter; useful profiles include `definitions`, `dependencies`, `callgraph`, `docs`, `runtime`, and `change_impact`.\n\ -- For architecture orientation, use MCP `graph_architecture_queries`, then execute selected read-only statements with MCP `graph_query`.\n\ -- Use MCP `graph_schema` or `graph_query_helpers` before writing raw graph queries, and keep `graph_query` read-only.\n\ -- If MCP tools are unavailable, fall back to CLI: `{command} codebase-search --no-refresh --detail slim --context-limit 1`, `{command} codebase-context --profile --no-refresh --detail slim --context-limit 2`, `{command} codebase-architecture-queries`, `{command} graph-query \"\"`, `{command} schema`, and `{command} query-helpers`.\n\ -- Do not rerun install to refresh the graph. The MCP server started from this setup config watches the repo and refreshes automatically; use `{command} build --mode full` only for explicit manual rebuilds. Setup config: `{config_path}`.\n\ -\n", - command = server_command(), - config_path = config_path.to_string_lossy(), - ) -} - -pub(super) fn upsert_instruction_text( - existing: &str, - block: &str, - created: bool, -) -> (String, &'static str) { - const START: &str = ""; - const END: &str = ""; - if existing.trim().is_empty() { - return (block.to_string(), "created"); - } - let Some(start) = existing.find(START) else { - let separator = if existing.ends_with('\n') { "" } else { "\n" }; - let action = if created { "created" } else { "updated" }; - return ( - format!("{}{separator}\n{}", existing.trim_end(), block), - action, - ); - }; - let Some(end) = existing.find(END) else { - return ( - format!("{}\n\n{}", existing.trim_end(), block), - if created { "created" } else { "updated" }, - ); - }; - if end < start { - return ( - format!("{}\n\n{}", existing.trim_end(), block), - if created { "created" } else { "updated" }, - ); - } - let after_end = end + END.len(); - let text = format!( - "{}\n\n{}\n\n{}", - existing[..start].trim_end(), - block.trim_end(), - existing[after_end..].trim_start() - ) - .trim() - .to_string() - + "\n"; - (text, "updated") -} - -pub(super) fn remove_instruction_text(existing: &str) -> (String, bool) { - const START: &str = ""; - const END: &str = ""; - let Some(start) = existing.find(START) else { - return (existing.to_string(), false); - }; - let Some(end) = existing[start..].find(END).map(|index| start + index) else { - return (existing.to_string(), false); - }; - let after_end = end + END.len(); - let before = existing[..start].trim_end(); - let after = existing[after_end..].trim_start(); - let text = match (before.is_empty(), after.is_empty()) { - (true, true) => String::new(), - (true, false) => format!("{after}\n"), - (false, true) => format!("{before}\n"), - (false, false) => format!("{before}\n\n{after}"), - }; - (text, true) -} -#[derive(Debug)] -pub(super) struct GraphStatePaths { - pub(super) repo_name: String, - pub(super) state_dir: PathBuf, - pub(super) db_path: PathBuf, - pub(super) manifest_path: PathBuf, - pub(super) config_path: PathBuf, -} - -impl GraphStatePaths { - pub(super) fn derive(repo_root: &Path) -> Self { - let repo_name = safe_name( - repo_root - .file_name() - .and_then(|value| value.to_str()) - .unwrap_or("repository"), - ); - let state_dir = repo_root.join(".codebaseGraph"); - Self { - db_path: state_dir.join(format!("{repo_name}_graph.ldb")), - manifest_path: state_dir.join("manifest.json"), - config_path: state_dir.join("config.json"), - state_dir, - repo_name, - } - } -} - -pub(super) fn safe_name(value: &str) -> String { - let normalized: String = value - .chars() - .map(|character| { - if character.is_alphanumeric() || character == '-' || character == '_' { - character - } else { - '_' - } - }) - .collect(); - let trimmed = normalized.trim_matches(['.', '_', '-']); - if trimmed.is_empty() { - "repository".to_string() - } else { - trimmed.to_string() - } -} diff --git a/src/cli/uninstall.rs b/src/cli/uninstall.rs deleted file mode 100644 index e1957f7..0000000 --- a/src/cli/uninstall.rs +++ /dev/null @@ -1,331 +0,0 @@ -use super::{ - format::uninstall_help, - install::{ - build_mcp_descriptor, default_client_config_path, install_scope, remove_client_config, - supported_install_clients, supported_install_clients_with_all, write_text_atomic, - McpInstallOptions, - }, - setup::{remove_instruction_text, GraphStatePaths}, - util::{read_json_file, required_arg, resolve_repo_root}, -}; -use serde_json::json; -use std::{ - fs, - io::Write, - path::{Path, PathBuf}, -}; - -#[derive(Debug)] -pub(in crate::cli) struct UninstallOptions { - repo_root: Option, - config: Option, - mcp_client: String, - client_config_path: Option, - dry_run: bool, - json: bool, - help: bool, -} - -impl UninstallOptions { - fn parse(args: &[String]) -> Result { - let mut options = Self { - repo_root: None, - config: None, - mcp_client: "all".to_string(), - client_config_path: None, - dry_run: false, - json: false, - help: false, - }; - let mut index = 0; - while index < args.len() { - match args[index].as_str() { - "-h" | "--help" => { - options.help = true; - index += 1; - } - "--repo-root" | "--source-root" => { - options.repo_root = - Some(PathBuf::from(required_arg(args, index, "--repo-root")?)); - index += 2; - } - "--config" => { - options.config = Some(PathBuf::from(required_arg(args, index, "--config")?)); - index += 2; - } - "--mcp-client" => { - let client = required_arg(args, index, "--mcp-client")?; - if client != "all" && !supported_install_clients().contains(&client) { - return Err(format!( - "--mcp-client must be one of {}", - supported_install_clients_with_all().join(", ") - )); - } - options.mcp_client = client.to_string(); - index += 2; - } - "--client-config-path" => { - options.client_config_path = Some(PathBuf::from(required_arg( - args, - index, - "--client-config-path", - )?)); - index += 2; - } - "--dry-run" => { - options.dry_run = true; - index += 1; - } - "--json" => { - options.json = true; - index += 1; - } - other => { - return Err(format!( - "unknown uninstall option: {other}\n\n{}", - uninstall_help() - )); - } - } - } - if options.client_config_path.is_some() && options.mcp_client == "all" { - return Err("--client-config-path requires --mcp-client ".to_string()); - } - Ok(options) - } -} - -pub(in crate::cli) fn run_uninstall( - args: &[String], - stdout: &mut W, -) -> Result<(), String> { - let options = UninstallOptions::parse(args)?; - if options.help { - writeln!(stdout, "{}", uninstall_help()).map_err(|error| error.to_string())?; - return Ok(()); - } - let repo_root = resolve_repo_root(options.repo_root.as_deref())?; - let paths = GraphStatePaths::derive(&repo_root); - let config_path = options - .config - .clone() - .unwrap_or_else(|| paths.config_path.clone()); - let server_name = uninstall_server_name(&repo_root, &config_path)?; - let state = uninstall_state_dir(&paths.state_dir, options.dry_run)?; - let instructions = uninstall_instruction_blocks(&repo_root, options.dry_run)?; - let mcp_clients = uninstall_mcp_clients(&options, &repo_root, &config_path, &server_name)?; - let output = json!({ - "ok": true, - "repo_root": repo_root, - "config_path": config_path, - "server_name": server_name, - "dry_run": options.dry_run, - "state": state, - "instructions": instructions, - "mcp_clients": mcp_clients, - }); - if options.json { - writeln!( - stdout, - "{}", - serde_json::to_string_pretty(&output).map_err(|error| error.to_string())? - ) - .map_err(|error| error.to_string())?; - } else { - write!(stdout, "{}", serialize_uninstall_block(&output)) - .map_err(|error| error.to_string())?; - } - Ok(()) -} - -fn uninstall_server_name(repo_root: &Path, config_path: &Path) -> Result { - if config_path.exists() { - let config = read_json_file(config_path)?; - if let Some(name) = config - .pointer("/mcp/server_name") - .and_then(serde_json::Value::as_str) - .filter(|value| !value.trim().is_empty()) - { - return Ok(name.to_string()); - } - } - Ok(build_mcp_descriptor(&McpInstallOptions { - client: "generic".to_string(), - scope: "local".to_string(), - name: None, - config_path: Some(config_path.to_path_buf()), - client_config_path: None, - repo_root: Some(repo_root.to_path_buf()), - dry_run: true, - verify: false, - json: true, - help: false, - })? - .name) -} - -fn uninstall_state_dir(path: &Path, dry_run: bool) -> Result { - if !path.exists() { - return Ok(json!({"action": "unchanged", "path": path})); - } - if !dry_run { - fs::remove_dir_all(path).map_err(|error| { - format!( - "failed to remove state directory {}: {error}", - path.display() - ) - })?; - } - Ok(json!({"action": if dry_run { "dry_run" } else { "removed" }, "path": path})) -} - -fn uninstall_instruction_blocks( - repo_root: &Path, - dry_run: bool, -) -> Result, String> { - ["AGENTS.md", "CLAUDE.md"] - .into_iter() - .map(|file_name| uninstall_instruction_file(&repo_root.join(file_name), dry_run)) - .collect() -} - -fn uninstall_instruction_file(path: &Path, dry_run: bool) -> Result { - let Ok(existing) = fs::read_to_string(path) else { - return Ok(json!({"action": "unchanged", "path": path})); - }; - let (next, removed) = remove_instruction_text(&existing); - if !removed { - return Ok(json!({"action": "unchanged", "path": path})); - } - if !dry_run { - fs::write(path, next).map_err(|error| { - format!("failed to update instructions {}: {error}", path.display()) - })?; - } - Ok(json!({"action": if dry_run { "dry_run" } else { "removed" }, "path": path})) -} - -fn uninstall_mcp_clients( - options: &UninstallOptions, - repo_root: &Path, - config_path: &Path, - server_name: &str, -) -> Result, String> { - let clients = if options.mcp_client == "all" { - supported_install_clients() - .into_iter() - .map(str::to_string) - .collect::>() - } else { - vec![options.mcp_client.clone()] - }; - Ok(clients - .into_iter() - .map(|client| { - uninstall_mcp_client(&client, options, repo_root, config_path, server_name) - .unwrap_or_else(|error| { - json!({ - "action": "failed", - "client": client, - "server_name": server_name, - "error": error, - }) - }) - }) - .collect()) -} - -fn uninstall_mcp_client( - client: &str, - options: &UninstallOptions, - repo_root: &Path, - config_path: &Path, - server_name: &str, -) -> Result { - if matches!(client, "copilot-studio" | "microsoft-copilot") { - return Ok(json!({ - "action": "skipped", - "reason": "manual_metadata", - "client": client, - "server_name": server_name, - })); - } - let scope = if client == "claude-project" { - "project" - } else { - "local" - }; - let descriptor = build_mcp_descriptor(&McpInstallOptions { - client: client.to_string(), - scope: scope.to_string(), - name: Some(server_name.to_string()), - config_path: Some(config_path.to_path_buf()), - client_config_path: options.client_config_path.clone(), - repo_root: Some(repo_root.to_path_buf()), - dry_run: true, - verify: false, - json: true, - help: false, - })?; - let scope = install_scope(client, scope); - let path = options - .client_config_path - .clone() - .unwrap_or_else(|| default_client_config_path(client, &scope, &descriptor)); - let existing = fs::read_to_string(&path).ok(); - let removed = remove_client_config(client, &scope, existing.as_deref(), server_name)?; - if removed.action == "removed" && !options.dry_run { - write_text_atomic(&path, &removed.text)?; - } - let action = if removed.action == "removed" && options.dry_run { - "dry_run".to_string() - } else { - removed.action - }; - Ok(json!({ - "action": action, - "client": client, - "scope": scope, - "server_name": server_name, - "path": path, - "previous": removed.previous, - "payload": removed.payload, - })) -} - -fn serialize_uninstall_block(output: &serde_json::Value) -> String { - let mut lines = vec![format!( - "uninstall ok={} server_name={}", - output["ok"].as_bool().unwrap_or(false), - output["server_name"].as_str().unwrap_or_default() - )]; - if let Some(state) = output["state"].as_object() { - lines.push(format!( - "state action={} path={}", - state - .get("action") - .and_then(serde_json::Value::as_str) - .unwrap_or("unknown"), - state - .get("path") - .and_then(serde_json::Value::as_str) - .unwrap_or_default() - )); - } - for item in output["instructions"].as_array().into_iter().flatten() { - lines.push(format!( - "instructions action={} path={}", - item["action"].as_str().unwrap_or("unknown"), - item["path"].as_str().unwrap_or_default() - )); - } - for item in output["mcp_clients"].as_array().into_iter().flatten() { - lines.push(format!( - "mcp client={} action={} path={}", - item["client"].as_str().unwrap_or("unknown"), - item["action"].as_str().unwrap_or("unknown"), - item["path"].as_str().unwrap_or_default() - )); - } - lines.join("\n") + "\n" -} diff --git a/src/cli/util.rs b/src/cli/util.rs deleted file mode 100644 index 519d443..0000000 --- a/src/cli/util.rs +++ /dev/null @@ -1,207 +0,0 @@ -use std::{ - env, fs, io, - path::{Path, PathBuf}, -}; - -pub(super) fn snapshot_file(path: &Path) -> Result, String> { - if !path.exists() { - return Ok(None); - } - fs::read_to_string(path) - .map(Some) - .map_err(|error| format!("failed to snapshot {}: {error}", path.display())) -} - -pub(super) fn restore_file(path: &Path, previous: Option<&str>) -> Result<(), String> { - match previous { - Some(text) => { - if let Some(parent) = path.parent() { - fs::create_dir_all(parent).map_err(|error| { - format!("failed to restore directory {}: {error}", parent.display()) - })?; - } - fs::write(path, text) - .map_err(|error| format!("failed to restore {}: {error}", path.display())) - } - None => match fs::remove_file(path) { - Ok(()) => Ok(()), - Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(()), - Err(error) => Err(format!("failed to remove {}: {error}", path.display())), - }, - } -} - -pub(super) fn read_json_file(path: &Path) -> Result { - let text = fs::read_to_string(path) - .map_err(|error| format!("failed to read JSON file {}: {error}", path.display()))?; - serde_json::from_str(&text) - .map_err(|error| format!("failed to parse JSON file {}: {error}", path.display())) -} - -pub(super) fn resolve_repo_root(explicit: Option<&Path>) -> Result { - resolve_root(explicit, "repo root") -} - -pub(super) fn resolve_source_root(explicit: Option<&Path>) -> Result { - resolve_root(explicit, "source root") -} - -fn resolve_root(explicit: Option<&Path>, label: &str) -> Result { - if let Some(path) = explicit { - return path - .canonicalize() - .map_err(|error| format!("failed to resolve {label}: {error}")); - } - - let current_dir = - env::current_dir().map_err(|error| format!("failed to read current directory: {error}"))?; - if let Some(root) = discover_codebase_graph_root(¤t_dir)? { - return Ok(root); - } - if let Some(root) = discover_git_root(¤t_dir) { - return Ok(root); - } - Ok(current_dir) -} - -fn discover_codebase_graph_root(start: &Path) -> Result, String> { - for ancestor in start.ancestors() { - let config_path = ancestor.join(".codebaseGraph").join("config.json"); - if !config_path.exists() { - continue; - } - let config = read_json_file(&config_path)?; - if let Some(repo_root) = config.get("repo_root").and_then(serde_json::Value::as_str) { - let path = PathBuf::from(repo_root); - return Ok(Some(path.canonicalize().unwrap_or(path))); - } - return Ok(Some(ancestor.to_path_buf())); - } - Ok(None) -} - -fn discover_git_root(start: &Path) -> Option { - start - .ancestors() - .find(|ancestor| ancestor.join(".git").exists()) - .map(Path::to_path_buf) -} - -pub(super) fn required_arg<'a>( - args: &'a [String], - index: usize, - name: &str, -) -> Result<&'a str, String> { - args.get(index + 1) - .map(String::as_str) - .ok_or_else(|| format!("{name} requires a value")) -} - -pub(super) fn parse_usize_arg(args: &[String], index: usize, name: &str) -> Result { - required_arg(args, index, name)? - .parse::() - .map_err(|error| format!("{name} must be an integer: {error}")) -} - -#[cfg(test)] -mod tests { - use super::*; - - fn unique_temp_dir(prefix: &str) -> PathBuf { - env::temp_dir().join(format!( - "{prefix}-{}-{}", - std::process::id(), - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap() - .as_nanos() - )) - } - - #[test] - fn resolve_repo_root_uses_explicit_path() { - let root = unique_temp_dir("codebase-graph-explicit-root"); - let nested = root.join("nested"); - fs::create_dir_all(&nested).unwrap(); - - let resolved = resolve_repo_root(Some(&nested)).unwrap(); - - assert_eq!(resolved, nested.canonicalize().unwrap()); - let _ = fs::remove_dir_all(root); - } - - #[test] - fn resolve_root_finds_codebase_graph_config_ancestor() { - let root = unique_temp_dir("codebase-graph-config-root"); - let nested = root.join("src").join("cli"); - fs::create_dir_all(root.join(".codebaseGraph")).unwrap(); - fs::create_dir_all(&nested).unwrap(); - fs::write(root.join(".codebaseGraph").join("config.json"), "{}").unwrap(); - - let resolved = resolve_root_from(&nested).unwrap(); - - assert_eq!(resolved, root.canonicalize().unwrap()); - let _ = fs::remove_dir_all(root); - } - - #[test] - fn resolve_root_uses_repo_root_from_config() { - let root = unique_temp_dir("codebase-graph-config-repo-root"); - let real_root = root.join("real"); - let command_root = root.join("command"); - let nested = command_root.join("src"); - fs::create_dir_all(&real_root).unwrap(); - fs::create_dir_all(command_root.join(".codebaseGraph")).unwrap(); - fs::create_dir_all(&nested).unwrap(); - fs::write( - command_root.join(".codebaseGraph").join("config.json"), - serde_json::to_string(&serde_json::json!({ - "repo_root": real_root - })) - .unwrap(), - ) - .unwrap(); - - let resolved = resolve_root_from(&nested).unwrap(); - - assert_eq!(resolved, real_root.canonicalize().unwrap()); - let _ = fs::remove_dir_all(root); - } - - #[test] - fn resolve_root_falls_back_to_git_ancestor() { - let root = unique_temp_dir("codebase-graph-git-root"); - let nested = root.join("src").join("cli"); - fs::create_dir_all(root.join(".git")).unwrap(); - fs::create_dir_all(&nested).unwrap(); - - let resolved = resolve_root_from(&nested).unwrap(); - - assert_eq!(resolved, root.canonicalize().unwrap()); - let _ = fs::remove_dir_all(root); - } - - #[test] - fn resolve_root_falls_back_to_start_directory() { - let root = unique_temp_dir("codebase-graph-current-root"); - fs::create_dir_all(&root).unwrap(); - - let resolved = resolve_root_from(&root).unwrap(); - - assert_eq!(resolved, root.canonicalize().unwrap()); - let _ = fs::remove_dir_all(root); - } - - fn resolve_root_from(start: &Path) -> Result { - let start = start - .canonicalize() - .map_err(|error| format!("failed to resolve test root: {error}"))?; - if let Some(root) = discover_codebase_graph_root(&start)? { - return Ok(root); - } - if let Some(root) = discover_git_root(&start) { - return Ok(root); - } - Ok(start) - } -} diff --git a/src/cli/watch/batch.rs b/src/cli/watch/batch.rs deleted file mode 100644 index b8fb2ca..0000000 --- a/src/cli/watch/batch.rs +++ /dev/null @@ -1,76 +0,0 @@ -use super::{ - types::{WatchChangeBatch, WatchMessage}, - WatchEventFilter, -}; -use std::{ - collections::VecDeque, - sync::mpsc::{self, Receiver}, - time::{Duration, Instant}, -}; - -pub(in crate::cli) fn collect_watch_batch( - first: WatchMessage, - rx: &Receiver, - queued: &mut VecDeque, - filter: &WatchEventFilter, - debounce: Duration, - max_wait: Duration, -) -> Result, String> { - let mut batch = WatchChangeBatch::default(); - apply_watch_message(first, filter, &mut batch)?; - if batch.paths.is_empty() { - return Ok(None); - } - - let started = Instant::now(); - let mut last_relevant = started; - loop { - let elapsed = started.elapsed(); - if elapsed >= max_wait { - return Ok(Some(batch)); - } - let quiet_elapsed = last_relevant.elapsed(); - if quiet_elapsed >= debounce { - return Ok(Some(batch)); - } - let timeout = debounce - .saturating_sub(quiet_elapsed) - .min(max_wait.saturating_sub(elapsed)); - let message = match queued.pop_front() { - Some(message) => Ok(message), - None => rx.recv_timeout(timeout), - }; - match message { - Ok(message) => { - let before = batch.paths.len(); - let before_events = batch.event_count; - apply_watch_message(message, filter, &mut batch)?; - if batch.paths.len() != before || batch.event_count != before_events { - last_relevant = Instant::now(); - } - } - Err(mpsc::RecvTimeoutError::Timeout) => return Ok(Some(batch)), - Err(mpsc::RecvTimeoutError::Disconnected) => { - return Err("filesystem watcher stopped".to_string()) - } - } - } -} - -pub(in crate::cli) fn apply_watch_message( - message: WatchMessage, - filter: &WatchEventFilter, - batch: &mut WatchChangeBatch, -) -> Result<(), String> { - match message { - WatchMessage::Event(event) => { - let paths = filter.relevant_paths(&event); - if !paths.is_empty() { - batch.event_count += 1; - batch.paths.extend(paths); - } - Ok(()) - } - WatchMessage::Error(error) => Err(format!("filesystem watcher error: {error}")), - } -} diff --git a/src/cli/watch/command.rs b/src/cli/watch/command.rs deleted file mode 100644 index 6ae892c..0000000 --- a/src/cli/watch/command.rs +++ /dev/null @@ -1,73 +0,0 @@ -use super::{ - filter::WatchEventFilter, - native::{probe_native_watcher, run_native_watch, start_native_watcher}, - options::{WatchBackend, WatchLoopConfig, WatchOptions}, - output::{write_watch_event, write_watch_status}, - poll::run_poll_watch, -}; -use crate::cli::{build::materialize, format::watch_help, util::resolve_source_root}; -use std::{collections::VecDeque, io::Write}; - -pub(in crate::cli) fn run_watch(args: &[String], stdout: &mut W) -> Result<(), String> { - let options = WatchOptions::parse(args)?; - if options.help { - writeln!(stdout, "{}", watch_help()).map_err(|error| error.to_string())?; - return Ok(()); - } - let backend = options.backend; - let loop_config = WatchLoopConfig { - poll_ms: options.poll_ms, - debounce_ms: options.debounce_ms, - max_iterations: options.max_iterations, - }; - let once = options.once; - let mut materialize_options = options.materialize; - let source_root = resolve_source_root(materialize_options.source_root.as_deref())?; - materialize_options.source_root = Some(source_root.clone()); - let filter = WatchEventFilter::from_options(&source_root, &materialize_options)?; - if once { - let (_, response) = materialize(&materialize_options)?; - write_watch_event(stdout, "refreshed", None, 0, 0, &response)?; - return Ok(()); - } - match backend { - WatchBackend::Poll => run_poll_watch(stdout, loop_config, &materialize_options, &filter), - WatchBackend::Native => { - let (watcher, rx) = start_native_watcher(&source_root)?; - run_native_watch( - stdout, - loop_config, - &materialize_options, - &filter, - watcher, - rx, - VecDeque::new(), - ) - } - WatchBackend::Auto => match start_native_watcher(&source_root) { - Ok((watcher, rx)) => { - let probe = probe_native_watcher(&source_root, &filter, &rx)?; - if probe.delivered { - run_native_watch( - stdout, - loop_config, - &materialize_options, - &filter, - watcher, - rx, - probe.queued, - ) - } else { - drop(watcher); - write_watch_status(stdout, "fallback", "poll", probe.reason.as_deref())?; - run_poll_watch(stdout, loop_config, &materialize_options, &filter) - } - } - Err(error) => { - write_watch_status(stdout, "fallback", "poll", Some("watcher_start_failed"))?; - let _ = error; - run_poll_watch(stdout, loop_config, &materialize_options, &filter) - } - }, - } -} diff --git a/src/cli/watch/filter.rs b/src/cli/watch/filter.rs deleted file mode 100644 index 2e724c0..0000000 --- a/src/cli/watch/filter.rs +++ /dev/null @@ -1,135 +0,0 @@ -use super::helpers::watch_matches_any_pattern; -use crate::cli::{ - build::{ - default_excluded_parts, read_codebase_graph_ignore, read_materialization_config_rules, - MaterializeOptions, - }, - setup::GraphStatePaths, -}; -use notify::{ - event::{AccessKind, AccessMode}, - Event, EventKind, -}; -use std::{ - collections::BTreeSet, - env, - path::{Path, PathBuf}, -}; - -#[derive(Debug)] -pub(in crate::cli) struct WatchEventFilter { - pub(in crate::cli) source_root: PathBuf, - pub(in crate::cli) current_dir: PathBuf, - pub(in crate::cli) excluded_parts: BTreeSet, - pub(in crate::cli) include_patterns: Vec, - pub(in crate::cli) exclude_patterns: Vec, - pub(in crate::cli) ignore_patterns: Vec, -} - -impl WatchEventFilter { - pub(in crate::cli) fn from_options( - source_root: &Path, - options: &MaterializeOptions, - ) -> Result { - let paths = GraphStatePaths::derive(source_root); - let config_rules = read_materialization_config_rules(&paths.config_path)?; - let mut include_patterns = config_rules.include_patterns; - include_patterns.extend(options.include_patterns.clone()); - let mut exclude_patterns = config_rules.exclude_patterns; - exclude_patterns.extend(options.exclude_patterns.clone()); - Ok(Self { - source_root: source_root.to_path_buf(), - current_dir: env::current_dir().unwrap_or_else(|_| source_root.to_path_buf()), - excluded_parts: default_excluded_parts().into_iter().collect(), - include_patterns, - exclude_patterns, - ignore_patterns: read_codebase_graph_ignore(source_root)?, - }) - } - - pub(in crate::cli) fn relevant_paths(&self, event: &Event) -> BTreeSet { - if !watch_event_refreshes(event) { - return BTreeSet::new(); - } - event - .paths - .iter() - .filter_map(|path| self.relevant_path(path)) - .collect() - } - - pub(in crate::cli) fn relevant_path(&self, path: &Path) -> Option { - let relative = self.relative_event_path(path)?; - if relative.as_os_str().is_empty() { - return None; - } - if relative.components().any(|component| { - self.excluded_parts - .contains(component.as_os_str().to_string_lossy().as_ref()) - }) { - return None; - } - let relative = relative.to_string_lossy().replace('\\', "/"); - if self.ignored_by_patterns(&relative) { - None - } else { - Some(relative) - } - } - - pub(in crate::cli) fn relative_event_path(&self, path: &Path) -> Option { - if let Ok(relative) = path.strip_prefix(&self.source_root) { - return Some(relative.to_path_buf()); - } - if path.is_relative() { - let absolute = self.current_dir.join(path); - if let Ok(relative) = absolute.strip_prefix(&self.source_root) { - return Some(relative.to_path_buf()); - } - #[cfg(windows)] - { - let absolute = normalize_windows_verbatim_path(&absolute); - let source_root = normalize_windows_verbatim_path(&self.source_root); - if let Ok(relative) = absolute.strip_prefix(source_root) { - return Some(relative.to_path_buf()); - } - } - return Some(path.to_path_buf()); - } - None - } - - pub(in crate::cli) fn ignored_by_patterns(&self, relative_path: &str) -> bool { - if !self.include_patterns.is_empty() - && !watch_matches_any_pattern(relative_path, &self.include_patterns) - { - return true; - } - watch_matches_any_pattern(relative_path, &self.ignore_patterns) - || watch_matches_any_pattern(relative_path, &self.exclude_patterns) - } -} - -#[cfg(windows)] -fn normalize_windows_verbatim_path(path: &Path) -> PathBuf { - let normalized = path.to_string_lossy().replace('\\', "/"); - if let Some(stripped) = normalized.strip_prefix("//?/UNC/") { - PathBuf::from(format!("//{stripped}")) - } else if let Some(stripped) = normalized.strip_prefix("//?/") { - PathBuf::from(stripped) - } else { - PathBuf::from(normalized) - } -} - -pub(in crate::cli) fn watch_event_refreshes(event: &Event) -> bool { - matches!( - event.kind, - EventKind::Any - | EventKind::Create(_) - | EventKind::Modify(_) - | EventKind::Remove(_) - | EventKind::Other - | EventKind::Access(AccessKind::Close(AccessMode::Write)) - ) -} diff --git a/src/cli/watch/helpers.rs b/src/cli/watch/helpers.rs deleted file mode 100644 index 67ef316..0000000 --- a/src/cli/watch/helpers.rs +++ /dev/null @@ -1,64 +0,0 @@ -use std::time::Duration; - -pub(in crate::cli) fn watch_max_wait(debounce_ms: u64) -> Duration { - Duration::from_secs(5).max(Duration::from_millis(debounce_ms.saturating_mul(10))) -} - -pub(in crate::cli) fn watch_matches_any_pattern(path: &str, patterns: &[String]) -> bool { - patterns - .iter() - .map(|pattern| pattern.trim()) - .filter(|pattern| !pattern.is_empty() && !pattern.starts_with('#')) - .any(|pattern| watch_glob_matches(path, pattern)) -} - -pub(in crate::cli) fn watch_glob_matches(path: &str, pattern: &str) -> bool { - let pattern = watch_normalize_pattern(pattern); - if pattern.ends_with('/') { - return path.starts_with(pattern.trim_end_matches('/')); - } - if !pattern.contains('/') - && watch_wildcard_match(path.rsplit('/').next().unwrap_or(path), &pattern) - { - return true; - } - watch_wildcard_match(path, &pattern) -} - -pub(in crate::cli) fn watch_normalize_pattern(pattern: &str) -> String { - pattern - .trim() - .trim_start_matches("./") - .replace('\\', "/") - .to_string() -} - -pub(in crate::cli) fn watch_wildcard_match(text: &str, pattern: &str) -> bool { - let (mut text_index, mut pattern_index) = (0_usize, 0_usize); - let mut star_index = None; - let mut match_index = 0_usize; - let text = text.as_bytes(); - let pattern = pattern.as_bytes(); - while text_index < text.len() { - if pattern_index < pattern.len() - && (pattern[pattern_index] == b'?' || pattern[pattern_index] == text[text_index]) - { - text_index += 1; - pattern_index += 1; - } else if pattern_index < pattern.len() && pattern[pattern_index] == b'*' { - star_index = Some(pattern_index); - match_index = text_index; - pattern_index += 1; - } else if let Some(star) = star_index { - pattern_index = star + 1; - match_index += 1; - text_index = match_index; - } else { - return false; - } - } - while pattern_index < pattern.len() && pattern[pattern_index] == b'*' { - pattern_index += 1; - } - pattern_index == pattern.len() -} diff --git a/src/cli/watch/mod.rs b/src/cli/watch/mod.rs deleted file mode 100644 index beefc41..0000000 --- a/src/cli/watch/mod.rs +++ /dev/null @@ -1,29 +0,0 @@ -mod batch; -mod command; -mod filter; -mod helpers; -mod native; -mod options; -mod output; -mod poll; -mod refresh; -mod snapshot; -mod types; - -pub(in crate::cli) use batch::collect_watch_batch; -pub(in crate::cli) use command::run_watch; -pub(in crate::cli) use filter::WatchEventFilter; -pub(in crate::cli) use native::{probe_native_watcher, start_native_watcher}; -pub(in crate::cli) use options::{SetupOptions, WatchLoopConfig}; -pub(in crate::cli) use poll::collect_poll_batch; -pub(in crate::cli) use snapshot::{scan_source_snapshots, watch_file_snapshot}; -pub(in crate::cli) use types::WatchMessage; - -#[cfg(test)] -pub(super) use batch::apply_watch_message; -#[cfg(test)] -pub(super) use options::{WatchBackend, WatchOptions}; -#[cfg(test)] -pub(super) use snapshot::watch_snapshot_diff; -#[cfg(test)] -pub(super) use types::WatchChangeBatch; diff --git a/src/cli/watch/native.rs b/src/cli/watch/native.rs deleted file mode 100644 index b5ef774..0000000 --- a/src/cli/watch/native.rs +++ /dev/null @@ -1,176 +0,0 @@ -use super::{ - batch::collect_watch_batch, - filter::WatchEventFilter, - helpers::watch_max_wait, - refresh::refresh_watch_batch, - types::{WatchMessage, WatchProbeOutcome}, - WatchLoopConfig, -}; -use crate::cli::build::MaterializeOptions; -use notify::{Event, RecursiveMode, Watcher}; -use std::{ - collections::VecDeque, - env, fs, - io::Write, - path::Path, - sync::mpsc::{self, Receiver}, - time::{Duration, Instant}, -}; - -pub(in crate::cli) fn start_native_watcher( - source_root: &Path, -) -> Result<(notify::RecommendedWatcher, Receiver), String> { - let (tx, rx) = mpsc::channel(); - let mut watcher = notify::recommended_watcher(move |result: notify::Result| { - let message = match result { - Ok(event) => WatchMessage::Event(event), - Err(error) => WatchMessage::Error(error.to_string()), - }; - let _ = tx.send(message); - }) - .map_err(|error| format!("failed to start filesystem watcher: {error}"))?; - watcher - .watch(source_root, RecursiveMode::Recursive) - .map_err(|error| format!("failed to watch {}: {error}", source_root.display()))?; - Ok((watcher, rx)) -} - -pub(in crate::cli) fn run_native_watch( - stdout: &mut W, - loop_config: WatchLoopConfig, - materialize_options: &MaterializeOptions, - filter: &WatchEventFilter, - _watcher: notify::RecommendedWatcher, - rx: Receiver, - mut queued: VecDeque, -) -> Result<(), String> { - let mut refreshes = 0_usize; - loop { - let first = match queued.pop_front() { - Some(message) => message, - None => rx - .recv() - .map_err(|error| format!("filesystem watcher stopped: {error}"))?, - }; - let batch = match collect_watch_batch( - first, - &rx, - &mut queued, - filter, - Duration::from_millis(loop_config.debounce_ms), - watch_max_wait(loop_config.debounce_ms), - )? { - Some(batch) => batch, - None => continue, - }; - let refreshed = refresh_watch_batch( - stdout, - "native", - materialize_options, - batch.event_count, - &batch.paths, - )?; - if !refreshed { - continue; - } - refreshes += 1; - if loop_config - .max_iterations - .is_some_and(|max| refreshes >= max) - { - return Ok(()); - } - } -} - -pub(in crate::cli) fn probe_native_watcher( - source_root: &Path, - filter: &WatchEventFilter, - rx: &Receiver, -) -> Result { - let timeout = watch_probe_timeout(); - let probe_dir = source_root.join(".codebaseGraph").join("watch-probe"); - let probe_path = probe_dir.join(format!( - "probe-{}-{}.tmp", - std::process::id(), - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map(|duration| duration.as_nanos()) - .unwrap_or(0) - )); - if !watch_probe_skip_write() { - fs::create_dir_all(&probe_dir) - .map_err(|error| format!("failed to create watch probe directory: {error}"))?; - fs::write(&probe_path, b"probe") - .map_err(|error| format!("failed to write watch probe: {error}"))?; - } - - let started = Instant::now(); - let mut outcome = WatchProbeOutcome::default(); - while started.elapsed() < timeout { - let remaining = timeout.saturating_sub(started.elapsed()); - match rx.recv_timeout(remaining) { - Ok(WatchMessage::Event(event)) => { - outcome.delivered = true; - if !watch_event_is_under_dir(&event, &probe_dir, source_root, &filter.current_dir) { - outcome.queued.push_back(WatchMessage::Event(event)); - } - } - Ok(WatchMessage::Error(error)) => { - outcome.reason = Some("watcher_error".to_string()); - outcome.queued.push_back(WatchMessage::Error(error)); - break; - } - Err(mpsc::RecvTimeoutError::Timeout) => break, - Err(mpsc::RecvTimeoutError::Disconnected) => { - return Err("filesystem watcher stopped during health probe".to_string()) - } - } - } - let _ = fs::remove_file(&probe_path); - if !outcome.delivered && outcome.reason.is_none() { - outcome.reason = Some("probe_timeout".to_string()); - } - Ok(outcome) -} - -pub(in crate::cli) fn watch_probe_timeout() -> Duration { - env::var("CODEBASE_GRAPH_WATCH_PROBE_TIMEOUT_MS") - .ok() - .and_then(|value| value.parse::().ok()) - .map(Duration::from_millis) - .unwrap_or_else(|| Duration::from_millis(750)) -} - -pub(in crate::cli) fn watch_probe_skip_write() -> bool { - env::var("CODEBASE_GRAPH_WATCH_PROBE_SKIP_WRITE").is_ok_and(|value| value == "1") -} - -pub(in crate::cli) fn watch_event_is_under_dir( - event: &Event, - directory: &Path, - source_root: &Path, - current_dir: &Path, -) -> bool { - !event.paths.is_empty() - && event - .paths - .iter() - .all(|path| watch_path_is_under_dir(path, directory, source_root, current_dir)) -} - -pub(in crate::cli) fn watch_path_is_under_dir( - path: &Path, - directory: &Path, - source_root: &Path, - current_dir: &Path, -) -> bool { - if path.starts_with(directory) { - return true; - } - if path.is_relative() { - return current_dir.join(path).starts_with(directory) - || source_root.join(path).starts_with(directory); - } - false -} diff --git a/src/cli/watch/poll.rs b/src/cli/watch/poll.rs deleted file mode 100644 index 37be922..0000000 --- a/src/cli/watch/poll.rs +++ /dev/null @@ -1,96 +0,0 @@ -use super::{ - helpers::watch_max_wait, - refresh::refresh_watch_batch, - snapshot::{watch_file_snapshot, watch_snapshot_diff}, - types::WatchChangeBatch, - types::WatchFileSnapshot, - WatchEventFilter, WatchLoopConfig, -}; -use crate::cli::build::MaterializeOptions; -use std::{ - io::Write, - time::{Duration, Instant}, -}; - -pub(in crate::cli) fn run_poll_watch( - stdout: &mut W, - loop_config: WatchLoopConfig, - materialize_options: &MaterializeOptions, - filter: &WatchEventFilter, -) -> Result<(), String> { - let mut previous_snapshot = watch_file_snapshot(filter)?; - let mut refreshes = 0_usize; - loop { - let batch = collect_poll_batch( - filter, - &mut previous_snapshot, - Duration::from_millis(loop_config.poll_ms), - Duration::from_millis(loop_config.debounce_ms), - watch_max_wait(loop_config.debounce_ms), - )?; - let refreshed = refresh_watch_batch( - stdout, - "poll", - materialize_options, - batch.event_count, - &batch.paths, - )?; - if !refreshed { - continue; - } - refreshes += 1; - if loop_config - .max_iterations - .is_some_and(|max| refreshes >= max) - { - return Ok(()); - } - } -} - -pub(in crate::cli) fn collect_poll_batch( - filter: &WatchEventFilter, - previous_snapshot: &mut WatchFileSnapshot, - poll_interval: Duration, - debounce: Duration, - max_wait: Duration, -) -> Result { - loop { - std::thread::sleep(poll_interval); - let current_snapshot = watch_file_snapshot(filter)?; - let changed_paths = watch_snapshot_diff(previous_snapshot, ¤t_snapshot); - *previous_snapshot = current_snapshot; - if changed_paths.is_empty() { - continue; - } - - let started = Instant::now(); - let mut last_relevant = started; - let mut batch = WatchChangeBatch { - paths: changed_paths, - event_count: 1, - }; - loop { - let elapsed = started.elapsed(); - if elapsed >= max_wait { - return Ok(batch); - } - let quiet_elapsed = last_relevant.elapsed(); - if quiet_elapsed >= debounce { - return Ok(batch); - } - let timeout = poll_interval - .min(debounce.saturating_sub(quiet_elapsed)) - .min(max_wait.saturating_sub(elapsed)); - std::thread::sleep(timeout); - let current_snapshot = watch_file_snapshot(filter)?; - let changed_paths = watch_snapshot_diff(previous_snapshot, ¤t_snapshot); - *previous_snapshot = current_snapshot; - if !changed_paths.is_empty() { - batch.paths.extend(changed_paths); - batch.event_count += 1; - last_relevant = Instant::now(); - } - } - } -} diff --git a/src/cli/watch/refresh.rs b/src/cli/watch/refresh.rs deleted file mode 100644 index e4b8aba..0000000 --- a/src/cli/watch/refresh.rs +++ /dev/null @@ -1,154 +0,0 @@ -use super::output::{write_watch_event, write_watch_status}; -use crate::{ - cli::build::{materialize_candidate_paths, MaterializeOptions}, - db_writer::is_transient_database_error, - protocol::NativeSyntaxMaterializationResponse, -}; -use std::{collections::BTreeSet, io::Write, thread, time::Duration}; - -const INITIAL_RETRY_DELAY: Duration = Duration::from_millis(100); -const MAX_RETRY_DELAY: Duration = Duration::from_millis(1_000); - -pub(in crate::cli) fn refresh_watch_batch( - stdout: &mut W, - backend: &str, - materialize_options: &MaterializeOptions, - event_count: usize, - paths: &BTreeSet, -) -> Result { - refresh_watch_batch_with(stdout, backend, event_count, paths, |candidate_paths| { - materialize_candidate_paths(materialize_options, candidate_paths) - .map(|(_, response)| response) - }) -} - -fn refresh_watch_batch_with( - stdout: &mut W, - backend: &str, - event_count: usize, - paths: &BTreeSet, - mut refresh: impl FnMut(Vec) -> Result, -) -> Result { - let mut delay = INITIAL_RETRY_DELAY; - loop { - match refresh(paths.iter().cloned().collect()) { - Ok(response) => { - write_watch_event( - stdout, - "refreshed", - Some(backend), - event_count, - paths.len(), - &response, - )?; - return Ok(true); - } - Err(error) => { - let transient = is_transient_database_error(&error); - write_watch_status( - stdout, - if transient { "retrying" } else { "error" }, - backend, - Some(&watch_error_reason(&error)), - )?; - if !transient { - return Ok(false); - } - thread::sleep(delay); - delay = delay.saturating_mul(2).min(MAX_RETRY_DELAY); - } - } - } -} - -fn watch_error_reason(error: &str) -> String { - let reason = error.lines().next().unwrap_or("refresh_failed").trim(); - if reason.is_empty() { - "refresh_failed".to_string() - } else { - reason - .split_whitespace() - .collect::>() - .join("_") - .chars() - .take(160) - .collect() - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::protocol::ManifestDiff; - use std::{ - collections::BTreeMap, - sync::atomic::{AtomicUsize, Ordering}, - }; - - fn skipped_response() -> NativeSyntaxMaterializationResponse { - NativeSyntaxMaterializationResponse::skipped( - BTreeMap::new(), - ManifestDiff { - added: Vec::new(), - modified: Vec::new(), - unchanged: Vec::new(), - deleted: Vec::new(), - force_rebuild: false, - }, - Vec::new(), - Vec::new(), - BTreeMap::new(), - ) - } - - #[test] - fn watch_error_reason_compacts_multiline_errors() { - assert_eq!( - watch_error_reason("IO exception: Could not set lock\nSee docs"), - "IO_exception:_Could_not_set_lock" - ); - } - - #[test] - fn watch_refresh_retries_transient_errors_before_success() { - let attempts = AtomicUsize::new(0); - let mut output = Vec::new(); - let refreshed = refresh_watch_batch_with( - &mut output, - "poll", - 2, - &BTreeSet::from(["src/lib.rs".to_string()]), - |_| { - if attempts.fetch_add(1, Ordering::SeqCst) == 0 { - Err("IO exception: Could not set lock on file".to_string()) - } else { - Ok(skipped_response()) - } - }, - ) - .unwrap(); - let text = String::from_utf8(output).unwrap(); - - assert!(refreshed); - assert_eq!(attempts.load(Ordering::SeqCst), 2); - assert!(text.contains("watch event=retrying backend=poll")); - assert!(text.contains("watch event=refreshed backend=poll")); - } - - #[test] - fn watch_refresh_reports_non_transient_errors_without_success() { - let mut output = Vec::new(); - let refreshed = refresh_watch_batch_with( - &mut output, - "native", - 1, - &BTreeSet::from(["src/lib.rs".to_string()]), - |_| Err("parser exploded".to_string()), - ) - .unwrap(); - let text = String::from_utf8(output).unwrap(); - - assert!(!refreshed); - assert!(text.contains("watch event=error backend=native reason=parser_exploded")); - } -} diff --git a/src/cli/watch/snapshot.rs b/src/cli/watch/snapshot.rs deleted file mode 100644 index 1780d66..0000000 --- a/src/cli/watch/snapshot.rs +++ /dev/null @@ -1,129 +0,0 @@ -use super::{ - types::{WatchFileSnapshot, WatchFileState}, - WatchEventFilter, -}; -use crate::cli::build::default_excluded_parts; -use std::{ - collections::{BTreeMap, BTreeSet}, - fs, - path::Path, -}; - -pub(in crate::cli) fn watch_file_snapshot( - filter: &WatchEventFilter, -) -> Result { - let mut snapshot = BTreeMap::new(); - watch_file_snapshot_inner(filter, &filter.source_root, &mut snapshot)?; - Ok(snapshot) -} - -pub(in crate::cli) fn watch_file_snapshot_inner( - filter: &WatchEventFilter, - directory: &Path, - snapshot: &mut WatchFileSnapshot, -) -> Result<(), String> { - let entries = fs::read_dir(directory) - .map_err(|error| format!("failed to read directory {}: {error}", directory.display()))?; - for entry in entries.flatten() { - let path = entry.path(); - if path.is_dir() { - let name = path - .file_name() - .and_then(|value| value.to_str()) - .unwrap_or(""); - if filter.excluded_parts.contains(name) { - continue; - } - watch_file_snapshot_inner(filter, &path, snapshot)?; - } else if path.is_file() { - let Some(relative_path) = filter.relevant_path(&path) else { - continue; - }; - let metadata = match fs::metadata(&path) { - Ok(metadata) => metadata, - Err(_) => continue, - }; - let modified_nanos = metadata - .modified() - .ok() - .and_then(|modified| { - modified - .duration_since(std::time::UNIX_EPOCH) - .ok() - .map(|duration| duration.as_nanos()) - }) - .unwrap_or(0); - snapshot.insert( - relative_path, - WatchFileState { - modified_nanos, - len: metadata.len(), - }, - ); - } - } - Ok(()) -} - -pub(in crate::cli) fn watch_snapshot_diff( - previous: &WatchFileSnapshot, - current: &WatchFileSnapshot, -) -> BTreeSet { - let mut changed_paths = BTreeSet::new(); - for (path, state) in current { - if previous.get(path) != Some(state) { - changed_paths.insert(path.clone()); - } - } - for path in previous.keys() { - if !current.contains_key(path) { - changed_paths.insert(path.clone()); - } - } - changed_paths -} - -pub(in crate::cli) fn scan_source_snapshots(root: &Path) -> Vec<(String, Option<&'static str>)> { - let mut snapshots = Vec::new(); - scan_source_snapshots_inner(root, root, &mut snapshots); - snapshots.sort_by(|left, right| left.0.cmp(&right.0)); - snapshots -} - -pub(in crate::cli) fn scan_source_snapshots_inner( - root: &Path, - directory: &Path, - snapshots: &mut Vec<(String, Option<&'static str>)>, -) { - let Ok(entries) = fs::read_dir(directory) else { - return; - }; - for entry in entries.flatten() { - let path = entry.path(); - let name = path - .file_name() - .and_then(|value| value.to_str()) - .unwrap_or(""); - if default_excluded_parts().iter().any(|part| part == name) { - continue; - } - if path.is_dir() { - scan_source_snapshots_inner(root, &path, snapshots); - } else if path.is_file() { - let relative = path.strip_prefix(root).unwrap_or(&path).to_string_lossy(); - snapshots.push((relative.to_string(), language_for_path(&path))); - } - } -} - -pub(in crate::cli) fn language_for_path(path: &Path) -> Option<&'static str> { - match path.extension().and_then(|value| value.to_str()) { - Some("py") => Some("python"), - Some("rs") => Some("rust"), - Some("go") => Some("go"), - Some("c") | Some("h") => Some("c"), - Some("cc") | Some("cpp") | Some("cxx") | Some("hpp") | Some("hh") => Some("cpp"), - Some("f") | Some("f90") | Some("f95") | Some("for") => Some("fortran"), - _ => None, - } -} diff --git a/src/cli/watch/types.rs b/src/cli/watch/types.rs deleted file mode 100644 index e880d72..0000000 --- a/src/cli/watch/types.rs +++ /dev/null @@ -1,29 +0,0 @@ -use notify::Event; -use std::collections::{BTreeMap, BTreeSet, VecDeque}; - -#[derive(Debug)] -pub(in crate::cli) enum WatchMessage { - Event(Event), - Error(String), -} - -#[derive(Debug, Default, PartialEq, Eq)] -pub(in crate::cli) struct WatchChangeBatch { - pub(in crate::cli) paths: BTreeSet, - pub(in crate::cli) event_count: usize, -} - -#[derive(Debug, Default)] -pub(in crate::cli) struct WatchProbeOutcome { - pub(in crate::cli) delivered: bool, - pub(in crate::cli) queued: VecDeque, - pub(in crate::cli) reason: Option, -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub(in crate::cli) struct WatchFileState { - pub(in crate::cli) modified_nanos: u128, - pub(in crate::cli) len: u64, -} - -pub(in crate::cli) type WatchFileSnapshot = BTreeMap; diff --git a/src/error.rs b/src/error.rs index 2e473b1..cc32696 100644 --- a/src/error.rs +++ b/src/error.rs @@ -9,6 +9,8 @@ pub enum NativeError { Unsupported(String), } +pub type MaterializationError = NativeError; + impl fmt::Display for NativeError { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { match self { diff --git a/src/execution/mod.rs b/src/execution/mod.rs index 4d4c3b6..fc7c3d0 100644 --- a/src/execution/mod.rs +++ b/src/execution/mod.rs @@ -3,5 +3,5 @@ mod plan; mod run; mod timing; -pub use plan::plan_syntax_materialization; -pub use run::{materialize_syntax_batch, materialize_syntax_batch_json}; +pub use plan::plan_materialization; +pub use run::execute_materialization_pipeline; diff --git a/src/execution/parallel.rs b/src/execution/parallel.rs index 3f3bfe1..f5a28c6 100644 --- a/src/execution/parallel.rs +++ b/src/execution/parallel.rs @@ -2,7 +2,6 @@ use super::timing::elapsed_seconds; use crate::error::NativeError; use crate::parser; use crate::partition_builder; -use crate::profiles; use crate::protocol::{LanguageProfile, NativeSyntaxMaterializationRequest, SourceSnapshot}; use crate::scan; use std::thread; @@ -15,12 +14,11 @@ pub(super) struct PartitionBuildResult { pub(super) graph_build_seconds: f64, } -pub(super) fn build_partitions( - request: &NativeSyntaxMaterializationRequest, +pub(super) fn build_execution_plan( scan: &scan::SourceScan, rebuild_paths: &[String], ) -> Result, NativeError> { - let profile_set = profiles::ProfileSet::new(&request.profiles); + let request = &scan.input; if request.parallel && rebuild_paths.len() > 1 { return thread::scope(|scope| { let mut handles = Vec::new(); @@ -31,7 +29,11 @@ pub(super) fn build_partitions( let Some(language) = snapshot.language.as_deref() else { continue; }; - let Some(profile) = profile_set.profile_for_language(language) else { + let Some(profile) = scan + .profiles + .iter() + .find(|profile| profile.language == language) + else { continue; }; handles.push( @@ -57,7 +59,11 @@ pub(super) fn build_partitions( let Some(language) = snapshot.language.as_deref() else { continue; }; - let Some(profile) = profile_set.profile_for_language(language) else { + let Some(profile) = scan + .profiles + .iter() + .find(|profile| profile.language == language) + else { continue; }; results.push(build_partition_for_snapshot(request, snapshot, profile)?); @@ -84,3 +90,63 @@ fn build_partition_for_snapshot( graph_build_seconds, }) } + +#[cfg(test)] +mod tests { + use super::*; + use crate::protocol::{NativeSyntaxMaterializationRequest, OntologySchema}; + use std::fs; + use std::time::{SystemTime, UNIX_EPOCH}; + + #[test] + fn execution_plan_uses_scanned_source_after_repository_is_removed() { + let nonce = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("clock should be after epoch") + .as_nanos(); + let source_root = + std::env::temp_dir().join(format!("codebase-graph-scanned-source-{nonce}")); + fs::create_dir_all(source_root.join("src")).expect("source directory should be created"); + fs::write( + source_root.join("src/lib.rs"), + "pub fn scanned_source() -> bool { true }\n", + ) + .expect("source file should be written"); + + let request = NativeSyntaxMaterializationRequest { + source_root: source_root.to_string_lossy().into_owned(), + repository_label: "scanned-source".to_string(), + mode: "full".to_string(), + parser_version: "test".to_string(), + manifest_schema_version: 1, + ontology: "code_ontology_v1".to_string(), + ontology_schema: OntologySchema::default(), + previous_manifest: None, + profiles: Vec::new(), + excluded_parts: Vec::new(), + include_patterns: Vec::new(), + exclude_patterns: Vec::new(), + ignore_patterns: Vec::new(), + candidate_paths: Vec::new(), + db_path: source_root.join("graph").to_string_lossy().into_owned(), + include_fts: false, + semantic_enrichment: false, + semantic_provider_mode: "local_only".to_string(), + schema_statements: Vec::new(), + staging_dir: source_root.join("staging").to_string_lossy().into_owned(), + atomic_rebuild: false, + strict: true, + parallel: false, + progress: false, + }; + let scan = crate::scan::scan_sources(&request).expect("scan should succeed"); + let rebuild_paths = scan.diff.rebuild_paths(); + + fs::remove_dir_all(&source_root).expect("source repository should be removed"); + + let plan = build_execution_plan(&scan, &rebuild_paths) + .expect("planning should use the scanned source payload"); + assert_eq!(plan.len(), 1); + assert_eq!(plan[0].partition.entry.path, "src/lib.rs"); + } +} diff --git a/src/execution/plan.rs b/src/execution/plan.rs index f27bb70..0352958 100644 --- a/src/execution/plan.rs +++ b/src/execution/plan.rs @@ -5,12 +5,12 @@ use crate::scan; use std::collections::BTreeMap; use std::time::Instant; -pub fn plan_syntax_materialization( +pub fn plan_materialization( request: &NativeSyntaxMaterializationRequest, ) -> Result { let mut phase_timings = BTreeMap::new(); let scan_started = Instant::now(); - let scan = scan::scan_source_state(request)?; + let scan = scan::scan_sources(request)?; phase_timings.insert("scan_seconds".to_string(), elapsed_seconds(scan_started)); Ok(NativeSyntaxMaterializationResponse::skipped( scan.snapshots, diff --git a/src/execution/run.rs b/src/execution/run.rs index 5d57814..0555248 100644 --- a/src/execution/run.rs +++ b/src/execution/run.rs @@ -1,4 +1,4 @@ -use super::parallel::build_partitions; +use super::parallel::build_execution_plan; use super::timing::elapsed_seconds; use crate::error::NativeError; use crate::protocol::{ @@ -9,13 +9,21 @@ use crate::{scan, semantic_enrichment, staging_writer}; use std::collections::{BTreeMap, BTreeSet}; use std::time::Instant; -pub fn materialize_syntax_batch( +pub fn execute_materialization_pipeline( request: &NativeSyntaxMaterializationRequest, ) -> Result { let mut phase_timings = BTreeMap::new(); let scan_started = Instant::now(); - let scan = scan::scan_source_state(request)?; + let scan = scan::scan_sources(request)?; phase_timings.insert("scan_seconds".to_string(), elapsed_seconds(scan_started)); + execute_scanned_materialization(scan, phase_timings) +} + +fn execute_scanned_materialization( + scan: scan::SourceScan, + mut phase_timings: BTreeMap, +) -> Result { + let request = &scan.input; let diff = scan.diff.clone(); if diff.rebuild_paths().is_empty() && diff.deleted.is_empty() { return Ok(NativeSyntaxMaterializationResponse::skipped( @@ -41,7 +49,7 @@ pub fn materialize_syntax_batch( let (retained_nodes, retained_edges) = retained_manifest_ids(request, &diff, &rebuild_paths); let mut partitions = Vec::new(); - for (index, result) in build_partitions(request, &scan, &rebuild_paths)? + for (index, result) in build_execution_plan(&scan, &rebuild_paths)? .into_iter() .enumerate() { @@ -62,7 +70,7 @@ pub fn materialize_syntax_batch( phase_timings.insert("parse_seconds".to_string(), parse_seconds); phase_timings.insert("graph_build_seconds".to_string(), graph_build_seconds); - let semantic_stats = semantic_enrichment::enrich_partitions(&mut partitions, request)?; + let semantic_stats = semantic_enrichment::enrich_semantics(&mut partitions, request)?; for (phase, seconds) in semantic_stats.phase_timings { phase_timings.insert(phase, seconds); } @@ -107,6 +115,13 @@ pub fn materialize_syntax_batch( phase_timings, ); response.progress_events = progress_events; + let graph_write_started = Instant::now(); + staging_writer::write_graph_rows(request, &response).map_err(NativeError::InvalidInput)?; + response.phase_timings.insert( + "database_write_seconds".to_string(), + elapsed_seconds(graph_write_started), + ); + response.database_written = true; Ok(response) } @@ -139,11 +154,67 @@ fn retained_manifest_ids( (retained_nodes, retained_edges) } -pub fn materialize_syntax_batch_json(payload: &str) -> Result { - let decode_started = Instant::now(); - let request: NativeSyntaxMaterializationRequest = serde_json::from_str(payload)?; - let json_decode_seconds = elapsed_seconds(decode_started); - let mut response = materialize_syntax_batch(&request)?; - response.add_phase_timing("rust_json_decode_seconds", json_decode_seconds); - Ok(serde_json::to_string(&response)?) +#[cfg(test)] +mod tests { + use super::*; + use crate::protocol::{NativeSyntaxMaterializationRequest, OntologySchema}; + use std::fs; + use std::time::{SystemTime, UNIX_EPOCH}; + + #[test] + fn scanned_payload_completes_enrichment_and_graph_write_after_source_removal() { + let nonce = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("clock should be after epoch") + .as_nanos(); + let root = std::env::temp_dir().join(format!("codebase-graph-scanned-pipeline-{nonce}")); + let source_root = root.join("repository"); + let state_root = root.join("state"); + fs::create_dir_all(source_root.join("src")).expect("source directory should be created"); + fs::create_dir_all(&state_root).expect("state directory should be created"); + fs::write( + source_root.join("src/lib.rs"), + "pub fn scanned_pipeline() -> bool { true }\n", + ) + .expect("source file should be written"); + let db_path = state_root.join("graph.ldb"); + let request = NativeSyntaxMaterializationRequest { + source_root: source_root.to_string_lossy().into_owned(), + repository_label: "scanned-pipeline".to_string(), + mode: "full".to_string(), + parser_version: "test".to_string(), + manifest_schema_version: 1, + ontology: "code_ontology_v1".to_string(), + ontology_schema: OntologySchema::default(), + previous_manifest: None, + profiles: Vec::new(), + excluded_parts: Vec::new(), + include_patterns: Vec::new(), + exclude_patterns: Vec::new(), + ignore_patterns: Vec::new(), + candidate_paths: Vec::new(), + db_path: db_path.to_string_lossy().into_owned(), + include_fts: false, + semantic_enrichment: true, + semantic_provider_mode: "local_only".to_string(), + schema_statements: Vec::new(), + staging_dir: state_root.join("staging").to_string_lossy().into_owned(), + atomic_rebuild: false, + strict: true, + parallel: false, + progress: false, + }; + let mut timings = BTreeMap::new(); + let scan = crate::scan::scan_sources(&request).expect("scan should succeed"); + timings.insert("scan_seconds".to_string(), 0.0); + + fs::remove_dir_all(&source_root).expect("source repository should be removed"); + + let response = execute_scanned_materialization(scan, timings) + .expect("scanned payload should complete the pipeline"); + assert!(response.database_written); + assert_eq!(response.rebuilt_entries.len(), 1); + assert!(db_path.exists()); + let _ = fs::remove_dir_all(root); + } } diff --git a/src/lib.rs b/src/lib.rs index 138149b..725b31c 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,4 +1,6 @@ -pub mod cli; +pub mod adapters; +pub mod api; +mod bootstrap; pub mod db_writer; pub mod error; mod execution; @@ -14,6 +16,9 @@ mod semantic_enrichment; mod staging_writer; mod syntax_materializer; -pub use execution::{ - materialize_syntax_batch, materialize_syntax_batch_json, plan_syntax_materialization, -}; +pub use error::MaterializationError; +pub use execution::{execute_materialization_pipeline, plan_materialization}; +pub use protocol::{MaterializationInput, MaterializationResult}; + +pub use adapters::cli; +pub use bootstrap::run_from_env; diff --git a/src/parser/mod.rs b/src/parser/mod.rs index 97fa3fe..f9fd419 100644 --- a/src/parser/mod.rs +++ b/src/parser/mod.rs @@ -6,7 +6,6 @@ mod tree_sitter; use crate::error::NativeError; use crate::normalize::SyntaxNode; use crate::protocol::{LanguageProfile, SourceSnapshot}; -use std::fs; #[derive(Debug, Clone)] pub(crate) struct ParseOutput { @@ -18,8 +17,13 @@ pub(crate) fn parse_file( snapshot: &SourceSnapshot, profile: &LanguageProfile, ) -> Result { - let source = fs::read_to_string(&snapshot.absolute_path)?; - parse_source(&source, profile) + let source = snapshot.source.as_deref().ok_or_else(|| { + NativeError::InvalidInput(format!( + "missing source content for scanned file: {}", + snapshot.path + )) + })?; + parse_source(source, profile) } fn parse_source(source: &str, profile: &LanguageProfile) -> Result { diff --git a/src/profiles.rs b/src/profiles.rs index 232dbd2..d902c52 100644 --- a/src/profiles.rs +++ b/src/profiles.rs @@ -32,9 +32,20 @@ impl ProfileSet { .cloned() } + #[cfg(test)] pub(crate) fn profile_for_language(&self, language: &str) -> Option<&LanguageProfile> { self.by_language.get(language) } + + pub(crate) fn selected_profiles( + &self, + languages: &std::collections::BTreeSet, + ) -> Vec { + languages + .iter() + .filter_map(|language| self.by_language.get(language).cloned()) + .collect() + } } fn base_profiles() -> Vec { diff --git a/src/protocol.rs b/src/protocol.rs index 71f615c..71cf7b0 100644 --- a/src/protocol.rs +++ b/src/protocol.rs @@ -43,6 +43,8 @@ pub struct NativeSyntaxMaterializationRequest { pub progress: bool, } +pub type MaterializationInput = NativeSyntaxMaterializationRequest; + fn default_semantic_provider_mode() -> String { "local_only".to_string() } @@ -127,6 +129,8 @@ pub struct SourceSnapshot { pub absolute_path: String, pub content_hash: String, pub language: Option, + #[serde(skip)] + pub source: Option, } #[derive(Debug, Clone, Deserialize, Serialize)] @@ -147,7 +151,7 @@ impl ManifestDiff { } } -#[derive(Debug, Clone, Serialize)] +#[derive(Debug, Clone, Deserialize, Serialize)] pub struct NativeSyntaxMaterializationResponse { pub snapshots: BTreeMap, pub diff: ManifestDiff, @@ -165,13 +169,15 @@ pub struct NativeSyntaxMaterializationResponse { pub database_written: bool, } -#[derive(Debug, Clone, Default, Serialize)] +pub type MaterializationResult = NativeSyntaxMaterializationResponse; + +#[derive(Debug, Clone, Default, Deserialize, Serialize)] pub struct GraphSummary { pub node_count: usize, pub edge_count: usize, } -#[derive(Debug, Clone, Serialize)] +#[derive(Debug, Clone, Deserialize, Serialize)] pub struct ProgressEvent { pub phase: String, pub current: usize, @@ -231,10 +237,6 @@ impl NativeSyntaxMaterializationResponse { database_written: false, } } - - pub(crate) fn add_phase_timing(&mut self, phase: &str, seconds: f64) { - self.phase_timings.insert(phase.to_string(), seconds); - } } fn manifest_files_from_any<'de, D>( diff --git a/src/scan.rs b/src/scan.rs index ee9b44f..d3a7172 100644 --- a/src/scan.rs +++ b/src/scan.rs @@ -1,19 +1,24 @@ use crate::error::NativeError; use crate::hash; use crate::profiles::ProfileSet; -use crate::protocol::{ManifestDiff, NativeSyntaxMaterializationRequest, SourceSnapshot}; +use crate::protocol::{ + LanguageProfile, ManifestDiff, NativeSyntaxMaterializationRequest, SourceSnapshot, +}; use std::collections::{BTreeMap, BTreeSet}; +use std::fs; use std::path::{Path, PathBuf}; use walkdir::WalkDir; pub(crate) struct SourceScan { + pub(crate) input: NativeSyntaxMaterializationRequest, + pub(crate) profiles: Vec, pub(crate) snapshots: BTreeMap, pub(crate) supported: BTreeMap, pub(crate) diagnostics: Vec, pub(crate) diff: ManifestDiff, } -pub(crate) fn scan_source_state( +pub(crate) fn scan_sources( request: &NativeSyntaxMaterializationRequest, ) -> Result { let source_root = PathBuf::from(&request.source_root); @@ -60,11 +65,17 @@ pub(crate) fn scan_source_state( if language.is_none() { diagnostics.push(format!("Skipped unsupported file: {relative_path}")); } + let source = if language.is_some() { + Some(fs::read_to_string(path)?) + } else { + None + }; let snapshot = SourceSnapshot { path: relative_path.clone(), absolute_path: path.to_string_lossy().to_string(), content_hash, language, + source, }; if snapshot.language.is_some() { supported.insert(relative_path.clone(), snapshot.clone()); @@ -73,7 +84,13 @@ pub(crate) fn scan_source_state( } let diff = compute_diff(request, &supported, &candidate_paths); + let selected_languages = supported + .values() + .filter_map(|snapshot| snapshot.language.clone()) + .collect::>(); Ok(SourceScan { + input: request.clone(), + profiles: profiles.selected_profiles(&selected_languages), snapshots, supported, diagnostics, diff --git a/src/semantic_enrichment/mod.rs b/src/semantic_enrichment/mod.rs index 2822d43..4d89a99 100644 --- a/src/semantic_enrichment/mod.rs +++ b/src/semantic_enrichment/mod.rs @@ -34,7 +34,7 @@ struct SemanticOutput { fallbacks: Vec, } -pub(crate) fn enrich_partitions( +pub(crate) fn enrich_semantics( partitions: &mut [GraphPartition], request: &NativeSyntaxMaterializationRequest, ) -> Result { @@ -296,7 +296,7 @@ mod tests { progress: false, }; - let stats = enrich_partitions(&mut partitions, &request).unwrap(); + let stats = enrich_semantics(&mut partitions, &request).unwrap(); assert!(stats .phase_timings diff --git a/src/staging_writer/mod.rs b/src/staging_writer/mod.rs index 0b590d6..983f83b 100644 --- a/src/staging_writer/mod.rs +++ b/src/staging_writer/mod.rs @@ -5,9 +5,11 @@ mod merge; mod ordering; mod result; mod rows; +mod writer; pub(crate) use accumulator::StagingAccumulator; pub(crate) use result::StagingResult; +pub(crate) use writer::write_graph_rows; #[cfg(test)] mod tests; diff --git a/src/staging_writer/writer.rs b/src/staging_writer/writer.rs new file mode 100644 index 0000000..16e3826 --- /dev/null +++ b/src/staging_writer/writer.rs @@ -0,0 +1,33 @@ +use crate::api::catalog::schema_statements_from_copy_statements; +use crate::db_writer::{ + incoming_row_delete_statements, partition_delete_statements, write_database, + LadybugWriteRequest, +}; +use crate::protocol::{NativeSyntaxMaterializationRequest, NativeSyntaxMaterializationResponse}; + +pub(crate) fn write_graph_rows( + request: &NativeSyntaxMaterializationRequest, + response: &NativeSyntaxMaterializationResponse, +) -> Result<(), String> { + let schema_statements = if request.schema_statements.is_empty() { + schema_statements_from_copy_statements(request.include_fts, &response.copy_statements) + } else { + request.schema_statements.clone() + }; + let mut delete_statements = + partition_delete_statements(request.previous_manifest.as_ref(), &response.diff); + delete_statements.extend(incoming_row_delete_statements( + request.previous_manifest.as_ref(), + &response.diff, + &response.rebuilt_entries, + )); + write_database(LadybugWriteRequest { + db_path: request.db_path.clone(), + include_fts: request.include_fts, + schema_statements, + replace_database: response.diff.force_rebuild, + delete_statements, + copy_statements: response.copy_statements.clone(), + }) + .map_err(|error| error.to_string()) +}