diff --git a/.agents/skills/distributing-tauri-for-android/SKILL.md b/.agents/skills/distributing-tauri-for-android/SKILL.md new file mode 100644 index 0000000..acc2a46 --- /dev/null +++ b/.agents/skills/distributing-tauri-for-android/SKILL.md @@ -0,0 +1,363 @@ +--- +name: distributing-tauri-for-android +description: Guides the user through distributing Tauri applications for Android, including Google Play Store submission, APK and AAB generation, build configuration, signing setup, and version management. +--- + +# Distributing Tauri Apps for Android + +This skill covers the complete workflow for preparing and distributing Tauri v2 applications on Android, including Google Play Store publication. + +## Prerequisites + +Before distributing your Tauri app for Android: + +1. **Play Console Account**: Create a developer account at https://play.google.com/console/developers +2. **Android SDK**: Ensure Android SDK is installed and configured +3. **Code Signing**: Set up Android code signing (keystore) +4. **Tauri Android Initialized**: Run `tauri android init` if not already done + +## App Icon Configuration + +After initializing Android support, configure your app icon: + +```bash +# npm +npm run tauri icon /path/to/app-icon.png + +# yarn +yarn tauri icon /path/to/app-icon.png + +# pnpm +pnpm tauri icon /path/to/app-icon.png + +# cargo +cargo tauri icon /path/to/app-icon.png +``` + +This generates icons in all required sizes for Android. + +## Build Configuration + +### tauri.conf.json Android Settings + +Configure Android-specific settings in your `tauri.conf.json`: + +```json +{ + "bundle": { + "android": { + "minSdkVersion": 24, + "versionCode": 1 + } + } +} +``` + +### Configuration Options + +| Option | Default | Description | +|--------|---------|-------------| +| `minSdkVersion` | 24 | Minimum Android SDK version (Android 7.0) | +| `versionCode` | Auto-calculated | Integer version code for Play Store | + +### Version Code Calculation + +Tauri automatically calculates the version code from your app version: + +``` +versionCode = major * 1000000 + minor * 1000 + patch +``` + +**Example**: Version `1.2.3` becomes version code `1002003` + +Override this in `tauri.conf.json` if you need sequential numbering: + +```json +{ + "bundle": { + "android": { + "versionCode": 42 + } + } +} +``` + +### Minimum SDK Version + +Default minimum is Android 7.0 (SDK 24). For higher requirements: + +```json +{ + "bundle": { + "android": { + "minSdkVersion": 28 + } + } +} +``` + +**Common SDK versions**: +- SDK 24: Android 7.0 (Nougat) +- SDK 26: Android 8.0 (Oreo) +- SDK 28: Android 9.0 (Pie) +- SDK 29: Android 10 +- SDK 30: Android 11 +- SDK 31: Android 12 +- SDK 33: Android 13 +- SDK 34: Android 14 + +## Building for Distribution + +### Android App Bundle (AAB) - Recommended + +Google Play requires AAB format for new apps. Generate an AAB: + +```bash +# npm +npm run tauri android build -- --aab + +# yarn +yarn tauri android build --aab + +# pnpm +pnpm tauri android build -- --aab + +# cargo +cargo tauri android build --aab +``` + +**Output location**: +``` +gen/android/app/build/outputs/bundle/universalRelease/app-universal-release.aab +``` + +### APK Generation + +For testing or alternative distribution channels: + +```bash +# npm +npm run tauri android build -- --apk + +# yarn +yarn tauri android build --apk + +# pnpm +pnpm tauri android build -- --apk + +# cargo +cargo tauri android build --apk +``` + +### Architecture-Specific Builds + +Build for specific CPU architectures: + +```bash +# Single architecture +npm run tauri android build -- --target aarch64 + +# Multiple architectures +npm run tauri android build -- --target aarch64 --target armv7 +``` + +**Available targets**: +- `aarch64` - ARM 64-bit (most modern devices) +- `armv7` - ARM 32-bit (older devices) +- `i686` - Intel 32-bit (emulators) +- `x86_64` - Intel 64-bit (emulators, some Chromebooks) + +### Split APKs by Architecture + +Create separate APKs per architecture (useful for testing): + +```bash +npm run tauri android build -- --apk --split-per-abi +``` + +**Note**: Not needed for Play Store submission. Google Play automatically serves the correct architecture from your AAB. + +## Code Signing + +### Generate a Keystore + +Create a release keystore for signing: + +```bash +keytool -genkey -v -keystore release-key.keystore \ + -alias my-app-alias \ + -keyalg RSA \ + -keysize 2048 \ + -validity 10000 +``` + +**Important**: Store your keystore securely. Losing it means you cannot update your app. + +### Configure Signing in Gradle + +Create or update `gen/android/keystore.properties`: + +```properties +storePassword=your_store_password +keyPassword=your_key_password +keyAlias=my-app-alias +storeFile=/path/to/release-key.keystore +``` + +Update `gen/android/app/build.gradle.kts` to use the keystore: + +```kotlin +import java.util.Properties +import java.io.FileInputStream + +val keystorePropertiesFile = rootProject.file("keystore.properties") +val keystoreProperties = Properties() +if (keystorePropertiesFile.exists()) { + keystoreProperties.load(FileInputStream(keystorePropertiesFile)) +} + +android { + signingConfigs { + create("release") { + keyAlias = keystoreProperties["keyAlias"] as String + keyPassword = keystoreProperties["keyPassword"] as String + storeFile = file(keystoreProperties["storeFile"] as String) + storePassword = keystoreProperties["storePassword"] as String + } + } + buildTypes { + release { + signingConfig = signingConfigs.getByName("release") + } + } +} +``` + +### Environment Variables for CI/CD + +For automated builds, use environment variables: + +```kotlin +android { + signingConfigs { + create("release") { + keyAlias = System.getenv("ANDROID_KEY_ALIAS") + keyPassword = System.getenv("ANDROID_KEY_PASSWORD") + storeFile = file(System.getenv("ANDROID_KEYSTORE_PATH")) + storePassword = System.getenv("ANDROID_STORE_PASSWORD") + } + } +} +``` + +## Google Play Store Submission + +### Pre-Submission Checklist + +1. **App signed** with release keystore +2. **Version code** incremented from previous release +3. **App icon** configured in all required sizes +4. **Screenshots** prepared (required by Play Store) +5. **Privacy policy** URL ready (required for most apps) +6. **Content rating** questionnaire completed + +### Upload Process + +1. **Navigate** to Play Console: https://play.google.com/console/developers +2. **Create application** or select existing app +3. **Upload AAB** file from: + ``` + gen/android/app/build/outputs/bundle/universalRelease/app-universal-release.aab + ``` +4. **Complete store listing** (title, description, screenshots) +5. **Set content rating** +6. **Configure pricing and distribution** +7. **Submit for review** + +### First Release Requirements + +The initial submission requires manual upload through Play Console for signature verification. Google will manage your app signing key through Play App Signing. + +### Automation Note + +Tauri currently does not offer built-in automation for creating Android releases. However, you can use the Google Play Developer API for automated submissions in CI/CD pipelines. + +## Troubleshooting + +### Build Fails with Signing Error + +Ensure your keystore path is absolute or relative to the correct directory: + +```properties +# Absolute path +storeFile=/Users/username/keys/release-key.keystore + +# Relative to gen/android directory +storeFile=../../release-key.keystore +``` + +### Version Code Not Incrementing + +If using auto-calculation, ensure your `package.json` or `Cargo.toml` version is updated. For manual control: + +```json +{ + "bundle": { + "android": { + "versionCode": 2 + } + } +} +``` + +### APK Not Installing on Device + +Check minimum SDK version compatibility: + +```bash +# Check device Android version +adb shell getprop ro.build.version.sdk +``` + +### AAB Too Large + +Consider using `--split-per-abi` for testing, but for Play Store, Google handles this automatically. If still too large: + +1. Optimize your frontend assets +2. Use dynamic feature modules +3. Enable ProGuard/R8 minification + +## Quick Reference + +### Common Build Commands + +```bash +# Development build +npm run tauri android dev + +# Release AAB for Play Store +npm run tauri android build -- --aab + +# Release APK for testing +npm run tauri android build -- --apk + +# Specific architecture +npm run tauri android build -- --aab --target aarch64 +``` + +### File Locations + +| File | Location | +|------|----------| +| AAB output | `gen/android/app/build/outputs/bundle/universalRelease/app-universal-release.aab` | +| APK output | `gen/android/app/build/outputs/apk/universal/release/app-universal-release-unsigned.apk` | +| Gradle config | `gen/android/app/build.gradle.kts` | +| Keystore properties | `gen/android/keystore.properties` | +| Android manifest | `gen/android/app/src/main/AndroidManifest.xml` | + +### Resources + +- [Google Play Console](https://play.google.com/console/developers) +- [Tauri Android Documentation](https://v2.tauri.app/distribute/google-play) +- [Google Play Release Checklist](https://developer.android.com/distribute/best-practices/launch/launch-checklist) +- [Play App Signing](https://developer.android.com/studio/publish/app-signing) diff --git a/.agents/skills/fastlane/SKILL.md b/.agents/skills/fastlane/SKILL.md new file mode 100644 index 0000000..63e9272 --- /dev/null +++ b/.agents/skills/fastlane/SKILL.md @@ -0,0 +1,496 @@ +--- +name: fastlane +description: > + iOS and Android app deployment automation with Fastlane. Use when building, + signing, and distributing apps to TestFlight, App Store, or Google Play. + Covers Match code signing, CI/CD keychain setup, and Tauri integration. + Triggers on Fastfile, Appfile, Matchfile, fastlane commands. +--- + +# Fastlane Deployment Skill + +> Automate iOS and Android app building, code signing, and distribution. + +## When to Apply + +Reference this skill when: +- Setting up Fastlane for a new project +- Configuring code signing with Match +- Building lanes for TestFlight or App Store distribution +- Setting up Android Play Store deployment +- Debugging code signing or build failures +- Configuring CI/CD pipelines for mobile apps +- Integrating Fastlane with Tauri v2 projects + +## Quick Start + +### Minimal Fastfile Structure + +```ruby +default_platform(:ios) + +platform :ios do + desc "Build and upload to TestFlight" + lane :beta do + match(type: "appstore", readonly: true) + build_app(scheme: "MyApp") + upload_to_testflight + end +end + +platform :android do + desc "Build and upload to Play Store beta" + lane :beta do + gradle(task: "bundle", build_type: "Release") + upload_to_play_store(track: "beta") + end +end +``` + +## Tool Aliases + +Fastlane provides shorter aliases for common actions: + +| Alias | Action | Purpose | +|-------|--------|---------| +| `gym` | `build_app` | Build and sign iOS/macOS apps | +| `pilot` | `upload_to_testflight` | Upload to TestFlight | +| `deliver` | `upload_to_app_store` | Submit to App Store | +| `supply` | `upload_to_play_store` | Upload to Google Play | +| `match` | `sync_code_signing` | Sync certificates and profiles | +| `cert` | `get_certificates` | Download signing certificates | +| `sigh` | `get_provisioning_profile` | Download provisioning profiles | +| `scan` | `run_tests` | Run unit and UI tests | +| `snapshot` | `capture_screenshots` | Automated App Store screenshots | +| `frameit` | `frame_screenshots` | Add device frames to screenshots | +| `produce` | `create_app_online` | Create app in App Store Connect | +| `pem` | `get_push_certificate` | Download push notification certs | +| `precheck` | `check_app_store_metadata` | Validate metadata before submission | + +## iOS Workflows + +### TestFlight Deployment + +```ruby +lane :beta do + # Sync code signing + match(type: "appstore", readonly: true) + + # Increment build number + increment_build_number( + build_number: Time.now.utc.strftime("%y%m%d%H%M") + ) + + # Build the app + build_app( + scheme: "MyApp", + export_method: "app-store", + output_directory: "./build" + ) + + # Upload to TestFlight + upload_to_testflight( + skip_waiting_for_build_processing: true, + uses_non_exempt_encryption: false + ) +end +``` + +### App Store Release + +```ruby +lane :release do + match(type: "appstore", readonly: true) + + build_app( + scheme: "MyApp", + export_method: "app-store" + ) + + upload_to_app_store( + skip_screenshots: true, + skip_metadata: true, + submit_for_review: false + ) +end +``` + +### App Store Connect API Key + +```ruby +def api_key + app_store_connect_api_key( + key_id: ENV['APP_STORE_CONNECT_API_KEY_KEY_ID'], + issuer_id: ENV['APP_STORE_CONNECT_API_KEY_ISSUER_ID'], + key_content: ENV['APP_STORE_CONNECT_API_KEY_KEY'], + is_key_content_base64: true + ) +end + +lane :beta do + upload_to_testflight(api_key: api_key) +end +``` + +## Android Workflows + +### Play Store Beta + +```ruby +platform :android do + lane :beta do + gradle( + task: "bundle", + build_type: "Release", + project_dir: "./android" + ) + + upload_to_play_store( + track: "beta", + aab: "./android/app/build/outputs/bundle/release/app-release.aab", + skip_upload_metadata: true, + skip_upload_images: true + ) + end +end +``` + +### Play Store Production + +```ruby +lane :release do + gradle(task: "bundle", build_type: "Release") + + upload_to_play_store( + track: "production", + aab: "./android/app/build/outputs/bundle/release/app-release.aab" + ) +end +``` + +## Code Signing with Match + +### Initial Setup + +```bash +# Initialize Match configuration +fastlane match init + +# Generate certificates (run once per team) +fastlane match appstore +fastlane match development +``` + +### Matchfile Configuration + +```ruby +# fastlane/Matchfile +git_url("git@github.com:your-org/certificates.git") +storage_mode("git") +type("appstore") +app_identifier("com.example.app") +team_id("TEAM_ID") +``` + +### S3/MinIO Storage (Alternative to Git) + +```ruby +# Matchfile for S3-compatible storage +storage_mode("s3") +s3_region("us-east-1") +s3_bucket("certificates") +s3_access_key(ENV['AWS_ACCESS_KEY_ID']) +s3_secret_access_key(ENV['AWS_SECRET_ACCESS_KEY']) + +# For MinIO, set endpoint +# ENV['AWS_ENDPOINT_URL'] = "https://minio.example.com" +``` + +### Using Match in Lanes + +```ruby +lane :beta do + match( + type: "appstore", + readonly: true, # Don't create new certs + keychain_name: ENV['CI'] ? "fastlane_ci" : nil, + keychain_password: ENV['CI'] ? "fastlane_ci_password" : nil + ) +end +``` + +## CI/CD Integration + +### Keychain Setup for CI + +```ruby +CI_KEYCHAIN_NAME = "fastlane_ci" +CI_KEYCHAIN_PASSWORD = "fastlane_ci_password" + +def setup_ci_keychain + if ENV['CI'] + create_keychain( + name: CI_KEYCHAIN_NAME, + password: CI_KEYCHAIN_PASSWORD, + default_keychain: true, + unlock: true, + timeout: 3600, + lock_when_sleeps: false, + add_to_search_list: true + ) + end +end + +def cleanup_ci_keychain + if ENV['CI'] + delete_keychain(name: CI_KEYCHAIN_NAME) + end +end + +lane :beta do + setup_ci_keychain + match( + type: "appstore", + keychain_name: CI_KEYCHAIN_NAME, + keychain_password: CI_KEYCHAIN_PASSWORD + ) + # ... build and upload + cleanup_ci_keychain +end +``` + +### GitHub Actions Example + +```yaml +# .github/workflows/ios.yml +name: iOS Build +on: [push] + +jobs: + build: + runs-on: macos-latest + steps: + - uses: actions/checkout@v4 + + - name: Install Fastlane + run: brew install fastlane + + - name: Build and Deploy + env: + MATCH_PASSWORD: ${{ secrets.MATCH_PASSWORD }} + MATCH_GIT_URL: ${{ secrets.MATCH_GIT_URL }} + APP_STORE_CONNECT_API_KEY_KEY_ID: ${{ secrets.ASC_KEY_ID }} + APP_STORE_CONNECT_API_KEY_ISSUER_ID: ${{ secrets.ASC_ISSUER_ID }} + APP_STORE_CONNECT_API_KEY_KEY: ${{ secrets.ASC_KEY_CONTENT }} + CI: true + run: fastlane ios beta +``` + +## Environment Variables + +### iOS (App Store Connect) + +| Variable | Description | +|----------|-------------| +| `APP_STORE_CONNECT_API_KEY_KEY_ID` | API Key ID from App Store Connect | +| `APP_STORE_CONNECT_API_KEY_ISSUER_ID` | Issuer ID from App Store Connect | +| `APP_STORE_CONNECT_API_KEY_KEY` | Base64-encoded .p8 key content | +| `MATCH_PASSWORD` | Encryption password for Match | +| `MATCH_GIT_URL` | Git repository URL for certificates | + +### Android (Google Play) + +| Variable | Description | +|----------|-------------| +| `SUPPLY_JSON_KEY_DATA` | Google Play service account JSON (base64) | +| `SUPPLY_JSON_KEY` | Path to service account JSON file | + +### Encoding API Keys + +```bash +# Encode .p8 file to base64 +base64 -i AuthKey_XXXXXXXXXX.p8 | tr -d '\n' + +# Encode Google Play JSON +base64 -i play-store-key.json | tr -d '\n' +``` + +## App Store Requirements + +### Metadata Character Limits + +| Field | Limit | +|-------|-------| +| App Name | 30 characters | +| Subtitle | 30 characters | +| Keywords | 100 characters | +| Description | 4000 characters | +| Release Notes | 4000 characters | +| Promotional Text | 170 characters | + +### Screenshot Requirements (2024) + +| Device | Size | Required | +|--------|------|----------| +| iPhone 6.7" | 1290 x 2796 | Yes (primary) | +| iPhone 6.5" | 1284 x 2778 | Alternative | +| iPhone 5.5" | 1242 x 2208 | Optional | +| iPad Pro 12.9" | 2048 x 2732 | If iPad supported | +| iPad Pro 11" | 1668 x 2388 | Alternative | + +## Known Issues Prevention + +| Issue | Root Cause | Solution | +|-------|-----------|----------| +| "Multiple commands produce" | Duplicate files in build | Remove duplicates from sources | +| Code signing fails in CI | No keychain access | Use `create_keychain` + Match | +| Build number rejected | Duplicate build number | Use timestamp: `Time.now.utc.strftime("%y%m%d%H%M")` | +| Profile not found | Wrong Match type | Use `appstore` for TestFlight/App Store | +| Invalid PEM format | Wrong key encoding | Ensure base64 with `is_key_content_base64: true` | +| "Missing compliance" | Encryption declaration | Set `uses_non_exempt_encryption: false` | +| Gradle build fails | Missing SDK/NDK | Set `ANDROID_HOME` and `ANDROID_NDK_HOME` | + +## Tauri Integration + +### Version from Cargo.toml + +```ruby +ROOT_DIR = File.expand_path("..", __dir__) + +def get_app_version + cargo_toml = File.read("#{ROOT_DIR}/src-tauri/Cargo.toml") + if cargo_toml =~ /^version\s*=\s*"([^"]+)"/ + $1 + else + "1.0.0" + end +end + +def get_next_build_number + Time.now.utc.strftime("%y%m%d%H%M").to_i +end +``` + +### Update tauri.conf.json + +```ruby +def update_tauri_config_version + app_version = get_app_version + build_number = get_next_build_number + + tauri_conf_path = "#{ROOT_DIR}/src-tauri/tauri.conf.json" + tauri_conf = JSON.parse(File.read(tauri_conf_path)) + tauri_conf["version"] = app_version + tauri_conf["bundle"] ||= {} + tauri_conf["bundle"]["iOS"] ||= {} + tauri_conf["bundle"]["iOS"]["bundleVersion"] = build_number.to_s + + File.write(tauri_conf_path, JSON.pretty_generate(tauri_conf)) +end +``` + +### Fix Tauri project.yml (Duplicate libapp.a) + +```ruby +def fix_tauri_project_yml + project_yml_path = "#{ROOT_DIR}/src-tauri/gen/apple/project.yml" + return unless File.exist?(project_yml_path) + + content = File.read(project_yml_path) + + # Remove "- path: Externals" to prevent duplicate libapp.a + if content.include?("- path: Externals") + content.gsub!(/^\s*- path: Externals\n/, "") + File.write(project_yml_path, content) + sh("cd #{ROOT_DIR}/src-tauri/gen/apple && xcodegen generate") + end +end +``` + +### Tauri iOS Build Lane + +```ruby +lane :beta do + match(type: "appstore", readonly: true) + + update_tauri_config_version + fix_tauri_project_yml + + # Configure signing + update_code_signing_settings( + use_automatic_signing: false, + path: "#{ROOT_DIR}/src-tauri/gen/apple/MyApp.xcodeproj", + team_id: TEAM_ID, + bundle_identifier: APP_IDENTIFIER, + profile_name: "match AppStore #{APP_IDENTIFIER}", + code_sign_identity: "Apple Distribution" + ) + + # Build with Tauri + sh("cd #{ROOT_DIR}/src-tauri && npx tauri ios build --export-method app-store-connect") + + upload_to_testflight( + ipa: "#{ROOT_DIR}/src-tauri/gen/apple/build/arm64/MyApp.ipa", + skip_waiting_for_build_processing: true + ) +end +``` + +### Tauri Android Build Lane + +```ruby +platform :android do + lane :beta do + ENV['ANDROID_HOME'] = "/opt/homebrew/share/android-commandlinetools" + ENV['ANDROID_NDK_HOME'] = "#{ENV['ANDROID_HOME']}/ndk/28.2.13676358" + + # Update version in tauri.conf.json + app_version = get_app_version + build_number = get_next_build_number + + tauri_conf_path = "#{ROOT_DIR}/src-tauri/tauri.conf.json" + tauri_conf = JSON.parse(File.read(tauri_conf_path)) + tauri_conf["version"] = app_version + tauri_conf["bundle"]["android"] ||= {} + tauri_conf["bundle"]["android"]["versionCode"] = build_number + File.write(tauri_conf_path, JSON.pretty_generate(tauri_conf)) + + # Build with Tauri + sh("cd #{ROOT_DIR}/src-tauri && npx tauri android build") + + upload_to_play_store( + track: "beta", + aab: "#{ROOT_DIR}/src-tauri/gen/android/app/build/outputs/bundle/release/app-release.aab" + ) + end +end +``` + +## Essential Commands + +```bash +# Initialize Fastlane +fastlane init + +# List available actions +fastlane actions + +# Run a specific lane +fastlane ios beta +fastlane android release + +# Debug with verbose output +fastlane ios beta --verbose + +# Run Match commands +fastlane match appstore +fastlane match development +fastlane match nuke distribution # Reset all distribution certs +``` + +## Sources + +- [Fastlane Documentation](https://docs.fastlane.tools/) +- [App Store Connect API](https://developer.apple.com/documentation/appstoreconnectapi) +- [Google Play Developer API](https://developers.google.com/android-publisher) +- [Tauri v2 Mobile Guide](https://v2.tauri.app/distribute/) diff --git a/.agents/skills/rust-async-patterns/SKILL.md b/.agents/skills/rust-async-patterns/SKILL.md new file mode 100644 index 0000000..90ab101 --- /dev/null +++ b/.agents/skills/rust-async-patterns/SKILL.md @@ -0,0 +1,519 @@ +--- +name: rust-async-patterns +description: Master Rust async programming with Tokio, async traits, error handling, and concurrent patterns. Use when building async Rust applications, implementing concurrent systems, or debugging async code. +--- + +# Rust Async Patterns + +Production patterns for async Rust programming with Tokio runtime, including tasks, channels, streams, and error handling. + +## When to Use This Skill + +- Building async Rust applications +- Implementing concurrent network services +- Using Tokio for async I/O +- Handling async errors properly +- Debugging async code issues +- Optimizing async performance + +## Core Concepts + +### 1. Async Execution Model + +``` +Future (lazy) → poll() → Ready(value) | Pending + ↑ ↓ + Waker ← Runtime schedules +``` + +### 2. Key Abstractions + +| Concept | Purpose | +| ---------- | ---------------------------------------- | +| `Future` | Lazy computation that may complete later | +| `async fn` | Function returning impl Future | +| `await` | Suspend until future completes | +| `Task` | Spawned future running concurrently | +| `Runtime` | Executor that polls futures | + +## Quick Start + +```toml +# Cargo.toml +[dependencies] +tokio = { version = "1", features = ["full"] } +futures = "0.3" +async-trait = "0.1" +anyhow = "1.0" +tracing = "0.1" +tracing-subscriber = "0.3" +``` + +```rust +use tokio::time::{sleep, Duration}; +use anyhow::Result; + +#[tokio::main] +async fn main() -> Result<()> { + // Initialize tracing + tracing_subscriber::fmt::init(); + + // Async operations + let result = fetch_data("https://api.example.com").await?; + println!("Got: {}", result); + + Ok(()) +} + +async fn fetch_data(url: &str) -> Result { + // Simulated async operation + sleep(Duration::from_millis(100)).await; + Ok(format!("Data from {}", url)) +} +``` + +## Patterns + +### Pattern 1: Concurrent Task Execution + +```rust +use tokio::task::JoinSet; +use anyhow::Result; + +// Spawn multiple concurrent tasks +async fn fetch_all_concurrent(urls: Vec) -> Result> { + let mut set = JoinSet::new(); + + for url in urls { + set.spawn(async move { + fetch_data(&url).await + }); + } + + let mut results = Vec::new(); + while let Some(res) = set.join_next().await { + match res { + Ok(Ok(data)) => results.push(data), + Ok(Err(e)) => tracing::error!("Task failed: {}", e), + Err(e) => tracing::error!("Join error: {}", e), + } + } + + Ok(results) +} + +// With concurrency limit +use futures::stream::{self, StreamExt}; + +async fn fetch_with_limit(urls: Vec, limit: usize) -> Vec> { + stream::iter(urls) + .map(|url| async move { fetch_data(&url).await }) + .buffer_unordered(limit) // Max concurrent tasks + .collect() + .await +} + +// Select first to complete +use tokio::select; + +async fn race_requests(url1: &str, url2: &str) -> Result { + select! { + result = fetch_data(url1) => result, + result = fetch_data(url2) => result, + } +} +``` + +### Pattern 2: Channels for Communication + +```rust +use tokio::sync::{mpsc, broadcast, oneshot, watch}; + +// Multi-producer, single-consumer +async fn mpsc_example() { + let (tx, mut rx) = mpsc::channel::(100); + + // Spawn producer + let tx2 = tx.clone(); + tokio::spawn(async move { + tx2.send("Hello".to_string()).await.unwrap(); + }); + + // Consume + while let Some(msg) = rx.recv().await { + println!("Got: {}", msg); + } +} + +// Broadcast: multi-producer, multi-consumer +async fn broadcast_example() { + let (tx, _) = broadcast::channel::(100); + + let mut rx1 = tx.subscribe(); + let mut rx2 = tx.subscribe(); + + tx.send("Event".to_string()).unwrap(); + + // Both receivers get the message + let _ = rx1.recv().await; + let _ = rx2.recv().await; +} + +// Oneshot: single value, single use +async fn oneshot_example() -> String { + let (tx, rx) = oneshot::channel::(); + + tokio::spawn(async move { + tx.send("Result".to_string()).unwrap(); + }); + + rx.await.unwrap() +} + +// Watch: single producer, multi-consumer, latest value +async fn watch_example() { + let (tx, mut rx) = watch::channel("initial".to_string()); + + tokio::spawn(async move { + loop { + // Wait for changes + rx.changed().await.unwrap(); + println!("New value: {}", *rx.borrow()); + } + }); + + tx.send("updated".to_string()).unwrap(); +} +``` + +### Pattern 3: Async Error Handling + +```rust +use anyhow::{Context, Result, bail}; +use thiserror::Error; + +#[derive(Error, Debug)] +pub enum ServiceError { + #[error("Network error: {0}")] + Network(#[from] reqwest::Error), + + #[error("Database error: {0}")] + Database(#[from] sqlx::Error), + + #[error("Not found: {0}")] + NotFound(String), + + #[error("Timeout after {0:?}")] + Timeout(std::time::Duration), +} + +// Using anyhow for application errors +async fn process_request(id: &str) -> Result { + let data = fetch_data(id) + .await + .context("Failed to fetch data")?; + + let parsed = parse_response(&data) + .context("Failed to parse response")?; + + Ok(parsed) +} + +// Using custom errors for library code +async fn get_user(id: &str) -> Result { + let result = db.query(id).await?; + + match result { + Some(user) => Ok(user), + None => Err(ServiceError::NotFound(id.to_string())), + } +} + +// Timeout wrapper +use tokio::time::timeout; + +async fn with_timeout(duration: Duration, future: F) -> Result +where + F: std::future::Future>, +{ + timeout(duration, future) + .await + .map_err(|_| ServiceError::Timeout(duration))? +} +``` + +### Pattern 4: Graceful Shutdown + +```rust +use tokio::signal; +use tokio::sync::broadcast; +use tokio_util::sync::CancellationToken; + +async fn run_server() -> Result<()> { + // Method 1: CancellationToken + let token = CancellationToken::new(); + let token_clone = token.clone(); + + // Spawn task that respects cancellation + tokio::spawn(async move { + loop { + tokio::select! { + _ = token_clone.cancelled() => { + tracing::info!("Task shutting down"); + break; + } + _ = do_work() => {} + } + } + }); + + // Wait for shutdown signal + signal::ctrl_c().await?; + tracing::info!("Shutdown signal received"); + + // Cancel all tasks + token.cancel(); + + // Give tasks time to cleanup + tokio::time::sleep(Duration::from_secs(5)).await; + + Ok(()) +} + +// Method 2: Broadcast channel for shutdown +async fn run_with_broadcast() -> Result<()> { + let (shutdown_tx, _) = broadcast::channel::<()>(1); + + let mut rx = shutdown_tx.subscribe(); + tokio::spawn(async move { + tokio::select! { + _ = rx.recv() => { + tracing::info!("Received shutdown"); + } + _ = async { loop { do_work().await } } => {} + } + }); + + signal::ctrl_c().await?; + let _ = shutdown_tx.send(()); + + Ok(()) +} +``` + +### Pattern 5: Async Traits + +```rust +use async_trait::async_trait; + +#[async_trait] +pub trait Repository { + async fn get(&self, id: &str) -> Result; + async fn save(&self, entity: &Entity) -> Result<()>; + async fn delete(&self, id: &str) -> Result<()>; +} + +pub struct PostgresRepository { + pool: sqlx::PgPool, +} + +#[async_trait] +impl Repository for PostgresRepository { + async fn get(&self, id: &str) -> Result { + sqlx::query_as!(Entity, "SELECT * FROM entities WHERE id = $1", id) + .fetch_one(&self.pool) + .await + .map_err(Into::into) + } + + async fn save(&self, entity: &Entity) -> Result<()> { + sqlx::query!( + "INSERT INTO entities (id, data) VALUES ($1, $2) + ON CONFLICT (id) DO UPDATE SET data = $2", + entity.id, + entity.data + ) + .execute(&self.pool) + .await?; + Ok(()) + } + + async fn delete(&self, id: &str) -> Result<()> { + sqlx::query!("DELETE FROM entities WHERE id = $1", id) + .execute(&self.pool) + .await?; + Ok(()) + } +} + +// Trait object usage +async fn process(repo: &dyn Repository, id: &str) -> Result<()> { + let entity = repo.get(id).await?; + // Process... + repo.save(&entity).await +} +``` + +### Pattern 6: Streams and Async Iteration + +```rust +use futures::stream::{self, Stream, StreamExt}; +use async_stream::stream; + +// Create stream from async iterator +fn numbers_stream() -> impl Stream { + stream! { + for i in 0..10 { + tokio::time::sleep(Duration::from_millis(100)).await; + yield i; + } + } +} + +// Process stream +async fn process_stream() { + let stream = numbers_stream(); + + // Map and filter + let processed: Vec<_> = stream + .filter(|n| futures::future::ready(*n % 2 == 0)) + .map(|n| n * 2) + .collect() + .await; + + println!("{:?}", processed); +} + +// Chunked processing +async fn process_in_chunks() { + let stream = numbers_stream(); + + let mut chunks = stream.chunks(3); + + while let Some(chunk) = chunks.next().await { + println!("Processing chunk: {:?}", chunk); + } +} + +// Merge multiple streams +async fn merge_streams() { + let stream1 = numbers_stream(); + let stream2 = numbers_stream(); + + let merged = stream::select(stream1, stream2); + + merged + .for_each(|n| async move { + println!("Got: {}", n); + }) + .await; +} +``` + +### Pattern 7: Resource Management + +```rust +use std::sync::Arc; +use tokio::sync::{Mutex, RwLock, Semaphore}; + +// Shared state with RwLock (prefer for read-heavy) +struct Cache { + data: RwLock>, +} + +impl Cache { + async fn get(&self, key: &str) -> Option { + self.data.read().await.get(key).cloned() + } + + async fn set(&self, key: String, value: String) { + self.data.write().await.insert(key, value); + } +} + +// Connection pool with semaphore +struct Pool { + semaphore: Semaphore, + connections: Mutex>, +} + +impl Pool { + fn new(size: usize) -> Self { + Self { + semaphore: Semaphore::new(size), + connections: Mutex::new((0..size).map(|_| Connection::new()).collect()), + } + } + + async fn acquire(&self) -> PooledConnection<'_> { + let permit = self.semaphore.acquire().await.unwrap(); + let conn = self.connections.lock().await.pop().unwrap(); + PooledConnection { pool: self, conn: Some(conn), _permit: permit } + } +} + +struct PooledConnection<'a> { + pool: &'a Pool, + conn: Option, + _permit: tokio::sync::SemaphorePermit<'a>, +} + +impl Drop for PooledConnection<'_> { + fn drop(&mut self) { + if let Some(conn) = self.conn.take() { + let pool = self.pool; + tokio::spawn(async move { + pool.connections.lock().await.push(conn); + }); + } + } +} +``` + +## Debugging Tips + +```rust +// Enable tokio-console for runtime debugging +// Cargo.toml: tokio = { features = ["tracing"] } +// Run: RUSTFLAGS="--cfg tokio_unstable" cargo run +// Then: tokio-console + +// Instrument async functions +use tracing::instrument; + +#[instrument(skip(pool))] +async fn fetch_user(pool: &PgPool, id: &str) -> Result { + tracing::debug!("Fetching user"); + // ... +} + +// Track task spawning +let span = tracing::info_span!("worker", id = %worker_id); +tokio::spawn(async move { + // Enters span when polled +}.instrument(span)); +``` + +## Best Practices + +### Do's + +- **Use `tokio::select!`** - For racing futures +- **Prefer channels** - Over shared state when possible +- **Use `JoinSet`** - For managing multiple tasks +- **Instrument with tracing** - For debugging async code +- **Handle cancellation** - Check `CancellationToken` + +### Don'ts + +- **Don't block** - Never use `std::thread::sleep` in async +- **Don't hold locks across awaits** - Causes deadlocks +- **Don't spawn unboundedly** - Use semaphores for limits +- **Don't ignore errors** - Propagate with `?` or log +- **Don't forget Send bounds** - For spawned futures + +## Resources + +- [Tokio Tutorial](https://tokio.rs/tokio/tutorial) +- [Async Book](https://rust-lang.github.io/async-book/) +- [Tokio Console](https://github.com/tokio-rs/console) diff --git a/.agents/skills/rust-skills/CLAUDE.md b/.agents/skills/rust-skills/CLAUDE.md new file mode 100644 index 0000000..c0f008d --- /dev/null +++ b/.agents/skills/rust-skills/CLAUDE.md @@ -0,0 +1,335 @@ +--- +name: rust-skills +description: > + Comprehensive Rust coding guidelines with 179 rules across 14 categories. + Use when writing, reviewing, or refactoring Rust code. Covers ownership, + error handling, async patterns, API design, memory optimization, performance, + testing, and common anti-patterns. Invoke with /rust-skills. +license: MIT +metadata: + author: leonardomso + version: "1.0.0" + sources: + - Rust API Guidelines + - Rust Performance Book + - ripgrep, tokio, serde, polars codebases +--- + +# Rust Best Practices + +Comprehensive guide for writing high-quality, idiomatic, and highly optimized Rust code. Contains 179 rules across 14 categories, prioritized by impact to guide LLMs in code generation and refactoring. + +## When to Apply + +Reference these guidelines when: +- Writing new Rust functions, structs, or modules +- Implementing error handling or async code +- Designing public APIs for libraries +- Reviewing code for ownership/borrowing issues +- Optimizing memory usage or reducing allocations +- Tuning performance for hot paths +- Refactoring existing Rust code + +## Rule Categories by Priority + +| Priority | Category | Impact | Prefix | Rules | +|----------|----------|--------|--------|-------| +| 1 | Ownership & Borrowing | CRITICAL | `own-` | 12 | +| 2 | Error Handling | CRITICAL | `err-` | 12 | +| 3 | Memory Optimization | CRITICAL | `mem-` | 15 | +| 4 | API Design | HIGH | `api-` | 15 | +| 5 | Async/Await | HIGH | `async-` | 15 | +| 6 | Compiler Optimization | HIGH | `opt-` | 12 | +| 7 | Naming Conventions | MEDIUM | `name-` | 16 | +| 8 | Type Safety | MEDIUM | `type-` | 10 | +| 9 | Testing | MEDIUM | `test-` | 13 | +| 10 | Documentation | MEDIUM | `doc-` | 11 | +| 11 | Performance Patterns | MEDIUM | `perf-` | 11 | +| 12 | Project Structure | LOW | `proj-` | 11 | +| 13 | Clippy & Linting | LOW | `lint-` | 11 | +| 14 | Anti-patterns | REFERENCE | `anti-` | 15 | + +--- + +## Quick Reference + +### 1. Ownership & Borrowing (CRITICAL) + +- [`own-borrow-over-clone`](rules/own-borrow-over-clone.md) - Prefer `&T` borrowing over `.clone()` +- [`own-slice-over-vec`](rules/own-slice-over-vec.md) - Accept `&[T]` not `&Vec`, `&str` not `&String` +- [`own-cow-conditional`](rules/own-cow-conditional.md) - Use `Cow<'a, T>` for conditional ownership +- [`own-arc-shared`](rules/own-arc-shared.md) - Use `Arc` for thread-safe shared ownership +- [`own-rc-single-thread`](rules/own-rc-single-thread.md) - Use `Rc` for single-threaded sharing +- [`own-refcell-interior`](rules/own-refcell-interior.md) - Use `RefCell` for interior mutability (single-thread) +- [`own-mutex-interior`](rules/own-mutex-interior.md) - Use `Mutex` for interior mutability (multi-thread) +- [`own-rwlock-readers`](rules/own-rwlock-readers.md) - Use `RwLock` when reads dominate writes +- [`own-copy-small`](rules/own-copy-small.md) - Derive `Copy` for small, trivial types +- [`own-clone-explicit`](rules/own-clone-explicit.md) - Make `Clone` explicit, avoid implicit copies +- [`own-move-large`](rules/own-move-large.md) - Move large data instead of cloning +- [`own-lifetime-elision`](rules/own-lifetime-elision.md) - Rely on lifetime elision when possible + +### 2. Error Handling (CRITICAL) + +- [`err-thiserror-lib`](rules/err-thiserror-lib.md) - Use `thiserror` for library error types +- [`err-anyhow-app`](rules/err-anyhow-app.md) - Use `anyhow` for application error handling +- [`err-result-over-panic`](rules/err-result-over-panic.md) - Return `Result`, don't panic on expected errors +- [`err-context-chain`](rules/err-context-chain.md) - Add context with `.context()` or `.with_context()` +- [`err-no-unwrap-prod`](rules/err-no-unwrap-prod.md) - Never use `.unwrap()` in production code +- [`err-expect-bugs-only`](rules/err-expect-bugs-only.md) - Use `.expect()` only for programming errors +- [`err-question-mark`](rules/err-question-mark.md) - Use `?` operator for clean propagation +- [`err-from-impl`](rules/err-from-impl.md) - Use `#[from]` for automatic error conversion +- [`err-source-chain`](rules/err-source-chain.md) - Use `#[source]` to chain underlying errors +- [`err-lowercase-msg`](rules/err-lowercase-msg.md) - Error messages: lowercase, no trailing punctuation +- [`err-doc-errors`](rules/err-doc-errors.md) - Document errors with `# Errors` section +- [`err-custom-type`](rules/err-custom-type.md) - Create custom error types, not `Box` + +### 3. Memory Optimization (CRITICAL) + +- [`mem-with-capacity`](rules/mem-with-capacity.md) - Use `with_capacity()` when size is known +- [`mem-smallvec`](rules/mem-smallvec.md) - Use `SmallVec` for usually-small collections +- [`mem-arrayvec`](rules/mem-arrayvec.md) - Use `ArrayVec` for bounded-size collections +- [`mem-box-large-variant`](rules/mem-box-large-variant.md) - Box large enum variants to reduce type size +- [`mem-boxed-slice`](rules/mem-boxed-slice.md) - Use `Box<[T]>` instead of `Vec` when fixed +- [`mem-thinvec`](rules/mem-thinvec.md) - Use `ThinVec` for often-empty vectors +- [`mem-clone-from`](rules/mem-clone-from.md) - Use `clone_from()` to reuse allocations +- [`mem-reuse-collections`](rules/mem-reuse-collections.md) - Reuse collections with `clear()` in loops +- [`mem-avoid-format`](rules/mem-avoid-format.md) - Avoid `format!()` when string literals work +- [`mem-write-over-format`](rules/mem-write-over-format.md) - Use `write!()` instead of `format!()` +- [`mem-arena-allocator`](rules/mem-arena-allocator.md) - Use arena allocators for batch allocations +- [`mem-zero-copy`](rules/mem-zero-copy.md) - Use zero-copy patterns with slices and `Bytes` +- [`mem-compact-string`](rules/mem-compact-string.md) - Use `CompactString` for small string optimization +- [`mem-smaller-integers`](rules/mem-smaller-integers.md) - Use smallest integer type that fits +- [`mem-assert-type-size`](rules/mem-assert-type-size.md) - Assert hot type sizes to prevent regressions + +### 4. API Design (HIGH) + +- [`api-builder-pattern`](rules/api-builder-pattern.md) - Use Builder pattern for complex construction +- [`api-builder-must-use`](rules/api-builder-must-use.md) - Add `#[must_use]` to builder types +- [`api-newtype-safety`](rules/api-newtype-safety.md) - Use newtypes for type-safe distinctions +- [`api-typestate`](rules/api-typestate.md) - Use typestate for compile-time state machines +- [`api-sealed-trait`](rules/api-sealed-trait.md) - Seal traits to prevent external implementations +- [`api-extension-trait`](rules/api-extension-trait.md) - Use extension traits to add methods to foreign types +- [`api-parse-dont-validate`](rules/api-parse-dont-validate.md) - Parse into validated types at boundaries +- [`api-impl-into`](rules/api-impl-into.md) - Accept `impl Into` for flexible string inputs +- [`api-impl-asref`](rules/api-impl-asref.md) - Accept `impl AsRef` for borrowed inputs +- [`api-must-use`](rules/api-must-use.md) - Add `#[must_use]` to `Result` returning functions +- [`api-non-exhaustive`](rules/api-non-exhaustive.md) - Use `#[non_exhaustive]` for future-proof enums/structs +- [`api-from-not-into`](rules/api-from-not-into.md) - Implement `From`, not `Into` (auto-derived) +- [`api-default-impl`](rules/api-default-impl.md) - Implement `Default` for sensible defaults +- [`api-common-traits`](rules/api-common-traits.md) - Implement `Debug`, `Clone`, `PartialEq` eagerly +- [`api-serde-optional`](rules/api-serde-optional.md) - Gate `Serialize`/`Deserialize` behind feature flag + +### 5. Async/Await (HIGH) + +- [`async-tokio-runtime`](rules/async-tokio-runtime.md) - Use Tokio for production async runtime +- [`async-no-lock-await`](rules/async-no-lock-await.md) - Never hold `Mutex`/`RwLock` across `.await` +- [`async-spawn-blocking`](rules/async-spawn-blocking.md) - Use `spawn_blocking` for CPU-intensive work +- [`async-tokio-fs`](rules/async-tokio-fs.md) - Use `tokio::fs` not `std::fs` in async code +- [`async-cancellation-token`](rules/async-cancellation-token.md) - Use `CancellationToken` for graceful shutdown +- [`async-join-parallel`](rules/async-join-parallel.md) - Use `tokio::join!` for parallel operations +- [`async-try-join`](rules/async-try-join.md) - Use `tokio::try_join!` for fallible parallel ops +- [`async-select-racing`](rules/async-select-racing.md) - Use `tokio::select!` for racing/timeouts +- [`async-bounded-channel`](rules/async-bounded-channel.md) - Use bounded channels for backpressure +- [`async-mpsc-queue`](rules/async-mpsc-queue.md) - Use `mpsc` for work queues +- [`async-broadcast-pubsub`](rules/async-broadcast-pubsub.md) - Use `broadcast` for pub/sub patterns +- [`async-watch-latest`](rules/async-watch-latest.md) - Use `watch` for latest-value sharing +- [`async-oneshot-response`](rules/async-oneshot-response.md) - Use `oneshot` for request/response +- [`async-joinset-structured`](rules/async-joinset-structured.md) - Use `JoinSet` for dynamic task groups +- [`async-clone-before-await`](rules/async-clone-before-await.md) - Clone data before await, release locks + +### 6. Compiler Optimization (HIGH) + +- [`opt-inline-small`](rules/opt-inline-small.md) - Use `#[inline]` for small hot functions +- [`opt-inline-always-rare`](rules/opt-inline-always-rare.md) - Use `#[inline(always)]` sparingly +- [`opt-inline-never-cold`](rules/opt-inline-never-cold.md) - Use `#[inline(never)]` for cold paths +- [`opt-cold-unlikely`](rules/opt-cold-unlikely.md) - Use `#[cold]` for error/unlikely paths +- [`opt-likely-hint`](rules/opt-likely-hint.md) - Use `likely()`/`unlikely()` for branch hints +- [`opt-lto-release`](rules/opt-lto-release.md) - Enable LTO in release builds +- [`opt-codegen-units`](rules/opt-codegen-units.md) - Use `codegen-units = 1` for max optimization +- [`opt-pgo-profile`](rules/opt-pgo-profile.md) - Use PGO for production builds +- [`opt-target-cpu`](rules/opt-target-cpu.md) - Set `target-cpu=native` for local builds +- [`opt-bounds-check`](rules/opt-bounds-check.md) - Use iterators to avoid bounds checks +- [`opt-simd-portable`](rules/opt-simd-portable.md) - Use portable SIMD for data-parallel ops +- [`opt-cache-friendly`](rules/opt-cache-friendly.md) - Design cache-friendly data layouts (SoA) + +### 7. Naming Conventions (MEDIUM) + +- [`name-types-camel`](rules/name-types-camel.md) - Use `UpperCamelCase` for types, traits, enums +- [`name-variants-camel`](rules/name-variants-camel.md) - Use `UpperCamelCase` for enum variants +- [`name-funcs-snake`](rules/name-funcs-snake.md) - Use `snake_case` for functions, methods, modules +- [`name-consts-screaming`](rules/name-consts-screaming.md) - Use `SCREAMING_SNAKE_CASE` for constants/statics +- [`name-lifetime-short`](rules/name-lifetime-short.md) - Use short lowercase lifetimes: `'a`, `'de`, `'src` +- [`name-type-param-single`](rules/name-type-param-single.md) - Use single uppercase for type params: `T`, `E`, `K`, `V` +- [`name-as-free`](rules/name-as-free.md) - `as_` prefix: free reference conversion +- [`name-to-expensive`](rules/name-to-expensive.md) - `to_` prefix: expensive conversion +- [`name-into-ownership`](rules/name-into-ownership.md) - `into_` prefix: ownership transfer +- [`name-no-get-prefix`](rules/name-no-get-prefix.md) - No `get_` prefix for simple getters +- [`name-is-has-bool`](rules/name-is-has-bool.md) - Use `is_`, `has_`, `can_` for boolean methods +- [`name-iter-convention`](rules/name-iter-convention.md) - Use `iter`/`iter_mut`/`into_iter` for iterators +- [`name-iter-method`](rules/name-iter-method.md) - Name iterator methods consistently +- [`name-iter-type-match`](rules/name-iter-type-match.md) - Iterator type names match method +- [`name-acronym-word`](rules/name-acronym-word.md) - Treat acronyms as words: `Uuid` not `UUID` +- [`name-crate-no-rs`](rules/name-crate-no-rs.md) - Crate names: no `-rs` suffix + +### 8. Type Safety (MEDIUM) + +- [`type-newtype-ids`](rules/type-newtype-ids.md) - Wrap IDs in newtypes: `UserId(u64)` +- [`type-newtype-validated`](rules/type-newtype-validated.md) - Newtypes for validated data: `Email`, `Url` +- [`type-enum-states`](rules/type-enum-states.md) - Use enums for mutually exclusive states +- [`type-option-nullable`](rules/type-option-nullable.md) - Use `Option` for nullable values +- [`type-result-fallible`](rules/type-result-fallible.md) - Use `Result` for fallible operations +- [`type-phantom-marker`](rules/type-phantom-marker.md) - Use `PhantomData` for type-level markers +- [`type-never-diverge`](rules/type-never-diverge.md) - Use `!` type for functions that never return +- [`type-generic-bounds`](rules/type-generic-bounds.md) - Add trait bounds only where needed +- [`type-no-stringly`](rules/type-no-stringly.md) - Avoid stringly-typed APIs, use enums/newtypes +- [`type-repr-transparent`](rules/type-repr-transparent.md) - Use `#[repr(transparent)]` for FFI newtypes + +### 9. Testing (MEDIUM) + +- [`test-cfg-test-module`](rules/test-cfg-test-module.md) - Use `#[cfg(test)] mod tests { }` +- [`test-use-super`](rules/test-use-super.md) - Use `use super::*;` in test modules +- [`test-integration-dir`](rules/test-integration-dir.md) - Put integration tests in `tests/` directory +- [`test-descriptive-names`](rules/test-descriptive-names.md) - Use descriptive test names +- [`test-arrange-act-assert`](rules/test-arrange-act-assert.md) - Structure tests as arrange/act/assert +- [`test-proptest-properties`](rules/test-proptest-properties.md) - Use `proptest` for property-based testing +- [`test-mockall-mocking`](rules/test-mockall-mocking.md) - Use `mockall` for trait mocking +- [`test-mock-traits`](rules/test-mock-traits.md) - Use traits for dependencies to enable mocking +- [`test-fixture-raii`](rules/test-fixture-raii.md) - Use RAII pattern (Drop) for test cleanup +- [`test-tokio-async`](rules/test-tokio-async.md) - Use `#[tokio::test]` for async tests +- [`test-should-panic`](rules/test-should-panic.md) - Use `#[should_panic]` for panic tests +- [`test-criterion-bench`](rules/test-criterion-bench.md) - Use `criterion` for benchmarking +- [`test-doctest-examples`](rules/test-doctest-examples.md) - Keep doc examples as executable tests + +### 10. Documentation (MEDIUM) + +- [`doc-all-public`](rules/doc-all-public.md) - Document all public items with `///` +- [`doc-module-inner`](rules/doc-module-inner.md) - Use `//!` for module-level documentation +- [`doc-examples-section`](rules/doc-examples-section.md) - Include `# Examples` with runnable code +- [`doc-errors-section`](rules/doc-errors-section.md) - Include `# Errors` for fallible functions +- [`doc-panics-section`](rules/doc-panics-section.md) - Include `# Panics` for panicking functions +- [`doc-safety-section`](rules/doc-safety-section.md) - Include `# Safety` for unsafe functions +- [`doc-question-mark`](rules/doc-question-mark.md) - Use `?` in examples, not `.unwrap()` +- [`doc-hidden-setup`](rules/doc-hidden-setup.md) - Use `# ` prefix to hide example setup code +- [`doc-intra-links`](rules/doc-intra-links.md) - Use intra-doc links: `[Vec]` +- [`doc-link-types`](rules/doc-link-types.md) - Link related types and functions in docs +- [`doc-cargo-metadata`](rules/doc-cargo-metadata.md) - Fill `Cargo.toml` metadata + +### 11. Performance Patterns (MEDIUM) + +- [`perf-iter-over-index`](rules/perf-iter-over-index.md) - Prefer iterators over manual indexing +- [`perf-iter-lazy`](rules/perf-iter-lazy.md) - Keep iterators lazy, collect() only when needed +- [`perf-collect-once`](rules/perf-collect-once.md) - Don't `collect()` intermediate iterators +- [`perf-entry-api`](rules/perf-entry-api.md) - Use `entry()` API for map insert-or-update +- [`perf-drain-reuse`](rules/perf-drain-reuse.md) - Use `drain()` to reuse allocations +- [`perf-extend-batch`](rules/perf-extend-batch.md) - Use `extend()` for batch insertions +- [`perf-chain-avoid`](rules/perf-chain-avoid.md) - Avoid `chain()` in hot loops +- [`perf-collect-into`](rules/perf-collect-into.md) - Use `collect_into()` for reusing containers +- [`perf-black-box-bench`](rules/perf-black-box-bench.md) - Use `black_box()` in benchmarks +- [`perf-release-profile`](rules/perf-release-profile.md) - Optimize release profile settings +- [`perf-profile-first`](rules/perf-profile-first.md) - Profile before optimizing + +### 12. Project Structure (LOW) + +- [`proj-lib-main-split`](rules/proj-lib-main-split.md) - Keep `main.rs` minimal, logic in `lib.rs` +- [`proj-mod-by-feature`](rules/proj-mod-by-feature.md) - Organize modules by feature, not type +- [`proj-flat-small`](rules/proj-flat-small.md) - Keep small projects flat +- [`proj-mod-rs-dir`](rules/proj-mod-rs-dir.md) - Use `mod.rs` for multi-file modules +- [`proj-pub-crate-internal`](rules/proj-pub-crate-internal.md) - Use `pub(crate)` for internal APIs +- [`proj-pub-super-parent`](rules/proj-pub-super-parent.md) - Use `pub(super)` for parent-only visibility +- [`proj-pub-use-reexport`](rules/proj-pub-use-reexport.md) - Use `pub use` for clean public API +- [`proj-prelude-module`](rules/proj-prelude-module.md) - Create `prelude` module for common imports +- [`proj-bin-dir`](rules/proj-bin-dir.md) - Put multiple binaries in `src/bin/` +- [`proj-workspace-large`](rules/proj-workspace-large.md) - Use workspaces for large projects +- [`proj-workspace-deps`](rules/proj-workspace-deps.md) - Use workspace dependency inheritance + +### 13. Clippy & Linting (LOW) + +- [`lint-deny-correctness`](rules/lint-deny-correctness.md) - `#![deny(clippy::correctness)]` +- [`lint-warn-suspicious`](rules/lint-warn-suspicious.md) - `#![warn(clippy::suspicious)]` +- [`lint-warn-style`](rules/lint-warn-style.md) - `#![warn(clippy::style)]` +- [`lint-warn-complexity`](rules/lint-warn-complexity.md) - `#![warn(clippy::complexity)]` +- [`lint-warn-perf`](rules/lint-warn-perf.md) - `#![warn(clippy::perf)]` +- [`lint-pedantic-selective`](rules/lint-pedantic-selective.md) - Enable `clippy::pedantic` selectively +- [`lint-missing-docs`](rules/lint-missing-docs.md) - `#![warn(missing_docs)]` +- [`lint-unsafe-doc`](rules/lint-unsafe-doc.md) - `#![warn(clippy::undocumented_unsafe_blocks)]` +- [`lint-cargo-metadata`](rules/lint-cargo-metadata.md) - `#![warn(clippy::cargo)]` for published crates +- [`lint-rustfmt-check`](rules/lint-rustfmt-check.md) - Run `cargo fmt --check` in CI +- [`lint-workspace-lints`](rules/lint-workspace-lints.md) - Configure lints at workspace level + +### 14. Anti-patterns (REFERENCE) + +- [`anti-unwrap-abuse`](rules/anti-unwrap-abuse.md) - Don't use `.unwrap()` in production code +- [`anti-expect-lazy`](rules/anti-expect-lazy.md) - Don't use `.expect()` for recoverable errors +- [`anti-clone-excessive`](rules/anti-clone-excessive.md) - Don't clone when borrowing works +- [`anti-lock-across-await`](rules/anti-lock-across-await.md) - Don't hold locks across `.await` +- [`anti-string-for-str`](rules/anti-string-for-str.md) - Don't accept `&String` when `&str` works +- [`anti-vec-for-slice`](rules/anti-vec-for-slice.md) - Don't accept `&Vec` when `&[T]` works +- [`anti-index-over-iter`](rules/anti-index-over-iter.md) - Don't use indexing when iterators work +- [`anti-panic-expected`](rules/anti-panic-expected.md) - Don't panic on expected/recoverable errors +- [`anti-empty-catch`](rules/anti-empty-catch.md) - Don't use empty `if let Err(_) = ...` blocks +- [`anti-over-abstraction`](rules/anti-over-abstraction.md) - Don't over-abstract with excessive generics +- [`anti-premature-optimize`](rules/anti-premature-optimize.md) - Don't optimize before profiling +- [`anti-type-erasure`](rules/anti-type-erasure.md) - Don't use `Box` when `impl Trait` works +- [`anti-format-hot-path`](rules/anti-format-hot-path.md) - Don't use `format!()` in hot paths +- [`anti-collect-intermediate`](rules/anti-collect-intermediate.md) - Don't `collect()` intermediate iterators +- [`anti-stringly-typed`](rules/anti-stringly-typed.md) - Don't use strings for structured data + +--- + +## Recommended Cargo.toml Settings + +```toml +[profile.release] +opt-level = 3 +lto = "fat" +codegen-units = 1 +panic = "abort" +strip = true + +[profile.bench] +inherits = "release" +debug = true +strip = false + +[profile.dev] +opt-level = 0 +debug = true + +[profile.dev.package."*"] +opt-level = 3 # Optimize dependencies in dev +``` + +--- + +## How to Use + +This skill provides rule identifiers for quick reference. When generating or reviewing Rust code: + +1. **Check relevant category** based on task type +2. **Apply rules** with matching prefix +3. **Prioritize** CRITICAL > HIGH > MEDIUM > LOW +4. **Read rule files** in `rules/` for detailed examples + +### Rule Application by Task + +| Task | Primary Categories | +|------|-------------------| +| New function | `own-`, `err-`, `name-` | +| New struct/API | `api-`, `type-`, `doc-` | +| Async code | `async-`, `own-` | +| Error handling | `err-`, `api-` | +| Memory optimization | `mem-`, `own-`, `perf-` | +| Performance tuning | `opt-`, `mem-`, `perf-` | +| Code review | `anti-`, `lint-` | + +--- + +## Sources + +This skill synthesizes best practices from: +- [Rust API Guidelines](https://rust-lang.github.io/api-guidelines/) +- [Rust Performance Book](https://nnethercote.github.io/perf-book/) +- [Rust Design Patterns](https://rust-unofficial.github.io/patterns/) +- Production codebases: ripgrep, tokio, serde, polars, axum, deno +- Clippy lint documentation +- Community conventions (2024-2025) diff --git a/.agents/skills/rust-skills/LICENSE b/.agents/skills/rust-skills/LICENSE new file mode 100644 index 0000000..3f27070 --- /dev/null +++ b/.agents/skills/rust-skills/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 Leonardo Maldonado + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/.agents/skills/rust-skills/SKILL.md b/.agents/skills/rust-skills/SKILL.md new file mode 100644 index 0000000..c0f008d --- /dev/null +++ b/.agents/skills/rust-skills/SKILL.md @@ -0,0 +1,335 @@ +--- +name: rust-skills +description: > + Comprehensive Rust coding guidelines with 179 rules across 14 categories. + Use when writing, reviewing, or refactoring Rust code. Covers ownership, + error handling, async patterns, API design, memory optimization, performance, + testing, and common anti-patterns. Invoke with /rust-skills. +license: MIT +metadata: + author: leonardomso + version: "1.0.0" + sources: + - Rust API Guidelines + - Rust Performance Book + - ripgrep, tokio, serde, polars codebases +--- + +# Rust Best Practices + +Comprehensive guide for writing high-quality, idiomatic, and highly optimized Rust code. Contains 179 rules across 14 categories, prioritized by impact to guide LLMs in code generation and refactoring. + +## When to Apply + +Reference these guidelines when: +- Writing new Rust functions, structs, or modules +- Implementing error handling or async code +- Designing public APIs for libraries +- Reviewing code for ownership/borrowing issues +- Optimizing memory usage or reducing allocations +- Tuning performance for hot paths +- Refactoring existing Rust code + +## Rule Categories by Priority + +| Priority | Category | Impact | Prefix | Rules | +|----------|----------|--------|--------|-------| +| 1 | Ownership & Borrowing | CRITICAL | `own-` | 12 | +| 2 | Error Handling | CRITICAL | `err-` | 12 | +| 3 | Memory Optimization | CRITICAL | `mem-` | 15 | +| 4 | API Design | HIGH | `api-` | 15 | +| 5 | Async/Await | HIGH | `async-` | 15 | +| 6 | Compiler Optimization | HIGH | `opt-` | 12 | +| 7 | Naming Conventions | MEDIUM | `name-` | 16 | +| 8 | Type Safety | MEDIUM | `type-` | 10 | +| 9 | Testing | MEDIUM | `test-` | 13 | +| 10 | Documentation | MEDIUM | `doc-` | 11 | +| 11 | Performance Patterns | MEDIUM | `perf-` | 11 | +| 12 | Project Structure | LOW | `proj-` | 11 | +| 13 | Clippy & Linting | LOW | `lint-` | 11 | +| 14 | Anti-patterns | REFERENCE | `anti-` | 15 | + +--- + +## Quick Reference + +### 1. Ownership & Borrowing (CRITICAL) + +- [`own-borrow-over-clone`](rules/own-borrow-over-clone.md) - Prefer `&T` borrowing over `.clone()` +- [`own-slice-over-vec`](rules/own-slice-over-vec.md) - Accept `&[T]` not `&Vec`, `&str` not `&String` +- [`own-cow-conditional`](rules/own-cow-conditional.md) - Use `Cow<'a, T>` for conditional ownership +- [`own-arc-shared`](rules/own-arc-shared.md) - Use `Arc` for thread-safe shared ownership +- [`own-rc-single-thread`](rules/own-rc-single-thread.md) - Use `Rc` for single-threaded sharing +- [`own-refcell-interior`](rules/own-refcell-interior.md) - Use `RefCell` for interior mutability (single-thread) +- [`own-mutex-interior`](rules/own-mutex-interior.md) - Use `Mutex` for interior mutability (multi-thread) +- [`own-rwlock-readers`](rules/own-rwlock-readers.md) - Use `RwLock` when reads dominate writes +- [`own-copy-small`](rules/own-copy-small.md) - Derive `Copy` for small, trivial types +- [`own-clone-explicit`](rules/own-clone-explicit.md) - Make `Clone` explicit, avoid implicit copies +- [`own-move-large`](rules/own-move-large.md) - Move large data instead of cloning +- [`own-lifetime-elision`](rules/own-lifetime-elision.md) - Rely on lifetime elision when possible + +### 2. Error Handling (CRITICAL) + +- [`err-thiserror-lib`](rules/err-thiserror-lib.md) - Use `thiserror` for library error types +- [`err-anyhow-app`](rules/err-anyhow-app.md) - Use `anyhow` for application error handling +- [`err-result-over-panic`](rules/err-result-over-panic.md) - Return `Result`, don't panic on expected errors +- [`err-context-chain`](rules/err-context-chain.md) - Add context with `.context()` or `.with_context()` +- [`err-no-unwrap-prod`](rules/err-no-unwrap-prod.md) - Never use `.unwrap()` in production code +- [`err-expect-bugs-only`](rules/err-expect-bugs-only.md) - Use `.expect()` only for programming errors +- [`err-question-mark`](rules/err-question-mark.md) - Use `?` operator for clean propagation +- [`err-from-impl`](rules/err-from-impl.md) - Use `#[from]` for automatic error conversion +- [`err-source-chain`](rules/err-source-chain.md) - Use `#[source]` to chain underlying errors +- [`err-lowercase-msg`](rules/err-lowercase-msg.md) - Error messages: lowercase, no trailing punctuation +- [`err-doc-errors`](rules/err-doc-errors.md) - Document errors with `# Errors` section +- [`err-custom-type`](rules/err-custom-type.md) - Create custom error types, not `Box` + +### 3. Memory Optimization (CRITICAL) + +- [`mem-with-capacity`](rules/mem-with-capacity.md) - Use `with_capacity()` when size is known +- [`mem-smallvec`](rules/mem-smallvec.md) - Use `SmallVec` for usually-small collections +- [`mem-arrayvec`](rules/mem-arrayvec.md) - Use `ArrayVec` for bounded-size collections +- [`mem-box-large-variant`](rules/mem-box-large-variant.md) - Box large enum variants to reduce type size +- [`mem-boxed-slice`](rules/mem-boxed-slice.md) - Use `Box<[T]>` instead of `Vec` when fixed +- [`mem-thinvec`](rules/mem-thinvec.md) - Use `ThinVec` for often-empty vectors +- [`mem-clone-from`](rules/mem-clone-from.md) - Use `clone_from()` to reuse allocations +- [`mem-reuse-collections`](rules/mem-reuse-collections.md) - Reuse collections with `clear()` in loops +- [`mem-avoid-format`](rules/mem-avoid-format.md) - Avoid `format!()` when string literals work +- [`mem-write-over-format`](rules/mem-write-over-format.md) - Use `write!()` instead of `format!()` +- [`mem-arena-allocator`](rules/mem-arena-allocator.md) - Use arena allocators for batch allocations +- [`mem-zero-copy`](rules/mem-zero-copy.md) - Use zero-copy patterns with slices and `Bytes` +- [`mem-compact-string`](rules/mem-compact-string.md) - Use `CompactString` for small string optimization +- [`mem-smaller-integers`](rules/mem-smaller-integers.md) - Use smallest integer type that fits +- [`mem-assert-type-size`](rules/mem-assert-type-size.md) - Assert hot type sizes to prevent regressions + +### 4. API Design (HIGH) + +- [`api-builder-pattern`](rules/api-builder-pattern.md) - Use Builder pattern for complex construction +- [`api-builder-must-use`](rules/api-builder-must-use.md) - Add `#[must_use]` to builder types +- [`api-newtype-safety`](rules/api-newtype-safety.md) - Use newtypes for type-safe distinctions +- [`api-typestate`](rules/api-typestate.md) - Use typestate for compile-time state machines +- [`api-sealed-trait`](rules/api-sealed-trait.md) - Seal traits to prevent external implementations +- [`api-extension-trait`](rules/api-extension-trait.md) - Use extension traits to add methods to foreign types +- [`api-parse-dont-validate`](rules/api-parse-dont-validate.md) - Parse into validated types at boundaries +- [`api-impl-into`](rules/api-impl-into.md) - Accept `impl Into` for flexible string inputs +- [`api-impl-asref`](rules/api-impl-asref.md) - Accept `impl AsRef` for borrowed inputs +- [`api-must-use`](rules/api-must-use.md) - Add `#[must_use]` to `Result` returning functions +- [`api-non-exhaustive`](rules/api-non-exhaustive.md) - Use `#[non_exhaustive]` for future-proof enums/structs +- [`api-from-not-into`](rules/api-from-not-into.md) - Implement `From`, not `Into` (auto-derived) +- [`api-default-impl`](rules/api-default-impl.md) - Implement `Default` for sensible defaults +- [`api-common-traits`](rules/api-common-traits.md) - Implement `Debug`, `Clone`, `PartialEq` eagerly +- [`api-serde-optional`](rules/api-serde-optional.md) - Gate `Serialize`/`Deserialize` behind feature flag + +### 5. Async/Await (HIGH) + +- [`async-tokio-runtime`](rules/async-tokio-runtime.md) - Use Tokio for production async runtime +- [`async-no-lock-await`](rules/async-no-lock-await.md) - Never hold `Mutex`/`RwLock` across `.await` +- [`async-spawn-blocking`](rules/async-spawn-blocking.md) - Use `spawn_blocking` for CPU-intensive work +- [`async-tokio-fs`](rules/async-tokio-fs.md) - Use `tokio::fs` not `std::fs` in async code +- [`async-cancellation-token`](rules/async-cancellation-token.md) - Use `CancellationToken` for graceful shutdown +- [`async-join-parallel`](rules/async-join-parallel.md) - Use `tokio::join!` for parallel operations +- [`async-try-join`](rules/async-try-join.md) - Use `tokio::try_join!` for fallible parallel ops +- [`async-select-racing`](rules/async-select-racing.md) - Use `tokio::select!` for racing/timeouts +- [`async-bounded-channel`](rules/async-bounded-channel.md) - Use bounded channels for backpressure +- [`async-mpsc-queue`](rules/async-mpsc-queue.md) - Use `mpsc` for work queues +- [`async-broadcast-pubsub`](rules/async-broadcast-pubsub.md) - Use `broadcast` for pub/sub patterns +- [`async-watch-latest`](rules/async-watch-latest.md) - Use `watch` for latest-value sharing +- [`async-oneshot-response`](rules/async-oneshot-response.md) - Use `oneshot` for request/response +- [`async-joinset-structured`](rules/async-joinset-structured.md) - Use `JoinSet` for dynamic task groups +- [`async-clone-before-await`](rules/async-clone-before-await.md) - Clone data before await, release locks + +### 6. Compiler Optimization (HIGH) + +- [`opt-inline-small`](rules/opt-inline-small.md) - Use `#[inline]` for small hot functions +- [`opt-inline-always-rare`](rules/opt-inline-always-rare.md) - Use `#[inline(always)]` sparingly +- [`opt-inline-never-cold`](rules/opt-inline-never-cold.md) - Use `#[inline(never)]` for cold paths +- [`opt-cold-unlikely`](rules/opt-cold-unlikely.md) - Use `#[cold]` for error/unlikely paths +- [`opt-likely-hint`](rules/opt-likely-hint.md) - Use `likely()`/`unlikely()` for branch hints +- [`opt-lto-release`](rules/opt-lto-release.md) - Enable LTO in release builds +- [`opt-codegen-units`](rules/opt-codegen-units.md) - Use `codegen-units = 1` for max optimization +- [`opt-pgo-profile`](rules/opt-pgo-profile.md) - Use PGO for production builds +- [`opt-target-cpu`](rules/opt-target-cpu.md) - Set `target-cpu=native` for local builds +- [`opt-bounds-check`](rules/opt-bounds-check.md) - Use iterators to avoid bounds checks +- [`opt-simd-portable`](rules/opt-simd-portable.md) - Use portable SIMD for data-parallel ops +- [`opt-cache-friendly`](rules/opt-cache-friendly.md) - Design cache-friendly data layouts (SoA) + +### 7. Naming Conventions (MEDIUM) + +- [`name-types-camel`](rules/name-types-camel.md) - Use `UpperCamelCase` for types, traits, enums +- [`name-variants-camel`](rules/name-variants-camel.md) - Use `UpperCamelCase` for enum variants +- [`name-funcs-snake`](rules/name-funcs-snake.md) - Use `snake_case` for functions, methods, modules +- [`name-consts-screaming`](rules/name-consts-screaming.md) - Use `SCREAMING_SNAKE_CASE` for constants/statics +- [`name-lifetime-short`](rules/name-lifetime-short.md) - Use short lowercase lifetimes: `'a`, `'de`, `'src` +- [`name-type-param-single`](rules/name-type-param-single.md) - Use single uppercase for type params: `T`, `E`, `K`, `V` +- [`name-as-free`](rules/name-as-free.md) - `as_` prefix: free reference conversion +- [`name-to-expensive`](rules/name-to-expensive.md) - `to_` prefix: expensive conversion +- [`name-into-ownership`](rules/name-into-ownership.md) - `into_` prefix: ownership transfer +- [`name-no-get-prefix`](rules/name-no-get-prefix.md) - No `get_` prefix for simple getters +- [`name-is-has-bool`](rules/name-is-has-bool.md) - Use `is_`, `has_`, `can_` for boolean methods +- [`name-iter-convention`](rules/name-iter-convention.md) - Use `iter`/`iter_mut`/`into_iter` for iterators +- [`name-iter-method`](rules/name-iter-method.md) - Name iterator methods consistently +- [`name-iter-type-match`](rules/name-iter-type-match.md) - Iterator type names match method +- [`name-acronym-word`](rules/name-acronym-word.md) - Treat acronyms as words: `Uuid` not `UUID` +- [`name-crate-no-rs`](rules/name-crate-no-rs.md) - Crate names: no `-rs` suffix + +### 8. Type Safety (MEDIUM) + +- [`type-newtype-ids`](rules/type-newtype-ids.md) - Wrap IDs in newtypes: `UserId(u64)` +- [`type-newtype-validated`](rules/type-newtype-validated.md) - Newtypes for validated data: `Email`, `Url` +- [`type-enum-states`](rules/type-enum-states.md) - Use enums for mutually exclusive states +- [`type-option-nullable`](rules/type-option-nullable.md) - Use `Option` for nullable values +- [`type-result-fallible`](rules/type-result-fallible.md) - Use `Result` for fallible operations +- [`type-phantom-marker`](rules/type-phantom-marker.md) - Use `PhantomData` for type-level markers +- [`type-never-diverge`](rules/type-never-diverge.md) - Use `!` type for functions that never return +- [`type-generic-bounds`](rules/type-generic-bounds.md) - Add trait bounds only where needed +- [`type-no-stringly`](rules/type-no-stringly.md) - Avoid stringly-typed APIs, use enums/newtypes +- [`type-repr-transparent`](rules/type-repr-transparent.md) - Use `#[repr(transparent)]` for FFI newtypes + +### 9. Testing (MEDIUM) + +- [`test-cfg-test-module`](rules/test-cfg-test-module.md) - Use `#[cfg(test)] mod tests { }` +- [`test-use-super`](rules/test-use-super.md) - Use `use super::*;` in test modules +- [`test-integration-dir`](rules/test-integration-dir.md) - Put integration tests in `tests/` directory +- [`test-descriptive-names`](rules/test-descriptive-names.md) - Use descriptive test names +- [`test-arrange-act-assert`](rules/test-arrange-act-assert.md) - Structure tests as arrange/act/assert +- [`test-proptest-properties`](rules/test-proptest-properties.md) - Use `proptest` for property-based testing +- [`test-mockall-mocking`](rules/test-mockall-mocking.md) - Use `mockall` for trait mocking +- [`test-mock-traits`](rules/test-mock-traits.md) - Use traits for dependencies to enable mocking +- [`test-fixture-raii`](rules/test-fixture-raii.md) - Use RAII pattern (Drop) for test cleanup +- [`test-tokio-async`](rules/test-tokio-async.md) - Use `#[tokio::test]` for async tests +- [`test-should-panic`](rules/test-should-panic.md) - Use `#[should_panic]` for panic tests +- [`test-criterion-bench`](rules/test-criterion-bench.md) - Use `criterion` for benchmarking +- [`test-doctest-examples`](rules/test-doctest-examples.md) - Keep doc examples as executable tests + +### 10. Documentation (MEDIUM) + +- [`doc-all-public`](rules/doc-all-public.md) - Document all public items with `///` +- [`doc-module-inner`](rules/doc-module-inner.md) - Use `//!` for module-level documentation +- [`doc-examples-section`](rules/doc-examples-section.md) - Include `# Examples` with runnable code +- [`doc-errors-section`](rules/doc-errors-section.md) - Include `# Errors` for fallible functions +- [`doc-panics-section`](rules/doc-panics-section.md) - Include `# Panics` for panicking functions +- [`doc-safety-section`](rules/doc-safety-section.md) - Include `# Safety` for unsafe functions +- [`doc-question-mark`](rules/doc-question-mark.md) - Use `?` in examples, not `.unwrap()` +- [`doc-hidden-setup`](rules/doc-hidden-setup.md) - Use `# ` prefix to hide example setup code +- [`doc-intra-links`](rules/doc-intra-links.md) - Use intra-doc links: `[Vec]` +- [`doc-link-types`](rules/doc-link-types.md) - Link related types and functions in docs +- [`doc-cargo-metadata`](rules/doc-cargo-metadata.md) - Fill `Cargo.toml` metadata + +### 11. Performance Patterns (MEDIUM) + +- [`perf-iter-over-index`](rules/perf-iter-over-index.md) - Prefer iterators over manual indexing +- [`perf-iter-lazy`](rules/perf-iter-lazy.md) - Keep iterators lazy, collect() only when needed +- [`perf-collect-once`](rules/perf-collect-once.md) - Don't `collect()` intermediate iterators +- [`perf-entry-api`](rules/perf-entry-api.md) - Use `entry()` API for map insert-or-update +- [`perf-drain-reuse`](rules/perf-drain-reuse.md) - Use `drain()` to reuse allocations +- [`perf-extend-batch`](rules/perf-extend-batch.md) - Use `extend()` for batch insertions +- [`perf-chain-avoid`](rules/perf-chain-avoid.md) - Avoid `chain()` in hot loops +- [`perf-collect-into`](rules/perf-collect-into.md) - Use `collect_into()` for reusing containers +- [`perf-black-box-bench`](rules/perf-black-box-bench.md) - Use `black_box()` in benchmarks +- [`perf-release-profile`](rules/perf-release-profile.md) - Optimize release profile settings +- [`perf-profile-first`](rules/perf-profile-first.md) - Profile before optimizing + +### 12. Project Structure (LOW) + +- [`proj-lib-main-split`](rules/proj-lib-main-split.md) - Keep `main.rs` minimal, logic in `lib.rs` +- [`proj-mod-by-feature`](rules/proj-mod-by-feature.md) - Organize modules by feature, not type +- [`proj-flat-small`](rules/proj-flat-small.md) - Keep small projects flat +- [`proj-mod-rs-dir`](rules/proj-mod-rs-dir.md) - Use `mod.rs` for multi-file modules +- [`proj-pub-crate-internal`](rules/proj-pub-crate-internal.md) - Use `pub(crate)` for internal APIs +- [`proj-pub-super-parent`](rules/proj-pub-super-parent.md) - Use `pub(super)` for parent-only visibility +- [`proj-pub-use-reexport`](rules/proj-pub-use-reexport.md) - Use `pub use` for clean public API +- [`proj-prelude-module`](rules/proj-prelude-module.md) - Create `prelude` module for common imports +- [`proj-bin-dir`](rules/proj-bin-dir.md) - Put multiple binaries in `src/bin/` +- [`proj-workspace-large`](rules/proj-workspace-large.md) - Use workspaces for large projects +- [`proj-workspace-deps`](rules/proj-workspace-deps.md) - Use workspace dependency inheritance + +### 13. Clippy & Linting (LOW) + +- [`lint-deny-correctness`](rules/lint-deny-correctness.md) - `#![deny(clippy::correctness)]` +- [`lint-warn-suspicious`](rules/lint-warn-suspicious.md) - `#![warn(clippy::suspicious)]` +- [`lint-warn-style`](rules/lint-warn-style.md) - `#![warn(clippy::style)]` +- [`lint-warn-complexity`](rules/lint-warn-complexity.md) - `#![warn(clippy::complexity)]` +- [`lint-warn-perf`](rules/lint-warn-perf.md) - `#![warn(clippy::perf)]` +- [`lint-pedantic-selective`](rules/lint-pedantic-selective.md) - Enable `clippy::pedantic` selectively +- [`lint-missing-docs`](rules/lint-missing-docs.md) - `#![warn(missing_docs)]` +- [`lint-unsafe-doc`](rules/lint-unsafe-doc.md) - `#![warn(clippy::undocumented_unsafe_blocks)]` +- [`lint-cargo-metadata`](rules/lint-cargo-metadata.md) - `#![warn(clippy::cargo)]` for published crates +- [`lint-rustfmt-check`](rules/lint-rustfmt-check.md) - Run `cargo fmt --check` in CI +- [`lint-workspace-lints`](rules/lint-workspace-lints.md) - Configure lints at workspace level + +### 14. Anti-patterns (REFERENCE) + +- [`anti-unwrap-abuse`](rules/anti-unwrap-abuse.md) - Don't use `.unwrap()` in production code +- [`anti-expect-lazy`](rules/anti-expect-lazy.md) - Don't use `.expect()` for recoverable errors +- [`anti-clone-excessive`](rules/anti-clone-excessive.md) - Don't clone when borrowing works +- [`anti-lock-across-await`](rules/anti-lock-across-await.md) - Don't hold locks across `.await` +- [`anti-string-for-str`](rules/anti-string-for-str.md) - Don't accept `&String` when `&str` works +- [`anti-vec-for-slice`](rules/anti-vec-for-slice.md) - Don't accept `&Vec` when `&[T]` works +- [`anti-index-over-iter`](rules/anti-index-over-iter.md) - Don't use indexing when iterators work +- [`anti-panic-expected`](rules/anti-panic-expected.md) - Don't panic on expected/recoverable errors +- [`anti-empty-catch`](rules/anti-empty-catch.md) - Don't use empty `if let Err(_) = ...` blocks +- [`anti-over-abstraction`](rules/anti-over-abstraction.md) - Don't over-abstract with excessive generics +- [`anti-premature-optimize`](rules/anti-premature-optimize.md) - Don't optimize before profiling +- [`anti-type-erasure`](rules/anti-type-erasure.md) - Don't use `Box` when `impl Trait` works +- [`anti-format-hot-path`](rules/anti-format-hot-path.md) - Don't use `format!()` in hot paths +- [`anti-collect-intermediate`](rules/anti-collect-intermediate.md) - Don't `collect()` intermediate iterators +- [`anti-stringly-typed`](rules/anti-stringly-typed.md) - Don't use strings for structured data + +--- + +## Recommended Cargo.toml Settings + +```toml +[profile.release] +opt-level = 3 +lto = "fat" +codegen-units = 1 +panic = "abort" +strip = true + +[profile.bench] +inherits = "release" +debug = true +strip = false + +[profile.dev] +opt-level = 0 +debug = true + +[profile.dev.package."*"] +opt-level = 3 # Optimize dependencies in dev +``` + +--- + +## How to Use + +This skill provides rule identifiers for quick reference. When generating or reviewing Rust code: + +1. **Check relevant category** based on task type +2. **Apply rules** with matching prefix +3. **Prioritize** CRITICAL > HIGH > MEDIUM > LOW +4. **Read rule files** in `rules/` for detailed examples + +### Rule Application by Task + +| Task | Primary Categories | +|------|-------------------| +| New function | `own-`, `err-`, `name-` | +| New struct/API | `api-`, `type-`, `doc-` | +| Async code | `async-`, `own-` | +| Error handling | `err-`, `api-` | +| Memory optimization | `mem-`, `own-`, `perf-` | +| Performance tuning | `opt-`, `mem-`, `perf-` | +| Code review | `anti-`, `lint-` | + +--- + +## Sources + +This skill synthesizes best practices from: +- [Rust API Guidelines](https://rust-lang.github.io/api-guidelines/) +- [Rust Performance Book](https://nnethercote.github.io/perf-book/) +- [Rust Design Patterns](https://rust-unofficial.github.io/patterns/) +- Production codebases: ripgrep, tokio, serde, polars, axum, deno +- Clippy lint documentation +- Community conventions (2024-2025) diff --git a/.agents/skills/rust-skills/rules/anti-clone-excessive.md b/.agents/skills/rust-skills/rules/anti-clone-excessive.md new file mode 100644 index 0000000..539dac2 --- /dev/null +++ b/.agents/skills/rust-skills/rules/anti-clone-excessive.md @@ -0,0 +1,124 @@ +# anti-clone-excessive + +> Don't clone when borrowing works + +## Why It Matters + +`.clone()` allocates memory and copies data. When you only need to read data, borrowing (`&T`) is free. Excessive cloning wastes memory, CPU cycles, and often indicates misunderstanding of ownership. + +## Bad + +```rust +// Cloning to pass to a function that only reads +fn print_name(name: String) { // Takes ownership + println!("{}", name); +} +let name = "Alice".to_string(); +print_name(name.clone()); // Unnecessary clone +print_name(name); // Could have just done this + +// Cloning in a loop +for item in items.clone() { // Clones entire Vec + process(&item); +} + +// Cloning for comparison +if input.clone() == expected { // Pointless clone + // ... +} + +// Cloning struct fields +fn get_name(&self) -> String { + self.name.clone() // Caller might not need ownership +} +``` + +## Good + +```rust +// Accept reference if only reading +fn print_name(name: &str) { + println!("{}", name); +} +let name = "Alice".to_string(); +print_name(&name); // Borrow, no clone + +// Iterate by reference +for item in &items { + process(item); +} + +// Compare by reference +if input == expected { + // ... +} + +// Return reference when possible +fn get_name(&self) -> &str { + &self.name +} +``` + +## When to Clone + +```rust +// Need owned data for async move +let name = name.clone(); +tokio::spawn(async move { + process(name).await; +}); + +// Storing in a new struct +struct Cache { + data: String, +} +impl Cache { + fn store(&mut self, data: &str) { + self.data = data.to_string(); // Must own + } +} + +// Multiple owners (use Arc instead if frequent) +let shared = data.clone(); +thread::spawn(move || use_data(shared)); +``` + +## Alternatives to Clone + +| Instead of | Use | +|------------|-----| +| `s.clone()` for reading | `&s` | +| `vec.clone()` for iteration | `&vec` or `vec.iter()` | +| `Clone` for shared ownership | `Arc` | +| Clone in hot loop | Move outside loop | +| `s.to_string()` from `&str` | Accept `&str` if possible | + +## Pattern: Clone on Write + +```rust +use std::borrow::Cow; + +fn process(input: Cow) -> Cow { + if needs_modification(&input) { + Cow::Owned(modify(&input)) // Clone only if needed + } else { + input // No clone + } +} +``` + +## Detecting Excessive Clones + +```toml +# Cargo.toml +[lints.clippy] +clone_on_copy = "warn" +clone_on_ref_ptr = "warn" +redundant_clone = "warn" +``` + +## See Also + +- [own-borrow-over-clone](./own-borrow-over-clone.md) - Borrowing patterns +- [own-cow-conditional](./own-cow-conditional.md) - Clone on write +- [own-arc-shared](./own-arc-shared.md) - Shared ownership diff --git a/.agents/skills/rust-skills/rules/anti-collect-intermediate.md b/.agents/skills/rust-skills/rules/anti-collect-intermediate.md new file mode 100644 index 0000000..b83a2fb --- /dev/null +++ b/.agents/skills/rust-skills/rules/anti-collect-intermediate.md @@ -0,0 +1,131 @@ +# anti-collect-intermediate + +> Don't collect intermediate iterators + +## Why It Matters + +Each `.collect()` allocates a new collection. Collecting intermediate results in a chain creates unnecessary allocations and prevents iterator fusion. Keep the chain lazy; collect only at the end. + +## Bad + +```rust +// Three allocations, three passes +fn process(data: Vec) -> Vec { + let step1: Vec<_> = data.into_iter() + .filter(|x| *x > 0) + .collect(); + + let step2: Vec<_> = step1.into_iter() + .map(|x| x * 2) + .collect(); + + step2.into_iter() + .filter(|x| *x < 100) + .collect() +} + +// Collecting just to check length +fn has_valid_items(items: &[Item]) -> bool { + let valid: Vec<_> = items.iter() + .filter(|i| i.is_valid()) + .collect(); + !valid.is_empty() +} + +// Collecting to iterate again +fn sum_valid(items: &[Item]) -> i64 { + let valid: Vec<_> = items.iter() + .filter(|i| i.is_valid()) + .collect(); + valid.iter().map(|i| i.value).sum() +} +``` + +## Good + +```rust +// Single allocation, single pass +fn process(data: Vec) -> Vec { + data.into_iter() + .filter(|x| *x > 0) + .map(|x| x * 2) + .filter(|x| *x < 100) + .collect() +} + +// No allocation - iterator short-circuits +fn has_valid_items(items: &[Item]) -> bool { + items.iter().any(|i| i.is_valid()) +} + +// No intermediate allocation +fn sum_valid(items: &[Item]) -> i64 { + items.iter() + .filter(|i| i.is_valid()) + .map(|i| i.value) + .sum() +} +``` + +## When Collection Is Needed + +```rust +// Need to iterate twice +let valid: Vec<_> = items.iter() + .filter(|i| i.is_valid()) + .collect(); +let count = valid.len(); +for item in &valid { + process(item); +} + +// Need to sort (requires concrete collection) +let mut sorted: Vec<_> = items.iter() + .filter(|i| i.is_active()) + .collect(); +sorted.sort_by_key(|i| i.priority); + +// Need random access +let indexed: Vec<_> = items.iter().collect(); +let middle = indexed.get(indexed.len() / 2); +``` + +## Iterator Methods That Avoid Collection + +| Instead of Collecting to... | Use | +|-----------------------------|-----| +| Check if empty | `.any(|_| true)` or `.next().is_some()` | +| Check if any match | `.any(predicate)` | +| Check if all match | `.all(predicate)` | +| Count elements | `.count()` | +| Sum elements | `.sum()` | +| Find first | `.find(predicate)` | +| Get first | `.next()` | +| Get last | `.last()` | + +## Pattern: Deferred Collection + +```rust +// Return iterator, let caller collect if needed +fn valid_items(items: &[Item]) -> impl Iterator { + items.iter().filter(|i| i.is_valid()) +} + +// Caller decides +let count = valid_items(&items).count(); // No collection +let vec: Vec<_> = valid_items(&items).collect(); // Collection when needed +``` + +## Comparison + +| Pattern | Allocations | Passes | +|---------|-------------|--------| +| `.collect()` each step | N | N | +| Single chain, one `.collect()` | 1 | 1 | +| No collection (streaming) | 0 | 1 | + +## See Also + +- [perf-collect-once](./perf-collect-once.md) - Single collect +- [perf-iter-lazy](./perf-iter-lazy.md) - Lazy evaluation +- [perf-iter-over-index](./perf-iter-over-index.md) - Iterator patterns diff --git a/.agents/skills/rust-skills/rules/anti-empty-catch.md b/.agents/skills/rust-skills/rules/anti-empty-catch.md new file mode 100644 index 0000000..b2b63f2 --- /dev/null +++ b/.agents/skills/rust-skills/rules/anti-empty-catch.md @@ -0,0 +1,132 @@ +# anti-empty-catch + +> Don't silently ignore errors + +## Why It Matters + +Empty error handling (`if let Err(_) = ...`, `let _ = result`, `.ok()`) silently discards errors. Failures go unnoticed, bugs hide, and debugging becomes impossible. Every error deserves acknowledgment—even if just logging. + +## Bad + +```rust +// Silently ignores errors +let _ = write_to_file(data); + +// Discards error completely +if let Err(_) = send_notification() { + // Nothing - error vanishes +} + +// Converts Result to Option, losing error info +let value = risky_operation().ok(); + +// Match with empty arm +match database.save(record) { + Ok(_) => println!("saved"), + Err(_) => {} // Silent failure +} + +// Ignored in loop +for item in items { + let _ = process(item); // Failures unnoticed +} +``` + +## Good + +```rust +// Log the error +if let Err(e) = write_to_file(data) { + error!("failed to write file: {}", e); +} + +// Propagate if possible +send_notification()?; + +// Or handle explicitly +match send_notification() { + Ok(_) => info!("notification sent"), + Err(e) => warn!("notification failed: {}", e), +} + +// Collect errors in batch operations +let (successes, failures): (Vec<_>, Vec<_>) = items + .into_iter() + .map(process) + .partition(Result::is_ok); + +if !failures.is_empty() { + warn!("{} items failed to process", failures.len()); +} + +// Explicit documentation when ignoring +// Intentionally ignored: cleanup failure is not critical +let _ = cleanup_temp_file(); // Add comment explaining why +``` + +## Acceptable Ignoring (Documented) + +```rust +// Close errors often ignored, but document it +// INTENTIONAL: TCP close errors are not actionable +let _ = stream.shutdown(Shutdown::Both); + +// Mutex poisoning recovery +// INTENTIONAL: We'll reset the state anyway +let guard = mutex.lock().unwrap_or_else(|e| e.into_inner()); +``` + +## Pattern: Collect and Report + +```rust +fn process_batch(items: Vec) -> BatchResult { + let mut errors = Vec::new(); + + for item in items { + if let Err(e) = process_item(&item) { + errors.push((item.id, e)); + } + } + + if errors.is_empty() { + BatchResult::AllSucceeded + } else { + BatchResult::PartialFailure(errors) + } +} +``` + +## Pattern: Best-Effort Operations + +```rust +// Metrics/telemetry can fail without affecting main flow +fn report_metric(name: &str, value: f64) { + if let Err(e) = metrics_client.record(name, value) { + // Log but don't propagate - metrics are not critical + debug!("failed to record metric {}: {}", name, e); + } +} +``` + +## Clippy Lint + +```toml +[lints.clippy] +let_underscore_drop = "warn" +ignored_unit_patterns = "warn" +``` + +## Decision Guide + +| Situation | Action | +|-----------|--------| +| Critical operation | `?` or handle explicitly | +| Non-critical, debugging needed | Log the error | +| Truly ignorable (rare) | `let _ =` with comment | +| Batch operation | Collect errors, report | + +## See Also + +- [err-result-over-panic](./err-result-over-panic.md) - Proper error handling +- [err-context-chain](./err-context-chain.md) - Adding context +- [anti-unwrap-abuse](./anti-unwrap-abuse.md) - Unwrap issues diff --git a/.agents/skills/rust-skills/rules/anti-expect-lazy.md b/.agents/skills/rust-skills/rules/anti-expect-lazy.md new file mode 100644 index 0000000..24e2b3d --- /dev/null +++ b/.agents/skills/rust-skills/rules/anti-expect-lazy.md @@ -0,0 +1,95 @@ +# anti-expect-lazy + +> Don't use expect for recoverable errors + +## Why It Matters + +`.expect()` panics with a custom message, but it's still a panic. Using it for errors that could reasonably occur in production (network failures, file not found, invalid input) crashes the program instead of handling the error gracefully. + +Reserve `.expect()` for programming errors where panic is appropriate. + +## Bad + +```rust +// Network failures are expected - don't panic +let response = client.get(url).await.expect("failed to fetch"); + +// Files might not exist +let config = fs::read_to_string("config.toml").expect("config not found"); + +// User input can be invalid +let age: u32 = input.parse().expect("invalid age"); + +// Database queries can fail +let user = db.find_user(id).await.expect("user not found"); +``` + +## Good + +```rust +// Handle recoverable errors properly +let response = client.get(url).await + .context("failed to fetch URL")?; + +// Return error if file doesn't exist +let config = fs::read_to_string("config.toml") + .context("failed to read config file")?; + +// Validate and return error +let age: u32 = input.parse() + .map_err(|_| Error::InvalidInput("age must be a number"))?; + +// Handle missing data +let user = db.find_user(id).await? + .ok_or(Error::NotFound("user"))?; +``` + +## When expect() Is Appropriate + +Use `.expect()` for invariants that indicate bugs: + +```rust +// Mutex poisoning indicates a bug elsewhere +let guard = mutex.lock().expect("mutex poisoned"); + +// Regex is known valid at compile time +let re = Regex::new(r"^\d{4}$").expect("invalid regex"); + +// Thread spawn failure is unrecoverable +let handle = thread::spawn(|| work()).expect("failed to spawn thread"); + +// Static data that must be valid +let config: Config = toml::from_str(EMBEDDED_CONFIG) + .expect("embedded config is invalid"); +``` + +## Pattern: expect() vs unwrap() + +```rust +// unwrap: no context, hard to debug +let x = option.unwrap(); + +// expect: gives context, still panics +let x = option.expect("value should exist after validation"); + +// ?: proper error handling +let x = option.ok_or(Error::MissingValue)?; +``` + +## Decision Guide + +| Situation | Use | +|-----------|-----| +| User input | `?` with error | +| File/network I/O | `?` with error | +| Database operations | `?` with error | +| Parsed constants | `.expect()` | +| Thread/mutex operations | `.expect()` | +| After validation check | `.expect()` with explanation | +| Never expected to fail | `.expect()` documenting invariant | + +## See Also + +- [err-expect-bugs-only](./err-expect-bugs-only.md) - When to use expect +- [err-no-unwrap-prod](./err-no-unwrap-prod.md) - Avoiding unwrap +- [anti-unwrap-abuse](./anti-unwrap-abuse.md) - Unwrap anti-pattern diff --git a/.agents/skills/rust-skills/rules/anti-format-hot-path.md b/.agents/skills/rust-skills/rules/anti-format-hot-path.md new file mode 100644 index 0000000..368627f --- /dev/null +++ b/.agents/skills/rust-skills/rules/anti-format-hot-path.md @@ -0,0 +1,141 @@ +# anti-format-hot-path + +> Don't use format! in hot paths + +## Why It Matters + +`format!()` allocates a new `String` every call. In hot paths (loops, frequently called functions), this creates allocation churn that impacts performance. Pre-allocate, reuse buffers, or use `write!()` to an existing buffer. + +## Bad + +```rust +// format! in loop - allocates every iteration +fn log_events(events: &[Event]) { + for event in events { + let message = format!("[{}] {}: {}", event.level, event.source, event.message); + logger.log(&message); + } +} + +// format! for building parts +fn build_url(base: &str, path: &str, params: &[(&str, &str)]) -> String { + let mut url = format!("{}{}", base, path); + for (key, value) in params { + url = format!("{}{}={}&", url, key, value); // New allocation each time + } + url +} + +// format! for simple concatenation +fn greet(name: &str) -> String { + format!("Hello, {}!", name) // Fine for one-off, bad if called 1M times +} +``` + +## Good + +```rust +use std::fmt::Write; + +// Reuse buffer across iterations +fn log_events(events: &[Event]) { + let mut buffer = String::with_capacity(256); + for event in events { + buffer.clear(); + write!(buffer, "[{}] {}: {}", event.level, event.source, event.message).unwrap(); + logger.log(&buffer); + } +} + +// Build incrementally in single buffer +fn build_url(base: &str, path: &str, params: &[(&str, &str)]) -> String { + let mut url = String::with_capacity(base.len() + path.len() + params.len() * 20); + url.push_str(base); + url.push_str(path); + for (key, value) in params { + write!(url, "{}={}&", key, value).unwrap(); + } + url +} + +// For truly hot paths, avoid allocation entirely +fn greet_to_buf(name: &str, buffer: &mut String) { + buffer.clear(); + buffer.push_str("Hello, "); + buffer.push_str(name); + buffer.push('!'); +} +``` + +## Comparison + +| Approach | Allocations | Performance | +|----------|-------------|-------------| +| `format!()` in loop | N | Slow | +| `write!()` to reused buffer | 1 | Fast | +| `push_str()` + `push()` | 1 | Fastest | +| Pre-sized `String::with_capacity()` | 1 (no realloc) | Fast | + +## When format! Is Fine + +```rust +// One-time initialization +let config_path = format!("{}/config.toml", home_dir); + +// Error messages (not hot path) +return Err(format!("invalid input: {}", input)); + +// Debug output +println!("Debug: {:?}", value); +``` + +## Pattern: Formatter Buffer Pool + +```rust +use std::cell::RefCell; + +thread_local! { + static BUFFER: RefCell = RefCell::new(String::with_capacity(256)); +} + +fn format_event(event: &Event) -> String { + BUFFER.with(|buf| { + let mut buf = buf.borrow_mut(); + buf.clear(); + write!(buf, "[{}] {}", event.level, event.message).unwrap(); + buf.clone() // Still one allocation per call, but no parsing + }) +} +``` + +## Pattern: Display Implementation + +```rust +struct Event { + level: Level, + message: String, +} + +impl std::fmt::Display for Event { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "[{}] {}", self.level, self.message) + } +} + +// Caller controls allocation +let mut buf = String::new(); +write!(buf, "{}", event)?; +``` + +## Clippy Lint + +```toml +[lints.clippy] +format_in_format_args = "warn" +``` + +## See Also + +- [mem-avoid-format](./mem-avoid-format.md) - Avoiding format +- [mem-write-over-format](./mem-write-over-format.md) - Using write! +- [mem-reuse-collections](./mem-reuse-collections.md) - Buffer reuse diff --git a/.agents/skills/rust-skills/rules/anti-index-over-iter.md b/.agents/skills/rust-skills/rules/anti-index-over-iter.md new file mode 100644 index 0000000..c22d85f --- /dev/null +++ b/.agents/skills/rust-skills/rules/anti-index-over-iter.md @@ -0,0 +1,125 @@ +# anti-index-over-iter + +> Don't use indexing when iterators work + +## Why It Matters + +Manual indexing (`for i in 0..len`) requires bounds checks on every access, prevents SIMD optimization, and introduces off-by-one error risks. Iterators eliminate these issues and are more idiomatic Rust. + +## Bad + +```rust +// Manual indexing - bounds checked every access +fn sum_squares(data: &[i32]) -> i64 { + let mut result = 0i64; + for i in 0..data.len() { + result += (data[i] as i64) * (data[i] as i64); + } + result +} + +// Index-based with multiple arrays +fn dot_product(a: &[f64], b: &[f64]) -> f64 { + let mut sum = 0.0; + for i in 0..a.len().min(b.len()) { + sum += a[i] * b[i]; + } + sum +} + +// Mutation with indices +fn normalize(data: &mut [f64]) { + let max = data.iter().cloned().fold(0.0, f64::max); + for i in 0..data.len() { + data[i] /= max; + } +} +``` + +## Good + +```rust +// Iterator - no bounds checks, SIMD-friendly +fn sum_squares(data: &[i32]) -> i64 { + data.iter() + .map(|&x| (x as i64) * (x as i64)) + .sum() +} + +// Zip - handles length mismatch automatically +fn dot_product(a: &[f64], b: &[f64]) -> f64 { + a.iter() + .zip(b.iter()) + .map(|(&x, &y)| x * y) + .sum() +} + +// Mutable iteration +fn normalize(data: &mut [f64]) { + let max = data.iter().cloned().fold(0.0, f64::max); + for x in data.iter_mut() { + *x /= max; + } +} +``` + +## When Indices Are Needed + +Sometimes you genuinely need indices: + +```rust +// Need index in output +for (i, item) in items.iter().enumerate() { + println!("{}: {}", i, item); +} + +// Non-sequential access +for i in (0..len).step_by(2) { + swap(&mut data[i], &mut data[i + 1]); +} + +// Multi-dimensional iteration +for i in 0..rows { + for j in 0..cols { + matrix[i][j] = i * cols + j; + } +} +``` + +## Comparison + +| Pattern | Bounds Checks | SIMD | Safety | +|---------|---------------|------|--------| +| `for i in 0..len { data[i] }` | Every access | Limited | Off-by-one risk | +| `for x in &data` | None | Good | Safe | +| `for x in data.iter()` | None | Good | Safe | +| `data.iter().enumerate()` | None | Good | Safe | + +## Common Conversions + +| Index Pattern | Iterator Pattern | +|---------------|------------------| +| `for i in 0..v.len()` | `for x in &v` | +| `v[0]` | `v.first()` | +| `v[v.len()-1]` | `v.last()` | +| `for i in 0..a.len() { a[i] + b[i] }` | `a.iter().zip(&b)` | +| `for i in 0..v.len() { v[i] *= 2 }` | `for x in &mut v { *x *= 2 }` | + +## Performance Note + +```rust +// Iterator version can auto-vectorize +let sum: i32 = data.iter().sum(); + +// Manual indexing prevents vectorization +let mut sum = 0; +for i in 0..data.len() { + sum += data[i]; +} +``` + +## See Also + +- [perf-iter-over-index](./perf-iter-over-index.md) - Performance details +- [opt-bounds-check](./opt-bounds-check.md) - Bounds check elimination +- [perf-iter-lazy](./perf-iter-lazy.md) - Lazy iterators diff --git a/.agents/skills/rust-skills/rules/anti-lock-across-await.md b/.agents/skills/rust-skills/rules/anti-lock-across-await.md new file mode 100644 index 0000000..20742d8 --- /dev/null +++ b/.agents/skills/rust-skills/rules/anti-lock-across-await.md @@ -0,0 +1,127 @@ +# anti-lock-across-await + +> Don't hold locks across await points + +## Why It Matters + +Holding a `Mutex` or `RwLock` guard across an `.await` causes the lock to be held while the task is suspended. Other tasks waiting for the lock block indefinitely. With `std::sync::Mutex`, this is even worse—it can deadlock the entire runtime. + +## Bad + +```rust +use std::sync::Mutex; +use tokio::sync::Mutex as AsyncMutex; + +// DEADLOCK RISK: std::sync::Mutex held across await +async fn bad_std_mutex(data: &Mutex>) { + let mut guard = data.lock().unwrap(); + do_async_work().await; // Lock held during await! + guard.push(42); +} + +// BLOCKS OTHER TASKS: tokio Mutex held across await +async fn bad_async_mutex(data: &AsyncMutex>) { + let mut guard = data.lock().await; + slow_network_call().await; // Lock held for entire call! + guard.push(42); +} +``` + +## Good + +```rust +use std::sync::Mutex; +use tokio::sync::Mutex as AsyncMutex; + +// Release lock before await +async fn good_approach(data: &Mutex>) { + let value = { + let guard = data.lock().unwrap(); + guard.last().copied() // Extract what you need + }; // Lock released here + + let result = do_async_work(value).await; + + { + let mut guard = data.lock().unwrap(); + guard.push(result); + } +} + +// Minimize lock scope with async mutex +async fn good_async_mutex(data: &AsyncMutex>, item: i32) { + // Quick lock, quick release + data.lock().await.push(item); + + // Async work without lock + let result = slow_network_call().await; + + // Quick lock again + data.lock().await.push(result); +} +``` + +## Pattern: Clone Before Await + +```rust +async fn process(data: &AsyncMutex) -> Result<()> { + // Clone inside lock scope + let config = data.lock().await.clone(); + + // Now use config freely across awaits + let result = fetch_data(&config.url).await?; + process_result(&config, result).await?; + + Ok(()) +} +``` + +## Pattern: Restructure to Avoid Lock + +```rust +// Instead of locking a shared map +struct Service { + data: AsyncMutex>, +} + +// Use channels or owned data +struct BetterService { + // Each task owns its data via channels + sender: mpsc::Sender, +} + +impl BetterService { + async fn request(&self, key: String) -> Data { + let (tx, rx) = oneshot::channel(); + self.sender.send(Request { key, respond: tx }).await?; + rx.await? + } +} +``` + +## What Can Cross Await + +| Type | Safe Across Await? | +|------|--------------------| +| `std::sync::Mutex` guard | **NO** - can deadlock | +| `std::sync::RwLock` guard | **NO** - can deadlock | +| `tokio::sync::Mutex` guard | Allowed but blocks tasks | +| `tokio::sync::RwLock` guard | Allowed but blocks tasks | +| Owned values | Yes | +| `Arc` | Yes | +| References | Depends on lifetime | + +## Detection + +```toml +# Cargo.toml +[lints.clippy] +await_holding_lock = "deny" +await_holding_refcell_ref = "deny" +``` + +## See Also + +- [async-no-lock-await](./async-no-lock-await.md) - Async lock patterns +- [async-clone-before-await](./async-clone-before-await.md) - Clone pattern +- [own-mutex-interior](./own-mutex-interior.md) - Mutex usage diff --git a/.agents/skills/rust-skills/rules/anti-over-abstraction.md b/.agents/skills/rust-skills/rules/anti-over-abstraction.md new file mode 100644 index 0000000..44a12f0 --- /dev/null +++ b/.agents/skills/rust-skills/rules/anti-over-abstraction.md @@ -0,0 +1,120 @@ +# anti-over-abstraction + +> Don't over-abstract with excessive generics + +## Why It Matters + +Generics and traits are powerful but come at a cost: compile times, binary size, and cognitive load. Over-abstraction—making everything generic "for flexibility"—often adds complexity without benefit. Start concrete; generalize when you have real use cases. + +## Bad + +```rust +// Overly generic for a simple function +fn add(a: T, b: U) -> R +where + T: Into, + U: Into, + R: std::ops::Add, +{ + a.into() + b.into() +} + +// Just call add(1, 2) - why make it this complex? + +// Trait explosion +trait Readable {} +trait Writable {} +trait ReadWritable: Readable + Writable {} +trait AsyncReadable {} +trait AsyncWritable {} +trait AsyncReadWritable: AsyncReadable + AsyncWritable {} + +// Abstract factory pattern (Java flashback) +trait Factory { + fn create(&self) -> T; +} +trait FactoryFactory, T> { + fn create_factory(&self) -> F; +} +``` + +## Good + +```rust +// Concrete implementation - clear and simple +fn add_i32(a: i32, b: i32) -> i32 { + a + b +} + +// Generic when actually needed (e.g., library code) +fn add>(a: T, b: T) -> T { + a + b +} + +// Simple traits for actual polymorphism needs +trait Storage { + fn save(&self, key: &str, value: &[u8]) -> Result<(), Error>; + fn load(&self, key: &str) -> Result, Error>; +} + +// Concrete types first +struct FileStorage { path: PathBuf } +struct MemoryStorage { data: HashMap> } +``` + +## Signs of Over-Abstraction + +| Sign | Symptom | +|------|---------| +| Single implementation | Generic trait with only one impl | +| Type parameter soup | `T, U, V, W` everywhere | +| Marker traits | Traits with no methods | +| Deep trait bounds | `where T: A + B + C + D + E` | +| Phantom generics | Type parameters not used meaningfully | + +## When to Generalize + +Generalize when: +- You have 2+ concrete types that share behavior +- You're writing library code for public consumption +- Performance requires static dispatch +- The abstraction simplifies the API + +Don't generalize when: +- You "might need it later" (YAGNI) +- Only one type will ever implement it +- It makes code harder to understand + +## Rule of Three + +Wait until you have three similar concrete implementations before abstracting: + +```rust +// Version 1: Just FileStorage +struct FileStorage { /* ... */ } + +// Version 2: Added MemoryStorage, similar interface +struct MemoryStorage { /* ... */ } + +// Version 3: Now Redis too - time to abstract +trait Storage { + fn save(&self, key: &str, value: &[u8]) -> Result<()>; + fn load(&self, key: &str) -> Result>; +} +``` + +## Prefer Concrete Types in Private Code + +```rust +// Internal function - concrete type is fine +fn process_orders(db: &PostgresDb, orders: Vec) { } + +// Public API - might benefit from abstraction +pub fn process_orders(storage: &S, orders: Vec) { } +``` + +## See Also + +- [type-generic-bounds](./type-generic-bounds.md) - Minimal bounds +- [api-sealed-trait](./api-sealed-trait.md) - Controlled extension +- [anti-type-erasure](./anti-type-erasure.md) - When Box is wrong diff --git a/.agents/skills/rust-skills/rules/anti-panic-expected.md b/.agents/skills/rust-skills/rules/anti-panic-expected.md new file mode 100644 index 0000000..cecb34c --- /dev/null +++ b/.agents/skills/rust-skills/rules/anti-panic-expected.md @@ -0,0 +1,131 @@ +# anti-panic-expected + +> Don't panic on expected or recoverable errors + +## Why It Matters + +Panics crash the program. They're for unrecoverable situations—bugs, corrupted state, invariant violations. Using panic for expected conditions (network failures, file not found, invalid input) makes programs fragile and forces callers to catch panics or die. + +Use `Result` for recoverable errors. + +## Bad + +```rust +// Network failures are expected +fn fetch_data(url: &str) -> Data { + let response = reqwest::blocking::get(url) + .expect("network error"); // Crashes on timeout + response.json().expect("invalid json") // Crashes on bad response +} + +// User input is often invalid +fn parse_config(input: &str) -> Config { + toml::from_str(input).expect("invalid config") // Crashes on typo +} + +// Files may not exist +fn load_settings() -> Settings { + let content = fs::read_to_string("settings.json") + .expect("settings not found"); // Crashes if missing + serde_json::from_str(&content).expect("invalid settings") +} + +// Custom panic for validation +fn process_age(age: i32) { + if age < 0 { + panic!("age cannot be negative"); // Should return error + } +} +``` + +## Good + +```rust +// Return errors for expected failures +fn fetch_data(url: &str) -> Result { + let response = reqwest::blocking::get(url) + .context("failed to connect")?; + let data = response.json() + .context("failed to parse response")?; + Ok(data) +} + +// Validate and return Result +fn parse_config(input: &str) -> Result { + toml::from_str(input).map_err(ConfigError::Parse) +} + +// Handle missing files gracefully +fn load_settings() -> Result { + let content = fs::read_to_string("settings.json")?; + let settings = serde_json::from_str(&content)?; + Ok(settings) +} + +// Return error for validation failure +fn process_age(age: i32) -> Result<(), ValidationError> { + if age < 0 { + return Err(ValidationError::NegativeAge); + } + Ok(()) +} +``` + +## When to Panic + +Panic IS appropriate for: + +```rust +// Bug detection - invariant violated +fn get_unchecked(&self, index: usize) -> &T { + assert!(index < self.len(), "index out of bounds - this is a bug"); + unsafe { self.data.get_unchecked(index) } +} + +// Unrecoverable state +fn init() { + if !CAN_PROCEED { + panic!("system requirements not met"); + } +} + +// Tests +#[test] +fn test_fails() { + panic!("expected panic in test"); +} +``` + +## Decision Guide + +| Condition | Action | +|-----------|--------| +| Invalid user input | Return `Err` | +| Network failure | Return `Err` | +| File not found | Return `Err` | +| Malformed data | Return `Err` | +| Bug/impossible state | `panic!` or `unreachable!` | +| Failed assertion in test | `panic!` | +| Unrecoverable init failure | `panic!` | + +## Anti-pattern: panic! for Control Flow + +```rust +// BAD: Using panic for control flow +fn find_or_die(items: &[Item], id: u64) -> &Item { + items.iter() + .find(|i| i.id == id) + .unwrap_or_else(|| panic!("item {} not found", id)) +} + +// GOOD: Return Option or Result +fn find(items: &[Item], id: u64) -> Option<&Item> { + items.iter().find(|i| i.id == id) +} +``` + +## See Also + +- [err-result-over-panic](./err-result-over-panic.md) - Use Result +- [anti-unwrap-abuse](./anti-unwrap-abuse.md) - Unwrap anti-pattern +- [err-expect-bugs-only](./err-expect-bugs-only.md) - When to expect diff --git a/.agents/skills/rust-skills/rules/anti-premature-optimize.md b/.agents/skills/rust-skills/rules/anti-premature-optimize.md new file mode 100644 index 0000000..646007e --- /dev/null +++ b/.agents/skills/rust-skills/rules/anti-premature-optimize.md @@ -0,0 +1,156 @@ +# anti-premature-optimize + +> Don't optimize before profiling + +## Why It Matters + +Premature optimization wastes time, complicates code, and often targets the wrong bottlenecks. Most code isn't performance-critical; the hot 10% matters. Profile first, then optimize the actual bottlenecks with data-driven decisions. + +## Bad + +```rust +// "Optimizing" without measurement +fn sum(data: &[i32]) -> i32 { + // Using unsafe "for performance" without profiling + unsafe { + let mut sum = 0; + for i in 0..data.len() { + sum += *data.get_unchecked(i); + } + sum + } +} + +// Complex caching with no evidence it's needed +lazy_static! { + static ref CACHE: RwLock>> = + RwLock::new(HashMap::new()); +} + +// Hand-rolled data structures "for speed" +struct MyVec { + ptr: *mut T, + len: usize, + cap: usize, +} +``` + +## Good + +```rust +// Simple, idiomatic - let compiler optimize +fn sum(data: &[i32]) -> i32 { + data.iter().sum() +} + +// Profile, then optimize if needed +fn sum_optimized(data: &[i32]) -> i32 { + // After profiling showed this is a bottleneck, + // we measured that manual SIMD gives 3x speedup + #[cfg(target_arch = "x86_64")] + { + // SIMD implementation with benchmark data + } + #[cfg(not(target_arch = "x86_64"))] + { + data.iter().sum() + } +} + +// Use standard library - it's well-optimized +let cache: HashMap = HashMap::new(); +``` + +## Profiling Workflow + +```bash +# 1. Write correct code first +cargo build --release + +# 2. Profile with real workloads +cargo flamegraph --bin my_app -- --real-args +# or +cargo bench + +# 3. Identify hotspots (top 10% of time) + +# 4. Measure before optimizing +# 5. Optimize ONE thing +# 6. Measure after - verify improvement +# 7. Repeat if still slow +``` + +## Optimization Principles + +| Do | Don't | +|----|-------| +| Profile first | Guess at bottlenecks | +| Optimize hotspots | Optimize everything | +| Measure improvement | Assume it's faster | +| Keep it simple | Add complexity speculatively | +| Trust the compiler | Outsmart the compiler | + +## When to Optimize + +```rust +// AFTER profiling shows this is 40% of runtime +#[inline] +fn hot_function(data: &[u8]) -> u64 { + // Optimized implementation justified by benchmarks +} + +// Clear, measurable benefit documented +/// Pre-allocated buffer for repeated formatting. +/// Benchmarks show 3x speedup for >1000 calls/sec workloads. +struct FormatterPool { + buffers: Vec, +} +``` + +## Common Premature Optimizations + +| Premature | Reality | +|-----------|---------| +| `#[inline(always)]` everywhere | Compiler usually knows better | +| `unsafe` for bounds check removal | Iterator does this safely | +| Custom allocator | Default is usually fine | +| Object pooling | Allocator is fast enough | +| Manual SIMD | Auto-vectorization works | + +## Profile Tools + +```bash +# Sampling profiler +perf record ./target/release/app && perf report + +# Flamegraph +cargo install flamegraph +cargo flamegraph + +# Criterion benchmarks +cargo bench + +# Memory profiling +valgrind --tool=massif ./target/release/app +``` + +## Document Optimizations + +```rust +/// Lookup table for fast character classification. +/// +/// # Performance +/// +/// Benchmarked with criterion (benchmarks/char_class.rs): +/// - Table lookup: 2.3ns/op +/// - Match statement: 8.7ns/op +/// +/// Justified for hot path in parser (called 10M+ times). +static CHAR_CLASS: [CharClass; 256] = [/* ... */]; +``` + +## See Also + +- [perf-profile-first](./perf-profile-first.md) - Profile before optimize +- [test-criterion-bench](./test-criterion-bench.md) - Benchmarking +- [opt-inline-small](./opt-inline-small.md) - Inline guidelines diff --git a/.agents/skills/rust-skills/rules/anti-string-for-str.md b/.agents/skills/rust-skills/rules/anti-string-for-str.md new file mode 100644 index 0000000..16de141 --- /dev/null +++ b/.agents/skills/rust-skills/rules/anti-string-for-str.md @@ -0,0 +1,122 @@ +# anti-string-for-str + +> Don't accept &String when &str works + +## Why It Matters + +`&String` is strictly less flexible than `&str`. A `&str` can be created from `String`, `&str`, literals, and slices. A `&String` requires exactly a `String`. This forces callers to allocate when they might not need to. + +## Bad + +```rust +// Forces callers to have a String +fn greet(name: &String) { + println!("Hello, {}", name); +} + +// Caller must allocate +greet(&"Alice".to_string()); // Unnecessary allocation +greet(&name); // Only works if name is String + +// In struct +struct Config { + name: String, +} + +impl Config { + fn set_name(&mut self, name: &String) { // Too restrictive + self.name = name.clone(); + } +} +``` + +## Good + +```rust +// Accept &str - works with String, &str, literals +fn greet(name: &str) { + println!("Hello, {}", name); +} + +// All these work +greet("Alice"); // String literal +greet(&name); // &String coerces to &str +greet(name.as_str()); // Explicit &str + +// In struct +impl Config { + fn set_name(&mut self, name: &str) { + self.name = name.to_string(); + } + + // Or accept owned String if caller usually has one + fn set_name_owned(&mut self, name: String) { + self.name = name; + } + + // Or be generic + fn set_name_into(&mut self, name: impl Into) { + self.name = name.into(); + } +} +``` + +## Deref Coercion + +`String` implements `Deref`, so `&String` automatically coerces to `&str`: + +```rust +fn takes_str(s: &str) { } + +let owned = String::from("hello"); +takes_str(&owned); // &String -> &str via Deref +``` + +## When to Accept &String + +Rarely. Maybe if you need `String`-specific methods: + +```rust +fn needs_capacity(s: &String) -> usize { + s.capacity() // Only String has capacity() +} +``` + +But usually you'd take `&str` and let the caller manage the `String`. + +## Pattern: Flexible APIs + +```rust +// Most flexible: accept anything that can become &str +fn process(input: impl AsRef) { + let s: &str = input.as_ref(); + // ... +} + +process("literal"); +process(String::from("owned")); +process(&some_string); +``` + +## Similar Anti-patterns + +| Anti-pattern | Better | +|--------------|--------| +| `&String` | `&str` | +| `&Vec` | `&[T]` | +| `&Box` | `&T` | +| `&PathBuf` | `&Path` | +| `&OsString` | `&OsStr` | + +## Clippy Detection + +```toml +[lints.clippy] +ptr_arg = "warn" # Catches &String, &Vec, &PathBuf +``` + +## See Also + +- [anti-vec-for-slice](./anti-vec-for-slice.md) - Similar pattern for Vec +- [own-slice-over-vec](./own-slice-over-vec.md) - Slice patterns +- [api-impl-asref](./api-impl-asref.md) - AsRef pattern diff --git a/.agents/skills/rust-skills/rules/anti-stringly-typed.md b/.agents/skills/rust-skills/rules/anti-stringly-typed.md new file mode 100644 index 0000000..fac9579 --- /dev/null +++ b/.agents/skills/rust-skills/rules/anti-stringly-typed.md @@ -0,0 +1,167 @@ +# anti-stringly-typed + +> Don't use strings where enums or newtypes would provide type safety + +## Why It Matters + +Strings are the most primitive way to represent data—they accept any value, provide no validation, and offer no IDE support. When you have a fixed set of valid values or a semantic type, use enums or newtypes. The compiler catches mistakes at compile time instead of runtime. + +## Bad + +```rust +fn process_order(status: &str, priority: &str) { + // What are valid statuses? "pending"? "Pending"? "PENDING"? + // What are valid priorities? "high"? "1"? "urgent"? + match status { + "pending" => { ... } + "completed" => { ... } + _ => panic!("unknown status"), // Runtime error + } +} + +struct User { + email: String, // Any string, even "not an email" + phone: String, // Any string, even "hello" + user_id: String, // Could be confused with other string IDs +} + +// Easy to make mistakes +process_order("complete", "high"); // Typo: "complete" vs "completed" +process_order("high", "pending"); // Swapped arguments - compiles! +``` + +## Good + +```rust +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum OrderStatus { + Pending, + Processing, + Completed, + Cancelled, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +enum Priority { + Low, + Medium, + High, + Critical, +} + +fn process_order(status: OrderStatus, priority: Priority) { + match status { + OrderStatus::Pending => { ... } + OrderStatus::Processing => { ... } + OrderStatus::Completed => { ... } + OrderStatus::Cancelled => { ... } + } // Exhaustive - compiler checks all cases +} + +// Validated newtypes +struct Email(String); +struct PhoneNumber(String); +struct UserId(u64); + +impl Email { + pub fn new(s: &str) -> Result { + if is_valid_email(s) { + Ok(Email(s.to_string())) + } else { + Err(ValidationError::InvalidEmail) + } + } +} + +struct User { + email: Email, // Must be valid email + phone: PhoneNumber, // Must be valid phone + user_id: UserId, // Can't confuse with other IDs +} + +// Compile errors catch mistakes +process_order(OrderStatus::Completed, Priority::High); // Clear and correct +process_order(Priority::High, OrderStatus::Pending); // Compile error! +``` + +## Parsing Strings to Types + +```rust +use std::str::FromStr; + +#[derive(Debug, Clone, Copy)] +enum OrderStatus { + Pending, + Processing, + Completed, + Cancelled, +} + +impl FromStr for OrderStatus { + type Err = ParseError; + + fn from_str(s: &str) -> Result { + match s.to_lowercase().as_str() { + "pending" => Ok(OrderStatus::Pending), + "processing" => Ok(OrderStatus::Processing), + "completed" => Ok(OrderStatus::Completed), + "cancelled" | "canceled" => Ok(OrderStatus::Cancelled), + _ => Err(ParseError::UnknownStatus(s.to_string())), + } + } +} + +// Parse at boundary, use types internally +fn handle_request(status_str: &str) -> Result<(), Error> { + let status: OrderStatus = status_str.parse()?; // Validate once + process_order(status); // Type-safe from here + Ok(()) +} +``` + +## With Serde + +```rust +use serde::{Serialize, Deserialize}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +enum Status { + Pending, + InProgress, + Completed, +} + +// JSON: {"status": "in_progress"} +// Deserialization validates automatically +``` + +## Error Messages + +```rust +#[derive(Debug, Clone, Copy)] +enum Color { + Red, + Green, + Blue, +} + +impl std::fmt::Display for Color { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Color::Red => write!(f, "red"), + Color::Green => write!(f, "green"), + Color::Blue => write!(f, "blue"), + } + } +} + +// Type-safe and displayable +println!("Selected color: {}", Color::Red); +``` + +## See Also + +- [api-newtype-safety](./api-newtype-safety.md) - Newtype pattern +- [api-parse-dont-validate](./api-parse-dont-validate.md) - Parse at boundaries +- [type-newtype-ids](./type-newtype-ids.md) - Type-safe IDs diff --git a/.agents/skills/rust-skills/rules/anti-type-erasure.md b/.agents/skills/rust-skills/rules/anti-type-erasure.md new file mode 100644 index 0000000..b1d1535 --- /dev/null +++ b/.agents/skills/rust-skills/rules/anti-type-erasure.md @@ -0,0 +1,134 @@ +# anti-type-erasure + +> Don't use Box when impl Trait works + +## Why It Matters + +`Box` (type erasure) introduces heap allocation and dynamic dispatch overhead. When you have a single concrete type or can use generics, `impl Trait` provides the same flexibility with zero overhead through monomorphization. + +## Bad + +```rust +// Unnecessary type erasure +fn get_iterator() -> Box> { + Box::new((0..10).map(|x| x * 2)) +} + +// Boxing for no reason +fn make_handler() -> Box i32> { + Box::new(|x| x + 1) +} + +// Vec of boxed trait objects when one type would do +fn get_validators() -> Vec> { + vec![ + Box::new(LengthValidator), + Box::new(RegexValidator), + ] +} +``` + +## Good + +```rust +// impl Trait - zero overhead, inlined +fn get_iterator() -> impl Iterator { + (0..10).map(|x| x * 2) +} + +// impl Fn - no boxing +fn make_handler() -> impl Fn(i32) -> i32 { + |x| x + 1 +} + +// When mixed types are genuinely needed, Box is OK +fn get_validators() -> Vec> { + // Actually different types at runtime - Box is appropriate + config.validators.iter() + .map(|v| v.create_validator()) + .collect() +} +``` + +## When to Use Box + +Type erasure IS appropriate when: + +```rust +// Heterogeneous collection of different types +let handlers: Vec> = vec![ + Box::new(LogHandler), + Box::new(MetricsHandler), + Box::new(AuthHandler), +]; + +// Type cannot be known at compile time +fn create_from_config(config: &Config) -> Box { + match config.db_type { + DbType::Postgres => Box::new(PostgresDb::new()), + DbType::Sqlite => Box::new(SqliteDb::new()), + } +} + +// Recursive types +struct Node { + value: i32, + children: Vec>, +} + +// Breaking cycles in complex ownership +struct EventLoop { + handlers: Vec>, +} +``` + +## Comparison + +| Approach | Allocation | Dispatch | Binary Size | +|----------|------------|----------|-------------| +| `impl Trait` | Stack/inline | Static | Larger (monomorphization) | +| `Box` | Heap | Dynamic | Smaller | +| Generics `` | Stack/inline | Static | Larger | + +## impl Trait Positions + +```rust +// Return position - caller doesn't need to know concrete type +fn process() -> impl Future { } + +// Argument position - like generics but simpler +fn handle(handler: impl Handler) { } + +// Can't use in trait definitions (use associated types instead) +trait Processor { + type Output: Display; // Not impl Display + fn process(&self) -> Self::Output; +} +``` + +## Pattern: Enum Instead of dyn + +```rust +// Instead of Box +enum Shape { + Circle { radius: f64 }, + Rectangle { width: f64, height: f64 }, + Triangle { base: f64, height: f64 }, +} + +impl Shape { + fn area(&self) -> f64 { + match self { + Shape::Circle { radius } => PI * radius * radius, + Shape::Rectangle { width, height } => width * height, + Shape::Triangle { base, height } => 0.5 * base * height, + } + } +} +``` + +## See Also + +- [anti-over-abstraction](./anti-over-abstraction.md) - Excessive generics +- [type-generic-bounds](./type-generic-bounds.md) - Generic constraints +- [mem-box-large-variant](./mem-box-large-variant.md) - Boxing enum variants diff --git a/.agents/skills/rust-skills/rules/anti-unwrap-abuse.md b/.agents/skills/rust-skills/rules/anti-unwrap-abuse.md new file mode 100644 index 0000000..ca33261 --- /dev/null +++ b/.agents/skills/rust-skills/rules/anti-unwrap-abuse.md @@ -0,0 +1,143 @@ +# anti-unwrap-abuse + +> Don't use `.unwrap()` in production code + +## Why It Matters + +`.unwrap()` panics on `None` or `Err`, crashing your program. In production, this means lost data, failed requests, and unhappy users. It also makes debugging harder since panic messages often lack context. + +## Bad + +```rust +// Crashes if file doesn't exist +let content = std::fs::read_to_string("config.toml").unwrap(); + +// Crashes on invalid input +let num: i32 = user_input.parse().unwrap(); + +// Crashes if key missing +let value = map.get("key").unwrap(); + +// Crashes if channel closed +let msg = receiver.recv().unwrap(); +``` + +## Good + +```rust +// Propagate with ? +fn load_config() -> Result { + let content = std::fs::read_to_string("config.toml")?; + Ok(toml::from_str(&content)?) +} + +// Provide default +let num: i32 = user_input.parse().unwrap_or(0); + +// Handle missing key +let value = map.get("key").ok_or(Error::MissingKey)?; + +// Or use if-let +if let Some(value) = map.get("key") { + process(value); +} + +// Channel with proper handling +match receiver.recv() { + Ok(msg) => handle(msg), + Err(_) => break, // Channel closed +} +``` + +## When unwrap() Is Acceptable + +```rust +// 1. Tests - panics are expected failures +#[test] +fn test_parse() { + let result = parse("valid").unwrap(); // OK in tests + assert_eq!(result, expected); +} + +// 2. Const/static initialization (compile-time guaranteed) +static REGEX: Lazy = Lazy::new(|| { + Regex::new(r"^\d+$").unwrap() // Known-valid pattern +}); + +// 3. After a check that guarantees success +if map.contains_key("key") { + let value = map.get("key").unwrap(); // Just checked +} +// Better: use if-let or entry API instead + +// 4. Truly impossible cases with proof comment +let last = vec.pop().unwrap(); +// OK only if you just checked !vec.is_empty() +// Better: use last() or pattern match +``` + +## Alternatives to unwrap() + +```rust +// unwrap_or - provide default +let x = opt.unwrap_or(default); + +// unwrap_or_default - use Default trait +let x = opt.unwrap_or_default(); + +// unwrap_or_else - compute default lazily +let x = opt.unwrap_or_else(|| expensive_default()); + +// ? operator - propagate errors +let x = opt.ok_or(Error::Missing)?; + +// if let - handle Some/Ok case +if let Some(x) = opt { + use_x(x); +} + +// match - handle all cases +match opt { + Some(x) => use_x(x), + None => handle_none(), +} + +// map - transform if present +let y = opt.map(|x| x + 1); + +// and_then - chain fallible operations +let z = opt.and_then(|x| x.checked_add(1)); +``` + +## expect() Is Slightly Better + +```rust +// unwrap() - no context +let file = File::open(path).unwrap(); +// Panics with: "called `Result::unwrap()` on an `Err` value: Os { code: 2, ... }" + +// expect() - adds context +let file = File::open(path) + .expect("config file should exist at startup"); +// Panics with: "config file should exist at startup: Os { code: 2, ... }" + +// But still use only for invariants, not error handling +``` + +## Clippy Lint + +```rust +// Enable these lints to catch unwrap usage: +#![warn(clippy::unwrap_used)] +#![warn(clippy::expect_used)] // Stricter + +// Or per-function: +#[allow(clippy::unwrap_used)] +fn tests_only() { } +``` + +## See Also + +- [err-question-mark](err-question-mark.md) - Use ? for propagation +- [err-result-over-panic](err-result-over-panic.md) - Return Result instead of panicking +- [anti-expect-lazy](anti-expect-lazy.md) - Don't use expect for recoverable errors diff --git a/.agents/skills/rust-skills/rules/anti-vec-for-slice.md b/.agents/skills/rust-skills/rules/anti-vec-for-slice.md new file mode 100644 index 0000000..ed50a03 --- /dev/null +++ b/.agents/skills/rust-skills/rules/anti-vec-for-slice.md @@ -0,0 +1,121 @@ +# anti-vec-for-slice + +> Don't accept &Vec when &[T] works + +## Why It Matters + +`&Vec` is strictly less flexible than `&[T]`. A slice can be created from `Vec`, arrays, and other slice-like types. Accepting `&Vec` forces callers to have exactly a `Vec`, preventing them from using arrays, slices, or other collections. + +## Bad + +```rust +// Forces callers to have a Vec +fn sum(numbers: &Vec) -> i32 { + numbers.iter().sum() +} + +// Caller must allocate +let arr = [1, 2, 3, 4, 5]; +sum(&arr.to_vec()); // Unnecessary allocation + +// Slice won't work +let slice: &[i32] = &[1, 2, 3]; +// sum(slice); // Error: expected &Vec +``` + +## Good + +```rust +// Accept slice - works with Vec, arrays, slices +fn sum(numbers: &[i32]) -> i32 { + numbers.iter().sum() +} + +// All these work +sum(&[1, 2, 3, 4, 5]); // Array +sum(&vec![1, 2, 3]); // Vec +sum(&numbers[1..3]); // Slice of slice +sum(numbers.as_slice()); // Explicit slice +``` + +## Deref Coercion + +`Vec` implements `Deref`, so `&Vec` automatically coerces to `&[T]`: + +```rust +fn takes_slice(s: &[i32]) { } + +let vec = vec![1, 2, 3]; +takes_slice(&vec); // &Vec -> &[i32] via Deref +``` + +## Mutable Slices + +Same applies to `&mut`: + +```rust +// Bad +fn double(numbers: &mut Vec) { + for n in numbers.iter_mut() { + *n *= 2; + } +} + +// Good +fn double(numbers: &mut [i32]) { + for n in numbers.iter_mut() { + *n *= 2; + } +} +``` + +## When to Accept &Vec + +Rarely. Only when you need Vec-specific operations: + +```rust +fn needs_capacity(v: &Vec) -> usize { + v.capacity() // Only Vec has capacity +} + +fn might_grow(v: &mut Vec) { + v.push(42); // Slice can't push +} +``` + +## Pattern: Accepting Multiple Types + +```rust +// Accept anything that can be viewed as a slice +fn process>(data: T) { + let bytes: &[u8] = data.as_ref(); + // ... +} + +process(&[1u8, 2, 3]); // Array +process(vec![1u8, 2, 3]); // Vec +process(&some_vec); // &Vec +process(b"bytes"); // Byte string +``` + +## Similar Anti-patterns + +| Anti-pattern | Better | +|--------------|--------| +| `&Vec` | `&[T]` | +| `&String` | `&str` | +| `&PathBuf` | `&Path` | +| `&Box` | `&T` | + +## Clippy Detection + +```toml +[lints.clippy] +ptr_arg = "warn" # Catches &Vec, &String, &PathBuf +``` + +## See Also + +- [anti-string-for-str](./anti-string-for-str.md) - Similar for String +- [own-slice-over-vec](./own-slice-over-vec.md) - Slice patterns +- [api-impl-asref](./api-impl-asref.md) - AsRef pattern diff --git a/.agents/skills/rust-skills/rules/api-builder-must-use.md b/.agents/skills/rust-skills/rules/api-builder-must-use.md new file mode 100644 index 0000000..988849d --- /dev/null +++ b/.agents/skills/rust-skills/rules/api-builder-must-use.md @@ -0,0 +1,143 @@ +# api-builder-must-use + +> Mark builder methods with `#[must_use]` to prevent silent drops + +## Why It Matters + +Builder pattern methods return a modified builder. Without `#[must_use]`, calling a builder method and ignoring the return value silently does nothing—the builder is dropped, and the configuration is lost. This creates confusing bugs where code appears correct but has no effect. + +## Bad + +```rust +struct RequestBuilder { + url: String, + timeout: Option, + headers: Vec<(String, String)>, +} + +impl RequestBuilder { + fn timeout(mut self, duration: Duration) -> Self { + self.timeout = Some(duration); + self + } + + fn header(mut self, key: &str, value: &str) -> Self { + self.headers.push((key.to_string(), value.to_string())); + self + } +} + +// Bug: builder methods are ignored - no warning! +let request = RequestBuilder::new("https://api.example.com"); +request.timeout(Duration::from_secs(30)); // Dropped silently! +request.header("Authorization", "Bearer token"); // Dropped silently! +let response = request.send(); // Sends with no timeout or headers +``` + +## Good + +```rust +struct RequestBuilder { + url: String, + timeout: Option, + headers: Vec<(String, String)>, +} + +impl RequestBuilder { + #[must_use = "builder methods return modified builder - chain or assign"] + fn timeout(mut self, duration: Duration) -> Self { + self.timeout = Some(duration); + self + } + + #[must_use = "builder methods return modified builder - chain or assign"] + fn header(mut self, key: &str, value: &str) -> Self { + self.headers.push((key.to_string(), value.to_string())); + self + } +} + +// Now warns: unused return value that must be used +let request = RequestBuilder::new("https://api.example.com"); +request.timeout(Duration::from_secs(30)); // Warning! + +// Correct: chain methods +let response = RequestBuilder::new("https://api.example.com") + .timeout(Duration::from_secs(30)) + .header("Authorization", "Bearer token") + .send(); +``` + +## Apply to Entire Type + +```rust +#[must_use = "builders do nothing unless consumed"] +struct ConfigBuilder { + log_level: Level, + max_connections: usize, +} + +// Now all methods returning Self warn if ignored +impl ConfigBuilder { + fn log_level(mut self, level: Level) -> Self { + self.log_level = level; + self + } + + fn max_connections(mut self, n: usize) -> Self { + self.max_connections = n; + self + } + + fn build(self) -> Config { + Config { + log_level: self.log_level, + max_connections: self.max_connections, + } + } +} +``` + +## Message Guidelines + +```rust +// Descriptive message helps users understand +#[must_use = "builder methods return modified builder"] +fn with_foo(self, foo: Foo) -> Self { ... } + +#[must_use = "this creates a new String and does not modify the original"] +fn to_uppercase(&self) -> String { ... } + +#[must_use = "iterator adaptors are lazy - use .collect() to consume"] +fn map(self, f: F) -> Map { ... } +``` + +## Clippy Lint + +```toml +[lints.clippy] +must_use_candidate = "warn" # Suggests where #[must_use] would help +return_self_not_must_use = "warn" # Specifically for -> Self methods +``` + +## Standard Library Examples + +```rust +// std::Option - must_use on map, and, or +let x: Option = Some(5); +x.map(|v| v * 2); // Warning: unused return value + +// std::Result - must_use on the type itself +#[must_use = "this `Result` may be an `Err` variant, which should be handled"] +pub enum Result { ... } + +// Iterator adaptors +let v = vec![1, 2, 3]; +v.iter().map(|x| x * 2); // Warning: iterators are lazy +``` + +## See Also + +- [api-builder-pattern](./api-builder-pattern.md) - Builder pattern best practices +- [api-must-use](./api-must-use.md) - General must_use guidelines +- [err-result-over-panic](./err-result-over-panic.md) - Result types are must_use diff --git a/.agents/skills/rust-skills/rules/api-builder-pattern.md b/.agents/skills/rust-skills/rules/api-builder-pattern.md new file mode 100644 index 0000000..f825d1d --- /dev/null +++ b/.agents/skills/rust-skills/rules/api-builder-pattern.md @@ -0,0 +1,187 @@ +# api-builder-pattern + +> Use Builder pattern for complex construction + +## Why It Matters + +When a type has many optional parameters or complex initialization, the Builder pattern provides a clear, flexible API. It avoids constructors with many parameters (which are error-prone) and makes the code self-documenting. + +## Bad + +```rust +// Constructor with many parameters - hard to read, easy to get wrong +let client = Client::new( + "https://api.example.com", // Which is which? + 30, // Timeout? Retries? + true, // What does this mean? + None, + Some("auth_token"), + false, +); + +// Or many Option fields +struct Client { + url: String, + timeout: Option, + retries: Option, + // ... 10 more optional fields +} +``` + +## Good + +```rust +#[derive(Default)] +#[must_use = "builders do nothing unless you call build()"] +pub struct ClientBuilder { + base_url: Option, + timeout: Option, + max_retries: u32, + auth_token: Option, +} + +impl ClientBuilder { + pub fn new() -> Self { + Self::default() + } + + /// Sets the base URL for all requests. + pub fn base_url(mut self, url: impl Into) -> Self { + self.base_url = Some(url.into()); + self + } + + /// Sets the request timeout. Default is 30 seconds. + pub fn timeout(mut self, timeout: Duration) -> Self { + self.timeout = Some(timeout); + self + } + + /// Sets the maximum number of retries. Default is 3. + pub fn max_retries(mut self, n: u32) -> Self { + self.max_retries = n; + self + } + + /// Sets the authentication token. + pub fn auth_token(mut self, token: impl Into) -> Self { + self.auth_token = Some(token.into()); + self + } + + /// Builds the client with the configured options. + pub fn build(self) -> Result { + let base_url = self.base_url + .ok_or(BuilderError::MissingBaseUrl)?; + + Ok(Client { + base_url, + timeout: self.timeout.unwrap_or(Duration::from_secs(30)), + max_retries: self.max_retries, + auth_token: self.auth_token, + }) + } +} + +// Usage - clear and self-documenting +let client = ClientBuilder::new() + .base_url("https://api.example.com") + .timeout(Duration::from_secs(10)) + .max_retries(5) + .auth_token("secret") + .build()?; +``` + +## Builder Variations + +```rust +// 1. Infallible builder (build() returns T, not Result) +impl WidgetBuilder { + pub fn build(self) -> Widget { + Widget { + color: self.color.unwrap_or(Color::Black), + size: self.size.unwrap_or(Size::Medium), + } + } +} + +// 2. Typestate builder (compile-time required field checking) +pub struct ClientBuilder { + url: Url, + timeout: Option, +} + +pub struct NoUrl; +pub struct HasUrl(String); + +impl ClientBuilder { + pub fn new() -> Self { + Self { url: NoUrl, timeout: None } + } + + pub fn url(self, url: String) -> ClientBuilder { + ClientBuilder { url: HasUrl(url), timeout: self.timeout } + } +} + +impl ClientBuilder { + pub fn build(self) -> Client { + // url is guaranteed to be set + Client { url: self.url.0, timeout: self.timeout } + } +} + +// 3. Consuming vs borrowing (consuming is more common) +// Consuming (takes self) +pub fn timeout(mut self, t: Duration) -> Self { ... } + +// Borrowing (takes &mut self, allows reuse) +pub fn timeout(&mut self, t: Duration) -> &mut Self { ... } +``` + +## Evidence from reqwest + +```rust +// https://github.com/seanmonstar/reqwest/blob/master/src/async_impl/client.rs + +#[must_use] +pub struct ClientBuilder { + config: Config, +} + +impl ClientBuilder { + pub fn new() -> ClientBuilder { + ClientBuilder { + config: Config::default(), + } + } + + pub fn timeout(mut self, timeout: Duration) -> ClientBuilder { + self.config.timeout = Some(timeout); + self + } + + pub fn build(self) -> Result { + // Validation and construction + } +} +``` + +## Key Attributes + +```rust +#[derive(Default)] // Enables MyBuilder::default() +#[must_use = "builders do nothing unless you call build()"] +pub struct MyBuilder { ... } + +impl MyBuilder { + #[must_use] // Each method should have this + pub fn option(mut self, value: T) -> Self { ... } +} +``` + +## See Also + +- [api-builder-must-use](api-builder-must-use.md) - Add #[must_use] to builders +- [api-typestate](api-typestate.md) - Compile-time state machines +- [api-impl-into](api-impl-into.md) - Accept impl Into for flexibility diff --git a/.agents/skills/rust-skills/rules/api-common-traits.md b/.agents/skills/rust-skills/rules/api-common-traits.md new file mode 100644 index 0000000..64a61ae --- /dev/null +++ b/.agents/skills/rust-skills/rules/api-common-traits.md @@ -0,0 +1,165 @@ +# api-common-traits + +> Implement standard traits (Debug, Clone, PartialEq, etc.) for public types + +## Why It Matters + +Standard traits make your types interoperable with the Rust ecosystem. `Debug` enables `println!("{:?}")` and error messages. `Clone` allows explicit duplication. `PartialEq` enables `==`. Without these, users can't use your types in common patterns like testing, collections, or debugging. + +## Bad + +```rust +// Bare struct - severely limited usability +pub struct Point { + pub x: f64, + pub y: f64, +} + +// Can't debug +println!("{:?}", point); // Error: Debug not implemented + +// Can't compare +if point1 == point2 { } // Error: PartialEq not implemented + +// Can't use in HashMap +let mut map: HashMap = HashMap::new(); // Error: Hash not implemented + +// Can't clone +let copy = point.clone(); // Error: Clone not implemented +``` + +## Good + +```rust +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct Point { + pub x: f64, + pub y: f64, +} + +// Now everything works +println!("{:?}", point); +assert_eq!(point1, point2); +let copy = point; // Copy, not just Clone + +// For hashable types +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct UserId(u64); + +let mut map: HashMap = HashMap::new(); +``` + +## Trait Derivation Guide + +| Trait | Derive When | Requirements | +|-------|-------------|--------------| +| `Debug` | Always for public types | All fields implement Debug | +| `Clone` | Type can be duplicated | All fields implement Clone | +| `Copy` | Small, simple types | All fields implement Copy, no Drop | +| `PartialEq` | Comparison makes sense | All fields implement PartialEq | +| `Eq` | Total equality | PartialEq, no floating-point fields | +| `Hash` | Used as HashMap/HashSet key | Eq, consistent with PartialEq | +| `Default` | Sensible default exists | All fields implement Default | +| `PartialOrd` | Ordering makes sense | PartialEq, all fields implement PartialOrd | +| `Ord` | Total ordering | Eq + PartialOrd, no floating-point | + +## Common Trait Bundles + +```rust +// ID types +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct EntityId(u64); + +// Value types +#[derive(Debug, Clone, Copy, PartialEq, Default)] +pub struct Vector2 { x: f32, y: f32 } + +// Configuration +#[derive(Debug, Clone, PartialEq, Default)] +pub struct Config { + name: String, + options: HashMap, +} + +// Error types +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ParseError { + InvalidSyntax(String), + UnexpectedToken(Token), +} +``` + +## Manual Implementations + +```rust +// When derive doesn't do what you want +struct CaseInsensitiveString(String); + +impl PartialEq for CaseInsensitiveString { + fn eq(&self, other: &Self) -> bool { + self.0.to_lowercase() == other.0.to_lowercase() + } +} + +impl Eq for CaseInsensitiveString {} + +impl Hash for CaseInsensitiveString { + fn hash(&self, state: &mut H) { + // Must be consistent with PartialEq + self.0.to_lowercase().hash(state); + } +} + +// Custom Debug for sensitive data +struct Password(String); + +impl Debug for Password { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "Password([REDACTED])") + } +} +``` + +## Serde Traits + +```rust +use serde::{Serialize, Deserialize}; + +// For serializable types +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct ApiResponse { + pub status: String, + pub data: Vec, +} + +// With custom serialization +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Config { + #[serde(default)] + pub verbose: bool, + + #[serde(skip_serializing_if = "Option::is_none")] + pub api_key: Option, +} +``` + +## Minimum Recommended + +```rust +// At minimum, public types should have: +#[derive(Debug, Clone, PartialEq)] +pub struct MyType { ... } + +// Add based on use case: +// + Eq, Hash → for HashMap keys +// + Ord, PartialOrd → for BTreeMap, sorting +// + Default → for Option::unwrap_or_default() +// + Copy → for small value types +// + Serialize → for serialization +``` + +## See Also + +- [own-copy-small](./own-copy-small.md) - When to implement Copy +- [api-default-impl](./api-default-impl.md) - Implementing Default +- [doc-examples-section](./doc-examples-section.md) - Documenting trait implementations diff --git a/.agents/skills/rust-skills/rules/api-default-impl.md b/.agents/skills/rust-skills/rules/api-default-impl.md new file mode 100644 index 0000000..be82a02 --- /dev/null +++ b/.agents/skills/rust-skills/rules/api-default-impl.md @@ -0,0 +1,177 @@ +# api-default-impl + +> Implement `Default` for types with sensible default values + +## Why It Matters + +`Default` is a standard trait that provides a canonical way to create a default instance. It integrates with many ecosystem patterns: `Option::unwrap_or_default()`, `#[derive(Default)]`, struct update syntax `..Default::default()`, and generic code that requires `T: Default`. Implementing it makes your types more ergonomic. + +## Bad + +```rust +struct Config { + timeout: Duration, + retries: u32, + verbose: bool, +} + +impl Config { + // Custom constructor - works but non-standard + fn new() -> Self { + Config { + timeout: Duration::from_secs(30), + retries: 3, + verbose: false, + } + } +} + +// Can't use with standard patterns +let config: Config = Default::default(); // Error: Default not implemented +let timeout = settings.get("timeout").unwrap_or_default(); // Won't work +``` + +## Good + +```rust +#[derive(Default)] +struct Config { + #[default = Duration::from_secs(30)] // Nightly, or implement manually + timeout: Duration, + retries: u32, // Defaults to 0 with derive + verbose: bool, // Defaults to false with derive +} + +// Or implement manually for custom defaults +impl Default for Config { + fn default() -> Self { + Config { + timeout: Duration::from_secs(30), + retries: 3, + verbose: false, + } + } +} + +// Now works with all standard patterns +let config = Config::default(); +let config = Config { retries: 5, ..Default::default() }; +let value = map.get("key").cloned().unwrap_or_default(); +``` + +## Derive vs Manual + +```rust +// Derive: all fields use their own Default +#[derive(Default)] +struct Simple { + count: u32, // 0 + name: String, // "" + items: Vec, // [] +} + +// Manual: when you need custom defaults +struct Connection { + host: String, + port: u16, + timeout: Duration, +} + +impl Default for Connection { + fn default() -> Self { + Connection { + host: "localhost".to_string(), + port: 8080, + timeout: Duration::from_secs(30), + } + } +} +``` + +## Builder with Default + +```rust +#[derive(Default)] +struct ServerBuilder { + host: String, + port: u16, + workers: usize, +} + +impl ServerBuilder { + fn host(mut self, host: impl Into) -> Self { + self.host = host.into(); + self + } + + fn port(mut self, port: u16) -> Self { + self.port = port; + self + } +} + +// Clean initialization +let server = ServerBuilder::default() + .host("0.0.0.0") + .port(3000) + .build(); +``` + +## Default with Required Fields + +```rust +// When some fields have no sensible default, don't implement Default +struct User { + id: UserId, // No sensible default + name: String, // Could default to "" +} + +// Instead, provide a constructor +impl User { + fn new(id: UserId, name: impl Into) -> Self { + User { id, name: name.into() } + } +} + +// Or use builder with required fields +struct UserBuilder { + id: Option, + name: String, +} + +impl Default for UserBuilder { + fn default() -> Self { + UserBuilder { + id: None, + name: String::new(), + } + } +} +``` + +## Generic Default + +```rust +// Require Default in generic bounds when needed +fn create_or_default(opt: Option) -> T { + opt.unwrap_or_default() +} + +// PhantomData is Default regardless of T +use std::marker::PhantomData; +struct Wrapper { + _marker: PhantomData, +} + +impl Default for Wrapper { + fn default() -> Self { + Wrapper { _marker: PhantomData } + } +} +``` + +## See Also + +- [api-builder-pattern](./api-builder-pattern.md) - Building complex types +- [api-common-traits](./api-common-traits.md) - Other common traits to implement +- [api-from-not-into](./api-from-not-into.md) - Conversion traits diff --git a/.agents/skills/rust-skills/rules/api-extension-trait.md b/.agents/skills/rust-skills/rules/api-extension-trait.md new file mode 100644 index 0000000..92e8dd9 --- /dev/null +++ b/.agents/skills/rust-skills/rules/api-extension-trait.md @@ -0,0 +1,163 @@ +# api-extension-trait + +> Use extension traits to add methods to external types + +## Why It Matters + +Rust's orphan rules prevent implementing external traits on external types. Extension traits provide a workaround: define a new trait with your methods, then implement it for the external type. This pattern is used extensively in the ecosystem (e.g., `itertools::Itertools`, `tokio::AsyncReadExt`). + +## Bad + +```rust +// Can't add methods directly to external types +impl Vec { + fn as_hex(&self) -> String { + // Error: cannot define inherent impl for a type outside this crate + } +} + +// Can't implement external trait for external type +impl SomeExternalTrait for Vec { + // Error: orphan rules violation +} +``` + +## Good + +```rust +// Define an extension trait +pub trait ByteSliceExt { + fn as_hex(&self) -> String; + fn is_ascii_printable(&self) -> bool; +} + +// Implement for the external type +impl ByteSliceExt for [u8] { + fn as_hex(&self) -> String { + self.iter() + .map(|b| format!("{:02x}", b)) + .collect() + } + + fn is_ascii_printable(&self) -> bool { + self.iter().all(|b| b.is_ascii_graphic() || b.is_ascii_whitespace()) + } +} + +// Usage: import the trait to use the methods +use my_crate::ByteSliceExt; + +let data: &[u8] = b"hello"; +println!("{}", data.as_hex()); // "68656c6c6f" +``` + +## Convention: Ext Suffix + +```rust +// Standard naming: TypeExt for extending Type +pub trait OptionExt { + fn unwrap_or_log(self, msg: &str) -> Option; +} + +impl OptionExt for Option { + fn unwrap_or_log(self, msg: &str) -> Option { + if self.is_none() { + log::warn!("{}", msg); + } + self + } +} + +// For generic extensions +pub trait ResultExt { + fn log_err(self) -> Self; +} + +impl ResultExt for Result { + fn log_err(self) -> Self { + if let Err(ref e) = self { + log::error!("{}", e); + } + self + } +} +``` + +## Ecosystem Examples + +```rust +// itertools::Itertools +use itertools::Itertools; +let groups = vec![1, 1, 2, 2, 3].into_iter().group_by(|x| *x); + +// futures::StreamExt +use futures::StreamExt; +let next = stream.next().await; + +// tokio::io::AsyncReadExt +use tokio::io::AsyncReadExt; +let mut buf = [0u8; 1024]; +reader.read(&mut buf).await?; + +// anyhow::Context +use anyhow::Context; +let content = std::fs::read_to_string(path) + .with_context(|| format!("failed to read {}", path))?; +``` + +## Scoped Extensions + +```rust +// Extension only visible where imported +mod string_utils { + pub trait StringExt { + fn truncate_ellipsis(&self, max_len: usize) -> String; + } + + impl StringExt for str { + fn truncate_ellipsis(&self, max_len: usize) -> String { + if self.len() <= max_len { + self.to_string() + } else { + format!("{}...", &self[..max_len.saturating_sub(3)]) + } + } + } +} + +// Only available when explicitly imported +use string_utils::StringExt; +let short = "very long string".truncate_ellipsis(10); +``` + +## Generic Extensions with Bounds + +```rust +pub trait VecExt { + fn push_if_unique(&mut self, item: T) + where + T: PartialEq; +} + +impl VecExt for Vec { + fn push_if_unique(&mut self, item: T) + where + T: PartialEq, + { + if !self.contains(&item) { + self.push(item); + } + } +} + +// Works with any T: PartialEq +let mut v = vec![1, 2, 3]; +v.push_if_unique(2); // No-op +v.push_if_unique(4); // Adds 4 +``` + +## See Also + +- [api-sealed-trait](./api-sealed-trait.md) - Controlling trait implementations +- [api-impl-into](./api-impl-into.md) - Using standard conversion traits +- [name-as-free](./name-as-free.md) - Naming conventions for conversions diff --git a/.agents/skills/rust-skills/rules/api-from-not-into.md b/.agents/skills/rust-skills/rules/api-from-not-into.md new file mode 100644 index 0000000..8500a80 --- /dev/null +++ b/.agents/skills/rust-skills/rules/api-from-not-into.md @@ -0,0 +1,146 @@ +# api-from-not-into + +> Implement `From`, not `Into` - From gives you Into for free + +## Why It Matters + +The standard library has a blanket implementation: `impl Into for T where U: From`. This means implementing `From for U` automatically gives you `Into for T`. Implementing `Into` directly bypasses this and is considered non-idiomatic. Always implement `From`. + +## Bad + +```rust +struct UserId(u64); + +// Non-idiomatic: implementing Into directly +impl Into for u64 { + fn into(self) -> UserId { + UserId(self) + } +} + +// Works, but now you can't use From syntax +let id = UserId::from(42); // Error: From not implemented +let id: UserId = 42.into(); // Works, but limited +``` + +## Good + +```rust +struct UserId(u64); + +// Idiomatic: implement From +impl From for UserId { + fn from(id: u64) -> Self { + UserId(id) + } +} + +// Now both work automatically +let id = UserId::from(42); // From syntax +let id: UserId = 42.into(); // Into syntax (via blanket impl) + +// And Into bound works in generics +fn process(id: impl Into) { + let id: UserId = id.into(); +} +process(42u64); // Works! +``` + +## Blanket Implementation + +```rust +// This is in std, you don't write it +impl Into for T +where + U: From, +{ + fn into(self) -> U { + U::from(self) + } +} + +// So when you implement From: +impl From for MyType { ... } + +// You automatically get: +// impl Into for String { ... } +``` + +## Multiple From Implementations + +```rust +struct Email(String); + +impl From for Email { + fn from(s: String) -> Self { + Email(s) + } +} + +impl From<&str> for Email { + fn from(s: &str) -> Self { + Email(s.to_string()) + } +} + +// All of these work +let e1 = Email::from("test@example.com"); +let e2 = Email::from(String::from("test@example.com")); +let e3: Email = "test@example.com".into(); +let e4: Email = String::from("test@example.com").into(); +``` + +## TryFrom for Fallible Conversions + +```rust +use std::convert::TryFrom; + +struct PositiveInt(u32); + +// Fallible conversion +impl TryFrom for PositiveInt { + type Error = &'static str; + + fn try_from(value: i32) -> Result { + if value > 0 { + Ok(PositiveInt(value as u32)) + } else { + Err("value must be positive") + } + } +} + +// Usage +let pos = PositiveInt::try_from(42)?; // From-style +let pos: PositiveInt = 42.try_into()?; // Into-style (via blanket) +``` + +## Clippy Lint + +```toml +[lints.clippy] +from_over_into = "warn" # Warns when implementing Into instead of From +``` + +```rust +// Clippy will warn: +impl Into for Foo { // Warning: prefer From + fn into(self) -> Bar { ... } +} +``` + +## When Into IS Needed (Rare) + +```rust +// Only when implementing for external types in specific trait bounds +// This is very rare and usually indicates a design issue + +// Example: you can't implement From for ExternalB +// because of orphan rules. But you usually shouldn't need to. +``` + +## See Also + +- [api-impl-into](./api-impl-into.md) - Using Into in function parameters +- [err-from-impl](./err-from-impl.md) - From for error types +- [api-newtype-safety](./api-newtype-safety.md) - Newtype conversions diff --git a/.agents/skills/rust-skills/rules/api-impl-asref.md b/.agents/skills/rust-skills/rules/api-impl-asref.md new file mode 100644 index 0000000..688f8cc --- /dev/null +++ b/.agents/skills/rust-skills/rules/api-impl-asref.md @@ -0,0 +1,142 @@ +# api-impl-asref + +> Use `AsRef` when you only need to borrow the inner data + +## Why It Matters + +`AsRef` provides a cheap borrowed view of data without taking ownership or copying. Functions accepting `impl AsRef` can work with multiple types that contain or represent `T`, making APIs flexible while avoiding unnecessary allocations. Use `AsRef` when you only need to read, `Into` when you need to own. + +## Bad + +```rust +// Forces callers to provide exact types +fn process_text(text: &str) { ... } +fn read_file(path: &Path) { ... } + +// Can't call directly with owned types +let s = String::from("hello"); +process_text(&s); // Works but verbose + +let p = PathBuf::from("/file"); +read_file(&p); // Works but verbose +read_file("/file"); // Error! &str != &Path +``` + +## Good + +```rust +// Accept anything that can be viewed as the target type +fn process_text(text: impl AsRef) { + let s: &str = text.as_ref(); + println!("{}", s); +} + +fn read_file(path: impl AsRef) -> io::Result> { + std::fs::read(path.as_ref()) +} + +// All of these work: +process_text("literal"); // &str +process_text(String::from("owned")); // String +process_text(Cow::from("cow")); // Cow + +read_file("/path/to/file"); // &str +read_file(Path::new("/path")); // &Path +read_file(PathBuf::from("/path")); // PathBuf +read_file(OsStr::new("/path")); // &OsStr +``` + +## AsRef vs Into vs Borrow + +```rust +// AsRef: cheap borrow, no ownership transfer +fn read(p: impl AsRef) { + let path: &Path = p.as_ref(); +} + +// Into: ownership transfer, may allocate +fn store(p: impl Into) { + let owned: PathBuf = p.into(); +} + +// Borrow: like AsRef but with Eq/Hash consistency guarantee +use std::borrow::Borrow; +fn lookup(map: &HashMap, key: &Q) -> Option<&V> +where + String: Borrow, + Q: Hash + Eq, +{ + map.get(key) +} +``` + +## Implement AsRef for Custom Types + +```rust +struct Name(String); + +impl AsRef for Name { + fn as_ref(&self) -> &str { + &self.0 + } +} + +impl AsRef<[u8]> for Name { + fn as_ref(&self) -> &[u8] { + self.0.as_bytes() + } +} + +// Now Name works with functions expecting AsRef +fn greet(name: impl AsRef) { + println!("Hello, {}!", name.as_ref()); +} + +greet(Name("Alice".into())); +``` + +## Common AsRef Implementations + +```rust +// Standard library provides many +impl AsRef for String { ... } +impl AsRef for str { ... } +impl AsRef<[u8]> for str { ... } +impl AsRef<[u8]> for String { ... } +impl AsRef<[u8]> for Vec { ... } +impl AsRef for str { ... } +impl AsRef for String { ... } +impl AsRef for PathBuf { ... } +impl AsRef for OsStr { ... } +impl AsRef for str { ... } +``` + +## When to Use Which + +| Trait | Use When | +|-------|----------| +| `&T` | Single type, simple API | +| `AsRef` | Read-only access, multiple input types | +| `Into` | Need to store/own the value | +| `Borrow` | HashMap/HashSet keys, Eq/Hash needed | +| `Deref` | Smart pointer semantics | + +## Pattern: Optional AsRef Bound + +```rust +// When T itself might be passed +fn process, U>(value: T) { + let inner: &U = value.as_ref(); +} + +// More flexible: accept T or &T +fn process + ?Sized, U: ?Sized>(value: &T) { + let inner: &U = value.as_ref(); +} +``` + +## See Also + +- [api-impl-into](./api-impl-into.md) - When to use Into instead +- [own-slice-over-vec](./own-slice-over-vec.md) - Using slices for flexibility +- [own-borrow-over-clone](./own-borrow-over-clone.md) - Preferring borrows diff --git a/.agents/skills/rust-skills/rules/api-impl-into.md b/.agents/skills/rust-skills/rules/api-impl-into.md new file mode 100644 index 0000000..9d67075 --- /dev/null +++ b/.agents/skills/rust-skills/rules/api-impl-into.md @@ -0,0 +1,160 @@ +# api-impl-into + +> Accept `impl Into` for flexible APIs, implement `From` for conversions + +## Why It Matters + +APIs that accept `impl Into` are ergonomic—callers can pass the target type directly or any type that converts to it. This reduces boilerplate `.into()` calls at call sites. Implement `From` rather than `Into` because `From` implies `Into` through a blanket implementation. + +## Bad + +```rust +// Requires exact type - forces callers to convert +fn process_path(path: PathBuf) { ... } +fn set_name(name: String) { ... } + +// Caller must convert explicitly +process_path(PathBuf::from("/path/to/file")); +process_path("/path/to/file".to_path_buf()); // Verbose +process_path("/path/to/file".into()); // Explicit + +set_name(String::from("Alice")); +set_name("Alice".to_string()); // Verbose +``` + +## Good + +```rust +// Accept anything that converts to the target type +fn process_path(path: impl Into) { + let path = path.into(); // Convert once inside + // ... +} + +fn set_name(name: impl Into) { + let name = name.into(); + // ... +} + +// Callers are ergonomic +process_path("/path/to/file"); // &str converts automatically +process_path(PathBuf::from(".")); // PathBuf works too + +set_name("Alice"); // &str +set_name(String::from("Alice")); // String +set_name(format!("User-{}", id)); // String from format! +``` + +## Implement From, Not Into + +```rust +struct UserId(u64); + +// ✅ Implement From +impl From for UserId { + fn from(id: u64) -> Self { + UserId(id) + } +} + +// Into is automatically provided by blanket impl +let id: UserId = 42u64.into(); // Works! + +// ❌ Don't implement Into directly +impl Into for u64 { + fn into(self) -> UserId { + UserId(self) // This works but is non-idiomatic + } +} +``` + +## Common Conversions + +```rust +// String-like types +fn log_message(msg: impl Into) { ... } +log_message("literal"); // &str +log_message(String::from("own")); // String +log_message(Cow::from("cow")); // Cow + +// Path-like types +fn read_file(path: impl AsRef) { ... } // AsRef for borrowed access +fn write_file(path: impl Into) { ... } // Into when storing + +// Duration +fn set_timeout(duration: impl Into) { ... } +set_timeout(Duration::from_secs(5)); +// Note: no blanket impl for integers, would need custom wrapper +``` + +## AsRef vs Into + +```rust +// AsRef: borrow as &T, no conversion cost +fn count_bytes(data: impl AsRef<[u8]>) -> usize { + data.as_ref().len() // Just borrows, no allocation +} +count_bytes("hello"); // &str -> &[u8] +count_bytes(b"hello"); // &[u8] -> &[u8] +count_bytes(vec![1, 2, 3]); // &Vec -> &[u8] + +// Into: convert to owned T, may allocate +fn store_data(data: impl Into>) { + let owned: Vec = data.into(); // Takes ownership + // ... +} +``` + +## When NOT to Use impl Into + +```rust +// ❌ Trait objects need Sized +fn process(handler: impl Into>) { } +// Better: just take Box directly + +// ❌ Recursive types +struct Node { + children: Vec>, // Error: impl Trait not allowed here +} + +// ❌ Performance-critical hot paths (minor overhead of trait dispatch) +fn hot_path(value: impl Into) { + // Consider taking u64 directly if called billions of times +} + +// ❌ When you need to name the type +fn returns_impl() -> impl Into { } // Opaque, hard to use +``` + +## Builder Pattern with Into + +```rust +struct Config { + name: String, + path: PathBuf, +} + +impl Config { + fn new(name: impl Into) -> Self { + Config { + name: name.into(), + path: PathBuf::new(), + } + } + + fn path(mut self, path: impl Into) -> Self { + self.path = path.into(); + self + } +} + +// Clean builder calls +let config = Config::new("myapp") + .path("/etc/myapp"); +``` + +## See Also + +- [api-impl-asref](./api-impl-asref.md) - When to use AsRef instead +- [api-from-not-into](./api-from-not-into.md) - Why From is preferred +- [err-from-impl](./err-from-impl.md) - From for error conversion diff --git a/.agents/skills/rust-skills/rules/api-must-use.md b/.agents/skills/rust-skills/rules/api-must-use.md new file mode 100644 index 0000000..1e94a21 --- /dev/null +++ b/.agents/skills/rust-skills/rules/api-must-use.md @@ -0,0 +1,125 @@ +# api-must-use + +> Mark types and functions with `#[must_use]` when ignoring results is likely a bug + +## Why It Matters + +Some return values should never be ignored—`Result`, locks, RAII guards, computed values that have no side effects. Without `#[must_use]`, silently discarding these values can introduce subtle bugs that are hard to detect. The attribute generates compiler warnings when the value is unused. + +## Bad + +```rust +// Result ignored - error silently dropped +fn send_email(to: &str, body: &str) -> Result<(), EmailError> { ... } + +send_email("user@example.com", "Hello!"); // No warning if Result ignored! +// Email may have failed, but we don't know + +// Computed value ignored - likely a bug +fn compute_checksum(data: &[u8]) -> u32 { ... } + +let data = vec![1, 2, 3, 4]; +compute_checksum(&data); // Result discarded - pointless call +``` + +## Good + +```rust +#[must_use = "this `Result` may be an `Err` that should be handled"] +fn send_email(to: &str, body: &str) -> Result<(), EmailError> { ... } + +send_email("user@example.com", "Hello!"); +// Warning: unused `Result` that must be used + +// Mark pure functions +#[must_use = "this returns a new value and does not modify the input"] +fn compute_checksum(data: &[u8]) -> u32 { ... } + +compute_checksum(&data); +// Warning: unused return value of `compute_checksum` that must be used +``` + +## Apply to Types + +```rust +// Mark the type itself when it should always be used +#[must_use = "futures do nothing unless polled"] +struct MyFuture { ... } + +// Mark RAII guards +#[must_use = "if unused, the lock will be immediately released"] +struct MutexGuard<'a, T> { ... } + +// Mark results/errors +#[must_use = "errors should be handled"] +enum AppError { ... } +``` + +## Standard Library Examples + +```rust +// Result and Option are #[must_use] +let v: Vec = vec![1, 2, 3]; +v.first(); // Warning: unused Option + +// Iterator adapters are #[must_use] +v.iter().map(|x| x * 2); // Warning: iterators are lazy + +// String methods that return new values +let s = "hello"; +s.to_uppercase(); // Warning: unused String +``` + +## When to Apply + +```rust +// ✅ Pure functions (no side effects) +#[must_use] +fn add(a: i32, b: i32) -> i32 { a + b } + +// ✅ Builder methods returning Self +#[must_use = "builder methods return a new builder"] +fn with_timeout(self, t: Duration) -> Self { ... } + +// ✅ Fallible operations +#[must_use] +fn try_parse(s: &str) -> Result { ... } + +// ✅ Iterators and futures (lazy) +#[must_use = "iterators are lazy and do nothing unless consumed"] +struct Map { ... } + +// ❌ Side-effecting functions where result is optional +fn log(msg: &str) -> Result<(), io::Error> { ... } // Might be ok to ignore + +// ❌ Methods with useful side effects +fn vec.push(item); // Mutates vec, no return to use +``` + +## Custom Messages + +```rust +#[must_use = "creating a guard does nothing without assignment"] +struct ScopeGuard { ... } + +#[must_use = "this returns the old value"] +fn replace(&mut self, new: T) -> T { ... } + +#[must_use = "use `.await` to execute the future"] +async fn fetch() -> Data { ... } +``` + +## Clippy Lints + +```toml +[lints.clippy] +must_use_candidate = "warn" # Suggests where to add #[must_use] +unused_must_use = "deny" # Built-in, treat warnings as errors +double_must_use = "warn" # Redundant #[must_use] +``` + +## See Also + +- [api-builder-must-use](./api-builder-must-use.md) - Builder pattern must_use +- [err-result-over-panic](./err-result-over-panic.md) - Result types require handling +- [lint-deny-correctness](./lint-deny-correctness.md) - Enabling useful lints diff --git a/.agents/skills/rust-skills/rules/api-newtype-safety.md b/.agents/skills/rust-skills/rules/api-newtype-safety.md new file mode 100644 index 0000000..9afbb09 --- /dev/null +++ b/.agents/skills/rust-skills/rules/api-newtype-safety.md @@ -0,0 +1,162 @@ +# api-newtype-safety + +> Use newtypes to prevent mixing semantically different values + +## Why It Matters + +Raw primitives like `u64` or `String` carry no semantic meaning. A function taking `(u64, u64)` can easily be called with arguments swapped. Newtypes wrap primitives in distinct types, making the compiler catch mistakes at compile time rather than runtime. + +## Bad + +```rust +struct User { + id: u64, + group_id: u64, + created_at: u64, // Unix timestamp +} + +fn add_user_to_group(user_id: u64, group_id: u64) { ... } + +// Bug: arguments swapped - compiles fine, fails at runtime +let user = User { id: 100, group_id: 5, created_at: 1234567890 }; +add_user_to_group(user.group_id, user.id); // Silent bug! + +// Bug: wrong field used - timestamp passed as ID +add_user_to_group(user.created_at, user.group_id); // Compiles fine! +``` + +## Good + +```rust +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +struct UserId(u64); + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +struct GroupId(u64); + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +struct Timestamp(u64); + +struct User { + id: UserId, + group_id: GroupId, + created_at: Timestamp, +} + +fn add_user_to_group(user_id: UserId, group_id: GroupId) { ... } + +// Compile error: expected UserId, found GroupId +let user = User { ... }; +add_user_to_group(user.group_id, user.id); // Error! + +// Compile error: expected UserId, found Timestamp +add_user_to_group(user.created_at, user.group_id); // Error! +``` + +## Derive Common Traits + +```rust +// Minimal: just enough for your use case +#[derive(Debug, Clone, Copy)] +struct MeterId(u32); + +// Full ID type: hashable, comparable, displayable +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +struct OrderId(u64); + +impl std::fmt::Display for OrderId { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "ORD-{:08}", self.0) + } +} + +// With serde for serialization +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(transparent)] // Serializes as raw u64 +struct ProductId(u64); +``` + +## Constructor Patterns + +```rust +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +struct Email(String); + +impl Email { + /// Creates a new Email, validating the format. + pub fn new(s: &str) -> Result { + if is_valid_email(s) { + Ok(Email(s.to_string())) + } else { + Err(EmailError::InvalidFormat) + } + } + + /// Returns the email as a string slice. + pub fn as_str(&self) -> &str { + &self.0 + } +} + +// Usage enforces validation +let email = Email::new("user@example.com")?; // Must go through validation +``` + +## Zero-Cost Abstraction + +```rust +use std::mem::size_of; + +#[derive(Clone, Copy)] +struct Miles(f64); + +#[derive(Clone, Copy)] +struct Kilometers(f64); + +// Same size as raw f64 +assert_eq!(size_of::(), size_of::()); +assert_eq!(size_of::(), size_of::()); + +// But can't accidentally mix them +fn drive(distance: Miles) { ... } + +let km = Kilometers(100.0); +drive(km); // Error: expected Miles, found Kilometers + +// Explicit conversion +impl From for Miles { + fn from(km: Kilometers) -> Self { + Miles(km.0 * 0.621371) + } +} + +drive(km.into()); // Explicit, visible conversion +``` + +## When Newtypes Help Most + +```rust +// ✅ IDs that could be confused +fn transfer(from: AccountId, to: AccountId, amount: Money) { ... } + +// ✅ Units that shouldn't mix +struct Celsius(f64); +struct Fahrenheit(f64); + +// ✅ Validated strings +struct Username(String); // Validated alphanumeric +struct Password(String); // Never logged + +// ✅ Different meanings of same type +struct Milliseconds(u64); +struct Seconds(u64); + +// ❌ Overkill: single use, no confusion possible +struct X(i32); // Just use i32 +``` + +## See Also + +- [type-newtype-ids](./type-newtype-ids.md) - Newtype pattern for IDs +- [api-parse-dont-validate](./api-parse-dont-validate.md) - Type-driven validation +- [own-copy-small](./own-copy-small.md) - Making newtypes Copy diff --git a/.agents/skills/rust-skills/rules/api-non-exhaustive.md b/.agents/skills/rust-skills/rules/api-non-exhaustive.md new file mode 100644 index 0000000..621e4bb --- /dev/null +++ b/.agents/skills/rust-skills/rules/api-non-exhaustive.md @@ -0,0 +1,177 @@ +# api-non-exhaustive + +> Use `#[non_exhaustive]` on public enums and structs for forward compatibility + +## Why It Matters + +Adding a variant to a public enum or a field to a public struct is normally a breaking change—downstream code may match exhaustively or use struct literal syntax. `#[non_exhaustive]` forces external code to use wildcards in matches and constructors, allowing you to add variants/fields in minor versions without breaking callers. + +## Bad + +```rust +// Public enum - adding variant breaks downstream matches +pub enum ErrorKind { + NotFound, + PermissionDenied, + TimedOut, +} + +// Downstream code +match error.kind() { + ErrorKind::NotFound => ..., + ErrorKind::PermissionDenied => ..., + ErrorKind::TimedOut => ..., + // No wildcard - will break when you add ErrorKind::Interrupted +} + +// Public struct - adding field breaks downstream construction +pub struct Config { + pub name: String, + pub value: i32, +} + +// Downstream code +let config = Config { name: "test".into(), value: 42 }; +// Will break when you add `pub enabled: bool` +``` + +## Good + +```rust +// Can add variants in minor versions +#[non_exhaustive] +pub enum ErrorKind { + NotFound, + PermissionDenied, + TimedOut, + // Future: can add Interrupted here without breaking changes +} + +// Downstream code MUST have wildcard +match error.kind() { + ErrorKind::NotFound => ..., + ErrorKind::PermissionDenied => ..., + ErrorKind::TimedOut => ..., + _ => ..., // Required by non_exhaustive +} + +// Can add fields in minor versions +#[non_exhaustive] +pub struct Config { + pub name: String, + pub value: i32, +} + +// Downstream CANNOT use struct literal syntax +// let config = Config { name: "test".into(), value: 42 }; // Error! + +// Must use constructor +impl Config { + pub fn new(name: impl Into, value: i32) -> Self { + Config { name: name.into(), value } + } +} +``` + +## How It Works + +```rust +#[non_exhaustive] +pub enum Status { + Active, + Inactive, +} + +// Inside your crate: exhaustive match is allowed +fn internal(s: Status) { + match s { + Status::Active => {}, + Status::Inactive => {}, + // No wildcard needed inside defining crate + } +} + +// Outside your crate: wildcard required +fn external(s: my_crate::Status) { + match s { + my_crate::Status::Active => {}, + my_crate::Status::Inactive => {}, + _ => {}, // REQUIRED + } +} +``` + +## Struct Usage + +```rust +#[non_exhaustive] +pub struct Point { + pub x: f64, + pub y: f64, +} + +impl Point { + // Provide constructor + pub fn new(x: f64, y: f64) -> Self { + Point { x, y } + } +} + +// External code can read fields but not construct with literals +fn external(p: Point) { + println!("x: {}, y: {}", p.x, p.y); // Reading is fine + + // let p2 = Point { x: 1.0, y: 2.0 }; // Error! + let p2 = Point::new(1.0, 2.0); // Must use constructor +} +``` + +## Non-Exhaustive Variants + +```rust +pub enum Message { + // Specific variant is non-exhaustive + #[non_exhaustive] + Error { code: u32, message: String }, + + Ok(Data), +} + +// Can destructure Ok normally +// But Error requires `..` to handle future fields +match msg { + Message::Ok(data) => {}, + Message::Error { code, message, .. } => {}, // `..` required +} +``` + +## When to Use + +```rust +// ✅ Use for public API types that may evolve +#[non_exhaustive] +pub enum ApiError { ... } + +#[non_exhaustive] +pub struct Options { ... } + +// ✅ Use for error types +#[non_exhaustive] +pub enum MyError { ... } + +// ❌ Don't use for internal types +enum InternalState { ... } // Not public, no concern + +// ❌ Don't use for stable, complete types +pub enum Ordering { // Less, Equal, Greater is complete + Less, + Equal, + Greater, +} +``` + +## See Also + +- [api-sealed-trait](./api-sealed-trait.md) - Controlling trait implementations +- [err-custom-type](./err-custom-type.md) - Error type design +- [api-builder-pattern](./api-builder-pattern.md) - Alternative to struct literals diff --git a/.agents/skills/rust-skills/rules/api-parse-dont-validate.md b/.agents/skills/rust-skills/rules/api-parse-dont-validate.md new file mode 100644 index 0000000..8328cfc --- /dev/null +++ b/.agents/skills/rust-skills/rules/api-parse-dont-validate.md @@ -0,0 +1,184 @@ +# api-parse-dont-validate + +> Parse into validated types at boundaries + +## Why It Matters + +Instead of validating data and hoping you remember to check everywhere, parse it into a type that can only be constructed from valid data. The type system then guarantees validity - you can't forget to validate because invalid states are unrepresentable. + +## Bad + +```rust +// Validation scattered throughout codebase +fn send_email(email: &str) -> Result<(), Error> { + // Did someone validate this already? Who knows! + if !is_valid_email(email) { + return Err(Error::InvalidEmail); + } + // Send email... +} + +fn add_to_mailing_list(email: &str) -> Result<(), Error> { + // Duplicate validation, or did we forget? + if !is_valid_email(email) { + return Err(Error::InvalidEmail); + } + // Add to list... +} + +// Easy to forget validation +fn process_user_email(email: &str) { + // Oops, no validation! + database.store_email(email); +} +``` + +## Good + +```rust +/// A validated email address. +/// Can only be constructed via `Email::parse()`. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct Email(String); + +impl Email { + /// Parses and validates an email address. + pub fn parse(s: impl Into) -> Result { + let s = s.into(); + if Self::is_valid(&s) { + Ok(Email(s)) + } else { + Err(EmailError::Invalid) + } + } + + fn is_valid(s: &str) -> bool { + s.contains('@') && s.len() > 3 // Simplified + } + + pub fn as_str(&self) -> &str { + &self.0 + } +} + +// Now functions can accept Email - guaranteed valid! +fn send_email(email: &Email) -> Result<(), Error> { + // No validation needed - Email is always valid + smtp_send(email.as_str()) +} + +fn add_to_mailing_list(email: Email) { + // No validation needed + list.push(email); +} +``` + +## More Examples + +```rust +// Port number (1-65535) +pub struct Port(u16); + +impl Port { + pub fn new(n: u16) -> Option { + if n > 0 { Some(Port(n)) } else { None } + } + + pub fn get(&self) -> u16 { + self.0 + } +} + +// Non-empty string +pub struct NonEmptyString(String); + +impl NonEmptyString { + pub fn new(s: impl Into) -> Option { + let s = s.into(); + if s.is_empty() { None } else { Some(Self(s)) } + } +} + +// Positive integer +pub struct PositiveI32(i32); + +impl PositiveI32 { + pub fn new(n: i32) -> Option { + if n > 0 { Some(Self(n)) } else { None } + } +} + +// Bounded value +pub struct Percentage(u8); + +impl Percentage { + pub fn new(n: u8) -> Option { + if n <= 100 { Some(Self(n)) } else { None } + } +} +``` + +## Parsing at Boundaries + +```rust +// Parse at the system boundary (API, CLI, config file) +fn handle_request(raw: RawRequest) -> Result { + // Parse ALL inputs upfront + let email = Email::parse(&raw.email)?; + let age = Age::parse(raw.age)?; + let username = Username::parse(&raw.username)?; + + // Now work with validated types + process_user(email, age, username) +} + +fn process_user(email: Email, age: Age, username: Username) { + // All inputs guaranteed valid - no checks needed +} +``` + +## Evidence from sqlx + +```rust +// sqlx parses SQL at compile time, ensuring query validity +// https://github.com/launchbadge/sqlx/blob/master/src/macros/mod.rs + +// The query! macro parses and validates SQL +let user = sqlx::query!("SELECT * FROM users WHERE id = ?", id) + .fetch_one(&pool) + .await?; + +// If SQL is invalid, compilation fails - invalid state unrepresentable +``` + +## Combining with Display + +```rust +use std::fmt; + +pub struct Email(String); + +impl Email { + pub fn parse(s: &str) -> Result { ... } +} + +// Implement Display for easy printing +impl fmt::Display for Email { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.0) + } +} + +// Implement AsRef for easy borrowing +impl AsRef for Email { + fn as_ref(&self) -> &str { + &self.0 + } +} +``` + +## See Also + +- [api-newtype-safety](api-newtype-safety.md) - Use newtypes for type safety +- [type-newtype-validated](type-newtype-validated.md) - Newtypes for validated data +- [api-typestate](api-typestate.md) - Compile-time state machines diff --git a/.agents/skills/rust-skills/rules/api-sealed-trait.md b/.agents/skills/rust-skills/rules/api-sealed-trait.md new file mode 100644 index 0000000..0d2df3a --- /dev/null +++ b/.agents/skills/rust-skills/rules/api-sealed-trait.md @@ -0,0 +1,168 @@ +# api-sealed-trait + +> Use sealed traits to prevent external implementations while allowing use + +## Why It Matters + +Public traits can be implemented by anyone, which may be undesirable when you need to guarantee behavior or add methods in future versions. A sealed trait can be used by external code but not implemented by it, giving you control over implementations while maintaining a usable API. + +## Bad + +```rust +// Anyone can implement this trait +pub trait DatabaseDriver { + fn connect(&self, url: &str) -> Connection; + fn execute(&self, query: &str) -> Result; +} + +// External crate implements it incorrectly +impl DatabaseDriver for MyBadDriver { + fn connect(&self, url: &str) -> Connection { + // Buggy implementation that doesn't handle errors + unsafe { force_connect(url) } + } +} + +// Later, you want to add a required method - BREAKING CHANGE +pub trait DatabaseDriver { + fn connect(&self, url: &str) -> Connection; + fn execute(&self, query: &str) -> Result; + fn transaction(&self) -> Transaction; // External impls now broken! +} +``` + +## Good + +```rust +// Create a private module with a private trait +mod private { + pub trait Sealed {} +} + +// Public trait requires the private trait +pub trait DatabaseDriver: private::Sealed { + fn connect(&self, url: &str) -> Connection; + fn execute(&self, query: &str) -> Result; +} + +// Only your crate can implement Sealed, thus DatabaseDriver +pub struct PostgresDriver; +impl private::Sealed for PostgresDriver {} +impl DatabaseDriver for PostgresDriver { + fn connect(&self, url: &str) -> Connection { ... } + fn execute(&self, query: &str) -> Result { ... } +} + +pub struct MySqlDriver; +impl private::Sealed for MySqlDriver {} +impl DatabaseDriver for MySqlDriver { + fn connect(&self, url: &str) -> Connection { ... } + fn execute(&self, query: &str) -> Result { ... } +} + +// External crate cannot implement - private::Sealed is not accessible +// impl DatabaseDriver for ExternalDriver { } // Error! + +// But external code CAN use the trait +fn use_driver(driver: &impl DatabaseDriver) { + let conn = driver.connect("postgres://localhost"); +} +``` + +## Full Pattern + +```rust +pub mod db { + mod private { + pub trait Sealed {} + } + + /// Database driver trait. + /// + /// This trait is sealed and cannot be implemented outside this crate. + pub trait Driver: private::Sealed { + /// Connects to the database. + fn connect(&self, url: &str) -> Result; + + /// Executes a query. + fn execute(&self, sql: &str) -> Result; + } + + pub struct Postgres; + impl private::Sealed for Postgres {} + impl Driver for Postgres { ... } + + pub struct Sqlite; + impl private::Sealed for Sqlite {} + impl Driver for Sqlite { ... } +} + +// Usage works fine +use db::{Driver, Postgres}; + +fn query(driver: &impl Driver) { + driver.execute("SELECT 1")?; +} + +query(&Postgres); +``` + +## Benefits of Sealing + +```rust +// 1. Add methods without breaking changes +pub trait Format: private::Sealed { + fn format(&self) -> String; + + // Added later - not breaking because no external impls exist + fn format_pretty(&self) -> String { + self.format() // Default implementation + } +} + +// 2. Guarantee invariants +pub trait SafeBuffer: private::Sealed { + // You control all implementations, so you know they're all correct + fn get(&self, index: usize) -> Option<&u8>; +} + +// 3. Use as marker traits +pub trait ValidConfig: private::Sealed {} +// Only validated configs implement this +``` + +## Partially Sealed + +```rust +// Allow implementing some methods but not all +mod private { + pub trait SealedCore {} +} + +pub trait Plugin: private::SealedCore { + // Sealed - only we implement + fn initialize(&self); + fn shutdown(&self); + + // Open - users can override + fn name(&self) -> &str { "unnamed" } +} + +// Only we can add new required sealed methods +// Users can customize open methods +``` + +## When to Seal + +| Seal When | Don't Seal When | +|-----------|-----------------| +| API stability is critical | You want extension points | +| Implementation correctness is hard | Users need custom implementations | +| You'll add methods later | Trait is simple and stable | +| Safety invariants required | Standard patterns (Iterator, etc.) | + +## See Also + +- [api-non-exhaustive](./api-non-exhaustive.md) - Related pattern for enums/structs +- [api-extension-trait](./api-extension-trait.md) - Adding methods to external types +- [api-typestate](./api-typestate.md) - Compile-time state guarantees diff --git a/.agents/skills/rust-skills/rules/api-serde-optional.md b/.agents/skills/rust-skills/rules/api-serde-optional.md new file mode 100644 index 0000000..022ffbd --- /dev/null +++ b/.agents/skills/rust-skills/rules/api-serde-optional.md @@ -0,0 +1,182 @@ +# api-serde-optional + +> Make serde a feature flag, not a hard dependency for library crates + +## Why It Matters + +Not all users of your library need serialization. Making serde a required dependency adds compile time and binary size for everyone. Feature flags let users opt-in to serde support only when needed, following Rust's philosophy of zero-cost abstractions and minimal dependencies. + +## Bad + +```rust +// Cargo.toml +[dependencies] +serde = { version = "1.0", features = ["derive"] } + +// lib.rs +use serde::{Serialize, Deserialize}; + +// Every user pays for serde, even if they don't need it +#[derive(Serialize, Deserialize)] +pub struct Config { + pub name: String, + pub value: i32, +} +``` + +## Good + +```rust +// Cargo.toml +[dependencies] +serde = { version = "1.0", features = ["derive"], optional = true } + +[features] +default = [] +serde = ["dep:serde"] + +// lib.rs +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub struct Config { + pub name: String, + pub value: i32, +} + +// Users opt-in: +// my_crate = { version = "1.0", features = ["serde"] } +``` + +## Macro Pattern + +```rust +// Reusable macro for serde derives +#[cfg(feature = "serde")] +macro_rules! impl_serde { + ($($t:ty),*) => { + $( + impl serde::Serialize for $t { + // ... + } + impl<'de> serde::Deserialize<'de> for $t { + // ... + } + )* + }; +} + +#[cfg(not(feature = "serde"))] +macro_rules! impl_serde { + ($($t:ty),*) => {}; +} + +// Or use cfg_attr for derived impls +#[derive(Debug, Clone)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub struct Point { + pub x: f64, + pub y: f64, +} +``` + +## Feature Documentation + +```rust +// lib.rs + +//! # Features +//! +//! - `serde`: Enables `Serialize` and `Deserialize` implementations for all types. +//! +//! # Example with serde +//! +//! ```toml +//! [dependencies] +//! my_crate = { version = "1.0", features = ["serde"] } +//! ``` + +#![cfg_attr(docsrs, feature(doc_cfg))] + +/// A configuration type. +/// +/// When the `serde` feature is enabled, this type implements +/// `Serialize` and `Deserialize`. +#[derive(Debug, Clone)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(docsrs, doc(cfg(feature = "serde")))] +pub struct Config { + pub name: String, +} +``` + +## Multiple Optional Dependencies + +```rust +// Cargo.toml +[dependencies] +serde = { version = "1.0", features = ["derive"], optional = true } +rkyv = { version = "0.7", optional = true } +borsh = { version = "0.10", optional = true } + +[features] +default = [] +serde = ["dep:serde"] +rkyv = ["dep:rkyv"] +borsh = ["dep:borsh"] + +// lib.rs +#[derive(Debug, Clone)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "rkyv", derive(rkyv::Archive, rkyv::Serialize, rkyv::Deserialize))] +#[cfg_attr(feature = "borsh", derive(borsh::BorshSerialize, borsh::BorshDeserialize))] +pub struct Message { + pub id: u64, + pub content: String, +} +``` + +## Testing with Features + +```bash +# Test without serde +cargo test + +# Test with serde +cargo test --features serde + +# Test all feature combinations +cargo test --all-features +``` + +```rust +// Test serde round-trip when feature enabled +#[cfg(feature = "serde")] +#[test] +fn test_serde_roundtrip() { + let config = Config { name: "test".into() }; + let json = serde_json::to_string(&config).unwrap(); + let parsed: Config = serde_json::from_str(&json).unwrap(); + assert_eq!(config, parsed); +} +``` + +## When to Make Serde Required + +```rust +// ✅ Required: Library is about serialization +// (e.g., json-schema, config-file parser) +[dependencies] +serde = "1.0" + +// ✅ Required: Domain heavily uses serde +// (e.g., API client, data format library) + +// ❌ Optional: General-purpose utility library +// ❌ Optional: Math/algorithm library +// ❌ Optional: Most libraries! +``` + +## See Also + +- [proj-lib-main-split](./proj-lib-main-split.md) - Library structure +- [api-common-traits](./api-common-traits.md) - Core trait implementations +- [lint-deny-correctness](./lint-deny-correctness.md) - Feature testing diff --git a/.agents/skills/rust-skills/rules/api-typestate.md b/.agents/skills/rust-skills/rules/api-typestate.md new file mode 100644 index 0000000..94b970e --- /dev/null +++ b/.agents/skills/rust-skills/rules/api-typestate.md @@ -0,0 +1,199 @@ +# api-typestate + +> Use typestate pattern to encode state machine invariants in the type system + +## Why It Matters + +State machines with runtime state checks ("are we connected?", "is the transaction started?") can have invalid transitions. The typestate pattern uses different types for each state, making invalid state transitions compile errors. The compiler enforces your state machine. + +## Bad + +```rust +struct Connection { + state: ConnectionState, + socket: Option, +} + +enum ConnectionState { + Disconnected, + Connected, + Authenticated, +} + +impl Connection { + fn send(&mut self, data: &[u8]) -> Result<(), Error> { + // Runtime check - can fail if called in wrong state + if self.state != ConnectionState::Authenticated { + return Err(Error::NotAuthenticated); + } + self.socket.as_mut().unwrap().write_all(data)?; + Ok(()) + } + + fn authenticate(&mut self, password: &str) -> Result<(), Error> { + // Runtime check - can fail + if self.state != ConnectionState::Connected { + return Err(Error::NotConnected); + } + // ... + } +} + +// Bug: forgot to authenticate +let mut conn = Connection::new(); +conn.connect()?; +conn.send(b"data")?; // Runtime error: NotAuthenticated +``` + +## Good + +```rust +// Different types for each state +struct Disconnected; +struct Connected { socket: TcpStream } +struct Authenticated { socket: TcpStream, session: Session } + +struct Connection { + state: State, +} + +impl Connection { + fn new() -> Self { + Connection { state: Disconnected } + } + + fn connect(self, addr: &str) -> Result, Error> { + let socket = TcpStream::connect(addr)?; + Ok(Connection { state: Connected { socket } }) + } +} + +impl Connection { + fn authenticate(self, password: &str) -> Result, Error> { + let session = do_auth(&self.state.socket, password)?; + Ok(Connection { + state: Authenticated { socket: self.state.socket, session } + }) + } +} + +impl Connection { + fn send(&mut self, data: &[u8]) -> Result<(), Error> { + // No runtime check needed - type guarantees we're authenticated + self.state.socket.write_all(data)?; + Ok(()) + } +} + +// Bug: forgot to authenticate +let conn = Connection::new(); +let conn = conn.connect("server:8080")?; +conn.send(b"data"); // Compile error! send() not available on Connection + +// Correct usage +let conn = Connection::new(); +let conn = conn.connect("server:8080")?; +let mut conn = conn.authenticate("secret")?; +conn.send(b"data")?; // Works - type is Connection +``` + +## Builder Typestate + +```rust +// Enforce required fields via typestate +struct BuilderNoUrl; +struct BuilderWithUrl { url: String } + +struct RequestBuilder { + state: State, + timeout: Option, +} + +impl RequestBuilder { + fn new() -> Self { + RequestBuilder { + state: BuilderNoUrl, + timeout: None, + } + } + + fn url(self, url: &str) -> RequestBuilder { + RequestBuilder { + state: BuilderWithUrl { url: url.to_string() }, + timeout: self.timeout, + } + } +} + +impl RequestBuilder { + fn timeout(mut self, t: Duration) -> Self { + self.timeout = Some(t); + self + } + + // Only available once URL is set + fn build(self) -> Request { + Request { + url: self.state.url, + timeout: self.timeout, + } + } +} + +// Compile error: build() not available +let bad = RequestBuilder::new().build(); + +// Correct: must set URL first +let good = RequestBuilder::new() + .url("https://example.com") + .timeout(Duration::from_secs(30)) + .build(); +``` + +## Transaction Example + +```rust +struct NotStarted; +struct InProgress { tx_id: u64 } +struct Committed; + +struct Transaction { + conn: Connection, + state: State, +} + +impl Transaction { + fn begin(conn: Connection) -> Result, Error> { + let tx_id = conn.execute("BEGIN")?; + Ok(Transaction { + conn, + state: InProgress { tx_id }, + }) + } +} + +impl Transaction { + fn execute(&mut self, sql: &str) -> Result<(), Error> { + self.conn.execute(sql) + } + + fn commit(self) -> Result, Error> { + self.conn.execute("COMMIT")?; + Ok(Transaction { + conn: self.conn, + state: Committed, + }) + } + + fn rollback(self) -> Connection { + let _ = self.conn.execute("ROLLBACK"); + self.conn + } +} +``` + +## See Also + +- [api-builder-pattern](./api-builder-pattern.md) - Basic builder pattern +- [api-parse-dont-validate](./api-parse-dont-validate.md) - Type-driven invariants +- [api-sealed-trait](./api-sealed-trait.md) - Restricting trait implementations diff --git a/.agents/skills/rust-skills/rules/async-bounded-channel.md b/.agents/skills/rust-skills/rules/async-bounded-channel.md new file mode 100644 index 0000000..0c31e4c --- /dev/null +++ b/.agents/skills/rust-skills/rules/async-bounded-channel.md @@ -0,0 +1,175 @@ +# async-bounded-channel + +> Use bounded channels to apply backpressure and prevent unbounded memory growth + +## Why It Matters + +Unbounded channels grow without limit when producers outpace consumers. In production, this leads to memory exhaustion. Bounded channels apply backpressure—producers wait when the channel is full, naturally throttling the system. This prevents OOM and makes resource usage predictable. + +## Bad + +```rust +use tokio::sync::mpsc; + +// Unbounded channel - can grow forever +let (tx, mut rx) = mpsc::unbounded_channel::(); + +// Fast producer, slow consumer = unbounded memory growth +tokio::spawn(async move { + loop { + let msg = generate_message(); + tx.send(msg).unwrap(); // Never blocks, never fails (until OOM) + } +}); + +tokio::spawn(async move { + while let Some(msg) = rx.recv().await { + slow_process(msg).await; // Can't keep up + } +}); +// Memory grows unboundedly until crash +``` + +## Good + +```rust +use tokio::sync::mpsc; + +// Bounded channel - backpressure when full +let (tx, mut rx) = mpsc::channel::(100); // Max 100 items + +// Producer waits when channel full +tokio::spawn(async move { + loop { + let msg = generate_message(); + // Blocks if channel is full - natural backpressure + tx.send(msg).await.unwrap(); + } +}); + +tokio::spawn(async move { + while let Some(msg) = rx.recv().await { + slow_process(msg).await; + } +}); +// Memory usage capped at ~100 messages +``` + +## Choosing Buffer Size + +```rust +// Too small: frequent blocking, reduced throughput +let (tx, rx) = mpsc::channel::(1); + +// Too large: delayed backpressure, memory waste +let (tx, rx) = mpsc::channel::(1_000_000); + +// Guidelines: +// - Start with expected burst size +// - Measure actual usage in production +// - Err on the smaller side initially + +// Small items, high throughput +let (tx, rx) = mpsc::channel::(1000); + +// Large items, moderate throughput +let (tx, rx) = mpsc::channel::(100); + +// Low latency requirement +let (tx, rx) = mpsc::channel::(10); +``` + +## Handling Full Channel + +```rust +use tokio::sync::mpsc; +use tokio::time::{timeout, Duration}; + +let (tx, mut rx) = mpsc::channel::(100); + +// Option 1: Wait indefinitely (default) +tx.send(msg).await?; + +// Option 2: Try send, fail if full +match tx.try_send(msg) { + Ok(()) => println!("Sent"), + Err(TrySendError::Full(msg)) => { + println!("Channel full, dropping message"); + } + Err(TrySendError::Closed(msg)) => { + println!("Receiver dropped"); + } +} + +// Option 3: Timeout +match timeout(Duration::from_secs(1), tx.send(msg)).await { + Ok(Ok(())) => println!("Sent"), + Ok(Err(_)) => println!("Channel closed"), + Err(_) => println!("Timeout - channel full for too long"), +} + +// Option 4: send with permit reservation +let permit = tx.reserve().await?; +permit.send(msg); // Guaranteed to succeed +``` + +## Channel Types + +```rust +// mpsc: many producers, single consumer +let (tx, rx) = mpsc::channel::(100); +let tx2 = tx.clone(); // Can clone sender + +// oneshot: single value, one producer, one consumer +let (tx, rx) = oneshot::channel::(); +tx.send(response); // Can only send once + +// broadcast: multiple consumers, each gets all messages +let (tx, _) = broadcast::channel::(100); +let mut rx1 = tx.subscribe(); +let mut rx2 = tx.subscribe(); + +// watch: single latest value, multiple consumers +let (tx, rx) = watch::channel::(initial); +// Receivers see latest value, not all values +``` + +## Worker Pool Pattern + +```rust +async fn process_with_workers(items: Vec) -> Vec { + let (tx, rx) = mpsc::channel(100); + let rx = Arc::new(Mutex::new(rx)); + + // Spawn worker pool + let workers: Vec<_> = (0..4).map(|_| { + let rx = rx.clone(); + tokio::spawn(async move { + loop { + let item = { + let mut rx = rx.lock().await; + rx.recv().await + }; + match item { + Some(item) => process(item).await, + None => break, + } + } + }) + }).collect(); + + // Send items + for item in items { + tx.send(item).await.unwrap(); + } + drop(tx); // Signal workers to stop + + futures::future::join_all(workers).await; +} +``` + +## See Also + +- [async-mpsc-queue](./async-mpsc-queue.md) - Multi-producer patterns +- [async-oneshot-response](./async-oneshot-response.md) - Request-response pattern +- [async-watch-latest](./async-watch-latest.md) - Latest-value broadcasting diff --git a/.agents/skills/rust-skills/rules/async-broadcast-pubsub.md b/.agents/skills/rust-skills/rules/async-broadcast-pubsub.md new file mode 100644 index 0000000..86ea938 --- /dev/null +++ b/.agents/skills/rust-skills/rules/async-broadcast-pubsub.md @@ -0,0 +1,185 @@ +# async-broadcast-pubsub + +> Use `broadcast` channel for pub/sub where all subscribers receive all messages + +## Why It Matters + +Unlike `mpsc` where one consumer receives each message, `broadcast` delivers each message to all subscribers. This is ideal for event broadcasting, real-time notifications, or when multiple components need to react to the same events independently. + +## Bad + +```rust +use tokio::sync::mpsc; + +// mpsc only delivers to ONE consumer +let (tx, mut rx) = mpsc::channel::(100); + +// Only one of these receives each message! +let mut rx2 = ???; // Can't clone receiver +``` + +## Good + +```rust +use tokio::sync::broadcast; + +// broadcast delivers to ALL subscribers +let (tx, _) = broadcast::channel::(100); + +// Each subscriber gets ALL messages +let mut rx1 = tx.subscribe(); +let mut rx2 = tx.subscribe(); + +tokio::spawn(async move { + while let Ok(event) = rx1.recv().await { + handle_in_logger(event); + } +}); + +tokio::spawn(async move { + while let Ok(event) = rx2.recv().await { + handle_in_metrics(event); + } +}); + +// Both subscribers receive this +tx.send(Event::UserLogin { user_id: 42 })?; +``` + +## Broadcast Semantics + +```rust +use tokio::sync::broadcast; + +let (tx, mut rx1) = broadcast::channel::(16); +let mut rx2 = tx.subscribe(); + +tx.send(1)?; +tx.send(2)?; + +// Both receive all messages +assert_eq!(rx1.recv().await?, 1); +assert_eq!(rx1.recv().await?, 2); +assert_eq!(rx2.recv().await?, 1); +assert_eq!(rx2.recv().await?, 2); +``` + +## Handling Lagging Receivers + +```rust +use tokio::sync::broadcast::{self, error::RecvError}; + +let (tx, mut rx) = broadcast::channel::(16); + +loop { + match rx.recv().await { + Ok(event) => { + process(event); + } + Err(RecvError::Lagged(count)) => { + // Receiver couldn't keep up, missed `count` messages + log::warn!("Missed {} events", count); + // Continue receiving new messages + } + Err(RecvError::Closed) => { + break; // All senders dropped + } + } +} +``` + +## Event Bus Pattern + +```rust +use tokio::sync::broadcast; + +#[derive(Clone, Debug)] +enum AppEvent { + UserLoggedIn { user_id: u64 }, + OrderCreated { order_id: u64 }, + SystemShutdown, +} + +struct EventBus { + tx: broadcast::Sender, +} + +impl EventBus { + fn new() -> Self { + let (tx, _) = broadcast::channel(1000); + EventBus { tx } + } + + fn publish(&self, event: AppEvent) { + // Ignore error if no subscribers + let _ = self.tx.send(event); + } + + fn subscribe(&self) -> broadcast::Receiver { + self.tx.subscribe() + } +} + +// Usage +let bus = EventBus::new(); + +// Logger subscribes +let mut log_rx = bus.subscribe(); +tokio::spawn(async move { + while let Ok(event) = log_rx.recv().await { + log::info!("Event: {:?}", event); + } +}); + +// Metrics subscribes +let mut metrics_rx = bus.subscribe(); +tokio::spawn(async move { + while let Ok(event) = metrics_rx.recv().await { + record_metric(&event); + } +}); + +// Publish events +bus.publish(AppEvent::UserLoggedIn { user_id: 42 }); +``` + +## Broadcast vs Watch + +```rust +// broadcast: subscribers get ALL messages +// Good for: events, logs, notifications +let (tx, _) = broadcast::channel::(100); + +// watch: subscribers get LATEST value only +// Good for: config changes, state updates +let (tx, _) = watch::channel(initial_state); + +// If subscriber is slow: +// - broadcast: they receive old messages (or lag) +// - watch: they skip to latest (no history) +``` + +## Clone Requirement + +```rust +// broadcast requires Clone because message is cloned to each receiver +use tokio::sync::broadcast; + +#[derive(Clone)] // Required for broadcast +struct Event { + data: String, +} + +let (tx, _) = broadcast::channel::(100); + +// For non-Clone types, wrap in Arc +use std::sync::Arc; + +let (tx, _) = broadcast::channel::>(100); +``` + +## See Also + +- [async-mpsc-queue](./async-mpsc-queue.md) - Single-consumer channels +- [async-watch-latest](./async-watch-latest.md) - Latest-value only +- [async-bounded-channel](./async-bounded-channel.md) - Buffer sizing diff --git a/.agents/skills/rust-skills/rules/async-cancellation-token.md b/.agents/skills/rust-skills/rules/async-cancellation-token.md new file mode 100644 index 0000000..549f305 --- /dev/null +++ b/.agents/skills/rust-skills/rules/async-cancellation-token.md @@ -0,0 +1,203 @@ +# async-cancellation-token + +> Use `CancellationToken` for graceful shutdown and task cancellation + +## Why It Matters + +Dropping a `JoinHandle` doesn't cancel the task—it just detaches it. For graceful shutdown, you need explicit cancellation. `tokio_util::sync::CancellationToken` provides a cooperative cancellation mechanism that tasks can check and respond to, enabling clean resource cleanup. + +## Bad + +```rust +// Dropping handle doesn't stop the task +let handle = tokio::spawn(async { + loop { + do_work().await; + } +}); + +drop(handle); // Task continues running in background! + +// Using bool flag - not async-aware +let running = Arc::new(AtomicBool::new(true)); + +tokio::spawn({ + let running = running.clone(); + async move { + while running.load(Ordering::Relaxed) { + do_work().await; // Can't wake up if blocked here + } + } +}); + +running.store(false, Ordering::Relaxed); +// Task won't stop until current do_work() completes +``` + +## Good + +```rust +use tokio_util::sync::CancellationToken; + +let token = CancellationToken::new(); + +let handle = tokio::spawn({ + let token = token.clone(); + async move { + loop { + tokio::select! { + _ = token.cancelled() => { + println!("Shutting down gracefully"); + cleanup().await; + break; + } + _ = do_work() => { + // Work completed + } + } + } + } +}); + +// Later: trigger cancellation +token.cancel(); +handle.await?; // Task completes cleanly +``` + +## CancellationToken API + +```rust +use tokio_util::sync::CancellationToken; + +// Create token +let token = CancellationToken::new(); + +// Clone for sharing (cheap Arc-based clone) +let token2 = token.clone(); + +// Check if cancelled (non-blocking) +if token.is_cancelled() { + return; +} + +// Wait for cancellation (async) +token.cancelled().await; + +// Trigger cancellation +token.cancel(); + +// Child tokens - cancelled when parent is cancelled +let child = token.child_token(); +``` + +## Hierarchical Cancellation + +```rust +async fn run_server(shutdown: CancellationToken) { + let listener = TcpListener::bind("0.0.0.0:8080").await?; + + loop { + tokio::select! { + _ = shutdown.cancelled() => { + println!("Server shutting down"); + break; + } + result = listener.accept() => { + let (socket, _) = result?; + // Each connection gets child token + let conn_token = shutdown.child_token(); + tokio::spawn(handle_connection(socket, conn_token)); + } + } + } + + // Child tokens auto-cancelled when we exit +} + +async fn handle_connection(socket: TcpStream, token: CancellationToken) { + loop { + tokio::select! { + _ = token.cancelled() => { + // Connection cleanup + break; + } + data = socket.read() => { + // Handle data + } + } + } +} +``` + +## Graceful Shutdown Pattern + +```rust +use tokio::signal; + +async fn main() -> Result<()> { + let shutdown = CancellationToken::new(); + + // Spawn signal handler + let shutdown_trigger = shutdown.clone(); + tokio::spawn(async move { + signal::ctrl_c().await.expect("failed to listen for Ctrl+C"); + println!("Received Ctrl+C, initiating shutdown..."); + shutdown_trigger.cancel(); + }); + + // Run application with shutdown token + run_app(shutdown).await +} + +async fn run_app(shutdown: CancellationToken) -> Result<()> { + let mut tasks = JoinSet::new(); + + tasks.spawn(worker_task(shutdown.child_token())); + tasks.spawn(server_task(shutdown.child_token())); + + // Wait for shutdown or task completion + tokio::select! { + _ = shutdown.cancelled() => { + println!("Shutdown requested, waiting for tasks..."); + } + Some(result) = tasks.join_next() => { + // A task completed/failed + result??; + } + } + + // Wait for remaining tasks with timeout + tokio::time::timeout( + Duration::from_secs(30), + async { while tasks.join_next().await.is_some() {} } + ).await.ok(); + + Ok(()) +} +``` + +## DropGuard Pattern + +```rust +use tokio_util::sync::CancellationToken; + +// Auto-cancel on drop +let token = CancellationToken::new(); +let guard = token.clone().drop_guard(); + +tokio::spawn({ + let token = token.clone(); + async move { + token.cancelled().await; + println!("Cancelled!"); + } +}); + +drop(guard); // Automatically calls token.cancel() +``` + +## See Also + +- [async-joinset-structured](./async-joinset-structured.md) - Managing multiple tasks +- [async-select-racing](./async-select-racing.md) - select! for cancellation +- [async-tokio-runtime](./async-tokio-runtime.md) - Runtime shutdown diff --git a/.agents/skills/rust-skills/rules/async-clone-before-await.md b/.agents/skills/rust-skills/rules/async-clone-before-await.md new file mode 100644 index 0000000..2a01999 --- /dev/null +++ b/.agents/skills/rust-skills/rules/async-clone-before-await.md @@ -0,0 +1,171 @@ +# async-clone-before-await + +> Clone Arc/Rc data before await points to avoid holding references across suspension + +## Why It Matters + +References held across `.await` points extend the future's lifetime and can cause borrow checker issues or prevent `Send` bounds. Cloning `Arc`/`Rc` before the await ensures the future only holds owned data, making it `Send` and avoiding lifetime complications. + +## Bad + +```rust +use std::sync::Arc; + +async fn process(data: Arc) { + // Borrow extends across await - future is not Send + let slice = &data.items[..]; // Borrow of Arc contents + + expensive_async_operation().await; // Await with active borrow + + use_slice(slice); // Still using the borrow +} + +// Error: future cannot be sent between threads safely +// because `&[Item]` cannot be sent between threads safely +tokio::spawn(process(data)); +``` + +## Good + +```rust +use std::sync::Arc; + +async fn process(data: Arc) { + // Clone what you need before await + let items = data.items.clone(); // Owned Vec + + expensive_async_operation().await; + + use_items(&items); // Using owned data +} + +// Or clone the Arc itself +async fn share_data(data: Arc) { + let data = data.clone(); // Another Arc handle + + some_async_work().await; + + process(&data); // Safe - we own the Arc +} +``` + +## The Send Problem + +```rust +// Futures must be Send to spawn on multi-threaded runtime +async fn not_send() { + let rc = Rc::new(42); // Rc is !Send + + tokio::time::sleep(Duration::from_secs(1)).await; + + println!("{}", rc); // rc held across await +} + +tokio::spawn(not_send()); // ERROR: future is not Send + +// Fix: use Arc or don't hold across await +async fn is_send() { + let arc = Arc::new(42); // Arc is Send + + tokio::time::sleep(Duration::from_secs(1)).await; + + println!("{}", arc); +} + +tokio::spawn(is_send()); // OK +``` + +## Minimizing Clones + +```rust +// Bad: clone everything eagerly +async fn wasteful(data: Arc) { + let data = (*data).clone(); // Clones entire LargeData + async_work().await; + use_one_field(&data.small_field); +} + +// Good: clone only what you need +async fn efficient(data: Arc) { + let small = data.small_field.clone(); // Clone only needed field + async_work().await; + use_one_field(&small); +} + +// Good: if you need the whole thing, keep the Arc +async fn arc_efficient(data: Arc) { + let data = data.clone(); // Cheap Arc clone + async_work().await; + use_data(&data); // Access through Arc +} +``` + +## Spawn Pattern + +```rust +// Common pattern: clone for spawned task +let shared = Arc::new(SharedState::new()); + +for i in 0..10 { + let shared = shared.clone(); // Clone before moving into spawn + tokio::spawn(async move { + // Task owns its Arc clone + shared.do_something(i).await; + }); +} +``` + +## Scope-Based Approach + +```rust +// Limit borrow scope to before await +async fn scoped(data: Arc) { + // Scope 1: borrow, compute, drop borrow + let computed = { + let slice = &data.items[..]; // Borrow + compute_something(slice) // Use + }; // Borrow ends here + + // Now safe to await + expensive_async_operation().await; + + use_computed(computed); +} +``` + +## MutexGuard Across Await + +```rust +use tokio::sync::Mutex; + +// BAD: holding guard across await +async fn bad(mutex: Arc>) { + let mut guard = mutex.lock().await; + guard.value += 1; + + slow_operation().await; // Guard held during await! + + guard.value += 1; +} + +// GOOD: release before await +async fn good(mutex: Arc>) { + { + let mut guard = mutex.lock().await; + guard.value += 1; + } // Guard released + + slow_operation().await; + + { + let mut guard = mutex.lock().await; + guard.value += 1; + } +} +``` + +## See Also + +- [async-no-lock-await](./async-no-lock-await.md) - Lock guards across await +- [own-arc-shared](./own-arc-shared.md) - Arc usage patterns +- [async-spawn-blocking](./async-spawn-blocking.md) - Blocking in async diff --git a/.agents/skills/rust-skills/rules/async-join-parallel.md b/.agents/skills/rust-skills/rules/async-join-parallel.md new file mode 100644 index 0000000..bab3c68 --- /dev/null +++ b/.agents/skills/rust-skills/rules/async-join-parallel.md @@ -0,0 +1,158 @@ +# async-join-parallel + +> Use `join!` or `try_join!` for concurrent independent futures + +## Why It Matters + +Awaiting futures sequentially takes the sum of their durations. `join!` runs futures concurrently, taking only as long as the slowest one. For independent operations like multiple API calls or parallel file reads, this can dramatically reduce latency. + +## Bad + +```rust +async fn fetch_data() -> (User, Posts, Comments) { + // Sequential: 300ms total (100 + 100 + 100) + let user = fetch_user().await; // 100ms + let posts = fetch_posts().await; // 100ms + let comments = fetch_comments().await; // 100ms + + (user, posts, comments) +} + +async fn read_configs() -> Result<(Config, Settings)> { + // Sequential: 20ms + 20ms = 40ms + let config = fs::read_to_string("config.toml").await?; + let settings = fs::read_to_string("settings.json").await?; + + Ok((parse_config(&config)?, parse_settings(&settings)?)) +} +``` + +## Good + +```rust +use tokio::join; + +async fn fetch_data() -> (User, Posts, Comments) { + // Concurrent: ~100ms total (max of all three) + let (user, posts, comments) = join!( + fetch_user(), + fetch_posts(), + fetch_comments(), + ); + + (user, posts, comments) +} + +use tokio::try_join; + +async fn read_configs() -> Result<(Config, Settings)> { + // Concurrent: ~20ms total + let (config_str, settings_str) = try_join!( + fs::read_to_string("config.toml"), + fs::read_to_string("settings.json"), + )?; + + Ok((parse_config(&config_str)?, parse_settings(&settings_str)?)) +} +``` + +## join! vs try_join! + +```rust +// join! - all futures run to completion, returns tuple +let (a, b, c) = join!(future_a, future_b, future_c); + +// try_join! - short-circuits on first error +let (a, b, c) = try_join!(fallible_a, fallible_b, fallible_c)?; +// If fallible_b fails, returns Err immediately +// Other futures may still be running (cancellation is async) +``` + +## futures::join_all for Dynamic Collections + +```rust +use futures::future::join_all; + +async fn fetch_all_users(ids: &[u64]) -> Vec { + let futures: Vec<_> = ids.iter() + .map(|id| fetch_user(*id)) + .collect(); + + join_all(futures).await +} + +// With fallible futures +use futures::future::try_join_all; + +async fn fetch_all_users(ids: &[u64]) -> Result> { + let futures: Vec<_> = ids.iter() + .map(|id| fetch_user(*id)) + .collect(); + + try_join_all(futures).await +} +``` + +## Limiting Concurrency + +```rust +use futures::stream::{self, StreamExt}; + +async fn fetch_with_limit(ids: &[u64]) -> Vec> { + stream::iter(ids) + .map(|id| fetch_user(*id)) + .buffer_unordered(10) // Max 10 concurrent requests + .collect() + .await +} + +// Or with tokio::sync::Semaphore +use tokio::sync::Semaphore; + +async fn fetch_with_semaphore(ids: &[u64]) -> Vec { + let semaphore = Arc::new(Semaphore::new(10)); + + let futures: Vec<_> = ids.iter().map(|id| { + let semaphore = semaphore.clone(); + async move { + let _permit = semaphore.acquire().await.unwrap(); + fetch_user(*id).await + } + }).collect(); + + join_all(futures).await +} +``` + +## When NOT to Use join! + +```rust +// ❌ Dependent futures - must be sequential +async fn create_and_populate() -> Result<()> { + let db = create_database().await?; // Must complete first + populate_tables(&db).await?; // Depends on db + Ok(()) +} + +// ❌ Short-circuiting logic +async fn find_first() -> Option { + // Want to stop when one succeeds + // Use select! instead +} + +// ❌ Shared mutable state +async fn bad_shared_state() { + let counter = Arc::new(Mutex::new(0)); + // This might work but can cause contention + join!( + increment(counter.clone()), + increment(counter.clone()), + ); +} +``` + +## See Also + +- [async-try-join](./async-try-join.md) - Error handling in concurrent futures +- [async-select-racing](./async-select-racing.md) - Racing futures +- [async-joinset-structured](./async-joinset-structured.md) - Dynamic task sets diff --git a/.agents/skills/rust-skills/rules/async-joinset-structured.md b/.agents/skills/rust-skills/rules/async-joinset-structured.md new file mode 100644 index 0000000..a98c522 --- /dev/null +++ b/.agents/skills/rust-skills/rules/async-joinset-structured.md @@ -0,0 +1,195 @@ +# async-joinset-structured + +> Use `JoinSet` for managing dynamic collections of spawned tasks + +## Why It Matters + +When spawning a variable number of tasks, collecting `JoinHandle`s in a `Vec` and using `join_all` works but lacks flexibility. `JoinSet` provides a better abstraction: add/remove tasks dynamically, get results as they complete, and abort all on drop. It's the idiomatic way to manage task collections. + +## Bad + +```rust +// Manual handle management +let mut handles: Vec>> = Vec::new(); + +for url in urls { + handles.push(tokio::spawn(fetch(url))); +} + +// Wait for all, in order (not as they complete) +let results = futures::future::join_all(handles).await; + +// No easy way to cancel all, handle errors progressively, or add more tasks +``` + +## Good + +```rust +use tokio::task::JoinSet; + +let mut set = JoinSet::new(); + +for url in urls { + set.spawn(fetch(url.clone())); +} + +// Process results as they complete +while let Some(result) = set.join_next().await { + match result { + Ok(Ok(data)) => process(data), + Ok(Err(e)) => log::error!("Task failed: {}", e), + Err(e) => log::error!("Task panicked: {}", e), + } +} + +// All tasks done, set is empty +``` + +## Dynamic Task Addition + +```rust +use tokio::task::JoinSet; + +async fn worker_pool(mut rx: mpsc::Receiver) { + let mut set = JoinSet::new(); + let max_concurrent = 10; + + loop { + tokio::select! { + // Accept new tasks if under limit + Some(task) = rx.recv(), if set.len() < max_concurrent => { + set.spawn(process_task(task)); + } + + // Process completed tasks + Some(result) = set.join_next() => { + handle_result(result); + } + + // Exit when no tasks and channel closed + else => break, + } + } +} +``` + +## Abort on Drop + +```rust +use tokio::task::JoinSet; + +{ + let mut set = JoinSet::new(); + set.spawn(long_running_task()); + set.spawn(another_task()); + + // Early exit + return; +} // JoinSet dropped here - all tasks are aborted! + +// Explicit abort +let mut set = JoinSet::new(); +set.spawn(task()); +set.abort_all(); // Cancel all tasks +``` + +## Error Handling Pattern + +```rust +use tokio::task::JoinSet; + +async fn fetch_all(urls: &[String]) -> Vec> { + let mut set = JoinSet::new(); + let mut results = Vec::new(); + + for url in urls { + set.spawn(fetch(url.clone())); + } + + while let Some(join_result) = set.join_next().await { + let result = match join_result { + Ok(task_result) => task_result, + Err(join_error) => { + if join_error.is_panic() { + Err(Error::TaskPanicked) + } else { + Err(Error::TaskCancelled) + } + } + }; + results.push(result); + } + + results +} +``` + +## With Cancellation + +```rust +use tokio::task::JoinSet; +use tokio_util::sync::CancellationToken; + +async fn run_workers(shutdown: CancellationToken) { + let mut set = JoinSet::new(); + + for i in 0..4 { + let token = shutdown.child_token(); + set.spawn(async move { + loop { + tokio::select! { + _ = token.cancelled() => break, + _ = do_work(i) => {} + } + } + }); + } + + // Wait for shutdown + shutdown.cancelled().await; + + // Abort remaining tasks + set.abort_all(); + + // Wait for all to finish (drain aborted tasks) + while set.join_next().await.is_some() {} +} +``` + +## Spawning with Context + +```rust +use tokio::task::JoinSet; + +let mut set: JoinSet<(usize, Result)> = JoinSet::new(); + +for (index, url) in urls.iter().enumerate() { + let url = url.clone(); + set.spawn(async move { + (index, fetch(&url).await) + }); +} + +// Results include their index +while let Some(result) = set.join_next().await { + if let Ok((index, data)) = result { + results[index] = Some(data); + } +} +``` + +## JoinSet vs join_all + +| Feature | JoinSet | join_all | +|---------|---------|----------| +| Add tasks dynamically | Yes | No | +| Results as-completed | Yes | No (all at once) | +| Abort all on drop | Yes | No | +| Cancel individual | Yes | No | +| Memory efficient | Yes | Pre-allocates | + +## See Also + +- [async-join-parallel](./async-join-parallel.md) - Static concurrent futures +- [async-cancellation-token](./async-cancellation-token.md) - Cancellation patterns +- [async-try-join](./async-try-join.md) - Error handling in joins diff --git a/.agents/skills/rust-skills/rules/async-mpsc-queue.md b/.agents/skills/rust-skills/rules/async-mpsc-queue.md new file mode 100644 index 0000000..765b02f --- /dev/null +++ b/.agents/skills/rust-skills/rules/async-mpsc-queue.md @@ -0,0 +1,171 @@ +# async-mpsc-queue + +> Use `mpsc` channels for async message queues between tasks + +## Why It Matters + +`tokio::sync::mpsc` (multi-producer, single-consumer) is the workhorse channel for async Rust. It provides async send/receive, backpressure via bounded capacity, and efficient cloning of senders. It's the default choice for task-to-task communication. + +## Bad + +```rust +use std::sync::mpsc; // Wrong! Blocks the async runtime + +let (tx, rx) = std::sync::mpsc::channel(); + +tokio::spawn(async move { + tx.send("hello").unwrap(); // Might block +}); + +tokio::spawn(async move { + let msg = rx.recv().unwrap(); // BLOCKS the executor thread! +}); +``` + +## Good + +```rust +use tokio::sync::mpsc; + +let (tx, mut rx) = mpsc::channel::(100); + +tokio::spawn(async move { + tx.send("hello".to_string()).await.unwrap(); +}); + +tokio::spawn(async move { + while let Some(msg) = rx.recv().await { + println!("Received: {}", msg); + } +}); +``` + +## Sender Cloning + +```rust +use tokio::sync::mpsc; + +let (tx, mut rx) = mpsc::channel::(100); + +// Multiple producers +for i in 0..10 { + let tx = tx.clone(); // Cheap clone + tokio::spawn(async move { + tx.send(Event { source: i }).await.unwrap(); + }); +} + +// Drop original sender so channel closes when all clones dropped +drop(tx); + +// Consumer +while let Some(event) = rx.recv().await { + process(event); +} +// Loop exits when all senders dropped +``` + +## Message Handler Pattern + +```rust +use tokio::sync::mpsc; + +enum Command { + Get { key: String, reply: oneshot::Sender> }, + Set { key: String, value: Value }, + Delete { key: String }, +} + +async fn run_store(mut commands: mpsc::Receiver) { + let mut store = HashMap::new(); + + while let Some(cmd) = commands.recv().await { + match cmd { + Command::Get { key, reply } => { + let _ = reply.send(store.get(&key).cloned()); + } + Command::Set { key, value } => { + store.insert(key, value); + } + Command::Delete { key } => { + store.remove(&key); + } + } + } +} + +// Usage +async fn client(tx: mpsc::Sender) -> Option { + let (reply_tx, reply_rx) = oneshot::channel(); + + tx.send(Command::Get { + key: "foo".to_string(), + reply: reply_tx + }).await.unwrap(); + + reply_rx.await.unwrap() +} +``` + +## Graceful Shutdown + +```rust +async fn worker(mut rx: mpsc::Receiver, shutdown: CancellationToken) { + loop { + tokio::select! { + _ = shutdown.cancelled() => { + // Drain remaining messages + while let Ok(task) = rx.try_recv() { + process(task).await; + } + break; + } + Some(task) = rx.recv() => { + process(task).await; + } + else => break, // Channel closed + } + } +} +``` + +## WeakSender for Optional Producers + +```rust +use tokio::sync::mpsc; + +let (tx, mut rx) = mpsc::channel::(100); +let weak = tx.downgrade(); // Doesn't keep channel alive + +tokio::spawn(async move { + // Strong sender - keeps channel alive + tx.send("from strong".into()).await.unwrap(); +}); + +tokio::spawn(async move { + // Weak sender - may fail if strong senders dropped + if let Some(tx) = weak.upgrade() { + tx.send("from weak".into()).await.unwrap(); + } +}); +``` + +## Permit Pattern + +```rust +// Reserve slot before preparing message +let permit = tx.reserve().await?; + +// Now we have guaranteed capacity +let message = expensive_to_create_message(); +permit.send(message); // Never fails + +// Useful when message creation is expensive +// and you don't want to create it if channel is full +``` + +## See Also + +- [async-bounded-channel](./async-bounded-channel.md) - Why bounded channels +- [async-oneshot-response](./async-oneshot-response.md) - Request-response with oneshot +- [async-broadcast-pubsub](./async-broadcast-pubsub.md) - Multiple consumers diff --git a/.agents/skills/rust-skills/rules/async-no-lock-await.md b/.agents/skills/rust-skills/rules/async-no-lock-await.md new file mode 100644 index 0000000..b1c4715 --- /dev/null +++ b/.agents/skills/rust-skills/rules/async-no-lock-await.md @@ -0,0 +1,156 @@ +# async-no-lock-await + +> Never hold `Mutex`/`RwLock` across `.await` + +## Why It Matters + +Holding a lock across an `.await` point can cause deadlocks and severely hurt performance. The task may be suspended while holding the lock, blocking all other tasks waiting for it - potentially indefinitely. + +## Bad + +```rust +use tokio::sync::Mutex; + +async fn bad_update(state: &Mutex) { + let mut guard = state.lock().await; + + // BAD: Lock held across await! + let data = fetch_from_network().await; + + guard.value = data; +} // Lock finally released + +// This can deadlock or starve other tasks +``` + +## Good + +```rust +use tokio::sync::Mutex; + +async fn good_update(state: &Mutex) { + // Fetch data BEFORE taking the lock + let data = fetch_from_network().await; + + // Lock only for the quick update + let mut guard = state.lock().await; + guard.value = data; +} // Lock released immediately + +// Alternative: Clone data out, process, then update +async fn good_update_v2(state: &Mutex) { + // Extract what we need + let id = { + let guard = state.lock().await; + guard.id.clone() + }; // Lock released! + + // Do async work without lock + let data = fetch_by_id(id).await; + + // Quick update + state.lock().await.value = data; +} +``` + +## The Problem Visualized + +```rust +// Task A: +let guard = mutex.lock().await; // Acquires lock +expensive_io().await; // Suspended, still holding lock! +// ... many milliseconds pass ... +drop(guard); // Finally releases + +// Task B, C, D: +let guard = mutex.lock().await; // All blocked waiting for A! +``` + +## Patterns for Extraction + +```rust +use tokio::sync::Mutex; + +// Pattern 1: Clone out, process, update +async fn pattern_clone(state: &Mutex) { + let config = state.lock().await.config.clone(); + let result = process_with_io(&config).await; + state.lock().await.result = result; +} + +// Pattern 2: Compute closure, apply +async fn pattern_closure(state: &Mutex) { + let update = compute_update().await; + + state.lock().await.apply(update); +} + +// Pattern 3: Message passing +async fn pattern_message( + state: &Mutex, + tx: mpsc::Sender, +) { + let update = compute_update().await; + tx.send(update).await.unwrap(); +} + +// Separate task handles updates +async fn state_manager( + state: Arc>, + mut rx: mpsc::Receiver, +) { + while let Some(update) = rx.recv().await { + state.lock().await.apply(update); + } +} +``` + +## Using RwLock + +```rust +use tokio::sync::RwLock; + +async fn read_heavy(state: &RwLock) { + // Multiple readers OK, but still don't hold across await + let value = { + let guard = state.read().await; + guard.value.clone() + }; + + // Process without lock + let result = process(value).await; + + // Write lock for update + state.write().await.result = result; +} +``` + +## std::sync::Mutex vs tokio::sync::Mutex + +```rust +// std::sync::Mutex: Blocks the entire thread +// - Use for quick, CPU-only operations +// - NEVER use in async code with await inside + +// tokio::sync::Mutex: Async-aware, yields to runtime +// - Use in async code +// - Still don't hold across await points! + +// std::sync::Mutex in async (quick operation, OK): +async fn quick_update(state: &std::sync::Mutex) { + state.lock().unwrap().counter += 1; // No await, OK +} + +// tokio::sync::Mutex (must use if lock scope has await): +async fn must_await_inside(state: &tokio::sync::Mutex) { + let mut guard = state.lock().await; + // Only if you REALLY need the lock during async op + // (usually you don't - redesign instead) +} +``` + +## See Also + +- [async-spawn-blocking](async-spawn-blocking.md) - Use spawn_blocking for CPU work +- [async-clone-before-await](async-clone-before-await.md) - Clone data before await +- [anti-lock-across-await](anti-lock-across-await.md) - Anti-pattern reference diff --git a/.agents/skills/rust-skills/rules/async-oneshot-response.md b/.agents/skills/rust-skills/rules/async-oneshot-response.md new file mode 100644 index 0000000..c7fc821 --- /dev/null +++ b/.agents/skills/rust-skills/rules/async-oneshot-response.md @@ -0,0 +1,191 @@ +# async-oneshot-response + +> Use `oneshot` channel for request-response patterns + +## Why It Matters + +When one task needs to send a request and wait for exactly one response, `oneshot` is the perfect fit. It's a single-use channel optimized for this pattern—no buffering, no clone overhead. Combined with `mpsc`, it enables clean actor-style message passing. + +## Bad + +```rust +// Using mpsc for single response - wasteful +let (tx, mut rx) = mpsc::channel::(1); +send_request().await; +let response = rx.recv().await.unwrap(); +// Channel persists, could accidentally receive more + +// Using shared state - complex +let result = Arc::new(Mutex::new(None)); +send_request(result.clone()).await; +while result.lock().await.is_none() { + tokio::time::sleep(Duration::from_millis(10)).await; // Polling! +} +``` + +## Good + +```rust +use tokio::sync::oneshot; + +let (tx, rx) = oneshot::channel::(); + +// Send request with reply channel +send_request(Request { data, reply: tx }).await; + +// Wait for response +let response = rx.await?; + +// Channel is consumed - can't accidentally reuse +``` + +## Request-Response Pattern + +```rust +use tokio::sync::{mpsc, oneshot}; + +enum Request { + Get { + key: String, + reply: oneshot::Sender>, + }, + Set { + key: String, + value: Value, + reply: oneshot::Sender, + }, +} + +// Service handler +async fn service(mut rx: mpsc::Receiver) { + let mut store = HashMap::new(); + + while let Some(req) = rx.recv().await { + match req { + Request::Get { key, reply } => { + let value = store.get(&key).cloned(); + let _ = reply.send(value); // Ignore if receiver dropped + } + Request::Set { key, value, reply } => { + store.insert(key, value); + let _ = reply.send(true); + } + } + } +} + +// Client +async fn get_value(tx: &mpsc::Sender, key: &str) -> Option { + let (reply_tx, reply_rx) = oneshot::channel(); + + tx.send(Request::Get { + key: key.to_string(), + reply: reply_tx, + }).await.ok()?; + + reply_rx.await.ok()? +} +``` + +## With Timeout + +```rust +use tokio::time::{timeout, Duration}; + +async fn request_with_timeout( + tx: &mpsc::Sender, + key: &str, +) -> Result { + let (reply_tx, reply_rx) = oneshot::channel(); + + tx.send(Request::Get { + key: key.to_string(), + reply: reply_tx, + }).await.map_err(|_| Error::ServiceDown)?; + + timeout(Duration::from_secs(5), reply_rx) + .await + .map_err(|_| Error::Timeout)? + .map_err(|_| Error::ServiceDown)? + .ok_or(Error::NotFound) +} +``` + +## Error Handling + +```rust +use tokio::sync::oneshot; + +let (tx, rx) = oneshot::channel::(); + +// Sender dropped without sending +drop(tx); +match rx.await { + Ok(value) => println!("Got: {}", value), + Err(oneshot::error::RecvError { .. }) => { + println!("Sender dropped"); + } +} + +// Receiver dropped before send +let (tx, rx) = oneshot::channel::(); +drop(rx); +match tx.send("hello".to_string()) { + Ok(()) => println!("Sent"), + Err(value) => println!("Receiver dropped, value: {}", value), +} +``` + +## Closed Detection + +```rust +// Check if receiver is still waiting +let (tx, rx) = oneshot::channel::(); + +// In producer +if tx.is_closed() { + println!("Receiver already gone, skip expensive computation"); +} else { + let result = expensive_computation(); + tx.send(result).ok(); +} + +// Async wait for close +let tx_clone = tx.clone(); // Note: can't actually clone, just showing concept +tokio::select! { + _ = tx.closed() => println!("Receiver dropped"), + result = compute() => { tx.send(result).ok(); } +} +``` + +## Response Type Wrapper + +```rust +// Standardize request-response pattern +struct RpcRequest { + request: Req, + reply: oneshot::Sender, +} + +impl RpcRequest { + fn new(request: Req) -> (Self, oneshot::Receiver) { + let (tx, rx) = oneshot::channel(); + (RpcRequest { request, reply: tx }, rx) + } + + fn respond(self, response: Res) { + let _ = self.reply.send(response); + } +} + +// Usage +let (req, rx) = RpcRequest::new(GetUser { id: 42 }); +tx.send(req).await?; +let user = rx.await?; +``` + +## See Also + +- [async-mpsc-queue](./async-mpsc-queue.md) - Pair with oneshot for request-response +- [async-bounded-channel](./async-bounded-channel.md) - Channel sizing +- [async-select-racing](./async-select-racing.md) - Timeout patterns diff --git a/.agents/skills/rust-skills/rules/async-select-racing.md b/.agents/skills/rust-skills/rules/async-select-racing.md new file mode 100644 index 0000000..ba71893 --- /dev/null +++ b/.agents/skills/rust-skills/rules/async-select-racing.md @@ -0,0 +1,198 @@ +# async-select-racing + +> Use `select!` to race futures and handle the first to complete + +## Why It Matters + +Sometimes you need the first result from multiple futures—timeout vs operation, cancellation vs work, or competing alternatives. `tokio::select!` lets you race futures and handle whichever completes first, while properly cancelling the others. + +## Bad + +```rust +// Can't express "whichever finishes first" +async fn fetch_with_fallback() -> Data { + match fetch_primary().await { + Ok(data) => data, + Err(_) => fetch_fallback().await.unwrap(), // Sequential, not racing + } +} + +// Manual timeout is error-prone +async fn fetch_with_timeout() -> Option { + let start = Instant::now(); + loop { + if start.elapsed() > Duration::from_secs(5) { + return None; + } + // How do we check timeout while awaiting? + } +} +``` + +## Good + +```rust +use tokio::select; + +async fn fetch_with_timeout() -> Result { + select! { + result = fetch_data() => result, + _ = tokio::time::sleep(Duration::from_secs(5)) => { + Err(Error::Timeout) + } + } +} + +async fn fetch_with_fallback() -> Data { + select! { + result = fetch_primary() => { + match result { + Ok(data) => data, + Err(_) => fetch_fallback().await.unwrap() + } + } + _ = tokio::time::sleep(Duration::from_secs(1)) => { + // Primary too slow, use fallback + fetch_fallback().await.unwrap() + } + } +} +``` + +## select! Syntax + +```rust +select! { + // Pattern = future => handler + result = async_operation() => { + // Handle result + println!("Got: {:?}", result); + } + + // Can bind with pattern matching + Ok(data) = fallible_operation() => { + process(data); + } + + // Conditional branches with if guards + msg = channel.recv(), if should_receive => { + handle_message(msg); + } + + // else branch for when all futures are disabled + else => { + println!("All branches disabled"); + } +} +``` + +## Cancellation Behavior + +```rust +async fn select_example() { + select! { + _ = operation_a() => { + println!("A completed first"); + // operation_b() is dropped/cancelled + } + _ = operation_b() => { + println!("B completed first"); + // operation_a() is dropped/cancelled + } + } +} + +// Futures are cancelled at their next .await point +// For immediate cancellation, futures must be cancel-safe +``` + +## Biased Selection + +```rust +// By default, select! randomly picks when multiple are ready +// Use biased mode for deterministic priority +select! { + biased; // Check branches in order + + msg = high_priority.recv() => handle_high(msg), + msg = low_priority.recv() => handle_low(msg), +} + +// Without biased, both channels have equal chance +// when both have messages ready +``` + +## Loop with select! + +```rust +async fn event_loop( + mut commands: mpsc::Receiver, + shutdown: CancellationToken, +) { + loop { + select! { + _ = shutdown.cancelled() => { + println!("Shutting down"); + break; + } + Some(cmd) = commands.recv() => { + process_command(cmd).await; + } + else => { + // commands channel closed + break; + } + } + } +} +``` + +## Racing Multiple of Same Type + +```rust +// Race multiple servers for fastest response +async fn fastest_response(servers: &[String]) -> Result { + let futures = servers.iter() + .map(|s| fetch_from(s)) + .collect::>(); + + // select! requires static branches, use select_all for dynamic + let (result, _index, _remaining) = + futures::future::select_all(futures).await; + + result +} +``` + +## Common Patterns + +```rust +// Timeout +select! { + result = operation() => result, + _ = sleep(Duration::from_secs(5)) => Err(Timeout), +} + +// Cancellation +select! { + result = operation() => result, + _ = cancel_token.cancelled() => Err(Cancelled), +} + +// Interval with cancellation +let mut interval = tokio::time::interval(Duration::from_secs(1)); +loop { + select! { + _ = shutdown.cancelled() => break, + _ = interval.tick() => { + do_periodic_work().await; + } + } +} +``` + +## See Also + +- [async-cancellation-token](./async-cancellation-token.md) - Cancellation patterns +- [async-join-parallel](./async-join-parallel.md) - All futures, not racing +- [async-bounded-channel](./async-bounded-channel.md) - Channel operations in select diff --git a/.agents/skills/rust-skills/rules/async-spawn-blocking.md b/.agents/skills/rust-skills/rules/async-spawn-blocking.md new file mode 100644 index 0000000..8312d33 --- /dev/null +++ b/.agents/skills/rust-skills/rules/async-spawn-blocking.md @@ -0,0 +1,154 @@ +# async-spawn-blocking + +> Use `spawn_blocking` for CPU-intensive work + +## Why It Matters + +Async runtimes like Tokio use a small number of threads to handle many tasks. CPU-intensive or blocking operations on these threads starve other tasks. `spawn_blocking` moves such work to a dedicated thread pool. + +## Bad + +```rust +// BAD: Blocks the async runtime thread +async fn process_image(data: &[u8]) -> ProcessedImage { + // CPU-intensive work on async thread! + let resized = resize_image(data); // Blocks! + let compressed = compress(resized); // Blocks! + compressed +} + +// BAD: Synchronous file I/O in async context +async fn read_large_file(path: &Path) -> Vec { + std::fs::read(path).unwrap() // Blocks the runtime! +} +``` + +## Good + +```rust +use tokio::task; + +// GOOD: Offload CPU work to blocking pool +async fn process_image(data: Vec) -> ProcessedImage { + task::spawn_blocking(move || { + let resized = resize_image(&data); + compress(resized) + }) + .await + .expect("spawn_blocking failed") +} + +// GOOD: Use async file I/O +async fn read_large_file(path: &Path) -> tokio::io::Result> { + tokio::fs::read(path).await +} + +// GOOD: Or spawn_blocking for unavoidable sync I/O +async fn read_with_sync_lib(path: PathBuf) -> Vec { + task::spawn_blocking(move || { + sync_library::read_file(&path) + }) + .await + .unwrap() +} +``` + +## What Counts as Blocking + +```rust +// CPU-intensive operations +- Cryptographic operations (hashing, encryption) +- Image/video processing +- Compression/decompression +- Complex parsing +- Mathematical computations + +// Blocking I/O +- std::fs operations +- Synchronous database drivers +- Synchronous HTTP clients +- Thread::sleep + +// Example thresholds (rough guidelines): +// < 10µs: OK on async thread +// 10µs - 1ms: Consider spawn_blocking +// > 1ms: Definitely spawn_blocking +``` + +## Practical Examples + +```rust +// Password hashing (CPU-intensive) +async fn hash_password(password: String) -> String { + task::spawn_blocking(move || { + bcrypt::hash(password, bcrypt::DEFAULT_COST).unwrap() + }) + .await + .unwrap() +} + +// JSON parsing of large documents +async fn parse_large_json(data: String) -> serde_json::Value { + task::spawn_blocking(move || { + serde_json::from_str(&data).unwrap() + }) + .await + .unwrap() +} + +// Compression +async fn compress_data(data: Vec) -> Vec { + task::spawn_blocking(move || { + let mut encoder = flate2::write::GzEncoder::new( + Vec::new(), + flate2::Compression::default(), + ); + encoder.write_all(&data).unwrap(); + encoder.finish().unwrap() + }) + .await + .unwrap() +} +``` + +## spawn_blocking vs spawn + +```rust +// spawn: Runs async code on runtime threads +tokio::spawn(async { + // Async code here + some_async_operation().await; +}); + +// spawn_blocking: Runs sync code on blocking thread pool +tokio::task::spawn_blocking(|| { + // Synchronous, possibly CPU-intensive code + heavy_computation(); +}); + +// spawn_blocking returns JoinHandle that can be awaited +let result = tokio::task::spawn_blocking(|| { + expensive_sync_operation() +}).await?; +``` + +## Rayon for Parallel CPU Work + +```rust +// For parallel CPU work, consider Rayon inside spawn_blocking +async fn parallel_process(items: Vec) -> Vec { + task::spawn_blocking(move || { + use rayon::prelude::*; + items.par_iter() + .map(|item| cpu_intensive_transform(item)) + .collect() + }) + .await + .unwrap() +} +``` + +## See Also + +- [async-tokio-fs](async-tokio-fs.md) - Use tokio::fs for async file I/O +- [async-no-lock-await](async-no-lock-await.md) - Don't hold locks across await diff --git a/.agents/skills/rust-skills/rules/async-tokio-fs.md b/.agents/skills/rust-skills/rules/async-tokio-fs.md new file mode 100644 index 0000000..afe599c --- /dev/null +++ b/.agents/skills/rust-skills/rules/async-tokio-fs.md @@ -0,0 +1,167 @@ +# async-tokio-fs + +> Use `tokio::fs` instead of `std::fs` in async code + +## Why It Matters + +`std::fs` operations are blocking—they stop the current thread until the syscall completes. In async code, this blocks the executor thread, preventing it from running other tasks. `tokio::fs` wraps filesystem operations in `spawn_blocking`, keeping the executor responsive. + +## Bad + +```rust +async fn process_files(paths: &[PathBuf]) -> Result> { + let mut contents = Vec::new(); + + for path in paths { + // BLOCKS the entire executor thread! + let data = std::fs::read_to_string(path)?; + contents.push(data); + } + + Ok(contents) +} + +// While reading a file, NO other tasks can run on this thread +``` + +## Good + +```rust +use tokio::fs; + +async fn process_files(paths: &[PathBuf]) -> Result> { + let mut contents = Vec::new(); + + for path in paths { + // Non-blocking: allows other tasks to run + let data = fs::read_to_string(path).await?; + contents.push(data); + } + + Ok(contents) +} + +// Even better: concurrent reads +async fn process_files_concurrent(paths: &[PathBuf]) -> Result> { + let futures: Vec<_> = paths.iter() + .map(|path| fs::read_to_string(path)) + .collect(); + + futures::future::try_join_all(futures).await +} +``` + +## tokio::fs API + +```rust +use tokio::fs; + +// Reading +let contents = fs::read_to_string("file.txt").await?; +let bytes = fs::read("file.bin").await?; + +// Writing +fs::write("output.txt", "contents").await?; + +// File operations +let file = fs::File::open("file.txt").await?; +let file = fs::File::create("new.txt").await?; + +// Directory operations +fs::create_dir("new_dir").await?; +fs::create_dir_all("nested/dir/path").await?; +fs::remove_dir("empty_dir").await?; +fs::remove_dir_all("dir_with_contents").await?; + +// Metadata +let metadata = fs::metadata("file.txt").await?; +let canonical = fs::canonicalize("./relative").await?; + +// Rename/remove +fs::rename("old.txt", "new.txt").await?; +fs::remove_file("file.txt").await?; + +// Read directory +let mut entries = fs::read_dir("some_dir").await?; +while let Some(entry) = entries.next_entry().await? { + println!("{}", entry.path().display()); +} +``` + +## Async File I/O + +```rust +use tokio::fs::File; +use tokio::io::{AsyncReadExt, AsyncWriteExt, AsyncBufReadExt, BufReader}; + +// Read with buffer +let mut file = File::open("large.bin").await?; +let mut buffer = vec![0u8; 4096]; +let bytes_read = file.read(&mut buffer).await?; + +// Read all +let mut contents = Vec::new(); +file.read_to_end(&mut contents).await?; + +// Write +let mut file = File::create("output.bin").await?; +file.write_all(b"data").await?; +file.flush().await?; + +// Buffered line reading +let file = File::open("lines.txt").await?; +let reader = BufReader::new(file); +let mut lines = reader.lines(); + +while let Some(line) = lines.next_line().await? { + println!("{}", line); +} +``` + +## When std::fs is Acceptable + +```rust +// Startup/initialization (before async runtime) +fn main() { + let config = std::fs::read_to_string("config.toml") + .expect("config file required"); + + tokio::runtime::Runtime::new() + .unwrap() + .block_on(run_with_config(config)); +} + +// Single-threaded current_thread runtime (less impact) +#[tokio::main(flavor = "current_thread")] +async fn main() { + // Still prefer tokio::fs, but impact is lower +} + +// When file operations are rare and quick +// (e.g., reading small config once per hour) +``` + +## Performance Considerations + +```rust +// tokio::fs uses spawn_blocking internally +// For many small files, the overhead adds up + +// Batch operations when possible +let paths: Vec<_> = entries.iter() + .map(|e| e.path()) + .collect(); + +let contents = futures::future::try_join_all( + paths.iter().map(|p| fs::read_to_string(p)) +).await?; + +// For heavy I/O, consider memory-mapped files +// (requires unsafe or mmap crate) +``` + +## See Also + +- [async-spawn-blocking](./async-spawn-blocking.md) - How tokio::fs works internally +- [async-tokio-runtime](./async-tokio-runtime.md) - Runtime configuration +- [err-context-chain](./err-context-chain.md) - Adding path context to IO errors diff --git a/.agents/skills/rust-skills/rules/async-tokio-runtime.md b/.agents/skills/rust-skills/rules/async-tokio-runtime.md new file mode 100644 index 0000000..1bc7554 --- /dev/null +++ b/.agents/skills/rust-skills/rules/async-tokio-runtime.md @@ -0,0 +1,169 @@ +# async-tokio-runtime + +> Configure Tokio runtime appropriately for your workload + +## Why It Matters + +Tokio's default multi-threaded runtime isn't always optimal. CPU-bound work needs different configuration than IO-bound work. Incorrect configuration leads to poor performance, blocked workers, or resource exhaustion. Understanding runtime options lets you tune for your specific use case. + +## Bad + +```rust +// Default runtime for everything - not optimal +#[tokio::main] +async fn main() { + // CPU-heavy work on async executor starves IO tasks + for data in datasets { + let result = heavy_computation(data).await; + } +} + +// Single-threaded when multi-threaded is needed +#[tokio::main(flavor = "current_thread")] +async fn main() { + // Can't utilize multiple cores for concurrent tasks + for _ in 0..1000 { + tokio::spawn(async { /* IO work */ }); + } +} +``` + +## Good + +```rust +// Multi-threaded for concurrent IO (default) +#[tokio::main] +async fn main() { + // Good for many concurrent network connections + let handles: Vec<_> = urls.iter() + .map(|url| tokio::spawn(fetch(url.clone()))) + .collect(); + + futures::future::join_all(handles).await; +} + +// Current-thread for single-threaded scenarios +#[tokio::main(flavor = "current_thread")] +async fn main() { + // Good for single-connection clients, simpler debugging + let client = Client::new(); + client.run().await; +} + +// Custom configuration +#[tokio::main(worker_threads = 4)] +async fn main() { + // Limit to 4 worker threads +} + +// Or manual setup for more control +fn main() { + let runtime = tokio::runtime::Builder::new_multi_thread() + .worker_threads(4) + .enable_all() + .thread_name("my-worker") + .build() + .unwrap(); + + runtime.block_on(async_main()); +} +``` + +## Runtime Types + +| Runtime | Use Case | Configuration | +|---------|----------|---------------| +| Multi-thread | IO-bound, many connections | `#[tokio::main]` (default) | +| Current-thread | CLI tools, tests, single connection | `flavor = "current_thread"` | +| Custom | Fine-tuned performance | `Builder::new_*()` | + +## Worker Thread Tuning + +```rust +use tokio::runtime::Builder; + +// IO-bound: more threads than cores can help +let io_runtime = Builder::new_multi_thread() + .worker_threads(num_cpus::get() * 2) // IO can benefit from oversubscription + .max_blocking_threads(32) // For spawn_blocking calls + .enable_io() + .enable_time() + .build()?; + +// CPU-bound: match core count +let cpu_runtime = Builder::new_multi_thread() + .worker_threads(num_cpus::get()) // No benefit from more than cores + .build()?; +``` + +## Multiple Runtimes + +```rust +// Separate runtimes for different workloads +struct App { + io_runtime: Runtime, + cpu_runtime: Runtime, +} + +impl App { + fn new() -> Self { + Self { + io_runtime: Builder::new_multi_thread() + .worker_threads(8) + .thread_name("io-worker") + .build() + .unwrap(), + cpu_runtime: Builder::new_multi_thread() + .worker_threads(4) + .thread_name("cpu-worker") + .build() + .unwrap(), + } + } + + fn spawn_io(&self, future: F) + where F: Future + Send + 'static, F::Output: Send + 'static + { + self.io_runtime.spawn(future); + } + + fn spawn_cpu(&self, task: F) + where F: FnOnce() + Send + 'static + { + self.cpu_runtime.spawn_blocking(task); + } +} +``` + +## Runtime in Tests + +```rust +// Single test runtime +#[tokio::test] +async fn test_single() { + assert!(true); +} + +// Multi-threaded test +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn test_concurrent() { + let (tx, rx) = tokio::sync::oneshot::channel(); + tokio::spawn(async move { tx.send(42).unwrap() }); + assert_eq!(rx.await.unwrap(), 42); +} + +// Custom runtime in test +#[test] +fn test_with_custom_runtime() { + let rt = Builder::new_current_thread().build().unwrap(); + rt.block_on(async { + // test code + }); +} +``` + +## See Also + +- [async-spawn-blocking](./async-spawn-blocking.md) - Handling blocking code +- [async-no-lock-await](./async-no-lock-await.md) - Avoiding lock issues +- [async-joinset-structured](./async-joinset-structured.md) - Managing spawned tasks diff --git a/.agents/skills/rust-skills/rules/async-try-join.md b/.agents/skills/rust-skills/rules/async-try-join.md new file mode 100644 index 0000000..9170a0e --- /dev/null +++ b/.agents/skills/rust-skills/rules/async-try-join.md @@ -0,0 +1,172 @@ +# async-try-join + +> Use `try_join!` for concurrent fallible operations with early return on error + +## Why It Matters + +When running multiple fallible operations concurrently, `try_join!` returns `Err` as soon as any future fails, without waiting for the others. This provides fail-fast behavior while still running operations in parallel. For many operations, use `futures::future::try_join_all`. + +## Bad + +```rust +// Sequential - slow and no early return benefit +async fn fetch_all() -> Result<(A, B, C)> { + let a = fetch_a().await?; // If this fails, we wait for nothing + let b = fetch_b().await?; // But if this fails, we waited for A + let c = fetch_c().await?; + Ok((a, b, c)) +} + +// join! ignores errors +async fn fetch_all() -> (Result, Result, Result) { + let (a, b, c) = join!(fetch_a(), fetch_b(), fetch_c()); + // All complete even if first one failed + (a, b, c) // Now we have to handle three Results +} +``` + +## Good + +```rust +use tokio::try_join; + +async fn fetch_all() -> Result<(A, B, C)> { + // Concurrent AND fail-fast + let (a, b, c) = try_join!( + fetch_a(), + fetch_b(), + fetch_c(), + )?; + + Ok((a, b, c)) +} + +// For dynamic collections +use futures::future::try_join_all; + +async fn fetch_users(ids: &[u64]) -> Result> { + let futures: Vec<_> = ids.iter() + .map(|id| fetch_user(*id)) + .collect(); + + try_join_all(futures).await +} +``` + +## Error Handling Patterns + +```rust +// Different error types - need common error type +async fn mixed_operations() -> Result<(A, B), Error> { + let (a, b) = try_join!( + fetch_a().map_err(Error::from), // Convert errors + fetch_b().map_err(Error::from), + )?; + Ok((a, b)) +} + +// Collect all results, then handle errors +async fn all_or_nothing(ids: &[u64]) -> Result> { + try_join_all(ids.iter().map(|id| fetch_user(*id))).await +} + +// Collect successes, log failures +async fn best_effort(ids: &[u64]) -> Vec { + let results = futures::future::join_all( + ids.iter().map(|id| fetch_user(*id)) + ).await; + + results.into_iter() + .filter_map(|r| match r { + Ok(user) => Some(user), + Err(e) => { + log::warn!("Failed to fetch user: {}", e); + None + } + }) + .collect() +} +``` + +## Cancellation Behavior + +```rust +// try_join! cancels remaining futures on error +async fn with_cancellation() -> Result<()> { + // If fetch_a() fails, fetch_b() and fetch_c() are dropped + // But "dropped" != "immediately stopped" + // They stop at their next .await point + + try_join!( + async { + fetch_a().await?; + cleanup_a().await; // May not run if other future fails + Ok::<_, Error>(()) + }, + async { + fetch_b().await?; + cleanup_b().await; // May not run if other future fails + Ok::<_, Error>(()) + }, + )?; + + Ok(()) +} + +// For guaranteed cleanup, use Drop guards or explicit handling +``` + +## With Timeout + +```rust +use tokio::time::{timeout, Duration}; + +async fn fetch_with_timeout() -> Result<(A, B)> { + timeout( + Duration::from_secs(10), + try_join!(fetch_a(), fetch_b()) + ) + .await + .map_err(|_| Error::Timeout)? +} + +// Per-operation timeout +async fn individual_timeouts() -> Result<(A, B)> { + try_join!( + timeout(Duration::from_secs(5), fetch_a()) + .map_err(|_| Error::Timeout) + .and_then(|r| async { r }), + timeout(Duration::from_secs(5), fetch_b()) + .map_err(|_| Error::Timeout) + .and_then(|r| async { r }), + ) +} +``` + +## try_join! vs FuturesUnordered + +```rust +use futures::stream::{FuturesUnordered, StreamExt}; + +// try_join!: wait for all, fail fast +let (a, b, c) = try_join!(fa, fb, fc)?; + +// FuturesUnordered: process as they complete +let mut futures = FuturesUnordered::new(); +futures.push(fetch_a()); +futures.push(fetch_b()); +futures.push(fetch_c()); + +while let Some(result) = futures.next().await { + match result { + Ok(data) => process(data), + Err(e) => return Err(e), // Can fail fast manually + } +} +``` + +## See Also + +- [async-join-parallel](./async-join-parallel.md) - Non-fallible concurrent futures +- [async-select-racing](./async-select-racing.md) - First-to-complete semantics +- [err-question-mark](./err-question-mark.md) - Error propagation diff --git a/.agents/skills/rust-skills/rules/async-watch-latest.md b/.agents/skills/rust-skills/rules/async-watch-latest.md new file mode 100644 index 0000000..5ed4baf --- /dev/null +++ b/.agents/skills/rust-skills/rules/async-watch-latest.md @@ -0,0 +1,189 @@ +# async-watch-latest + +> Use `watch` channel for sharing the latest value with multiple observers + +## Why It Matters + +`watch` is optimized for scenarios where receivers only care about the most recent value, not the history of changes. Unlike `broadcast`, slow receivers don't lag—they simply skip intermediate values. This is perfect for configuration, state, or status that should always reflect the current situation. + +## Bad + +```rust +// Using broadcast when only latest value matters +let (tx, _) = broadcast::channel::(100); + +// Receivers might process stale configs if they're slow +// And they waste time processing intermediate values + +// Using mpsc with buffered stale values +let (tx, mut rx) = mpsc::channel::(100); +// Receiver might process outdated statuses +``` + +## Good + +```rust +use tokio::sync::watch; + +let (tx, rx) = watch::channel(Config::default()); + +// Multiple observers +let rx1 = rx.clone(); +let rx2 = rx.clone(); + +// Observer 1: waits for changes +tokio::spawn(async move { + let mut rx = rx1; + while rx.changed().await.is_ok() { + let config = rx.borrow(); + apply_config(&*config); + } +}); + +// Observer 2: also sees all changes +tokio::spawn(async move { + let mut rx = rx2; + while rx.changed().await.is_ok() { + let config = rx.borrow(); + log_config_change(&*config); + } +}); + +// Update the value +tx.send(Config::new())?; +``` + +## watch Semantics + +```rust +use tokio::sync::watch; + +let (tx, mut rx) = watch::channel("initial"); + +// Immediate read - no waiting +assert_eq!(*rx.borrow(), "initial"); + +// Wait for change +tx.send("updated")?; +rx.changed().await?; +assert_eq!(*rx.borrow(), "updated"); + +// Multiple rapid updates - receiver sees latest +tx.send("v1")?; +tx.send("v2")?; +tx.send("v3")?; +rx.changed().await?; +assert_eq!(*rx.borrow(), "v3"); // Skipped v1, v2 +``` + +## Configuration Reload Pattern + +```rust +use tokio::sync::watch; +use std::sync::Arc; + +struct AppConfig { + log_level: Level, + max_connections: usize, +} + +async fn config_watcher(tx: watch::Sender>) { + loop { + tokio::time::sleep(Duration::from_secs(60)).await; + + if let Ok(new_config) = reload_config_from_disk() { + // Only notifies if value actually changed + tx.send_if_modified(|current| { + if *current != new_config { + *current = Arc::new(new_config); + true + } else { + false + } + }); + } + } +} + +async fn worker(mut config_rx: watch::Receiver>) { + loop { + tokio::select! { + _ = config_rx.changed() => { + let config = config_rx.borrow().clone(); + reconfigure(&config); + } + _ = do_work() => {} + } + } +} +``` + +## State Machine Updates + +```rust +#[derive(Clone, PartialEq)] +enum ConnectionState { + Disconnected, + Connecting, + Connected, + Error(String), +} + +struct Connection { + state_tx: watch::Sender, + state_rx: watch::Receiver, +} + +impl Connection { + async fn wait_connected(&mut self) -> Result<(), Error> { + loop { + let state = self.state_rx.borrow().clone(); + match state { + ConnectionState::Connected => return Ok(()), + ConnectionState::Error(e) => return Err(Error::Connection(e)), + _ => { + self.state_rx.changed().await?; + } + } + } + } +} +``` + +## Borrow vs Clone + +```rust +use tokio::sync::watch; + +let (tx, rx) = watch::channel(vec![1, 2, 3]); + +// borrow() returns Ref - must not hold across await +{ + let data = rx.borrow(); + println!("{:?}", *data); +} // Ref dropped here + +// For use across await, clone the data +let data = rx.borrow().clone(); +some_async_operation().await; +use_data(&data); // Safe + +// Or use borrow_and_update() to mark as seen +let data = rx.borrow_and_update().clone(); +``` + +## watch vs broadcast vs mpsc + +| Feature | watch | broadcast | mpsc | +|---------|-------|-----------|------| +| Receivers | Multiple | Multiple | Single | +| Message delivery | Latest only | All messages | All messages | +| Slow receiver | Skips to latest | Lags/misses | Backpressure | +| Clone required | No | Yes | No | +| Best for | Config, status | Events | Work queues | + +## See Also + +- [async-broadcast-pubsub](./async-broadcast-pubsub.md) - When history matters +- [async-mpsc-queue](./async-mpsc-queue.md) - Work queue patterns +- [async-cancellation-token](./async-cancellation-token.md) - Related pattern diff --git a/.agents/skills/rust-skills/rules/doc-all-public.md b/.agents/skills/rust-skills/rules/doc-all-public.md new file mode 100644 index 0000000..00f3df0 --- /dev/null +++ b/.agents/skills/rust-skills/rules/doc-all-public.md @@ -0,0 +1,113 @@ +# doc-all-public + +> Document all public items with `///` doc comments + +## Why It Matters + +Public items define your crate's API contract. Without documentation, users must read source code to understand how to use your library. Well-documented APIs reduce support burden, improve adoption, and serve as the primary reference for users. + +Rust's `cargo doc` generates beautiful HTML documentation from doc comments, but only if you write them. + +## Bad + +```rust +pub struct Config { + pub timeout: Duration, + pub retries: u32, + pub base_url: String, +} + +pub fn connect(config: Config) -> Result { + // ... +} + +pub enum Status { + Pending, + Active, + Failed, +} +``` + +## Good + +```rust +/// Configuration for establishing a connection to the service. +/// +/// # Examples +/// +/// ``` +/// use my_crate::Config; +/// use std::time::Duration; +/// +/// let config = Config { +/// timeout: Duration::from_secs(30), +/// retries: 3, +/// base_url: "https://api.example.com".to_string(), +/// }; +/// ``` +pub struct Config { + /// Maximum time to wait for a response before timing out. + pub timeout: Duration, + + /// Number of retry attempts for failed requests. + pub retries: u32, + + /// Base URL for all API requests. + pub base_url: String, +} + +/// Establishes a connection using the provided configuration. +/// +/// # Errors +/// +/// Returns an error if the connection cannot be established +/// or if the configuration is invalid. +pub fn connect(config: Config) -> Result { + // ... +} + +/// Represents the current status of a job. +pub enum Status { + /// Job is waiting to be processed. + Pending, + /// Job is currently being processed. + Active, + /// Job has failed and will not be retried. + Failed, +} +``` + +## What to Document + +| Item Type | Required Content | +|-----------|------------------| +| Structs | Purpose, usage example | +| Struct fields | What the field represents | +| Enums | When to use each variant | +| Enum variants | What state it represents | +| Functions | What it does, parameters, return value | +| Traits | Contract and expected behavior | +| Trait methods | Default implementation behavior | +| Type aliases | Why the alias exists | +| Constants | What the value represents | + +## Enforcement + +Enable the `missing_docs` lint to catch undocumented public items: + +```rust +#![warn(missing_docs)] +``` + +Or in `Cargo.toml` for workspace-wide enforcement: + +```toml +[workspace.lints.rust] +missing_docs = "warn" +``` + +## See Also + +- [doc-module-inner](./doc-module-inner.md) - Module-level documentation +- [doc-examples-section](./doc-examples-section.md) - Adding examples +- [lint-missing-docs](./lint-missing-docs.md) - Enforcing documentation diff --git a/.agents/skills/rust-skills/rules/doc-cargo-metadata.md b/.agents/skills/rust-skills/rules/doc-cargo-metadata.md new file mode 100644 index 0000000..482a8d9 --- /dev/null +++ b/.agents/skills/rust-skills/rules/doc-cargo-metadata.md @@ -0,0 +1,147 @@ +# doc-cargo-metadata + +> Fill `Cargo.toml` metadata for published crates + +## Why It Matters + +Cargo.toml metadata appears on crates.io, in search results, and helps users evaluate your crate. Missing metadata makes your crate look unprofessional, harder to find, and harder to trust. Complete metadata improves discoverability and adoption. + +## Bad + +```toml +[package] +name = "my-awesome-crate" +version = "0.1.0" +edition = "2021" + +[dependencies] +# ... +``` + +## Good + +```toml +[package] +name = "my-awesome-crate" +version = "0.1.0" +edition = "2021" +rust-version = "1.70" + +# Required for crates.io +description = "A fast, ergonomic HTTP client for Rust" +license = "MIT OR Apache-2.0" +repository = "https://github.com/username/my-awesome-crate" + +# Highly recommended +documentation = "https://docs.rs/my-awesome-crate" +readme = "README.md" +keywords = ["http", "client", "async", "networking"] +categories = ["network-programming", "web-programming::http-client"] +authors = ["Your Name "] +homepage = "https://my-awesome-crate.dev" + +# Optional but helpful +include = ["src/**/*", "Cargo.toml", "LICENSE*", "README.md"] +exclude = ["tests/fixtures/*", ".github/*"] + +[badges] +maintenance = { status = "actively-developed" } + +[dependencies] +# ... +``` + +## Required Fields for Publishing + +| Field | Purpose | +|-------|---------| +| `name` | Crate name on crates.io | +| `version` | Semver version | +| `license` or `license-file` | SPDX license identifier | +| `description` | One-line summary (≤256 chars) | + +## Recommended Fields + +| Field | Purpose | Example | +|-------|---------|---------| +| `repository` | Link to source code | `https://github.com/user/repo` | +| `documentation` | Link to docs | `https://docs.rs/crate` | +| `readme` | Path to README | `README.md` | +| `keywords` | Search terms (max 5) | `["http", "async"]` | +| `categories` | crates.io categories | `["network-programming"]` | +| `rust-version` | MSRV | `"1.70"` | + +## Keywords Best Practices + +```toml +# Good: specific, searchable terms +keywords = ["json", "serialization", "serde", "parsing"] + +# Bad: too generic or redundant +keywords = ["rust", "library", "awesome", "fast", "best"] +``` + +## Categories + +Choose from [crates.io categories](https://crates.io/category_slugs): + +```toml +categories = [ + "network-programming", + "web-programming::http-client", + "asynchronous", +] +``` + +## License Patterns + +```toml +# Single license +license = "MIT" + +# Dual license (common in Rust ecosystem) +license = "MIT OR Apache-2.0" + +# Custom license file +license-file = "LICENSE" +``` + +## Include/Exclude + +Control what gets published: + +```toml +# Explicit include (whitelist) +include = [ + "src/**/*", + "Cargo.toml", + "LICENSE*", + "README.md", + "CHANGELOG.md", +] + +# Or exclude (blacklist) +exclude = [ + "tests/fixtures/large-file.bin", + ".github/*", + "benches/*", +] +``` + +## Verification + +Check your package before publishing: + +```bash +# See what will be included +cargo package --list + +# Check metadata +cargo publish --dry-run +``` + +## See Also + +- [doc-module-inner](./doc-module-inner.md) - Crate-level documentation +- [lint-cargo-metadata](./lint-cargo-metadata.md) - Linting Cargo.toml +- [proj-workspace-deps](./proj-workspace-deps.md) - Workspace management diff --git a/.agents/skills/rust-skills/rules/doc-errors-section.md b/.agents/skills/rust-skills/rules/doc-errors-section.md new file mode 100644 index 0000000..2c861ec --- /dev/null +++ b/.agents/skills/rust-skills/rules/doc-errors-section.md @@ -0,0 +1,122 @@ +# doc-errors-section + +> Include `# Errors` section for fallible functions + +## Why It Matters + +Functions returning `Result` can fail in specific, documented ways. The `# Errors` section tells users exactly when and why a function might return an error, enabling them to handle failures appropriately without reading source code. + +This is especially critical for library code where users cannot easily inspect implementation details. + +## Bad + +```rust +/// Opens a file and reads its contents. +pub fn read_file(path: &Path) -> Result { + // Users have no idea what errors to expect +} + +/// Connects to the database. +pub async fn connect(url: &str) -> Result { + // Multiple failure modes, none documented +} +``` + +## Good + +```rust +/// Opens a file and reads its contents as a UTF-8 string. +/// +/// # Errors +/// +/// Returns an error if: +/// - The file does not exist ([`Error::NotFound`]) +/// - The process lacks permission to read the file ([`Error::PermissionDenied`]) +/// - The file contains invalid UTF-8 ([`Error::InvalidUtf8`]) +pub fn read_file(path: &Path) -> Result { + // ... +} + +/// Establishes a connection to the database. +/// +/// # Errors +/// +/// This function will return an error if: +/// - The URL is malformed ([`DbError::InvalidUrl`]) +/// - The database server is unreachable ([`DbError::ConnectionFailed`]) +/// - Authentication fails ([`DbError::AuthenticationFailed`]) +/// - The connection pool is exhausted ([`DbError::PoolExhausted`]) +pub async fn connect(url: &str) -> Result { + // ... +} +``` + +## Error Documentation Patterns + +### Simple Single Error + +```rust +/// Parses a string as an integer. +/// +/// # Errors +/// +/// Returns [`ParseIntError`] if the string is not a valid integer. +pub fn parse_int(s: &str) -> Result { + s.parse() +} +``` + +### Multiple Error Variants + +```rust +/// Sends an HTTP request and returns the response. +/// +/// # Errors +/// +/// | Error | Condition | +/// |-------|-----------| +/// | [`HttpError::Timeout`] | Request exceeded timeout duration | +/// | [`HttpError::InvalidUrl`] | URL could not be parsed | +/// | [`HttpError::ConnectionRefused`] | Server refused connection | +/// | [`HttpError::TlsError`] | TLS handshake failed | +pub fn send(request: Request) -> Result { + // ... +} +``` + +### Propagated Errors + +```rust +/// Loads configuration from a file. +/// +/// # Errors +/// +/// Returns an error if: +/// - The configuration file cannot be read (IO error) +/// - The file contains invalid TOML syntax +/// - Required fields are missing from the configuration +/// +/// The underlying error is wrapped with context about which +/// configuration file failed to load. +pub fn load_config(path: &Path) -> Result { + // ... +} +``` + +## Linking to Error Types + +Use intra-doc links to connect error variants to their definitions: + +```rust +/// # Errors +/// +/// Returns [`ValidationError::TooShort`] if the input is less than +/// the minimum length, or [`ValidationError::InvalidChars`] if it +/// contains forbidden characters. +``` + +## See Also + +- [doc-panics-section](./doc-panics-section.md) - Documenting panics +- [err-doc-errors](./err-doc-errors.md) - Error documentation patterns +- [doc-intra-links](./doc-intra-links.md) - Linking to types diff --git a/.agents/skills/rust-skills/rules/doc-examples-section.md b/.agents/skills/rust-skills/rules/doc-examples-section.md new file mode 100644 index 0000000..c8036ec --- /dev/null +++ b/.agents/skills/rust-skills/rules/doc-examples-section.md @@ -0,0 +1,161 @@ +# doc-examples-section + +> Include `# Examples` with runnable code + +## Why It Matters + +Examples are the most valuable part of documentation. They show users exactly how to use your API. Rust's doc tests ensure examples stay correct as code evolves. + +## Bad + +```rust +/// Parses a string into a Foo. +pub fn parse(s: &str) -> Result { + // No examples - users have to guess usage +} + +/// A widget for doing things. +/// +/// This widget is very useful. +pub struct Widget { + // Still no examples +} +``` + +## Good + +```rust +/// Parses a string into a Foo. +/// +/// # Examples +/// +/// ``` +/// use my_crate::parse; +/// +/// let foo = parse("hello").unwrap(); +/// assert_eq!(foo.name(), "hello"); +/// ``` +/// +/// Handles empty strings: +/// +/// ``` +/// use my_crate::parse; +/// +/// let foo = parse("").unwrap(); +/// assert!(foo.is_empty()); +/// ``` +pub fn parse(s: &str) -> Result { + // ... +} +``` + +## Use ? Not unwrap() + +```rust +/// Loads configuration from a file. +/// +/// # Examples +/// +/// ``` +/// # fn main() -> Result<(), Box> { +/// use my_crate::Config; +/// +/// let config = Config::load("config.toml")?; +/// println!("Port: {}", config.port); +/// # Ok(()) +/// # } +/// ``` +pub fn load(path: &str) -> Result { + // ... +} +``` + +## Hide Setup Code + +```rust +/// Processes items from a database. +/// +/// # Examples +/// +/// ``` +/// # use my_crate::{Database, Item}; +/// # fn get_db() -> Database { Database::mock() } +/// let db = get_db(); +/// let items = db.process_items()?; +/// assert!(!items.is_empty()); +/// # Ok::<(), my_crate::Error>(()) +/// ``` +pub fn process_items(&self) -> Result, Error> { + // ... +} +``` + +## Multiple Examples + +```rust +/// Creates a new buffer with the specified capacity. +/// +/// # Examples +/// +/// Basic usage: +/// +/// ``` +/// use my_crate::Buffer; +/// +/// let buf = Buffer::with_capacity(1024); +/// assert_eq!(buf.capacity(), 1024); +/// ``` +/// +/// Zero capacity creates an empty buffer: +/// +/// ``` +/// use my_crate::Buffer; +/// +/// let buf = Buffer::with_capacity(0); +/// assert!(buf.is_empty()); +/// ``` +pub fn with_capacity(cap: usize) -> Self { + // ... +} +``` + +## Show Error Cases + +```rust +/// Divides two numbers. +/// +/// # Examples +/// +/// ``` +/// use my_crate::divide; +/// +/// assert_eq!(divide(10, 2), Ok(5)); +/// ``` +/// +/// Division by zero returns an error: +/// +/// ``` +/// use my_crate::{divide, MathError}; +/// +/// assert_eq!(divide(10, 0), Err(MathError::DivisionByZero)); +/// ``` +pub fn divide(a: i32, b: i32) -> Result { + // ... +} +``` + +## Running Doc Tests + +```bash +# Run all doc tests +cargo test --doc + +# Run doc tests for specific item +cargo test --doc my_function +``` + +## See Also + +- [doc-question-mark](doc-question-mark.md) - Use ? in examples +- [doc-hidden-setup](doc-hidden-setup.md) - Hide setup code with # +- [doc-errors-section](doc-errors-section.md) - Document error conditions diff --git a/.agents/skills/rust-skills/rules/doc-hidden-setup.md b/.agents/skills/rust-skills/rules/doc-hidden-setup.md new file mode 100644 index 0000000..5ef3b30 --- /dev/null +++ b/.agents/skills/rust-skills/rules/doc-hidden-setup.md @@ -0,0 +1,149 @@ +# doc-hidden-setup + +> Use `# ` prefix to hide example setup code + +## Why It Matters + +Doc examples often require setup code (imports, struct initialization, mock data) that distracts from the main point. The `# ` prefix hides lines from rendered documentation while keeping them in the compiled test, showing users only the relevant code. + +This keeps examples focused and readable while ensuring they still compile and run. + +## Bad + +```rust +/// Processes a batch of items. +/// +/// # Examples +/// +/// ``` +/// use my_crate::{Processor, Config, Item}; +/// use std::sync::Arc; +/// +/// let config = Config { +/// batch_size: 100, +/// timeout_ms: 5000, +/// retry_count: 3, +/// }; +/// let processor = Processor::new(Arc::new(config)); +/// let items = vec![ +/// Item::new("a"), +/// Item::new("b"), +/// Item::new("c"), +/// ]; +/// +/// // This is the actual example - buried after 15 lines of setup +/// let results = processor.process_batch(&items)?; +/// assert!(results.all_succeeded()); +/// # Ok::<(), my_crate::Error>(()) +/// ``` +pub fn process_batch(&self, items: &[Item]) -> Result { + // ... +} +``` + +## Good + +```rust +/// Processes a batch of items. +/// +/// # Examples +/// +/// ``` +/// # use my_crate::{Processor, Config, Item, Error}; +/// # use std::sync::Arc; +/// # let config = Config { batch_size: 100, timeout_ms: 5000, retry_count: 3 }; +/// # let processor = Processor::new(Arc::new(config)); +/// # let items = vec![Item::new("a"), Item::new("b"), Item::new("c")]; +/// let results = processor.process_batch(&items)?; +/// assert!(results.all_succeeded()); +/// # Ok::<(), Error>(()) +/// ``` +pub fn process_batch(&self, items: &[Item]) -> Result { + // ... +} +``` + +Users see only: + +```rust +let results = processor.process_batch(&items)?; +assert!(results.all_succeeded()); +``` + +## What to Hide + +| Hide | Show | +|------|------| +| `use` statements | Core API usage | +| Type definitions | Method calls | +| Mock/test data setup | Key parameters | +| Error handling boilerplate | Return value handling | +| `Ok(())` return | Assertions (sometimes) | + +## Pattern: Hiding Multi-Line Setup + +```rust +/// # Examples +/// +/// ``` +/// # use my_crate::{Client, Request}; +/// # fn main() -> Result<(), Box> { +/// # let client = Client::builder() +/// # .timeout(30) +/// # .retry(3) +/// # .build()?; +/// let response = client.send(Request::get("/users"))?; +/// println!("Status: {}", response.status()); +/// # Ok(()) +/// # } +/// ``` +``` + +## Pattern: Showing Setup When Relevant + +Sometimes setup IS the point—don't hide it: + +```rust +/// Creates a new client with custom configuration. +/// +/// # Examples +/// +/// ``` +/// use my_crate::Client; +/// +/// // Configuration IS the example - show it +/// let client = Client::builder() +/// .base_url("https://api.example.com") +/// .timeout_secs(30) +/// .max_retries(3) +/// .build()?; +/// # Ok::<(), my_crate::Error>(()) +/// ``` +``` + +## Pattern: `ignore` and `no_run` + +For examples that shouldn't run in tests: + +```rust +/// # Examples +/// +/// ```no_run +/// # use my_crate::Server; +/// // This would actually start a server - don't run in tests +/// let server = Server::bind("0.0.0.0:8080").await?; +/// server.run().await?; +/// # Ok::<(), my_crate::Error>(()) +/// ``` + +/// ```ignore +/// // Pseudocode or incomplete example +/// let magic = do_something_undefined(); +/// ``` +``` + +## See Also + +- [doc-examples-section](./doc-examples-section.md) - Writing examples +- [doc-question-mark](./doc-question-mark.md) - Using `?` in examples +- [test-doctest-examples](./test-doctest-examples.md) - Doctests as tests diff --git a/.agents/skills/rust-skills/rules/doc-intra-links.md b/.agents/skills/rust-skills/rules/doc-intra-links.md new file mode 100644 index 0000000..47c693f --- /dev/null +++ b/.agents/skills/rust-skills/rules/doc-intra-links.md @@ -0,0 +1,138 @@ +# doc-intra-links + +> Use intra-doc links to reference types and items + +## Why It Matters + +Intra-doc links (`[TypeName]`, `[method](Self::method)`) create clickable references in generated documentation. They're verified at doc-build time, catching broken links early. Unlike URL links, they automatically update when items are renamed or moved. + +## Bad + +```rust +/// Returns the length of the buffer. +/// +/// See also `capacity()` for the allocated size, and the +/// `Buffer` struct for more details. +pub fn len(&self) -> usize { + self.data.len() +} + +/// Parses the input using std::str::FromStr trait. +/// Check the Error enum for possible failures. +pub fn parse(input: &str) -> Result { + // ... +} +``` + +## Good + +```rust +/// Returns the length of the buffer. +/// +/// See also [`capacity()`](Self::capacity) for the allocated size, and +/// [`Buffer`] for more details. +pub fn len(&self) -> usize { + self.data.len() +} + +/// Parses the input using [`FromStr`] trait. +/// Check [`Error`] for possible failures. +/// +/// [`FromStr`]: std::str::FromStr +pub fn parse(input: &str) -> Result { + // ... +} +``` + +## Link Syntax + +| Syntax | Links To | Example | +|--------|----------|---------| +| `[Name]` | Item in scope | `[Vec]`, `[Option]` | +| `[path::Name]` | Fully qualified item | `[std::vec::Vec]` | +| `[Self::method]` | Method on current type | `[Self::new]` | +| `[Type::method]` | Method on other type | `[String::new]` | +| `[Type::CONST]` | Associated constant | `[usize::MAX]` | +| `[text](path)` | Custom text | `[see here](Self::len)` | + +## Common Patterns + +### Linking to Self Members + +```rust +impl Buffer { + /// Creates an empty buffer. + /// + /// Use [`with_capacity`](Self::with_capacity) if you know the size. + pub fn new() -> Self { /* ... */ } + + /// Creates a buffer with pre-allocated capacity. + /// + /// See [`new`](Self::new) for the default constructor. + pub fn with_capacity(cap: usize) -> Self { /* ... */ } +} +``` + +### Linking to Trait Methods + +```rust +/// Converts to a string representation. +/// +/// This is the implementation of [`Display::fmt`](std::fmt::Display::fmt). +impl Display for MyType { + fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { + // ... + } +} +``` + +### Disambiguation + +When names conflict, use suffixes: + +```rust +/// See [`foo()`](fn@foo) for the function and [`foo`](mod@foo) for the module. + +/// Works with [`Error`](struct@Error) struct or [`Error`](trait@Error) trait. +``` + +| Suffix | Item Type | +|--------|-----------| +| `fn@` | Function | +| `mod@` | Module | +| `struct@` | Struct | +| `enum@` | Enum | +| `trait@` | Trait | +| `type@` | Type alias | +| `const@` | Constant | +| `macro@` | Macro | + +### Reference-Style Links + +For repeated links or long paths: + +```rust +/// Parses using [`serde`] with [`Deserialize`] trait. +/// Returns a [`Result`] that may contain [`Error`]. +/// +/// [`serde`]: https://serde.rs +/// [`Deserialize`]: serde::Deserialize +/// [`Result`]: std::result::Result +/// [`Error`]: crate::Error +``` + +## Verification + +Enable link checking in CI: + +```bash +RUSTDOCFLAGS="-D warnings" cargo doc --no-deps +``` + +This fails if any intra-doc links are broken. + +## See Also + +- [doc-all-public](./doc-all-public.md) - Documenting public items +- [doc-examples-section](./doc-examples-section.md) - Adding examples +- [doc-errors-section](./doc-errors-section.md) - Documenting errors diff --git a/.agents/skills/rust-skills/rules/doc-link-types.md b/.agents/skills/rust-skills/rules/doc-link-types.md new file mode 100644 index 0000000..f2b5b0f --- /dev/null +++ b/.agents/skills/rust-skills/rules/doc-link-types.md @@ -0,0 +1,169 @@ +# doc-link-types + +> Use intra-doc links to connect related types and functions + +## Why It Matters + +Intra-doc links (`[`TypeName`]`) create clickable references in generated documentation. They enable navigation between related items, verify that referenced items exist at compile time, and update automatically when items are renamed. Plain text references become stale and unclickable. + +## Bad + +```rust +/// Parses input and returns a ParseResult. +/// +/// See also: ParseError for error types. +/// Uses the Tokenizer internally. +pub fn parse(input: &str) -> ParseResult { + // "ParseResult", "ParseError", "Tokenizer" are not clickable + // No verification they exist +} +``` + +## Good + +```rust +/// Parses input and returns a [`ParseResult`]. +/// +/// # Errors +/// +/// Returns [`ParseError::InvalidSyntax`] if the input contains invalid tokens. +/// Returns [`ParseError::UnexpectedEof`] if the input ends prematurely. +/// +/// # Related +/// +/// - [`Tokenizer`] - The underlying tokenizer used by this parser +/// - [`parse_file`] - Convenience function for parsing files +/// - [`ParseOptions`] - Configuration options for parsing +pub fn parse(input: &str) -> ParseResult { + // All links are clickable and verified +} +``` + +## Link Syntax + +```rust +/// Basic link to type in same module +/// See [`MyType`] for details. + +/// Link to method +/// Use [`MyType::new`] to create instances. + +/// Link to associated type +/// Returns [`Iterator::Item`]. + +/// Link to module +/// See the [`parser`] module. + +/// Link to external crate type +/// Works with [`std::collections::HashMap`]. + +/// Link with custom text +/// See [the parser][`parse`] for details. + +/// Link to module item +/// See [`crate::utils::helper`]. + +/// Link to parent module item +/// See [`super::Parent`]. +``` + +## Common Patterns + +```rust +/// A configuration builder. +/// +/// # Example +/// +/// ``` +/// use my_crate::Config; +/// +/// let config = Config::builder() +/// .timeout(30) +/// .build()?; +/// ``` +/// +/// # Methods +/// +/// - [`Config::builder`] - Create a new builder +/// - [`Config::default`] - Create with defaults +/// +/// # Related Types +/// +/// - [`ConfigBuilder`] - The builder returned by [`Config::builder`] +/// - [`ConfigError`] - Errors that can occur when building +pub struct Config { ... } + +impl Config { + /// Creates a new [`ConfigBuilder`]. + /// + /// This is equivalent to [`ConfigBuilder::new`]. + pub fn builder() -> ConfigBuilder { ... } +} +``` + +## Linking to Trait Items + +```rust +/// Implements [`Iterator`] for lazy evaluation. +/// +/// The [`Iterator::next`] method advances the cursor. +/// +/// For parallel iteration, see [`rayon::ParallelIterator`]. +pub struct MyIterator { ... } + +impl Iterator for MyIterator { + /// Advances and returns the next value. + /// + /// See also [`Iterator::nth`] for skipping elements. + fn next(&mut self) -> Option { ... } +} +``` + +## Broken Link Detection + +```bash +# Catch broken intra-doc links +RUSTDOCFLAGS="-D warnings" cargo doc + +# Or in CI +cargo doc --no-deps 2>&1 | grep "warning: unresolved link" +``` + +```toml +# Cargo.toml - deny broken links +[lints.rustdoc] +broken_intra_doc_links = "deny" +``` + +## Module-Level Documentation + +```rust +//! # Parser Module +//! +//! This module provides parsing utilities. +//! +//! ## Main Types +//! +//! - [`Parser`] - The main parser struct +//! - [`Token`] - Tokens produced by tokenization +//! - [`Ast`] - The abstract syntax tree +//! +//! ## Functions +//! +//! - [`parse`] - Parse a string +//! - [`parse_file`] - Parse a file +//! +//! ## Errors +//! +//! All functions return [`ParseError`] on failure. + +pub struct Parser { ... } +pub enum Token { ... } +pub struct Ast { ... } +``` + +## See Also + +- [doc-examples-section](./doc-examples-section.md) - Code examples in docs +- [err-doc-errors](./err-doc-errors.md) - Documenting errors +- [lint-deny-correctness](./lint-deny-correctness.md) - Lint settings diff --git a/.agents/skills/rust-skills/rules/doc-module-inner.md b/.agents/skills/rust-skills/rules/doc-module-inner.md new file mode 100644 index 0000000..9a4e028 --- /dev/null +++ b/.agents/skills/rust-skills/rules/doc-module-inner.md @@ -0,0 +1,116 @@ +# doc-module-inner + +> Use `//!` for module-level documentation + +## Why It Matters + +Inner doc comments (`//!`) document the module itself, not the next item. They appear at the top of module files and describe the module's purpose, contents, and usage patterns. This helps users understand what a module provides before diving into individual items. + +Module docs are the first thing users see in `cargo doc` when navigating to a module. + +## Bad + +```rust +// This module handles authentication +// It provides JWT and session-based auth + +mod auth; + +pub use auth::*; +``` + +```rust +// auth.rs +/// Authentication utilities // Wrong: this documents nothing useful +use std::collections::HashMap; + +pub struct Session { /* ... */ } +``` + +## Good + +```rust +//! Authentication and authorization utilities. +//! +//! This module provides multiple authentication strategies: +//! +//! - [`JwtAuth`] - JSON Web Token based authentication +//! - [`SessionAuth`] - Cookie-based session authentication +//! - [`ApiKeyAuth`] - API key authentication for services +//! +//! # Examples +//! +//! ``` +//! use my_crate::auth::{JwtAuth, Authenticator}; +//! +//! let auth = JwtAuth::new("secret-key"); +//! let token = auth.generate_token(&user)?; +//! ``` +//! +//! # Feature Flags +//! +//! - `jwt` - Enables JWT authentication (enabled by default) +//! - `sessions` - Enables session-based authentication + +use std::collections::HashMap; + +pub struct Session { /* ... */ } +``` + +## Where to Use Inner Docs + +| Location | Purpose | +|----------|---------| +| `lib.rs` | Crate-level documentation (appears on crate root) | +| `mod.rs` | Module documentation for directory modules | +| `module.rs` | Module documentation for single-file modules | + +## Crate Root Example + +```rust +//! # My Awesome Crate +//! +//! `my_crate` provides utilities for handling complex workflows. +//! +//! ## Quick Start +//! +//! ```rust +//! use my_crate::prelude::*; +//! +//! let workflow = Workflow::builder() +//! .add_step(Step::new("fetch")) +//! .add_step(Step::new("process")) +//! .build(); +//! ``` +//! +//! ## Modules +//! +//! - [`workflow`] - Core workflow engine +//! - [`steps`] - Built-in workflow steps +//! - [`prelude`] - Common imports +//! +//! ## Feature Flags +//! +//! | Feature | Description | +//! |---------|-------------| +//! | `async` | Async workflow execution | +//! | `serde` | Serialization support | + +pub mod workflow; +pub mod steps; +pub mod prelude; +``` + +## Key Sections for Module Docs + +1. **Brief description** - One-line summary +2. **Overview** - What the module provides +3. **Examples** - How to use it +4. **Feature flags** - Optional functionality +5. **See Also** - Related modules + +## See Also + +- [doc-all-public](./doc-all-public.md) - Documenting public items +- [doc-examples-section](./doc-examples-section.md) - Adding examples +- [doc-cargo-metadata](./doc-cargo-metadata.md) - Crate metadata diff --git a/.agents/skills/rust-skills/rules/doc-panics-section.md b/.agents/skills/rust-skills/rules/doc-panics-section.md new file mode 100644 index 0000000..30a8b88 --- /dev/null +++ b/.agents/skills/rust-skills/rules/doc-panics-section.md @@ -0,0 +1,128 @@ +# doc-panics-section + +> Include `# Panics` section for functions that can panic + +## Why It Matters + +Panics are exceptional conditions that crash the program (or unwind the stack). Users need to know when a function might panic so they can ensure preconditions are met or avoid the function in contexts where panics are unacceptable (e.g., `no_std`, embedded, FFI). + +If a function can panic, document exactly when. + +## Bad + +```rust +/// Returns the element at the given index. +pub fn get(index: usize) -> &T { + &self.data[index] // Panics if out of bounds - not documented! +} + +/// Divides two numbers. +pub fn divide(a: i32, b: i32) -> i32 { + a / b // Panics on division by zero - not documented! +} +``` + +## Good + +```rust +/// Returns the element at the given index. +/// +/// # Panics +/// +/// Panics if `index` is out of bounds (i.e., `index >= self.len()`). +/// +/// # Examples +/// +/// ``` +/// let v = vec![1, 2, 3]; +/// assert_eq!(v.get(1), &2); +/// ``` +pub fn get(&self, index: usize) -> &T { + &self.data[index] +} + +/// Divides two numbers. +/// +/// # Panics +/// +/// Panics if `divisor` is zero. +/// +/// For a non-panicking version, use [`checked_divide`]. +pub fn divide(dividend: i32, divisor: i32) -> i32 { + dividend / divisor +} + +/// Divides two numbers, returning `None` if the divisor is zero. +pub fn checked_divide(dividend: i32, divisor: i32) -> Option { + if divisor == 0 { + None + } else { + Some(dividend / divisor) + } +} +``` + +## Common Panic Conditions + +| Operation | Panic Condition | +|-----------|-----------------| +| Index access `[i]` | Index out of bounds | +| Division `/`, `%` | Division by zero | +| `.unwrap()` | `None` or `Err` value | +| `.expect()` | `None` or `Err` value | +| `slice::split_at(mid)` | `mid > len` | +| `Vec::remove(i)` | `i >= len` | +| Overflow (debug) | Integer overflow | + +## Pattern: Panic vs Return Error + +Document why you chose to panic vs return `Result`: + +```rust +/// Creates a new buffer with the given capacity. +/// +/// # Panics +/// +/// Panics if `capacity` is zero. A buffer must have at least +/// one byte of capacity. +/// +/// This panics rather than returning an error because a zero-capacity +/// buffer represents a programming error, not a runtime condition. +pub fn new(capacity: usize) -> Self { + assert!(capacity > 0, "capacity must be non-zero"); + // ... +} +``` + +## Pattern: Debug-Only Panics + +```rust +/// Adds an item to the collection. +/// +/// # Panics +/// +/// In debug builds, panics if the collection is at capacity. +/// In release builds, this is a no-op when at capacity. +pub fn push(&mut self, item: T) { + debug_assert!(self.len < self.capacity, "collection at capacity"); + // ... +} +``` + +## Provide Non-Panicking Alternatives + +When documenting a panicking function, point to safe alternatives: + +```rust +/// # Panics +/// +/// Panics if the index is out of bounds. +/// +/// For a non-panicking version, use [`get`] which returns `Option<&T>`. +``` + +## See Also + +- [doc-errors-section](./doc-errors-section.md) - Documenting errors +- [doc-safety-section](./doc-safety-section.md) - Documenting unsafe +- [err-result-over-panic](./err-result-over-panic.md) - Preferring Result diff --git a/.agents/skills/rust-skills/rules/doc-question-mark.md b/.agents/skills/rust-skills/rules/doc-question-mark.md new file mode 100644 index 0000000..6c85233 --- /dev/null +++ b/.agents/skills/rust-skills/rules/doc-question-mark.md @@ -0,0 +1,136 @@ +# doc-question-mark + +> Use `?` in examples, not `.unwrap()` + +## Why It Matters + +Doc examples should model best practices. Using `.unwrap()` teaches users to ignore errors, while `?` demonstrates proper error propagation. Examples with `?` also fail the doctest if an error occurs, catching bugs in documentation. + +Rust doctests wrap examples in a function that returns `Result<(), E>` by default when you use `?`, making this pattern easy to adopt. + +## Bad + +```rust +/// Reads a configuration file. +/// +/// # Examples +/// +/// ``` +/// let config = Config::from_file("config.toml").unwrap(); +/// println!("{:?}", config.database_url); +/// ``` +pub fn from_file(path: &str) -> Result { + // ... +} + +/// Fetches data from the API. +/// +/// # Examples +/// +/// ``` +/// let client = Client::new(); +/// let response = client.get("https://api.example.com").unwrap(); +/// let data: Data = response.json().unwrap(); +/// ``` +pub async fn get(&self, url: &str) -> Result { + // ... +} +``` + +## Good + +```rust +/// Reads a configuration file. +/// +/// # Examples +/// +/// ``` +/// # use my_crate::{Config, Error}; +/// # fn main() -> Result<(), Error> { +/// let config = Config::from_file("config.toml")?; +/// println!("{:?}", config.database_url); +/// # Ok(()) +/// # } +/// ``` +pub fn from_file(path: &str) -> Result { + // ... +} + +/// Fetches data from the API. +/// +/// # Examples +/// +/// ```no_run +/// # use my_crate::{Client, Data, Error}; +/// # async fn example() -> Result<(), Error> { +/// let client = Client::new(); +/// let response = client.get("https://api.example.com").await?; +/// let data: Data = response.json().await?; +/// # Ok(()) +/// # } +/// ``` +pub async fn get(&self, url: &str) -> Result { + // ... +} +``` + +## Doctest Wrapper Pattern + +Rust wraps doc examples in a function. You can make this explicit: + +```rust +/// # Examples +/// +/// ``` +/// # fn main() -> Result<(), Box> { +/// let value = parse_config("key=value")?; +/// assert_eq!(value.key, "value"); +/// # Ok(()) +/// # } +/// ``` +``` + +Or use the implicit wrapper (Rust 2021+): + +```rust +/// # Examples +/// +/// ``` +/// # use my_crate::parse_config; +/// let value = parse_config("key=value")?; +/// assert_eq!(value.key, "value"); +/// # Ok::<(), my_crate::Error>(()) +/// ``` +``` + +## When to Use `.unwrap()` + +There are specific cases where `.unwrap()` is acceptable in examples: + +```rust +/// # Examples +/// +/// ``` +/// // Static regex that is known at compile time to be valid +/// let re = Regex::new(r"^\d{4}-\d{2}-\d{2}$").unwrap(); +/// +/// // Parsing a literal that cannot fail +/// let n: i32 = "42".parse().unwrap(); +/// ``` +``` + +But still prefer `?` when demonstrating error handling patterns. + +## Comparison + +| Pattern | Behavior on Error | Teaches | +|---------|-------------------|---------| +| `.unwrap()` | Panics with generic message | Bad habits | +| `.expect()` | Panics with custom message | Slightly better | +| `?` | Propagates error, test fails | Best practices | + +## See Also + +- [doc-examples-section](./doc-examples-section.md) - Writing examples +- [doc-hidden-setup](./doc-hidden-setup.md) - Hiding setup code +- [err-question-mark](./err-question-mark.md) - Error propagation diff --git a/.agents/skills/rust-skills/rules/doc-safety-section.md b/.agents/skills/rust-skills/rules/doc-safety-section.md new file mode 100644 index 0000000..52206f1 --- /dev/null +++ b/.agents/skills/rust-skills/rules/doc-safety-section.md @@ -0,0 +1,131 @@ +# doc-safety-section + +> Include `# Safety` section for unsafe functions + +## Why It Matters + +Unsafe functions require callers to uphold invariants that the compiler cannot verify. The `# Safety` section documents exactly what the caller must guarantee for the function to be sound. Without this, users cannot safely call the function. + +This is not optional—it's a requirement for sound unsafe code. + +## Bad + +```rust +/// Reads a value from a raw pointer. +pub unsafe fn read_ptr(ptr: *const T) -> T { + // What guarantees must the caller provide? Unknown! + ptr.read() +} + +/// Creates a string from raw parts. +pub unsafe fn string_from_raw(ptr: *mut u8, len: usize, cap: usize) -> String { + String::from_raw_parts(ptr, len, cap) +} +``` + +## Good + +```rust +/// Reads a value from a raw pointer. +/// +/// # Safety +/// +/// The caller must ensure that: +/// - `ptr` is valid for reads of `size_of::()` bytes +/// - `ptr` is properly aligned for type `T` +/// - `ptr` points to a properly initialized value of type `T` +/// - The memory referenced by `ptr` is not mutated during this call +pub unsafe fn read_ptr(ptr: *const T) -> T { + ptr.read() +} + +/// Creates a `String` from raw parts. +/// +/// # Safety +/// +/// The caller must guarantee that: +/// - `ptr` was allocated by the same allocator that `String` uses +/// - `len` is less than or equal to `cap` +/// - The first `len` bytes at `ptr` are valid UTF-8 +/// - `cap` is the capacity that `ptr` was allocated with +/// - No other code will use `ptr` after this call (ownership is transferred) +/// +/// Violating these requirements leads to undefined behavior including +/// memory corruption, use-after-free, or invalid UTF-8 in strings. +pub unsafe fn string_from_raw(ptr: *mut u8, len: usize, cap: usize) -> String { + String::from_raw_parts(ptr, len, cap) +} +``` + +## Key Elements of Safety Documentation + +| Element | Description | +|---------|-------------| +| **Preconditions** | What must be true before calling | +| **Pointer validity** | Alignment, null-ness, lifetime | +| **Memory ownership** | Who owns what, transfer semantics | +| **Invariants** | Type invariants that must hold | +| **Consequences** | What happens if violated | + +## Pattern: Unsafe Trait Implementations + +```rust +/// A type that can be safely zeroed. +/// +/// # Safety +/// +/// Implementing this trait guarantees that: +/// - All bit patterns of zeros represent a valid value of this type +/// - The type has no padding bytes that could leak data +/// - The type contains no references or pointers +pub unsafe trait Zeroable { + fn zeroed() -> Self; +} + +// SAFETY: u32 is a primitive integer type where all zero bits +// represent a valid value (0). +unsafe impl Zeroable for u32 { + fn zeroed() -> Self { + 0 + } +} +``` + +## Pattern: Unsafe Blocks in Safe Functions + +When a safe function contains unsafe blocks, document the invariants: + +```rust +/// Returns a reference to the element at the given index. +/// +/// Returns `None` if the index is out of bounds. +pub fn get(&self, index: usize) -> Option<&T> { + if index < self.len { + // SAFETY: We just verified that index < len, so this + // access is within bounds. + Some(unsafe { self.data.get_unchecked(index) }) + } else { + None + } +} +``` + +## Common Safety Requirements + +```rust +/// # Safety +/// +/// - Pointer must be non-null +/// - Pointer must be aligned to `align_of::()` +/// - Pointer must be valid for reads/writes of `size_of::()` bytes +/// - Pointer must point to an initialized value of `T` +/// - The referenced memory must not be accessed through any other pointer +/// for the duration of the returned reference +/// - The total size must not exceed `isize::MAX` +``` + +## See Also + +- [doc-panics-section](./doc-panics-section.md) - Documenting panics +- [lint-unsafe-doc](./lint-unsafe-doc.md) - Enforcing unsafe documentation +- [doc-errors-section](./doc-errors-section.md) - Documenting errors diff --git a/.agents/skills/rust-skills/rules/err-anyhow-app.md b/.agents/skills/rust-skills/rules/err-anyhow-app.md new file mode 100644 index 0000000..b9eca49 --- /dev/null +++ b/.agents/skills/rust-skills/rules/err-anyhow-app.md @@ -0,0 +1,179 @@ +# err-anyhow-app + +> Use `anyhow` for application error handling + +## Why It Matters + +Applications often don't need typed errors - they just need to report what went wrong with good context. `anyhow` provides easy error handling with context chaining, backtraces, and conversion from any error type. + +## Bad + +```rust +// Tedious type management +fn load_config() -> Result> { + let path = find_config()?; // Returns FindError + let content = std::fs::read_to_string(&path)?; // Returns io::Error + let config: Config = toml::from_str(&content)?; // Returns toml::Error + validate(&config)?; // Returns ValidationError + Ok(config) +} + +// No context - hard to debug +fn process() -> Result<(), Box> { + let data = fetch()?; // Which fetch failed? + transform(data)?; // What was being transformed? + save()?; // Where was it saving to? + Ok(()) +} +``` + +## Good + +```rust +use anyhow::{Context, Result}; + +fn load_config() -> Result { + let path = find_config() + .context("failed to locate config file")?; + + let content = std::fs::read_to_string(&path) + .with_context(|| format!("failed to read config from {}", path.display()))?; + + let config: Config = toml::from_str(&content) + .context("failed to parse config as TOML")?; + + validate(&config) + .context("config validation failed")?; + + Ok(config) +} + +// Error message: "config validation failed: field 'port' must be > 0" +// Full chain preserved for debugging +``` + +## Key Features + +```rust +use anyhow::{anyhow, bail, ensure, Context, Result}; + +fn example() -> Result<()> { + // Create ad-hoc errors + let err = anyhow!("something went wrong"); + + // Early return with error + bail!("aborting due to {}", reason); + + // Assert with error + ensure!(condition, "condition was false"); + + // Add context to any error + risky_operation() + .context("risky operation failed")?; + + // Dynamic context + fetch(url) + .with_context(|| format!("failed to fetch {}", url))?; + + Ok(()) +} +``` + +## Main Function Pattern + +```rust +use anyhow::Result; + +fn main() -> Result<()> { + let config = load_config()?; + run_app(config)?; + Ok(()) +} + +// Or with custom exit handling +fn main() { + if let Err(e) = run() { + eprintln!("Error: {:#}", e); // Pretty-print with causes + std::process::exit(1); + } +} + +fn run() -> Result<()> { + // Application logic + Ok(()) +} +``` + +## Error Display Formats + +```rust +use anyhow::Result; + +fn show_error(err: anyhow::Error) { + // Just the top-level message + println!("{}", err); + // "config validation failed" + + // With cause chain (# alternate format) + println!("{:#}", err); + // "config validation failed: field 'port' must be > 0" + + // Debug format with backtrace + println!("{:?}", err); + // Full backtrace if RUST_BACKTRACE=1 + + // Iterate through cause chain + for cause in err.chain() { + println!("Caused by: {}", cause); + } +} +``` + +## Combining with thiserror + +```rust +// In your library crate - typed errors +use thiserror::Error; + +#[derive(Error, Debug)] +pub enum ApiError { + #[error("rate limited")] + RateLimited, + #[error("not found: {0}")] + NotFound(String), +} + +// In your application - anyhow for handling +use anyhow::{Context, Result}; + +fn fetch_user(id: u64) -> Result { + api::get_user(id) + .with_context(|| format!("failed to fetch user {}", id)) +} + +// Can still downcast if needed +fn handle_error(err: anyhow::Error) { + if let Some(api_err) = err.downcast_ref::() { + match api_err { + ApiError::RateLimited => wait_and_retry(), + ApiError::NotFound(id) => log_missing(id), + } + } +} +``` + +## When to Use Which + +| Situation | Use | +|-----------|-----| +| Library public API | `thiserror` | +| Application code | `anyhow` | +| CLI tools | `anyhow` | +| Internal library code | Either | +| Need to match error variants | `thiserror` | +| Just need to report errors | `anyhow` | + +## See Also + +- [err-thiserror-lib](err-thiserror-lib.md) - Use thiserror for libraries +- [err-context-chain](err-context-chain.md) - Add context to errors diff --git a/.agents/skills/rust-skills/rules/err-context-chain.md b/.agents/skills/rust-skills/rules/err-context-chain.md new file mode 100644 index 0000000..7ab41cc --- /dev/null +++ b/.agents/skills/rust-skills/rules/err-context-chain.md @@ -0,0 +1,144 @@ +# err-context-chain + +> Add context with `.context()` or `.with_context()` + +## Why It Matters + +Raw errors often lack information about what operation failed. Adding context creates an error chain that tells the full story: what you were trying to do, and why it failed. + +## Bad + +```rust +// Raw error - no context +fn load_user(id: u64) -> Result { + let path = format!("users/{}.json", id); + let content = std::fs::read_to_string(&path)?; + Ok(serde_json::from_str(&content)?) +} + +// Error message: "No such file or directory (os error 2)" +// Which file? What were we doing? +``` + +## Good + +```rust +use anyhow::{Context, Result}; + +fn load_user(id: u64) -> Result { + let path = format!("users/{}.json", id); + + let content = std::fs::read_to_string(&path) + .with_context(|| format!("failed to read user file: {}", path))?; + + let user: User = serde_json::from_str(&content) + .with_context(|| format!("failed to parse user {} JSON", id))?; + + Ok(user) +} + +// Error: "failed to parse user 42 JSON" +// Caused by: "expected ':' at line 5 column 12" +``` + +## context() vs with_context() + +```rust +// context() - static string (slight allocation) +fs::read_to_string(path) + .context("failed to read config")?; + +// with_context() - lazy evaluation (only allocates on error) +fs::read_to_string(path) + .with_context(|| format!("failed to read {}", path))?; + +// Use with_context() when: +// - Message includes runtime data (format!) +// - Computing the message is expensive +// - Error path is cold (most of the time) +``` + +## Building Context Chains + +```rust +fn process_order(order_id: u64) -> Result<()> { + let order = fetch_order(order_id) + .with_context(|| format!("failed to fetch order {}", order_id))?; + + let user = load_user(order.user_id) + .with_context(|| format!("failed to load user for order {}", order_id))?; + + let payment = process_payment(&order, &user) + .context("payment processing failed")?; + + ship_order(&order, &payment) + .context("shipping failed")?; + + Ok(()) +} + +// Full error chain: +// "shipping failed" +// Caused by: "carrier API returned 503" +// Caused by: "connection refused" +``` + +## Displaying Error Chains + +```rust +fn main() { + if let Err(e) = run() { + // Just top-level message + eprintln!("Error: {}", e); + + // Full chain with alternate format + eprintln!("Error: {:#}", e); + + // Debug format (includes backtrace if enabled) + eprintln!("Error: {:?}", e); + + // Iterate through chain + for (i, cause) in e.chain().enumerate() { + eprintln!(" {}: {}", i, cause); + } + } +} +``` + +## With thiserror + +```rust +use thiserror::Error; + +#[derive(Error, Debug)] +pub enum AppError { + #[error("failed to load config from {path}")] + ConfigLoad { + path: String, + #[source] + cause: std::io::Error, + }, + + #[error("failed to connect to database")] + Database { + #[source] + cause: sqlx::Error, + }, +} + +// Usage +fn load_config(path: &str) -> Result { + let content = std::fs::read_to_string(path) + .map_err(|e| AppError::ConfigLoad { + path: path.to_string(), + cause: e, + })?; + // ... +} +``` + +## See Also + +- [err-anyhow-app](err-anyhow-app.md) - Use anyhow for applications +- [err-source-chain](err-source-chain.md) - Use #[source] to chain errors +- [err-question-mark](err-question-mark.md) - Use ? for propagation diff --git a/.agents/skills/rust-skills/rules/err-custom-type.md b/.agents/skills/rust-skills/rules/err-custom-type.md new file mode 100644 index 0000000..6dbaf84 --- /dev/null +++ b/.agents/skills/rust-skills/rules/err-custom-type.md @@ -0,0 +1,152 @@ +# err-custom-type + +> Define custom error types for domain-specific failures + +## Why It Matters + +Generic errors like `String`, `Box`, or catch-all enums obscure what can actually go wrong. Custom error types document failure modes in the type system, enable pattern matching for specific handling, and provide clear API contracts. They make your code self-documenting and help callers handle errors appropriately. + +## Bad + +```rust +// Generic string errors - no structure +fn validate_user(user: &User) -> Result<(), String> { + if user.name.is_empty() { + return Err("Name is empty".to_string()); + } + if user.age > 150 { + return Err("Age is invalid".to_string()); + } + Ok(()) +} + +// Caller can't match on specific errors +match validate_user(&user) { + Ok(()) => save(user), + Err(msg) => { + // Can only do string comparison - fragile! + if msg.contains("Name") { + prompt_for_name() + } + } +} +``` + +## Good + +```rust +use thiserror::Error; + +#[derive(Error, Debug)] +pub enum ValidationError { + #[error("name cannot be empty")] + EmptyName, + + #[error("name exceeds maximum length of {max} characters")] + NameTooLong { max: usize, actual: usize }, + + #[error("invalid age {0}: must be between 0 and 150")] + InvalidAge(u8), + + #[error("email format is invalid: {0}")] + InvalidEmail(String), +} + +fn validate_user(user: &User) -> Result<(), ValidationError> { + if user.name.is_empty() { + return Err(ValidationError::EmptyName); + } + if user.name.len() > 100 { + return Err(ValidationError::NameTooLong { + max: 100, + actual: user.name.len() + }); + } + if user.age > 150 { + return Err(ValidationError::InvalidAge(user.age)); + } + Ok(()) +} + +// Caller can match specifically +match validate_user(&user) { + Ok(()) => save(user), + Err(ValidationError::EmptyName) => prompt_for_name(), + Err(ValidationError::InvalidAge(age)) => { + show_error(&format!("Please enter a valid age (you entered {})", age)) + } + Err(e) => show_error(&e.to_string()), +} +``` + +## Error Type Design Guidelines + +```rust +// 1. Group related errors in domain-specific enums +#[derive(Error, Debug)] +pub enum AuthError { + #[error("invalid credentials")] + InvalidCredentials, + #[error("account locked after {attempts} failed attempts")] + AccountLocked { attempts: u32 }, + #[error("token expired")] + TokenExpired, +} + +#[derive(Error, Debug)] +pub enum PaymentError { + #[error("insufficient funds: need {required}, have {available}")] + InsufficientFunds { required: Decimal, available: Decimal }, + #[error("card declined: {reason}")] + CardDeclined { reason: String }, +} + +// 2. Include relevant data for error handling/display +#[derive(Error, Debug)] +pub enum FileError { + #[error("file not found: {path}")] + NotFound { path: PathBuf }, + #[error("permission denied for {path}")] + PermissionDenied { path: PathBuf }, +} + +// 3. Consider #[non_exhaustive] for public APIs +#[derive(Error, Debug)] +#[non_exhaustive] // Allows adding variants without breaking changes +pub enum ApiError { + #[error("rate limited")] + RateLimited, + #[error("not found")] + NotFound, +} +``` + +## When to Use What + +| Error Pattern | Use Case | +|---------------|----------| +| Custom enum | Library with specific failure modes | +| `thiserror` | Libraries needing `std::error::Error` | +| `anyhow::Error` | Applications, prototypes | +| Struct with source | Single error type with wrapped cause | + +## Struct-Based Errors + +For single error types with rich context: + +```rust +#[derive(Error, Debug)] +#[error("query failed for table '{table}' with filter '{filter}'")] +pub struct QueryError { + pub table: String, + pub filter: String, + #[source] + pub source: DatabaseError, +} +``` + +## See Also + +- [err-thiserror-lib](./err-thiserror-lib.md) - thiserror for error definitions +- [err-anyhow-app](./err-anyhow-app.md) - When to use anyhow instead +- [api-non-exhaustive](./api-non-exhaustive.md) - Forward-compatible enums diff --git a/.agents/skills/rust-skills/rules/err-doc-errors.md b/.agents/skills/rust-skills/rules/err-doc-errors.md new file mode 100644 index 0000000..b1262a3 --- /dev/null +++ b/.agents/skills/rust-skills/rules/err-doc-errors.md @@ -0,0 +1,145 @@ +# err-doc-errors + +> Document error conditions with `# Errors` section in doc comments + +## Why It Matters + +Users of your API need to know what can go wrong and why. The `# Errors` documentation section is the standard Rust convention for describing when a function returns `Err`. Good error documentation helps callers handle errors appropriately and understand the contract of your API. + +## Bad + +```rust +/// Loads a configuration from the specified path. +pub fn load_config(path: &Path) -> Result { + // No documentation of error conditions + // Caller must read source code to understand what can fail +} + +/// Parses and validates the input string. +/// +/// Returns the parsed value. // What about errors? +pub fn parse_input(input: &str) -> Result { + // ... +} +``` + +## Good + +```rust +/// Loads a configuration from the specified path. +/// +/// # Errors +/// +/// Returns an error if: +/// - The file at `path` does not exist or cannot be read +/// - The file contents are not valid TOML +/// - Required configuration keys are missing +/// - Configuration values are out of valid ranges +/// +/// # Examples +/// +/// ``` +/// # use mylib::{load_config, ConfigError}; +/// # fn main() -> Result<(), ConfigError> { +/// let config = load_config("app.toml")?; +/// # Ok(()) +/// # } +/// ``` +pub fn load_config(path: &Path) -> Result { + // ... +} + +/// Parses and validates the input string as a positive integer. +/// +/// # Errors +/// +/// Returns [`ParseError::Empty`] if the input is empty. +/// Returns [`ParseError::InvalidFormat`] if the input contains non-digit characters. +/// Returns [`ParseError::Overflow`] if the value exceeds `i64::MAX`. +/// Returns [`ParseError::NotPositive`] if the value is zero or negative. +pub fn parse_positive_int(input: &str) -> Result { + // ... +} +``` + +## Linking to Error Variants + +```rust +/// Attempts to connect to the database. +/// +/// # Errors +/// +/// This function will return an error if: +/// +/// - [`DbError::ConnectionFailed`] - The database server is unreachable +/// - [`DbError::AuthenticationFailed`] - Invalid credentials +/// - [`DbError::Timeout`] - Connection attempt exceeded timeout +/// - [`DbError::TlsError`] - TLS handshake failed +/// +/// See [`DbError`] for more details on each variant. +pub fn connect(config: &DbConfig) -> Result { + // ... +} +``` + +## Panic vs Error Documentation + +```rust +/// Divides two numbers. +/// +/// # Errors +/// +/// Returns [`MathError::DivisionByZero`] if `divisor` is zero. +/// +/// # Panics +/// +/// Panics if called from a non-main thread (debug builds only). +pub fn divide(dividend: i64, divisor: i64) -> Result { + // ... +} +``` + +## Error Section Format Options + +```rust +// Style 1: Bullet list (good for multiple conditions) +/// # Errors +/// +/// Returns an error if: +/// - The file does not exist +/// - The file cannot be read +/// - The content is invalid UTF-8 + +// Style 2: Returns statements (good for mapping to variants) +/// # Errors +/// +/// Returns [`Error::NotFound`] if the item doesn't exist. +/// Returns [`Error::PermissionDenied`] if access is forbidden. + +// Style 3: Prose (good for complex conditions) +/// # Errors +/// +/// This function returns an error when the input fails validation. +/// Validation includes checking that all required fields are present, +/// that numeric fields are within allowed ranges, and that string +/// fields match their expected formats. +``` + +## Clippy Lint + +```toml +# Cargo.toml - require error documentation +[lints.clippy] +missing_errors_doc = "warn" +``` + +```rust +// This will warn without # Errors section +pub fn might_fail() -> Result<(), Error> { Ok(()) } +``` + +## See Also + +- [doc-examples-section](./doc-examples-section.md) - Examples in documentation +- [err-thiserror-lib](./err-thiserror-lib.md) - Defining error types +- [api-must-use](./api-must-use.md) - Marking Results as must_use diff --git a/.agents/skills/rust-skills/rules/err-expect-bugs-only.md b/.agents/skills/rust-skills/rules/err-expect-bugs-only.md new file mode 100644 index 0000000..d437042 --- /dev/null +++ b/.agents/skills/rust-skills/rules/err-expect-bugs-only.md @@ -0,0 +1,133 @@ +# err-expect-bugs-only + +> Use `expect()` only for invariants that indicate bugs, not user errors + +## Why It Matters + +`expect()` is better than `unwrap()` because it provides context, but it still panics. Reserve it for situations where failure indicates a bug in your code—a violated invariant, not a user error or external failure. The message should explain why the invariant should hold, helping future developers understand and fix the bug. + +## Bad + +```rust +// User input can legitimately fail - don't expect +fn parse_user_input(input: &str) -> Config { + serde_json::from_str(input) + .expect("Invalid JSON") // User error, not a bug! +} + +// Network can fail - don't expect +fn fetch_data(url: &str) -> Data { + reqwest::get(url) + .expect("Network request failed") // External failure! + .json() + .expect("Invalid response") +} + +// File might not exist - don't expect +fn load_config() -> Config { + let content = fs::read_to_string("config.json") + .expect("Config file missing"); // Environment issue! +} +``` + +## Good + +```rust +// Invariant: after insert, key exists +fn cache_and_get(&mut self, key: String, value: Value) -> &Value { + self.cache.insert(key.clone(), value); + self.cache.get(&key) + .expect("BUG: key must exist immediately after insert") +} + +// Invariant: regex is compile-time constant +fn create_parser() -> Regex { + Regex::new(r"^\d{4}-\d{2}-\d{2}$") + .expect("BUG: date regex is invalid - this is a compile-time constant") +} + +// Invariant: already validated +fn process_validated(data: ValidatedData) -> Result { + let value = data.required_field + .expect("BUG: ValidatedData guarantees required_field is Some"); + // ... +} + +// Invariant: type system guarantees +fn get_first(vec: Vec) -> T +where + Vec: NonEmpty, // Hypothetical trait +{ + vec.into_iter().next() + .expect("BUG: NonEmpty Vec cannot be empty") +} +``` + +## expect() Message Guidelines + +Messages should: +1. Start with "BUG:" or similar to indicate it's an invariant +2. Explain WHY the invariant should hold +3. Help developers fix the issue + +```rust +// ❌ Bad messages +.expect("failed") // No context +.expect("should not be None") // Doesn't explain why +.expect("Invalid state") // Vague + +// ✅ Good messages +.expect("BUG: HashMap entry exists after insert") +.expect("BUG: validated input must parse - validation is broken") +.expect("BUG: static regex compilation failed - regex syntax error in source") +``` + +## Pattern: Validate Once, expect() After + +```rust +struct ValidatedEmail(String); + +impl ValidatedEmail { + pub fn new(email: &str) -> Result { + // Validation happens here, returns Result + if !is_valid_email(email) { + return Err(EmailError::Invalid); + } + Ok(ValidatedEmail(email.to_string())) + } + + pub fn domain(&self) -> &str { + // After validation, expect() is fine + self.0.split('@').nth(1) + .expect("BUG: ValidatedEmail must contain @") + } +} +``` + +## Alternatives When expect() Is Wrong + +```rust +// Don't: expect on user data +let port: u16 = input.parse().expect("Invalid port"); + +// Do: Return Result +let port: u16 = input.parse().map_err(|_| ConfigError::InvalidPort)?; + +// Do: Provide default +let port: u16 = input.parse().unwrap_or(8080); + +// Do: Handle explicitly +let port: u16 = match input.parse() { + Ok(p) => p, + Err(_) => { + log::warn!("Invalid port '{}', using default", input); + 8080 + } +}; +``` + +## See Also + +- [err-no-unwrap-prod](./err-no-unwrap-prod.md) - Avoiding unwrap in production +- [err-result-over-panic](./err-result-over-panic.md) - When to return Result +- [api-parse-dont-validate](./api-parse-dont-validate.md) - Type-driven validation diff --git a/.agents/skills/rust-skills/rules/err-from-impl.md b/.agents/skills/rust-skills/rules/err-from-impl.md new file mode 100644 index 0000000..79613dd --- /dev/null +++ b/.agents/skills/rust-skills/rules/err-from-impl.md @@ -0,0 +1,152 @@ +# err-from-impl + +> Implement `From` for error conversions to enable `?` operator + +## Why It Matters + +The `?` operator automatically converts errors using `From` trait. By implementing `From for YourError`, you enable seamless error propagation without explicit `.map_err()` calls. This makes error handling code cleaner and ensures consistent error wrapping throughout your codebase. + +## Bad + +```rust +#[derive(Debug)] +enum AppError { + Io(std::io::Error), + Parse(serde_json::Error), + Database(diesel::result::Error), +} + +fn load_config(path: &str) -> Result { + let content = std::fs::read_to_string(path) + .map_err(|e| AppError::Io(e))?; // Manual conversion everywhere + + let config: Config = serde_json::from_str(&content) + .map_err(|e| AppError::Parse(e))?; // Repeated boilerplate + + save_to_db(&config) + .map_err(|e| AppError::Database(e))?; // Gets tedious + + Ok(config) +} +``` + +## Good + +```rust +#[derive(Debug)] +enum AppError { + Io(std::io::Error), + Parse(serde_json::Error), + Database(diesel::result::Error), +} + +// Implement From for each source error type +impl From for AppError { + fn from(err: std::io::Error) -> Self { + AppError::Io(err) + } +} + +impl From for AppError { + fn from(err: serde_json::Error) -> Self { + AppError::Parse(err) + } +} + +impl From for AppError { + fn from(err: diesel::result::Error) -> Self { + AppError::Database(err) + } +} + +fn load_config(path: &str) -> Result { + let content = std::fs::read_to_string(path)?; // Auto-converts + let config: Config = serde_json::from_str(&content)?; // Clean! + save_to_db(&config)?; + Ok(config) +} +``` + +## Use thiserror for Automatic From + +```rust +use thiserror::Error; + +#[derive(Error, Debug)] +enum AppError { + #[error("IO error: {0}")] + Io(#[from] std::io::Error), // Auto-generates From impl + + #[error("Parse error: {0}")] + Parse(#[from] serde_json::Error), // #[from] does the work + + #[error("Database error: {0}")] + Database(#[from] diesel::result::Error), +} + +// Now ? just works +fn load_config(path: &str) -> Result { + let content = std::fs::read_to_string(path)?; + let config: Config = serde_json::from_str(&content)?; + save_to_db(&config)?; + Ok(config) +} +``` + +## From with Context + +Sometimes you need to add context during conversion: + +```rust +#[derive(Error, Debug)] +enum ConfigError { + #[error("Failed to read config from '{path}': {source}")] + ReadFailed { + path: String, + #[source] + source: std::io::Error, + }, +} + +// Can't use #[from] when you need extra context +fn load_config(path: &str) -> Result { + let content = std::fs::read_to_string(path) + .map_err(|source| ConfigError::ReadFailed { + path: path.to_string(), + source, + })?; + // ... +} + +// Or use anyhow for ad-hoc context +use anyhow::{Context, Result}; + +fn load_config(path: &str) -> Result { + let content = std::fs::read_to_string(path) + .with_context(|| format!("Failed to read config from '{}'", path))?; + // ... +} +``` + +## Blanket From Implementations + +Be careful with blanket implementations: + +```rust +// ❌ Too broad - conflicts with other From impls +impl From for AppError { + fn from(err: E) -> Self { + AppError::Other(err.to_string()) + } +} + +// ✅ Specific implementations +impl From for AppError { ... } +impl From for AppError { ... } +``` + +## See Also + +- [err-thiserror-lib](./err-thiserror-lib.md) - Using thiserror for libraries +- [err-source-chain](./err-source-chain.md) - Preserving error chains +- [err-question-mark](./err-question-mark.md) - The ? operator diff --git a/.agents/skills/rust-skills/rules/err-lowercase-msg.md b/.agents/skills/rust-skills/rules/err-lowercase-msg.md new file mode 100644 index 0000000..0ffd325 --- /dev/null +++ b/.agents/skills/rust-skills/rules/err-lowercase-msg.md @@ -0,0 +1,124 @@ +# err-lowercase-msg + +> Start error messages lowercase, no trailing punctuation + +## Why It Matters + +Error messages are often chained, logged, or displayed with additional context. Consistent formatting—lowercase start, no trailing period—allows clean composition: "failed to load config: invalid JSON: unexpected token". Mixed case and punctuation create awkward output: "Failed to load config.: Invalid JSON.: Unexpected token.". + +## Bad + +```rust +use thiserror::Error; + +#[derive(Error, Debug)] +enum ConfigError { + #[error("Failed to read config file.")] // Capital F, trailing period + ReadFailed(#[from] std::io::Error), + + #[error("Invalid JSON format!")] // Capital I, exclamation + ParseFailed(#[from] serde_json::Error), + + #[error("The requested key was not found")] // Reads like a sentence + KeyNotFound(String), +} + +// Chained output: "Config load error: Failed to read config file.: No such file" +// Awkward capitalization and punctuation +``` + +## Good + +```rust +use thiserror::Error; + +#[derive(Error, Debug)] +enum ConfigError { + #[error("failed to read config file")] // lowercase, no period + ReadFailed(#[from] std::io::Error), + + #[error("invalid JSON format")] // lowercase, no period + ParseFailed(#[from] serde_json::Error), + + #[error("key not found: {0}")] // lowercase, data at end + KeyNotFound(String), +} + +// Chained output: "config load error: failed to read config file: no such file" +// Clean, consistent +``` + +## Rust Standard Library Convention + +The standard library follows this convention: + +```rust +// std::io::Error messages +"entity not found" +"permission denied" +"connection refused" + +// std::num::ParseIntError +"invalid digit found in string" + +// std::str::Utf8Error +"invalid utf-8 sequence" +``` + +## Formatting Guidelines + +| Do | Don't | +|----|-------| +| `"failed to parse config"` | `"Failed to parse config."` | +| `"invalid input: expected number"` | `"Invalid input - expected a number!"` | +| `"connection timed out after {0}s"` | `"Connection Timed Out After {0} seconds."` | +| `"key '{0}' not found"` | `"Key Not Found: {0}"` | + +## Context Addition Pattern + +```rust +use anyhow::{Context, Result}; + +fn load_user(id: u64) -> Result { + let data = fetch(id) + .with_context(|| format!("failed to fetch user {}", id))?; + + parse_user(data) + .with_context(|| "failed to parse user data")? +} + +// Output: "failed to fetch user 42: connection refused" +// All lowercase, clean chain +``` + +## Display vs Debug + +```rust +#[derive(Error, Debug)] +#[error("invalid configuration")] // Display: for users/logs +pub struct ConfigError { + path: PathBuf, + source: io::Error, +} + +// Debug output (for developers) can have more detail +// Display output (for users) should be clean +``` + +## When to Use Capitals + +```rust +// Proper nouns / acronyms keep their case +#[error("invalid JSON syntax")] // JSON is an acronym +#[error("OAuth token expired")] // OAuth is a proper noun +#[error("HTTP request failed")] // HTTP is an acronym + +// Error codes can be uppercase +#[error("error code E0001: invalid input")] +``` + +## See Also + +- [err-thiserror-lib](./err-thiserror-lib.md) - Error definition with thiserror +- [err-context-chain](./err-context-chain.md) - Adding context to errors +- [doc-examples-section](./doc-examples-section.md) - Documentation conventions diff --git a/.agents/skills/rust-skills/rules/err-no-unwrap-prod.md b/.agents/skills/rust-skills/rules/err-no-unwrap-prod.md new file mode 100644 index 0000000..87c8156 --- /dev/null +++ b/.agents/skills/rust-skills/rules/err-no-unwrap-prod.md @@ -0,0 +1,115 @@ +# err-no-unwrap-prod + +> Avoid `unwrap()` in production code; use `?`, `expect()`, or handle errors + +## Why It Matters + +`unwrap()` panics on `None` or `Err` without any context about what went wrong. In production, this creates cryptic crash messages that are hard to debug. Either propagate errors with `?`, use `expect()` with a message explaining the invariant, or handle the error explicitly. + +## Bad + +```rust +fn process_request(req: Request) -> Response { + let user_id = req.headers.get("X-User-Id").unwrap(); // Why did it fail? + let user = database.find_user(user_id).unwrap(); // Which operation? + let data = user.preferences.get("theme").unwrap(); // No context + + Response::new(data) +} + +// Crash message: "called `Option::unwrap()` on a `None` value" +// Where? Why? No idea. +``` + +## Good + +```rust +// Option 1: Propagate with ? +fn process_request(req: Request) -> Result { + let user_id = req.headers + .get("X-User-Id") + .ok_or(AppError::MissingHeader("X-User-Id"))?; + + let user = database.find_user(user_id)?; + + let data = user.preferences + .get("theme") + .ok_or(AppError::MissingPreference("theme"))?; + + Ok(Response::new(data)) +} + +// Option 2: expect() for invariants (not user input) +fn get_config_value(&self, key: &str) -> &str { + self.config + .get(key) + .expect("BUG: required config key missing after validation") +} + +// Option 3: Provide defaults +fn get_theme(user: &User) -> &str { + user.preferences + .get("theme") + .unwrap_or(&"default") +} + +// Option 4: Match for complex handling +fn process_optional(value: Option) -> ProcessedData { + match value { + Some(data) => process(data), + None => { + log::warn!("No data provided, using fallback"); + ProcessedData::default() + } + } +} +``` + +## `expect()` vs `unwrap()` + +```rust +// Bad: no context +let port = config.get("port").unwrap(); + +// Better: explains the invariant +let port = config.get("port") + .expect("config must contain 'port' after validation"); + +// Best: propagate if it's not truly an invariant +let port = config.get("port") + .ok_or_else(|| ConfigError::MissingKey("port"))?; +``` + +## Alternatives to unwrap() + +| Situation | Use Instead | +|-----------|-------------| +| Can propagate error | `?` operator | +| Has sensible default | `unwrap_or()`, `unwrap_or_default()` | +| Default requires computation | `unwrap_or_else(\|\| ...)` | +| Internal invariant | `expect("explanation")` | +| Need to handle both cases | `match` or `if let` | + +## Clippy Lints + +```toml +# Cargo.toml +[lints.clippy] +unwrap_used = "warn" # Warn on unwrap() +expect_used = "warn" # Also warn on expect() (stricter) +``` + +```rust +// Allow in specific places where it's justified +#[allow(clippy::unwrap_used)] +fn definitely_safe() { + // Unwrap is safe here because... + let x = Some(5).unwrap(); +} +``` + +## See Also + +- [err-result-over-panic](./err-result-over-panic.md) - Return Result instead of panicking +- [err-expect-bugs-only](./err-expect-bugs-only.md) - When expect() is appropriate +- [anti-unwrap-abuse](./anti-unwrap-abuse.md) - Patterns for avoiding unwrap diff --git a/.agents/skills/rust-skills/rules/err-question-mark.md b/.agents/skills/rust-skills/rules/err-question-mark.md new file mode 100644 index 0000000..2d5ec42 --- /dev/null +++ b/.agents/skills/rust-skills/rules/err-question-mark.md @@ -0,0 +1,151 @@ +# err-question-mark + +> Use `?` operator for clean propagation + +## Why It Matters + +The `?` operator is Rust's idiomatic way to propagate errors. It's concise, readable, and automatically converts between compatible error types using `From`. It replaces verbose `match` or `unwrap()` calls. + +## Bad + +```rust +// Verbose match-based error handling +fn load_config() -> Result { + let content = match std::fs::read_to_string("config.toml") { + Ok(c) => c, + Err(e) => return Err(Error::Io(e)), + }; + + let config = match toml::from_str(&content) { + Ok(c) => c, + Err(e) => return Err(Error::Parse(e)), + }; + + Ok(config) +} + +// Or worse - using unwrap +fn load_config_bad() -> Config { + let content = std::fs::read_to_string("config.toml").unwrap(); + toml::from_str(&content).unwrap() +} +``` + +## Good + +```rust +fn load_config() -> Result { + let content = std::fs::read_to_string("config.toml")?; + let config = toml::from_str(&content)?; + Ok(config) +} + +// Even more concise +fn load_config() -> Result { + Ok(toml::from_str(&std::fs::read_to_string("config.toml")?)?) +} +``` + +## How ? Works + +```rust +// This: +let x = expr?; + +// Expands roughly to: +let x = match expr { + Ok(val) => val, + Err(err) => return Err(From::from(err)), +}; +``` + +## Combining with Context + +```rust +use anyhow::{Context, Result}; + +fn load_user(id: u64) -> Result { + let path = format!("users/{}.json", id); + + let content = std::fs::read_to_string(&path) + .with_context(|| format!("failed to read user file: {}", path))?; + + let user: User = serde_json::from_str(&content) + .context("failed to parse user JSON")?; + + Ok(user) +} +``` + +## ? with Option + +```rust +fn get_first_word(text: &str) -> Option<&str> { + let first_line = text.lines().next()?; + let first_word = first_line.split_whitespace().next()?; + Some(first_word) +} + +// Convert Option to Result +fn get_required_config(key: &str) -> Result { + config.get(key) + .cloned() + .ok_or_else(|| Error::MissingConfig(key.to_string())) +} +``` + +## Error Type Conversion + +```rust +use thiserror::Error; + +#[derive(Error, Debug)] +enum MyError { + #[error("io error")] + Io(#[from] std::io::Error), // Auto From impl + + #[error("parse error")] + Parse(#[from] serde_json::Error), // Auto From impl +} + +fn process() -> Result<(), MyError> { + // ? automatically converts io::Error to MyError via From + let content = std::fs::read_to_string("file.txt")?; + + // ? automatically converts serde_json::Error to MyError + let data: Data = serde_json::from_str(&content)?; + + Ok(()) +} +``` + +## In main() + +```rust +// Option 1: Return Result from main +fn main() -> Result<(), Box> { + let config = load_config()?; + run_app(config)?; + Ok(()) +} + +// Option 2: Handle in main, exit on error +fn main() { + if let Err(e) = run() { + eprintln!("Error: {:#}", e); + std::process::exit(1); + } +} + +fn run() -> anyhow::Result<()> { + let config = load_config()?; + run_app(config)?; + Ok(()) +} +``` + +## See Also + +- [err-context-chain](err-context-chain.md) - Add context with .context() +- [err-from-impl](err-from-impl.md) - Use #[from] for automatic conversion +- [err-anyhow-app](err-anyhow-app.md) - Use anyhow for applications diff --git a/.agents/skills/rust-skills/rules/err-result-over-panic.md b/.agents/skills/rust-skills/rules/err-result-over-panic.md new file mode 100644 index 0000000..4e294d2 --- /dev/null +++ b/.agents/skills/rust-skills/rules/err-result-over-panic.md @@ -0,0 +1,130 @@ +# err-result-over-panic + +> Return `Result` instead of panicking for recoverable errors + +## Why It Matters + +Panics unwind the stack and crash the thread (or program). They're unrecoverable from the caller's perspective. `Result` gives callers the ability to decide how to handle errors—retry, fallback, propagate, or log. Libraries should almost never panic; applications should minimize panics to truly unrecoverable situations. + +## Bad + +```rust +fn parse_config(path: &str) -> Config { + let content = std::fs::read_to_string(path) + .expect("Failed to read config"); // Crashes on missing file + + serde_json::from_str(&content) + .expect("Invalid config format") // Crashes on bad JSON +} + +fn divide(a: i32, b: i32) -> i32 { + if b == 0 { + panic!("Division by zero!"); // Crashes the program + } + a / b +} +``` + +Caller has no chance to recover or provide a fallback. + +## Good + +```rust +use thiserror::Error; + +#[derive(Error, Debug)] +enum ConfigError { + #[error("Failed to read config file: {0}")] + Io(#[from] std::io::Error), + #[error("Invalid config format: {0}")] + Parse(#[from] serde_json::Error), +} + +fn parse_config(path: &str) -> Result { + let content = std::fs::read_to_string(path)?; + let config = serde_json::from_str(&content)?; + Ok(config) +} + +fn divide(a: i32, b: i32) -> Result { + if b == 0 { + return Err("Division by zero"); + } + Ok(a / b) +} + +// Caller decides how to handle +match parse_config("app.json") { + Ok(config) => run_app(config), + Err(e) => { + eprintln!("Using default config: {}", e); + run_app(Config::default()) + } +} +``` + +## When Panic IS Appropriate + +```rust +// 1. Bug in the program (invariant violation) +fn get_cached_value(&self, key: &str) -> &Value { + self.cache.get(key).expect("BUG: key was verified to exist") +} + +// 2. Setup/initialization that can't reasonably fail +fn main() { + let config = Config::load().expect("Failed to load required config"); + // Can't run without config, panic is reasonable +} + +// 3. Tests +#[test] +fn test_parse() { + let result = parse("valid input").unwrap(); // unwrap OK in tests + assert_eq!(result, expected); +} + +// 4. Examples and prototypes +fn main() { + // Quick prototype, panic is fine + let data = fetch_data().unwrap(); +} +``` + +## Panic vs Result Decision Guide + +| Situation | Use | +|-----------|-----| +| File not found | `Result` | +| Network error | `Result` | +| Invalid user input | `Result` | +| Parse error | `Result` | +| Index out of bounds (from user data) | `Result` | +| Index out of bounds (internal bug) | Panic | +| Violated internal invariant | Panic | +| Unimplemented code path | Panic (`unimplemented!()`) | +| Impossible state reached | Panic (`unreachable!()`) | + +## Library vs Application + +```rust +// Library: NEVER panic on user input +pub fn parse(input: &str) -> Result { + // Always return Result +} + +// Application: Can panic at top level for critical failures +fn main() { + if let Err(e) = run() { + eprintln!("Fatal error: {}", e); + std::process::exit(1); + } +} +``` + +## See Also + +- [err-thiserror-lib](./err-thiserror-lib.md) - Define error types for libraries +- [err-anyhow-app](./err-anyhow-app.md) - Ergonomic errors for applications +- [err-no-unwrap-prod](./err-no-unwrap-prod.md) - Avoid unwrap in production code +- [anti-unwrap-abuse](./anti-unwrap-abuse.md) - When unwrap is acceptable diff --git a/.agents/skills/rust-skills/rules/err-source-chain.md b/.agents/skills/rust-skills/rules/err-source-chain.md new file mode 100644 index 0000000..c6390c1 --- /dev/null +++ b/.agents/skills/rust-skills/rules/err-source-chain.md @@ -0,0 +1,155 @@ +# err-source-chain + +> Preserve error chains with `#[source]` or `source()` method + +## Why It Matters + +Errors often have underlying causes. Preserving the error chain (via `source()` method) allows logging frameworks and error reporters to show the full context: "config parse failed → JSON syntax error at line 5 → unexpected token". Without chaining, you lose valuable debugging information. + +## Bad + +```rust +#[derive(Debug)] +enum ConfigError { + ParseFailed(String), // Lost the original serde_json::Error +} + +fn load_config(path: &str) -> Result { + let content = std::fs::read_to_string(path) + .map_err(|e| ConfigError::ParseFailed(e.to_string()))?; // Chain lost! + + serde_json::from_str(&content) + .map_err(|e| ConfigError::ParseFailed(e.to_string()))? // No source +} + +// Error output: "Parse failed: invalid type: ..." +// Missing: which file? what line? what was the parent error? +``` + +## Good + +```rust +use thiserror::Error; + +#[derive(Error, Debug)] +enum ConfigError { + #[error("Failed to read config file '{path}'")] + ReadFailed { + path: String, + #[source] // Preserves the error chain + source: std::io::Error, + }, + + #[error("Failed to parse config file '{path}'")] + ParseFailed { + path: String, + #[source] // Original parse error preserved + source: serde_json::Error, + }, +} + +fn load_config(path: &str) -> Result { + let content = std::fs::read_to_string(path) + .map_err(|source| ConfigError::ReadFailed { + path: path.to_string(), + source, // Chain preserved + })?; + + serde_json::from_str(&content) + .map_err(|source| ConfigError::ParseFailed { + path: path.to_string(), + source, + }) +} +``` + +## Manual source() Implementation + +```rust +use std::error::Error; + +#[derive(Debug)] +struct MyError { + message: String, + source: Option>, +} + +impl std::fmt::Display for MyError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.message) + } +} + +impl Error for MyError { + fn source(&self) -> Option<&(dyn Error + 'static)> { + self.source.as_ref().map(|e| e.as_ref() as &(dyn Error + 'static)) + } +} +``` + +## Walking the Error Chain + +```rust +fn print_error_chain(error: &dyn std::error::Error) { + eprintln!("Error: {}", error); + + let mut source = error.source(); + while let Some(err) = source { + eprintln!("Caused by: {}", err); + source = err.source(); + } +} + +// With anyhow, use {:?} for full chain +let result: anyhow::Result<()> = do_something(); +if let Err(e) = result { + eprintln!("{:?}", e); // Prints full chain with backtraces +} +``` + +## anyhow Context + +```rust +use anyhow::{Context, Result}; + +fn load_config(path: &str) -> Result { + let content = std::fs::read_to_string(path) + .with_context(|| format!("Failed to read '{}'", path))?; + + let config: Config = serde_json::from_str(&content) + .with_context(|| format!("Failed to parse '{}'", path))?; + + Ok(config) +} + +// Output: +// Error: Failed to parse 'config.json' +// Caused by: expected `:` at line 5 column 10 +``` + +## #[from] vs #[source] + +```rust +use thiserror::Error; + +#[derive(Error, Debug)] +enum MyError { + // #[from] = implements From + sets source + #[error("IO error")] + Io(#[from] std::io::Error), + + // #[source] = only sets source (no From impl) + #[error("Parse error in file '{path}'")] + Parse { + path: String, + #[source] + source: serde_json::Error, + }, +} +``` + +## See Also + +- [err-thiserror-lib](./err-thiserror-lib.md) - thiserror for error definitions +- [err-context-chain](./err-context-chain.md) - Adding context to errors +- [err-from-impl](./err-from-impl.md) - From implementations for ? diff --git a/.agents/skills/rust-skills/rules/err-thiserror-lib.md b/.agents/skills/rust-skills/rules/err-thiserror-lib.md new file mode 100644 index 0000000..6a7d0c8 --- /dev/null +++ b/.agents/skills/rust-skills/rules/err-thiserror-lib.md @@ -0,0 +1,171 @@ +# err-thiserror-lib + +> Use `thiserror` for library error types + +## Why It Matters + +Libraries should expose typed, matchable errors so users can handle specific error conditions. `thiserror` generates `Error` trait implementations with minimal boilerplate, creating ergonomic error types that are easy to match against. + +## Bad + +```rust +// String errors - not matchable +fn parse(input: &str) -> Result { + Err("parse error".to_string()) +} + +// Box - not matchable +fn load(path: &Path) -> Result> { + Err(Box::new(std::io::Error::new(std::io::ErrorKind::NotFound, "file not found"))) +} + +// Manual implementation - verbose +#[derive(Debug)] +enum MyError { + Io(std::io::Error), + Parse(String), +} + +impl std::fmt::Display for MyError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + MyError::Io(e) => write!(f, "io error: {}", e), + MyError::Parse(s) => write!(f, "parse error: {}", s), + } + } +} + +impl std::error::Error for MyError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + MyError::Io(e) => Some(e), + MyError::Parse(_) => None, + } + } +} +``` + +## Good + +```rust +use thiserror::Error; + +#[derive(Error, Debug)] +pub enum ParseError { + #[error("invalid syntax at line {line}: {message}")] + Syntax { line: usize, message: String }, + + #[error("unexpected end of file")] + UnexpectedEof, + + #[error("invalid utf-8 encoding")] + Utf8(#[from] std::str::Utf8Error), + + #[error("io error reading input")] + Io(#[from] std::io::Error), +} + +// Usage +fn parse(input: &str) -> Result { + if input.is_empty() { + return Err(ParseError::UnexpectedEof); + } + // ... +} + +// Users can match specific errors +match parse(input) { + Ok(ast) => process(ast), + Err(ParseError::Syntax { line, message }) => { + eprintln!("Syntax error on line {}: {}", line, message); + } + Err(ParseError::UnexpectedEof) => { + eprintln!("File ended unexpectedly"); + } + Err(e) => eprintln!("Error: {}", e), +} +``` + +## Key Attributes + +```rust +use thiserror::Error; + +#[derive(Error, Debug)] +pub enum MyError { + // Simple message + #[error("operation failed")] + Failed, + + // Interpolated fields + #[error("invalid value: {0}")] + InvalidValue(String), + + // Named fields + #[error("connection to {host}:{port} failed")] + Connection { host: String, port: u16 }, + + // Automatic From impl with #[from] + #[error("database error")] + Database(#[from] sqlx::Error), + + // Source without From (manual conversion needed) + #[error("validation failed")] + Validation { + #[source] + cause: ValidationError, + field: String, + }, + + // Transparent - delegates Display and source to inner + #[error(transparent)] + Other(#[from] anyhow::Error), +} +``` + +## Error Chaining + +```rust +use thiserror::Error; + +#[derive(Error, Debug)] +pub enum ConfigError { + #[error("failed to read config file")] + Read(#[source] std::io::Error), + + #[error("failed to parse config")] + Parse(#[source] toml::de::Error), + + #[error("invalid config value for '{key}'")] + InvalidValue { + key: String, + #[source] + cause: ValueError, + }, +} + +// Error chain is preserved +fn load_config(path: &Path) -> Result { + let content = std::fs::read_to_string(path) + .map_err(ConfigError::Read)?; + + let config: Config = toml::from_str(&content) + .map_err(ConfigError::Parse)?; + + Ok(config) +} +``` + +## Library vs Application + +| Context | Crate | Why | +|---------|-------|-----| +| Library | `thiserror` | Typed errors users can match | +| Application | `anyhow` | Easy error handling with context | +| Both | `thiserror` for public API, `anyhow` internally | Best of both | + +## See Also + +- [err-anyhow-app](err-anyhow-app.md) - Use anyhow for applications +- [err-from-impl](err-from-impl.md) - Use #[from] for automatic conversion +- [err-source-chain](err-source-chain.md) - Use #[source] to chain errors diff --git a/.agents/skills/rust-skills/rules/lint-cargo-metadata.md b/.agents/skills/rust-skills/rules/lint-cargo-metadata.md new file mode 100644 index 0000000..8e5b16a --- /dev/null +++ b/.agents/skills/rust-skills/rules/lint-cargo-metadata.md @@ -0,0 +1,138 @@ +# lint-cargo-metadata + +> Enable clippy::cargo for published crates + +## Why It Matters + +The `clippy::cargo` lint group checks Cargo.toml for issues that affect publishing and dependency management. For crates intended for crates.io, these checks help ensure a professional, well-configured package. + +## Configuration + +```toml +# Cargo.toml +[lints.clippy] +cargo = "warn" +``` + +Or in code: + +```rust +#![warn(clippy::cargo)] +``` + +## What It Catches + +### Missing Metadata + +```toml +# WARN: missing package.description +# WARN: missing package.license or package.license-file +# WARN: missing package.repository +[package] +name = "my-crate" +version = "0.1.0" +``` + +### Dependency Issues + +```toml +# WARN: feature used but not defined +# WARN: dependency version not specified +[dependencies] +serde = "*" # Bad: any version +tokio = { git = "..." } # WARN for published crates +``` + +### Feature Issues + +```toml +# WARN: negative_feature_names +[features] +no-std = [] # Should be: std = [] (opt-out vs opt-in) + +# WARN: redundant_feature_names +[features] +default = ["feature-a"] +feature-a = [] # Feature name matches crate name +``` + +## Notable Lints + +| Lint | Issue | +|------|-------| +| `cargo_common_metadata` | Missing description/license/repository | +| `multiple_crate_versions` | Same crate at different versions | +| `negative_feature_names` | Features like `no-std` instead of `std` | +| `redundant_feature_names` | Feature same as crate name | +| `wildcard_dependencies` | Using `*` for version | + +## Complete Cargo.toml + +```toml +[package] +name = "my-crate" +version = "0.1.0" +edition = "2021" +rust-version = "1.70" + +# Required for cargo lint satisfaction +description = "A short description of what this crate does" +license = "MIT OR Apache-2.0" +repository = "https://github.com/user/my-crate" + +# Recommended +documentation = "https://docs.rs/my-crate" +readme = "README.md" +keywords = ["keyword1", "keyword2"] +categories = ["category-slug"] + +[dependencies] +# Specific versions, not wildcards +serde = "1.0" +tokio = { version = "1.0", features = ["full"] } + +[features] +default = ["std"] +std = [] # Opt-out, not no-std opt-in + +[lints.clippy] +cargo = "warn" +``` + +## Multiple Crate Versions + +``` +# WARN: multiple versions of `syn` in dependency tree +# syn v1.0.109 +# syn v2.0.48 +``` + +Fix by updating dependencies or using `[patch]`: + +```toml +[patch.crates-io] +old-dep = { git = "...", branch = "syn-2" } +``` + +## When to Disable + +For internal/unpublished crates: + +```toml +[lints.clippy] +cargo = "allow" # Not publishing, metadata not needed +``` + +Or selectively: + +```toml +[lints.clippy] +cargo = "warn" +multiple_crate_versions = "allow" # Acceptable in this project +``` + +## See Also + +- [doc-cargo-metadata](./doc-cargo-metadata.md) - Cargo.toml metadata +- [proj-workspace-deps](./proj-workspace-deps.md) - Workspace dependencies +- [lint-deny-correctness](./lint-deny-correctness.md) - Correctness lints diff --git a/.agents/skills/rust-skills/rules/lint-deny-correctness.md b/.agents/skills/rust-skills/rules/lint-deny-correctness.md new file mode 100644 index 0000000..bf6074d --- /dev/null +++ b/.agents/skills/rust-skills/rules/lint-deny-correctness.md @@ -0,0 +1,107 @@ +# lint-deny-correctness + +> `#![deny(clippy::correctness)]` + +## Why It Matters + +Clippy's correctness lints catch code that is outright wrong - logic errors, undefined behavior, or code that doesn't do what you think. These should always be errors, not warnings. + +## Setup + +```rust +// At the top of lib.rs or main.rs +#![deny(clippy::correctness)] + +// Or in Cargo.toml for workspace-wide +[lints.clippy] +correctness = "deny" +``` + +## What It Catches + +```rust +// Infinite loop (iter::repeat without take) +for x in std::iter::repeat(1) { // ERROR: infinite iterator + println!("{}", x); +} + +// Comparison to NaN (always false) +if x == f64::NAN { // ERROR: NaN != NaN always + // This never executes +} + +// Use after free patterns +let r; +{ + let x = 5; + r = &x; // ERROR: x dropped here +} +println!("{}", r); + +// Wrong equality check +if x = 5 { // ERROR: assignment in condition (should be ==) +} + +// Useless comparisons +if x >= 0 && x < 0 { // ERROR: impossible condition +} +``` + +## Important Correctness Lints + +```rust +// approx_constant - using imprecise PI, E values +let pi = 3.14; // Use std::f64::consts::PI + +// invalid_regex - regex that won't compile +let re = Regex::new("["); // Invalid regex + +// iter_next_loop - using .next() in for loop incorrectly +for x in iter.next() { // Should be: for x in iter + +// never_loop - loop that never actually loops +loop { + break; // Always breaks immediately +} + +// nonsensical_open_options - impossible file options +File::options().read(false).write(false).open("f"); + +// unit_cmp - comparing unit type () +if foo() == bar() { } // Both return (), always true +``` + +## Full Recommended Lints + +```rust +#![deny(clippy::correctness)] +#![warn(clippy::suspicious)] +#![warn(clippy::style)] +#![warn(clippy::complexity)] +#![warn(clippy::perf)] + +// For published crates +#![warn(missing_docs)] +#![warn(clippy::cargo)] +``` + +## Running Clippy + +```bash +# Basic check +cargo clippy + +# With all warnings as errors +cargo clippy -- -D warnings + +# Check specific lint category +cargo clippy -- -W clippy::correctness + +# In CI (fail on warnings) +cargo clippy -- -D warnings -D clippy::correctness +``` + +## See Also + +- [lint-warn-suspicious](lint-warn-suspicious.md) - Warn on suspicious code +- [lint-warn-perf](lint-warn-perf.md) - Warn on performance issues diff --git a/.agents/skills/rust-skills/rules/lint-missing-docs.md b/.agents/skills/rust-skills/rules/lint-missing-docs.md new file mode 100644 index 0000000..17fa262 --- /dev/null +++ b/.agents/skills/rust-skills/rules/lint-missing-docs.md @@ -0,0 +1,154 @@ +# lint-missing-docs + +> Warn on missing documentation for public items + +## Why It Matters + +The `missing_docs` lint ensures all public API items are documented. For libraries, documentation IS the user interface. Missing docs mean users can't understand your API without reading source code. + +## Configuration + +```rust +// In lib.rs +#![warn(missing_docs)] +``` + +Or in `Cargo.toml`: + +```toml +[lints.rust] +missing_docs = "warn" +``` + +For strict enforcement: + +```rust +#![deny(missing_docs)] +``` + +## What It Catches + +```rust +#![warn(missing_docs)] + +pub struct User { // WARN: missing documentation for a struct + pub name: String, // WARN: missing documentation for a field + pub age: u32, // WARN: missing documentation for a field +} + +pub fn process() { } // WARN: missing documentation for a function + +pub trait Handler { // WARN: missing documentation for a trait + fn handle(&self); // WARN: missing documentation for a method +} +``` + +## Good + +```rust +#![warn(missing_docs)] + +//! User management module. + +/// Represents a registered user in the system. +pub struct User { + /// The user's display name. + pub name: String, + /// The user's age in years. + pub age: u32, +} + +/// Processes pending user requests. +/// +/// # Examples +/// +/// ``` +/// process(); +/// ``` +pub fn process() { } + +/// Handler trait for request processing. +pub trait Handler { + /// Handle an incoming request. + fn handle(&self); +} +``` + +## Private Items + +`missing_docs` only applies to `pub` items. Private items don't trigger warnings: + +```rust +#![warn(missing_docs)] + +struct Internal { } // No warning - private + +pub struct Public { } // WARN - public, needs docs +``` + +## Allow for Specific Items + +```rust +#![warn(missing_docs)] + +/// Documented module. +pub mod api { + /// Documented struct. + pub struct Config { } + + #[allow(missing_docs)] + pub mod internal { + // Internal API, docs not required + pub struct Helper { } + } +} +``` + +## Gradual Adoption + +For existing codebases, start with `warn` and fix incrementally: + +```rust +// Phase 1: Warn, fix critical items +#![warn(missing_docs)] + +// Phase 2: After cleanup, deny +#![deny(missing_docs)] +``` + +## Combining with doc Attributes + +```rust +#![warn(missing_docs)] +#![warn(rustdoc::broken_intra_doc_links)] +#![warn(rustdoc::private_intra_doc_links)] +``` + +## Workspace Configuration + +```toml +# In workspace Cargo.toml +[workspace.lints.rust] +missing_docs = "warn" + +# Member crates inherit +[lints] +workspace = true +``` + +## What to Document + +| Item | Doc Focus | +|------|-----------| +| Structs | Purpose, usage example | +| Struct fields | What it represents | +| Enums | When to use each variant | +| Functions | What it does, params, return | +| Traits | Contract and expectations | +| Modules | What the module provides | + +## See Also + +- [doc-all-public](./doc-all-public.md) - Documentation patterns +- [lint-unsafe-doc](./lint-unsafe-doc.md) - Unsafe documentation +- [doc-examples-section](./doc-examples-section.md) - Adding examples diff --git a/.agents/skills/rust-skills/rules/lint-pedantic-selective.md b/.agents/skills/rust-skills/rules/lint-pedantic-selective.md new file mode 100644 index 0000000..896e9fc --- /dev/null +++ b/.agents/skills/rust-skills/rules/lint-pedantic-selective.md @@ -0,0 +1,118 @@ +# lint-pedantic-selective + +> Enable clippy::pedantic selectively + +## Why It Matters + +The `clippy::pedantic` group contains opinionated lints that aren't universally applicable. Enabling it wholesale produces noise; selectively enabling useful pedantic lints improves code quality without false positives. + +## Bad + +```rust +// Too noisy - will fight you constantly +#![warn(clippy::pedantic)] +``` + +## Good + +```toml +# Cargo.toml - cherry-pick useful pedantic lints +[lints.clippy] +# Enable pedantic as baseline +pedantic = "warn" + +# Disable noisy ones +missing_errors_doc = "allow" # Document errors separately +missing_panics_doc = "allow" # Document panics separately +module_name_repetitions = "allow" # Allow Foo::FooError pattern +too_many_lines = "allow" # Function length varies +must_use_candidate = "allow" # Too many suggestions +``` + +## Recommended Pedantic Lints + +| Lint | Why Enable | +|------|-----------| +| `doc_markdown` | Catch unmarked code in docs | +| `match_wildcard_for_single_variants` | Explicit variant matching | +| `semicolon_if_nothing_returned` | Consistent semicolons | +| `string_add_assign` | Use `+=` for string concatenation | +| `unnested_or_patterns` | Simplify match patterns | +| `unused_self` | Catch methods that should be functions | +| `used_underscore_binding` | Warn on using `_var` | +| `wildcard_imports` | Avoid glob imports | + +## Often Disabled + +| Lint | Why Disable | +|------|-------------| +| `missing_errors_doc` | Handle with `#[doc]` policy | +| `missing_panics_doc` | Handle with `#[doc]` policy | +| `module_name_repetitions` | Sometimes intentional | +| `must_use_candidate` | Too aggressive | +| `too_many_lines` | Arbitrary threshold | +| `struct_excessive_bools` | Valid for config structs | + +## Full Configuration + +```toml +# Cargo.toml +[lints.clippy] +# Start with pedantic +pedantic = "warn" + +# Keep these +doc_markdown = "warn" +match_wildcard_for_single_variants = "warn" +semicolon_if_nothing_returned = "warn" +unused_self = "warn" +wildcard_imports = "warn" + +# Disable these +missing_errors_doc = "allow" +missing_panics_doc = "allow" +module_name_repetitions = "allow" +must_use_candidate = "allow" +too_many_lines = "allow" +similar_names = "allow" +struct_excessive_bools = "allow" +``` + +## Alternative: Explicit Opt-in + +```toml +# Only enable specific lints, not the group +[lints.clippy] +# From pedantic, only these: +doc_markdown = "warn" +semicolon_if_nothing_returned = "warn" +unused_self = "warn" +wildcard_imports = "warn" +``` + +## Module-Level Overrides + +```rust +// Allow specific lint for a module +#![allow(clippy::module_name_repetitions)] + +// Or for specific items +#[allow(clippy::too_many_arguments)] +fn complex_function(/* many args */) { } +``` + +## Team Consensus + +Pedantic lints are style choices. Agree as a team: + +1. Enable `pedantic` as baseline +2. Run `cargo clippy` on codebase +3. Discuss each warning category +4. Disable ones that don't fit your style +5. Document decisions in `clippy.toml` + +## See Also + +- [lint-warn-style](./lint-warn-style.md) - Style warnings +- [lint-warn-complexity](./lint-warn-complexity.md) - Complexity warnings +- [lint-deny-correctness](./lint-deny-correctness.md) - Correctness lints diff --git a/.agents/skills/rust-skills/rules/lint-rustfmt-check.md b/.agents/skills/rust-skills/rules/lint-rustfmt-check.md new file mode 100644 index 0000000..6c7f9e6 --- /dev/null +++ b/.agents/skills/rust-skills/rules/lint-rustfmt-check.md @@ -0,0 +1,157 @@ +# lint-rustfmt-check + +> Run cargo fmt --check in CI + +## Why It Matters + +Consistent formatting eliminates style debates and makes diffs cleaner. Running `cargo fmt --check` in CI ensures all code follows the same format. This catches formatting issues before merge, not after. + +## CI Configuration + +### GitHub Actions + +```yaml +name: CI + +on: [push, pull_request] + +jobs: + fmt: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + with: + components: rustfmt + - run: cargo fmt --all --check +``` + +### GitLab CI + +```yaml +fmt: + image: rust:latest + script: + - rustup component add rustfmt + - cargo fmt --all --check +``` + +### Pre-commit Hook + +```bash +#!/bin/sh +# .git/hooks/pre-commit +cargo fmt --all --check +``` + +## Configuration + +Create `rustfmt.toml` for custom settings: + +```toml +# rustfmt.toml +edition = "2021" +max_width = 100 +use_small_heuristics = "Max" +imports_granularity = "Module" +group_imports = "StdExternalCrate" +reorder_imports = true +``` + +## Common Options + +| Option | Default | Description | +|--------|---------|-------------| +| `max_width` | 100 | Maximum line width | +| `tab_spaces` | 4 | Spaces per indent | +| `edition` | "2015" | Rust edition | +| `use_small_heuristics` | "Default" | Layout heuristics | +| `imports_granularity` | "Preserve" | Import grouping | +| `group_imports` | "Preserve" | Import ordering | + +## Running Locally + +```bash +# Check formatting (doesn't modify files) +cargo fmt --all --check + +# Apply formatting +cargo fmt --all + +# Format specific file +cargo fmt -- src/main.rs + +# Check with verbose output +cargo fmt --all --check -- --verbose +``` + +## Workspace Formatting + +```bash +# Format all workspace members +cargo fmt --all + +# Format specific package +cargo fmt -p my-package +``` + +## Ignoring Files + +In `rustfmt.toml`: + +```toml +# Skip generated files +ignore = [ + "src/generated/*", + "build.rs", +] +``` + +Or in code: + +```rust +#[rustfmt::skip] +mod generated_code; + +#[rustfmt::skip] +const MATRIX: [[i32; 4]; 4] = [ + [1, 0, 0, 0], + [0, 1, 0, 0], + [0, 0, 1, 0], + [0, 0, 0, 1], +]; +``` + +## Nightly Features + +Some options require nightly: + +```toml +# rustfmt.toml (nightly only) +unstable_features = true +imports_granularity = "Crate" +wrap_comments = true +format_code_in_doc_comments = true +``` + +```bash +# Use nightly rustfmt +cargo +nightly fmt +``` + +## IDE Integration + +Most IDEs format on save. Configure to use project `rustfmt.toml`: + +```json +// VS Code settings.json +{ + "rust-analyzer.rustfmt.extraArgs": ["--config-path", "./rustfmt.toml"] +} +``` + +## See Also + +- [lint-warn-style](./lint-warn-style.md) - Style lints +- [lint-pedantic-selective](./lint-pedantic-selective.md) - Pedantic lints +- [name-funcs-snake](./name-funcs-snake.md) - Naming conventions diff --git a/.agents/skills/rust-skills/rules/lint-unsafe-doc.md b/.agents/skills/rust-skills/rules/lint-unsafe-doc.md new file mode 100644 index 0000000..87112ed --- /dev/null +++ b/.agents/skills/rust-skills/rules/lint-unsafe-doc.md @@ -0,0 +1,133 @@ +# lint-unsafe-doc + +> Require documentation for unsafe blocks + +## Why It Matters + +The `undocumented_unsafe_blocks` lint ensures every unsafe block has a `// SAFETY:` comment explaining why the operation is sound. Unsafe code is the source of most memory safety bugs—documenting invariants catches mistakes and helps reviewers. + +## Configuration + +```rust +#![warn(clippy::undocumented_unsafe_blocks)] +``` + +Or in `Cargo.toml`: + +```toml +[lints.clippy] +undocumented_unsafe_blocks = "warn" +``` + +For strict enforcement: + +```toml +[lints.clippy] +undocumented_unsafe_blocks = "deny" +``` + +## Bad + +```rust +pub fn read_data(ptr: *const u8, len: usize) -> &[u8] { + unsafe { + std::slice::from_raw_parts(ptr, len) // WARN: undocumented + } +} + +impl Buffer { + pub fn get_unchecked(&self, index: usize) -> &u8 { + unsafe { self.data.get_unchecked(index) } // WARN + } +} +``` + +## Good + +```rust +pub fn read_data(ptr: *const u8, len: usize) -> &[u8] { + // SAFETY: Caller guarantees: + // - ptr is valid for reads of len bytes + // - ptr is properly aligned for u8 + // - the memory is initialized + // - no mutable references exist to this memory + unsafe { + std::slice::from_raw_parts(ptr, len) + } +} + +impl Buffer { + pub fn get_unchecked(&self, index: usize) -> &u8 { + debug_assert!(index < self.len(), "index out of bounds"); + // SAFETY: We verified index < len in debug builds. + // Callers must ensure index is within bounds. + unsafe { self.data.get_unchecked(index) } + } +} +``` + +## SAFETY Comment Format + +```rust +// SAFETY: +unsafe { + // ... +} +``` + +The comment should explain: +1. **What invariants are upheld** - preconditions that make this safe +2. **Why the invariants hold** - how you know they're satisfied +3. **What could go wrong** - if invariants are violated + +## Examples by Category + +### Pointer Operations + +```rust +// SAFETY: ptr was obtained from Box::into_raw, so it's valid +// and properly aligned. We're taking back ownership. +let boxed = unsafe { Box::from_raw(ptr) }; +``` + +### Unchecked Operations + +```rust +// SAFETY: We just checked that i < self.len() above. +// The bounds check cannot be elided by the optimizer +// because len() is not inlined. +unsafe { self.data.get_unchecked(i) } +``` + +### FFI Calls + +```rust +// SAFETY: libc::getenv is safe to call with a null-terminated +// string. We ensure null termination with CString::new. +// The returned pointer is valid for the lifetime of the environment. +let value = unsafe { libc::getenv(key.as_ptr()) }; +``` + +### Trait Implementations + +```rust +// SAFETY: MyType contains no pointers or interior mutability, +// and all bit patterns are valid MyType values. +unsafe impl Send for MyType {} +unsafe impl Sync for MyType {} +``` + +## Related Lints + +```toml +[lints.clippy] +undocumented_unsafe_blocks = "warn" +# Also consider: +multiple_unsafe_ops_per_block = "warn" # One operation per block +``` + +## See Also + +- [doc-safety-section](./doc-safety-section.md) - `# Safety` in docs +- [lint-deny-correctness](./lint-deny-correctness.md) - Correctness lints +- [type-repr-transparent](./type-repr-transparent.md) - FFI safety diff --git a/.agents/skills/rust-skills/rules/lint-warn-complexity.md b/.agents/skills/rust-skills/rules/lint-warn-complexity.md new file mode 100644 index 0000000..dd88b9c --- /dev/null +++ b/.agents/skills/rust-skills/rules/lint-warn-complexity.md @@ -0,0 +1,131 @@ +# lint-warn-complexity + +> Enable clippy::complexity for simpler code + +## Why It Matters + +The `clippy::complexity` lint group identifies unnecessarily complex code that can be simplified. Complex code is harder to read, maintain, and often hides bugs. Clippy suggests cleaner alternatives. + +## Configuration + +```rust +// In lib.rs or main.rs +#![warn(clippy::complexity)] +``` + +Or in `Cargo.toml`: + +```toml +[lints.clippy] +complexity = "warn" +``` + +## What It Catches + +### Unnecessary Complexity + +```rust +// WARN: Overly complex boolean expression +if !(x == 0) { } // Use: if x != 0 { } + +// WARN: Manual implementation of Option::map +match option { + Some(x) => Some(x + 1), + None => None, +} // Use: option.map(|x| x + 1) + +// WARN: Unnecessary filter before count +iter.filter(|x| predicate(x)).count() // Could simplify if only counting +``` + +### Redundant Operations + +```rust +// WARN: Redundant allocation +let s = format!("literal"); // Use: "literal".to_string() or just "literal" + +// WARN: Unnecessarily complicated match +match result { + Ok(ok) => Ok(ok), + Err(err) => Err(err), +} // Just use: result + +// WARN: Box::new in return position +fn make_error() -> Box { + Box::new(MyError) // Could use: MyError.into() +} +``` + +### Overly Verbose Code + +```rust +// WARN: bind_instead_of_map +option.and_then(|x| Some(x + 1)) // Use: option.map(|x| x + 1) + +// WARN: clone_on_copy +let y = x.clone(); // Where x is Copy type, just use: let y = x; + +// WARN: useless_let_if_seq +let result; +if condition { + result = 1; +} else { + result = 2; +} +// Use: let result = if condition { 1 } else { 2 }; +``` + +## Notable Lints in This Group + +| Lint | Simplification | +|------|---------------| +| `bind_instead_of_map` | Use `map` instead of `and_then(Some(...))` | +| `bool_comparison` | `if x == true` → `if x` | +| `clone_on_copy` | Remove `.clone()` for Copy types | +| `filter_next` | Use `.find()` instead | +| `option_map_unit_fn` | Use `if let` instead | +| `search_is_some` | Use `.any()` or `.contains()` | +| `unnecessary_cast` | Remove redundant casts | +| `useless_conversion` | Remove `.into()` when types match | + +## Examples + +```rust +// Before (complexity warnings) +fn find_positive(nums: &[i32]) -> Option { + let filtered: Vec<_> = nums.iter() + .cloned() + .filter(|x| *x > 0) + .collect(); + if filtered.len() == 0 { + None + } else { + Some(filtered[0]) + } +} + +// After (simplified) +fn find_positive(nums: &[i32]) -> Option { + nums.iter() + .copied() + .find(|&x| x > 0) +} +``` + +## Cognitive Load + +Complex code isn't just longer—it's harder to understand: + +```rust +// High cognitive load +let value = if x.is_some() { x.unwrap() } else { y.unwrap_or(z) }; + +// Lower cognitive load +let value = x.unwrap_or_else(|| y.unwrap_or(z)); +``` + +## See Also + +- [lint-warn-style](./lint-warn-style.md) - Style warnings +- [lint-warn-perf](./lint-warn-perf.md) - Performance warnings +- [lint-pedantic-selective](./lint-pedantic-selective.md) - Pedantic lints diff --git a/.agents/skills/rust-skills/rules/lint-warn-perf.md b/.agents/skills/rust-skills/rules/lint-warn-perf.md new file mode 100644 index 0000000..93ee454 --- /dev/null +++ b/.agents/skills/rust-skills/rules/lint-warn-perf.md @@ -0,0 +1,136 @@ +# lint-warn-perf + +> Enable clippy::perf for performance improvements + +## Why It Matters + +The `clippy::perf` lint group catches performance anti-patterns—inefficient allocations, unnecessary copies, suboptimal API usage. While not all performance issues are critical, avoiding obvious inefficiencies is good practice. + +## Configuration + +```rust +// In lib.rs or main.rs +#![warn(clippy::perf)] +``` + +Or in `Cargo.toml`: + +```toml +[lints.clippy] +perf = "warn" +``` + +## What It Catches + +### Unnecessary Allocations + +```rust +// WARN: Unnecessary to_string before into +fn take_string(s: impl Into) { } +take_string("hello".to_string()); // Just use: "hello" + +// WARN: Box::new in return with deref coercion +fn make_trait() -> Box { + Box::new(concrete) // Could use Into +} + +// WARN: Unnecessary vec! for iteration +for x in vec![1, 2, 3] { } // Use array: [1, 2, 3] +``` + +### Inefficient Operations + +```rust +// WARN: Single-character string patterns +s.starts_with("x") // Use char: 'x' +s.contains("a") // Use char: 'a' + +// WARN: iter().nth(0) instead of first() +iter.nth(0) // Use: iter.first() or iter.next() + +// WARN: Manual saturating arithmetic +if x > i32::MAX - y { i32::MAX } else { x + y } +// Use: x.saturating_add(y) +``` + +### Collection Inefficiencies + +```rust +// WARN: extend with a single element +vec.extend(std::iter::once(item)); // Use: vec.push(item) + +// WARN: Inefficient to_vec +slice.iter().cloned().collect::>() // Use: slice.to_vec() + +// WARN: Manual string concatenation +let s = format!("{}{}", a, b); // When both are &str, use: a.to_owned() + b +``` + +## Notable Lints in This Group + +| Lint | Improvement | +|------|-------------| +| `box_collection` | Use `Vec` not `Box>` | +| `iter_nth` | Use `.get(n)` or `.next()` | +| `large_enum_variant` | Box large variants | +| `manual_memcpy` | Use slice copy methods | +| `redundant_allocation` | Remove double boxing | +| `single_char_pattern` | Use `char` not `&str` | +| `slow_vector_initialization` | Use `vec![0; n]` | +| `unnecessary_to_owned` | Remove redundant `.to_owned()` | + +## Examples + +```rust +// Before (perf warnings) +fn process(input: &str) -> String { + let parts: Vec<_> = input.split(",").collect(); + let mut result = String::new(); + for part in parts.iter() { + if part.starts_with(" ") { + result = result + &part.trim().to_string(); + } + } + result +} + +// After (optimized) +fn process(input: &str) -> String { + input.split(',') + .filter(|part| part.starts_with(' ')) + .map(str::trim) + .collect() +} +``` + +## Allocation Patterns + +```rust +// Unnecessary allocation +let vec: Vec = vec![]; // Creates capacity +let vec: Vec = Vec::new(); // No allocation + +// Pre-allocation +let mut vec = Vec::with_capacity(100); // One allocation +for i in 0..100 { + vec.push(i); // No reallocation +} +``` + +## String Patterns + +```rust +// Slow: str pattern +s.contains("x"); +s.find("y"); + +// Fast: char pattern +s.contains('x'); +s.find('y'); +``` + +## See Also + +- [lint-warn-complexity](./lint-warn-complexity.md) - Complexity warnings +- [mem-with-capacity](./mem-with-capacity.md) - Pre-allocation +- [perf-profile-first](./perf-profile-first.md) - Profile before optimizing diff --git a/.agents/skills/rust-skills/rules/lint-warn-style.md b/.agents/skills/rust-skills/rules/lint-warn-style.md new file mode 100644 index 0000000..4e017cd --- /dev/null +++ b/.agents/skills/rust-skills/rules/lint-warn-style.md @@ -0,0 +1,135 @@ +# lint-warn-style + +> Enable clippy::style for idiomatic code + +## Why It Matters + +The `clippy::style` lint group enforces idiomatic Rust patterns. While not bugs, style violations make code harder to read and maintain. Consistent style helps teams work together and makes code easier to review. + +## Configuration + +```rust +// In lib.rs or main.rs +#![warn(clippy::style)] +``` + +Or in `Cargo.toml`: + +```toml +[lints.clippy] +style = "warn" +``` + +## What It Catches + +### Redundant Code + +```rust +// WARN: Redundant clone on Copy type +let x = 5; +let y = x.clone(); // Just use: let y = x; + +// WARN: Redundant closure +iter.map(|x| foo(x)) // Just use: iter.map(foo) + +// WARN: Redundant pattern matching +match result { + Ok(x) => Ok(x), + Err(e) => Err(e), +} // Just return result +``` + +### Non-Idiomatic Patterns + +```rust +// WARN: Should use if let +match option { + Some(x) => do_something(x), + None => {}, +} +// Better: if let Some(x) = option { do_something(x) } + +// WARN: Should use or_else +let value = if option.is_some() { + option.unwrap() +} else { + default() +}; +// Better: option.unwrap_or_else(default) + +// WARN: Collapsible if statements +if condition1 { + if condition2 { + do_something(); + } +} +// Better: if condition1 && condition2 { do_something() } +``` + +### Naming Issues + +```rust +// WARN: Function should not start with 'is_' returning non-bool +fn is_valid() -> i32 { 0 } // Misleading name + +// WARN: Method should not be named 'new' without returning Self +impl Foo { + fn new() -> Bar { Bar } // Confusing +} +``` + +## Notable Lints in This Group + +| Lint | Better Pattern | +|------|---------------| +| `len_zero` | Use `is_empty()` instead of `len() == 0` | +| `redundant_field_names` | Use shorthand `{ x }` not `{ x: x }` | +| `unused_unit` | Remove `-> ()` and trailing `()` | +| `collapsible_if` | Combine nested ifs with `&&` | +| `single_match` | Use `if let` instead | +| `match_like_matches_macro` | Use `matches!()` macro | +| `needless_return` | Remove explicit `return` at end | +| `question_mark` | Use `?` instead of `match` | + +## Examples + +```rust +// Before (style warnings) +fn process(data: Vec) -> Option { + if data.len() == 0 { + return None; + } + let first = match data.first() { + Some(x) => x, + None => return None, + }; + return Some(*first); +} + +// After (idiomatic) +fn process(data: Vec) -> Option { + if data.is_empty() { + return None; + } + let first = data.first()?; + Some(*first) +} +``` + +## Selective Allowance + +Some style lints may conflict with team preferences: + +```rust +// If your team prefers explicit returns +#[allow(clippy::needless_return)] +fn explicit_return() -> i32 { + return 42; +} +``` + +## See Also + +- [lint-warn-suspicious](./lint-warn-suspicious.md) - Suspicious patterns +- [lint-warn-complexity](./lint-warn-complexity.md) - Complexity warnings +- [lint-rustfmt-check](./lint-rustfmt-check.md) - Formatting checks diff --git a/.agents/skills/rust-skills/rules/lint-warn-suspicious.md b/.agents/skills/rust-skills/rules/lint-warn-suspicious.md new file mode 100644 index 0000000..2172ebc --- /dev/null +++ b/.agents/skills/rust-skills/rules/lint-warn-suspicious.md @@ -0,0 +1,122 @@ +# lint-warn-suspicious + +> Enable clippy::suspicious for likely bugs + +## Why It Matters + +The `clippy::suspicious` lint group catches code patterns that are syntactically valid but almost always wrong. These are potential bugs that deserve investigation. Enabling this group as a warning helps catch mistakes early. + +## Configuration + +```rust +// In lib.rs or main.rs +#![warn(clippy::suspicious)] +``` + +Or in `Cargo.toml`: + +```toml +[lints.clippy] +suspicious = "warn" +``` + +Or in `clippy.toml`: + +```toml +warn = ["clippy::suspicious"] +``` + +## What It Catches + +### Suspicious Arithmetic + +```rust +// WARN: Suspicious use of + in a << expression +let bits = 1 << 4 + 1; // Probably meant (1 << 4) + 1 or 1 << (4 + 1) + +// WARN: Suspicious use of | in a + expression +let value = x | 1 + y; // Probably meant (x | 1) + y or x | (1 + y) +``` + +### Suspicious Comparisons + +```rust +// WARN: Almost swapped operands in a comparison +if 5 < x && x < 3 { } // Impossible condition + +// WARN: Suspicious assignment in a condition +if (x = 5) { } // Probably meant x == 5 +``` + +### Suspicious Method Calls + +```rust +// WARN: Suspicious map usage +let _: Vec<_> = vec.iter().map(|x| { + println!("{}", x); // Side effect in map + x +}).collect(); // Use for_each instead + +// WARN: Suspicious string formatting +let s = format!("{}", format!("{}", x)); // Redundant nested format +``` + +### Suspicious Casts + +```rust +// WARN: Suspicious use of not on a bool +let inverted = !x as i32; // Did you mean (!x) as i32 or !(x as i32)? + +// WARN: Cast of float to int may lose precision +let n = 3.14_f64 as i32; // May want .round() first +``` + +## Notable Lints in This Group + +| Lint | Description | +|------|-------------| +| `suspicious_arithmetic_impl` | Unusual operator in arithmetic trait | +| `suspicious_assignment_formatting` | Looks like typo in assignment | +| `suspicious_else_formatting` | Else on wrong line | +| `suspicious_map` | Map with side effects | +| `suspicious_op_assign_impl` | Unusual op-assign implementation | +| `suspicious_splitn` | splitn that can't produce n parts | +| `suspicious_unary_op_formatting` | Confusing unary operator spacing | + +## Example Catches + +```rust +// Caught: Suspicious double negation +let value = --x; // In Rust, this is -(-x), not pre-decrement + +// Caught: Suspicious modulo +let remainder = x % 1; // Always 0 for integers + +// Caught: Suspicious else formatting +if condition { + do_something(); +} +else { // Weird formatting, might be a mistake + do_other(); +} +``` + +## When to Allow + +Rarely. If you need to suppress, document why: + +```rust +#[allow(clippy::suspicious_arithmetic_impl)] +impl Mul for Matrix { + // Custom matrix multiplication using + for reduction step + fn mul(self, rhs: Self) -> Self::Output { + // ... + } +} +``` + +## See Also + +- [lint-deny-correctness](./lint-deny-correctness.md) - Deny definite bugs +- [lint-warn-style](./lint-warn-style.md) - Style warnings +- [lint-warn-complexity](./lint-warn-complexity.md) - Complexity warnings diff --git a/.agents/skills/rust-skills/rules/lint-workspace-lints.md b/.agents/skills/rust-skills/rules/lint-workspace-lints.md new file mode 100644 index 0000000..67944ea --- /dev/null +++ b/.agents/skills/rust-skills/rules/lint-workspace-lints.md @@ -0,0 +1,172 @@ +# lint-workspace-lints + +> Configure lints at workspace level for consistent enforcement + +## Why It Matters + +Without centralized lint configuration, each crate develops its own standards (or none). Workspace-level lints (Rust 1.74+) ensure consistent code quality across all crates. Denied lints catch issues in CI before they reach production. + +## Bad + +```toml +# crate-a/Cargo.toml - strict +[lints.clippy] +unwrap_used = "deny" + +# crate-b/Cargo.toml - lenient +# No lint config + +# crate-c/Cargo.toml - different +[lints.clippy] +unwrap_used = "warn" + +# Inconsistent enforcement, some issues slip through +``` + +## Good + +```toml +# Root Cargo.toml +[workspace.lints.rust] +unsafe_code = "deny" +missing_docs = "warn" + +[workspace.lints.clippy] +# Correctness +unwrap_used = "deny" +expect_used = "warn" +panic = "deny" + +# Style +needless_pass_by_value = "warn" +redundant_clone = "warn" + +# Complexity +cognitive_complexity = "warn" + +[workspace.lints.rustdoc] +broken_intra_doc_links = "deny" + +# crate-a/Cargo.toml +[lints] +workspace = true + +# crate-b/Cargo.toml +[lints] +workspace = true +``` + +## Recommended Lint Configuration + +```toml +# Root Cargo.toml +[workspace.lints.rust] +# Safety +unsafe_code = "deny" +missing_debug_implementations = "warn" + +# Quality +unused_results = "warn" +unused_qualifications = "warn" + +[workspace.lints.clippy] +# === Correctness (deny) === +correctness = { level = "deny", priority = -1 } + +# === Suspicious (deny) === +suspicious = { level = "deny", priority = -1 } + +# === Style (warn) === +style = { level = "warn", priority = -1 } + +# === Complexity (warn) === +complexity = { level = "warn", priority = -1 } + +# === Perf (warn) === +perf = { level = "warn", priority = -1 } + +# === Pedantic (selective) === +# Not all pedantic lints are useful +doc_markdown = "warn" +needless_pass_by_value = "warn" +redundant_closure_for_method_calls = "warn" +semicolon_if_nothing_returned = "warn" + +# === Nursery (selective) === +cognitive_complexity = "warn" +useless_let_if_seq = "warn" + +# === Restriction (selective) === +unwrap_used = "deny" +expect_used = "warn" +dbg_macro = "warn" +print_stdout = "warn" # Use logging instead +todo = "warn" + +[workspace.lints.rustdoc] +broken_intra_doc_links = "deny" +private_intra_doc_links = "warn" +``` + +## Per-Crate Overrides + +```toml +# crate-with-binary/Cargo.toml +[lints] +workspace = true + +# Binary entry point can use unwrap +[lints.clippy] +unwrap_used = "allow" + +# test-utils/Cargo.toml +[lints] +workspace = true + +# Test utilities can print +[lints.clippy] +print_stdout = "allow" +``` + +## CI Integration + +```yaml +# .github/workflows/ci.yml +jobs: + lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + with: + components: clippy + + - name: Clippy + run: cargo clippy --workspace --all-targets -- -D warnings + + - name: Rustdoc + run: RUSTDOCFLAGS="-D warnings" cargo doc --workspace --no-deps +``` + +## Lint Categories + +```toml +# Category-level configuration +[workspace.lints.clippy] +# All lints in category at once +correctness = { level = "deny", priority = -1 } +suspicious = { level = "deny", priority = -1 } +style = { level = "warn", priority = -1 } +complexity = { level = "warn", priority = -1 } +perf = { level = "warn", priority = -1 } +pedantic = { level = "warn", priority = -1 } + +# Then override specific lints (higher priority) +missing_errors_doc = "allow" # Override pedantic +``` + +## See Also + +- [lint-deny-correctness](./lint-deny-correctness.md) - Critical lints +- [proj-workspace-deps](./proj-workspace-deps.md) - Workspace configuration +- [anti-unwrap-abuse](./anti-unwrap-abuse.md) - unwrap lints diff --git a/.agents/skills/rust-skills/rules/mem-arena-allocator.md b/.agents/skills/rust-skills/rules/mem-arena-allocator.md new file mode 100644 index 0000000..b5d32c3 --- /dev/null +++ b/.agents/skills/rust-skills/rules/mem-arena-allocator.md @@ -0,0 +1,168 @@ +# mem-arena-allocator + +> Use arena allocators for batch allocations + +## Why It Matters + +Arena allocators (bump allocators) allocate memory from a contiguous region, making allocation extremely fast (just bump a pointer). All allocations are freed at once when the arena is dropped. Perfect for request-scoped or parse-tree allocations. + +## Bad + +```rust +// Many small allocations during parsing +fn parse(input: &str) -> Vec { + let mut nodes = Vec::new(); + for token in tokenize(input) { + nodes.push(Box::new(Node::new(token))); // Heap alloc per node! + } + nodes +} + +// Per-request allocations add up +fn handle_request(req: Request) -> Response { + let headers = parse_headers(&req); // Allocates + let body = parse_body(&req); // Allocates + let response = generate_response(); // Allocates + // All freed individually at end + response +} +``` + +## Good + +```rust +use bumpalo::Bump; + +// All nodes allocated from same arena +fn parse<'a>(input: &str, arena: &'a Bump) -> Vec<&'a Node> { + let mut nodes = Vec::new(); + for token in tokenize(input) { + let node = arena.alloc(Node::new(token)); // Fast bump! + nodes.push(node); + } + nodes +} // Arena freed all at once + +// Per-request arena +fn handle_request(req: Request) -> Response { + let arena = Bump::new(); + + let headers = parse_headers(&req, &arena); + let body = parse_body(&req, &arena); + let response = generate_response(&arena); + + // Convert to owned response before arena drops + response.to_owned() +} // All request memory freed instantly +``` + +## Thread-Local Scratch Arena Pattern + +```rust +use bumpalo::Bump; +use std::cell::RefCell; + +thread_local! { + static SCRATCH: RefCell = RefCell::new(Bump::with_capacity(4 * 1024)); +} + +fn with_scratch(f: impl FnOnce(&Bump) -> T) -> T { + SCRATCH.with(|scratch| { + let arena = scratch.borrow(); + let result = f(&arena); + result + }) +} + +fn reset_scratch() { + SCRATCH.with(|scratch| { + scratch.borrow_mut().reset(); + }); +} + +// Usage +fn process_batch(items: &[Item]) -> Vec { + with_scratch(|arena| { + let temp_data: Vec<&TempData> = items + .iter() + .map(|item| arena.alloc(compute_temp(item))) + .collect(); + + // Use temp_data... + let result = finalize(&temp_data); + + reset_scratch(); // Reuse arena memory + result + }) +} +``` + +## Evidence from ROC Compiler + +```rust +// https://github.com/roc-lang/roc/blob/main/crates/compiler/solve/src/to_var.rs +std::thread_local! { + static SCRATCHPAD: RefCell> = + RefCell::new(Some(bumpalo::Bump::with_capacity(4 * 1024))); +} + +fn take_scratchpad() -> bumpalo::Bump { + SCRATCHPAD.with(|f| f.take().unwrap()) +} + +fn put_scratchpad(scratchpad: bumpalo::Bump) { + SCRATCHPAD.with(|f| { + f.replace(Some(scratchpad)); + }); +} +``` + +## Bumpalo Collections + +```rust +use bumpalo::Bump; +use bumpalo::collections::{Vec, String}; + +fn process<'a>(arena: &'a Bump, input: &str) -> Vec<'a, String<'a>> { + let mut results = Vec::new_in(arena); + + for word in input.split_whitespace() { + let mut s = String::new_in(arena); + s.push_str(word); + s.push_str("_processed"); + results.push(s); + } + + results // All allocated in arena +} +``` + +## When to Use Arenas + +| Situation | Use Arena? | +|-----------|-----------| +| Parsing (AST nodes) | Yes | +| Request handling | Yes | +| Batch processing | Yes | +| Long-lived data | No | +| Data escaping scope | No (or copy out) | +| Simple programs | Overkill | + +## Performance Impact + +```rust +// Benchmarks from production systems: +// - Individual allocations: ~25-50ns each +// - Arena bump: ~1-2ns each (20-50x faster) +// - Arena reset: O(1) regardless of allocation count + +// Memory overhead: +// - Arena wastes some memory (unused capacity) +// - But eliminates per-allocation metadata overhead +``` + +## See Also + +- [mem-with-capacity](mem-with-capacity.md) - Pre-allocate when size is known +- [mem-reuse-collections](mem-reuse-collections.md) - Reuse collections with clear() +- [opt-profile-first](perf-profile-first.md) - Profile to verify benefit diff --git a/.agents/skills/rust-skills/rules/mem-arrayvec.md b/.agents/skills/rust-skills/rules/mem-arrayvec.md new file mode 100644 index 0000000..76cecaf --- /dev/null +++ b/.agents/skills/rust-skills/rules/mem-arrayvec.md @@ -0,0 +1,142 @@ +# mem-arrayvec + +> Use `ArrayVec` for fixed-capacity collections that never heap-allocate + +## Why It Matters + +`ArrayVec` from the `arrayvec` crate provides Vec-like API with a compile-time maximum capacity, storing all elements inline on the stack. Unlike `SmallVec` which can spill to heap, `ArrayVec` guarantees no heap allocation—if you exceed capacity, it returns an error or panics. This is ideal for embedded systems, real-time code, or when you have a hard upper bound. + +## Bad + +```rust +// Vec always heap-allocates, even for small collections +fn parse_options(input: &str) -> Vec