//! Typed contract for `/crdt-sync` WS-RPC methods. //! //! Each RPC method has a typed params struct and a typed result struct. //! Handlers deserialize the incoming params into the typed struct, do their //! work, and serialise a typed result struct back. Frontend code mirrors //! these definitions in `frontend/src/api/rpcContract.ts`; the //! `rpc_contract_snapshot` test in this module emits a canonical JSON snapshot //! of every method's params + result. If the Rust and TS shapes drift, the //! snapshot drifts too and the test fails — surfacing the mismatch in CI. //! //! When adding a method: //! 1. Add typed `*_params` and `*_result` structs here. //! 2. Add it to the [`CONTRACT_METHODS`] slice with a fixture for params + result. //! 3. Mirror the structs and the method name in //! `frontend/src/api/rpcContract.ts`. use serde::{Deserialize, Serialize}; // ── Params types ───────────────────────────────────────────────────────────── /// Params for `model.set_preference`. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct SetModelPreferenceParams { pub model: String, } /// Params for `anthropic.set_api_key`. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct SetAnthropicApiKeyParams { pub api_key: String, } /// Params for `settings.put_editor`. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct PutEditorParams { pub editor_command: Option, } /// Params for `settings.open_file`. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct OpenFileParams { pub path: String, pub line: Option, } /// Params for `project.open`. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct OpenProjectParams { pub path: String, } /// Params for `project.forget`. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct ForgetProjectParams { pub path: String, } /// Params for `bot_config.save`. Shape mirrors the response from `bot_config.get`. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct BotConfigPayload { pub transport: Option, pub enabled: Option, pub homeserver: Option, pub username: Option, pub password: Option, pub room_ids: Option>, pub slack_bot_token: Option, pub slack_signing_secret: Option, pub slack_channel_ids: Option>, } /// Params (and result) for `settings.put_project`. Mirrors the /// `settings.get_project` shape. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] pub struct ProjectSettingsPayload { pub default_qa: String, pub default_coder_model: Option, pub max_coders: Option, pub max_retries: u32, pub base_branch: Option, pub rate_limit_notifications: bool, pub timezone: Option, pub rendezvous: Option, pub watcher_sweep_interval_secs: u64, pub watcher_done_retention_secs: u64, } // ── Result types ───────────────────────────────────────────────────────────── /// Result envelope for write methods that simply succeed or fail. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct OkResult { pub ok: bool, } /// Result for `settings.put_editor`. Mirrors `settings.get_editor` envelope. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct EditorSettingsResult { pub editor_command: Option, } /// Result for `project.open` — returns the canonical path string. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct OpenProjectResult { pub path: String, } // ── Contract metadata ──────────────────────────────────────────────────────── /// One entry per registered RPC method, used by the drift-snapshot test. #[cfg(test)] pub struct ContractEntry { pub method: &'static str, pub params_example: fn() -> serde_json::Value, pub result_example: fn() -> serde_json::Value, } /// Canonical fixtures for every typed write method. The TS-side /// `rpcContract.ts` file MUST expose the same shapes for every entry. #[cfg(test)] pub static CONTRACT_METHODS: &[ContractEntry] = &[ ContractEntry { method: "model.set_preference", params_example: || { serde_json::to_value(SetModelPreferenceParams { model: "claude-sonnet-4-6".to_string(), }) .unwrap() }, result_example: || serde_json::to_value(OkResult { ok: true }).unwrap(), }, ContractEntry { method: "anthropic.set_api_key", params_example: || { serde_json::to_value(SetAnthropicApiKeyParams { api_key: "sk-ant-...".to_string(), }) .unwrap() }, result_example: || serde_json::to_value(OkResult { ok: true }).unwrap(), }, ContractEntry { method: "settings.put_editor", params_example: || { serde_json::to_value(PutEditorParams { editor_command: Some("zed".to_string()), }) .unwrap() }, result_example: || { serde_json::to_value(EditorSettingsResult { editor_command: Some("zed".to_string()), }) .unwrap() }, }, ContractEntry { method: "settings.open_file", params_example: || { serde_json::to_value(OpenFileParams { path: "src/main.rs".to_string(), line: Some(42), }) .unwrap() }, result_example: || serde_json::to_value(OkResult { ok: true }).unwrap(), }, ContractEntry { method: "settings.put_project", params_example: || { serde_json::to_value(ProjectSettingsPayload { default_qa: "server".to_string(), default_coder_model: None, max_coders: None, max_retries: 2, base_branch: None, rate_limit_notifications: true, timezone: None, rendezvous: None, watcher_sweep_interval_secs: 60, watcher_done_retention_secs: 86_400, }) .unwrap() }, result_example: || { serde_json::to_value(ProjectSettingsPayload { default_qa: "server".to_string(), default_coder_model: None, max_coders: None, max_retries: 2, base_branch: None, rate_limit_notifications: true, timezone: None, rendezvous: None, watcher_sweep_interval_secs: 60, watcher_done_retention_secs: 86_400, }) .unwrap() }, }, ContractEntry { method: "project.open", params_example: || { serde_json::to_value(OpenProjectParams { path: "/path/to/project".to_string(), }) .unwrap() }, result_example: || { serde_json::to_value(OpenProjectResult { path: "/path/to/project".to_string(), }) .unwrap() }, }, ContractEntry { method: "project.close", params_example: || serde_json::json!({}), result_example: || serde_json::to_value(OkResult { ok: true }).unwrap(), }, ContractEntry { method: "project.forget", params_example: || { serde_json::to_value(ForgetProjectParams { path: "/path/to/project".to_string(), }) .unwrap() }, result_example: || serde_json::to_value(OkResult { ok: true }).unwrap(), }, ContractEntry { method: "bot_config.save", params_example: || { serde_json::to_value(BotConfigPayload { transport: Some("matrix".to_string()), enabled: Some(true), homeserver: Some("https://matrix.example".to_string()), username: Some("bot".to_string()), password: Some("secret".to_string()), room_ids: Some(vec!["!room:example".to_string()]), slack_bot_token: None, slack_signing_secret: None, slack_channel_ids: None, }) .unwrap() }, result_example: || { serde_json::to_value(BotConfigPayload { transport: Some("matrix".to_string()), enabled: Some(true), homeserver: Some("https://matrix.example".to_string()), username: Some("bot".to_string()), password: Some("secret".to_string()), room_ids: Some(vec!["!room:example".to_string()]), slack_bot_token: None, slack_signing_secret: None, slack_channel_ids: None, }) .unwrap() }, }, ContractEntry { method: "chat.cancel", params_example: || serde_json::json!({}), result_example: || serde_json::to_value(OkResult { ok: true }).unwrap(), }, ]; /// Build the canonical snapshot of the typed RPC contract. /// /// The snapshot is a JSON object keyed by method name; each value contains /// `params` and `result` example payloads. The frontend imports this same /// snapshot via `frontend/src/api/rpcContract.snapshot.json`. When the /// frontend types drift from the Rust types, the snapshots disagree and /// `rpc_contract_snapshot_matches_committed_file` fails. #[cfg(test)] pub fn build_contract_snapshot() -> serde_json::Value { let mut obj = serde_json::Map::new(); for entry in CONTRACT_METHODS { let mut method_obj = serde_json::Map::new(); method_obj.insert("params".to_string(), (entry.params_example)()); method_obj.insert("result".to_string(), (entry.result_example)()); obj.insert( entry.method.to_string(), serde_json::Value::Object(method_obj), ); } serde_json::Value::Object(obj) } #[cfg(test)] mod tests { use super::*; use std::path::PathBuf; fn snapshot_path() -> PathBuf { let manifest = PathBuf::from(env!("CARGO_MANIFEST_DIR")); manifest .parent() .expect("workspace root") .join("frontend") .join("src") .join("api") .join("rpcContract.snapshot.json") } /// Guards that the Rust-side typed contract matches the committed /// frontend snapshot. The snapshot file is consumed by the TS contract, /// so any drift between Rust types and TS types fails this test. /// /// To update: run with `UPDATE_RPC_CONTRACT_SNAPSHOT=1` set. #[test] fn rpc_contract_snapshot_matches_committed_file() { let current = build_contract_snapshot(); let rendered = serde_json::to_string_pretty(¤t).expect("snapshot must serialise") + "\n"; let path = snapshot_path(); if std::env::var("UPDATE_RPC_CONTRACT_SNAPSHOT").is_ok() { std::fs::write(&path, &rendered).expect("write snapshot"); return; } let on_disk = std::fs::read_to_string(&path).unwrap_or_default(); assert_eq!( on_disk.trim(), rendered.trim(), "RPC contract snapshot drift at {} — run UPDATE_RPC_CONTRACT_SNAPSHOT=1 cargo test to regenerate, then update frontend/src/api/rpcContract.ts to match", path.display(), ); } }