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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

## 0.7.2 - Unreleased

- Added Rust seed context for Cargo manifests, paired crate entrypoints, and directly declared modules across crate roots and binary layouts, thanks @Tanmay-008.

## 0.7.1 - 2026-07-20

### Highlights
Expand Down
2 changes: 1 addition & 1 deletion src/mapper.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11026,7 +11026,7 @@ let package = Package(name: "HybridApp", targets: [.target(name: "HybridApp")])
);

expect(library?.tests).toHaveLength(5);
expect(library?.contextFiles).toHaveLength(5);
expect(library?.contextFiles).toHaveLength(6);
expect(integrationTests).toHaveLength(8);
});

Expand Down
136 changes: 136 additions & 0 deletions src/mappers/rust.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
import { describe, expect, it } from "vitest";
import { rustSeeds } from "./rust.js";
import { join } from "node:path";
import { mkdir, writeFile } from "node:fs/promises";
import { mkdtemp } from "node:fs/promises";
import { tmpdir } from "node:os";

async function createTempDir() {
return await mkdtemp(join(tmpdir(), "clawpatch-rust-test-"));
}

describe("Rust Mapper", () => {
it("populates contextFiles for a basic binary crate", async () => {
const root = await createTempDir();
await mkdir(join(root, "src"), { recursive: true });
await writeFile(
join(root, "Cargo.toml"),
`
[package]
name = "my_bin"
version = "0.1.0"
`,
);
await writeFile(
join(root, "src/main.rs"),
`
mod api;
pub mod utils;
fn main() {}
`,
);
await writeFile(join(root, "src/api.rs"), "fn api() {}");
await mkdir(join(root, "src/utils"), { recursive: true });
await writeFile(join(root, "src/utils/mod.rs"), "fn utils() {}");

const seeds = await rustSeeds(root);
expect(seeds).toHaveLength(1);

const contextPaths = seeds[0]!.contextFiles?.map((f) => f.path);
expect(contextPaths).toEqual(["Cargo.toml", "src/api.rs", "src/utils/mod.rs"]);
});

it("cross-links lib.rs and main.rs", async () => {
const root = await createTempDir();
await mkdir(join(root, "src"), { recursive: true });
await writeFile(
join(root, "Cargo.toml"),
`
[package]
name = "dual_crate"
`,
);
await writeFile(join(root, "src/main.rs"), "fn main() {}");
await writeFile(join(root, "src/lib.rs"), "pub fn lib() {}");

const seeds = await rustSeeds(root);
expect(seeds).toHaveLength(2);

const mainSeed = seeds.find((s) => s.entryPath === "src/main.rs")!;
const libSeed = seeds.find((s) => s.entryPath === "src/lib.rs")!;

expect(mainSeed.contextFiles?.map((f) => f.path)).toContain("src/lib.rs");
expect(mainSeed.contextFiles?.map((f) => f.path)).toContain("Cargo.toml");

expect(libSeed.contextFiles?.map((f) => f.path)).toContain("src/main.rs");
expect(libSeed.contextFiles?.map((f) => f.path)).toContain("Cargo.toml");
});

it("resolves modules beside a nested binary entrypoint", async () => {
const root = await createTempDir();
await mkdir(join(root, "src/bin/worker"), { recursive: true });
await writeFile(join(root, "Cargo.toml"), `[package]\nname="nested_bin"`);
await writeFile(join(root, "src/bin/worker/main.rs"), "mod protocol;");
await writeFile(join(root, "src/bin/worker/protocol.rs"), "pub fn run() {}");

const seeds = await rustSeeds(root);
const worker = seeds.find((seed) => seed.entryPath === "src/bin/worker/main.rs");

expect(worker?.contextFiles?.map((file) => file.path)).toEqual([
"Cargo.toml",
"src/bin/worker/protocol.rs",
]);
});

it("resolves modules from a flat binary entrypoint stem", async () => {
const root = await createTempDir();
await mkdir(join(root, "src/bin/audit"), { recursive: true });
await writeFile(join(root, "Cargo.toml"), `[package]\nname="flat_bin"`);
await writeFile(join(root, "src/bin/audit.rs"), "mod output;");
await writeFile(join(root, "src/bin/audit/output.rs"), "pub fn write() {}");

const seeds = await rustSeeds(root);
const audit = seeds.find((seed) => seed.entryPath === "src/bin/audit.rs");

expect(audit?.contextFiles?.map((file) => file.path)).toEqual([
"Cargo.toml",
"src/bin/audit/output.rs",
]);
});

it("resolves contextFiles for workspace members", async () => {
const root = await createTempDir();
await writeFile(
join(root, "Cargo.toml"),
`
[workspace]
members = ["crates/web", "crates/db"]
`,
);

await mkdir(join(root, "crates/web/src"), { recursive: true });
await writeFile(join(root, "crates/web/Cargo.toml"), `[package]\nname="web"`);
await writeFile(join(root, "crates/web/src/main.rs"), "mod routes;");
await writeFile(join(root, "crates/web/src/routes.rs"), "");

await mkdir(join(root, "crates/db/src"), { recursive: true });
await writeFile(join(root, "crates/db/Cargo.toml"), `[package]\nname="db"`);
await writeFile(join(root, "crates/db/src/lib.rs"), "mod models;");
await writeFile(join(root, "crates/db/src/models.rs"), "");

const seeds = await rustSeeds(root);
expect(seeds).toHaveLength(2);

const webSeed = seeds.find((s) => s.entryPath === "crates/web/src/main.rs")!;
expect(webSeed.contextFiles?.map((f) => f.path)).toEqual([
"crates/web/Cargo.toml",
"crates/web/src/routes.rs",
]);

const dbSeed = seeds.find((s) => s.entryPath === "crates/db/src/lib.rs")!;
expect(dbSeed.contextFiles?.map((f) => f.path)).toEqual([
"crates/db/Cargo.toml",
"crates/db/src/models.rs",
]);
});
});
142 changes: 133 additions & 9 deletions src/mappers/rust.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import {
stripLineComments,
walk,
} from "./shared.js";
import { FeatureSeed } from "./types.js";
import { FeatureSeed, SeedFileRef } from "./types.js";

const rustFeatureTestLimit = 5;

Expand All @@ -32,20 +32,33 @@ export async function rustSeeds(root: string): Promise<FeatureSeed[]> {
: [];
const rootFeatureTests = rootTests.slice(0, rustFeatureTestLimit);
if (rootHasPackage && (await isSafeFile(root, join(root, "src/main.rs")))) {
seeds.push(rustCommandSeed("src/main.rs", packageName, rustTestCommand, rootFeatureTests));
const context = await rustCrateContextFiles(root, "Cargo.toml", "src/main.rs", false);
seeds.push(
rustCommandSeed("src/main.rs", packageName, rustTestCommand, rootFeatureTests, context),
);
}
if (rootHasPackage && (await isSafeFile(root, join(root, "src/lib.rs")))) {
seeds.push(rustLibrarySeed("src/lib.rs", packageName, rustTestCommand, rootFeatureTests));
const context = await rustCrateContextFiles(root, "Cargo.toml", "src/lib.rs", true);
seeds.push(
rustLibrarySeed("src/lib.rs", packageName, rustTestCommand, rootFeatureTests, context),
);
}
if (rootHasPackage) {
for (const file of (await walk(root, ["src/bin"])).filter((candidate) =>
/^src\/bin\/([^/]+\.rs|[^/]+\/main\.rs)$/u.test(candidate),
)) {
seeds.push(rustCommandSeed(file, rustBinCommand(file), rustTestCommand, rootFeatureTests));
const context = await rustCrateContextFiles(root, "Cargo.toml", file, false);
seeds.push(
rustCommandSeed(file, rustBinCommand(file), rustTestCommand, rootFeatureTests, context),
);
}
for (const test of rootTests) {
const name = test.path.split("/").at(-1)?.replace(/\.rs$/u, "") ?? "integration";
seeds.push(rustIntegrationTestSeed(test.path, name, rustTestCommand));
seeds.push(
rustIntegrationTestSeed(test.path, name, rustTestCommand, [
{ path: "Cargo.toml", reason: "cargo package manifest" },
]),
);
}
}
for (const member of await rustMemberDirs(root)) {
Expand All @@ -57,19 +70,41 @@ export async function rustSeeds(root: string): Promise<FeatureSeed[]> {
const memberTests = await rustIntegrationTests(root, `${memberDir}/tests`, member.testCommand);
const memberFeatureTests = memberTests.slice(0, rustFeatureTestLimit);
if (await isSafeFile(root, join(root, memberMain))) {
seeds.push(rustCommandSeed(memberMain, memberName, member.testCommand, memberFeatureTests));
const context = await rustCrateContextFiles(
root,
`${memberDir}/Cargo.toml`,
memberMain,
false,
);
seeds.push(
rustCommandSeed(memberMain, memberName, member.testCommand, memberFeatureTests, context),
);
}
if (await isSafeFile(root, join(root, memberLib))) {
seeds.push(rustLibrarySeed(memberLib, memberName, member.testCommand, memberFeatureTests));
const context = await rustCrateContextFiles(root, `${memberDir}/Cargo.toml`, memberLib, true);
seeds.push(
rustLibrarySeed(memberLib, memberName, member.testCommand, memberFeatureTests, context),
);
}
for (const file of (await walk(root, [`${memberDir}/src/bin`])).filter(isRustBinFile)) {
const context = await rustCrateContextFiles(root, `${memberDir}/Cargo.toml`, file, false);
seeds.push(
rustCommandSeed(file, rustBinCommand(file), member.testCommand, memberFeatureTests),
rustCommandSeed(
file,
rustBinCommand(file),
member.testCommand,
memberFeatureTests,
context,
),
);
}
for (const test of memberTests) {
const name = test.path.split("/").at(-1)?.replace(/\.rs$/u, "") ?? "integration";
seeds.push(rustIntegrationTestSeed(test.path, `${memberName}/${name}`, member.testCommand));
seeds.push(
rustIntegrationTestSeed(test.path, `${memberName}/${name}`, member.testCommand, [
{ path: `${memberDir}/Cargo.toml`, reason: "cargo package manifest" },
]),
);
}
}
return seeds;
Expand Down Expand Up @@ -241,6 +276,7 @@ function rustCommandSeed(
command: string,
testCommand: string | null = null,
tests: RustTestRef[] = [],
contextFiles: SeedFileRef[] = [],
): FeatureSeed {
return {
title: `Rust command ${command}`,
Expand All @@ -254,6 +290,7 @@ function rustCommandSeed(
command,
tags: ["rust", "cli"],
trustBoundaries: ["user-input", "filesystem", "process-exec", "network"],
contextFiles,
tests,
testCommand,
skipNearbyTests: true,
Expand All @@ -265,6 +302,7 @@ function rustLibrarySeed(
name: string,
testCommand: string | null = null,
tests: RustTestRef[] = [],
contextFiles: SeedFileRef[] = [],
): FeatureSeed {
return {
title: `Rust library ${name}`,
Expand All @@ -278,6 +316,7 @@ function rustLibrarySeed(
command: null,
tags: ["rust", "library"],
trustBoundaries: packageTrustBoundaries(name),
contextFiles,
tests,
testCommand,
skipNearbyTests: true,
Expand All @@ -301,6 +340,7 @@ function rustIntegrationTestSeed(
file: string,
name: string,
testCommand: string | null = null,
contextFiles: SeedFileRef[] = [],
): FeatureSeed {
return {
title: `Rust integration test ${name}`,
Expand All @@ -314,6 +354,7 @@ function rustIntegrationTestSeed(
command: null,
tags: ["rust", "test"],
trustBoundaries: [],
contextFiles,
testCommand,
skipNearbyTests: true,
};
Expand Down Expand Up @@ -357,3 +398,86 @@ async function hasCargoPackageManifest(root: string, manifestPath: string): Prom
const manifest = stripLineComments(await readFile(full, "utf8"), "#");
return cargoSection(manifest, "package").trim().length > 0;
}

function uniqueFileRefs(refs: SeedFileRef[]): SeedFileRef[] {
const seen = new Set<string>();
const unique: SeedFileRef[] = [];
for (const ref of refs) {
if (seen.has(ref.path)) {
continue;
}
seen.add(ref.path);
unique.push(ref);
}
return unique;
}

function rustModuleDirectory(entryFile: string): string {
const parts = entryFile.split("/");
const entryName = parts.at(-1) ?? entryFile;
if (entryName === "main.rs" || entryName === "lib.rs" || entryName === "mod.rs") {
return parts.slice(0, -1).join("/");
}
return entryFile.replace(/\.rs$/u, "");
}

async function rustCrateContextFiles(
root: string,
manifestPath: string,
entryFile: string,
isLibrary: boolean,
): Promise<SeedFileRef[]> {
const refs: SeedFileRef[] = [];
const manifestFull = join(root, manifestPath);
if (await isSafeFile(root, manifestFull)) {
refs.push({ path: manifestPath, reason: "cargo package manifest" });
}

const crateDir = manifestPath.replace(/\/Cargo\.toml$/u, "").replace(/^Cargo\.toml$/u, "");
const prefix = crateDir.length > 0 ? `${crateDir}/` : "";

if (isLibrary) {
const mainFile = `${prefix}src/main.rs`;
if (entryFile !== mainFile && (await isSafeFile(root, join(root, mainFile)))) {
refs.push({ path: mainFile, reason: "crate binary entry" });
}
} else {
const libFile = `${prefix}src/lib.rs`;
if (entryFile !== libFile && (await isSafeFile(root, join(root, libFile)))) {
refs.push({ path: libFile, reason: "crate library entry" });
}
}

const entryFull = join(root, entryFile);
if (!(await isSafeFile(root, entryFull))) {
return refs;
}

const source = await readFile(entryFull, "utf8");
const modPattern = /^\s*(?:pub\s+)?mod\s+(\w+)\s*;/gmu;
const matches = [...source.matchAll(modPattern)];
const moduleDir = rustModuleDirectory(entryFile);
const modulePrefix = moduleDir.length > 0 ? `${moduleDir}/` : "";

for (const match of matches) {
const modName = match[1];
if (modName === undefined) {
continue;
}

const modFile = `${modulePrefix}${modName}.rs`;
const modDirFile = `${modulePrefix}${modName}/mod.rs`;

if (await isSafeFile(root, join(root, modFile))) {
refs.push({ path: modFile, reason: "declared module" });
} else if (await isSafeFile(root, join(root, modDirFile))) {
refs.push({ path: modDirFile, reason: "declared module" });
}

if (refs.length >= 16) {
break;
}
}

return uniqueFileRefs(refs);
}