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
4 changes: 3 additions & 1 deletion docs/feature-mapping.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`,
Expand Down
114 changes: 114 additions & 0 deletions src/mapper.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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("\\", "/");
Expand Down
28 changes: 23 additions & 5 deletions src/mappers/rust.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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",
]);
});
});
99 changes: 99 additions & 0 deletions src/mappers/rust.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -13,6 +15,7 @@ import {
import { FeatureSeed, SeedFileRef } from "./types.js";

const rustFeatureTestLimit = 5;
const sourceGroupMaxOwnedFiles = 12;

type RustTestRef = {
path: string;
Expand Down Expand Up @@ -60,6 +63,14 @@ export async function rustSeeds(root: string): Promise<FeatureSeed[]> {
]),
);
}
seeds.push(
...(await rustSourceGroupSeeds(root, {
sourceRoot: "src",
packageName,
manifestPath: "Cargo.toml",
testCommand: rustTestCommand,
})),
);
}
for (const member of await rustMemberDirs(root)) {
const memberDir = member.dir;
Expand Down Expand Up @@ -106,10 +117,98 @@ export async function rustSeeds(root: string): Promise<FeatureSeed[]> {
]),
);
}
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<FeatureSeed[]> {
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<SeedFileRef[]> {
if (!(await isSafeFile(root, join(root, manifestPath)))) {
return [];
}
return [{ path: manifestPath, reason: "cargo package manifest" }];
}

type RustMemberDir = {
dir: string;
testCommand: string | null;
Expand Down