From d95d67b7f6726e233842d8ccd2a07d01998d2c41 Mon Sep 17 00:00:00 2001 From: kevinwang5658 <20214115+kevinwang5658@users.noreply.github.com> Date: Sat, 16 May 2026 20:24:53 +0000 Subject: [PATCH] feat: Add rust resource (auto-generated from issue #45) --- src/index.ts | 2 + .../rust/cargo-packages-parameter.ts | 84 +++++++++++++++++ src/resources/rust/rust-resource.ts | 91 +++++++++++++++++++ test/rust/rust.test.ts | 44 +++++++++ 4 files changed, 221 insertions(+) create mode 100644 src/resources/rust/cargo-packages-parameter.ts create mode 100644 src/resources/rust/rust-resource.ts create mode 100644 test/rust/rust.test.ts diff --git a/src/index.ts b/src/index.ts index 076647c..49d962c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -34,6 +34,7 @@ import { VenvProject } from './resources/python/venv/venv-project.js'; import { Virtualenv } from './resources/python/virtualenv/virtualenv.js'; import { VirtualenvProject } from './resources/python/virtualenv/virtualenv-project.js'; import { RbenvResource } from './resources/ruby/rbenv/rbenv.js'; +import { RustResource } from './resources/rust/rust-resource.js'; import { ActionResource } from './resources/scripting/action.js'; import { AliasResource } from './resources/shell/alias/alias-resource.js'; import { AliasesResource } from './resources/shell/aliases/aliases-resource.js'; @@ -113,6 +114,7 @@ runPlugin(Plugin.create( new SyncthingDeviceResource(), new SyncthingFolderResource(), new RbenvResource(), + new RustResource(), ], { minSupportedCliVersion: MIN_SUPPORTED_CLI_VERSION } )) diff --git a/src/resources/rust/cargo-packages-parameter.ts b/src/resources/rust/cargo-packages-parameter.ts new file mode 100644 index 0000000..f0ba642 --- /dev/null +++ b/src/resources/rust/cargo-packages-parameter.ts @@ -0,0 +1,84 @@ +import { ParameterSetting, Plan, StatefulParameter, getPty } from '@codifycli/plugin-core'; + +import { RustConfig } from './rust-resource.js'; + +function packageName(pkg: string): string { + const atIndex = pkg.lastIndexOf('@'); + return atIndex > 0 ? pkg.slice(0, atIndex) : pkg; +} + +function packageVersion(pkg: string): string | undefined { + const atIndex = pkg.lastIndexOf('@'); + return atIndex > 0 ? pkg.slice(atIndex + 1) : undefined; +} + +function parseCargoList(output: string): string[] { + return output + .split('\n') + .filter((line) => /^\S+\s+v[\d.]+.*:$/.test(line.trim())) + .map((line) => { + const match = line.trim().match(/^(\S+)\s+v([\d.]+[^\s:]*):/); + return match ? `${match[1]}@${match[2]}` : null; + }) + .filter((x): x is string => x !== null); +} + +export class CargoPackagesParameter extends StatefulParameter { + getSettings(): ParameterSetting { + return { + type: 'array', + isElementEqual: this.isEqual, + filterInStatelessMode: (desired, current) => + current.filter((c) => desired.some((d) => packageName(d) === packageName(c))), + }; + } + + async refresh(): Promise { + const $ = getPty(); + const { data } = await $.spawnSafe('cargo install --list', { interactive: true }); + if (!data) return []; + return parseCargoList(data); + } + + async add(valuesToAdd: string[]): Promise { + await this.install(valuesToAdd); + } + + async modify(newValue: string[], previousValue: string[], plan: Plan): Promise { + const toInstall = newValue.filter((n) => !previousValue.some((p) => packageName(n) === packageName(p))); + const toUninstall = previousValue.filter((p) => !newValue.some((n) => packageName(n) === packageName(p))); + + if (plan.isStateful && toUninstall.length > 0) { + await this.uninstall(toUninstall); + } + await this.install(toInstall); + } + + async remove(valuesToRemove: string[]): Promise { + await this.uninstall(valuesToRemove); + } + + private async install(packages: string[]): Promise { + if (packages.length === 0) return; + const $ = getPty(); + for (const pkg of packages) { + const name = packageName(pkg); + const version = packageVersion(pkg); + const versionFlag = version ? ` --version ${version}` : ''; + await $.spawn(`cargo install${versionFlag} ${name}`, { interactive: true }); + } + } + + private async uninstall(packages: string[]): Promise { + if (packages.length === 0) return; + const $ = getPty(); + await $.spawn(`cargo uninstall ${packages.map(packageName).join(' ')}`, { interactive: true }); + } + + isEqual(desired: string, current: string): boolean { + if (!desired.includes('@')) { + return packageName(desired) === packageName(current); + } + return desired === current; + } +} diff --git a/src/resources/rust/rust-resource.ts b/src/resources/rust/rust-resource.ts new file mode 100644 index 0000000..0354838 --- /dev/null +++ b/src/resources/rust/rust-resource.ts @@ -0,0 +1,91 @@ +import { + ExampleConfig, + getPty, + Resource, + ResourceSettings, + SpawnStatus, + Utils, + z, +} from '@codifycli/plugin-core'; +import { OS } from '@codifycli/schemas'; + +import { CargoPackagesParameter } from './cargo-packages-parameter.js'; + +const schema = z + .object({ + cargoPackages: z + .array(z.string()) + .describe( + 'Global CLI tools to install via cargo install (e.g. ["ripgrep", "bat@0.24.0"]). ' + + 'Use the name@version syntax to pin a specific version.' + ) + .optional(), + }) + .describe('rust resource — install Rust via rustup and manage global cargo packages'); + +export type RustConfig = z.infer; + +const defaultConfig: Partial = { + cargoPackages: [], +}; + +const exampleBasic: ExampleConfig = { + title: 'Install Rust with common CLI tools', + description: 'Install Rust via rustup and add widely-used CLI tools built with Rust.', + configs: [ + { + type: 'rust', + cargoPackages: ['ripgrep', 'bat', 'fd-find'], + }, + ], +}; + +const examplePinned: ExampleConfig = { + title: 'Install Rust with pinned package versions', + description: 'Install Rust via rustup and pin specific crate versions for reproducible tooling.', + configs: [ + { + type: 'rust', + cargoPackages: ['ripgrep@14.1.0', 'bat@0.24.0'], + }, + ], +}; + +export class RustResource extends Resource { + getSettings(): ResourceSettings { + return { + id: 'rust', + defaultConfig, + exampleConfigs: { + example1: exampleBasic, + example2: examplePinned, + }, + operatingSystems: [OS.Darwin, OS.Linux], + schema, + removeStatefulParametersBeforeDestroy: true, + parameterSettings: { + cargoPackages: { type: 'stateful', definition: new CargoPackagesParameter(), order: 1 }, + }, + dependencies: [...(Utils.isMacOS() ? ['xcode-tools'] : [])], + }; + } + + async refresh(): Promise | null> { + const $ = getPty(); + const { status } = await $.spawnSafe('rustup --version'); + return status === SpawnStatus.SUCCESS ? {} : null; + } + + async create(): Promise { + const $ = getPty(); + await $.spawn( + "curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y", + { interactive: true } + ); + } + + async destroy(): Promise { + const $ = getPty(); + await $.spawn('rustup self uninstall -y', { interactive: true }); + } +} diff --git a/test/rust/rust.test.ts b/test/rust/rust.test.ts new file mode 100644 index 0000000..2c467ef --- /dev/null +++ b/test/rust/rust.test.ts @@ -0,0 +1,44 @@ +import { describe, it, expect } from 'vitest'; +import { PluginTester, testSpawn } from '@codifycli/plugin-test'; +import * as path from 'node:path'; +import { SpawnStatus } from '@codifycli/plugin-core'; + +const pluginPath = path.resolve('./src/index.ts'); + +describe('Rust tests', async () => { + it('Can install and uninstall Rust via rustup', { timeout: 600000 }, async () => { + await PluginTester.fullTest(pluginPath, [{ type: 'rust' }], { + validateApply: async () => { + expect(await testSpawn('rustup --version')).toMatchObject({ status: SpawnStatus.SUCCESS }); + expect(await testSpawn('rustc --version')).toMatchObject({ status: SpawnStatus.SUCCESS }); + expect(await testSpawn('cargo --version')).toMatchObject({ status: SpawnStatus.SUCCESS }); + }, + validateDestroy: async () => { + expect(await testSpawn('rustup --version')).toMatchObject({ status: SpawnStatus.ERROR }); + }, + }); + }); + + it('Can install Rust with cargo packages', { timeout: 900000 }, async () => { + await PluginTester.fullTest( + pluginPath, + [{ type: 'rust', cargoPackages: ['ripgrep'] }], + { + validateApply: async () => { + expect(await testSpawn('rustup --version')).toMatchObject({ status: SpawnStatus.SUCCESS }); + expect(await testSpawn('rg --version')).toMatchObject({ status: SpawnStatus.SUCCESS }); + }, + testModify: { + modifiedConfigs: [{ type: 'rust', cargoPackages: ['ripgrep', 'fd-find'] }], + validateModify: async () => { + expect(await testSpawn('rg --version')).toMatchObject({ status: SpawnStatus.SUCCESS }); + expect(await testSpawn('fd --version')).toMatchObject({ status: SpawnStatus.SUCCESS }); + }, + }, + validateDestroy: async () => { + expect(await testSpawn('rustup --version')).toMatchObject({ status: SpawnStatus.ERROR }); + }, + } + ); + }); +});