huskies: merge 949
This commit is contained in:
@@ -62,6 +62,7 @@ mod client;
|
||||
mod dispatch;
|
||||
mod handshake;
|
||||
mod rpc;
|
||||
mod rpc_contract;
|
||||
mod server;
|
||||
mod wire;
|
||||
|
||||
|
||||
@@ -31,6 +31,11 @@ use std::sync::{Arc, OnceLock};
|
||||
|
||||
use serde_json::Value;
|
||||
|
||||
use super::rpc_contract::{
|
||||
BotConfigPayload, EditorSettingsResult, ForgetProjectParams, OkResult, OpenFileParams,
|
||||
OpenProjectParams, OpenProjectResult, ProjectSettingsPayload, PutEditorParams,
|
||||
SetAnthropicApiKeyParams, SetModelPreferenceParams,
|
||||
};
|
||||
use super::wire::RpcFrame;
|
||||
use crate::state::SessionState;
|
||||
use crate::store::JsonFileStore;
|
||||
@@ -119,8 +124,50 @@ static HANDLERS: &[(&str, Handler)] = &[
|
||||
("agents.get_output", |p| {
|
||||
Box::pin(handle_agents_get_output(p))
|
||||
}),
|
||||
// ── typed write methods ──────────────────────────────────────────────
|
||||
("model.set_preference", |p| {
|
||||
Box::pin(handle_model_set_preference(p))
|
||||
}),
|
||||
("anthropic.set_api_key", |p| {
|
||||
Box::pin(handle_anthropic_set_api_key(p))
|
||||
}),
|
||||
("settings.put_editor", |p| {
|
||||
Box::pin(handle_settings_put_editor(p))
|
||||
}),
|
||||
("settings.open_file", |p| {
|
||||
Box::pin(handle_settings_open_file(p))
|
||||
}),
|
||||
("settings.put_project", |p| {
|
||||
Box::pin(handle_settings_put_project(p))
|
||||
}),
|
||||
("project.open", |p| Box::pin(handle_project_open(p))),
|
||||
("project.close", |p| Box::pin(handle_project_close(p))),
|
||||
("project.forget", |p| Box::pin(handle_project_forget(p))),
|
||||
("bot_config.save", |p| Box::pin(handle_bot_config_save(p))),
|
||||
("chat.cancel", |p| Box::pin(handle_chat_cancel(p))),
|
||||
];
|
||||
|
||||
// ── typed-write helper macros ───────────────────────────────────────────────
|
||||
|
||||
/// Parse the incoming JSON params into a typed struct or return a typed error
|
||||
/// response shaped as `{"error": "..."}`.
|
||||
macro_rules! parse_params {
|
||||
($params:expr, $ty:ty) => {
|
||||
match serde_json::from_value::<$ty>($params) {
|
||||
Ok(v) => v,
|
||||
Err(e) => return serde_json::json!({"error": format!("invalid params: {e}")}),
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
fn ok_json() -> Value {
|
||||
serde_json::to_value(OkResult { ok: true }).unwrap_or(Value::Null)
|
||||
}
|
||||
|
||||
fn err_json(msg: impl Into<String>) -> Value {
|
||||
serde_json::json!({"error": msg.into()})
|
||||
}
|
||||
|
||||
// ── handlers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Handler for the `health.check` method.
|
||||
@@ -532,6 +579,205 @@ async fn handle_agents_get_output(params: Value) -> Value {
|
||||
}
|
||||
}
|
||||
|
||||
// ── typed write handlers ────────────────────────────────────────────────────
|
||||
|
||||
/// Handler for `model.set_preference`. Persists the user's chosen LLM model.
|
||||
async fn handle_model_set_preference(params: Value) -> Value {
|
||||
let typed = parse_params!(params, SetModelPreferenceParams);
|
||||
let Some(ctx) = RPC_CTX.get() else {
|
||||
return err_json("RPC context not initialised");
|
||||
};
|
||||
match crate::io::fs::set_model_preference(typed.model, ctx.store.as_ref()) {
|
||||
Ok(()) => ok_json(),
|
||||
Err(e) => err_json(e),
|
||||
}
|
||||
}
|
||||
|
||||
/// Handler for `anthropic.set_api_key`. Stores an Anthropic API key.
|
||||
async fn handle_anthropic_set_api_key(params: Value) -> Value {
|
||||
let typed = parse_params!(params, SetAnthropicApiKeyParams);
|
||||
let Some(ctx) = RPC_CTX.get() else {
|
||||
return err_json("RPC context not initialised");
|
||||
};
|
||||
match crate::service::anthropic::set_api_key(ctx.store.as_ref(), typed.api_key) {
|
||||
Ok(()) => ok_json(),
|
||||
Err(e) => err_json(e.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Handler for `settings.put_editor`. Updates the configured editor command.
|
||||
async fn handle_settings_put_editor(params: Value) -> Value {
|
||||
use crate::store::StoreOps;
|
||||
let typed = parse_params!(params, PutEditorParams);
|
||||
let Some(ctx) = RPC_CTX.get() else {
|
||||
return err_json("RPC context not initialised");
|
||||
};
|
||||
let trimmed = typed
|
||||
.editor_command
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(|s| s.to_string());
|
||||
match trimmed {
|
||||
Some(cmd) => {
|
||||
ctx.store.set(
|
||||
crate::service::settings::EDITOR_COMMAND_KEY,
|
||||
serde_json::json!(cmd.clone()),
|
||||
);
|
||||
if let Err(e) = ctx.store.save() {
|
||||
return err_json(e);
|
||||
}
|
||||
serde_json::to_value(EditorSettingsResult {
|
||||
editor_command: Some(cmd),
|
||||
})
|
||||
.unwrap_or(Value::Null)
|
||||
}
|
||||
None => {
|
||||
ctx.store
|
||||
.delete(crate::service::settings::EDITOR_COMMAND_KEY);
|
||||
if let Err(e) = ctx.store.save() {
|
||||
return err_json(e);
|
||||
}
|
||||
serde_json::to_value(EditorSettingsResult {
|
||||
editor_command: None,
|
||||
})
|
||||
.unwrap_or(Value::Null)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Handler for `settings.open_file`. Spawns the configured editor.
|
||||
async fn handle_settings_open_file(params: Value) -> Value {
|
||||
let typed = parse_params!(params, OpenFileParams);
|
||||
let Some(ctx) = RPC_CTX.get() else {
|
||||
return err_json("RPC context not initialised");
|
||||
};
|
||||
match crate::service::settings::open_file_in_editor(ctx.store.as_ref(), &typed.path, typed.line)
|
||||
{
|
||||
Ok(()) => ok_json(),
|
||||
Err(e) => err_json(e.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Handler for `settings.put_project`. Persists project.toml scalar settings.
|
||||
async fn handle_settings_put_project(params: Value) -> Value {
|
||||
let typed = parse_params!(params, ProjectSettingsPayload);
|
||||
let Some(ctx) = RPC_CTX.get() else {
|
||||
return err_json("RPC context not initialised");
|
||||
};
|
||||
let Ok(root) = ctx.state.get_project_root() else {
|
||||
return err_json("No project open");
|
||||
};
|
||||
let domain = crate::service::settings::ProjectSettings {
|
||||
default_qa: typed.default_qa,
|
||||
default_coder_model: typed.default_coder_model,
|
||||
max_coders: typed.max_coders,
|
||||
max_retries: typed.max_retries,
|
||||
base_branch: typed.base_branch,
|
||||
rate_limit_notifications: typed.rate_limit_notifications,
|
||||
timezone: typed.timezone,
|
||||
rendezvous: typed.rendezvous,
|
||||
watcher_sweep_interval_secs: typed.watcher_sweep_interval_secs,
|
||||
watcher_done_retention_secs: typed.watcher_done_retention_secs,
|
||||
};
|
||||
if let Err(e) = crate::service::settings::validate_project_settings(&domain) {
|
||||
return err_json(e.to_string());
|
||||
}
|
||||
if let Err(e) = crate::service::settings::write_project_settings(&root, &domain) {
|
||||
return err_json(e.to_string());
|
||||
}
|
||||
match crate::service::settings::load_project_settings(&root) {
|
||||
Ok(s) => serde_json::to_value(ProjectSettingsPayload {
|
||||
default_qa: s.default_qa,
|
||||
default_coder_model: s.default_coder_model,
|
||||
max_coders: s.max_coders,
|
||||
max_retries: s.max_retries,
|
||||
base_branch: s.base_branch,
|
||||
rate_limit_notifications: s.rate_limit_notifications,
|
||||
timezone: s.timezone,
|
||||
rendezvous: s.rendezvous,
|
||||
watcher_sweep_interval_secs: s.watcher_sweep_interval_secs,
|
||||
watcher_done_retention_secs: s.watcher_done_retention_secs,
|
||||
})
|
||||
.unwrap_or(Value::Null),
|
||||
Err(e) => err_json(e.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Handler for `project.open`. Opens a project on disk and updates session state.
|
||||
async fn handle_project_open(params: Value) -> Value {
|
||||
let typed = parse_params!(params, OpenProjectParams);
|
||||
let Some(ctx) = RPC_CTX.get() else {
|
||||
return err_json("RPC context not initialised");
|
||||
};
|
||||
let port = crate::http::resolve_port();
|
||||
match crate::service::project::open_project(typed.path, &ctx.state, ctx.store.as_ref(), port)
|
||||
.await
|
||||
{
|
||||
Ok(path) => serde_json::to_value(OpenProjectResult { path }).unwrap_or(Value::Null),
|
||||
Err(e) => err_json(e.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Handler for `project.close`. Closes the currently open project.
|
||||
async fn handle_project_close(_params: Value) -> Value {
|
||||
let Some(ctx) = RPC_CTX.get() else {
|
||||
return err_json("RPC context not initialised");
|
||||
};
|
||||
match crate::service::project::close_project(&ctx.state, ctx.store.as_ref()) {
|
||||
Ok(()) => ok_json(),
|
||||
Err(e) => err_json(e.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Handler for `project.forget`. Removes a project from the known list.
|
||||
async fn handle_project_forget(params: Value) -> Value {
|
||||
let typed = parse_params!(params, ForgetProjectParams);
|
||||
let Some(ctx) = RPC_CTX.get() else {
|
||||
return err_json("RPC context not initialised");
|
||||
};
|
||||
match crate::service::project::forget_known_project(typed.path, ctx.store.as_ref()) {
|
||||
Ok(()) => ok_json(),
|
||||
Err(e) => err_json(e.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Handler for `bot_config.save`. Writes `.huskies/bot.toml`.
|
||||
async fn handle_bot_config_save(params: Value) -> Value {
|
||||
let typed = parse_params!(params, BotConfigPayload);
|
||||
let Some(ctx) = RPC_CTX.get() else {
|
||||
return err_json("RPC context not initialised");
|
||||
};
|
||||
let Ok(root) = ctx.state.get_project_root() else {
|
||||
return err_json("No project open");
|
||||
};
|
||||
let bot_dir = root.join(".huskies");
|
||||
if let Err(e) = std::fs::create_dir_all(&bot_dir) {
|
||||
return err_json(format!("create .huskies dir: {e}"));
|
||||
}
|
||||
let bot_path = bot_dir.join("bot.toml");
|
||||
match toml::to_string_pretty(&typed) {
|
||||
Ok(s) => {
|
||||
if let Err(e) = std::fs::write(&bot_path, s) {
|
||||
return err_json(format!("write bot.toml: {e}"));
|
||||
}
|
||||
}
|
||||
Err(e) => return err_json(format!("serialise bot.toml: {e}")),
|
||||
}
|
||||
serde_json::to_value(typed).unwrap_or(Value::Null)
|
||||
}
|
||||
|
||||
/// Handler for `chat.cancel`. Cancels the in-flight LLM chat stream.
|
||||
async fn handle_chat_cancel(_params: Value) -> Value {
|
||||
let Some(ctx) = RPC_CTX.get() else {
|
||||
return err_json("RPC context not initialised");
|
||||
};
|
||||
match crate::llm::chat::cancel_chat(&ctx.state) {
|
||||
Ok(()) => ok_json(),
|
||||
Err(e) => err_json(e),
|
||||
}
|
||||
}
|
||||
|
||||
// ── dispatch ──────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Dispatch an incoming RPC method call to the registered handler.
|
||||
|
||||
@@ -0,0 +1,332 @@
|
||||
//! 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(),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user