From f0363e9b4f11d2e7eea66cf3bf928df9ec17b831 Mon Sep 17 00:00:00 2001 From: Kailas Mahavarkar <66670953+KailasMahavarkar@users.noreply.github.com> Date: Wed, 22 Apr 2026 02:39:06 +0530 Subject: [PATCH 1/3] feat: add corpus-backed rust practice loader + migrate borrow-over-clone Adds corpus support to `src/plugins/rust/tools/get-practice.ts`. Tool now reads per-practice slices from `corpus/backend/rust/.yaml`, validates required fields (chapter, rule, reason), and falls back to the in-file BEST_PRACTICES registry when the corpus entry is missing or invalid. Creates the rust corpus namespace: - `corpus/backend/rust/index.yaml` registers the `backend.rust` namespace and practice files - `corpus/backend/rust/borrow-over-clone.yaml` captures the coding-styles practice (prefer &T over clone; slice vs owned Vec example) - `corpus/index.yaml` registers the new namespace New `tests/rust-practice-corpus-backed-tools-behaviour.test.ts` asserts corpus source for borrow-over-clone and fallback for result-not-panic. Co-Authored-By: Claude Sonnet 4.6 --- corpus/backend/rust/borrow-over-clone.yaml | 8 ++ corpus/backend/rust/index.yaml | 4 + corpus/index.yaml | 2 + src/plugins/rust/tools/get-practice.ts | 107 +++++++++++++++++- ...tice-corpus-backed-tools-behaviour.test.ts | 22 ++++ 5 files changed, 141 insertions(+), 2 deletions(-) create mode 100644 corpus/backend/rust/borrow-over-clone.yaml create mode 100644 corpus/backend/rust/index.yaml create mode 100644 tests/rust-practice-corpus-backed-tools-behaviour.test.ts 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..3c20ce7 --- /dev/null +++ b/corpus/backend/rust/index.yaml @@ -0,0 +1,4 @@ +namespace: backend.rust +practices: + borrow-over-clone: + file: borrow-over-clone.yaml 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..14f0fde --- /dev/null +++ b/tests/rust-practice-corpus-backed-tools-behaviour.test.ts @@ -0,0 +1,22 @@ +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 falls back to in-file data for non-corpus practices", async () => { + const result = await rustGetPractice.invoke({ name: "result-not-panic" }); + const text = extractTextContent(result); + + expect(text).toContain("# result-not-panic [error-handling]"); + expect(text).not.toContain("**Corpus Source:** backend.rust"); +}); From 135dc472391bfd15ea0452611ce97a88ddb58b0e Mon Sep 17 00:00:00 2001 From: Kailas Mahavarkar <66670953+KailasMahavarkar@users.noreply.github.com> Date: Wed, 22 Apr 2026 02:40:01 +0530 Subject: [PATCH 2/3] feat: migrate rust result-not-panic practice to corpus-backed slice Adds corpus/backend/rust/result-not-panic.yaml with Result pattern (? operator) and unwrap() anti-pattern. Registers result-not-panic in namespace index. Test asserts corpus source for result-not-panic; fallback check moves to no-unwrap-in-prod. Co-Authored-By: Claude Sonnet 4.6 --- corpus/backend/rust/index.yaml | 2 ++ corpus/backend/rust/result-not-panic.yaml | 14 ++++++++++++++ ...-practice-corpus-backed-tools-behaviour.test.ts | 11 ++++++++++- 3 files changed, 26 insertions(+), 1 deletion(-) create mode 100644 corpus/backend/rust/result-not-panic.yaml diff --git a/corpus/backend/rust/index.yaml b/corpus/backend/rust/index.yaml index 3c20ce7..30dfa3a 100644 --- a/corpus/backend/rust/index.yaml +++ b/corpus/backend/rust/index.yaml @@ -2,3 +2,5 @@ namespace: backend.rust practices: borrow-over-clone: file: borrow-over-clone.yaml + result-not-panic: + file: result-not-panic.yaml 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/tests/rust-practice-corpus-backed-tools-behaviour.test.ts b/tests/rust-practice-corpus-backed-tools-behaviour.test.ts index 14f0fde..f0afe1f 100644 --- a/tests/rust-practice-corpus-backed-tools-behaviour.test.ts +++ b/tests/rust-practice-corpus-backed-tools-behaviour.test.ts @@ -13,10 +13,19 @@ test("rust_get_practice prefers corpus metadata for borrow-over-clone", async () expect(text).toContain("fn process(data: &[u8])"); }); -test("rust_get_practice falls back to in-file data for non-corpus practices", async () => { +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 falls back to in-file data for non-corpus practices", 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).not.toContain("**Corpus Source:** backend.rust"); }); From 5c60fe9d5e7d9a6e25c11060c009a87c85ca6f4a Mon Sep 17 00:00:00 2001 From: Kailas Mahavarkar <66670953+KailasMahavarkar@users.noreply.github.com> Date: Wed, 22 Apr 2026 02:40:46 +0530 Subject: [PATCH 3/3] feat: migrate rust no-unwrap-in-prod practice to corpus-backed slice Adds corpus/backend/rust/no-unwrap-in-prod.yaml with ? operator pattern, unwrap() anti-pattern, and expect() nuance tip. Registers no-unwrap-in-prod in namespace index. Test asserts corpus source for no-unwrap-in-prod; fallback check moves to prefer-iterators. Co-Authored-By: Claude Sonnet 4.6 --- corpus/backend/rust/index.yaml | 2 ++ corpus/backend/rust/no-unwrap-in-prod.yaml | 10 ++++++++++ ...ust-practice-corpus-backed-tools-behaviour.test.ts | 11 ++++++++++- 3 files changed, 22 insertions(+), 1 deletion(-) create mode 100644 corpus/backend/rust/no-unwrap-in-prod.yaml diff --git a/corpus/backend/rust/index.yaml b/corpus/backend/rust/index.yaml index 30dfa3a..ea73150 100644 --- a/corpus/backend/rust/index.yaml +++ b/corpus/backend/rust/index.yaml @@ -4,3 +4,5 @@ practices: 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/tests/rust-practice-corpus-backed-tools-behaviour.test.ts b/tests/rust-practice-corpus-backed-tools-behaviour.test.ts index f0afe1f..a5a734b 100644 --- a/tests/rust-practice-corpus-backed-tools-behaviour.test.ts +++ b/tests/rust-practice-corpus-backed-tools-behaviour.test.ts @@ -22,10 +22,19 @@ test("rust_get_practice prefers corpus metadata for result-not-panic", async () expect(text).toContain("fs::read_to_string(path)?"); }); -test("rust_get_practice falls back to in-file data for non-corpus practices", async () => { +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"); });