diff --git a/docs/resources/(resources)/rust.mdx b/docs/resources/(resources)/rust.mdx new file mode 100644 index 0000000..ea07a36 --- /dev/null +++ b/docs/resources/(resources)/rust.mdx @@ -0,0 +1,52 @@ +--- +title: rust +description: A reference page for the rust resource +--- + +The rust resource installs [Rust](https://www.rust-lang.org/) via [rustup](https://rustup.rs/), the official Rust toolchain installer. It also manages global CLI tools installed through `cargo install`. Supported on macOS and Linux. + +## Parameters: + +- **cargoPackages**: *(array[string])* Global CLI tools to install via `cargo install`. Use the `name@version` syntax to pin a specific version (e.g. `"ripgrep@14.1.0"`). Omitting the version installs the latest release. Codify adds packages that are missing and removes packages that are no longer listed (in stateful mode). + +## Example usage: + +### Install Rust with common CLI tools + +```json title="codify.jsonc" +[ + { + "type": "rust", + "cargoPackages": ["ripgrep", "bat", "fd-find"] + } +] +``` + +### Install Rust with pinned package versions + +```json title="codify.jsonc" +[ + { + "type": "rust", + "cargoPackages": ["ripgrep@14.1.0", "bat@0.24.0"] + } +] +``` + +### Install Rust without any additional packages + +```json title="codify.jsonc" +[ + { + "type": "rust" + } +] +``` + +## Notes: + +- On macOS, Xcode Command Line Tools must be installed before applying the rust resource. The [xcode-tools](/docs/resources/xcode-tools) resource can install them, and is added as a dependency automatically. +- Rust is installed via the official `rustup` script (`https://sh.rustup.rs`). This adds `~/.cargo/bin` to your `PATH` in your shell RC file. Open a new terminal after applying to pick up the updated `PATH`. +- To uninstall Rust and the toolchain, remove the rust resource from your config and run `codify apply`. This runs `rustup self uninstall`. +- Package versions in `cargoPackages` must match a published version on [crates.io](https://crates.io). To find available versions, run `cargo search ` or visit the crate's page on crates.io. +- Omitting `@version` for a package always tracks the latest release. Adding `@version` pins the exact version and will reinstall if the pin changes. \ No newline at end of file diff --git a/package.json b/package.json index deb1604..107fa6c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "default", - "version": "1.6.0", + "version": "1.7.0-beta.1", "description": "Default plugin for Codify - provides 50+ declarative resources for managing development tools and system configuration across macOS and Linux", "main": "dist/index.js", "scripts": { diff --git a/src/index.ts b/src/index.ts index b0145bc..6d2915c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -39,6 +39,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'; @@ -127,6 +128,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 }); + }, + } + ); + }); +});