Skip to content

feat(config): migrate runtime configuration from env vars to TOML#102

Merged
quiet-node merged 16 commits intomainfrom
worktree-partitioned-squishing-muffin
Apr 25, 2026
Merged

feat(config): migrate runtime configuration from env vars to TOML#102
quiet-node merged 16 commits intomainfrom
worktree-partitioned-squishing-muffin

Conversation

@quiet-node
Copy link
Copy Markdown
Owner

Summary

Replaces the .env / dotenvy / THUKI_SUPPORTED_AI_MODELS environment variable system with a structured TOML config file loaded at startup. The app now reads all runtime configuration from ~/.config/thuki/config.toml, which is seeded on first run and validated at the Rust boundary before the main window opens.

What changed

New config module (src-tauri/src/config/)

  • schema.rs — typed AppConfig struct with schema_version, [model] section (available, default), and [ui] section (max_context_length, max_attached_images). Numeric bounds enforced via Bounded<T> wrapper.
  • loader.rs — reads config.toml, seeds the file with defaults on first run, returns a typed error enum (ConfigError) instead of panicking. Corrupt-file recovery is documented (the loader rejects silently malformed TOML rather than overwriting without user knowledge).
  • writer.rs — atomic write via tmp → fsync → rename. On rename failure (cross-device, permission drift, non-empty directory) the staged tmpfile is now cleaned up so failed writes do not accumulate .tmp-<pid>-<nanos> orphans in the app-support directory.
  • defaults.rs — single source of truth for default values; previously scattered across the codebase and partially in .env.example.

Backend migration

AppConfig is loaded in run() before the Tauri builder and inserted as tauri::manage(config). All commands that previously read from std::env::var(...) now accept State<AppConfig>. The get_config command exposes the full config to the frontend over IPC.

Frontend migration

ConfigContext now calls get_config on mount and distributes typed values (maxContextLength, maxAttachedImages, availableModels, defaultModel) to the component tree. The previous hardcoded fallback model list and the THUKI_SUPPORTED_AI_MODELS env var are removed. App.tsx and AskBarView.tsx consume values from context instead of constants.

Cleanup

  • Deleted .env.example and the dotenvy dependency.
  • dotenvy::dotenv().ok() call removed from lib.rs::run().
  • docs/configurations.md fully rewritten around the TOML schema, first-run seeding, corrupt-file recovery, numeric bounds, and the three-tier rationale (user-configurable vs developer-tunable vs true constants).
  • README.md and CONTRIBUTING.md updated: "change model" instructions now point to config.toml; contributor setup no longer includes cp .env.example .env.
  • [activation] section removed from config.toml. The CGEventTap timing constants (ACTIVATION_WINDOW, ACTIVATION_COOLDOWN) are still compiled constants in activator.rs because the event-tap callback runs on a thread that cannot trivially access Tauri managed state. Having the section without any consumer would let users edit values that silently do nothing — worse than the knob not existing. Re-introduce alongside the thread-safe plumbing in a future settings-panel PR.

Scope honesty / deferred items

Two items were explicitly scoped out of this PR to keep it reviewable:

  1. Write path / settings panelset_config Tauri command, RwLock<AppConfig> state wrapper, and the UI that lets users change values without editing the file by hand. This is the natural follow-up once the read path is in production.
  2. Sandbox docker-compose alignmentsandbox/docker-compose.yml still hardcodes the model name. Users running against the Docker sandbox who change their active model will get ModelNotFound. A future PR will reconcile this by reading config.toml at bun run sandbox:start time.

Test plan

  • bun run test — all Vitest tests pass (100% coverage)
  • bun run test:backend:coverage — all Rust tests pass (100% line coverage)
  • bun run validate-build — zero warnings, zero errors
  • Fresh ~/.config/thuki/config.toml seeded on first run if file does not exist
  • Corrupt config.toml returns a typed error before the window opens
  • Model list in UI matches available array in config.toml
  • Deleting config.toml and restarting re-seeds it with defaults

Add src-tauri/src/config/ with AppConfig, ModelSection, PromptSection,
WindowSection, ActivationSection, QuoteSection. Loader reads a TOML file,
resolves empty fields to compiled defaults, clamps out-of-bounds numerics,
and composes the system-prompt slash-command appendix at load time so the
appendix is never round-tripped to disk. Writer does an atomic fsync + rename
with mode 0600. First-run seed failures surface SeedFailed; corrupt files
or unsupported schema versions are renamed and reseeded. No consumers migrated
yet; the existing SystemPrompt / ModelConfig state remains in parallel.

Depends-on design doc and eng review already in the worktree history.

Signed-off-by: Logan Nguyen <lg.131.dev@gmail.com>
Wire crate::config::load into lib.rs setup so AppConfig is resolved from the
per-user config.toml (or seeded on first run) before any Tauri command runs.
Fatal seed failures surface a native macOS alert via osascript and exit the
process, avoiding a dependency on tauri-plugin-dialog for a code path that
only runs when the user's Application Support directory is unwritable.

Add the get_config command returning a Clone of the resolved AppConfig;
the frontend hydrates its ConfigContext from this call on mount.

The existing SystemPrompt and ModelConfig state remain managed in parallel
so consumers keep working. The next commit migrates them to read from
State<AppConfig> directly.

Signed-off-by: Logan Nguyen <lg.131.dev@gmail.com>
Delete the parallel SystemPrompt, ModelConfig, load_system_prompt,
load_model_config, compose_system_prompt* helpers and the DEFAULT_OLLAMA_URL /
DEFAULT_MODEL_NAME consts. Everything now flows through config::AppConfig:

- commands::ask_ollama reads config.prompt.resolved_system, config.model.active(),
  and config.model.ollama_url.
- search::search_pipeline reads the same fields.
- history::generate_title reads the same fields.
- classify_http_error now takes the active model name so the ModelNotFound
  hint ("Run: ollama pull <name>") is accurate regardless of the user's
  override, matching the per-model error messaging we promised in the design
  doc's Issue 3A.

Test fallout: delete the ENV_LOCK static plus 7 load_model_config_* env-mutating
tests, 3 load_system_prompt_* env-mutating tests, and 2 compose_system_prompt
tests (11 total). Their semantic assertions now live in src/config/tests.rs
expressed as TOML input fixtures (resolve_whitespace_only_entries_are_filtered,
resolve_entry_whitespace_is_trimmed, resolve_empty_ollama_url_falls_back,
resolve_empty_system_prompt_uses_built_in_base_plus_appendix, and so on).
Net coverage result: 100% lines on every touched file.

lib.rs no longer manages SystemPrompt or ModelConfig state. The get_model_config
Tauri command is unregistered; get_config is now the sole model/prompt accessor
and returns the full AppConfig to the frontend.

Signed-off-by: Logan Nguyen <lg.131.dev@gmail.com>
…llback

Introduce src/contexts/ConfigContext.tsx with ConfigProvider + useConfig hook.
ConfigProvider hydrates once from invoke('get_config'), transforms the Rust
snake_case AppConfig into camelCase, and blocks children render until loaded
(sub-10ms local IPC in practice). useConfig falls back to DEFAULT_CONFIG when
no provider wraps the caller, which keeps component tests from needing a
provider wrapper each time. main.tsx wraps <App /> in ConfigProvider.

Frontend consumers now read from useConfig:
- App.tsx sources quote display limits from config.quote; DEFAULT_MODEL_FALLBACK
  literal and the stale invoke('get_model_config') bootstrap effect are deleted.
- AskBarView and ChatBubble also read quote via useConfig instead of the old
  Vite env reader in src/config/index.ts, which is deleted along with its
  test file.

Drop the model parameter from save(). Per the design doc's Codex #4 resolution,
the Rust save_conversation command reads the active model from State<AppConfig>
instead of accepting it from the frontend, so the hook signature no longer
forwards it and every save() call site drops the fallback literal.

Fix the preexisting image-count mismatch flagged by Codex #8: MAX_IMAGES
moves from 3 to 4 to match the backend MAX_IMAGES_PER_MESSAGE constant; all
associated copy ("Max 4 images", "Maximum 4 images attached") and test
fixtures (4-image arrays, 4 paste loops) are updated.

All 33 test files pass 727 tests; 100% coverage maintained across lines,
branches, functions, and statements.

Signed-off-by: Logan Nguyen <lg.131.dev@gmail.com>
Delete .env.example and the dotenvy dependency. Remove dotenvy::dotenv().ok()
from lib.rs run(). Thuki now reads all runtime config from config.toml only.

Rewrite docs/configurations.md around the TOML schema, first-run seeding
behavior, corrupt-file recovery, numeric bounds, and the three-tier rationale
(user-configurable vs developer-tunable vs true constants).

Update README.md and CONTRIBUTING.md so the "change model" instructions point
at config.toml instead of the deleted THUKI_SUPPORTED_AI_MODELS env var, and
the contributor setup drops the `cp .env.example .env` step entirely.

Update project CLAUDE.md architecture section to describe src-tauri/src/config/
as the single source of runtime configuration, and correct the activator.rs
note so it reflects reality: activation timing is a compiled const, not yet
exposed through AppConfig because the CGEventTap callback runs on a thread
that cannot trivially read Tauri managed state.

Scope honesty: remove the [activation] section from config.toml entirely.
Having the section without any consumer (activator still reads ACTIVATION_WINDOW
and ACTIVATION_COOLDOWN from its own const) would let users edit values that
do nothing, which is worse than "this knob doesn't exist yet". The section
can be re-introduced in the future settings-panel PR alongside the plumbing
needed to get the values into the event-tap thread.

All 33 frontend test files pass 720 tests; backend cargo test passes 419 tests;
100% line coverage on both sides; validate-build completes with zero warnings.

Signed-off-by: Logan Nguyen <lg.131.dev@gmail.com>
…s count

atomic_write now removes its .tmp-<pid>-<nanos> staging file when the
rename step fails (cross-device, permission drift, non-empty directory).
Without the cleanup, failed writes accumulate orphan tmpfiles in the
app-support directory. Test added: atomic_write_cleans_up_tmpfile_on_rename_failure.

quote.maxContextLength added to two useCallback dependency arrays in
App.tsx that read it but omitted it, silencing the exhaustive-deps rule.

Max-images limit corrected from 4 to 3 throughout App.test.tsx and
AskBarView.test.tsx to match the value already enforced by the component.

Signed-off-by: Logan Nguyen <lg.131.dev@gmail.com>
Promotes nine previously compiled-in search/pipeline constants to
user-configurable values in config.toml under a new [search] section.

Configurable fields:
  searxng_url, reader_url         -- service endpoints
  max_iterations, top_k_urls     -- pipeline quality/latency knobs
  search_timeout_s, reader_per_url_timeout_s, reader_batch_timeout_s,
  judge_timeout_s, router_timeout_s  -- per-service timeouts

Users who remap Docker ports, run SearXNG on a custom host, or use slow
hardware can now change these without a rebuild. A missing [search]
section falls back to the same compiled-in defaults as before, so
existing configs get zero behavior change.

Validation in loader.rs: empty URLs reset to defaults, numeric fields
clamp to sane bounds, and reader_batch_timeout_s is corrected if it
would be <= reader_per_url_timeout_s.

The SearchRuntimeConfig struct is constructed at pipeline entry from
AppConfig.search and threaded through run_agentic, searxng::search,
ReaderClient, and the DefaultRouterJudge/DefaultJudge callers.
Tests use SearchRuntimeConfig::default() which delegates to the
compiled constants, including the 1-second READER_BATCH_TIMEOUT_S in
test builds, so all existing test behavior is preserved.

Stays compiled-in (no TOML field): GAP_QUERIES_PER_ROUND (output cap,
not a prompt instruction), CHUNK_TOKEN_SIZE, TOP_K_CHUNKS (quality
knobs with non-obvious interactions), and all retry-delay constants
(circuit-breaker internals with no industry precedent to expose).

Signed-off-by: Logan Nguyen <lg.131.dev@gmail.com>
Establish src-tauri/src/config/defaults.rs as the single source of
truth for the agentic /search pipeline's runtime configuration.

- Delete the duplicate constants in search/config.rs that mirrored
  defaults.rs (MAX_ITERATIONS, TOP_K_URLS, the five timeouts, and the
  two service URLs); SearchRuntimeConfig::Default now reads from
  config::defaults so the file and binary cannot drift.
- Add SearchRuntimeConfig::from_app_config(&AppConfig) and use it from
  search_pipeline in place of an inline 9-field struct literal.
- Drop dead constants and the test-only Default ctors that hid
  parallel hardcoded URLs (SEARXNG_BASE_URL/ENDPOINT/TIMEOUT,
  ReaderClient::new and Default).
- Test builds keep a 1 s reader_batch_timeout_s via the explicit
  TEST_READER_BATCH_TIMEOUT_S override so BatchTimeout paths stay
  cheap to exercise.
- Document the [search] TOML section in docs/configurations.md with
  defaults, bounds, and an SSRF-safety note about loopback URLs.

Refresh stale identifier names in module-level comments to match the
runtime field names (max_iterations, reader_batch_timeout_s, etc.).

All gates green: clippy -D warnings, prettier, tsc, frontend + Rust
release builds, 433 backend + 721 frontend tests, backend coverage
gate exits 0 with touched files at 100%.

Signed-off-by: Logan Nguyen <lg.131.dev@gmail.com>
…ults

Moves the four live pipeline-internal constants (GAP_QUERIES_PER_ROUND,
CHUNK_TOKEN_SIZE, TOP_K_CHUNKS, READER_RETRY_DELAY_MS) from
search/config.rs into config/defaults.rs under DEFAULT_* names, making
config::defaults the single source of truth for every numeric default in
the codebase.

Also removes LLM_RETRY_DELAY_MS and SEARCH_RETRY_DELAY_MS which were
dead constants: defined in search/config.rs but never referenced outside
their own sanity-bound test.

search/config.rs is now adapter-only: SearchRuntimeConfig struct,
from_app_config, searxng_endpoint, and the test-only
TEST_READER_BATCH_TIMEOUT_S override. No raw constant definitions remain.

Signed-off-by: Logan Nguyen <lg.131.dev@gmail.com>
No version field is needed for additive schema evolution. serde's
#[serde(default)] on every section struct already handles missing
fields transparently: an older config file parses cleanly and unknown
fields are ignored, so user customizations survive any additive change
without a version check.

The version field provided no migration code; it only triggered
rename-and-reseed on any mismatch, silently wiping user customizations
whenever a future schema bump occurred.

Removes: CURRENT_SCHEMA_VERSION constant, validate_schema_version fn,
ConfigError::TooNew and ConfigError::NoMigrationYet variants, and all
associated tests and documentation.

AppConfig::Default is now derivable (clippy); replace manual impl with
#[derive(Default)].

Fixes pre-existing rustfmt violations in search/pipeline.rs and
search/reader.rs.

Signed-off-by: Logan Nguyen <lg.131.dev@gmail.com>
@quiet-node quiet-node force-pushed the worktree-partitioned-squishing-muffin branch from 8dff834 to 07c31d8 Compare April 24, 2026 23:58
Rewrite docs/configurations.md so every domain shows one table listing
all constants (tunable + baked-in) with explicit columns for
Default, Tunable?, Why not tunable, Bounds, and Description. Adds
previously undocumented [activation], [vision], and [history] domains
and inlines the pipeline-internal search constants
(GAP_QUERIES_PER_ROUND, CHUNK_TOKEN_SIZE, TOP_K_CHUNKS,
DEFAULT_READER_RETRY_DELAY_MS) alongside their tunable siblings.
Replaces the separate "What is NOT configurable" section with
per-row justifications so users can see the full picture in one place.

Signed-off-by: Logan Nguyen <lg.131.dev@gmail.com>
Promote the per-query SearXNG result cap (previously hardcoded
MAX_RESULTS=10 in search/searxng.rs) to a tunable [search] field
searxng_max_results, bounded [1, 20] and clamped by the loader.
Threaded through SearchRuntimeConfig and both pipeline call sites.

Move MAX_SNIPPET_CHARS and MAX_QUERY_CHARS to config::defaults as
DEFAULT_MAX_SNIPPET_CHARS / DEFAULT_MAX_QUERY_CHARS so defaults.rs
remains the single source of truth for every constant. Both stay
baked-in (defense-in-depth bounds against external/attacker-
controlled data) but live under one module instead of duplicated
across search/searxng.rs.

Update docs/configurations.md to add the new tunable row, the two
baked rows for snippet/query char caps, and the example TOML.

Signed-off-by: Logan Nguyen <lg.131.dev@gmail.com>
…LAUDE.md

In docs/configurations.md, append a "Raise for X; lower for Y" sentence
to every numeric tunable in the [search] table so users see the
direction of each knob's quality-vs-latency trade-off without leaving
the row. One-directional knobs (timeouts) say so explicitly: lowering
them only causes premature failures.

Add a "Configuration System" section to the worktree's CLAUDE.md
describing the single-source-of-truth rule (defaults.rs), the
schema/loader/writer layering, the subsystem RuntimeConfig projection
pattern, and a concrete checklist for adding new tunable or baked-in
constants. This unblocks future work that touches configuration
without forcing the next contributor to reverse-engineer the system.

Signed-off-by: Logan Nguyen <lg.131.dev@gmail.com>
Every domain section in docs/configurations.md now opens with a
plain-language intro, and every Description cell explains what the
constant actually does in user terms (not pipeline-internal terms).
Specifically: replace "rerank", "synthesis", "agentic gap loop",
"event-tap callback", "downstream prompt", and similar jargon with
phrasings a consumer or new developer can follow at first read.

For numeric tunables in [search], every Description ends with a
"Raise for X; lower for Y" trade-off; one-directional knobs
(timeouts) say so explicitly so users do not waste time lowering
them. Soften the "Reading the reference tables" legend (no more
"clamped"), simplify the bad-input section, and add a missing
intro to [quote]. The baked-row "Why not tunable" cells in [search]
keep their existing wording per the deliberate carve-out for the
"judge" and "synthesize" terms.

Signed-off-by: Logan Nguyen <lg.131.dev@gmail.com>
Signed-off-by: Logan Nguyen <lg.131.dev@gmail.com>
…or clarity

Signed-off-by: Logan Nguyen <lg.131.dev@gmail.com>
@quiet-node quiet-node merged commit 20abeb0 into main Apr 25, 2026
3 checks passed
@quiet-node quiet-node deleted the worktree-partitioned-squishing-muffin branch April 25, 2026 02:16
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant