1use std::io::{BufRead, Write};
24
25use serde_json::{Value, json};
26
27use crate::jsonrpc::{self, Framing, Incoming, Outgoing, codes};
28
29const PROTOCOL_VERSION: &str = "2024-11-05";
35
36pub trait Tools {
41 fn check(&mut self) -> Result<String, String>;
47
48 fn rules(&mut self) -> Result<String, String>;
54
55 fn explain(&mut self, rule: &str) -> Result<String, String>;
61}
62
63#[must_use]
65pub fn catalogue() -> Value {
66 json!({
67 "tools": [
68 {
69 "name": "lanekeep_check",
70 "description": "Check the project against its architectural rules. \
71 Returns every violation, grouped by rule, with the \
72 remediation for each and a good and bad example. Run this \
73 after editing code to find conventions the change broke.",
74 "inputSchema": { "type": "object", "properties": {} },
75 },
76 {
77 "name": "lanekeep_rules",
78 "description": "List the rules this project has configured, with what each \
79 one enforces. Use it to find out which conventions apply \
80 here before writing code, rather than after.",
81 "inputSchema": { "type": "object", "properties": {} },
82 },
83 {
84 "name": "lanekeep_explain",
85 "description": "Explain one rule: what it checks, why, and what to do \
86 instead, with a good and bad example. Call it with the id \
87 from a violation to find out how to fix it.",
88 "inputSchema": {
89 "type": "object",
90 "properties": {
91 "rule": {
92 "type": "string",
93 "description": "Namespaced rule id, as it appears in a \
94 violation — for example `lanekeep/no-default-export`.",
95 },
96 },
97 "required": ["rule"],
98 },
99 },
100 ],
101 })
102}
103
104#[must_use]
108pub fn content(text: &str, failed: bool) -> Value {
109 json!({
110 "content": [{ "type": "text", "text": text }],
111 "isError": failed,
112 })
113}
114
115pub fn serve(
122 input: &mut impl BufRead,
123 output: &mut impl Write,
124 tools: &mut impl Tools,
125) -> std::io::Result<()> {
126 while let Some(raw) = jsonrpc::read(input, Framing::Lines)? {
127 let Ok(message) = serde_json::from_str::<Incoming>(&raw) else {
128 jsonrpc::write(
129 output,
130 Framing::Lines,
131 &Outgoing::error(None, codes::PARSE_ERROR, "not a JSON-RPC message"),
132 )?;
133 continue;
134 };
135
136 let outcome = match message.method.as_str() {
137 "initialize" => Some(Ok(json!({
138 "protocolVersion": PROTOCOL_VERSION,
139 "capabilities": { "tools": {} },
140 "serverInfo": {
141 "name": "lanekeep",
142 "version": env!("CARGO_PKG_VERSION"),
143 },
144 }))),
145
146 "notifications/initialized" | "initialized" => None,
148 "ping" => Some(Ok(json!({}))),
149
150 "tools/list" => Some(Ok(catalogue())),
151
152 "tools/call" => Some(call(&message.params, tools)),
153
154 other => Some(Err((
155 codes::METHOD_NOT_FOUND,
156 format!("no method `{other}`"),
157 ))),
158 };
159
160 let Some(outcome) = outcome else { continue };
161 if !message.expects_reply() {
162 continue;
163 }
164
165 let response = match outcome {
166 Ok(result) => Outgoing::result(message.id.clone(), result),
167 Err((code, text)) => Outgoing::error(message.id.clone(), code, text),
168 };
169 jsonrpc::write(output, Framing::Lines, &response)?;
170 }
171
172 Ok(())
173}
174
175fn call(params: &Value, tools: &mut impl Tools) -> Result<Value, (i32, String)> {
177 let Some(name) = params["name"].as_str() else {
178 return Err((codes::INVALID_PARAMS, "`name` is required".to_owned()));
179 };
180
181 let outcome = match name {
182 "lanekeep_check" => tools.check(),
183 "lanekeep_rules" => tools.rules(),
184 "lanekeep_explain" => {
185 let Some(rule) = params["arguments"]["rule"].as_str() else {
188 return Err((
189 codes::INVALID_PARAMS,
190 "`lanekeep_explain` needs a `rule` argument".to_owned(),
191 ));
192 };
193 tools.explain(rule)
194 }
195 other => {
196 return Err((codes::INVALID_PARAMS, format!("no tool `{other}`")));
197 }
198 };
199
200 Ok(match outcome {
201 Ok(text) => content(&text, false),
202 Err(text) => content(&text, true),
203 })
204}
205
206#[cfg(test)]
207mod tests {
208 use super::*;
209
210 struct Fake {
212 answer: Result<String, String>,
213 explained: Option<String>,
214 called: Vec<&'static str>,
215 }
216
217 impl Fake {
218 fn ok() -> Self {
219 Self {
220 answer: Ok("nothing found".to_owned()),
221 explained: None,
222 called: Vec::new(),
223 }
224 }
225
226 fn failing() -> Self {
227 Self {
228 answer: Err("rule threw".to_owned()),
229 explained: None,
230 called: Vec::new(),
231 }
232 }
233 }
234
235 impl Tools for Fake {
236 fn check(&mut self) -> Result<String, String> {
237 self.called.push("check");
238 self.answer.clone()
239 }
240
241 fn rules(&mut self) -> Result<String, String> {
242 self.called.push("rules");
243 self.answer.clone()
244 }
245
246 fn explain(&mut self, rule: &str) -> Result<String, String> {
247 self.called.push("explain");
248 self.explained = Some(rule.to_owned());
249 self.answer.clone()
250 }
251 }
252
253 fn exchange(messages: &[Value], tools: &mut impl Tools) -> Vec<Value> {
254 use std::fmt::Write as _;
255
256 let mut wire = String::new();
257 for message in messages {
258 let _ = writeln!(wire, "{message}");
259 }
260 let mut input = std::io::BufReader::new(wire.as_bytes());
261 let mut output = Vec::new();
262 serve(&mut input, &mut output, tools).expect("serves");
263
264 String::from_utf8(output)
265 .expect("utf-8")
266 .lines()
267 .map(|line| serde_json::from_str(line).expect("parses"))
268 .collect()
269 }
270
271 #[test]
272 fn initialize_states_the_protocol_version_and_the_tools_capability() {
273 let replies = exchange(
274 &[json!({ "jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {} })],
275 &mut Fake::ok(),
276 );
277 assert_eq!(replies.len(), 1);
278 assert_eq!(replies[0]["result"]["protocolVersion"], PROTOCOL_VERSION);
279 assert!(replies[0]["result"]["capabilities"]["tools"].is_object());
280 assert_eq!(replies[0]["result"]["serverInfo"]["name"], "lanekeep");
281 }
282
283 #[test]
284 fn the_catalogue_lists_three_tools_each_with_a_schema() {
285 let replies = exchange(
286 &[json!({ "jsonrpc": "2.0", "id": 1, "method": "tools/list" })],
287 &mut Fake::ok(),
288 );
289 let tools = replies[0]["result"]["tools"].as_array().expect("an array");
290 assert_eq!(tools.len(), 3);
291
292 for tool in tools {
293 assert!(tool["name"].is_string(), "{tool}");
294 let description = tool["description"].as_str().expect("a description");
297 assert!(
298 description.len() > 40,
299 "too terse to choose by: {description}"
300 );
301 assert_eq!(tool["inputSchema"]["type"], "object", "{tool}");
302 }
303 }
304
305 #[test]
306 fn explain_declares_its_required_argument() {
307 let replies = exchange(
308 &[json!({ "jsonrpc": "2.0", "id": 1, "method": "tools/list" })],
309 &mut Fake::ok(),
310 );
311 let explain = replies[0]["result"]["tools"]
312 .as_array()
313 .expect("an array")
314 .iter()
315 .find(|tool| tool["name"] == "lanekeep_explain")
316 .expect("present");
317 assert_eq!(explain["inputSchema"]["required"][0], "rule");
318 }
319
320 #[test]
321 fn calling_check_returns_its_text_as_content() {
322 let mut tools = Fake::ok();
323 let replies = exchange(
324 &[json!({
325 "jsonrpc": "2.0", "id": 1, "method": "tools/call",
326 "params": { "name": "lanekeep_check", "arguments": {} }
327 })],
328 &mut tools,
329 );
330 assert_eq!(tools.called, ["check"]);
331 assert_eq!(replies[0]["result"]["content"][0]["type"], "text");
332 assert_eq!(replies[0]["result"]["content"][0]["text"], "nothing found");
333 assert_eq!(replies[0]["result"]["isError"], false);
334 }
335
336 #[test]
337 fn explain_receives_the_rule_it_was_given() {
338 let mut tools = Fake::ok();
339 exchange(
340 &[json!({
341 "jsonrpc": "2.0", "id": 1, "method": "tools/call",
342 "params": {
343 "name": "lanekeep_explain",
344 "arguments": { "rule": "lanekeep/no-default-export" }
345 }
346 })],
347 &mut tools,
348 );
349 assert_eq!(
350 tools.explained.as_deref(),
351 Some("lanekeep/no-default-export")
352 );
353 }
354
355 #[test]
356 fn a_failing_tool_is_a_successful_call_marked_as_an_error() {
357 let replies = exchange(
361 &[json!({
362 "jsonrpc": "2.0", "id": 1, "method": "tools/call",
363 "params": { "name": "lanekeep_check", "arguments": {} }
364 })],
365 &mut Fake::failing(),
366 );
367 assert!(
368 replies[0].get("error").is_none(),
369 "should not be a protocol error: {}",
370 replies[0]
371 );
372 assert_eq!(replies[0]["result"]["isError"], true);
373 assert_eq!(replies[0]["result"]["content"][0]["text"], "rule threw");
374 }
375
376 #[test]
377 fn a_missing_argument_is_a_protocol_error_not_a_tool_error() {
378 let replies = exchange(
381 &[json!({
382 "jsonrpc": "2.0", "id": 1, "method": "tools/call",
383 "params": { "name": "lanekeep_explain", "arguments": {} }
384 })],
385 &mut Fake::ok(),
386 );
387 assert_eq!(replies[0]["error"]["code"], codes::INVALID_PARAMS);
388 }
389
390 #[test]
391 fn an_unknown_tool_is_refused() {
392 let replies = exchange(
393 &[json!({
394 "jsonrpc": "2.0", "id": 1, "method": "tools/call",
395 "params": { "name": "lanekeep_deploy", "arguments": {} }
396 })],
397 &mut Fake::ok(),
398 );
399 assert_eq!(replies[0]["error"]["code"], codes::INVALID_PARAMS);
400 }
401
402 #[test]
403 fn an_unknown_method_is_refused() {
404 let replies = exchange(
405 &[json!({ "jsonrpc": "2.0", "id": 1, "method": "resources/list" })],
406 &mut Fake::ok(),
407 );
408 assert_eq!(replies[0]["error"]["code"], codes::METHOD_NOT_FOUND);
409 }
410
411 #[test]
412 fn the_initialized_notification_is_not_answered() {
413 let replies = exchange(
414 &[json!({ "jsonrpc": "2.0", "method": "notifications/initialized" })],
415 &mut Fake::ok(),
416 );
417 assert!(replies.is_empty(), "{replies:?}");
418 }
419
420 #[test]
421 fn ping_is_answered() {
422 let replies = exchange(
423 &[json!({ "jsonrpc": "2.0", "id": 1, "method": "ping" })],
424 &mut Fake::ok(),
425 );
426 assert_eq!(replies.len(), 1);
427 assert!(replies[0]["result"].is_object());
428 }
429
430 #[test]
431 fn a_malformed_line_is_answered_and_the_session_continues() {
432 let wire = "not json\n{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"ping\"}\n";
433 let mut input = std::io::BufReader::new(wire.as_bytes());
434 let mut output = Vec::new();
435 serve(&mut input, &mut output, &mut Fake::ok()).expect("serves");
436
437 let replies: Vec<Value> = String::from_utf8(output)
438 .expect("utf-8")
439 .lines()
440 .map(|line| serde_json::from_str(line).expect("parses"))
441 .collect();
442 assert_eq!(replies.len(), 2);
443 assert_eq!(replies[0]["error"]["code"], codes::PARSE_ERROR);
444 assert_eq!(replies[1]["id"], 1);
445 }
446
447 #[test]
448 fn every_reply_is_one_line() {
449 let mut tools = Fake::ok();
452 tools.answer = Ok("two\nlines".to_owned());
453 let wire = json!({
454 "jsonrpc": "2.0", "id": 1, "method": "tools/call",
455 "params": { "name": "lanekeep_check", "arguments": {} }
456 })
457 .to_string()
458 + "\n";
459
460 let mut input = std::io::BufReader::new(wire.as_bytes());
461 let mut output = Vec::new();
462 serve(&mut input, &mut output, &mut tools).expect("serves");
463
464 let text = String::from_utf8(output).expect("utf-8");
465 assert_eq!(text.trim_end().lines().count(), 1, "{text}");
466 let parsed: Value = serde_json::from_str(text.trim_end()).expect("parses");
467 assert_eq!(parsed["result"]["content"][0]["text"], "two\nlines");
468 }
469}