diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c3b1816..2a5b246 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -41,7 +41,7 @@ jobs: - name: Install Rust (stable) uses: dtolnay/rust-toolchain@5d458579430fc14a04a08a1e7d3694f545e91ce6 # stable with: - components: rustfmt + components: rustfmt, clippy - name: Setup sccache uses: mozilla-actions/sccache-action@7d986dd989559c6ecdb630a3fd2557667be217ad # v0.0.9 @@ -49,8 +49,11 @@ jobs: - name: Rust cache uses: Swatinem/rust-cache@779680da715d629ac1d338a641029a2f4372abb5 # v2.8.2 - - name: Format - run: cargo fmt --all --check + - name: Rustfmt (check) + run: cargo fmt --all -- --check + + - name: Cargo clippy (all features) + run: cargo clippy --all-targets --all-features -- -D warnings - name: Cargo check (all features) run: cargo check --all-targets --all-features diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index 5170303..540f900 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -133,6 +133,8 @@ jobs: - name: Install Rust (stable) uses: dtolnay/rust-toolchain@5d458579430fc14a04a08a1e7d3694f545e91ce6 # stable + with: + components: rustfmt, clippy - name: Setup sccache uses: mozilla-actions/sccache-action@7d986dd989559c6ecdb630a3fd2557667be217ad # v0.0.9 @@ -140,6 +142,12 @@ jobs: - name: Rust cache uses: Swatinem/rust-cache@779680da715d629ac1d338a641029a2f4372abb5 # v2.8.2 + - name: Rustfmt (check) + run: cargo fmt --all -- --check + + - name: Cargo clippy (all features) + run: cargo clippy --all-targets --all-features -- -D warnings + - name: Cargo package run: cargo package diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 385ca18..55bbba1 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -10,12 +10,6 @@ on: description: "Release tag (default: core-v)" required: false type: string - prerelease: - description: "Mark as pre-release" - required: false - type: boolean - default: false - permissions: contents: read @@ -42,6 +36,8 @@ jobs: - name: Install Rust (stable) uses: dtolnay/rust-toolchain@5d458579430fc14a04a08a1e7d3694f545e91ce6 # stable + with: + components: rustfmt, clippy - name: Setup sccache uses: mozilla-actions/sccache-action@7d986dd989559c6ecdb630a3fd2557667be217ad # v0.0.9 @@ -49,9 +45,32 @@ jobs: - name: Rust cache uses: Swatinem/rust-cache@779680da715d629ac1d338a641029a2f4372abb5 # v2.8.2 + - name: Rustfmt (check) + run: cargo fmt --all -- --check + + - name: Cargo clippy (all features) + run: cargo clippy --all-targets --all-features -- -D warnings + - name: Cargo package run: cargo package + - name: Require crates.io token + env: + CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }} + run: | + set -euo pipefail + if [ -z "${CARGO_REGISTRY_TOKEN:-}" ]; then + echo 'Error: secrets.CARGO_REGISTRY_TOKEN is not set. Aborting release.' + exit 1 + fi + + - name: Publish to crates.io + env: + CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }} + run: | + set -euo pipefail + cargo publish --token "$CARGO_REGISTRY_TOKEN" + - name: Determine tag id: tag shell: bash @@ -78,7 +97,7 @@ jobs: with: tag_name: ${{ steps.tag.outputs.tag }} name: ${{ steps.tag.outputs.tag }} - prerelease: ${{ github.event_name == 'workflow_dispatch' && inputs.prerelease || false }} + prerelease: true generate_release_notes: true files: | target/package/*.crate diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..6785d35 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,43 @@ +# Repository Guidelines + +## Project Structure & Module Organization + +- `src/`: Single Rust crate (`openvcs-core`) providing shared types/traits for the OpenVCS client and plugins. + - Feature-gated modules: `plugin_protocol`, `plugin_stdio`, `plugin_runtime`, `events`, `host`. + - VCS surface area lives behind the `vcs` feature (see `src/lib.rs`). +- `.github/workflows/`: CI, CodeQL, nightly packaging/release automation. +- `target/`: Cargo build artifacts (do not commit). + +## Build, Test, and Development Commands + +- `cargo check --all-targets --all-features`: Fast compile check matching CI’s broadest configuration. +- `cargo test`: Run default-feature unit tests. +- `cargo test --no-default-features --features plugin-protocol,vcs,backend-registry`: Validate feature combinations used in CI. +- `cargo package`: Ensure the crate packages cleanly (used by nightly automation). + +## Coding Style & Naming Conventions + +- Format with Rustfmt: `cargo fmt`. +- Prefer Clippy-clean code: `cargo clippy --all-targets --all-features -D warnings`. +- Naming: `snake_case` (functions/modules), `CamelCase` (types), `SCREAMING_SNAKE_CASE` (constants). +- Preserve feature gates (`#[cfg(feature = "...")]`) when adding new APIs; keep the default feature set lightweight. + +## Testing Guidelines + +- Uses Rust’s built-in test harness (`#[test]`) with unit tests colocated in modules (e.g., `src/models.rs`). +- Add tests next to the code they cover; keep tests deterministic and free of network/file-system assumptions unless required. + +## Commit & Pull Request Guidelines + +- Before committing, run `cargo fmt` (CI enforces `cargo fmt --all -- --check`). +- Commit messages in this repo are short and imperative (e.g., “Update lib.rs”, “Fix compile issues”). Follow that pattern and mention the touched area. +- Commit message format: agents must format commit messages with a short + title of at most 72 characters, followed by a blank line and any + additional explanatory text in the body. +- Open PRs against the `Dev` branch; include a clear description, rationale, and any relevant issue links. +- Ensure CI passes locally where possible (at minimum `cargo test`, ideally the full-feature test command above). + +- After making changes, run `just fix` to automatically apply formatting and simple fixes before committing. +- Commit changes locally with a clear, conventional message, but do NOT push to remotes or create PRs—leave pushing and PR creation to a human maintainer. + +**Sandbox note**: Running `just fix` and some `cargo` commands (for example `cargo build` or commands that fetch dependencies or build native binaries) may require network access or host-level tooling and therefore should be run outside a restricted sandbox or container. If operating with sandboxing or restricted network access, request approval before executing these commands or run them on the host machine. diff --git a/Cargo.lock b/Cargo.lock index d708ea8..cd0b865 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -42,7 +42,7 @@ checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" [[package]] name = "openvcs-core" -version = "0.1.0" +version = "0.1.5" dependencies = [ "linkme", "log", @@ -53,18 +53,18 @@ dependencies = [ [[package]] name = "proc-macro2" -version = "1.0.104" +version = "1.0.105" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9695f8df41bb4f3d222c95a67532365f569318332d03d5f3f67f37b20e6ebdf0" +checksum = "535d180e0ecab6268a3e718bb9fd44db66bbbc256257165fc699dadf70d16fe7" dependencies = [ "unicode-ident", ] [[package]] name = "quote" -version = "1.0.42" +version = "1.0.43" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a338cc41d27e6cc6dce6cefc13a0729dfbb81c262b1f519331575dd80ef3067f" +checksum = "dc74d9a594b72ae6656596548f56f667211f8a97b3d4c3d467150794690dc40a" dependencies = [ "proc-macro2", ] @@ -101,9 +101,9 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.148" +version = "1.0.149" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3084b546a1dd6289475996f182a22aba973866ea8e8b02c51d9f46b1336a22da" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" dependencies = [ "itoa", "memchr", @@ -114,9 +114,9 @@ dependencies = [ [[package]] name = "syn" -version = "2.0.111" +version = "2.0.114" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "390cc9a294ab71bdb1aa2e99d13be9c753cd2d7bd6560c77118597410c4d2e87" +checksum = "d4d107df263a3013ef9b1879b0df87d706ff80f65a86ea879bd9c31f9b307c2a" dependencies = [ "proc-macro2", "quote", @@ -151,6 +151,6 @@ checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" [[package]] name = "zmij" -version = "1.0.0" +version = "1.0.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6d6085d62852e35540689d1f97ad663e3971fc19cf5eceab364d62c646ea167" +checksum = "2fc5a66a20078bf1251bde995aa2fdcc4b800c70b5d92dd2c62abc5c60f679f8" diff --git a/Cargo.toml b/Cargo.toml index c3457a0..6416ee2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,7 +1,27 @@ [package] name = "openvcs-core" -version = "0.1.0" +version = "0.1.5" edition = "2024" +description = "Core types and traits for OpenVCS." +license = "GPL-3.0-or-later" +homepage = "https://bbgames.dev/" +repository = "https://github.com/Open-VCS/OpenVCS-Core" +readme = "README.md" + +keywords = [ + "openvcs", + "vcs", + "version-control", + "plugin", + "rpc" +] + + +categories = [ + "development-tools", + "development-tools::build-utils", + "api-bindings" +] [features] # Keep the default lightweight for non-VCS plugins; enable what you need. @@ -11,17 +31,17 @@ default = ["plugin-protocol"] plugin-protocol = ["dep:serde_json"] # The VCS trait and VCS-related error type. -vcs = ["dep:thiserror"] +vcs = ["dep:thiserror", "backend-registry"] -# Static + dynamic backend registry (linkme + logging). -backend-registry = ["vcs", "dep:linkme", "dep:log"] +# Backend discovery via the `BACKENDS` registry (link-time registration). +backend-registry = ["dep:linkme"] [dependencies] serde = { version = "1", features = ["derive"] } serde_json = { version = "1", optional = true } thiserror = { version = "2", optional = true } linkme = { version = "0.3", optional = true } -log = { version = "0.4", optional = true } +log = "0.4" [dev-dependencies] serde_json = "1" diff --git a/Justfile b/Justfile new file mode 100644 index 0000000..66d931d --- /dev/null +++ b/Justfile @@ -0,0 +1,9 @@ +set shell := ["bash", "-eu", "-o", "pipefail", "-c"] + +default: + @just --list + +fix: + cargo fmt --all + cargo clippy --fix --all-targets --all-features --allow-dirty --allow-staged + diff --git a/README.md b/README.md index 6e98d87..c2f77ee 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,57 @@ -# OpenVCS-Core +# OpenVCS Core (`openvcs-core`) -Shared Rust crate for OpenVCS plugins and the OpenVCS client. +[![Dev CI (fast)](https://github.com/Open-VCS/OpenVCS-Core/actions/workflows/ci.yml/badge.svg)](https://github.com/Open-VCS/OpenVCS-Core/actions/workflows/ci.yml) + +Shared Rust crate for: +- OpenVCS plugins (JSON-RPC over stdio) +- The OpenVCS client/host (shared models + backend trait surface) ## Cargo features -- `plugin-protocol` (default): JSON RPC message types for plugin stdio communication. -- `vcs`: the `Vcs` trait + `VcsError`. -- `backend-registry`: backend registry helpers (requires `vcs`). + +- `plugin-protocol` (default): JSON-RPC wire types + plugin helper modules: + - `openvcs_core::plugin_protocol` (`PluginMessage`, `RpcRequest`, `RpcResponse`) + - `openvcs_core::plugin_stdio` (read/write helpers, `respond_shared`, host calls) + - `openvcs_core::plugin_runtime` (simple request dispatch loop + handler registry) + - `openvcs_core::events` (host event subscribe/emit helpers) + - `openvcs_core::host` (bridge for calling the host over stdio) +- `vcs`: the backend trait surface: + - `openvcs_core::Vcs`, `openvcs_core::VcsError`, `openvcs_core::Result` + - `openvcs_core::models` (shared request/response/event types) + - enables `backend-registry` +- `backend-registry`: link-time backend discovery via `openvcs_core::backend_descriptor::BACKENDS` + - intended to be enabled together with `vcs` + - on `wasm32`, the registry is always empty (no `linkme` support) + +## Plugin quickstart (stdio JSON-RPC) + +Register one or more RPC handlers and run the dispatch loop: + +```rust +use openvcs_core::plugin_runtime::{register_delegate, run_registered}; +use openvcs_core::plugin_stdio::ok; + +fn main() -> std::io::Result<()> { + register_delegate("ping", |_ctx, _req| ok(serde_json::json!({ "pong": true }))); + run_registered() +} +``` + +Notes: +- `openvcs_core::{trace, debug, info, warn, error}` forward logs to the OpenVCS host when available (and also emit normal `log` records). +- Host calls from plugins go through `openvcs_core::host::call(...)` (the runtime initializes the host bridge for stdio). +- `OPENVCS_PLUGIN_HOST_TIMEOUT_MS` controls host call timeouts (default: 60000ms). + +## Development + +Common checks (matches CI): +- `just fix` (runs rustfmt + clippy) +- `cargo fmt --all -- --check` +- `cargo clippy --all-targets --all-features -- -D warnings` +- `cargo check --all-targets --all-features` +- `cargo test` +- `cargo test --no-default-features --features plugin-protocol,vcs,backend-registry` +- `cargo package` + +## License + +GPL-3.0-or-later (see `LICENSE`). diff --git a/src/backend_descriptor.rs b/src/backend_descriptor.rs index ac4d952..9b81280 100644 --- a/src/backend_descriptor.rs +++ b/src/backend_descriptor.rs @@ -1,94 +1,27 @@ -/* ========================= Runtime backend registry ========================= - Backends contribute a `BackendDescriptor` into the distributed slice below. - The app can enumerate and pick any registered backend at runtime. -=============================================================================*/ -use crate::Vcs; use crate::backend_id::BackendId; use crate::models::{Capabilities, OnEvent}; +use crate::{Result, Vcs}; use std::path::Path; use std::sync::Arc; -use std::sync::{Mutex, OnceLock}; -pub type OpenRepoFn = fn(&Path) -> crate::Result>; -pub type CloneRepoFn = fn(&str, &Path, Option) -> crate::Result>; +pub type CapsFn = fn() -> Capabilities; +pub type OpenFn = fn(&Path) -> Result>; +pub type CloneRepoFn = fn(&str, &Path, Option) -> Result>; -/// Factory & metadata for a backend implementation. +#[derive(Clone)] pub struct BackendDescriptor { pub id: BackendId, pub name: &'static str, - pub caps: fn() -> Capabilities, - pub open: OpenRepoFn, + pub caps: CapsFn, + pub open: OpenFn, pub clone_repo: CloneRepoFn, } -/// The global registry. Each backend crate declares exactly one `BackendDescriptor` here. +#[cfg(not(target_arch = "wasm32"))] #[linkme::distributed_slice] pub static BACKENDS: [BackendDescriptor] = [..]; -static DYNAMIC_BACKENDS: OnceLock>> = OnceLock::new(); - -fn dynamic_backends() -> &'static Mutex> { - DYNAMIC_BACKENDS.get_or_init(|| Mutex::new(Vec::new())) -} - -/// Register a backend at runtime (e.g., loaded from an OpenVCS plugin). -/// -/// The descriptor is leaked to produce a `'static` reference, matching the static registry API. -pub fn register_backend(descriptor: BackendDescriptor) -> &'static BackendDescriptor { - let leaked: &'static BackendDescriptor = Box::leak(Box::new(descriptor)); - let mut lock = dynamic_backends() - .lock() - .expect("openvcs-core dynamic backend lock poisoned"); - lock.push(leaked); - leaked -} - -/// Enumerate all registered backends (static + runtime-registered). -/// -/// Order is link-order for static backends, then registration order for runtime backends. -pub fn list_backends() -> Vec<&'static BackendDescriptor> { - use log::{debug, trace}; - - let mut out: Vec<&'static BackendDescriptor> = Vec::new(); - out.extend(BACKENDS.iter()); - - let lock = dynamic_backends() - .lock() - .expect("openvcs-core dynamic backend lock poisoned"); - out.extend(lock.iter().copied()); - - debug!("openvcs-core: {} backends registered", out.len()); - for b in &out { - trace!("openvcs-core: backend loaded: {} ({})", b.id, b.name); - } - - out -} - -/// Lookup a backend descriptor by id. -pub fn get_backend(id: impl AsRef) -> Option<&'static BackendDescriptor> { - use log::{debug, warn}; - - let id = id.as_ref(); - let found = BACKENDS.iter().find(|b| b.id.as_ref() == id).or_else(|| { - dynamic_backends() - .lock() - .expect("openvcs-core dynamic backend lock poisoned") - .iter() - .copied() - .find(|b| b.id.as_ref() == id) - }); - match found { - Some(b) => { - debug!( - "openvcs-core: backend lookup succeeded → {} ({})", - b.id, b.name - ); - Some(b) - } - None => { - warn!("openvcs-core: backend lookup failed for id='{}'", id); - None - } - } -} +// `linkme` distributed slices are not supported on WASI/WASM targets. +// Plugins compiled to WASI do not need link-time backend discovery. +#[cfg(target_arch = "wasm32")] +pub static BACKENDS: [BackendDescriptor; 0] = []; diff --git a/src/events.rs b/src/events.rs new file mode 100644 index 0000000..f91a9d5 --- /dev/null +++ b/src/events.rs @@ -0,0 +1,27 @@ +use crate::host; +use crate::plugin_runtime::{PluginCtx, on_event}; +use crate::plugin_stdio::PluginError; +use serde_json::Value; + +pub fn subscribe_host(name: &str) -> Result<(), PluginError> { + let _ = host::call("events.subscribe", serde_json::json!({ "name": name }))?; + Ok(()) +} + +pub fn subscribe( + name: &'static str, + mut handler: impl FnMut(Value) -> Result<(), PluginError> + Send + 'static, +) -> Result<(), PluginError> { + on_event(name, move |_ctx: &mut PluginCtx, payload: Value| { + handler(payload) + }); + subscribe_host(name) +} + +pub fn emit(name: &str, payload: Value) -> Result<(), PluginError> { + let _ = host::call( + "events.emit", + serde_json::json!({ "name": name, "payload": payload }), + )?; + Ok(()) +} diff --git a/src/host.rs b/src/host.rs new file mode 100644 index 0000000..077f19f --- /dev/null +++ b/src/host.rs @@ -0,0 +1,91 @@ +use crate::plugin_protocol::RpcRequest; +use crate::plugin_stdio::{PluginError, RequestIdState, call_host}; +use serde_json::Value; +use std::collections::VecDeque; +use std::io::{BufReader, LineWriter}; +use std::sync::{Arc, Mutex, OnceLock}; +use std::time::Duration; + +pub type HostStdout = LineWriter; +pub type HostStdin = BufReader; + +#[derive(Clone)] +struct HostContext { + out: Arc>, + stdin: Arc>, + queue: Arc>>, + ids: Arc>, + timeout: Duration, +} + +static HOST: OnceLock = OnceLock::new(); + +pub fn init_stdio_default(next_id: u64, timeout: Duration) { + let out = Arc::new(Mutex::new(LineWriter::new(std::io::stdout()))); + let stdin = Arc::new(Mutex::new(BufReader::new(std::io::stdin()))); + let queue = Arc::new(Mutex::new(VecDeque::new())); + let ids = Arc::new(Mutex::new(RequestIdState { next_id })); + init_default_stdio_host(out, stdin, queue, ids, timeout); +} + +pub fn init_default_stdio_host( + out: Arc>, + stdin: Arc>, + queue: Arc>>, + ids: Arc>, + timeout: Duration, +) { + let _ = HOST.set(HostContext { + out, + stdin, + queue, + ids, + timeout, + }); +} + +pub fn stdout() -> Result<&'static Arc>, PluginError> { + Ok(&HOST + .get() + .ok_or_else(|| PluginError::code("host.uninitialized", "host not initialized"))? + .out) +} + +pub fn stdin() -> Result<&'static Arc>, PluginError> { + Ok(&HOST + .get() + .ok_or_else(|| PluginError::code("host.uninitialized", "host not initialized"))? + .stdin) +} + +pub fn queue() -> Result<&'static Arc>>, PluginError> { + Ok(&HOST + .get() + .ok_or_else(|| PluginError::code("host.uninitialized", "host not initialized"))? + .queue) +} + +pub fn ids() -> Result<&'static Arc>, PluginError> { + Ok(&HOST + .get() + .ok_or_else(|| PluginError::code("host.uninitialized", "host not initialized"))? + .ids) +} + +pub fn call(method: &str, params: Value) -> Result { + let ctx = HOST.get().ok_or_else(|| { + PluginError::code( + "host.uninitialized", + "host bridge not initialized (call init_default_stdio_host)", + ) + })?; + call_host( + &ctx.out, + &ctx.stdin, + &ctx.queue, + &ctx.ids, + method, + params, + ctx.timeout, + ) +} diff --git a/src/lib.rs b/src/lib.rs index e24d74b..2a52338 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -5,9 +5,112 @@ pub mod models; pub use crate::backend_id::BackendId; +#[doc(hidden)] +pub use log as __log; + +#[cfg(feature = "backend-registry")] +pub mod backend_descriptor; + #[cfg(feature = "plugin-protocol")] pub mod plugin_protocol; +#[cfg(feature = "plugin-protocol")] +pub mod plugin_stdio; + +#[cfg(feature = "plugin-protocol")] +pub mod host; + +#[cfg(feature = "plugin-protocol")] +pub mod plugin_runtime; + +#[cfg(feature = "plugin-protocol")] +pub mod events; + +#[doc(hidden)] +pub fn __plugin_log_to_client(level: log::Level, target: &str, args: std::fmt::Arguments<'_>) { + #[cfg(feature = "plugin-protocol")] + { + let msg = format!("[{level}] {target}: {args}"); + if let Ok(out) = crate::host::stdout() { + crate::plugin_stdio::send_message_shared( + out, + &crate::plugin_protocol::PluginMessage::Event { + event: match level { + log::Level::Error => crate::models::VcsEvent::Error { msg }, + log::Level::Warn => crate::models::VcsEvent::Warning { msg }, + log::Level::Info | log::Level::Debug | log::Level::Trace => { + crate::models::VcsEvent::Info { msg } + } + }, + }, + ); + return; + } + } + + let _ = (level, target, args); +} + +#[macro_export] +macro_rules! trace { + (target: $target:expr, $($arg:tt)+) => {{ + $crate::__plugin_log_to_client($crate::__log::Level::Trace, $target, format_args!($($arg)+)); + $crate::__log::trace!(target: $target, $($arg)+); + }}; + ($($arg:tt)+) => {{ + $crate::__plugin_log_to_client($crate::__log::Level::Trace, module_path!(), format_args!($($arg)+)); + $crate::__log::trace!($($arg)+); + }}; +} + +#[macro_export] +macro_rules! debug { + (target: $target:expr, $($arg:tt)+) => {{ + $crate::__plugin_log_to_client($crate::__log::Level::Debug, $target, format_args!($($arg)+)); + $crate::__log::debug!(target: $target, $($arg)+); + }}; + ($($arg:tt)+) => {{ + $crate::__plugin_log_to_client($crate::__log::Level::Debug, module_path!(), format_args!($($arg)+)); + $crate::__log::debug!($($arg)+); + }}; +} + +#[macro_export] +macro_rules! info { + (target: $target:expr, $($arg:tt)+) => {{ + $crate::__plugin_log_to_client($crate::__log::Level::Info, $target, format_args!($($arg)+)); + $crate::__log::info!(target: $target, $($arg)+); + }}; + ($($arg:tt)+) => {{ + $crate::__plugin_log_to_client($crate::__log::Level::Info, module_path!(), format_args!($($arg)+)); + $crate::__log::info!($($arg)+); + }}; +} + +#[macro_export] +macro_rules! warn { + (target: $target:expr, $($arg:tt)+) => {{ + $crate::__plugin_log_to_client($crate::__log::Level::Warn, $target, format_args!($($arg)+)); + $crate::__log::warn!(target: $target, $($arg)+); + }}; + ($($arg:tt)+) => {{ + $crate::__plugin_log_to_client($crate::__log::Level::Warn, module_path!(), format_args!($($arg)+)); + $crate::__log::warn!($($arg)+); + }}; +} + +#[macro_export] +macro_rules! error { + (target: $target:expr, $($arg:tt)+) => {{ + $crate::__plugin_log_to_client($crate::__log::Level::Error, $target, format_args!($($arg)+)); + $crate::__log::error!(target: $target, $($arg)+); + }}; + ($($arg:tt)+) => {{ + $crate::__plugin_log_to_client($crate::__log::Level::Error, module_path!(), format_args!($($arg)+)); + $crate::__log::error!($($arg)+); + }}; +} + #[cfg(feature = "plugin-protocol")] pub use crate::plugin_protocol::{PluginMessage, RpcRequest, RpcResponse}; @@ -199,5 +302,27 @@ pub trait Vcs: Send + Sync { } } -#[cfg(all(feature = "backend-registry", feature = "vcs"))] -pub mod backend_descriptor; +#[cfg(test)] +mod tests { + #[test] + fn log_macros_are_callable() { + crate::trace!("trace"); + crate::debug!("debug"); + crate::info!("info"); + crate::warn!("warn"); + crate::error!("error"); + } + + #[cfg(feature = "vcs")] + #[test] + fn vcs_error_formats_useful_messages() { + let e = crate::VcsError::Unsupported(crate::BackendId::from("git")); + assert!(e.to_string().contains("unsupported backend")); + + let e = crate::VcsError::Backend { + backend: crate::BackendId::from("git"), + msg: "boom".into(), + }; + assert_eq!(e.to_string(), "git: boom"); + } +} diff --git a/src/models.rs b/src/models.rs index b270e31..586ed75 100644 --- a/src/models.rs +++ b/src/models.rs @@ -129,7 +129,9 @@ pub enum VcsEvent { Info { msg: String, }, - RemoteMessage(String), + RemoteMessage { + msg: String, + }, Progress { phase: String, detail: String, @@ -142,8 +144,12 @@ pub enum VcsEvent { refname: String, status: Option, }, - Warning(String), - Error(String), + Warning { + msg: String, + }, + Error { + msg: String, + }, } pub type OnEvent = Arc; @@ -170,4 +176,78 @@ mod tests { assert_eq!(summary.staged, 0); assert_eq!(summary.conflicted, 0); } + + #[test] + fn branch_kind_roundtrips_via_json() { + let local = BranchKind::Local; + let local_json = serde_json::to_value(&local).expect("serialize"); + let local_back: BranchKind = serde_json::from_value(local_json).expect("deserialize"); + assert_eq!(local_back, BranchKind::Local); + + let remote = BranchKind::Remote { + remote: "origin".into(), + }; + let remote_json = serde_json::to_value(&remote).expect("serialize"); + let remote_back: BranchKind = serde_json::from_value(remote_json).expect("deserialize"); + assert_eq!(remote_back, remote); + } + + #[test] + fn conflict_side_serializes_as_kebab_case_strings() { + let ours = serde_json::to_value(ConflictSide::Ours).expect("serialize"); + let theirs = serde_json::to_value(ConflictSide::Theirs).expect("serialize"); + assert_eq!(ours, serde_json::Value::String("ours".into())); + assert_eq!(theirs, serde_json::Value::String("theirs".into())); + + let ours_back: ConflictSide = serde_json::from_value(serde_json::json!("ours")).unwrap(); + let theirs_back: ConflictSide = + serde_json::from_value(serde_json::json!("theirs")).unwrap(); + assert_eq!(ours_back, ConflictSide::Ours); + assert_eq!(theirs_back, ConflictSide::Theirs); + } + + #[test] + fn file_entry_deserializes_optional_fields_with_defaults() { + let v = serde_json::json!({ + "path": "a.txt", + "status": "M", + "hunks": [] + }); + + let entry: FileEntry = serde_json::from_value(v).expect("deserialize"); + assert_eq!(entry.path, "a.txt"); + assert_eq!(entry.status, "M"); + assert!(entry.old_path.is_none()); + assert!(!entry.staged); + assert!(!entry.resolved_conflict); + assert!(entry.hunks.is_empty()); + } + + #[test] + fn vcs_event_roundtrips_via_json() { + let events = vec![ + VcsEvent::Info { + msg: "hello".into(), + }, + VcsEvent::Progress { + phase: "fetch".into(), + detail: "10/20".into(), + }, + VcsEvent::Auth { + method: "ssh".into(), + detail: "key".into(), + }, + VcsEvent::RemoteMessage { + msg: "remote".into(), + }, + VcsEvent::Warning { msg: "warn".into() }, + VcsEvent::Error { msg: "err".into() }, + ]; + + for e in events { + let v = serde_json::to_value(&e).expect("serialize"); + let back: VcsEvent = serde_json::from_value(v).expect("deserialize"); + assert_eq!(format!("{e:?}"), format!("{back:?}")); + } + } } diff --git a/src/plugin_protocol.rs b/src/plugin_protocol.rs index b0f1a3e..878c1d8 100644 --- a/src/plugin_protocol.rs +++ b/src/plugin_protocol.rs @@ -20,11 +20,73 @@ pub struct RpcResponse { pub result: Value, #[serde(default)] pub error: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub error_code: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub error_data: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(untagged)] pub enum PluginMessage { + Request(RpcRequest), Response(RpcResponse), Event { event: VcsEvent }, } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn rpc_request_params_defaults_to_null() { + let req: RpcRequest = + serde_json::from_str(r#"{"id":1,"method":"ping"}"#).expect("valid request"); + assert_eq!(req.id, 1); + assert_eq!(req.method, "ping"); + assert_eq!(req.params, serde_json::Value::Null); + } + + #[test] + fn rpc_response_fields_default_when_missing() { + let resp: RpcResponse = + serde_json::from_str(r#"{"id":9,"ok":true}"#).expect("valid response"); + assert_eq!(resp.id, 9); + assert!(resp.ok); + assert_eq!(resp.result, serde_json::Value::Null); + assert!(resp.error.is_none()); + assert!(resp.error_code.is_none()); + assert!(resp.error_data.is_none()); + } + + #[test] + fn rpc_response_skips_optional_error_fields_when_none() { + let resp = RpcResponse { + id: 1, + ok: false, + result: serde_json::Value::Null, + error: Some("boom".into()), + error_code: None, + error_data: None, + }; + + let value = serde_json::to_value(&resp).expect("serializes"); + assert!(value.get("error").is_some()); + assert!(value.get("error_code").is_none()); + assert!(value.get("error_data").is_none()); + } + + #[test] + fn plugin_message_deserializes_event_variant() { + let msg: PluginMessage = serde_json::from_str(r#"{"event":{"type":"info","msg":"hello"}}"#) + .expect("valid event message"); + + match msg { + PluginMessage::Event { event } => match event { + VcsEvent::Info { msg } => assert_eq!(msg, "hello"), + other => panic!("unexpected event: {other:?}"), + }, + other => panic!("unexpected message: {other:?}"), + } + } +} diff --git a/src/plugin_runtime.rs b/src/plugin_runtime.rs new file mode 100644 index 0000000..4943526 --- /dev/null +++ b/src/plugin_runtime.rs @@ -0,0 +1,374 @@ +use crate::models::VcsEvent; +use crate::plugin_protocol::{PluginMessage, RpcRequest}; +use crate::plugin_stdio::ok_null; +use crate::plugin_stdio::{PluginError, receive_message, respond_shared, send_message_shared}; +use std::collections::HashMap; +use std::collections::VecDeque; +use std::io::{self, BufReader, LineWriter}; +use std::sync::{Arc, Mutex, OnceLock}; +use std::time::Duration; + +pub type HandlerResult = Result; +pub type EventHandlerResult = Result<(), PluginError>; + +const DEFAULT_HOST_TIMEOUT_MS: u64 = 60_000; +const HOST_TIMEOUT_ENV: &str = "OPENVCS_PLUGIN_HOST_TIMEOUT_MS"; + +fn host_timeout() -> Duration { + std::env::var(HOST_TIMEOUT_ENV) + .ok() + .and_then(|s| s.parse::().ok()) + .filter(|&ms| ms > 0) + .map(Duration::from_millis) + .unwrap_or(Duration::from_millis(DEFAULT_HOST_TIMEOUT_MS)) +} + +pub struct PluginCtx { + stdout: Arc>>, +} + +impl PluginCtx { + pub fn emit(&self, event: VcsEvent) { + send_message_shared(&self.stdout, &PluginMessage::Event { event }); + } + + pub fn stdout(&self) -> Arc>> { + Arc::clone(&self.stdout) + } +} + +fn next_request( + queue: &Arc>>, + stdin: &Arc>>, +) -> Option { + if let Ok(mut q) = queue.lock() + && let Some(req) = q.pop_front() + { + return Some(req); + } + + loop { + let msg = { + let mut lock = stdin.lock().ok()?; + receive_message(&mut *lock)? + }; + match msg { + PluginMessage::Request(req) => return Some(req), + PluginMessage::Response(_) | PluginMessage::Event { .. } => continue, + } + } +} + +pub struct PluginRuntime { + ctx: PluginCtx, + stdin: Arc>>, + queue: Arc>>, +} + +impl PluginRuntime { + pub fn init() -> Self { + #[cfg(target_arch = "wasm32")] + let next_id = 1u64 << 63; + + #[cfg(not(target_arch = "wasm32"))] + let next_id = 1u64; + + let timeout = host_timeout(); + + let stdout = Arc::new(Mutex::new(LineWriter::new(io::stdout()))); + let stdin = Arc::new(Mutex::new(BufReader::new(io::stdin()))); + let queue: Arc>> = Arc::new(Mutex::new(VecDeque::new())); + let ids = Arc::new(Mutex::new(crate::plugin_stdio::RequestIdState { next_id })); + + crate::host::init_default_stdio_host( + Arc::clone(&stdout), + Arc::clone(&stdin), + Arc::clone(&queue), + Arc::clone(&ids), + timeout, + ); + + Self { + ctx: PluginCtx { stdout }, + stdin, + queue, + } + } + + pub fn ctx(&mut self) -> &mut PluginCtx { + &mut self.ctx + } + + pub(crate) fn tick( + &mut self, + mut handle: impl FnMut(&mut PluginCtx, RpcRequest) -> HandlerResult, + ) -> io::Result { + let Some(req) = next_request(&self.queue, &self.stdin) else { + return Ok(false); + }; + let id = req.id; + let res = handle(&mut self.ctx, req); + respond_shared(&self.ctx.stdout, id, res); + Ok(true) + } +} + +type RpcHandler = Box HandlerResult + Send + 'static>; +type EventHandler = + Box EventHandlerResult + Send + 'static>; + +struct Registry { + rpc: HashMap, + event: HashMap>, + fallback: Option, +} + +static REGISTRY: OnceLock> = OnceLock::new(); + +fn registry() -> &'static Mutex { + REGISTRY.get_or_init(|| { + Mutex::new(Registry { + rpc: HashMap::new(), + event: HashMap::new(), + fallback: None, + }) + }) +} + +fn register_rpc_impl( + method: &str, + handler: impl FnMut(&mut PluginCtx, RpcRequest) -> HandlerResult + Send + 'static, +) { + if let Ok(mut lock) = registry().lock() { + lock.rpc.insert(method.to_string(), Box::new(handler)); + } +} + +pub fn register_delegate( + method: &str, + handler: impl FnMut(&mut PluginCtx, RpcRequest) -> HandlerResult + Send + 'static, +) { + register_rpc_impl(method, handler) +} + +/// Backward-compatible alias (will be removed once callers migrate). +#[deprecated(note = "renamed to register_delegate")] +pub fn on_rpc( + method: &str, + handler: impl FnMut(&mut PluginCtx, RpcRequest) -> HandlerResult + Send + 'static, +) { + register_rpc_impl(method, handler) +} + +#[deprecated(note = "renamed to register_delegate")] +pub fn register_rpc( + method: &str, + handler: impl FnMut(&mut PluginCtx, RpcRequest) -> HandlerResult + Send + 'static, +) { + register_rpc_impl(method, handler) +} + +pub fn on_event( + name: &str, + handler: impl FnMut(&mut PluginCtx, serde_json::Value) -> EventHandlerResult + Send + 'static, +) { + if let Ok(mut lock) = registry().lock() { + lock.event + .entry(name.to_string()) + .or_default() + .push(Box::new(handler)); + } +} + +pub fn set_fallback_rpc( + handler: impl FnMut(&mut PluginCtx, RpcRequest) -> HandlerResult + Send + 'static, +) { + if let Ok(mut lock) = registry().lock() { + lock.fallback = Some(Box::new(handler)); + } +} + +fn dispatch_registered(ctx: &mut PluginCtx, req: RpcRequest) -> HandlerResult { + if req.method == "event.dispatch" { + #[derive(serde::Deserialize)] + struct P { + name: String, + #[serde(default)] + payload: serde_json::Value, + } + let p: P = + crate::plugin_stdio::parse_json_params(req.params).map_err(PluginError::message)?; + + if let Ok(mut lock) = registry().lock() + && let Some(handlers) = lock.event.get_mut(&p.name) + { + for h in handlers.iter_mut() { + h(ctx, p.payload.clone())?; + } + } + return ok_null(); + } + + if let Ok(mut lock) = registry().lock() { + if let Some(h) = lock.rpc.get_mut(&req.method) { + return h(ctx, req); + } + if let Some(fallback) = lock.fallback.as_mut() { + return fallback(ctx, req); + } + } + + Err(PluginError::code( + "plugin.unknown_method", + format!("unknown method '{}'", req.method), + )) +} + +pub fn run_registered() -> io::Result<()> { + let mut rt = PluginRuntime::init(); + while rt.tick(dispatch_registered)? {} + Ok(()) +} + +pub fn run(mut handle: impl FnMut(&mut PluginCtx, RpcRequest) -> HandlerResult) -> io::Result<()> { + let mut rt = PluginRuntime::init(); + while rt.tick(|ctx, req| handle(ctx, req))? {} + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::OnceLock; + + static TEST_LOCK: OnceLock> = OnceLock::new(); + + fn with_test_lock(f: impl FnOnce() -> T) -> T { + let lock = TEST_LOCK.get_or_init(|| Mutex::new(())); + let _guard = lock.lock().expect("test lock"); + f() + } + + fn reset_registry() { + if let Ok(mut lock) = registry().lock() { + lock.rpc.clear(); + lock.event.clear(); + lock.fallback = None; + } + } + + fn test_ctx() -> PluginCtx { + PluginCtx { + stdout: Arc::new(Mutex::new(LineWriter::new(io::stdout()))), + } + } + + #[test] + fn dispatch_registered_routes_to_rpc_handler() { + with_test_lock(|| { + reset_registry(); + register_delegate("ping", |_ctx, req| { + assert_eq!(req.method, "ping"); + crate::plugin_stdio::ok(serde_json::json!({"pong": true})) + }); + + let mut ctx = test_ctx(); + let req = RpcRequest { + id: 1, + method: "ping".into(), + params: serde_json::Value::Null, + }; + let res = dispatch_registered(&mut ctx, req).expect("ok"); + assert_eq!(res, serde_json::json!({"pong": true})); + }); + } + + #[test] + fn dispatch_registered_uses_fallback_when_no_handler() { + with_test_lock(|| { + reset_registry(); + set_fallback_rpc(|_ctx, req| { + crate::plugin_stdio::ok(serde_json::json!({ "method": req.method })) + }); + + let mut ctx = test_ctx(); + let req = RpcRequest { + id: 1, + method: "unknown".into(), + params: serde_json::Value::Null, + }; + + let res = dispatch_registered(&mut ctx, req).expect("ok"); + assert_eq!(res, serde_json::json!({ "method": "unknown" })); + }); + } + + #[test] + fn dispatch_registered_returns_unknown_method_error_when_unhandled() { + with_test_lock(|| { + reset_registry(); + + let mut ctx = test_ctx(); + let req = RpcRequest { + id: 1, + method: "unknown".into(), + params: serde_json::Value::Null, + }; + + let err = dispatch_registered(&mut ctx, req).expect_err("should error"); + assert_eq!(err.code.as_deref(), Some("plugin.unknown_method")); + assert!(err.message.contains("unknown method")); + }); + } + + #[test] + fn dispatch_registered_event_dispatch_invokes_handlers() { + with_test_lock(|| { + reset_registry(); + + let seen: Arc>> = Arc::new(Mutex::new(Vec::new())); + let seen_1 = Arc::clone(&seen); + on_event("evt", move |_ctx, payload| { + if let Ok(mut lock) = seen_1.lock() { + lock.push(payload); + } + Ok(()) + }); + + let mut ctx = test_ctx(); + let req = RpcRequest { + id: 1, + method: "event.dispatch".into(), + params: serde_json::json!({ "name": "evt", "payload": { "x": 1 } }), + }; + + let res = dispatch_registered(&mut ctx, req).expect("ok"); + assert_eq!(res, serde_json::Value::Null); + + let lock = seen.lock().expect("lock"); + assert_eq!(lock.as_slice(), &[serde_json::json!({ "x": 1 })]); + }); + } + + #[test] + fn dispatch_registered_event_dispatch_propagates_handler_error() { + with_test_lock(|| { + reset_registry(); + + on_event("evt", |_ctx, _payload| { + Err(PluginError::code("evt.fail", "nope")) + }); + + let mut ctx = test_ctx(); + let req = RpcRequest { + id: 1, + method: "event.dispatch".into(), + params: serde_json::json!({ "name": "evt", "payload": null }), + }; + + let err = dispatch_registered(&mut ctx, req).expect_err("should error"); + assert_eq!(err.code.as_deref(), Some("evt.fail")); + assert_eq!(err.message, "nope"); + }); + } +} diff --git a/src/plugin_stdio.rs b/src/plugin_stdio.rs new file mode 100644 index 0000000..ffcca73 --- /dev/null +++ b/src/plugin_stdio.rs @@ -0,0 +1,326 @@ +use crate::plugin_protocol::{PluginMessage, RpcRequest, RpcResponse}; +use serde::Serialize; +use serde::de::DeserializeOwned; +use serde_json::Value; +use std::collections::{HashMap, VecDeque}; +use std::io::{self, BufRead, Write}; +use std::sync::{Arc, Mutex}; +use std::time::{Duration, Instant}; + +#[derive(Debug, Clone)] +pub struct PluginError { + pub code: Option, + pub message: String, + pub data: Option, +} + +impl PluginError { + pub fn message(message: impl Into) -> Self { + Self { + code: None, + message: message.into(), + data: None, + } + } + + pub fn code(code: impl Into, message: impl Into) -> Self { + Self { + code: Some(code.into()), + message: message.into(), + data: None, + } + } + + pub fn with_data(mut self, data: Value) -> Self { + self.data = Some(data); + self + } +} + +pub fn err_display(err: impl std::fmt::Display) -> PluginError { + PluginError::message(err.to_string()) +} + +pub fn receive_message(stdin: &mut R) -> Option { + let mut line = String::new(); + loop { + line.clear(); + let n = stdin.read_line(&mut line).ok()?; + if n == 0 { + return None; + } + let trimmed = line.trim(); + if trimmed.is_empty() { + continue; + } + if let Ok(msg) = serde_json::from_str::(trimmed) { + return Some(msg); + } + } +} + +pub fn write_message(out: &mut W, msg: &PluginMessage) -> io::Result<()> { + let line = serde_json::to_string(msg).unwrap_or_else(|_| "{}".into()); + writeln!(out, "{line}")?; + out.flush()?; + Ok(()) +} + +pub fn send_message_shared(out: &Arc>, msg: &PluginMessage) { + if let Ok(mut w) = out.lock() { + let _ = write_message(&mut *w, msg); + } +} + +pub fn send_request_shared(out: &Arc>, req: RpcRequest) { + send_message_shared(out, &PluginMessage::Request(req)); +} + +pub fn send_request(out: &mut W, req: RpcRequest) -> io::Result<()> { + write_message(out, &PluginMessage::Request(req)) +} + +pub fn receive_request(stdin: &mut R) -> Option { + loop { + match receive_message(stdin)? { + PluginMessage::Request(req) => return Some(req), + PluginMessage::Response(_) | PluginMessage::Event { .. } => {} + } + } +} + +pub fn respond_shared(out: &Arc>, id: u64, res: Result) { + let response = match res { + Ok(result) => RpcResponse { + id, + ok: true, + result, + error: None, + error_code: None, + error_data: None, + }, + Err(err) => RpcResponse { + id, + ok: false, + result: Value::Null, + error: Some(err.message), + error_code: err.code, + error_data: err.data, + }, + }; + + send_message_shared(out, &PluginMessage::Response(response)); +} + +pub fn ok(value: T) -> Result { + serde_json::to_value(value).map_err(|e| PluginError::code("plugin.serialize", e.to_string())) +} + +pub fn ok_null() -> Result { + Ok(Value::Null) +} + +pub fn parse_json_params(value: Value) -> Result { + serde_json::from_value(value).map_err(|e| format!("invalid params: {e}")) +} + +#[derive(Debug)] +pub struct RequestIdState { + pub next_id: u64, +} + +pub fn call_host( + out: &Arc>, + stdin: &Arc>, + queue: &Arc>>, + ids: &Arc>, + method: &str, + params: Value, + timeout: Duration, +) -> Result { + let id = { + let mut lock = ids + .lock() + .map_err(|_| PluginError::message("pending lock poisoned"))?; + let id = lock.next_id; + lock.next_id = lock.next_id.saturating_add(1); + id + }; + + send_request_shared( + out, + crate::plugin_protocol::RpcRequest { + id, + method: method.to_string(), + params, + }, + ); + + let deadline = Instant::now() + timeout; + let mut stash: HashMap = HashMap::new(); + + loop { + if Instant::now() > deadline { + return Err(PluginError::code("host.timeout", "host call timed out")); + } + + if let Some(resp) = stash.remove(&id) { + return if resp.ok { + Ok(resp.result) + } else { + Err(PluginError { + code: resp.error_code.or(Some("host.error".into())), + message: resp.error.unwrap_or_else(|| "error".into()), + data: resp.error_data, + }) + }; + } + + let msg = { + let mut lock = stdin + .lock() + .map_err(|_| PluginError::message("stdin lock poisoned"))?; + receive_message(&mut *lock).ok_or_else(|| PluginError::message("host closed stdin"))? + }; + + match msg { + PluginMessage::Response(resp) => { + if resp.id == id { + return if resp.ok { + Ok(resp.result) + } else { + Err(PluginError { + code: resp.error_code.or(Some("host.error".into())), + message: resp.error.unwrap_or_else(|| "error".into()), + data: resp.error_data, + }) + }; + } + stash.insert(resp.id, resp); + } + PluginMessage::Request(req) => { + if let Ok(mut q) = queue.lock() { + q.push_back(req); + } + } + PluginMessage::Event { .. } => {} + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::models::VcsEvent; + use std::io::Cursor; + + #[test] + fn plugin_error_builders_set_fields() { + let err = PluginError::message("nope"); + assert!(err.code.is_none()); + assert_eq!(err.message, "nope"); + assert!(err.data.is_none()); + + let err = PluginError::code("x.y", "bad").with_data(serde_json::json!({"k": 1})); + assert_eq!(err.code.as_deref(), Some("x.y")); + assert_eq!(err.message, "bad"); + assert_eq!(err.data, Some(serde_json::json!({"k": 1}))); + } + + #[test] + fn receive_message_skips_blank_and_invalid_lines() { + let input = b"\n \nnot json\n{\"id\":1,\"method\":\"ping\"}\n"; + let mut cursor = Cursor::new(&input[..]); + + let msg = receive_message(&mut cursor).expect("message"); + match msg { + PluginMessage::Request(req) => { + assert_eq!(req.id, 1); + assert_eq!(req.method, "ping"); + assert_eq!(req.params, Value::Null); + } + other => panic!("unexpected message: {other:?}"), + } + } + + #[test] + fn receive_request_ignores_non_request_messages() { + let input = b"{\"id\":7,\"ok\":true,\"result\":null}\n{\"event\":{\"type\":\"info\",\"msg\":\"hi\"}}\n{\"id\":1,\"method\":\"ping\"}\n"; + let mut cursor = Cursor::new(&input[..]); + + let req = receive_request(&mut cursor).expect("request"); + assert_eq!(req.id, 1); + assert_eq!(req.method, "ping"); + } + + #[test] + fn write_message_writes_one_json_line() { + let msg = PluginMessage::Event { + event: VcsEvent::Info { + msg: "hello".into(), + }, + }; + + let mut out = Vec::::new(); + write_message(&mut out, &msg).expect("write ok"); + assert!(out.ends_with(b"\n")); + + let line = std::str::from_utf8(&out).expect("utf-8"); + let parsed: PluginMessage = serde_json::from_str(line.trim()).expect("valid message"); + match parsed { + PluginMessage::Event { event } => match event { + VcsEvent::Info { msg } => assert_eq!(msg, "hello"), + other => panic!("unexpected event: {other:?}"), + }, + other => panic!("unexpected message: {other:?}"), + } + } + + #[test] + fn parse_json_params_errors_are_prefixed() { + let err = parse_json_params::>(Value::String("x".into())) + .expect_err("should fail"); + assert!(err.starts_with("invalid params:")); + } + + #[test] + fn call_host_returns_ok_and_queues_incoming_requests() { + let out = Arc::new(Mutex::new(Vec::::new())); + let stdin = Arc::new(Mutex::new(Cursor::new( + b"{\"id\":999,\"ok\":true,\"result\":{\"ignored\":true}}\n\ + {\"id\":77,\"method\":\"noop\",\"params\":null}\n\ + {\"id\":5,\"ok\":true,\"result\":{\"answer\":42}}\n" as &[u8], + ))); + let queue = Arc::new(Mutex::new(VecDeque::::new())); + let ids = Arc::new(Mutex::new(RequestIdState { next_id: 5 })); + + let result = call_host( + &out, + &stdin, + &queue, + &ids, + "math.answer", + serde_json::json!({}), + Duration::from_secs(1), + ) + .expect("host call ok"); + assert_eq!(result, serde_json::json!({"answer": 42})); + + let queue = queue.lock().expect("queue lock"); + assert_eq!(queue.len(), 1); + assert_eq!(queue[0].id, 77); + assert_eq!(queue[0].method, "noop"); + + let out = out.lock().expect("out lock"); + let line = std::str::from_utf8(&out).expect("utf-8"); + let first = line.lines().next().expect("at least one line"); + let sent: PluginMessage = serde_json::from_str(first).expect("valid sent message"); + match sent { + PluginMessage::Request(req) => { + assert_eq!(req.id, 5); + assert_eq!(req.method, "math.answer"); + } + other => panic!("unexpected sent message: {other:?}"), + } + } +}