Skip to content

Commit 700cbba

Browse files
feat: add Rust framework support
Adds Rust as a supported framework, stacked on the Elixir framework PR: - Integration.rust enum entry in the frameworks block, before the language fallbacks - RUST_AGENT_CONFIG in src/frameworks/rust/ — claims a Cargo.toml with a [package] section (workspace-root-only manifests fall through; the agentic monorepo scan finds nested crates), gathers async-runtime presence (tokio/async-std/smol) to steer the agent between the default async client and the blocking one, installs via cargo add posthog-rs - cargoPackageManager helper + FRAMEWORK_REGISTRY entry - bash-fence allowlist for cargo: add/remove/build/check/fmt/clippy/metadata/tree/fetch; run/test stay denied (arbitrary code execution) and install/publish stay denied (outward-facing); the pinned cargo run assertion's reason changes from "no rust framework" to "arbitrary code execution" - Pins framework: rust on a new integration-rust entry in the variant-resolution contract test, matching context-mill PR #269 which must release before this merges - Marks Cargo.toml as a real framework target in the agentic manifest comment Generated-By: PostHog Code Task-Id: 9c949855-7611-48f2-b8b5-7aa4112543e5
1 parent 5bc160d commit 700cbba

10 files changed

Lines changed: 197 additions & 5 deletions

File tree

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
/* Rust wizard using posthog-agent with PostHog MCP */
2+
import * as fs from 'node:fs';
3+
import * as path from 'node:path';
4+
import type { WizardRunOptions } from '@utils/types';
5+
import type { FrameworkConfig } from '@lib/framework-config';
6+
import { cargoPackageManager } from '@lib/detection/package-manager';
7+
import { Integration } from '@lib/constants';
8+
9+
type RustContext = {
10+
asyncRuntime?: boolean;
11+
};
12+
13+
function readCargoToml(installDir: string): string | undefined {
14+
const cargoTomlPath = path.join(installDir, 'Cargo.toml');
15+
if (!fs.existsSync(cargoTomlPath)) {
16+
return undefined;
17+
}
18+
return fs.readFileSync(cargoTomlPath, 'utf-8');
19+
}
20+
21+
/** posthog-rs defaults to its async client; projects without an async runtime need the blocking one. */
22+
function hasAsyncRuntime(installDir: string): boolean {
23+
const cargoToml = readCargoToml(installDir);
24+
return !!cargoToml && /^\s*(tokio|async-std|smol)\s*[=.]/m.test(cargoToml);
25+
}
26+
27+
export const RUST_AGENT_CONFIG: FrameworkConfig<RustContext> = {
28+
metadata: {
29+
name: 'Rust',
30+
integration: Integration.rust,
31+
docsUrl: 'https://posthog.com/docs/libraries/rust',
32+
gatherContext: (options: WizardRunOptions) => {
33+
const asyncRuntime = hasAsyncRuntime(options.installDir);
34+
return Promise.resolve({ asyncRuntime });
35+
},
36+
},
37+
38+
detection: {
39+
packageName: 'posthog-rs',
40+
packageDisplayName: 'Rust',
41+
usesPackageJson: false,
42+
getVersion: () => undefined,
43+
// A Cargo.toml with a [package] section marks a crate root. A
44+
// workspace-root-only manifest ([workspace], no [package]) falls
45+
// through — the agentic monorepo scan finds the nested crates.
46+
detect: (options) => {
47+
const cargoToml = readCargoToml(options.installDir);
48+
return Promise.resolve(!!cargoToml && /^\s*\[package\]/m.test(cargoToml));
49+
},
50+
detectPackageManager: cargoPackageManager,
51+
},
52+
53+
environment: {
54+
uploadToHosting: false,
55+
getEnvVars: (apiKey: string, host: string) => ({
56+
POSTHOG_API_KEY: apiKey,
57+
POSTHOG_HOST: host,
58+
}),
59+
},
60+
61+
analytics: {
62+
getTags: (context) => ({
63+
asyncRuntime: context.asyncRuntime ? 'true' : 'false',
64+
}),
65+
},
66+
67+
prompts: {
68+
projectTypeDetection:
69+
'This is a Rust project. Look for Cargo.toml with a [package] section, src/main.rs or src/lib.rs, and Cargo.lock to confirm.',
70+
packageInstallation:
71+
'Install the PostHog Rust SDK with `cargo add posthog-rs`. Do not manually edit Cargo.toml; cargo updates it automatically.',
72+
getAdditionalContextLines: (context) => {
73+
const lines = [
74+
`Framework docs ID: rust (use posthog://docs/frameworks/rust for documentation)`,
75+
'capture() hands events to a background worker — the client must flush() or shutdown() before the process exits or buffered events are lost.',
76+
];
77+
if (context.asyncRuntime) {
78+
lines.push(
79+
'An async runtime is present — use the default async posthog-rs client.',
80+
);
81+
} else {
82+
lines.push(
83+
'No async runtime detected — install with `cargo add posthog-rs --no-default-features` and use the blocking client.',
84+
);
85+
}
86+
return lines;
87+
},
88+
},
89+
90+
ui: {
91+
successMessage: 'PostHog integration complete',
92+
estimatedDurationMinutes: 5,
93+
getOutroChanges: () => [
94+
'Analyzed your Rust project structure',
95+
'Installed the posthog-rs crate via cargo add',
96+
'Initialized a shared PostHog client configured from environment variables',
97+
'Instrumented meaningful events with client.capture',
98+
],
99+
getOutroNextSteps: () => [
100+
'Run your Rust application and trigger the instrumented code paths',
101+
'Visit your PostHog dashboard to see incoming events',
102+
'Use Event::new(event_name, distinct_id) and client.capture to track custom events',
103+
'Keep flush()/shutdown() in your exit path so queued events are sent',
104+
],
105+
},
106+
};

src/lib/__tests__/wizard-can-use-tool.test.ts

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -172,6 +172,21 @@ describe('bash fence — allows real toolchain commands (from skills + field log
172172
expect(allow('mix do deps.get, run evil.exs')).toBe('deny');
173173
});
174174

175+
it('rust ecosystem', () => {
176+
expect(allow('cargo add posthog-rs')).toBe('allow');
177+
expect(allow('cargo add posthog-rs --no-default-features')).toBe('allow');
178+
expect(allow('cargo build')).toBe('allow');
179+
expect(allow('cargo check --all-targets')).toBe('allow');
180+
expect(allow('cargo fmt')).toBe('allow');
181+
expect(allow('cargo clippy')).toBe('allow');
182+
expect(allow('cargo metadata --format-version 1')).toBe('allow');
183+
// run/test execute project code; install/publish are outward-facing.
184+
expect(allow('cargo run')).toBe('deny');
185+
expect(allow('cargo test')).toBe('deny');
186+
expect(allow('cargo install evil-tool')).toBe('deny');
187+
expect(allow('cargo publish')).toBe('deny');
188+
});
189+
175190
it('android/jvm ecosystem', () => {
176191
expect(allow('./gradlew assembleDebug')).toBe('allow');
177192
expect(allow('./gradlew :app:assembleDebug')).toBe('allow');
@@ -226,7 +241,7 @@ describe('bash fence — attack corpus (one test per bypass vector)', () => {
226241
expect(allow('xcodebuild test-without-building')).toBe('deny');
227242
expect(allow('bundle exec rspec')).toBe('deny');
228243
expect(allow('composer run-script evil')).toBe('deny');
229-
expect(allow('cargo run')).toBe('deny'); // no rust framework -> whole binary denied
244+
expect(allow('cargo run')).toBe('deny'); // arbitrary code execution
230245
expect(allow('go run main.go')).toBe('deny'); // arbitrary code execution
231246
});
232247

src/lib/agent/bash-fence.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,18 @@ const SIMPLE_MANAGERS: Record<string, readonly string[]> = {
9494
'format',
9595
'hex.info',
9696
],
97+
// cargo run/test execute project code; install/publish are outward-facing.
98+
cargo: [
99+
'add',
100+
'remove',
101+
'build',
102+
'check',
103+
'fmt',
104+
'clippy',
105+
'metadata',
106+
'tree',
107+
'fetch',
108+
],
97109
};
98110

99111
// go's verb list is closed: dependency + verify commands only. run/test/generate
@@ -120,7 +132,8 @@ const ALLOWED_TOOLS_SUMMARY =
120132
'xcodebuild (build/clean/archive actions), gradle/gradlew (build|clean|dependencies|assemble*/compile*/bundle*/lint* tasks), ' +
121133
'mvn (install|compile|package|verify|dependency:tree), ' +
122134
'go (get|build|vet|fmt|version|list, mod tidy/download/verify/graph/why), ' +
123-
'mix (deps.get|deps.update|deps.tree|compile|format|hex.info).';
135+
'mix (deps.get|deps.update|deps.tree|compile|format|hex.info), ' +
136+
'cargo (add|remove|build|check|fmt|clippy|metadata|tree|fetch).';
124137

125138
function deny(analyticsReason: string, message: string): BashFenceDecision {
126139
return { allowed: false, message, analyticsReason };

src/lib/agent/runner/sequence/orchestrator/__tests__/variant-resolution.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ const INTEGRATION_ENTRIES = [
5050
{ id: 'integration-ruby', framework: 'ruby' },
5151
{ id: 'integration-elixir', framework: 'elixir' },
5252
{ id: 'integration-go', framework: 'go' },
53+
{ id: 'integration-rust', framework: 'rust' },
5354
{ id: 'integration-swift', framework: 'swift' },
5455
{ id: 'integration-kmp', framework: 'kmp' },
5556
{ id: 'integration-flutter' },

src/lib/constants.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,7 @@ export enum Integration {
102102
rails = 'rails',
103103
go = 'go',
104104
elixir = 'elixir',
105+
rust = 'rust',
105106

106107
// Language fallbacks. Keep javascriptNode last: it matches any package.json.
107108
python = 'python',

src/lib/detection/__tests__/framework.test.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { ANDROID_AGENT_CONFIG } from '../../../frameworks/android/android-wizard
77
import { KMP_AGENT_CONFIG } from '../../../frameworks/kmp/kmp-wizard-agent';
88
import { GO_AGENT_CONFIG } from '../../../frameworks/go/go-wizard-agent';
99
import { ELIXIR_AGENT_CONFIG } from '../../../frameworks/elixir/elixir-wizard-agent';
10+
import { RUST_AGENT_CONFIG } from '../../../frameworks/rust/rust-wizard-agent';
1011

1112
/** A throwaway project dir seeded with the given files. */
1213
function makeProject(files: Record<string, string>): string {
@@ -171,6 +172,16 @@ describe('detectFramework (end-to-end over real project dirs)', () => {
171172
);
172173
});
173174

175+
test('a Rust crate resolves to rust', async () => {
176+
const opts = project({
177+
'Cargo.toml': '[package]\nname = "my-app"\nedition = "2021"\n',
178+
'src/main.rs': 'fn main() {}',
179+
});
180+
await expect(detectFramework(opts.installDir)).resolves.toBe(
181+
Integration.rust,
182+
);
183+
});
184+
174185
test('a Flutter project is not claimed by anything', async () => {
175186
const opts = project({
176187
'pubspec.yaml': 'name: my_flutter_app\nenvironment:\n sdk: ^3.0.0\n',
@@ -244,6 +255,29 @@ describe('elixir detect', () => {
244255
});
245256
});
246257

258+
describe('rust detect', () => {
259+
const detect = RUST_AGENT_CONFIG.detection.detect;
260+
261+
test('claims a crate with a [package] section', async () => {
262+
const opts = project({
263+
'Cargo.toml': '[package]\nname = "my-app"\nedition = "2021"\n',
264+
});
265+
await expect(detect(opts)).resolves.toBe(true);
266+
});
267+
268+
test('does not claim a workspace-root-only manifest', async () => {
269+
const opts = project({
270+
'Cargo.toml': '[workspace]\nmembers = ["crates/*"]\n',
271+
});
272+
await expect(detect(opts)).resolves.toBe(false);
273+
});
274+
275+
test('does not claim a project without a Cargo.toml', async () => {
276+
const opts = project({ 'src/main.rs': 'fn main() {}' });
277+
await expect(detect(opts)).resolves.toBe(false);
278+
});
279+
});
280+
247281
describe('kmp detect', () => {
248282
const detect = KMP_AGENT_CONFIG.detection.detect;
249283

src/lib/detection/__tests__/package-manager.test.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {
99
gradlePackageManager,
1010
goModulesPackageManager,
1111
mixPackageManager,
12+
cargoPackageManager,
1213
} from '@lib/detection/package-manager';
1314

1415
vi.mock('../../../utils/debug');
@@ -192,6 +193,7 @@ describe('static package manager helpers', () => {
192193
{ fn: gradlePackageManager, name: 'gradle' },
193194
{ fn: goModulesPackageManager, name: 'go' },
194195
{ fn: mixPackageManager, name: 'mix' },
196+
{ fn: cargoPackageManager, name: 'cargo' },
195197
])('$name returns valid PackageManagerInfo', async ({ fn }) => {
196198
const result = await fn();
197199
expect(result.detected).toHaveLength(1);

src/lib/detection/agentic.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -74,12 +74,12 @@ export const PROJECT_MANIFESTS: readonly string[] = [
7474
// Ruby / PHP
7575
'Gemfile',
7676
'composer.json',
77-
// Go / Elixir
77+
// Go / Elixir / Rust
7878
'go.mod',
7979
'mix.exs',
80-
// Rust / JVM / .NET: no framework targets yet, but found so
81-
// an existing PostHog SDK is reported (feeds self-driving's "continue" path).
8280
'Cargo.toml',
81+
// JVM / .NET: no framework targets yet, but found so
82+
// an existing PostHog SDK is reported (feeds self-driving's "continue" path).
8383
'pom.xml',
8484
'*.csproj',
8585
// Mobile / native

src/lib/detection/package-manager.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -253,6 +253,24 @@ export function mixPackageManager(): Promise<PackageManagerInfo> {
253253
});
254254
}
255255

256+
// ---------------------------------------------------------------------------
257+
// Rust (Cargo) helper
258+
// ---------------------------------------------------------------------------
259+
260+
const CARGO: DetectedPackageManager = {
261+
name: 'cargo',
262+
label: 'Cargo',
263+
installCommand: 'cargo add',
264+
};
265+
266+
export function cargoPackageManager(): Promise<PackageManagerInfo> {
267+
return Promise.resolve({
268+
detected: [CARGO],
269+
primary: CARGO,
270+
recommendation: 'Use Cargo (cargo add).',
271+
});
272+
}
273+
256274
// ---------------------------------------------------------------------------
257275
// Android (Gradle) helper
258276
// ---------------------------------------------------------------------------

src/lib/registry.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import { ANDROID_AGENT_CONFIG } from '@frameworks/android/android-wizard-agent';
2020
import { RAILS_AGENT_CONFIG } from '@frameworks/rails/rails-wizard-agent';
2121
import { GO_AGENT_CONFIG } from '@frameworks/go/go-wizard-agent';
2222
import { ELIXIR_AGENT_CONFIG } from '@frameworks/elixir/elixir-wizard-agent';
23+
import { RUST_AGENT_CONFIG } from '@frameworks/rust/rust-wizard-agent';
2324
import { PYTHON_AGENT_CONFIG } from '@frameworks/python/python-wizard-agent';
2425
import { RUBY_AGENT_CONFIG } from '@frameworks/ruby/ruby-wizard-agent';
2526
import { JAVASCRIPT_NODE_AGENT_CONFIG } from '@frameworks/javascript-node/javascript-node-wizard-agent';
@@ -46,6 +47,7 @@ export const FRAMEWORK_REGISTRY: Record<Integration, FrameworkConfig> = {
4647
[Integration.rails]: RAILS_AGENT_CONFIG,
4748
[Integration.go]: GO_AGENT_CONFIG,
4849
[Integration.elixir]: ELIXIR_AGENT_CONFIG,
50+
[Integration.rust]: RUST_AGENT_CONFIG,
4951
[Integration.python]: PYTHON_AGENT_CONFIG,
5052
[Integration.ruby]: RUBY_AGENT_CONFIG,
5153
[Integration.javascriptNode]: JAVASCRIPT_NODE_AGENT_CONFIG,

0 commit comments

Comments
 (0)