Skip to content

Commit b6b3420

Browse files
authored
feat(config): add TOML config file with layered loading (#9)
## Summary Add file-based configuration via TOML, replacing the env-var-only setup with layered loading. Each config source is a partial struct with all-`Option` fields, merged field-by-field so higher-priority sources override lower ones without requiring every source to specify every value. Precedence (highest wins): env vars > project `ox.toml` > user `~/.config/ox/config.toml` > built-in defaults. - Add `config/file.rs`: `FileConfig` struct with `#[serde(deny_unknown_fields)]` for typo detection, partial merge via `Option::or()`, user config path resolution (`$XDG_CONFIG_HOME` with `~/.config` fallback), project config discovery (walk CWD upward for nearest `ox.toml`). - Update `Config::load()` to merge file config into the layering chain for all fields (`api_key`, `model`, `base_url`, `max_tokens`, `show_thinking`). - Change `env_bool` from `bool` → `Option<bool>` so unset env vars fall through to file config. Any non-empty value (even `"0"` or `"no"`) is treated as an explicit override. - Core logic extracted into parameter-accepting functions (`resolve_user_config`, `find_project_config_from`) for testability without `unsafe` env mutation (Rust 2024 edition). ## Changes | File | Description | | ---- | ----------- | | `Cargo.toml` | Add `toml = "0.8"` to workspace dependencies | | `crates/oxide-code/Cargo.toml` | Wire up `toml` | | `crates/oxide-code/src/config/file.rs` | New module: `FileConfig` (all-Option partial config with `deny_unknown_fields`), `merge`, `load` (user + project), path discovery with 17 tests | | `crates/oxide-code/src/config.rs` | Add `mod file`; update `Config::load()` to integrate file config layer; change `env_bool` to `Option<bool>` with documented semantics | | `CLAUDE.md` | Add `file.rs` to crate structure diagram | | `docs/roadmap.md` | Move shipped config items to "Working Today", keep instruction directories as remaining work | | `docs/guide/configuration.md` | Restructure to document layered config: file locations, available keys, precedence, env var ↔ config key mapping | ## Test plan - [x] `cargo fmt --all --check` — clean - [x] `cargo build` compiles cleanly - [x] `cargo clippy --all-targets -- -D warnings` — zero warnings - [x] `cargo test` — 223 tests pass (17 new) - [x] `cargo llvm-cov --ignore-filename-regex 'main\.rs'` — 87% line coverage (`config/file.rs` at 89%)
1 parent c3bb8e3 commit b6b3420

9 files changed

Lines changed: 662 additions & 42 deletions

File tree

CLAUDE.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,8 +27,9 @@ ox # Start an interactive session
2727
├── client/
2828
│ ├── anthropic.rs # Anthropic Messages API streaming client
2929
│ └── billing.rs # Billing attribution header (fingerprint, cch attestation)
30-
├── config.rs # Configuration loading (env vars, model, base URL)
30+
├── config.rs # Configuration loading and layered merging
3131
├── config/
32+
│ ├── file.rs # TOML config file discovery, parsing, and merge (user + project)
3233
│ └── oauth.rs # Claude Code OAuth credentials (macOS Keychain + file), token refresh, file locking
3334
├── main.rs # CLI entry point, agent loop, async REPL
3435
├── message.rs # Conversation message types

Cargo.lock

Lines changed: 82 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ serde_json = "1"
3535
sha2 = "0.10"
3636
tempfile = "3"
3737
time = { version = "0.3", features = ["local-offset"] }
38+
toml = "0.8"
3839
tokio = { version = "1", features = [
3940
"io-std",
4041
"io-util",

README.md

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,15 @@ oxide-code is a Rust reimplementation of Claude Code — an interactive CLI agen
1313

1414
## Status
1515

16-
Early development. See [`docs/roadmap.md`](docs/roadmap.md) for the current roadmap.
16+
Early development. What works today:
17+
18+
- Agent loop with streaming and extended thinking
19+
- File and search tools (read, write, edit, glob, grep, bash)
20+
- System prompt with CLAUDE.md / AGENTS.md injection
21+
- Authentication (API key and Claude Code OAuth)
22+
- TOML config file with layered loading
23+
24+
Next up: terminal UI. See [`docs/roadmap.md`](docs/roadmap.md) for details.
1725

1826
## Usage
1927

crates/oxide-code/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ serde_json.workspace = true
2626
sha2.workspace = true
2727
time.workspace = true
2828
tokio.workspace = true
29+
toml.workspace = true
2930
tracing.workspace = true
3031
tracing-subscriber.workspace = true
3132
xxhash-rust.workspace = true

crates/oxide-code/src/config.rs

Lines changed: 31 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
mod file;
12
mod oauth;
23

34
use anyhow::{Context, Result};
@@ -33,12 +34,19 @@ pub struct Config {
3334
}
3435

3536
impl Config {
36-
/// Load configuration.
37+
/// Load configuration from files and environment variables.
3738
///
38-
/// Auth priority: `ANTHROPIC_API_KEY` env var > Claude Code OAuth
39-
/// credentials at `~/.claude/.credentials.json`.
39+
/// Precedence (highest wins): env vars > project `ox.toml` > user
40+
/// `~/.config/ox/config.toml` > built-in defaults.
41+
///
42+
/// Auth priority: `ANTHROPIC_API_KEY` env var > `api_key` in config
43+
/// file > Claude Code OAuth credentials.
4044
pub async fn load() -> Result<Self> {
41-
let auth = if let Some(key) = non_empty_env("ANTHROPIC_API_KEY") {
45+
let fc = file::load();
46+
let client = fc.client.unwrap_or_default();
47+
let tui = fc.tui.unwrap_or_default();
48+
49+
let auth = if let Some(key) = non_empty_env("ANTHROPIC_API_KEY").or(client.api_key) {
4250
Auth::ApiKey(key)
4351
} else {
4452
let token = oauth::load_token()
@@ -47,19 +55,25 @@ impl Config {
4755
Auth::OAuth(token)
4856
};
4957

50-
let model = non_empty_env("ANTHROPIC_MODEL").unwrap_or_else(|| DEFAULT_MODEL.to_owned());
58+
let model = non_empty_env("ANTHROPIC_MODEL")
59+
.or(client.model)
60+
.unwrap_or_else(|| DEFAULT_MODEL.to_owned());
5161

52-
let base_url =
53-
non_empty_env("ANTHROPIC_BASE_URL").unwrap_or_else(|| DEFAULT_BASE_URL.to_owned());
62+
let base_url = non_empty_env("ANTHROPIC_BASE_URL")
63+
.or(client.base_url)
64+
.unwrap_or_else(|| DEFAULT_BASE_URL.to_owned());
5465

5566
let max_tokens = non_empty_env("ANTHROPIC_MAX_TOKENS")
5667
.and_then(|v| v.parse().ok())
68+
.or(client.max_tokens)
5769
.unwrap_or(DEFAULT_MAX_TOKENS);
5870

5971
// Adaptive thinking is always enabled — the model decides the budget.
6072
let thinking = Some(ThinkingConfig::Adaptive);
6173

62-
let show_thinking = env_bool("OX_SHOW_THINKING");
74+
let show_thinking = env_bool("OX_SHOW_THINKING")
75+
.or(tui.show_thinking)
76+
.unwrap_or(false);
6377

6478
Ok(Self {
6579
auth,
@@ -76,8 +90,15 @@ fn non_empty_env(key: &str) -> Option<String> {
7690
std::env::var(key).ok().filter(|v| !v.is_empty())
7791
}
7892

79-
fn env_bool(key: &str) -> bool {
80-
non_empty_env(key).is_some_and(|v| v == "1" || v == "true")
93+
/// Parse a boolean environment variable.
94+
///
95+
/// Returns `Some(true)` for `"1"` or `"true"`, `Some(false)` for any other
96+
/// non-empty value, and `None` when unset or empty. The `Some(false)` case
97+
/// is intentional: setting the variable to any value (even `"0"` or `"no"`)
98+
/// is treated as an explicit override that prevents fallthrough to config
99+
/// file values.
100+
fn env_bool(key: &str) -> Option<bool> {
101+
non_empty_env(key).map(|v| v == "1" || v == "true")
81102
}
82103

83104
#[cfg(test)]

0 commit comments

Comments
 (0)