diff --git a/docs/feature-mapping.md b/docs/feature-mapping.md index 1f08194..dab0364 100644 --- a/docs/feature-mapping.md +++ b/docs/feature-mapping.md @@ -53,7 +53,9 @@ Supported deterministic mappers today: groups including Hilt, Dagger, Koin, and Metro - Ruby project metadata, executables, source groups, RSpec/Minitest suites, Rails configs, routes, views, assets, and database files -- Rust Cargo commands, libraries, workspace crates, and integration tests +- Rust Cargo commands, libraries, workspace crates, integration tests, and + bounded source groups under each package `src/` (entrypoint `lib.rs` / + `main.rs` / bin files stay on command and library features) - C/C++/CUDA standalone `main()` files, CMake targets, autotools targets, and bounded loose source groups - C#/.NET projects from `.sln`, `.slnx`, `.csproj`, `.fsproj`, and `.vbproj`, diff --git a/src/mapper.test.ts b/src/mapper.test.ts index e321017..1be127d 100644 --- a/src/mapper.test.ts +++ b/src/mapper.test.ts @@ -11030,6 +11030,120 @@ let package = Package(name: "HybridApp", targets: [.target(name: "HybridApp")]) expect(integrationTests).toHaveLength(8); }); + it("maps Rust source groups for crate modules without re-owning entrypoints", async () => { + const root = await fixtureRoot("clawpatch-rust-source-groups-"); + await writeFixture( + root, + "Cargo.toml", + '[workspace]\nmembers = ["crates/core", "crates/cli"]\n', + ); + await writeFixture(root, "crates/core/Cargo.toml", '[package]\nname = "core"\n'); + await writeFixture(root, "crates/core/src/lib.rs", "pub mod auth;\npub mod storage;\n"); + await writeFixture(root, "crates/core/src/auth/mod.rs", "pub fn login() {}\n"); + await writeFixture(root, "crates/core/src/auth/token.rs", "pub fn mint() {}\n"); + await writeFixture(root, "crates/core/src/storage/mod.rs", "pub fn open() {}\n"); + await writeFixture(root, "crates/core/src/storage/db.rs", "pub fn connect() {}\n"); + await writeFixture(root, "crates/cli/Cargo.toml", '[package]\nname = "cli"\n'); + await writeFixture(root, "crates/cli/src/main.rs", "fn main() {}\n"); + await writeFixture(root, "crates/cli/src/commands.rs", "pub fn run() {}\n"); + await writeFixture(root, "crates/cli/src/bin/worker.rs", "fn main() {}\n"); + await writeFixture(root, "crates/cli/src/bin/admin/main.rs", "fn main() {}\n"); + await writeFixture(root, "crates/cli/src/bin/admin/helpers.rs", "pub fn prep() {}\n"); + + const project = await detectProject(root); + const result = await mapFeatures(root, project, []); + const sourceGroups = result.features.filter( + (feature) => feature.source === "rust-source-group", + ); + const owned = sourceGroups.flatMap((feature) => feature.ownedFiles.map((file) => file.path)); + const titles = sourceGroups.map((feature) => feature.title); + + expect(sourceGroups.length).toBeGreaterThanOrEqual(2); + expect(owned).toContain("crates/core/src/auth/mod.rs"); + expect(owned).toContain("crates/core/src/auth/token.rs"); + expect(owned).toContain("crates/core/src/storage/mod.rs"); + expect(owned).toContain("crates/core/src/storage/db.rs"); + expect(owned).toContain("crates/cli/src/commands.rs"); + expect(owned).toContain("crates/cli/src/bin/admin/helpers.rs"); + expect(owned).not.toContain("crates/core/src/lib.rs"); + expect(owned).not.toContain("crates/cli/src/main.rs"); + expect(owned).not.toContain("crates/cli/src/bin/worker.rs"); + expect(owned).not.toContain("crates/cli/src/bin/admin/main.rs"); + expect(titles.some((title) => title.includes("crates/core/src"))).toBe(true); + expect( + sourceGroups.find((feature) => + feature.ownedFiles.some((file) => file.path === "crates/core/src/auth/mod.rs"), + ), + ).toMatchObject({ + kind: "library", + confidence: "medium", + tags: expect.arrayContaining(["rust", "source-group"]), + contextFiles: [{ path: "crates/core/Cargo.toml", reason: "cargo package manifest" }], + tests: [], + }); + expect(sourceGroups.every((feature) => feature.ownedFiles.length <= 12)).toBe(true); + expect(result.features.some((feature) => feature.title === "Rust library core")).toBe(true); + expect(result.features.some((feature) => feature.title === "Rust command cli")).toBe(true); + expect(result.features.some((feature) => feature.title === "Rust command worker")).toBe(true); + }); + + it("partitions oversized Rust source directories into bounded groups", async () => { + const root = await fixtureRoot("clawpatch-rust-source-chunk-"); + await writeFixture(root, "Cargo.toml", '[package]\nname = "chunky"\n'); + await writeFixture(root, "src/lib.rs", "pub fn root() {}\n"); + for (let index = 1; index <= 20; index += 1) { + await writeFixture(root, `src/module_${index}.rs`, `pub fn f${index}() {}\n`); + } + + const project = await detectProject(root); + const result = await mapFeatures(root, project, []); + const sourceGroups = result.features.filter( + (feature) => feature.source === "rust-source-group", + ); + const owned = sourceGroups.flatMap((feature) => feature.ownedFiles.map((file) => file.path)); + + expect(sourceGroups.length).toBeGreaterThan(1); + expect(owned).toHaveLength(20); + expect(owned).not.toContain("src/lib.rs"); + expect(sourceGroups.every((feature) => feature.ownedFiles.length <= 12)).toBe(true); + expect(sourceGroups.every((feature) => feature.entrypoints[0]?.path === "Cargo.toml")).toBe( + true, + ); + expect( + sourceGroups.every((feature) => + feature.contextFiles.some( + (file) => file.path === "Cargo.toml" && file.reason === "cargo package manifest", + ), + ), + ).toBe(true); + }); + + it("keeps Rust source group identities stable when modules are added", async () => { + const root = await fixtureRoot("clawpatch-rust-source-stable-"); + await writeFixture(root, "Cargo.toml", '[package]\nname = "stable"\n'); + await writeFixture(root, "src/lib.rs", "pub mod alpha;\n"); + await writeFixture(root, "src/alpha.rs", "pub fn a() {}\n"); + await writeFixture(root, "src/beta.rs", "pub fn b() {}\n"); + + const project = await detectProject(root); + const first = await mapFeatures(root, project, []); + const firstGroups = first.features.filter((feature) => feature.source === "rust-source-group"); + const firstIds = firstGroups.map((feature) => feature.featureId).toSorted(); + + await writeFixture(root, "src/aardvark.rs", "pub fn first() {}\n"); + const second = await mapFeatures(root, project, first.features); + const secondGroups = second.features.filter( + (feature) => feature.source === "rust-source-group", + ); + const secondIds = secondGroups.map((feature) => feature.featureId).toSorted(); + + expect(firstIds.length).toBeGreaterThan(0); + for (const id of firstIds) { + expect(secondIds).toContain(id); + } + expect(second.stale).toBe(0); + }); + it("maps CMake C and C++ targets without duplicating main files", async () => { const root = await fixtureRoot("clawpatch-cmake-cpp-map-"); const cmakeRoot = root.replaceAll("\\", "/"); diff --git a/src/mappers/rust.test.ts b/src/mappers/rust.test.ts index e644e91..b8643ce 100644 --- a/src/mappers/rust.test.ts +++ b/src/mappers/rust.test.ts @@ -34,10 +34,21 @@ fn main() {} await writeFile(join(root, "src/utils/mod.rs"), "fn utils() {}"); const seeds = await rustSeeds(root); - expect(seeds).toHaveLength(1); + expect(seeds).toHaveLength(2); - const contextPaths = seeds[0]!.contextFiles?.map((f) => f.path); + const command = seeds.find((seed) => seed.source === "rust-command"); + const contextPaths = command?.contextFiles?.map((f) => f.path); expect(contextPaths).toEqual(["Cargo.toml", "src/api.rs", "src/utils/mod.rs"]); + + const sourceGroup = seeds.find((seed) => seed.source === "rust-source-group"); + expect(sourceGroup).toMatchObject({ + entryPath: "Cargo.toml", + identityKey: "src", + ownedFiles: [ + { path: "src/api.rs", reason: "source group src" }, + { path: "src/utils/mod.rs", reason: "source group src" }, + ], + }); }); it("cross-links lib.rs and main.rs", async () => { @@ -119,18 +130,25 @@ members = ["crates/web", "crates/db"] await writeFile(join(root, "crates/db/src/models.rs"), ""); const seeds = await rustSeeds(root); - expect(seeds).toHaveLength(2); + expect(seeds).toHaveLength(4); - const webSeed = seeds.find((s) => s.entryPath === "crates/web/src/main.rs")!; + const webSeed = seeds.find((seed) => seed.source === "rust-command")!; 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")!; + const dbSeed = seeds.find((seed) => seed.source === "rust-library")!; expect(dbSeed.contextFiles?.map((f) => f.path)).toEqual([ "crates/db/Cargo.toml", "crates/db/src/models.rs", ]); + + const sourceGroups = seeds.filter((seed) => seed.source === "rust-source-group"); + expect(sourceGroups).toHaveLength(2); + expect(sourceGroups.map((seed) => seed.entryPath).toSorted()).toEqual([ + "crates/db/Cargo.toml", + "crates/web/Cargo.toml", + ]); }); }); diff --git a/src/mappers/rust.ts b/src/mappers/rust.ts index 26090f6..11bf6ea 100644 --- a/src/mappers/rust.ts +++ b/src/mappers/rust.ts @@ -2,9 +2,11 @@ import { readFile, readdir } from "node:fs/promises"; import { join } from "node:path"; import { pathExists } from "../fs.js"; import { shellQuotePath } from "../shell.js"; +import { partitionFileGroups } from "./grouping.js"; import { isSafeDirectory, isSafeFile, + packageKind, packageTrustBoundaries, normalize, stripLineComments, @@ -13,6 +15,7 @@ import { import { FeatureSeed, SeedFileRef } from "./types.js"; const rustFeatureTestLimit = 5; +const sourceGroupMaxOwnedFiles = 12; type RustTestRef = { path: string; @@ -60,6 +63,14 @@ export async function rustSeeds(root: string): Promise { ]), ); } + seeds.push( + ...(await rustSourceGroupSeeds(root, { + sourceRoot: "src", + packageName, + manifestPath: "Cargo.toml", + testCommand: rustTestCommand, + })), + ); } for (const member of await rustMemberDirs(root)) { const memberDir = member.dir; @@ -106,10 +117,98 @@ export async function rustSeeds(root: string): Promise { ]), ); } + seeds.push( + ...(await rustSourceGroupSeeds(root, { + sourceRoot: `${memberDir}/src`, + packageName: memberName, + manifestPath: `${memberDir}/Cargo.toml`, + testCommand: member.testCommand, + })), + ); + } + return seeds; +} + +type RustSourceGroupOptions = { + sourceRoot: string; + packageName: string; + manifestPath: string; + testCommand: string | null; +}; + +async function rustSourceGroupSeeds( + root: string, + options: RustSourceGroupOptions, +): Promise { + const { sourceRoot, packageName, manifestPath, testCommand } = options; + if (!(await isSafeDirectory(root, join(root, sourceRoot)))) { + return []; + } + const files = (await walk(root, [sourceRoot])).filter( + (path) => isRustSourceFile(path) && !isRustPackageEntrypoint(path, sourceRoot), + ); + if (files.length === 0) { + return []; + } + + const contextFiles = await rustManifestContextFiles(root, manifestPath); + const seeds: FeatureSeed[] = []; + for (const group of partitionFileGroups(sourceRoot, files, sourceGroupMaxOwnedFiles)) { + seeds.push({ + title: `Rust source ${group.label}`, + summary: + group.files.length === 1 + ? `Rust source file ${group.files[0]}.` + : `Rust source group ${group.label} with ${group.files.length} files.`, + kind: packageKind(`${packageName} ${group.label}`), + source: "rust-source-group", + confidence: "medium", + entryPath: manifestPath, + identityKey: group.label, + symbol: group.label, + route: null, + command: null, + ownedFiles: group.files.map((path) => ({ + path, + reason: `source group ${group.label}`, + })), + contextFiles, + tags: ["rust", "source-group"], + trustBoundaries: packageTrustBoundaries(`${packageName} ${group.label}`), + testCommand, + skipNearbyTests: true, + }); } return seeds; } +function isRustSourceFile(path: string): boolean { + return path.endsWith(".rs"); +} + +/** + * Package entrypoints already mapped as command/library/bin features. + * Remaining modules under src/ become reviewable source groups. + */ +function isRustPackageEntrypoint(path: string, sourceRoot: string): boolean { + if (path === `${sourceRoot}/lib.rs` || path === `${sourceRoot}/main.rs`) { + return true; + } + return new RegExp(`^${escapeRegExp(sourceRoot)}/bin/([^/]+\\.rs|[^/]+/main\\.rs)$`, "u").test( + path, + ); +} + +async function rustManifestContextFiles( + root: string, + manifestPath: string, +): Promise { + if (!(await isSafeFile(root, join(root, manifestPath)))) { + return []; + } + return [{ path: manifestPath, reason: "cargo package manifest" }]; +} + type RustMemberDir = { dir: string; testCommand: string | null;