diff --git a/corpus/backend/rust/borrow-over-clone.yaml b/corpus/backend/rust/borrow-over-clone.yaml new file mode 100644 index 0000000..a39f519 --- /dev/null +++ b/corpus/backend/rust/borrow-over-clone.yaml @@ -0,0 +1,8 @@ +name: borrow-over-clone +chapter: coding-styles +rule: Prefer &T over .clone() unless ownership transfer is required. +reason: Cloning allocates heap memory unnecessarily. References are zero-cost. +good: | + fn process(data: &[u8]) -> usize { data.len() } +bad: | + fn process(data: Vec) -> usize { data.len() } // forces caller to clone or give up ownership diff --git a/corpus/backend/rust/index.yaml b/corpus/backend/rust/index.yaml new file mode 100644 index 0000000..ea73150 --- /dev/null +++ b/corpus/backend/rust/index.yaml @@ -0,0 +1,8 @@ +namespace: backend.rust +practices: + borrow-over-clone: + file: borrow-over-clone.yaml + result-not-panic: + file: result-not-panic.yaml + no-unwrap-in-prod: + file: no-unwrap-in-prod.yaml diff --git a/corpus/backend/rust/no-unwrap-in-prod.yaml b/corpus/backend/rust/no-unwrap-in-prod.yaml new file mode 100644 index 0000000..feea5d2 --- /dev/null +++ b/corpus/backend/rust/no-unwrap-in-prod.yaml @@ -0,0 +1,10 @@ +name: no-unwrap-in-prod +chapter: error-handling +rule: Never use unwrap() or expect() outside of tests. +reason: Both panic on None/Err. Use ? operator or proper error handling. +good: | + let value = map.get(&key)?; // returns None/Err to caller +bad: | + let value = map.get(&key).unwrap(); // panics in production +tips: + - expect() is slightly better than unwrap() (message on panic), but still banned in prod diff --git a/corpus/backend/rust/result-not-panic.yaml b/corpus/backend/rust/result-not-panic.yaml new file mode 100644 index 0000000..22f7966 --- /dev/null +++ b/corpus/backend/rust/result-not-panic.yaml @@ -0,0 +1,14 @@ +name: result-not-panic +chapter: error-handling +rule: Return Result for fallible operations. Never panic! in production code. +reason: panic! unwinds the stack and kills the thread. Use it only for programmer errors. +good: | + fn parse_config(path: &str) -> Result { + let text = fs::read_to_string(path)?; + toml::from_str(&text).map_err(ConfigError::Parse) + } +bad: | + fn parse_config(path: &str) -> Config { + let text = fs::read_to_string(path).unwrap(); // panics if file missing + toml::from_str(&text).unwrap() + } diff --git a/corpus/index.yaml b/corpus/index.yaml index ff0295d..58756a7 100644 --- a/corpus/index.yaml +++ b/corpus/index.yaml @@ -17,3 +17,5 @@ namespaces: index: frontend/ui-ux/index.yaml frontend.react: index: frontend/react/index.yaml + backend.rust: + index: backend/rust/index.yaml diff --git a/src/plugins/rust/tools/get-practice.ts b/src/plugins/rust/tools/get-practice.ts index 5808cf3..25cf811 100644 --- a/src/plugins/rust/tools/get-practice.ts +++ b/src/plugins/rust/tools/get-practice.ts @@ -1,6 +1,101 @@ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import YAML from "yaml"; import { z } from "zod"; -import { BEST_PRACTICES } from "../data.js"; +import { BEST_PRACTICES, type BestPractice } from "../data.js"; + +type CorpusIndex = { + namespaces?: { + "backend.rust"?: { + index?: string; + }; + }; +}; + +type CorpusNamespaceIndex = { + namespace?: string; + practices?: Record; +}; + +type CorpusPracticeEntry = { + name?: string; + chapter?: string; + rule?: string; + reason?: string; + good?: string; + bad?: string; + tips?: string[]; +}; + +type LoadedCorpusPractice = { practice: BestPractice; source: string }; + +const moduleDir = dirname(fileURLToPath(import.meta.url)); +const corpusRoot = join(moduleDir, "../../../../corpus"); +const corpusNamespace = "backend.rust"; + +const cachedCorpusPractices = new Map(); + +function normalize(name: string): string { + return name.toLowerCase().trim(); +} + +function loadCorpusPractice(name: string): LoadedCorpusPractice | null { + const key = normalize(name); + if (cachedCorpusPractices.has(key)) { + return cachedCorpusPractices.get(key) ?? null; + } + + try { + const indexRaw = readFileSync(join(corpusRoot, "index.yaml"), "utf8"); + const index = YAML.parse(indexRaw) as CorpusIndex | null; + const namespaceIndexPath = index?.namespaces?.[corpusNamespace]?.index; + if (!namespaceIndexPath) { + cachedCorpusPractices.set(key, null); + return null; + } + + const namespaceRaw = readFileSync(join(corpusRoot, namespaceIndexPath), "utf8"); + const namespaceIndex = YAML.parse(namespaceRaw) as CorpusNamespaceIndex | null; + const practicePath = namespaceIndex?.practices?.[key]?.file; + if (!practicePath) { + cachedCorpusPractices.set(key, null); + return null; + } + + const raw = readFileSync(join(corpusRoot, "backend/rust", practicePath), "utf8"); + const entry = YAML.parse(raw) as CorpusPracticeEntry | null; + if ( + !entry || + normalize(entry.name ?? "") !== key || + !entry.chapter || + !entry.rule || + !entry.reason + ) { + cachedCorpusPractices.set(key, null); + return null; + } + + const loaded: LoadedCorpusPractice = { + practice: { + name: entry.name ?? name, + chapter: entry.chapter as BestPractice["chapter"], + rule: entry.rule, + reason: entry.reason, + good: entry.good, + bad: entry.bad, + tips: entry.tips, + }, + source: corpusNamespace, + }; + cachedCorpusPractices.set(key, loaded); + return loaded; + } catch { + cachedCorpusPractices.set(key, null); + return null; + } +} export function register(server: McpServer): void { server.tool( @@ -10,7 +105,10 @@ export function register(server: McpServer): void { name: z.string().describe("Practice name (e.g. 'borrow-over-clone', 'result-not-panic', 'thiserror-vs-anyhow', 'type-state-pattern', 'clippy-command')"), }, async ({ name }) => { - const practice = BEST_PRACTICES.find((p) => p.name.toLowerCase() === name.toLowerCase()); + const corpusEntry = loadCorpusPractice(name); + const practice = + corpusEntry?.practice ?? + BEST_PRACTICES.find((p) => p.name.toLowerCase() === name.toLowerCase()); if (!practice) { return { content: [{ type: "text", text: `Practice "${name}" not found.\n\nAvailable: ${BEST_PRACTICES.map((p) => p.name).join(", ")}` }], @@ -27,6 +125,11 @@ export function register(server: McpServer): void { text += `## Tips\n`; for (const tip of practice.tips) text += `- ${tip}\n`; } + + if (corpusEntry) { + text += `\n**Corpus Source:** ${corpusEntry.source}`; + } + return { content: [{ type: "text", text }] }; } ); diff --git a/tests/rust-practice-corpus-backed-tools-behaviour.test.ts b/tests/rust-practice-corpus-backed-tools-behaviour.test.ts new file mode 100644 index 0000000..a5a734b --- /dev/null +++ b/tests/rust-practice-corpus-backed-tools-behaviour.test.ts @@ -0,0 +1,40 @@ +import { expect, test } from "bun:test"; +import { captureTool, extractTextContent } from "./helpers"; +import { register as registerRustGetPractice } from "../src/plugins/rust/tools/get-practice.ts"; + +const rustGetPractice = captureTool(registerRustGetPractice); + +test("rust_get_practice prefers corpus metadata for borrow-over-clone", async () => { + const result = await rustGetPractice.invoke({ name: "borrow-over-clone" }); + const text = extractTextContent(result); + + expect(text).toContain("# borrow-over-clone [coding-styles]"); + expect(text).toContain("**Corpus Source:** backend.rust"); + expect(text).toContain("fn process(data: &[u8])"); +}); + +test("rust_get_practice prefers corpus metadata for result-not-panic", async () => { + const result = await rustGetPractice.invoke({ name: "result-not-panic" }); + const text = extractTextContent(result); + + expect(text).toContain("# result-not-panic [error-handling]"); + expect(text).toContain("**Corpus Source:** backend.rust"); + expect(text).toContain("fs::read_to_string(path)?"); +}); + +test("rust_get_practice prefers corpus metadata for no-unwrap-in-prod", async () => { + const result = await rustGetPractice.invoke({ name: "no-unwrap-in-prod" }); + const text = extractTextContent(result); + + expect(text).toContain("# no-unwrap-in-prod [error-handling]"); + expect(text).toContain("**Corpus Source:** backend.rust"); + expect(text).toContain("map.get(&key)?"); +}); + +test("rust_get_practice falls back to in-file data for non-corpus practices", async () => { + const result = await rustGetPractice.invoke({ name: "prefer-iterators" }); + const text = extractTextContent(result); + + expect(text).toContain("# prefer-iterators [performance]"); + expect(text).not.toContain("**Corpus Source:** backend.rust"); +});