| name | rust-tauri-practices |
| description | Rust + Tauri 2 best practices — auto-applied when writing Rust/Tauri code. Use when writing, reviewing, or modifying Rust or Tauri code. Triggers on ".rs", "Cargo.toml", "tauri", "rust", "Rust". |
| version | 1.0.0 |
| scope | public |
Rust + Tauri 2 Practices
Conventions and patterns for writing Rust and Tauri 2 code.
Out of Scope
- Windows/Linux platform-specific code
- Tauri mobile (Android/iOS)
- CI/CD, release signing
- Proc macros development
Rust Core Conventions
Ownership & Types
- Prefer borrowed (&str, &[T]) over owned (String, Vec<T>) for function parameters
- Use owned types only when ownership is needed
- Use Arc<T> for cross-thread sharing, Arc<Mutex<T>> for mutable sharing
- Use newtypes to wrap primitive types for semantic meaning (e.g., struct SampleRate(u32))
- Use enums for state machines, not boolean flags
- Use the type system to express invariants (NonZeroU32, Duration)
Error Handling
- Return Result<T, E>, don't panic
- Use thiserror to define domain errors
- One Error enum per module
- main/setup can use anyhow; library code uses concrete types
- Tauri command errors must impl serde::Serialize
Pattern:
#[derive(Debug, thiserror::Error)]
pub enum AudioError {
#[error("no input device available")]
NoDevice,
#[error("failed to build stream: {0}")]
StreamError(String),
}
impl serde::Serialize for AudioError {
fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
s.serialize_str(&self.to_string())
}
}
Structure & Organization
- Place impl blocks directly below struct/enum definitions
- Method grouping order: constructors -> getters -> mutations -> domain logic -> helpers
- Provide explicit constructors (new, with_config)
- Implement Display, Debug, From and other conversion traits
- Prefer pub(crate) over pub
- Each file corresponds to one module; module name reflects functionality
Control Flow
- Prefer for loops + mutable accumulators over iterator chains (better readability + clearer compile errors)
- Use let ... else for early returns, keeping the happy path unindented
- match should explicitly list all variants, don't use wildcard _
- Destructure structs with all fields listed explicitly (compiler warns when fields are added/removed)
Concurrency
- Use std::thread::spawn for short CPU-bound work
- Use tokio for I/O-bound work
- Use mpsc::channel or AtomicBool for inter-thread communication
- Don't do blocking I/O in async context; use spawn_blocking
- Mutex should protect minimal data scope; drop locks as soon as possible
Documentation
- /// doc comments for public items, third-person voice ("Returns the..." not "Return the...")
- No inline comments unless logic is non-obvious
- No TODO comments
- No commented-out code
Quality Checks
- cargo fmt for formatting
- cargo clippy --all-targets -- -D warnings
- cargo test to run all tests
- cargo check for quick iteration (when full compilation isn't needed)
Tauri 2 Conventions
Architecture Pattern
Core Process (Rust) WebView Process (JS)
├── Manage windows ├── Render UI
├── system tray ├── Listen to events
├── IPC routing └── invoke commands
├── Full OS access
└── Principle of least privilege
Commands (Request-Response)
Frontend -> Backend, with return value:
#[tauri::command]
fn get_state(state: tauri::State<'_, AppState>) -> Result<String, AppError> {
let s = state.inner().lock().map_err(|e| AppError::Lock(e.to_string()))?;
Ok(s.current.to_string())
}
tauri::Builder::default()
.manage(app_state)
.invoke_handler(tauri::generate_handler![get_state])
import { invoke } from '@tauri-apps/api/core';
const state = await invoke('get_state');
Events (Fire-and-Forget)
Backend -> Frontend, one-way notifications:
app_handle.emit("recording_state_changed", "recording")?;
import { listen } from '@tauri-apps/api/event';
const unlisten = await listen('recording_state_changed', (event) => {
updateUI(event.payload);
});
State Management
pub struct MurmurState {
pub recording: Mutex<RecordingState>,
pub engine: Mutex<Option<TranscriptionEngine>>,
}
tauri::Builder::default()
.manage(MurmurState::default())
Window Configuration
{
"windows": [{
"width": 320,
"height": 120,
"decorations": false,
"transparent": true,
"alwaysOnTop": true,
"skipTaskbar": true
}]
}
Capabilities (Tauri 2 Permission System)
{
"identifier": "default",
"windows": ["main"],
"permissions": [
"core:default",
"core:event:default"
]
}
Setup Hook
tauri::Builder::default()
.setup(|app| {
Ok(())
})
System Tray
use tauri::menu::{Menu, MenuItem};
use tauri::tray::TrayIconBuilder;
let quit = MenuItem::with_id(app, "quit", "Quit", true, None::<&str>)?;
let menu = Menu::with_items(app, &[&quit])?;
TrayIconBuilder::new()
.menu(&menu)
.on_menu_event(|app, event| {
if event.id() == "quit" {
app.exit(0);
}
})
.build(app)?;
Anti-Patterns (Don't Do This)
| Anti-Pattern | Correct Approach |
|---|
unwrap() / expect() in production code | Result + ? operator |
pub everything | pub(crate) minimal exposure |
Boolean parameter fn record(is_16khz: bool) | Enum SampleRateMode::Native / Resampled |
| Blocking I/O in async | tokio::task::spawn_blocking |
match _ => {} wildcard | Explicitly list all variants |
| Clone entire struct to pass around | Borrow or Arc |
String as function param | &str unless ownership is needed |
| Tauri command returning String error | Custom Error enum + Serialize |