Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions corpus/backend/rust/borrow-over-clone.yaml
Original file line number Diff line number Diff line change
@@ -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<u8>) -> usize { data.len() } // forces caller to clone or give up ownership
8 changes: 8 additions & 0 deletions corpus/backend/rust/index.yaml
Original file line number Diff line number Diff line change
@@ -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
10 changes: 10 additions & 0 deletions corpus/backend/rust/no-unwrap-in-prod.yaml
Original file line number Diff line number Diff line change
@@ -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
14 changes: 14 additions & 0 deletions corpus/backend/rust/result-not-panic.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
name: result-not-panic
chapter: error-handling
rule: Return Result<T, E> 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<Config, ConfigError> {
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()
}
2 changes: 2 additions & 0 deletions corpus/index.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
107 changes: 105 additions & 2 deletions src/plugins/rust/tools/get-practice.ts
Original file line number Diff line number Diff line change
@@ -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<string, { file?: string }>;
};

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<string, LoadedCorpusPractice | null>();

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(
Expand All @@ -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(", ")}` }],
Expand All @@ -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 }] };
}
);
Expand Down
40 changes: 40 additions & 0 deletions tests/rust-practice-corpus-backed-tools-behaviour.test.ts
Original file line number Diff line number Diff line change
@@ -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");
});
Loading