Unnamed repository; edit this file 'description' to name the repository.
-rw-r--r--editors/code/package.json40
-rw-r--r--editors/code/src/commands.ts1
-rw-r--r--editors/code/src/config.ts4
-rw-r--r--editors/code/src/main.ts6
-rw-r--r--editors/code/src/new_project.ts311
-rw-r--r--editors/code/src/toolchain.ts74
-rw-r--r--editors/code/src/util.ts6
-rw-r--r--editors/code/tests/unit/commands.test.ts80
-rw-r--r--editors/code/tests/unit/launch_config.test.ts49
-rw-r--r--editors/code/walkthrough-create-project.md9
10 files changed, 569 insertions, 11 deletions
diff --git a/editors/code/package.json b/editors/code/package.json
index 7bc8fda659..13d32283d2 100644
--- a/editors/code/package.json
+++ b/editors/code/package.json
@@ -215,6 +215,11 @@
"category": "rust-analyzer"
},
{
+ "command": "rust-analyzer.newProject",
+ "title": "Create New Project...",
+ "category": "rust-analyzer"
+ },
+ {
"command": "rust-analyzer.rebuildProcMacros",
"title": "Rebuild proc macros and build scripts",
"category": "rust-analyzer"
@@ -491,6 +496,23 @@
"markdownDescription": "Do not start rust-analyzer server when the extension is activated.",
"default": false,
"type": "boolean"
+ },
+ "rust-analyzer.projectCreation.openAfterCreate": {
+ "markdownDescription": "Control what happens after `rust-analyzer: Create New Project...` finishes creating a Cargo project.",
+ "default": "ask",
+ "enum": [
+ "ask",
+ "open",
+ "openNewWindow",
+ "addToWorkspace"
+ ],
+ "enumDescriptions": [
+ "Prompt for how to open the new project.",
+ "Open the new project in the current window.",
+ "Open the new project in a new window.",
+ "Add the new project to the current workspace, or open it if no workspace is open."
+ ],
+ "type": "string"
}
}
},
@@ -3813,6 +3835,9 @@
"when": "inRustProject"
},
{
+ "command": "rust-analyzer.newProject"
+ },
+ {
"command": "rust-analyzer.reloadWorkspace",
"when": "inRustProject"
},
@@ -3931,6 +3956,13 @@
}
]
},
+ "viewsWelcome": [
+ {
+ "view": "explorer",
+ "contents": "Create a new Rust project.\n[Create Rust Project](command:rust-analyzer.newProject)",
+ "when": "workspaceFolderCount == 0"
+ }
+ ],
"viewsContainers": {
"activitybar": [
{
@@ -3957,6 +3989,14 @@
"description": "A brief introduction to get started with rust-analyzer. Learn about key features and resources to help you get the most out of the extension.",
"steps": [
{
+ "id": "create-project",
+ "title": "Create a Rust project",
+ "description": "Start a new Cargo binary or library project from VS Code.\n\n[Create a Rust Project](command:rust-analyzer.newProject)",
+ "media": {
+ "markdown": "./walkthrough-create-project.md"
+ }
+ },
+ {
"id": "setup",
"title": "Useful Setup Tips",
"description": "There are a couple of things you might want to configure upfront to your tastes. We'll name a few here but be sure to check out the docs linked below!\n\n**Marking library sources as readonly**\n\nAdding the snippet on the right to your settings.json will mark all Rust library sources as readonly.\n\n**Check on Save**\n\nBy default, rust-analyzer will run ``cargo check`` on your codebase when you save a file, rendering diagnostics emitted by ``cargo check`` within your code. This can potentially collide with other ``cargo`` commands running concurrently, blocking them from running for a certain amount of time. In these cases it is recommended to disable the ``rust-analyzer.checkOnSave`` configuration and running the ``rust-analyzer: Run flycheck`` command on-demand instead.",
diff --git a/editors/code/src/commands.ts b/editors/code/src/commands.ts
index 9c8b7071b3..5514319c69 100644
--- a/editors/code/src/commands.ts
+++ b/editors/code/src/commands.ts
@@ -33,6 +33,7 @@ import { log } from "./util";
import type { SyntaxElement } from "./syntax_tree_provider";
export * from "./run";
+export { newProject } from "./new_project";
export function analyzerStatus(ctx: CtxInit): Cmd {
const tdcp = new (class implements vscode.TextDocumentContentProvider {
diff --git a/editors/code/src/config.ts b/editors/code/src/config.ts
index d65f011c75..0f783c71b9 100644
--- a/editors/code/src/config.ts
+++ b/editors/code/src/config.ts
@@ -447,6 +447,10 @@ export class Config {
return this.get<boolean>("initializeStopped");
}
+ get projectCreationOpenAfterCreate() {
+ return this.get<string>("projectCreation.openAfterCreate");
+ }
+
get askBeforeUpdateTest() {
return this.get<boolean>("runnables.askBeforeUpdateTest");
}
diff --git a/editors/code/src/main.ts b/editors/code/src/main.ts
index 6567bcd2f1..7136314e7c 100644
--- a/editors/code/src/main.ts
+++ b/editors/code/src/main.ts
@@ -161,6 +161,12 @@ function createCommands(): Record<string, CommandFactory> {
memoryUsage: { enabled: commands.memoryUsage },
reloadWorkspace: { enabled: commands.reloadWorkspace },
rebuildProcMacros: { enabled: commands.rebuildProcMacros },
+ newProject: {
+ // Project creation is a pure VS Code-side workflow and should stay available even in
+ // empty windows before rust-analyzer has started or a Rust workspace exists.
+ enabled: commands.newProject,
+ disabled: commands.newProject,
+ },
matchingBrace: { enabled: commands.matchingBrace },
joinLines: { enabled: commands.joinLines },
parentModule: { enabled: commands.parentModule },
diff --git a/editors/code/src/new_project.ts b/editors/code/src/new_project.ts
new file mode 100644
index 0000000000..f5f6d86da0
--- /dev/null
+++ b/editors/code/src/new_project.ts
@@ -0,0 +1,311 @@
+import * as vscode from "vscode";
+
+import type { Ctx, Cmd } from "./ctx";
+import * as ra from "./lsp_ext";
+import { cargoPath } from "./toolchain";
+import { log, spawnAsync } from "./util";
+
+type NewProjectKind = "bin" | "lib";
+type NewProjectOpenAction = "open" | "openNewWindow" | "addToWorkspace";
+
+type NewProjectTemplate = {
+ detail: string;
+ id: NewProjectKind;
+ label: string;
+};
+
+type NewProjectCargo = {
+ cargo: string;
+ cargoEnv: NodeJS.ProcessEnv;
+};
+
+const NEW_PROJECT_TEMPLATES: readonly NewProjectTemplate[] = [
+ {
+ id: "bin",
+ label: "Binary Application",
+ detail: "Create a Cargo binary package (`cargo new --bin`)",
+ },
+ {
+ id: "lib",
+ label: "Library",
+ detail: "Create a Cargo library package (`cargo new --lib`)",
+ },
+] as const;
+
+export function newProject(ctx: Ctx): Cmd {
+ return async () => {
+ const cargo = await resolveNewProjectCargo(ctx);
+
+ const selectedKind = await promptForNewProjectTemplate();
+ if (!selectedKind) {
+ return;
+ }
+
+ const parentFolder = await promptForNewProjectParentFolder();
+ if (!parentFolder) {
+ return;
+ }
+
+ const projectName = await promptForNewProjectName(parentFolder);
+ if (!projectName) {
+ return;
+ }
+
+ if (!(await createNewProject(cargo, parentFolder, selectedKind, projectName))) {
+ return;
+ }
+
+ const projectUri = vscode.Uri.joinPath(parentFolder, projectName);
+ const defaultAction = determineNewProjectOpenAction(
+ ctx.config.projectCreationOpenAfterCreate,
+ Boolean(vscode.workspace.workspaceFolders?.length),
+ );
+ const action =
+ defaultAction === "ask"
+ ? await promptForNewProjectOpenAction(
+ projectName,
+ Boolean(vscode.workspace.workspaceFolders?.length),
+ )
+ : defaultAction;
+
+ if (action) {
+ await executeNewProjectOpenAction(ctx, action, projectUri);
+ }
+ };
+}
+
+async function resolveNewProjectCargo(ctx: Ctx): Promise<NewProjectCargo> {
+ // Use the same effective environment rust-analyzer uses elsewhere so project creation sees
+ // toolchain wrappers, PATH overrides, and CARGO_HOME changes from configuration.
+ const cargoEnv = { ...process.env, ...ctx.config.serverExtraEnv };
+ return { cargo: await cargoPath(cargoEnv), cargoEnv };
+}
+
+async function promptForNewProjectTemplate(): Promise<NewProjectKind | undefined> {
+ const selected = await vscode.window.showQuickPick(NEW_PROJECT_TEMPLATES, {
+ placeHolder: "Select a Rust project kind",
+ });
+ return selected?.id;
+}
+
+async function promptForNewProjectParentFolder(): Promise<vscode.Uri | undefined> {
+ const selectedFolder = await vscode.window.showOpenDialog({
+ title: "Select the parent folder for the new Rust project",
+ openLabel: "Select parent folder",
+ canSelectFiles: false,
+ canSelectFolders: true,
+ canSelectMany: false,
+ });
+ if (!selectedFolder?.length) {
+ return undefined;
+ }
+ return selectedFolder[0];
+}
+
+const CARGO_MANIFEST_NAME_PATTERN = /^[\p{Alphabetic}\p{Number}_-]+$/u;
+
+// Keep local validation focused on stable checks that can be reported in the input box, then let
+// `cargo new` remain the source of truth for package-name-specific identifier and keyword rules.
+export function validateNewProjectName(
+ value: string,
+ existingNames: readonly string[],
+): string | undefined {
+ const trimmedValue = value.trim();
+ if (trimmedValue.length === 0) {
+ return "Project name cannot be empty.";
+ }
+ if (trimmedValue.includes("/") || trimmedValue.includes("\\")) {
+ return "Project name cannot contain '/' or '\\' characters.";
+ }
+ if (trimmedValue === "." || trimmedValue === "..") {
+ return "Project name cannot be '.' or '..'.";
+ }
+ if (!CARGO_MANIFEST_NAME_PATTERN.test(trimmedValue)) {
+ return "Project name can contain only alphanumeric characters, '-' or '_'.";
+ }
+ if (existingNames.includes(trimmedValue)) {
+ return "A file or folder with this name already exists.";
+ }
+ return undefined;
+}
+
+async function promptForNewProjectName(parentFolder: vscode.Uri): Promise<string | undefined> {
+ let existingNames: string[] = [];
+ try {
+ const entries = await vscode.workspace.fs.readDirectory(parentFolder);
+ existingNames = entries.map(([name]) => name);
+ } catch (error) {
+ log.error("Failed to read project parent folder", error);
+ void vscode.window.showErrorMessage("Failed to read the selected parent folder.");
+ return undefined;
+ }
+
+ const projectName = await vscode.window.showInputBox({
+ prompt: `Enter the new project name to create inside ${parentFolder.fsPath}`,
+ validateInput: async (value) => validateNewProjectName(value, existingNames),
+ });
+ return projectName?.trim();
+}
+
+export function cargoNewArgs(kind: NewProjectKind, name: string): string[] {
+ return ["new", kind === "bin" ? "--bin" : "--lib", name];
+}
+
+async function createNewProject(
+ cargo: NewProjectCargo,
+ parentFolder: vscode.Uri,
+ kind: NewProjectKind,
+ projectName: string,
+): Promise<boolean> {
+ const args = cargoNewArgs(kind, projectName);
+ const createResult = await vscode.window.withProgress(
+ {
+ location: vscode.ProgressLocation.Notification,
+ title: `Creating Rust project ${projectName}`,
+ },
+ async () =>
+ spawnAsync(cargo.cargo, args, {
+ cwd: parentFolder.fsPath,
+ env: cargo.cargoEnv,
+ }),
+ );
+
+ if (createResult.error || createResult.status !== 0) {
+ const details = formatProcessDetails(createResult);
+ await showNewProjectError("Failed to create Rust project.", details || undefined, {
+ cargo: cargo.cargo,
+ args,
+ cwd: parentFolder.fsPath,
+ error: createResult.error?.message,
+ status: createResult.status,
+ stderr: createResult.stderr || undefined,
+ stdout: createResult.stdout || undefined,
+ });
+ return false;
+ }
+
+ return true;
+}
+
+function formatProcessDetails(result: { error?: Error; stderr: string; stdout: string }): string {
+ return [result.stderr, result.stdout, result.error?.message]
+ .filter((value): value is string => Boolean(value && value.trim().length > 0))
+ .join("\n")
+ .trim();
+}
+
+async function showNewProjectError(
+ message: string,
+ details: string | undefined,
+ logContext: {
+ cargo: string;
+ args: string[];
+ cwd?: string;
+ error?: string;
+ status: number | null;
+ stderr?: string;
+ stdout?: string;
+ },
+): Promise<void> {
+ // Keep command-failure logging focused on the invocation and process output. Environment
+ // variables may contain secrets such as API keys, tokens, and credentials, so failure logs
+ // must not dump the merged env here.
+ const commandLine = [logContext.cargo, ...logContext.args].join(" ");
+ log.error(message);
+ log.error(`command: ${commandLine}`);
+ if (logContext.cwd) {
+ log.error(`cwd: ${logContext.cwd}`);
+ }
+ log.error(`exit status: ${String(logContext.status)}`);
+ if (logContext.error) {
+ log.error(`error: ${logContext.error}`);
+ }
+ if (logContext.stderr) {
+ log.error(`stderr:\n${logContext.stderr}`);
+ }
+ if (logContext.stdout) {
+ log.error(`stdout:\n${logContext.stdout}`);
+ }
+ const selection = await vscode.window.showErrorMessage(
+ details ? `${message}\n${details}` : message,
+ "Open Extension Logs",
+ );
+ if (selection === "Open Extension Logs") {
+ log.show();
+ }
+}
+
+export function determineNewProjectOpenAction(
+ configuredAction: string | undefined,
+ hasWorkspaceFolders: boolean,
+): "ask" | NewProjectOpenAction {
+ switch (configuredAction) {
+ case "open":
+ case "openNewWindow":
+ return configuredAction;
+ case "addToWorkspace":
+ // Adding to a workspace only makes sense when one is already open. Falling back to
+ // "open" keeps the setting usable in empty windows without adding another prompt path.
+ return hasWorkspaceFolders ? configuredAction : "open";
+ default:
+ return "ask";
+ }
+}
+
+async function promptForNewProjectOpenAction(
+ projectName: string,
+ hasWorkspaceFolders: boolean,
+): Promise<NewProjectOpenAction | undefined> {
+ let message = `Would you like to open ${projectName}?`;
+ const open = "Open";
+ const openNewWindow = "Open in New Window";
+ const choices = [open, openNewWindow];
+
+ const addToWorkspace = "Add to VS Code Workspace";
+ if (hasWorkspaceFolders) {
+ message = `Would you like to open ${projectName}, or add it to the current VS Code workspace?`;
+ choices.push(addToWorkspace);
+ }
+
+ const result = await vscode.window.showInformationMessage(
+ message,
+ { modal: true, detail: "The default action can be configured in settings." },
+ ...choices,
+ );
+
+ const actionMap: Record<string, NewProjectOpenAction> = {
+ [open]: "open",
+ [openNewWindow]: "openNewWindow",
+ [addToWorkspace]: "addToWorkspace",
+ };
+ return result ? actionMap[result] : undefined;
+}
+
+async function executeNewProjectOpenAction(
+ ctx: Ctx,
+ action: NewProjectOpenAction,
+ projectUri: vscode.Uri,
+): Promise<void> {
+ if (action === "open") {
+ await vscode.commands.executeCommand("vscode.openFolder", projectUri, {
+ forceReuseWindow: true,
+ });
+ return;
+ }
+
+ if (action === "openNewWindow") {
+ await vscode.commands.executeCommand("vscode.openFolder", projectUri, {
+ forceNewWindow: true,
+ });
+ return;
+ }
+
+ const index = vscode.workspace.workspaceFolders?.length ?? 0;
+ vscode.workspace.updateWorkspaceFolders(index, 0, { uri: projectUri });
+ // Reuse the existing workspace window when requested, but nudge rust-analyzer afterwards so
+ // the newly added Cargo project is discovered immediately instead of waiting for a later
+ // background refresh.
+ if (ctx.client?.isRunning()) {
+ await ctx.client.sendRequest(ra.reloadWorkspace);
+ }
+}
diff --git a/editors/code/src/toolchain.ts b/editors/code/src/toolchain.ts
index 76946d1510..6ae365c71c 100644
--- a/editors/code/src/toolchain.ts
+++ b/editors/code/src/toolchain.ts
@@ -3,7 +3,7 @@ import * as os from "os";
import * as path from "path";
import * as readline from "readline";
import * as vscode from "vscode";
-import { Env, log, memoizeAsync, unwrapUndefinable } from "./util";
+import { Env, isWindows, log, memoizeAsync, unwrapUndefinable } from "./util";
import type { CargoRunnableArgs } from "./lsp_ext";
interface CompilationArtifact {
@@ -160,9 +160,46 @@ export function cargoPath(env?: Env): Promise<string> {
if (env?.["RUSTC_TOOLCHAIN"]) {
return Promise.resolve("cargo");
}
+ if (env) {
+ return getPathForExecutableWithEnv("cargo", env);
+ }
return getPathForExecutable("cargo");
}
+/**
+ * Resolves an executable using an explicitly supplied environment instead of the VS Code host
+ * process environment.
+ *
+ * Some extension call sites already construct the exact env they will use for spawning, so path
+ * resolution needs to honor that same `PATH`/`CARGO_HOME` view to avoid launching a different
+ * toolchain than the one that was resolved.
+ */
+async function getPathForExecutableWithEnv(
+ executableName: "cargo" | "rustc" | "rustup",
+ env: Env,
+): Promise<string> {
+ const envVar = getEnvVar(env, executableName.toUpperCase());
+ if (envVar) {
+ return envVar;
+ }
+
+ if (await lookupInPath(executableName, getEnvVar(env, "PATH") ?? "")) {
+ return executableName;
+ }
+
+ const cargoHome = getCargoHomeFromPath(getEnvVar(env, "CARGO_HOME"));
+ if (cargoHome) {
+ for (const candidate of executableCandidates(executableName)) {
+ const standardPath = vscode.Uri.joinPath(cargoHome, "bin", candidate);
+ if (await isFileAtUri(standardPath)) {
+ return standardPath.fsPath;
+ }
+ }
+ }
+
+ return executableName;
+}
+
/** Mirrors `toolchain::get_path_for_executable()` implementation */
const getPathForExecutable = memoizeAsync(
// We apply caching to decrease file-system interactions
@@ -172,23 +209,22 @@ const getPathForExecutable = memoizeAsync(
if (envVar) return envVar;
}
- if (await lookupInPath(executableName)) return executableName;
+ if (await lookupInPath(executableName, process.env["PATH"] ?? "")) return executableName;
const cargoHome = getCargoHome();
if (cargoHome) {
- const standardPath = vscode.Uri.joinPath(cargoHome, "bin", executableName);
- if (await isFileAtUri(standardPath)) return standardPath.fsPath;
+ for (const candidate of executableCandidates(executableName)) {
+ const standardPath = vscode.Uri.joinPath(cargoHome, "bin", candidate);
+ if (await isFileAtUri(standardPath)) return standardPath.fsPath;
+ }
}
return executableName;
},
);
-async function lookupInPath(exec: string): Promise<boolean> {
- const paths = process.env["PATH"] ?? "";
-
+async function lookupInPath(exec: string, paths: string): Promise<boolean> {
const candidates = paths.split(path.delimiter).flatMap((dirInPath) => {
- const candidate = path.join(dirInPath, exec);
- return os.type() === "Windows_NT" ? [candidate, `${candidate}.exe`] : [candidate];
+ return executableCandidates(exec).map((candidate) => path.join(dirInPath, candidate));
});
for await (const isFile of candidates.map(isFileAtPath)) {
@@ -199,8 +235,28 @@ async function lookupInPath(exec: string): Promise<boolean> {
return false;
}
+function executableCandidates(executableName: string): string[] {
+ // Keep the extension-side probe aligned with `crates/toolchain::probe_for_binary()`, which
+ // checks both the bare executable name and the platform suffix such as `.exe` on Windows.
+ // That matters for both PATH lookups and `$CARGO_HOME/bin/<tool>` fallbacks.
+ return isWindows ? [executableName, `${executableName}.exe`] : [executableName];
+}
+
+function getEnvVar(env: Env, name: string): string | undefined {
+ if (!isWindows) {
+ return env[name];
+ }
+
+ const foldedName = name.toLowerCase();
+ return Object.entries(env).find(([key]) => key.toLowerCase() === foldedName)?.[1];
+}
+
function getCargoHome(): vscode.Uri | null {
const envVar = process.env["CARGO_HOME"];
+ return getCargoHomeFromPath(envVar);
+}
+
+function getCargoHomeFromPath(envVar: string | undefined): vscode.Uri | null {
if (envVar) return vscode.Uri.file(envVar);
try {
diff --git a/editors/code/src/util.ts b/editors/code/src/util.ts
index 05b475080c..ddeec814b4 100644
--- a/editors/code/src/util.ts
+++ b/editors/code/src/util.ts
@@ -22,6 +22,10 @@ class Log {
log: true,
});
+ show(): void {
+ this.output.show(true);
+ }
+
trace(...messages: [unknown, ...unknown[]]): void {
this.output.trace(this.stringify(messages));
}
@@ -40,7 +44,7 @@ class Log {
error(...messages: [unknown, ...unknown[]]): void {
this.output.error(this.stringify(messages));
- this.output.show(true);
+ this.show();
}
private stringify(messages: unknown[]): string {
diff --git a/editors/code/tests/unit/commands.test.ts b/editors/code/tests/unit/commands.test.ts
new file mode 100644
index 0000000000..900bb2779d
--- /dev/null
+++ b/editors/code/tests/unit/commands.test.ts
@@ -0,0 +1,80 @@
+import * as assert from "node:assert/strict";
+
+import {
+ cargoNewArgs,
+ determineNewProjectOpenAction,
+ validateNewProjectName,
+} from "../../src/new_project";
+import type { Context } from ".";
+
+export async function getTests(ctx: Context) {
+ await ctx.suite("New project command", (suite) => {
+ suite.addTest("rejects empty project name", async () => {
+ assert.equal(validateNewProjectName("", []), "Project name cannot be empty.");
+ assert.equal(validateNewProjectName(" ", []), "Project name cannot be empty.");
+ });
+
+ suite.addTest("rejects dot project names", async () => {
+ assert.equal(validateNewProjectName(".", []), "Project name cannot be '.' or '..'.");
+ assert.equal(validateNewProjectName("..", []), "Project name cannot be '.' or '..'.");
+ });
+
+ suite.addTest("rejects path separators", async () => {
+ assert.equal(
+ validateNewProjectName("foo/bar", []),
+ "Project name cannot contain '/' or '\\' characters.",
+ );
+ assert.equal(
+ validateNewProjectName("foo\\bar", []),
+ "Project name cannot contain '/' or '\\' characters.",
+ );
+ });
+
+ suite.addTest("rejects invalid Cargo package name characters", async () => {
+ assert.equal(
+ validateNewProjectName("foo.bar", []),
+ "Project name can contain only alphanumeric characters, '-' or '_'.",
+ );
+ assert.equal(
+ validateNewProjectName("foo bar", []),
+ "Project name can contain only alphanumeric characters, '-' or '_'.",
+ );
+ assert.equal(
+ validateNewProjectName("foo+bar", []),
+ "Project name can contain only alphanumeric characters, '-' or '_'.",
+ );
+ });
+
+ suite.addTest("rejects existing child folder collisions", async () => {
+ assert.equal(
+ validateNewProjectName("demo", ["demo"]),
+ "A file or folder with this name already exists.",
+ );
+ });
+
+ suite.addTest("accepts a normal project name", async () => {
+ assert.equal(validateNewProjectName("demo-project", []), undefined);
+ });
+
+ suite.addTest("resolves addToWorkspace fallback without workspace", async () => {
+ assert.equal(determineNewProjectOpenAction("addToWorkspace", false), "open");
+ });
+
+ suite.addTest("keeps addToWorkspace when workspace exists", async () => {
+ assert.equal(determineNewProjectOpenAction("addToWorkspace", true), "addToWorkspace");
+ });
+
+ suite.addTest("defaults to ask for unknown values", async () => {
+ assert.equal(determineNewProjectOpenAction(undefined, true), "ask");
+ assert.equal(determineNewProjectOpenAction("ask", true), "ask");
+ });
+
+ suite.addTest("builds binary cargo args", async () => {
+ assert.deepEqual(cargoNewArgs("bin", "demo"), ["new", "--bin", "demo"]);
+ });
+
+ suite.addTest("builds library cargo args", async () => {
+ assert.deepEqual(cargoNewArgs("lib", "demo"), ["new", "--lib", "demo"]);
+ });
+ });
+}
diff --git a/editors/code/tests/unit/launch_config.test.ts b/editors/code/tests/unit/launch_config.test.ts
index eeb8608a42..8da62cd241 100644
--- a/editors/code/tests/unit/launch_config.test.ts
+++ b/editors/code/tests/unit/launch_config.test.ts
@@ -1,5 +1,9 @@
import * as assert from "assert";
-import { Cargo } from "../../src/toolchain";
+import * as os from "os";
+import * as path from "path";
+import { mkdtemp, mkdir, rm, writeFile } from "fs/promises";
+import { Cargo, cargoPath } from "../../src/toolchain";
+import { isWindows, normalizeDriveLetter } from "../../src/util";
import type { Context } from ".";
export async function getTests(ctx: Context) {
@@ -96,4 +100,47 @@ export async function getTests(ctx: Context) {
assert.notDeepStrictEqual(args.filter, undefined);
});
});
+
+ await ctx.suite("Toolchain resolution", (suite) => {
+ suite.addTest("prefers explicit CARGO from provided env", async () => {
+ const explicitCargo = path.join(os.tmpdir(), "custom-cargo");
+ assert.strictEqual(await cargoPath({ CARGO: explicitCargo }), explicitCargo);
+ });
+
+ suite.addTest("resolves cargo from provided PATH", async () => {
+ const tempDir = await mkdtemp(path.join(os.tmpdir(), "ra-cargo-path-"));
+ try {
+ const cargoBinary = path.join(tempDir, isWindows ? "cargo.exe" : "cargo");
+ await writeFile(cargoBinary, "");
+
+ const pathKey = isWindows ? "Path" : "PATH";
+ assert.strictEqual(await cargoPath({ [pathKey]: tempDir }), "cargo");
+ } finally {
+ await rm(tempDir, { recursive: true, force: true });
+ }
+ });
+
+ suite.addTest("resolves cargo from provided CARGO_HOME", async () => {
+ const cargoHome = await mkdtemp(path.join(os.tmpdir(), "ra-cargo-home-"));
+ try {
+ const binDir = path.join(cargoHome, "bin");
+ await mkdir(binDir);
+ const cargoBinary = path.join(binDir, isWindows ? "cargo.exe" : "cargo");
+ await writeFile(cargoBinary, "");
+
+ assert.strictEqual(
+ normalizeComparablePath(await cargoPath({ PATH: "", CARGO_HOME: cargoHome })),
+ normalizeComparablePath(cargoBinary),
+ );
+ } finally {
+ await rm(cargoHome, { recursive: true, force: true });
+ }
+ });
+ });
+}
+
+function normalizeComparablePath(filePath: string): string {
+ // Windows path comparisons should ignore drive-letter casing because `fsPath`
+ // may normalize it differently while still pointing to the same file.
+ return normalizeDriveLetter(filePath, isWindows);
}
diff --git a/editors/code/walkthrough-create-project.md b/editors/code/walkthrough-create-project.md
new file mode 100644
index 0000000000..34c04cdd1d
--- /dev/null
+++ b/editors/code/walkthrough-create-project.md
@@ -0,0 +1,9 @@
+# Create a New Cargo Project
+
+Create a new Cargo project without leaving VS Code.
+
+The command lets you choose the package kind, parent folder, project name, and
+how the new project should be opened afterward.
+
+The project is created with Cargo, so the generated files and defaults match
+the normal `cargo new` experience.