diff --git a/CHANGELOG.md b/CHANGELOG.md index 1b1907a..d22e5c0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/src/mapper.test.ts b/src/mapper.test.ts index 9c32fb3..e321017 100644 --- a/src/mapper.test.ts +++ b/src/mapper.test.ts @@ -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); }); diff --git a/src/mappers/rust.test.ts b/src/mappers/rust.test.ts new file mode 100644 index 0000000..e644e91 --- /dev/null +++ b/src/mappers/rust.test.ts @@ -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", + ]); + }); +}); diff --git a/src/mappers/rust.ts b/src/mappers/rust.ts index de3ae64..26090f6 100644 --- a/src/mappers/rust.ts +++ b/src/mappers/rust.ts @@ -10,7 +10,7 @@ import { stripLineComments, walk, } from "./shared.js"; -import { FeatureSeed } from "./types.js"; +import { FeatureSeed, SeedFileRef } from "./types.js"; const rustFeatureTestLimit = 5; @@ -32,20 +32,33 @@ export async function rustSeeds(root: string): Promise { : []; 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)) { @@ -57,19 +70,41 @@ export async function rustSeeds(root: string): Promise { 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; @@ -241,6 +276,7 @@ function rustCommandSeed( command: string, testCommand: string | null = null, tests: RustTestRef[] = [], + contextFiles: SeedFileRef[] = [], ): FeatureSeed { return { title: `Rust command ${command}`, @@ -254,6 +290,7 @@ function rustCommandSeed( command, tags: ["rust", "cli"], trustBoundaries: ["user-input", "filesystem", "process-exec", "network"], + contextFiles, tests, testCommand, skipNearbyTests: true, @@ -265,6 +302,7 @@ function rustLibrarySeed( name: string, testCommand: string | null = null, tests: RustTestRef[] = [], + contextFiles: SeedFileRef[] = [], ): FeatureSeed { return { title: `Rust library ${name}`, @@ -278,6 +316,7 @@ function rustLibrarySeed( command: null, tags: ["rust", "library"], trustBoundaries: packageTrustBoundaries(name), + contextFiles, tests, testCommand, skipNearbyTests: true, @@ -301,6 +340,7 @@ function rustIntegrationTestSeed( file: string, name: string, testCommand: string | null = null, + contextFiles: SeedFileRef[] = [], ): FeatureSeed { return { title: `Rust integration test ${name}`, @@ -314,6 +354,7 @@ function rustIntegrationTestSeed( command: null, tags: ["rust", "test"], trustBoundaries: [], + contextFiles, testCommand, skipNearbyTests: true, }; @@ -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(); + 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 { + 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); +}