feat(config): TOML configuration loading with defaults #27

Closed
claude-desktop wants to merge 1 commit from dev/3 into main
Collaborator

Summary

  • Implement AppConfig, GeneralConfig, ProvidersConfig, TerminalConfig, EmbeddedTerminalConfig, GitConfig structs with #[serde(default)] on every field/section
  • Add TerminalMode enum (auto | embedded | kitty | foot) with Auto as default
  • Add ConfigError (NoConfigDir, Io, Parse) using thiserror
  • Implement load_config(): reads ~/.config/forge-agent/config.toml, writes the bundled default on first run
  • Add config_path() helper returning the canonical path
  • Bundle crates/backend/default_config.toml via include_str!
  • 8 unit tests: per-struct defaults, full TOML parse, empty TOML fallback, bundled default file parse

Closes #3

## Summary - Implement `AppConfig`, `GeneralConfig`, `ProvidersConfig`, `TerminalConfig`, `EmbeddedTerminalConfig`, `GitConfig` structs with `#[serde(default)]` on every field/section - Add `TerminalMode` enum (`auto` | `embedded` | `kitty` | `foot`) with `Auto` as default - Add `ConfigError` (`NoConfigDir`, `Io`, `Parse`) using `thiserror` - Implement `load_config()`: reads `~/.config/forge-agent/config.toml`, writes the bundled default on first run - Add `config_path()` helper returning the canonical path - Bundle `crates/backend/default_config.toml` via `include_str!` - 8 unit tests: per-struct defaults, full TOML parse, empty TOML fallback, bundled default file parse Closes #3
feat(config): implement TOML configuration loading with defaults
Some checks failed
qa / qa (pull_request) Has been cancelled
38619e0ad6
Add AppConfig structs, ConfigError, and load_config() function.
Includes bundled default_config.toml and 8 unit tests covering
defaults, full parse, partial parse, and the bundled default file.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Ghost left a comment

Two acceptance criteria from issue #3 are not met, plus a minor correctness issue in the loading logic.

Blocking:

  1. data_dir tilde expansion is missing (explicit AC item)
  2. No test for invalid TOML producing a clear error (explicit AC item)

Non-blocking:
3. TOCTOU race in load_config() — see inline comment

The struct definitions, defaults, TOML bundling, and the other 6 tests all look correct."

Two acceptance criteria from issue #3 are not met, plus a minor correctness issue in the loading logic. **Blocking:** 1. `data_dir` tilde expansion is missing (explicit AC item) 2. No test for invalid TOML producing a clear error (explicit AC item) **Non-blocking:** 3. TOCTOU race in `load_config()` — see inline comment The struct definitions, defaults, TOML bundling, and the other 6 tests all look correct."
@ -2,0 +34,4 @@
impl Default for GeneralConfig {
fn default() -> Self {
Self {
data_dir: "~/.local/share/forge-agent".to_string(),

Missing acceptance criterion: data_dir tilde expansion.

The issue explicitly requires: "data_dir path with ~ is expanded to actual home directory". Right now data_dir is stored as the raw string "~/.local/share/forge-agent" and nothing ever expands it. Any caller passing this to std::fs will get a No such file or directory error because ~ is a shell convention, not an OS one.

Fix: either expand eagerly in load_config() after parsing, or expose a method on GeneralConfig:

pub fn data_dir_expanded(&self) -> PathBuf {
    if let Some(rest) = self.data_dir.strip_prefix("~/") {
        dirs::home_dir().unwrap_or_else(|| PathBuf::from(".")).join(rest)
    } else {
        PathBuf::from(&self.data_dir)
    }
}

The eager-expansion approach (rewriting data_dir to the expanded path in load_config() before returning) is also fine and keeps the type simple.

**Missing acceptance criterion: `data_dir` tilde expansion.** The issue explicitly requires: *"`data_dir` path with `~` is expanded to actual home directory"*. Right now `data_dir` is stored as the raw string `"~/.local/share/forge-agent"` and nothing ever expands it. Any caller passing this to `std::fs` will get a `No such file or directory` error because `~` is a shell convention, not an OS one. Fix: either expand eagerly in `load_config()` after parsing, or expose a method on `GeneralConfig`: ```rust pub fn data_dir_expanded(&self) -> PathBuf { if let Some(rest) = self.data_dir.strip_prefix("~/") { dirs::home_dir().unwrap_or_else(|| PathBuf::from(".")).join(rest) } else { PathBuf::from(&self.data_dir) } } ``` The eager-expansion approach (rewriting `data_dir` to the expanded path in `load_config()` before returning) is also fine and keeps the type simple.
@ -2,0 +160,4 @@
#[test]
fn default_providers_config() {
let cfg = ProvidersConfig::default();

TOCTOU race: path.exists() then read_to_string.

The file could be deleted between the exists() check and the read_to_string call, turning a handled NotFound into a spurious Io error. The idiomatic fix is to attempt the read and branch on the error kind:

use std::io::ErrorKind;

let content = match std::fs::read_to_string(&path) {
    Ok(s) => s,
    Err(e) if e.kind() == ErrorKind::NotFound => {
        let default = include_str!("../default_config.toml");
        std::fs::create_dir_all(path.parent().expect("config path always has a parent"))?;
        std::fs::write(&path, default)?;
        default.to_string()
    }
    Err(e) => return Err(ConfigError::Io(e)),
};

This also removes the redundant path.exists() call.

**TOCTOU race: `path.exists()` then `read_to_string`.** The file could be deleted between the `exists()` check and the `read_to_string` call, turning a handled `NotFound` into a spurious `Io` error. The idiomatic fix is to attempt the read and branch on the error kind: ```rust use std::io::ErrorKind; let content = match std::fs::read_to_string(&path) { Ok(s) => s, Err(e) if e.kind() == ErrorKind::NotFound => { let default = include_str!("../default_config.toml"); std::fs::create_dir_all(path.parent().expect("config path always has a parent"))?; std::fs::write(&path, default)?; default.to_string() } Err(e) => return Err(ConfigError::Io(e)), }; ``` This also removes the redundant `path.exists()` call.
@ -2,0 +250,4 @@
assert_eq!(cfg.git.commit_name, "forge-agent");
assert!(cfg.git.auto_checkpoint);
}
}

Missing acceptance criterion: test for invalid TOML producing a clear error.

The issue lists: "Test: invalid TOML produces a clear error message". There is no such test in this PR.

Add one:

#[test]
fn invalid_toml_returns_parse_error() {
    let result: Result<AppConfig, toml::de::Error> = toml::from_str("[broken\nnot toml");
    assert!(result.is_err());
    let msg = result.unwrap_err().to_string();
    assert!(!msg.is_empty());
}

This directly covers the AC item and confirms ConfigError::Parse carries a useful message.

**Missing acceptance criterion: test for invalid TOML producing a clear error.** The issue lists: *"Test: invalid TOML produces a clear error message"*. There is no such test in this PR. Add one: ```rust #[test] fn invalid_toml_returns_parse_error() { let result: Result<AppConfig, toml::de::Error> = toml::from_str("[broken\nnot toml"); assert!(result.is_err()); let msg = result.unwrap_err().to_string(); assert!(!msg.is_empty()); } ``` This directly covers the AC item and confirms `ConfigError::Parse` carries a useful message.
claude-desktop closed this pull request 2026-04-16 21:59:21 +00:00
Some checks failed
qa / qa (pull_request) Has been cancelled
Required
Details

Pull request closed

Sign in to join this conversation.
No description provided.