# How llmfit Serializes and Reloads TUI Filter State Across Sessions

> Discover how llmfit serializes and reloads TUI filter state across sessions. Learn about JSON persistence, struct handling, and state translation for seamless user experience.

- Repository: [Alex Jones/llmfit](https://github.com/AlexsJones/llmfit)
- Tags: internals
- Published: 2026-09-12

---

**The llmfit TUI persists filter configurations to a JSON file at `~/.config/llmfit/filters.json` using the `FilterConfig` struct, which handles deserialization on startup via `load()` and serialization on exit via `save()`, while `apply_map()` and `build_map()` translate between HashMap storage and Vec<bool> UI state.**

The `llmfit` repository provides a terminal user interface for managing language model comparisons, and preserving user filter preferences between sessions requires a durable persistence layer. According to the source code in [`llmfit-tui/src/filter_config.rs`](https://github.com/AlexsJones/llmfit/blob/main/llmfit-tui/src/filter_config.rs), the application implements a robust three-stage pipeline that converts in-memory UI state to JSON and back, gracefully handling missing files or changed model data without breaking user preferences.

## The Three-Stage Persistence Pipeline

The serialization architecture operates through distinct load, apply, and save phases orchestrated by the `App` struct in [`llmfit-tui/src/tui_app.rs`](https://github.com/AlexsJones/llmfit/blob/main/llmfit-tui/src/tui_app.rs).

### Stage 1: Loading Configuration from Disk

When the TUI initializes, `App::with_specs_context_and_config` invokes `FilterConfig::load()` to retrieve previously saved preferences. The method constructs the configuration path using the `dirs` crate and attempts to deserialize the JSON contents:

```rust
pub fn load() -> Self {
    Self::config_path()
        .and_then(|p| fs::read_to_string(p).ok())
        .and_then(|s| serde_json::from_str(&s).ok())
        .unwrap_or_default()
}

```

If the file at `~/.config/llmfit/filters.json` is missing or malformed, the method returns `FilterConfig::default()`, ensuring the TUI always starts with a valid filter set. The underlying path resolution occurs in `config_path()`:

```rust
fn config_path() -> Option<PathBuf> {
    Some(dirs::config_dir()?.join("llmfit").join("filters.json"))
}

```

### Stage 2: Mapping Stored Values to UI State

After loading, the raw `FilterConfig` data must align with the runtime UI structures. Simple enum filters like `FitFilter` convert directly from stored string labels using `FitFilter::from_label`, while multi-select filters require positional mapping.

The `FilterConfig::apply_map` method synchronizes persisted `HashMap<String, bool>` entries with the UI's parallel `Vec<bool>` vectors:

```rust
pub fn apply_map(names: &[String], selected: &mut [bool], saved: &HashMap<String, bool>) {
    for (i, name) in names.iter().enumerate() {
        if let Some(&val) = saved.get(name) {
            selected[i] = val;
        }
    }
}

```

This approach accommodates dynamic model lists—if a provider name disappears from the available set, its entry in the map is simply ignored during restoration.

### Stage 3: Serializing Current State on Exit

When the user exits the TUI (triggered by `Ctrl-C` in [`llmfit-tui/src/tui_events.rs`](https://github.com/AlexsJones/llmfit/blob/main/llmfit-tui/src/tui_events.rs)), `App::save_filters` constructs a fresh `FilterConfig` instance from the current UI fields. The method uses `FilterConfig::build_map` to convert `Vec<bool>` selections back into serializable `HashMap<String, bool>` structures:

```rust
pub fn build_map(names: &[String], selected: &[bool]) -> HashMap<String, bool> {
    names.iter().zip(selected.iter())
         .map(|(n, &s)| (n.clone(), s))
         .collect()
}

```

The `save()` method then writes pretty-printed JSON to disk, creating parent directories if necessary:

```rust
pub fn save(&self) {
    if let Some(path) = Self::config_path() {
        let _ = fs::create_dir_all(path.parent().unwrap());
        if let Ok(json) = serde_json::to_string_pretty(self) {
            let _ = fs::write(path, json);
        }
    }
}

```

## Integration with the Application Lifecycle

The persistence layer integrates at critical points in the TUI lifecycle defined in [`llmfit-tui/src/tui_app.rs`](https://github.com/AlexsJones/llmfit/blob/main/llmfit-tui/src/tui_app.rs).

**Startup Restoration** occurs within `App::with_specs_context_and_config` (lines 81-109), where the code retrieves the saved fit level and restores provider, use-case, and capability selections via `apply_map`. The implementation preserves existing values like `download_dir` by reloading the current configuration before modification.

**Exit Serialization** is handled by `App::save_filters` (lines 93-122), which captures the current state of all filter widgets, reconstructs the `FilterConfig` struct, and invokes `config.save()`. This ensures every session's filter adjustments persist to the next invocation.

## Data Format and Version Tolerance

The JSON schema stores enum variants as string labels and multi-select states as name-to-boolean mappings. This design provides natural version tolerance—new filter categories added to future releases simply deserialize to `None` or default values in older saved files, while obsolete entries in existing JSON are ignored during the mapping phase.

Example saved configuration structure:

```rust
let config = FilterConfig {
    fit_filter: Some(self.fit_filter.label().to_string()),
    providers: Some(FilterConfig::build_map(&self.providers, &self.selected_providers)),
    use_cases: Some(FilterConfig::build_map(&self.use_cases, &self.selected_use_cases)),
    capabilities: Some(FilterConfig::build_map(&self.capabilities, &self.selected_capabilities)),
    download_dir: FilterConfig::load().download_dir,
};

```

## Testing Round-Trip Integrity

The codebase includes verification logic ensuring serialization fidelity. A round-trip test demonstrates saving and reloading preserves exact filter states:

```rust
#[test]
fn filter_state_roundtrip() {
    let mut original = FilterConfig::default();
    original.fit_filter = Some(FitFilter::Perfect.label().to_string());
    original.providers = Some(FilterConfig::build_map(&vec!["ollama".into()], &[true]));
    original.save();

    let loaded = FilterConfig::load();
    assert_eq!(original.fit_filter, loaded.fit_filter);
    assert_eq!(original.providers, loaded.providers);
}

```

## Summary

- **Storage Location**: Filter state persists to `~/.config/llmfit/filters.json` using platform-appropriate configuration directories via the `dirs` crate.
- **Serialization**: The `FilterConfig` struct uses `serde_json` for human-readable JSON output, with `save()` creating parent directories automatically.
- **Deserialization**: The `load()` method implements fault-tolerant parsing that defaults to empty filters on error or missing files.
- **State Mapping**: `apply_map()` and `build_map()` translate between stable `HashMap<String, bool>` storage and runtime `Vec<bool>` UI vectors, accommodating dynamic data sets.
- **Lifecycle Hooks**: [`llmfit-tui/src/tui_app.rs`](https://github.com/AlexsJones/llmfit/blob/main/llmfit-tui/src/tui_app.rs) orchestrates restoration at startup and [`llmfit-tui/src/tui_events.rs`](https://github.com/AlexsJones/llmfit/blob/main/llmfit-tui/src/tui_events.rs) triggers persistence on exit.

## Frequently Asked Questions

### Where does llmfit store the TUI filter configuration?

The application stores filter state in a JSON file located at `~/.config/llmfit/filters.json` (or the platform equivalent returned by `dirs::config_dir()`). The `FilterConfig::config_path()` method in [`llmfit-tui/src/filter_config.rs`](https://github.com/AlexsJones/llmfit/blob/main/llmfit-tui/src/filter_config.rs) constructs this path dynamically by joining the system configuration directory with the application-specific filename.

### What happens if the filters.json file is corrupted or deleted?

If `FilterConfig::load()` encounters a missing file or parsing error, it returns `FilterConfig::default()` via `unwrap_or_default()`, providing empty filters rather than crashing. This fallback behavior ensures the TUI remains usable even when the configuration is absent, malformed, or generated by an incompatible version of the application.

### How does llmfit handle filter persistence when the available models change?

The system uses name-based `HashMap` storage for multi-select filters rather than positional indices. During restoration, `apply_map()` only updates UI positions where the saved name matches the current available set, silently ignoring obsolete entries. This prevents deserialization errors when model providers are added or removed between sessions, as unknown keys in the JSON map are simply skipped during the reconciliation process.

### Which Rust crates enable the serialization in llmfit-tui?

The persistence layer relies on `serde` for derive macros and `serde_json` for JSON formatting, as specified in the project's [`Cargo.toml`](https://github.com/AlexsJones/llmfit/blob/main/Cargo.toml). The `dirs` crate resolves the appropriate configuration directory across Windows, macOS, and Linux platforms, ensuring the [`filters.json`](https://github.com/AlexsJones/llmfit/blob/main/filters.json) file writes to the correct user-specific location on each operating system.