Skip to content
Draft
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
106 changes: 106 additions & 0 deletions src/frameworks/rust/rust-wizard-agent.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
/* Rust wizard using posthog-agent with PostHog MCP */
import * as fs from 'node:fs';
import * as path from 'node:path';
import type { WizardRunOptions } from '@utils/types';
import type { FrameworkConfig } from '@lib/framework-config';
import { cargoPackageManager } from '@lib/detection/package-manager';
import { Integration } from '@lib/constants';

type RustContext = {
asyncRuntime?: boolean;
};

function readCargoToml(installDir: string): string | undefined {
const cargoTomlPath = path.join(installDir, 'Cargo.toml');
if (!fs.existsSync(cargoTomlPath)) {
return undefined;
}
return fs.readFileSync(cargoTomlPath, 'utf-8');
}

/** posthog-rs defaults to its async client; projects without an async runtime need the blocking one. */
function hasAsyncRuntime(installDir: string): boolean {
const cargoToml = readCargoToml(installDir);
return !!cargoToml && /^\s*(tokio|async-std|smol)\s*[=.]/m.test(cargoToml);
}

export const RUST_AGENT_CONFIG: FrameworkConfig<RustContext> = {
metadata: {
name: 'Rust',
integration: Integration.rust,
docsUrl: 'https://posthog.com/docs/libraries/rust',
gatherContext: (options: WizardRunOptions) => {
const asyncRuntime = hasAsyncRuntime(options.installDir);
return Promise.resolve({ asyncRuntime });
},
},

detection: {
packageName: 'posthog-rs',
packageDisplayName: 'Rust',
usesPackageJson: false,
getVersion: () => undefined,
// A Cargo.toml with a [package] section marks a crate root. A
// workspace-root-only manifest ([workspace], no [package]) falls
// through — the agentic monorepo scan finds the nested crates.
detect: (options) => {
const cargoToml = readCargoToml(options.installDir);
return Promise.resolve(!!cargoToml && /^\s*\[package\]/m.test(cargoToml));
},
detectPackageManager: cargoPackageManager,
},

environment: {
uploadToHosting: false,
getEnvVars: (apiKey: string, host: string) => ({
POSTHOG_API_KEY: apiKey,
POSTHOG_HOST: host,
}),
},

analytics: {
getTags: (context) => ({
asyncRuntime: context.asyncRuntime ? 'true' : 'false',
}),
},

prompts: {
projectTypeDetection:
'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.',
packageInstallation:
'Install the PostHog Rust SDK with `cargo add posthog-rs`. Do not manually edit Cargo.toml; cargo updates it automatically.',
getAdditionalContextLines: (context) => {
const lines = [
`Framework docs ID: rust (use posthog://docs/frameworks/rust for documentation)`,
'capture() hands events to a background worker — the client must flush() or shutdown() before the process exits or buffered events are lost.',
];
if (context.asyncRuntime) {
lines.push(
'An async runtime is present — use the default async posthog-rs client.',
);
} else {
lines.push(
'No async runtime detected — install with `cargo add posthog-rs --no-default-features` and use the blocking client.',
);
}
return lines;
},
},

ui: {
successMessage: 'PostHog integration complete',
estimatedDurationMinutes: 5,
getOutroChanges: () => [
'Analyzed your Rust project structure',
'Installed the posthog-rs crate via cargo add',
'Initialized a shared PostHog client configured from environment variables',
'Instrumented meaningful events with client.capture',
],
getOutroNextSteps: () => [
'Run your Rust application and trigger the instrumented code paths',
'Visit your PostHog dashboard to see incoming events',
'Use Event::new(event_name, distinct_id) and client.capture to track custom events',
'Keep flush()/shutdown() in your exit path so queued events are sent',
],
},
};
17 changes: 16 additions & 1 deletion src/lib/__tests__/wizard-can-use-tool.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,21 @@ describe('bash fence — allows real toolchain commands (from skills + field log
expect(allow('carthage bootstrap')).toBe('allow');
});

it('rust ecosystem', () => {
expect(allow('cargo add posthog-rs')).toBe('allow');
expect(allow('cargo add posthog-rs --no-default-features')).toBe('allow');
expect(allow('cargo build')).toBe('allow');
expect(allow('cargo check --all-targets')).toBe('allow');
expect(allow('cargo fmt')).toBe('allow');
expect(allow('cargo clippy')).toBe('allow');
expect(allow('cargo metadata --format-version 1')).toBe('allow');
// run/test execute project code; install/publish are outward-facing.
expect(allow('cargo run')).toBe('deny');
expect(allow('cargo test')).toBe('deny');
expect(allow('cargo install evil-tool')).toBe('deny');
expect(allow('cargo publish')).toBe('deny');
});

it('android/jvm ecosystem', () => {
expect(allow('./gradlew assembleDebug')).toBe('allow');
expect(allow('./gradlew :app:assembleDebug')).toBe('allow');
Expand Down Expand Up @@ -193,7 +208,7 @@ describe('bash fence — attack corpus (one test per bypass vector)', () => {
expect(allow('xcodebuild test-without-building')).toBe('deny');
expect(allow('bundle exec rspec')).toBe('deny');
expect(allow('composer run-script evil')).toBe('deny');
expect(allow('cargo run')).toBe('deny'); // no rust framework -> whole binary denied
expect(allow('cargo run')).toBe('deny'); // arbitrary code execution
expect(allow('go get github.com/x/y')).toBe('deny'); // no go framework
});

Expand Down
15 changes: 14 additions & 1 deletion src/lib/agent/bash-fence.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,18 @@ const SIMPLE_MANAGERS: Record<string, readonly string[]> = {
swift: ['package', 'build'],
pod: ['install', 'update', 'search'],
carthage: ['bootstrap', 'update'],
// cargo run/test execute project code; install/publish are outward-facing.
cargo: [
'add',
'remove',
'build',
'check',
'fmt',
'clippy',
'metadata',
'tree',
'fetch',
],
};

// Gradle tasks are verb-anchored camelCase: assembleDebug yes, publishToMavenCentral no.
Expand All @@ -102,7 +114,8 @@ const ALLOWED_TOOLS_SUMMARY =
'composer (install|require|update|remove|show), bundle (install|add|remove|update|show|exec <lint tool>), ' +
'gem (install|uninstall|list|search), swift (package|build), pod (install|update|search), carthage (bootstrap|update), ' +
'xcodebuild (build/clean/archive actions), gradle/gradlew (build|clean|dependencies|assemble*/compile*/bundle*/lint* tasks), ' +
'mvn (install|compile|package|verify|dependency:tree).';
'mvn (install|compile|package|verify|dependency:tree), ' +
'cargo (add|remove|build|check|fmt|clippy|metadata|tree|fetch).';

function deny(analyticsReason: string, message: string): BashFenceDecision {
return { allowed: false, message, analyticsReason };
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ const INTEGRATION_ENTRIES = [
{ id: 'integration-ruby', framework: 'ruby' },
{ id: 'integration-elixir' },
{ id: 'integration-go' },
{ id: 'integration-rust', framework: 'rust' },
{ id: 'integration-swift', framework: 'swift' },
{ id: 'integration-kmp', framework: 'kmp' },
{ id: 'integration-flutter' },
Expand Down
1 change: 1 addition & 0 deletions src/lib/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,7 @@ export enum Integration {
swift = 'swift',
android = 'android',
rails = 'rails',
rust = 'rust',

// Language fallbacks. Keep javascriptNode last: it matches any package.json.
python = 'python',
Expand Down
34 changes: 34 additions & 0 deletions src/lib/detection/__tests__/framework.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { detectFramework } from '@lib/detection/framework';
import { Integration } from '@lib/constants';
import { ANDROID_AGENT_CONFIG } from '../../../frameworks/android/android-wizard-agent';
import { KMP_AGENT_CONFIG } from '../../../frameworks/kmp/kmp-wizard-agent';
import { RUST_AGENT_CONFIG } from '../../../frameworks/rust/rust-wizard-agent';

/** A throwaway project dir seeded with the given files. */
function makeProject(files: Record<string, string>): string {
Expand Down Expand Up @@ -137,6 +138,16 @@ describe('detectFramework (end-to-end over real project dirs)', () => {
);
});

test('a Rust crate resolves to rust', async () => {
const opts = project({
'Cargo.toml': '[package]\nname = "my-app"\nedition = "2021"\n',
'src/main.rs': 'fn main() {}',
});
await expect(detectFramework(opts.installDir)).resolves.toBe(
Integration.rust,
);
});

test('a Flutter project is not claimed by anything', async () => {
const opts = project({
'pubspec.yaml': 'name: my_flutter_app\nenvironment:\n sdk: ^3.0.0\n',
Expand Down Expand Up @@ -169,6 +180,29 @@ describe('android detect', () => {
});
});

describe('rust detect', () => {
const detect = RUST_AGENT_CONFIG.detection.detect;

test('claims a crate with a [package] section', async () => {
const opts = project({
'Cargo.toml': '[package]\nname = "my-app"\nedition = "2021"\n',
});
await expect(detect(opts)).resolves.toBe(true);
});

test('does not claim a workspace-root-only manifest', async () => {
const opts = project({
'Cargo.toml': '[workspace]\nmembers = ["crates/*"]\n',
});
await expect(detect(opts)).resolves.toBe(false);
});

test('does not claim a project without a Cargo.toml', async () => {
const opts = project({ 'src/main.rs': 'fn main() {}' });
await expect(detect(opts)).resolves.toBe(false);
});
});

describe('kmp detect', () => {
const detect = KMP_AGENT_CONFIG.detection.detect;

Expand Down
2 changes: 2 additions & 0 deletions src/lib/detection/__tests__/package-manager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
composerPackageManager,
swiftPackageManager,
gradlePackageManager,
cargoPackageManager,
} from '@lib/detection/package-manager';

vi.mock('../../../utils/debug');
Expand Down Expand Up @@ -188,6 +189,7 @@ describe('static package manager helpers', () => {
{ fn: composerPackageManager, name: 'composer' },
{ fn: swiftPackageManager, name: 'spm' },
{ fn: gradlePackageManager, name: 'gradle' },
{ fn: cargoPackageManager, name: 'cargo' },
])('$name returns valid PackageManagerInfo', async ({ fn }) => {
const result = await fn();
expect(result.detected).toHaveLength(1);
Expand Down
5 changes: 3 additions & 2 deletions src/lib/detection/agentic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,9 +74,10 @@ export const PROJECT_MANIFESTS: readonly string[] = [
// Ruby / PHP
'Gemfile',
'composer.json',
// Rust / Go / Elixir / JVM / .NET: no framework targets yet, but found so
// an existing PostHog SDK is reported (feeds self-driving's "continue" path).
// Rust
'Cargo.toml',
// Go / Elixir / JVM / .NET: no framework targets yet, but found so
// an existing PostHog SDK is reported (feeds self-driving's "continue" path).
'go.mod',
'mix.exs',
'pom.xml',
Expand Down
18 changes: 18 additions & 0 deletions src/lib/detection/package-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -215,6 +215,24 @@ export function bundlerPackageManager(): Promise<PackageManagerInfo> {
});
}

// ---------------------------------------------------------------------------
// Rust (Cargo) helper
// ---------------------------------------------------------------------------

const CARGO: DetectedPackageManager = {
name: 'cargo',
label: 'Cargo',
installCommand: 'cargo add',
};

export function cargoPackageManager(): Promise<PackageManagerInfo> {
return Promise.resolve({
detected: [CARGO],
primary: CARGO,
recommendation: 'Use Cargo (cargo add).',
});
}

// ---------------------------------------------------------------------------
// Android (Gradle) helper
// ---------------------------------------------------------------------------
Expand Down
2 changes: 2 additions & 0 deletions src/lib/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import { SWIFT_AGENT_CONFIG } from '@frameworks/swift/swift-wizard-agent';
import { KMP_AGENT_CONFIG } from '@frameworks/kmp/kmp-wizard-agent';
import { ANDROID_AGENT_CONFIG } from '@frameworks/android/android-wizard-agent';
import { RAILS_AGENT_CONFIG } from '@frameworks/rails/rails-wizard-agent';
import { RUST_AGENT_CONFIG } from '@frameworks/rust/rust-wizard-agent';
import { PYTHON_AGENT_CONFIG } from '@frameworks/python/python-wizard-agent';
import { RUBY_AGENT_CONFIG } from '@frameworks/ruby/ruby-wizard-agent';
import { JAVASCRIPT_NODE_AGENT_CONFIG } from '@frameworks/javascript-node/javascript-node-wizard-agent';
Expand All @@ -42,6 +43,7 @@ export const FRAMEWORK_REGISTRY: Record<Integration, FrameworkConfig> = {
[Integration.swift]: SWIFT_AGENT_CONFIG,
[Integration.android]: ANDROID_AGENT_CONFIG,
[Integration.rails]: RAILS_AGENT_CONFIG,
[Integration.rust]: RUST_AGENT_CONFIG,
[Integration.python]: PYTHON_AGENT_CONFIG,
[Integration.ruby]: RUBY_AGENT_CONFIG,
[Integration.javascriptNode]: JAVASCRIPT_NODE_AGENT_CONFIG,
Expand Down
Loading