diff --git a/src/frameworks/rust/rust-wizard-agent.ts b/src/frameworks/rust/rust-wizard-agent.ts new file mode 100644 index 00000000..6c0aef1e --- /dev/null +++ b/src/frameworks/rust/rust-wizard-agent.ts @@ -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 = { + 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', + ], + }, +}; diff --git a/src/lib/__tests__/wizard-can-use-tool.test.ts b/src/lib/__tests__/wizard-can-use-tool.test.ts index 67afbecc..bd549735 100644 --- a/src/lib/__tests__/wizard-can-use-tool.test.ts +++ b/src/lib/__tests__/wizard-can-use-tool.test.ts @@ -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'); @@ -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 }); diff --git a/src/lib/agent/bash-fence.ts b/src/lib/agent/bash-fence.ts index 9fe251ee..252c8e9b 100644 --- a/src/lib/agent/bash-fence.ts +++ b/src/lib/agent/bash-fence.ts @@ -84,6 +84,18 @@ const SIMPLE_MANAGERS: Record = { 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. @@ -102,7 +114,8 @@ const ALLOWED_TOOLS_SUMMARY = 'composer (install|require|update|remove|show), bundle (install|add|remove|update|show|exec ), ' + '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 }; diff --git a/src/lib/agent/runner/sequence/orchestrator/__tests__/variant-resolution.test.ts b/src/lib/agent/runner/sequence/orchestrator/__tests__/variant-resolution.test.ts index f878a468..4e251369 100644 --- a/src/lib/agent/runner/sequence/orchestrator/__tests__/variant-resolution.test.ts +++ b/src/lib/agent/runner/sequence/orchestrator/__tests__/variant-resolution.test.ts @@ -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' }, diff --git a/src/lib/constants.ts b/src/lib/constants.ts index 78353cb6..18850ae4 100644 --- a/src/lib/constants.ts +++ b/src/lib/constants.ts @@ -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', diff --git a/src/lib/detection/__tests__/framework.test.ts b/src/lib/detection/__tests__/framework.test.ts index 075c46a2..aaae4203 100644 --- a/src/lib/detection/__tests__/framework.test.ts +++ b/src/lib/detection/__tests__/framework.test.ts @@ -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 { @@ -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', @@ -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; diff --git a/src/lib/detection/__tests__/package-manager.test.ts b/src/lib/detection/__tests__/package-manager.test.ts index 27b468ad..1e8c0aa7 100644 --- a/src/lib/detection/__tests__/package-manager.test.ts +++ b/src/lib/detection/__tests__/package-manager.test.ts @@ -7,6 +7,7 @@ import { composerPackageManager, swiftPackageManager, gradlePackageManager, + cargoPackageManager, } from '@lib/detection/package-manager'; vi.mock('../../../utils/debug'); @@ -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); diff --git a/src/lib/detection/agentic.ts b/src/lib/detection/agentic.ts index c808bf09..1f7096e6 100644 --- a/src/lib/detection/agentic.ts +++ b/src/lib/detection/agentic.ts @@ -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', diff --git a/src/lib/detection/package-manager.ts b/src/lib/detection/package-manager.ts index add3cc49..6cb413c6 100644 --- a/src/lib/detection/package-manager.ts +++ b/src/lib/detection/package-manager.ts @@ -215,6 +215,24 @@ export function bundlerPackageManager(): Promise { }); } +// --------------------------------------------------------------------------- +// Rust (Cargo) helper +// --------------------------------------------------------------------------- + +const CARGO: DetectedPackageManager = { + name: 'cargo', + label: 'Cargo', + installCommand: 'cargo add', +}; + +export function cargoPackageManager(): Promise { + return Promise.resolve({ + detected: [CARGO], + primary: CARGO, + recommendation: 'Use Cargo (cargo add).', + }); +} + // --------------------------------------------------------------------------- // Android (Gradle) helper // --------------------------------------------------------------------------- diff --git a/src/lib/registry.ts b/src/lib/registry.ts index 2dfba061..0305cf1d 100644 --- a/src/lib/registry.ts +++ b/src/lib/registry.ts @@ -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'; @@ -42,6 +43,7 @@ export const FRAMEWORK_REGISTRY: Record = { [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,