feat(config): migrate runtime configuration from env vars to TOML#102
Merged
quiet-node merged 16 commits intomainfrom Apr 25, 2026
Merged
feat(config): migrate runtime configuration from env vars to TOML#102quiet-node merged 16 commits intomainfrom
quiet-node merged 16 commits intomainfrom
Conversation
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>
8dff834 to
07c31d8
Compare
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Replaces the
.env/dotenvy/THUKI_SUPPORTED_AI_MODELSenvironment 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— typedAppConfigstruct withschema_version,[model]section (available,default), and[ui]section (max_context_length,max_attached_images). Numeric bounds enforced viaBounded<T>wrapper.loader.rs— readsconfig.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 viatmp → 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
AppConfigis loaded inrun()before the Tauri builder and inserted astauri::manage(config). All commands that previously read fromstd::env::var(...)now acceptState<AppConfig>. Theget_configcommand exposes the full config to the frontend over IPC.Frontend migration
ConfigContextnow callsget_configon mount and distributes typed values (maxContextLength,maxAttachedImages,availableModels,defaultModel) to the component tree. The previous hardcoded fallback model list and theTHUKI_SUPPORTED_AI_MODELSenv var are removed.App.tsxandAskBarView.tsxconsume values from context instead of constants.Cleanup
.env.exampleand thedotenvydependency.dotenvy::dotenv().ok()call removed fromlib.rs::run().docs/configurations.mdfully 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.mdandCONTRIBUTING.mdupdated: "change model" instructions now point toconfig.toml; contributor setup no longer includescp .env.example .env.[activation]section removed fromconfig.toml. The CGEventTap timing constants (ACTIVATION_WINDOW,ACTIVATION_COOLDOWN) are still compiled constants inactivator.rsbecause 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:
set_configTauri 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.sandbox/docker-compose.ymlstill hardcodes the model name. Users running against the Docker sandbox who change their active model will getModelNotFound. A future PR will reconcile this by readingconfig.tomlatbun run sandbox:starttime.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~/.config/thuki/config.tomlseeded on first run if file does not existconfig.tomlreturns a typed error before the window opensavailablearray inconfig.tomlconfig.tomland restarting re-seeds it with defaults