Skip to content
Open
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
2 changes: 2 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -113,6 +114,7 @@ runPlugin(Plugin.create(
new SyncthingDeviceResource(),
new SyncthingFolderResource(),
new RbenvResource(),
new RustResource(),
],
{ minSupportedCliVersion: MIN_SUPPORTED_CLI_VERSION }
))
84 changes: 84 additions & 0 deletions src/resources/rust/cargo-packages-parameter.ts
Original file line number Diff line number Diff line change
@@ -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<RustConfig, string[]> {
getSettings(): ParameterSetting {
return {
type: 'array',
isElementEqual: this.isEqual,
filterInStatelessMode: (desired, current) =>
current.filter((c) => desired.some((d) => packageName(d) === packageName(c))),
};
}

async refresh(): Promise<string[] | null> {
const $ = getPty();
const { data } = await $.spawnSafe('cargo install --list', { interactive: true });
if (!data) return [];
return parseCargoList(data);
}

async add(valuesToAdd: string[]): Promise<void> {
await this.install(valuesToAdd);
}

async modify(newValue: string[], previousValue: string[], plan: Plan<RustConfig>): Promise<void> {
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<void> {
await this.uninstall(valuesToRemove);
}

private async install(packages: string[]): Promise<void> {
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<void> {
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;
}
}
91 changes: 91 additions & 0 deletions src/resources/rust/rust-resource.ts
Original file line number Diff line number Diff line change
@@ -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<typeof schema>;

const defaultConfig: Partial<RustConfig> = {
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<RustConfig> {
getSettings(): ResourceSettings<RustConfig> {
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<Partial<RustConfig> | null> {
const $ = getPty();
const { status } = await $.spawnSafe('rustup --version');
return status === SpawnStatus.SUCCESS ? {} : null;
}

async create(): Promise<void> {
const $ = getPty();
await $.spawn(
"curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y",
{ interactive: true }
);
}

async destroy(): Promise<void> {
const $ = getPty();
await $.spawn('rustup self uninstall -y', { interactive: true });
}
}
44 changes: 44 additions & 0 deletions test/rust/rust.test.ts
Original file line number Diff line number Diff line change
@@ -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 });
},
}
);
});
});