Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Config and Persistence

How flow resolves, loads, validates, and applies user configuration – with code as the single source of truth for all default values.

Design Philosophy: Code Is the Source of Truth

flow’s configuration model makes a deliberate choice: compiled Rust Default impls are the canonical default values, not any TOML file. Every config struct in src/config/types.rs carries #[serde(default)] at the container level, so serde starts with a full Default instance and then overlays only the fields present in the user’s TOML. This means a user’s flow.toml may be partial, empty, or even nested-partial – serde fills every gap automatically.

A previous design used a two-layer TOML merge (shipped defaults + user overlay) at the toml::Value level before deserialization. That model silently fell back to stale compiled-in defaults when the shipped file was absent during development – the build never copied it next to flowd.exe. Moving defaults into code eliminated that failure mode entirely.

flowchart LR
    subgraph SourceOfTruth ["Single Source of Truth"]
        D["Default impls<br/>src/config/types.rs"]
    end

    subgraph Runtime ["Runtime (serde)"]
        TOML["User's flow.toml<br/>(partial / empty / full)"]
        MERGE["serde(default)<br/>Start with Default,<br/>overlay user fields"]
        CFG["FlowConfig"]
    end

    subgraph Example ["Example File (NOT runtime)"]
        EX["default-config.toml<br/>(hand-written starter)"]
        SYNC["Sync test:<br/>default_config_toml<br/>_matches_compiled_defaults"]
    end

    D --> MERGE
    TOML --> MERGE
    MERGE --> CFG
    EX -.->|enforced by| SYNC
    SYNC -.->|must equal| D

The Dual-Edit Rule

When adding or changing a config field, you must update both:

  1. The Default impl in src/config/types.rs – this is the actual runtime default.
  2. default-config.toml in the repo root – this is the human-readable example copied to users by flow config init.

The default_config_toml_matches_compiled_defaults test (in src/config/types.rs) enforces they stay in sync: it deserializes the example file and asserts it equals FlowConfig::default(). If you change a default in code and forget the TOML, CI fails.

The example file is never read at runtime. It exists solely as a commented starter template so first-time users understand what fields are available. Users can trim it, add comments, or even empty it entirely – serde will still produce a fully valid config from code defaults.

Config File Split

Configuration lives in two separate TOML files inside the config directory:

FileStructPurpose
flow.tomlFlowConfigApplication settings: column sizing, padding, animation, minimize-restore behavior
flow-rules.tomlWindowRulesConfigWindow classification rules and default action

This separation lets users edit rules frequently (adding ignore patterns for new apps) without risking their application settings, and vice-versa. Both files are documented at src/config/types.rs.

Config Directory Resolution

The config directory is resolved by src/config/dirs.rs using a three-level priority chain:

flowchart TD
    A["CLI --config flag"] -->|highest priority| D["Resolved config dir"]
    B["FLOW_CONFIG_DIR env var"] -->|second priority| D
    C["%USERPROFILE%\\.config\\flow\\"] -->|default| D
    C -->|"fallback: %APPDATA%-derived"| E["<user>\\.config\\flow\\"]
    E -->|"fallback: CWD"| F[".\\flow\\"]

The default path %USERPROFILE%\.config\flow\ follows the XDG Base Directory convention ($XDG_CONFIG_HOME/appname/), which is well-known to developers and increasingly expected on all platforms. The older %APPDATA% path was rejected because it hides configs inside a Roaming directory that most users never browse.

If %USERPROFILE% is unset (broken service account), the module falls back to %APPDATA% with the \AppData\Roaming suffix stripped, then ultimately to the current working directory. All fallbacks are logged. The directory is created on first resolution if it does not exist.

The Four-Phase Lifecycle

Defined in src/config/lifecycle.rs, the config system follows four phases:

1. Init – init_config_dir

Creates the config directory and writes default files if they do not already exist:

  • flow.toml – the fully-commented example template (default-config.toml), including its own #:schema header.
  • flow-rules.toml – default WindowRulesConfig as TOML with a #:schema header prepended.
  • schemas/flow-config.schema.json – JSON Schema for FlowConfig.
  • schemas/flow-rules.schema.json – JSON Schema for WindowRulesConfig.

Existing files are never overwritten. This makes init_config_dir safe to call on every daemon startup.

2. Load – load_app_config / load_rules_config

Reads TOML from disk and deserializes into the respective config struct. Load functions distinguish three failure modes and propagate parse/I/O errors rather than hiding them: a missing file is benign (fresh install), but a broken file is surfaced so the user learns their config is unreadable instead of silently running on defaults.

ConditionBehavior
File not foundOk(Default) – benign (fresh install), logged at debug
Parse / I/O errorErr(FlowError::Config) carrying <path>: <reason> – caller decides policy
SuccessOk(parsed); for flow.toml, validate() logs warnings but still returns the config

A private generic core (load_toml) and a ConfigLoadError { Missing, Io, Parse } enum sit behind both loaders, so the file-backed read/parse path is shared rather than duplicated.

Load-error policy (daemon vs. client)

The two processes apply different policy because the detached daemon’s stdout/stderr are discarded – it cannot tell the user anything. So flow start runs a pre-flight check on the user’s terminal before spawning flowd, and the daemon performs its own authoritative load as a backstop:

flow.toml failureflow-rules.toml failure
flow client (pre-flight, user terminal)fatal: refuse to spawnwarning on stderr + continue
flowd daemon (authoritative load)fatal: run() Err -> exit(1)non-fatal: warn + default rules

Both call the same compiled load_app_config/load_rules_config (one crate), so the pre-flight verdict matches the daemon’s – no desync. Rules failures are non-fatal everywhere because rules are advisory and the daemon is usable with defaults. The daemon’s fatal load catches a config edit landing in the spawn race window (the pipe is never created, so flow reports a startup timeout rather than silently starting on defaults).

flowchart TD
    START["flow start"] --> PRE["preflight_config_check<br/>on user terminal"]
    PRE -->|"flow.toml Err"| REFUSE["refuse: print why + where"]
    PRE -->|"flow-rules.toml Err"| WARN["warn on stderr, continue"]
    PRE -->|"flow.toml OK"| SPAWN["spawn flowd"]
    WARN --> SPAWN
    SPAWN --> AUTH["flowd authoritative load<br/>(same compiled loaders)"]
    AUTH -->|"flow.toml OK"| RUN["daemon runs"]
    AUTH -->|"flow.toml Err in race window"| DIE["exit 1: pipe never created"]

flow config reload reuses these same loaders to hot-reload a running daemon (see Hot-reload below).

After loading flow.toml, the daemon calls FlowConfig::validate() to catch semantically invalid values (negative padding, min_column_width_px exceeding column_width). Validation failures produce warnings but do not prevent the daemon from running.

3. Validate – check_config

Used by flow config check to validate config files without loading them into the daemon. Missing files are not errors – they simply mean defaults will be used. Logs nothing (designed for pure CLI output).

4. Use – daemon subsystems

The daemon extracts fields from FlowConfig into the layout engine. The ConfigEasing enum is converted to the animation engine’s EasingStyle in the daemon/ layer (see src/config/types.rs for the design rationale on module dependency ordering).

flowchart TD
    INIT["init_config_dir<br/>Write starter files & schemas"] --> LOAD["load_app_config<br/>load_rules_config"]
    LOAD --> VALID["check_config<br/>(CLI validation pass)"]
    LOAD --> USE["Daemon subsystems<br/>Layout engine, animation, registry"]

Hot-reload – flow config reload

flow config reload sends a ReloadConfig IPC message to the running daemon, which reloads flow.toml (and flow-rules.toml) from disk and applies the live-reloadable fields to the running daemon without disturbing runtime window/workspace state.

Validate-before-apply

The handler loads + validates the new config before touching any state. A broken file or a validation failure (e.g. columns_per_screen = 0) returns a SocketResponse::Error immediately – the running daemon keeps its current config untouched, so there is nothing to roll back. The error message surfaces on the user’s terminal via flow’s send_command.

Live-reloadable vs. structural fields

Not every field can change at runtime. Reload applies live fields in place; structural fields require a daemon restart.

FieldReload effect
borders.* (color/thickness/overlap)immediate recolor/resize of all overlays (refresh_all_border_styles)
padding.window_gapre-projects every workspace’s pixel layout from its existing virtual layout – windows animate to new gaps
columns_per_screenupdates the cached threshold (no window movement; affects future auto-center)
column_width (base), min_column_width_px, min_window_height_pxupdate cached bounds; existing per-column widths preserved
animation.*WindowAnimator::update_config for the new duration/easing/enabled
Workspace count (10), monitor geometrystructural – not reloadable

What is preserved

Reload touches only the geometry constants + borders + the animation config. The user’s arranged layout is untouched: per-ScrollingSpace virtual layout (columns, rows, window order), focus, scroll viewport, monocle state; plus the daemon’s registry (window slots/states), learned history rules, and float positions. Concretely, ScrollingSpace::reconfigure rebuilds the cached MutationConfig and re-projects actual from the same virtual_layout – windows keep their column/row assignment and slide to their new pixel positions.

Rules reload is non-fatal

flow-rules.toml (classification) is reloaded in the same operation via registry.set_user_rules. It affects only new windows, so it never disrupts existing layout. Consistent with startup policy, a rules failure is non-fatal: the daemon warns and keeps the current rules; flow.toml (if valid) still reloads. This is the per-file-independent policy chosen at design time.

sequenceDiagram
    participant U as User
    participant C as flow (client)
    participant D as flowd (daemon)
    U->>C: flow config reload
    C->>D: ReloadConfig (IPC)
    D->>D: load_app_config + validate (no state touched)
    alt load/validate Err
        D-->>C: SocketResponse::Error(message)
        C-->>U: print error (nothing applied)
    else Ok
        D->>D: store config<br/>reconfigure all workspaces<br/>animate active<br/>refresh borders<br/>reload rules (non-fatal)
        D-->>C: SocketResponse::Ok
        C-->>U: "configuration reloaded"
    end

Application Config Fields

All defaults are from FlowConfig::default() in src/config/types.rs:

Field / GroupTypeDefaultDescription
columns_per_screenu324Number of columns per monitor; daemon computes pixel width at runtime
column_widthOption<u32>NoneFixed pixel-width override; skips auto-computation when set
min_column_width_pxu32640Minimum allowed column width
padding.window_gapi3216Uniform gap between all elements
padding.upi3216Top screen margin
padding.downi3216Bottom screen margin
animation.enabledbooltrueEnable layout transition animations
animation.duration_msu32240Animation duration in milliseconds
animation.easingConfigEasingease-out-expoEasing curve (31 named variants)
minimize_restore.strategyenumoriginal_slotWhere restored windows are placed

Column Sizing Model

The primary sizing mode uses columns_per_screen: the daemon computes the actual pixel width at startup as base_content_width = (monitor_width - (N+1) * window_gap) / N where N = columns_per_screen. Setting column_width to a fixed pixel value overrides this computation entirely.

Window Classification Rules

Rules are defined in flow-rules.toml and evaluated top-to-bottom, first match wins against new windows. If no rule matches, default_action (default: float) is used. See window registry for how rules feed the classification pipeline, and classification & learned rules for the whitelist model and the machine-written history-flow-rules.toml.

Match Criteria

All fields in a match clause use AND logic – unspecified fields are ignored. Three matching modes are supported:

ModeFieldsCase Sensitivity
Exactexe, title, class, process_pathexe/process_path: insensitive; title/class: sensitive
Substringtitle_containsSensitive
Regexexe_regex, title_regex, class_regex, process_path_regexexe/process_path: insensitive; title/class: sensitive (use (?i) to override)

The exe and process_path fields are case-insensitive because Windows paths are case-insensitive. Window class names and titles are application-controlled strings and matched case-sensitively.

Actions

ActionBehavior
tileManaged by the layout engine
floatFree-floating, user-positioned
ignoreExcluded from tiling entirely

Default Rules

default-flow-rules.toml in the project root ships sensible defaults for common Windows system windows (taskbar, Search UI, system dialogs, Task Manager, on-screen keyboard, Chromium legacy windows, etc.). This file is embedded into the binary at compile time via include_str! in src/config/lifecycle.rs – it is never read from disk at runtime, so it cannot be accidentally deleted or corrupted by end users. Users override these defaults through their own flow-rules.toml, which the classification pipeline checks first.

JSON Schema Generation

src/config/schema.rs generates JSON Schemas from the Rust type definitions using the schemars crate. Two schemas are produced:

  • schemas/flow-config.schema.json – for FlowConfig
  • schemas/flow-rules.schema.json – for WindowRulesConfig

These are written into a schemas/ subdirectory inside the config directory during init_config_dir. Each TOML config file carries a #:schema comment header pointing to its schema, enabling autocomplete and validation in editors that support the taplo TOML language server (VS Code, Neovim).

The schemas are generated at init time (not build time), so they always reflect the exact types compiled into the running binary. The schemas in the repo root (schemas/) are checked-in artifacts for development convenience.

Dependency Management

When adding dependencies to the config module (or anywhere in the project), use cargo add to handle versions and Cargo.toml edits. Do not hand-edit Cargo.toml for dependency changes.