Skip to content

Commit 6cdf137

Browse files
feat: Add rust resource (#46)
* feat: Add rust resource (auto-generated from issue #45) * feat: add documentation * fix: fixed tests * feat: add apply note * chore: bump version --------- Co-authored-by: kevinwang5658 <20214115+kevinwang5658@users.noreply.github.com> Co-authored-by: kevinwang <kevinwang5658@gmail.com>
1 parent ba85259 commit 6cdf137

8 files changed

Lines changed: 284 additions & 5 deletions

File tree

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
---
2+
title: rust
3+
description: A reference page for the rust resource
4+
---
5+
6+
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.
7+
8+
## Parameters:
9+
10+
- **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).
11+
12+
## Example usage:
13+
14+
### Install Rust with common CLI tools
15+
16+
```json title="codify.jsonc"
17+
[
18+
{
19+
"type": "rust",
20+
"cargoPackages": ["ripgrep", "bat", "fd-find"]
21+
}
22+
]
23+
```
24+
25+
### Install Rust with pinned package versions
26+
27+
```json title="codify.jsonc"
28+
[
29+
{
30+
"type": "rust",
31+
"cargoPackages": ["ripgrep@14.1.0", "bat@0.24.0"]
32+
}
33+
]
34+
```
35+
36+
### Install Rust without any additional packages
37+
38+
```json title="codify.jsonc"
39+
[
40+
{
41+
"type": "rust"
42+
}
43+
]
44+
```
45+
46+
## Notes:
47+
48+
- 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.
49+
- 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`.
50+
- To uninstall Rust and the toolchain, remove the rust resource from your config and run `codify apply`. This runs `rustup self uninstall`.
51+
- Package versions in `cargoPackages` must match a published version on [crates.io](https://crates.io). To find available versions, run `cargo search <name>` or visit the crate's page on crates.io.
52+
- Omitting `@version` for a package always tracks the latest release. Adding `@version` pins the exact version and will reinstall if the pin changes.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "default",
3-
"version": "1.9.1",
3+
"version": "1.10.0",
44
"description": "Default plugin for Codify - provides 50+ declarative resources for managing development tools and system configuration across macOS and Linux",
55
"main": "dist/index.js",
66
"scripts": {

src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ import { VenvProject } from './resources/python/venv/venv-project.js';
4040
import { Virtualenv } from './resources/python/virtualenv/virtualenv.js';
4141
import { VirtualenvProject } from './resources/python/virtualenv/virtualenv-project.js';
4242
import { RbenvResource } from './resources/ruby/rbenv/rbenv.js';
43+
import { RustResource } from './resources/rust/rust-resource.js';
4344
import { ActionResource } from './resources/scripting/action.js';
4445
import { AliasResource } from './resources/shell/alias/alias-resource.js';
4546
import { AliasesResource } from './resources/shell/aliases/aliases-resource.js';
@@ -145,6 +146,7 @@ runPlugin(Plugin.create(
145146
new SyncthingFolderResource(),
146147
new RbenvResource(),
147148
new OpenClawResource(),
149+
new RustResource(),
148150
],
149151
{ minSupportedCliVersion: MIN_SUPPORTED_CLI_VERSION }
150152
))
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: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
import {
2+
ApplyNotes,
3+
CodifyCliSender,
4+
ExampleConfig,
5+
getPty,
6+
Resource,
7+
ResourceSettings,
8+
SpawnStatus,
9+
Utils,
10+
z,
11+
} from '@codifycli/plugin-core';
12+
import { OS } from '@codifycli/schemas';
13+
14+
import { CargoPackagesParameter } from './cargo-packages-parameter.js';
15+
16+
const schema = z
17+
.object({
18+
cargoPackages: z
19+
.array(z.string())
20+
.describe(
21+
'Global CLI tools to install via cargo install (e.g. ["ripgrep", "bat@0.24.0"]). ' +
22+
'Use the name@version syntax to pin a specific version.'
23+
)
24+
.optional(),
25+
})
26+
.describe('rust resource — install Rust via rustup and manage global cargo packages');
27+
28+
export type RustConfig = z.infer<typeof schema>;
29+
30+
const defaultConfig: Partial<RustConfig> = {
31+
cargoPackages: [],
32+
};
33+
34+
const exampleBasic: ExampleConfig = {
35+
title: 'Install Rust with common CLI tools',
36+
description: 'Install Rust via rustup and add widely-used CLI tools built with Rust.',
37+
configs: [
38+
{
39+
type: 'rust',
40+
cargoPackages: ['ripgrep', 'bat', 'fd-find'],
41+
},
42+
],
43+
};
44+
45+
const examplePinned: ExampleConfig = {
46+
title: 'Install Rust with pinned package versions',
47+
description: 'Install Rust via rustup and pin specific crate versions for reproducible tooling.',
48+
configs: [
49+
{
50+
type: 'rust',
51+
cargoPackages: ['ripgrep@14.1.0', 'bat@0.24.0'],
52+
},
53+
],
54+
};
55+
56+
export class RustResource extends Resource<RustConfig> {
57+
getSettings(): ResourceSettings<RustConfig> {
58+
return {
59+
id: 'rust',
60+
defaultConfig,
61+
exampleConfigs: {
62+
example1: exampleBasic,
63+
example2: examplePinned,
64+
},
65+
operatingSystems: [OS.Darwin, OS.Linux],
66+
schema,
67+
removeStatefulParametersBeforeDestroy: true,
68+
parameterSettings: {
69+
cargoPackages: { type: 'stateful', definition: new CargoPackagesParameter(), order: 1 },
70+
},
71+
dependencies: [...(Utils.isMacOS() ? ['xcode-tools'] : [])],
72+
};
73+
}
74+
75+
async refresh(): Promise<Partial<RustConfig> | null> {
76+
const $ = getPty();
77+
const { status } = await $.spawnSafe('rustup --version');
78+
return status === SpawnStatus.SUCCESS ? {} : null;
79+
}
80+
81+
async create(): Promise<void> {
82+
const $ = getPty();
83+
await $.spawn(
84+
"curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y",
85+
{ interactive: true }
86+
);
87+
88+
CodifyCliSender.sendApplyNote(ApplyNotes.NEW_SHELL_REQUIRED);
89+
}
90+
91+
async destroy(): Promise<void> {
92+
const $ = getPty();
93+
await $.spawn('rustup self uninstall -y', { interactive: true });
94+
95+
CodifyCliSender.sendApplyNote(ApplyNotes.NEW_SHELL_REQUIRED);
96+
}
97+
}

src/resources/scripting/action.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -75,8 +75,8 @@ export class ActionResource extends Resource<ActionConfig> {
7575
...(condition ? { condition } : undefined),
7676
...(action ? { action } : undefined),
7777
...(cwd ? { cwd } : undefined),
78-
...(requiresRoot ? { requiresRoot } : undefined),
79-
...(requiresStdin ? { requiresStdin } : undefined),
78+
...(requiresRoot != null ? { requiresRoot } : undefined),
79+
...(requiresStdin != null ? { requiresStdin } : undefined),
8080
};
8181
}
8282

test/openclaw/openclaw.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,12 +25,12 @@ describe('openclaw resource integration tests', async () => {
2525

2626
it('Can manage settings', { timeout: 300_000 }, async () => {
2727
const initialSettings = {
28-
gateway: { port: 18789, bind: 'loopback' },
28+
gateway: { mode: 'local', port: 18789, bind: 'loopback' },
2929
logging: { level: 'debug' },
3030
};
3131

3232
const modifiedSettings = {
33-
gateway: { port: 18790, bind: 'loopback' },
33+
gateway: { mode: 'local', port: 18790, bind: 'loopback' },
3434
logging: { level: 'debug' },
3535
};
3636

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)