333 lines
12 KiB
Rust
333 lines
12 KiB
Rust
|
|
//! 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<String>,
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Params for `settings.open_file`.
|
||
|
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||
|
|
pub struct OpenFileParams {
|
||
|
|
pub path: String,
|
||
|
|
pub line: Option<u32>,
|
||
|
|
}
|
||
|
|
|
||
|
|
/// 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<String>,
|
||
|
|
pub enabled: Option<bool>,
|
||
|
|
pub homeserver: Option<String>,
|
||
|
|
pub username: Option<String>,
|
||
|
|
pub password: Option<String>,
|
||
|
|
pub room_ids: Option<Vec<String>>,
|
||
|
|
pub slack_bot_token: Option<String>,
|
||
|
|
pub slack_signing_secret: Option<String>,
|
||
|
|
pub slack_channel_ids: Option<Vec<String>>,
|
||
|
|
}
|
||
|
|
|
||
|
|
/// 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<String>,
|
||
|
|
pub max_coders: Option<u32>,
|
||
|
|
pub max_retries: u32,
|
||
|
|
pub base_branch: Option<String>,
|
||
|
|
pub rate_limit_notifications: bool,
|
||
|
|
pub timezone: Option<String>,
|
||
|
|
pub rendezvous: Option<String>,
|
||
|
|
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<String>,
|
||
|
|
}
|
||
|
|
|
||
|
|
/// 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(),
|
||
|
|
);
|
||
|
|
}
|
||
|
|
}
|