pub mod protocol;
pub mod tools;
use std::io::stdin;
use std::io::stdout;
use serde_json::{Value, json};
use crate::config::Config;
use crate::error::Result;
use crate::storage::Store;
#[derive(Debug, Clone)]
enum Outbound {
Success(protocol::Response),
Error(protocol::ErrorResponse),
}
impl serde::Serialize for Outbound {
fn serialize<S: serde::Serializer>(
&self,
serializer: S,
) -> std::result::Result<S::Ok, S::Error> {
match self {
Outbound::Success(r) => r.serialize(serializer),
Outbound::Error(e) => e.serialize(serializer),
}
}
}
pub fn serve(config: &Config, store: &Store) -> Result<()> {
let stdin = stdin();
let stdout = stdout();
let mut reader = stdin.lock();
let mut writer = stdout.lock();
loop {
let line = match protocol::read_line(&mut reader)? {
Some(line) => line,
None => return Ok(()),
};
if let Some(outbound) = handle_message(config, store, &line) {
protocol::write_message(&mut writer, &outbound)?;
}
emit_disclosures();
}
}
pub fn emit_disclosures() {
for disclosure in crate::storage::take_disclosures() {
eprintln!(
"{}",
crate::diagnostics::Diagnostic::warning("W-tix-disclosure", disclosure).render_auto()
);
}
}
fn handle_message(config: &Config, store: &Store, line: &str) -> Option<Outbound> {
match protocol::parse_message(line) {
Ok(protocol::Message::Request { method, params, id }) => match method.as_str() {
"initialize" => {
let requested = params
.as_ref()
.and_then(|p| p.get("protocolVersion"))
.and_then(Value::as_str)
.unwrap_or_default();
let version = protocol::negotiate_protocol_version(requested);
let result = protocol::initialize_result(version, env!("CARGO_PKG_VERSION"));
Some(Outbound::Success(protocol::success(id, result)))
}
"notifications/initialized" => None,
"ping" => Some(Outbound::Success(protocol::success(id, json!({})))),
"tools/list" => Some(Outbound::Success(protocol::success(
id,
json!({ "tools": tools::list() }),
))),
"tools/call" => {
let params = params.unwrap_or(json!({}));
let name = params.get("name").and_then(Value::as_str);
let arguments = params.get("arguments").cloned().unwrap_or(Value::Null);
let name = match name {
Some(name) => name,
None => return Some(Outbound::Error(protocol::invalid_params(id))),
};
match tools::call(config, store, id.clone(), name, &arguments) {
Ok(result) => Some(Outbound::Success(protocol::success(id, result))),
Err(error) => Some(Outbound::Error(error)),
}
}
_ => Some(Outbound::Error(protocol::method_not_found(id))),
},
Ok(protocol::Message::Notification { method, .. }) => {
let _ = method;
None
}
Err(protocol::ParseError::MalformedJson) => Some(Outbound::Error(protocol::parse_error())),
Err(protocol::ParseError::InvalidRequest(maybe_id)) => {
maybe_id.map(|id| Outbound::Error(protocol::invalid_request(id)))
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn test_config() -> Config {
crate::config::Config::default()
}
fn test_store() -> Store {
Store {
issues_dir: std::env::temp_dir(),
config: crate::config::Config::default(),
}
}
fn from_line(line: &str) -> Option<Value> {
let out = handle_message(&test_config(), &test_store(), line)?;
Some(serde_json::to_value(&out).unwrap())
}
#[test]
fn initialize_echoes_supported_protocol_version() {
let resp = from_line(
r#"{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-03-26","capabilities":{}}}"#,
)
.unwrap();
assert_eq!(
resp["result"]["protocolVersion"],
serde_json::Value::String("2025-03-26".into())
);
assert_eq!(
resp["result"]["serverInfo"]["name"],
serde_json::Value::String("tix".into())
);
}
#[test]
fn initialize_negotiates_unsupported_to_latest() {
let resp = from_line(
r#"{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2019-01-01","capabilities":{}}}"#,
)
.unwrap();
assert_eq!(
resp["result"]["protocolVersion"],
serde_json::Value::String("2025-06-18".into())
);
}
#[test]
fn notifications_initialized_is_silently_ignored() {
let resp = from_line(r#"{"jsonrpc":"2.0","method":"notifications/initialized"}"#);
assert!(resp.is_none(), "must produce no response");
}
#[test]
fn ping_returns_empty_object() {
let resp = from_line(r#"{"jsonrpc":"2.0","id":42,"method":"ping"}"#).unwrap();
assert_eq!(resp["result"], json!({}));
}
#[test]
fn tools_list_returns_every_registered_tool() {
let resp = from_line(r#"{"jsonrpc":"2.0","id":2,"method":"tools/list"}"#).unwrap();
let tools = resp["result"]["tools"].as_array().unwrap();
let declared: Vec<&str> = tools::registry().iter().map(|t| t.name).collect();
let listed: Vec<&str> = tools.iter().map(|t| t["name"].as_str().unwrap()).collect();
assert_eq!(
listed, declared,
"tools/list must advertise exactly the registry, in the registry's order"
);
for required in [
"list_relationships",
"edit_relationship",
"remove_relationship",
"issue_chain",
"issue_blocked_state",
] {
assert!(
listed.contains(&required),
"the WP05 relationship tool `{required}` must be advertised: {listed:?}"
);
}
}
#[test]
fn tools_call_unknown_tool_is_method_not_found() {
let resp = from_line(
r#"{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"nonexistent","arguments":{}}}"#,
)
.unwrap();
assert_eq!(resp["error"]["code"], -32601);
}
#[test]
fn tools_call_missing_name_is_invalid_params() {
let resp = from_line(
r#"{"jsonrpc":"2.0","id":9,"method":"tools/call","params":{"arguments":{}}}"#,
)
.unwrap();
assert_eq!(resp["error"]["code"], -32602);
}
#[test]
fn unknown_method_is_method_not_found() {
let resp = from_line(r#"{"jsonrpc":"2.0","id":4,"method":"bogus"}"#).unwrap();
assert_eq!(resp["error"]["code"], -32601);
}
#[test]
fn malformed_json_is_parse_error_with_null_id() {
let resp = from_line("not json").unwrap();
assert_eq!(resp["error"]["code"], -32700);
assert_eq!(resp["id"], json!(null));
}
#[test]
fn missing_method_is_invalid_request_with_id() {
let resp = from_line(r#"{"jsonrpc":"2.0","id":5}"#).unwrap();
assert_eq!(resp["error"]["code"], -32600);
assert_eq!(resp["id"], 5);
}
#[test]
fn missing_method_without_id_produces_no_response() {
let resp = from_line(r#"{"jsonrpc":"2.0"}"#);
assert!(resp.is_none(), "notification with no id: no response");
}
#[test]
fn non_2d0_jsonrpc_version_is_invalid_request() {
let resp = from_line(r#"{"jsonrpc":"1.0","id":6,"method":"ping"}"#).unwrap();
assert_eq!(resp["error"]["code"], -32600);
assert_eq!(resp["id"], 6);
}
#[test]
fn notify_unknown_method_silently_dropped() {
let resp = from_line(r#"{"jsonrpc":"2.0","method":"unknown/notification"}"#);
assert!(resp.is_none());
}
}