zudo-tauri-wisdom

Type to search...

to open search from anywhere

Core Crate Testing Pattern

CreatedApr 3, 2026Takeshi Takatsudo

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.rs

Core 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/draft1.md, inbox/draft2.md, 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 -- --nocapture

This 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.