Core Crate Testing Pattern
Extracting business logic into a standalone Rust crate without Tauri dependencies for cross-platform testing
Core Crate Testing Pattern
Tauri commands depend on Tauri's runtime (app handle, window, state management), which in turn depends on platform-specific GUI libraries (GTK on Linux, Cocoa on macOS). This makes cargo test impossible on headless CI runners, WSL2, or any environment without a display server.
The solution is to extract pure business logic into a separate core crate that has zero Tauri dependencies.
The Problem
A typical Tauri command handler looks like this:
#[tauri::command]
fn settings_get(state: State<'_, AppState>) -> Option<serde_json::Value> {
let root = state.project_root.lock().unwrap();
let path = Path::new(&*root).join(".zudotext.settings.json");
let content = fs::read_to_string(&path).ok()?;
serde_json::from_str(&content).ok()
}The business logic (read a file, parse JSON) is simple, but the function signature ties it to tauri::State. You cannot call this function without a running Tauri app.
The Core Crate Structure
The zudotext-core crate mirrors the business logic without Tauri types:
tauri- app/
core/ # zudotext- core crate
Cargo. toml
src/
lib. rs # Module declarations
settings. rs # Settings read/ write
drafts. rs # Draft management
messages. rs # Message CRUD
pins. rs # Pin directory operations
assets. rs # Asset file management
draft. rs # Single- draft operations
workspace_ registry. rs # Multi- workspace management
helpers/ # Shared utilities
src/ # Tauri crate (depends on core)
main. rs
commands/ # Tauri command handlers (thin wrappers)
state. rsCore Crate Dependencies
The core crate only depends on standard ecosystem crates:
[package]
name = "zudotext-core"
version = "0.1.0"
edition = "2021"
[dependencies]
serde = { version = "1", features = ["derive"] }
serde_json = "1"
chrono = "0.4"
dirs = "5"
base64 = "0.22"
trash = "5"
[target.'cfg(target_os = "macos")'.dependencies]
font-kit = "0.14"
[dev-dependencies]
tempfile = "3"Note
There is no tauri dependency anywhere in this crate. The font-kit dependency is conditionally compiled only on macOS for system font enumeration -- it does not pull in GUI libraries.
Pure Functions with Path Parameters
The key pattern is replacing State<'_, AppState> with a plain &str project root parameter:
Core crate (pure logic):
// core/src/settings.rs
pub fn settings_get(project_root: &str) -> Option<serde_json::Value> {
if project_root.is_empty() {
return None;
}
let settings_path = Path::new(project_root).join(".zudotext.settings.json");
let content = fs::read_to_string(&settings_path).ok()?;
serde_json::from_str(&content).ok()
}
pub fn settings_save(project_root: &str, settings: &serde_json::Value) -> bool {
if project_root.is_empty() {
return false;
}
if !settings.is_object() {
return false;
}
let settings_path = Path::new(project_root).join(".zudotext.settings.json");
match serde_json::to_string_pretty(settings) {
Ok(json) => fs::write(&settings_path, json).is_ok(),
Err(_) => false,
}
}Tauri crate (thin wrapper):
// src/commands/settings.rs
#[tauri::command]
fn settings_get(state: State<'_, AppState>) -> Option<serde_json::Value> {
let root = state.project_root.lock().unwrap();
zudotext_core::settings::settings_get(&root)
}The Tauri command handler does nothing but extract the project root from state and delegate to the core function.
Testing with tempfile
Because core functions take a path string, tests create temporary directories with known content:
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn test_settings_roundtrip() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path().to_str().unwrap();
let settings = json!({
"theme": "dark",
"draftCount": 5,
"activeDraft": 2,
"pins": [{"path": "docs", "title": "Docs"}]
});
assert!(settings_save(root, &settings));
let loaded = settings_get(root).unwrap();
assert_eq!(loaded, settings);
}
#[test]
fn test_settings_get_returns_none_for_empty_root() {
assert_eq!(settings_get(""), None);
}
#[test]
fn test_settings_save_rejects_non_object() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path().to_str().unwrap();
assert!(!settings_save(root, &json!("string")));
assert!(!settings_save(root, &json!(42)));
assert!(!settings_save(root, &json!([1, 2, 3])));
}
}These tests run with a simple cargo test -- no display server, no Tauri runtime, no GTK.
A More Complex Example: Draft Management
The drafts module shows the pattern with more complex business logic. Draft files live on disk as inbox/, inbox/, etc.:
// core/src/drafts.rs
pub fn discover_draft_count(project_root: &str) -> u32 {
let inbox = Path::new(project_root).join("inbox");
let mut max: u32 = 0;
if let Ok(entries) = fs::read_dir(&inbox) {
for entry in entries.flatten() {
if let Some(n) = parse_draft_number(entry.file_name().to_str().unwrap_or("")) {
if n > max { max = n; }
}
}
}
if max == 0 { 1 } else { max }
}
pub fn drafts_tidy_up(project_root: &str) -> Option<TidyUpResult> {
// Collect non-empty drafts, compact gaps, remove trailing files
// ...complex logic with disk I/O...
}The tests create realistic directory structures:
#[test]
fn test_drafts_tidy_up_compacts_gaps() {
let dir = setup_project(json!({"activeDraft": 4}));
let root = dir.path().to_str().unwrap();
let inbox = dir.path().join("inbox");
fs::create_dir_all(&inbox).unwrap();
fs::write(inbox.join("draft1.md"), "Draft 1").unwrap();
fs::write(inbox.join("draft2.md"), "").unwrap(); // empty
fs::write(inbox.join("draft3.md"), " \n ").unwrap(); // whitespace-only
fs::write(inbox.join("draft4.md"), "Draft 4").unwrap();
let result = drafts_tidy_up(root).unwrap();
assert_eq!(result.new_count, 2);
assert_eq!(result.new_active, 2); // draft4 moved to position 2
}Running Tests
# Run all core crate tests (works on any platform)
cd tauri-app/core && cargo test
# Run with output for debugging
cd tauri-app/core && cargo test -- --nocaptureThis works on:
macOS (development machine)
WSL2 (no display server)
GitHub Actions headless runners (no GTK)
Any Linux server
When to Use This Pattern
Warning
Not all Tauri command logic should be moved to the core crate. Keep it in the Tauri crate if the logic:
Needs the app handle (e.g., window management, menu creation)
Requires Tauri plugins (e.g., native dialogs, system tray)
Manages Tauri-specific state (e.g., watcher handles, PTY process references)
Move to the core crate when the logic:
Reads/writes files based on a project root path
Parses or validates data structures (settings, frontmatter)
Performs business logic that does not need Tauri APIs
Would benefit from testing on CI without platform dependencies
Key Takeaway
The pattern is simple: accept a path parameter instead of Tauri state, do file I/O against that path, and use tempfile in tests. The Tauri command handler becomes a one-line delegation that extracts the path from state and calls the core function. This separation pays off immediately in testing speed and CI compatibility.