Skip to content

Commit d95d67b

Browse files
kevinwang5658github-actions[bot]
authored andcommitted
feat: Add rust resource (auto-generated from issue #45)
1 parent 4b1cc9c commit d95d67b

4 files changed

Lines changed: 221 additions & 0 deletions

File tree

src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ import { VenvProject } from './resources/python/venv/venv-project.js';
3434
import { Virtualenv } from './resources/python/virtualenv/virtualenv.js';
3535
import { VirtualenvProject } from './resources/python/virtualenv/virtualenv-project.js';
3636
import { RbenvResource } from './resources/ruby/rbenv/rbenv.js';
37+
import { RustResource } from './resources/rust/rust-resource.js';
3738
import { ActionResource } from './resources/scripting/action.js';
3839
import { AliasResource } from './resources/shell/alias/alias-resource.js';
3940
import { AliasesResource } from './resources/shell/aliases/aliases-resource.js';
@@ -113,6 +114,7 @@ runPlugin(Plugin.create(
113114
new SyncthingDeviceResource(),
114115
new SyncthingFolderResource(),
115116
new RbenvResource(),
117+
new RustResource(),
116118
],
117119
{ minSupportedCliVersion: MIN_SUPPORTED_CLI_VERSION }
118120
))
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
import { ParameterSetting, Plan, StatefulParameter, getPty } from '@codifycli/plugin-core';
2+
3+
import { RustConfig } from './rust-resource.js';
4+
5+
function packageName(pkg: string): string {
6+
const atIndex = pkg.lastIndexOf('@');
7+
return atIndex > 0 ? pkg.slice(0, atIndex) : pkg;
8+
}
9+
10+
function packageVersion(pkg: string): string | undefined {
11+
const atIndex = pkg.lastIndexOf('@');
12+
return atIndex > 0 ? pkg.slice(atIndex + 1) : undefined;
13+
}
14+
15+
function parseCargoList(output: string): string[] {
16+
return output
17+
.split('\n')
18+
.filter((line) => /^\S+\s+v[\d.]+.*:$/.test(line.trim()))
19+
.map((line) => {
20+
const match = line.trim().match(/^(\S+)\s+v([\d.]+[^\s:]*):/);
21+
return match ? `${match[1]}@${match[2]}` : null;
22+
})
23+
.filter((x): x is string => x !== null);
24+
}
25+
26+
export class CargoPackagesParameter extends StatefulParameter<RustConfig, string[]> {
27+
getSettings(): ParameterSetting {
28+
return {
29+
type: 'array',
30+
isElementEqual: this.isEqual,
31+
filterInStatelessMode: (desired, current) =>
32+
current.filter((c) => desired.some((d) => packageName(d) === packageName(c))),
33+
};
34+
}
35+
36+
async refresh(): Promise<string[] | null> {
37+
const $ = getPty();
38+
const { data } = await $.spawnSafe('cargo install --list', { interactive: true });
39+
if (!data) return [];
40+
return parseCargoList(data);
41+
}
42+
43+
async add(valuesToAdd: string[]): Promise<void> {
44+
await this.install(valuesToAdd);
45+
}
46+
47+
async modify(newValue: string[], previousValue: string[], plan: Plan<RustConfig>): Promise<void> {
48+
const toInstall = newValue.filter((n) => !previousValue.some((p) => packageName(n) === packageName(p)));
49+
const toUninstall = previousValue.filter((p) => !newValue.some((n) => packageName(n) === packageName(p)));
50+
51+
if (plan.isStateful && toUninstall.length > 0) {
52+
await this.uninstall(toUninstall);
53+
}
54+
await this.install(toInstall);
55+
}
56+
57+
async remove(valuesToRemove: string[]): Promise<void> {
58+
await this.uninstall(valuesToRemove);
59+
}
60+
61+
private async install(packages: string[]): Promise<void> {
62+
if (packages.length === 0) return;
63+
const $ = getPty();
64+
for (const pkg of packages) {
65+
const name = packageName(pkg);
66+
const version = packageVersion(pkg);
67+
const versionFlag = version ? ` --version ${version}` : '';
68+
await $.spawn(`cargo install${versionFlag} ${name}`, { interactive: true });
69+
}
70+
}
71+
72+
private async uninstall(packages: string[]): Promise<void> {
73+
if (packages.length === 0) return;
74+
const $ = getPty();
75+
await $.spawn(`cargo uninstall ${packages.map(packageName).join(' ')}`, { interactive: true });
76+
}
77+
78+
isEqual(desired: string, current: string): boolean {
79+
if (!desired.includes('@')) {
80+
return packageName(desired) === packageName(current);
81+
}
82+
return desired === current;
83+
}
84+
}
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
import {
2+
ExampleConfig,
3+
getPty,
4+
Resource,
5+
ResourceSettings,
6+
SpawnStatus,
7+
Utils,
8+
z,
9+
} from '@codifycli/plugin-core';
10+
import { OS } from '@codifycli/schemas';
11+
12+
import { CargoPackagesParameter } from './cargo-packages-parameter.js';
13+
14+
const schema = z
15+
.object({
16+
cargoPackages: z
17+
.array(z.string())
18+
.describe(
19+
'Global CLI tools to install via cargo install (e.g. ["ripgrep", "bat@0.24.0"]). ' +
20+
'Use the name@version syntax to pin a specific version.'
21+
)
22+
.optional(),
23+
})
24+
.describe('rust resource — install Rust via rustup and manage global cargo packages');
25+
26+
export type RustConfig = z.infer<typeof schema>;
27+
28+
const defaultConfig: Partial<RustConfig> = {
29+
cargoPackages: [],
30+
};
31+
32+
const exampleBasic: ExampleConfig = {
33+
title: 'Install Rust with common CLI tools',
34+
description: 'Install Rust via rustup and add widely-used CLI tools built with Rust.',
35+
configs: [
36+
{
37+
type: 'rust',
38+
cargoPackages: ['ripgrep', 'bat', 'fd-find'],
39+
},
40+
],
41+
};
42+
43+
const examplePinned: ExampleConfig = {
44+
title: 'Install Rust with pinned package versions',
45+
description: 'Install Rust via rustup and pin specific crate versions for reproducible tooling.',
46+
configs: [
47+
{
48+
type: 'rust',
49+
cargoPackages: ['ripgrep@14.1.0', 'bat@0.24.0'],
50+
},
51+
],
52+
};
53+
54+
export class RustResource extends Resource<RustConfig> {
55+
getSettings(): ResourceSettings<RustConfig> {
56+
return {
57+
id: 'rust',
58+
defaultConfig,
59+
exampleConfigs: {
60+
example1: exampleBasic,
61+
example2: examplePinned,
62+
},
63+
operatingSystems: [OS.Darwin, OS.Linux],
64+
schema,
65+
removeStatefulParametersBeforeDestroy: true,
66+
parameterSettings: {
67+
cargoPackages: { type: 'stateful', definition: new CargoPackagesParameter(), order: 1 },
68+
},
69+
dependencies: [...(Utils.isMacOS() ? ['xcode-tools'] : [])],
70+
};
71+
}
72+
73+
async refresh(): Promise<Partial<RustConfig> | null> {
74+
const $ = getPty();
75+
const { status } = await $.spawnSafe('rustup --version');
76+
return status === SpawnStatus.SUCCESS ? {} : null;
77+
}
78+
79+
async create(): Promise<void> {
80+
const $ = getPty();
81+
await $.spawn(
82+
"curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y",
83+
{ interactive: true }
84+
);
85+
}
86+
87+
async destroy(): Promise<void> {
88+
const $ = getPty();
89+
await $.spawn('rustup self uninstall -y', { interactive: true });
90+
}
91+
}

test/rust/rust.test.ts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import { describe, it, expect } from 'vitest';
2+
import { PluginTester, testSpawn } from '@codifycli/plugin-test';
3+
import * as path from 'node:path';
4+
import { SpawnStatus } from '@codifycli/plugin-core';
5+
6+
const pluginPath = path.resolve('./src/index.ts');
7+
8+
describe('Rust tests', async () => {
9+
it('Can install and uninstall Rust via rustup', { timeout: 600000 }, async () => {
10+
await PluginTester.fullTest(pluginPath, [{ type: 'rust' }], {
11+
validateApply: async () => {
12+
expect(await testSpawn('rustup --version')).toMatchObject({ status: SpawnStatus.SUCCESS });
13+
expect(await testSpawn('rustc --version')).toMatchObject({ status: SpawnStatus.SUCCESS });
14+
expect(await testSpawn('cargo --version')).toMatchObject({ status: SpawnStatus.SUCCESS });
15+
},
16+
validateDestroy: async () => {
17+
expect(await testSpawn('rustup --version')).toMatchObject({ status: SpawnStatus.ERROR });
18+
},
19+
});
20+
});
21+
22+
it('Can install Rust with cargo packages', { timeout: 900000 }, async () => {
23+
await PluginTester.fullTest(
24+
pluginPath,
25+
[{ type: 'rust', cargoPackages: ['ripgrep'] }],
26+
{
27+
validateApply: async () => {
28+
expect(await testSpawn('rustup --version')).toMatchObject({ status: SpawnStatus.SUCCESS });
29+
expect(await testSpawn('rg --version')).toMatchObject({ status: SpawnStatus.SUCCESS });
30+
},
31+
testModify: {
32+
modifiedConfigs: [{ type: 'rust', cargoPackages: ['ripgrep', 'fd-find'] }],
33+
validateModify: async () => {
34+
expect(await testSpawn('rg --version')).toMatchObject({ status: SpawnStatus.SUCCESS });
35+
expect(await testSpawn('fd --version')).toMatchObject({ status: SpawnStatus.SUCCESS });
36+
},
37+
},
38+
validateDestroy: async () => {
39+
expect(await testSpawn('rustup --version')).toMatchObject({ status: SpawnStatus.ERROR });
40+
},
41+
}
42+
);
43+
});
44+
});

0 commit comments

Comments
 (0)