From 3ea91b45350c19a25b1b9774e05f3a23d142e5d7 Mon Sep 17 00:00:00 2001 From: Tushar Date: Tue, 24 Mar 2026 15:33:33 +0530 Subject: [PATCH 01/67] feat(forge_config): add forge configuration crate and loader --- Cargo.lock | 161 ++++++++++++++++++++++ Cargo.toml | 1 + crates/forge_config/.config.json | 0 crates/forge_config/Cargo.toml | 17 +++ crates/forge_config/src/auto_dump.rs | 24 ++++ crates/forge_config/src/forge_config.rs | 175 ++++++++++++++++++++++++ crates/forge_config/src/http_config.rs | 94 +++++++++++++ crates/forge_config/src/lib.rs | 9 ++ crates/forge_config/src/retry_config.rs | 43 ++++++ 9 files changed, 524 insertions(+) create mode 100644 crates/forge_config/.config.json create mode 100644 crates/forge_config/Cargo.toml create mode 100644 crates/forge_config/src/auto_dump.rs create mode 100644 crates/forge_config/src/forge_config.rs create mode 100644 crates/forge_config/src/http_config.rs create mode 100644 crates/forge_config/src/lib.rs create mode 100644 crates/forge_config/src/retry_config.rs diff --git a/Cargo.lock b/Cargo.lock index 0267389343..430b27feb0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -852,6 +852,26 @@ version = "0.4.31" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "75984efb6ed102a0d42db99afb6c1948f0380d1d91808d5529916e6c08b49d8d" +[[package]] +name = "config" +version = "0.15.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b30fa8254caad766fc03cb0ccae691e14bf3bd72bfff27f72802ce729551b3d6" +dependencies = [ + "async-trait", + "convert_case 0.6.0", + "json5", + "pathdiff", + "ron", + "rust-ini", + "serde-untagged", + "serde_core", + "serde_json", + "toml 0.9.8", + "winnow", + "yaml-rust2", +] + [[package]] name = "console" version = "0.15.11" @@ -876,6 +896,35 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "const-random" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87e00182fe74b066627d63b85fd550ac2998d4b0bd86bfed477a0ae4c7c71359" +dependencies = [ + "const-random-macro", +] + +[[package]] +name = "const-random-macro" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9d839f2a20b0aee515dc581a6172f2321f96cab76c1a38a4c584a194955390e" +dependencies = [ + "getrandom 0.2.16", + "once_cell", + "tiny-keccak", +] + +[[package]] +name = "convert_case" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec182b0ca2f35d8fc196cf3404988fd8b8c739a4d270ff118a398feb0cbec1ca" +dependencies = [ + "unicode-segmentation", +] + [[package]] name = "convert_case" version = "0.10.0" @@ -1510,6 +1559,15 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "dlv-list" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "442039f5147480ba31067cb00ada1adae6892028e40e45fc5de7b7df6dcc1b5f" +dependencies = [ + "const-random", +] + [[package]] name = "document-features" version = "0.2.12" @@ -1623,6 +1681,17 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" +[[package]] +name = "erased-serde" +version = "0.4.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2add8a07dd6a8d93ff627029c51de145e12686fbc36ecb298ac22e74cf02dec" +dependencies = [ + "serde", + "serde_core", + "typeid", +] + [[package]] name = "errno" version = "0.3.14" @@ -1866,6 +1935,20 @@ dependencies = [ "serde_json", ] +[[package]] +name = "forge_config" +version = "0.1.0" +dependencies = [ + "anyhow", + "config", + "derive_more", + "derive_setters", + "fake", + "pretty_assertions", + "serde", + "url", +] + [[package]] name = "forge_display" version = "0.1.0" @@ -2759,6 +2842,12 @@ version = "0.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" + [[package]] name = "hashbrown" version = "0.15.5" @@ -3478,6 +3567,17 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "json5" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96b0db21af676c1ce64250b5f40f3ce2cf27e4e47cb91ed91eb6fe9350b430c1" +dependencies = [ + "pest", + "pest_derive", + "serde", +] + [[package]] name = "jsonwebtoken" version = "10.3.0" @@ -4175,6 +4275,16 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" +[[package]] +name = "ordered-multimap" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49203cdcae0030493bad186b28da2fa25645fa276a51b6fec8010d281e02ef79" +dependencies = [ + "dlv-list", + "hashbrown 0.14.5", +] + [[package]] name = "outref" version = "0.5.2" @@ -5119,6 +5229,30 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "ron" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd490c5b18261893f14449cbd28cb9c0b637aebf161cd77900bfdedaff21ec32" +dependencies = [ + "bitflags 2.10.0", + "once_cell", + "serde", + "serde_derive", + "typeid", + "unicode-ident", +] + +[[package]] +name = "rust-ini" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "796e8d2b6696392a43bea58116b667fb4c29727dc5abd27d6acf338bb4f688c7" +dependencies = [ + "cfg-if", + "ordered-multimap", +] + [[package]] name = "rustc-hash" version = "2.1.1" @@ -5437,6 +5571,18 @@ dependencies = [ "serde_derive", ] +[[package]] +name = "serde-untagged" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9faf48a4a2d2693be24c6289dbe26552776eb7737074e6722891fadbe6c5058" +dependencies = [ + "erased-serde", + "serde", + "serde_core", + "typeid", +] + [[package]] name = "serde_core" version = "1.0.228" @@ -6269,6 +6415,15 @@ dependencies = [ "time-core", ] +[[package]] +name = "tiny-keccak" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c9d3793400a45f954c52e73d068316d76b6f4e36977e3fcebb13a2721e80237" +dependencies = [ + "crunchy", +] + [[package]] name = "tinystr" version = "0.8.2" @@ -6649,6 +6804,12 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" +[[package]] +name = "typeid" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c" + [[package]] name = "typenum" version = "1.19.0" diff --git a/Cargo.toml b/Cargo.toml index e461963898..1e68ece5df 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -157,3 +157,4 @@ forge_select = { path = "crates/forge_select" } forge_test_kit = { path = "crates/forge_test_kit" } forge_markdown_stream = { path = "crates/forge_markdown_stream" } +forge_config = { path = "crates/forge_config" } diff --git a/crates/forge_config/.config.json b/crates/forge_config/.config.json new file mode 100644 index 0000000000..e69de29bb2 diff --git a/crates/forge_config/Cargo.toml b/crates/forge_config/Cargo.toml new file mode 100644 index 0000000000..9baf68115b --- /dev/null +++ b/crates/forge_config/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "forge_config" +version = "0.1.0" +edition.workspace = true +rust-version.workspace = true + +[dependencies] +anyhow.workspace = true +config = "0.15" +derive_more.workspace = true +derive_setters.workspace = true +serde.workspace = true +url.workspace = true +fake = { version = "5.1.0", features = ["derive"] } + +[dev-dependencies] +pretty_assertions.workspace = true diff --git a/crates/forge_config/src/auto_dump.rs b/crates/forge_config/src/auto_dump.rs new file mode 100644 index 0000000000..ba396fd2a9 --- /dev/null +++ b/crates/forge_config/src/auto_dump.rs @@ -0,0 +1,24 @@ +use serde::{Deserialize, Serialize}; + +/// The output format used when auto-dumping a conversation on task completion. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, fake::Dummy)] +#[serde(rename_all = "camelCase")] +pub enum AutoDumpFormat { + /// Dump as a JSON file + Json, + /// Dump as an HTML file + Html, +} + +#[cfg(test)] +mod tests { + use pretty_assertions::assert_eq; + + use super::*; + + #[test] + fn test_auto_dump_format_variants() { + assert_eq!(AutoDumpFormat::Json, AutoDumpFormat::Json); + assert_eq!(AutoDumpFormat::Html, AutoDumpFormat::Html); + } +} diff --git a/crates/forge_config/src/forge_config.rs b/crates/forge_config/src/forge_config.rs new file mode 100644 index 0000000000..0cc9b2a5ee --- /dev/null +++ b/crates/forge_config/src/forge_config.rs @@ -0,0 +1,175 @@ +use std::path::PathBuf; + +use derive_setters::Setters; +use serde::Deserialize; +use url::Url; + +use crate::{AutoDumpFormat, HttpConfig, RetryConfig, TlsBackend}; + +/// Forge configuration containing all the fields from the Environment struct. +#[derive(Debug, Setters, Clone, PartialEq, Deserialize, fake::Dummy)] +#[serde(rename_all = "camelCase")] +#[setters(strip_option)] +pub struct ForgeConfig { + /// The operating system of the environment + pub os: String, + /// The process ID of the current process + pub pid: u32, + /// The current working directory + pub cwd: PathBuf, + /// The home directory + pub home: Option, + /// The shell being used + pub shell: String, + /// The base path relative to which everything else stored + pub base_path: PathBuf, + /// Base URL for Forge's backend APIs + #[dummy(expr = "url::Url::parse(\"https://example.com\").unwrap()")] + pub forge_api_url: Url, + /// Configuration for the retry mechanism + pub retry_config: RetryConfig, + /// The maximum number of lines returned for FSSearch + pub max_search_lines: usize, + /// Maximum bytes allowed for search results + pub max_search_result_bytes: usize, + /// Maximum characters for fetch content + pub fetch_truncation_limit: usize, + /// Maximum lines for shell output prefix + pub stdout_max_prefix_length: usize, + /// Maximum lines for shell output suffix + pub stdout_max_suffix_length: usize, + /// Maximum characters per line for shell output + pub stdout_max_line_length: usize, + /// Maximum characters per line for file read operations + pub max_line_length: usize, + /// Maximum number of lines to read from a file + pub max_read_size: u64, + /// Maximum number of files that can be read in a single batch operation + pub max_file_read_batch_size: usize, + /// HTTP configuration + pub http: HttpConfig, + /// Maximum file size in bytes for operations + pub max_file_size: u64, + /// Maximum image file size in bytes for binary read operations + pub max_image_size: u64, + /// Maximum execution time in seconds for a single tool call + pub tool_timeout: u64, + /// Whether to automatically open HTML dump files in the browser + pub auto_open_dump: bool, + /// Path where debug request files should be written + pub debug_requests: Option, + /// Custom history file path + pub custom_history_path: Option, + /// Maximum number of conversations to show in list + pub max_conversations: usize, + /// Maximum number of results to return from initial vector search + pub sem_search_limit: usize, + /// Top-k parameter for relevance filtering during semantic search + pub sem_search_top_k: usize, + /// URL for the indexing server + #[dummy(expr = "url::Url::parse(\"http://localhost:8080\").unwrap()")] + pub workspace_server_url: Url, + /// Maximum number of file extensions to include in the system prompt + pub max_extensions: usize, + /// Format for automatically creating a dump when a task is completed + pub auto_dump: Option, + /// Maximum number of files read concurrently in parallel operations + pub parallel_file_reads: usize, + /// TTL in seconds for the model API list cache + pub model_cache_ttl: u64, +} + +impl ForgeConfig { + /// Load configuration from the embedded config file using the config crate. + pub fn get() -> Result { + let config = config::Config::builder() + .add_source(config::File::from_string( + include_str!("../.config.json"), + config::FileFormat::Json, + )) + .build()?; + + config.try_deserialize() + } +} + +#[cfg(test)] +mod tests { + use pretty_assertions::assert_eq; + + use super::*; + use crate::TlsBackend; + + #[test] + fn test_forge_config_fields() { + let config = ForgeConfig { + os: "linux".to_string(), + pid: 1234, + cwd: PathBuf::from("/current/working/dir"), + home: Some(PathBuf::from("/home/user")), + shell: "zsh".to_string(), + base_path: PathBuf::from("/home/user/.forge"), + forge_api_url: "https://api.example.com".parse().unwrap(), + retry_config: RetryConfig { + initial_backoff_ms: 200, + min_delay_ms: 1000, + backoff_factor: 2, + max_retry_attempts: 8, + retry_status_codes: vec![429, 500, 502, 503, 504], + max_delay: None, + suppress_retry_errors: false, + }, + max_search_lines: 1000, + max_search_result_bytes: 10240, + fetch_truncation_limit: 50000, + stdout_max_prefix_length: 100, + stdout_max_suffix_length: 100, + stdout_max_line_length: 500, + max_line_length: 2000, + max_read_size: 2000, + max_file_read_batch_size: 50, + http: HttpConfig { + connect_timeout: 30, + read_timeout: 900, + pool_idle_timeout: 90, + pool_max_idle_per_host: 5, + max_redirects: 10, + hickory: false, + tls_backend: TlsBackend::Default, + min_tls_version: None, + max_tls_version: None, + adaptive_window: true, + keep_alive_interval: Some(60), + keep_alive_timeout: 10, + keep_alive_while_idle: true, + accept_invalid_certs: false, + root_cert_paths: None, + }, + max_file_size: 104857600, + tool_timeout: 300, + auto_open_dump: false, + debug_requests: None, + custom_history_path: None, + max_conversations: 100, + sem_search_limit: 100, + sem_search_top_k: 10, + max_image_size: 262144, + workspace_server_url: "http://localhost:8080".parse().unwrap(), + max_extensions: 15, + auto_dump: None, + parallel_file_reads: 64, + model_cache_ttl: 604_800, + }; + + assert_eq!(config.os, "linux"); + assert_eq!(config.pid, 1234); + assert_eq!(config.max_search_lines, 1000); + } + + #[test] + fn test_forge_config_get() { + let config = ForgeConfig::get().expect("Failed to load config"); + assert_eq!(config.os, "linux"); + assert_eq!(config.tool_timeout, 300); + } +} diff --git a/crates/forge_config/src/http_config.rs b/crates/forge_config/src/http_config.rs new file mode 100644 index 0000000000..d110310f49 --- /dev/null +++ b/crates/forge_config/src/http_config.rs @@ -0,0 +1,94 @@ +use serde::{Deserialize, Serialize}; + +/// TLS version enum for configuring TLS protocol versions. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, fake::Dummy)] +#[serde(rename_all = "camelCase")] +pub enum TlsVersion { + #[serde(rename = "1.0")] + V1_0, + #[serde(rename = "1.1")] + V1_1, + #[serde(rename = "1.2")] + V1_2, + #[serde(rename = "1.3")] + V1_3, +} + +/// TLS backend option. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, fake::Dummy)] +#[serde(rename_all = "camelCase")] +pub enum TlsBackend { + #[serde(rename = "default")] + Default, + #[serde(rename = "rustls")] + Rustls, +} + +/// HTTP client configuration. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, fake::Dummy)] +#[serde(rename_all = "camelCase")] +pub struct HttpConfig { + pub connect_timeout: u64, + pub read_timeout: u64, + pub pool_idle_timeout: u64, + pub pool_max_idle_per_host: usize, + pub max_redirects: usize, + pub hickory: bool, + pub tls_backend: TlsBackend, + /// Minimum TLS protocol version to use + pub min_tls_version: Option, + /// Maximum TLS protocol version to use + pub max_tls_version: Option, + /// Adaptive window sizing for improved flow control + pub adaptive_window: bool, + /// Keep-alive interval in seconds + pub keep_alive_interval: Option, + /// Keep-alive timeout in seconds + pub keep_alive_timeout: u64, + /// Keep-alive while connection is idle + pub keep_alive_while_idle: bool, + /// Accept invalid certificates + pub accept_invalid_certs: bool, + /// Paths to root certificate files + pub root_cert_paths: Option>, +} + +#[cfg(test)] +mod tests { + use pretty_assertions::assert_eq; + + use super::*; + + #[test] + fn test_http_config_fields() { + let config = HttpConfig { + connect_timeout: 30, + read_timeout: 900, + pool_idle_timeout: 90, + pool_max_idle_per_host: 5, + max_redirects: 10, + hickory: false, + tls_backend: TlsBackend::Default, + min_tls_version: None, + max_tls_version: None, + adaptive_window: true, + keep_alive_interval: Some(60), + keep_alive_timeout: 10, + keep_alive_while_idle: true, + accept_invalid_certs: false, + root_cert_paths: None, + }; + assert_eq!(config.connect_timeout, 30); + assert_eq!(config.adaptive_window, true); + } + + #[test] + fn test_tls_version_variants() { + assert_eq!(TlsVersion::V1_3, TlsVersion::V1_3); + } + + #[test] + fn test_tls_backend_variants() { + assert_eq!(TlsBackend::Default, TlsBackend::Default); + } +} diff --git a/crates/forge_config/src/lib.rs b/crates/forge_config/src/lib.rs new file mode 100644 index 0000000000..e398c32276 --- /dev/null +++ b/crates/forge_config/src/lib.rs @@ -0,0 +1,9 @@ +mod auto_dump; +mod forge_config; +mod http_config; +mod retry_config; + +pub use auto_dump::*; +pub use forge_config::*; +pub use http_config::*; +pub use retry_config::*; diff --git a/crates/forge_config/src/retry_config.rs b/crates/forge_config/src/retry_config.rs new file mode 100644 index 0000000000..38f481a3e8 --- /dev/null +++ b/crates/forge_config/src/retry_config.rs @@ -0,0 +1,43 @@ +use serde::{Deserialize, Serialize}; + +/// Configuration for retry mechanism. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, fake::Dummy)] +#[serde(rename_all = "camelCase")] +pub struct RetryConfig { + /// Initial backoff delay in milliseconds for retry operations + pub initial_backoff_ms: u64, + /// Minimum delay in milliseconds between retry attempts + pub min_delay_ms: u64, + /// Backoff multiplication factor for each retry attempt + pub backoff_factor: u64, + /// Maximum number of retry attempts + pub max_retry_attempts: usize, + /// HTTP status codes that should trigger retries + pub retry_status_codes: Vec, + /// Maximum delay between retries in seconds + pub max_delay: Option, + /// Whether to suppress retry error logging and events + pub suppress_retry_errors: bool, +} + +#[cfg(test)] +mod tests { + use pretty_assertions::assert_eq; + + use super::*; + + #[test] + fn test_retry_config_fields() { + let config = RetryConfig { + initial_backoff_ms: 200, + min_delay_ms: 1000, + backoff_factor: 2, + max_retry_attempts: 8, + retry_status_codes: vec![429, 500, 502, 503, 504, 408, 522, 520, 529], + max_delay: None, + suppress_retry_errors: false, + }; + assert_eq!(config.initial_backoff_ms, 200); + assert_eq!(config.suppress_retry_errors, false); + } +} From c6edcea9bd989612a1f808da92ef318ffe323e1d Mon Sep 17 00:00:00 2001 From: Tushar Date: Tue, 24 Mar 2026 15:40:10 +0530 Subject: [PATCH 02/67] refactor(forge_config): switch config serde to snake_case --- crates/forge_config/.config.json | 53 ++++++++++++ crates/forge_config/src/auto_dump.rs | 2 +- crates/forge_config/src/forge_config.rs | 105 +++++++++++++----------- crates/forge_config/src/http_config.rs | 28 +++---- crates/forge_config/src/retry_config.rs | 6 +- 5 files changed, 126 insertions(+), 68 deletions(-) diff --git a/crates/forge_config/.config.json b/crates/forge_config/.config.json index e69de29bb2..4e244b9605 100644 --- a/crates/forge_config/.config.json +++ b/crates/forge_config/.config.json @@ -0,0 +1,53 @@ +{ + "shell": "bash", + "forge_api_url": "https://example.com", + "retry_config": { + "initial_backoff_ms": 200, + "min_delay_ms": 1000, + "backoff_factor": 2, + "max_retry_attempts": 8, + "status_codes": [429, 500, 502, 503, 504, 408, 522, 520, 529], + "max_delay": null, + "suppress_retry_errors": false + }, + "max_search_lines": 1000, + "max_search_result_bytes": 10240, + "max_fetch_chars": 50000, + "max_stdout_prefix_lines": 100, + "max_stdout_suffix_lines": 100, + "max_stdout_line_chars": 500, + "max_line_chars": 2000, + "max_read_lines": 2000, + "max_file_read_batch_size": 50, + "http": { + "connect_timeout_secs": 30, + "read_timeout_secs": 900, + "pool_idle_timeout_secs": 90, + "pool_max_idle_per_host": 5, + "max_redirects": 10, + "hickory": false, + "tls_backend": "default", + "min_tls_version": null, + "max_tls_version": null, + "adaptive_window": true, + "keep_alive_interval_secs": 60, + "keep_alive_timeout_secs": 10, + "keep_alive_while_idle": true, + "accept_invalid_certs": false, + "root_cert_paths": null + }, + "max_file_size_bytes": 104857600, + "max_image_size_bytes": 262144, + "tool_timeout_secs": 300, + "auto_open_dump": false, + "debug_requests": null, + "custom_history_path": null, + "max_conversations": 100, + "max_sem_search_results": 100, + "sem_search_top_k": 10, + "workspace_server_url": "http://localhost:8080", + "max_extensions": 15, + "auto_dump": null, + "parallel_file_reads": 64, + "model_cache_ttl_secs": 604800 +} diff --git a/crates/forge_config/src/auto_dump.rs b/crates/forge_config/src/auto_dump.rs index ba396fd2a9..a40adf653a 100644 --- a/crates/forge_config/src/auto_dump.rs +++ b/crates/forge_config/src/auto_dump.rs @@ -2,7 +2,7 @@ use serde::{Deserialize, Serialize}; /// The output format used when auto-dumping a conversation on task completion. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, fake::Dummy)] -#[serde(rename_all = "camelCase")] +#[serde(rename_all = "snake_case")] pub enum AutoDumpFormat { /// Dump as a JSON file Json, diff --git a/crates/forge_config/src/forge_config.rs b/crates/forge_config/src/forge_config.rs index 0cc9b2a5ee..9f4ac8dbfa 100644 --- a/crates/forge_config/src/forge_config.rs +++ b/crates/forge_config/src/forge_config.rs @@ -4,25 +4,36 @@ use derive_setters::Setters; use serde::Deserialize; use url::Url; -use crate::{AutoDumpFormat, HttpConfig, RetryConfig, TlsBackend}; +use crate::{AutoDumpFormat, HttpConfig, RetryConfig}; /// Forge configuration containing all the fields from the Environment struct. +/// +/// # Field Naming Convention +/// +/// Fields follow these rules to make units and semantics unambiguous at the call-site: +/// +/// - **Unit suffixes are mandatory** for any numeric field that carries a physical unit: +/// - `_ms` — duration in milliseconds +/// - `_secs` — duration in seconds +/// - `_bytes` — size in bytes +/// - `_lines` — count of text lines +/// - `_chars` — count of characters +/// - Pure counts / dimensionless values (e.g. `max_redirects`) carry no suffix. +/// +/// - **`max_` is always a prefix**, never embedded mid-name: +/// - Correct: `max_stdout_prefix_lines` +/// - Incorrect: `stdout_max_prefix_length` +/// +/// - **No redundant struct-name prefixes inside a sub-struct**: fields inside `RetryConfig` +/// must not repeat `retry_` (e.g. use `status_codes`, not `retry_status_codes`). +/// +/// - **`_limit` is avoided**; prefer the explicit `max_` prefix + unit suffix instead. #[derive(Debug, Setters, Clone, PartialEq, Deserialize, fake::Dummy)] -#[serde(rename_all = "camelCase")] +#[serde(rename_all = "snake_case")] #[setters(strip_option)] pub struct ForgeConfig { - /// The operating system of the environment - pub os: String, - /// The process ID of the current process - pub pid: u32, - /// The current working directory - pub cwd: PathBuf, - /// The home directory - pub home: Option, /// The shell being used pub shell: String, - /// The base path relative to which everything else stored - pub base_path: PathBuf, /// Base URL for Forge's backend APIs #[dummy(expr = "url::Url::parse(\"https://example.com\").unwrap()")] pub forge_api_url: Url, @@ -33,27 +44,27 @@ pub struct ForgeConfig { /// Maximum bytes allowed for search results pub max_search_result_bytes: usize, /// Maximum characters for fetch content - pub fetch_truncation_limit: usize, + pub max_fetch_chars: usize, /// Maximum lines for shell output prefix - pub stdout_max_prefix_length: usize, + pub max_stdout_prefix_lines: usize, /// Maximum lines for shell output suffix - pub stdout_max_suffix_length: usize, + pub max_stdout_suffix_lines: usize, /// Maximum characters per line for shell output - pub stdout_max_line_length: usize, + pub max_stdout_line_chars: usize, /// Maximum characters per line for file read operations - pub max_line_length: usize, + pub max_line_chars: usize, /// Maximum number of lines to read from a file - pub max_read_size: u64, + pub max_read_lines: u64, /// Maximum number of files that can be read in a single batch operation pub max_file_read_batch_size: usize, /// HTTP configuration pub http: HttpConfig, /// Maximum file size in bytes for operations - pub max_file_size: u64, + pub max_file_size_bytes: u64, /// Maximum image file size in bytes for binary read operations - pub max_image_size: u64, + pub max_image_size_bytes: u64, /// Maximum execution time in seconds for a single tool call - pub tool_timeout: u64, + pub tool_timeout_secs: u64, /// Whether to automatically open HTML dump files in the browser pub auto_open_dump: bool, /// Path where debug request files should be written @@ -63,7 +74,7 @@ pub struct ForgeConfig { /// Maximum number of conversations to show in list pub max_conversations: usize, /// Maximum number of results to return from initial vector search - pub sem_search_limit: usize, + pub max_sem_search_results: usize, /// Top-k parameter for relevance filtering during semantic search pub sem_search_top_k: usize, /// URL for the indexing server @@ -76,14 +87,14 @@ pub struct ForgeConfig { /// Maximum number of files read concurrently in parallel operations pub parallel_file_reads: usize, /// TTL in seconds for the model API list cache - pub model_cache_ttl: u64, + pub model_cache_ttl_secs: u64, } impl ForgeConfig { /// Load configuration from the embedded config file using the config crate. pub fn get() -> Result { let config = config::Config::builder() - .add_source(config::File::from_string( + .add_source(config::File::from_str( include_str!("../.config.json"), config::FileFormat::Json, )) @@ -103,35 +114,30 @@ mod tests { #[test] fn test_forge_config_fields() { let config = ForgeConfig { - os: "linux".to_string(), - pid: 1234, - cwd: PathBuf::from("/current/working/dir"), - home: Some(PathBuf::from("/home/user")), shell: "zsh".to_string(), - base_path: PathBuf::from("/home/user/.forge"), forge_api_url: "https://api.example.com".parse().unwrap(), retry_config: RetryConfig { initial_backoff_ms: 200, min_delay_ms: 1000, backoff_factor: 2, max_retry_attempts: 8, - retry_status_codes: vec![429, 500, 502, 503, 504], + status_codes: vec![429, 500, 502, 503, 504], max_delay: None, suppress_retry_errors: false, }, max_search_lines: 1000, max_search_result_bytes: 10240, - fetch_truncation_limit: 50000, - stdout_max_prefix_length: 100, - stdout_max_suffix_length: 100, - stdout_max_line_length: 500, - max_line_length: 2000, - max_read_size: 2000, + max_fetch_chars: 50000, + max_stdout_prefix_lines: 100, + max_stdout_suffix_lines: 100, + max_stdout_line_chars: 500, + max_line_chars: 2000, + max_read_lines: 2000, max_file_read_batch_size: 50, http: HttpConfig { - connect_timeout: 30, - read_timeout: 900, - pool_idle_timeout: 90, + connect_timeout_secs: 30, + read_timeout_secs: 900, + pool_idle_timeout_secs: 90, pool_max_idle_per_host: 5, max_redirects: 10, hickory: false, @@ -139,37 +145,36 @@ mod tests { min_tls_version: None, max_tls_version: None, adaptive_window: true, - keep_alive_interval: Some(60), - keep_alive_timeout: 10, + keep_alive_interval_secs: Some(60), + keep_alive_timeout_secs: 10, keep_alive_while_idle: true, accept_invalid_certs: false, root_cert_paths: None, }, - max_file_size: 104857600, - tool_timeout: 300, + max_file_size_bytes: 104857600, + tool_timeout_secs: 300, auto_open_dump: false, debug_requests: None, custom_history_path: None, max_conversations: 100, - sem_search_limit: 100, + max_sem_search_results: 100, sem_search_top_k: 10, - max_image_size: 262144, + max_image_size_bytes: 262144, workspace_server_url: "http://localhost:8080".parse().unwrap(), max_extensions: 15, auto_dump: None, parallel_file_reads: 64, - model_cache_ttl: 604_800, + model_cache_ttl_secs: 604_800, }; - assert_eq!(config.os, "linux"); - assert_eq!(config.pid, 1234); + assert_eq!(config.shell, "zsh"); assert_eq!(config.max_search_lines, 1000); } #[test] fn test_forge_config_get() { let config = ForgeConfig::get().expect("Failed to load config"); - assert_eq!(config.os, "linux"); - assert_eq!(config.tool_timeout, 300); + assert_eq!(config.shell, "bash"); + assert_eq!(config.tool_timeout_secs, 300); } } diff --git a/crates/forge_config/src/http_config.rs b/crates/forge_config/src/http_config.rs index d110310f49..e25916d458 100644 --- a/crates/forge_config/src/http_config.rs +++ b/crates/forge_config/src/http_config.rs @@ -2,7 +2,7 @@ use serde::{Deserialize, Serialize}; /// TLS version enum for configuring TLS protocol versions. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, fake::Dummy)] -#[serde(rename_all = "camelCase")] +#[serde(rename_all = "snake_case")] pub enum TlsVersion { #[serde(rename = "1.0")] V1_0, @@ -16,7 +16,7 @@ pub enum TlsVersion { /// TLS backend option. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, fake::Dummy)] -#[serde(rename_all = "camelCase")] +#[serde(rename_all = "snake_case")] pub enum TlsBackend { #[serde(rename = "default")] Default, @@ -26,11 +26,11 @@ pub enum TlsBackend { /// HTTP client configuration. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, fake::Dummy)] -#[serde(rename_all = "camelCase")] +#[serde(rename_all = "snake_case")] pub struct HttpConfig { - pub connect_timeout: u64, - pub read_timeout: u64, - pub pool_idle_timeout: u64, + pub connect_timeout_secs: u64, + pub read_timeout_secs: u64, + pub pool_idle_timeout_secs: u64, pub pool_max_idle_per_host: usize, pub max_redirects: usize, pub hickory: bool, @@ -42,9 +42,9 @@ pub struct HttpConfig { /// Adaptive window sizing for improved flow control pub adaptive_window: bool, /// Keep-alive interval in seconds - pub keep_alive_interval: Option, + pub keep_alive_interval_secs: Option, /// Keep-alive timeout in seconds - pub keep_alive_timeout: u64, + pub keep_alive_timeout_secs: u64, /// Keep-alive while connection is idle pub keep_alive_while_idle: bool, /// Accept invalid certificates @@ -62,9 +62,9 @@ mod tests { #[test] fn test_http_config_fields() { let config = HttpConfig { - connect_timeout: 30, - read_timeout: 900, - pool_idle_timeout: 90, + connect_timeout_secs: 30, + read_timeout_secs: 900, + pool_idle_timeout_secs: 90, pool_max_idle_per_host: 5, max_redirects: 10, hickory: false, @@ -72,13 +72,13 @@ mod tests { min_tls_version: None, max_tls_version: None, adaptive_window: true, - keep_alive_interval: Some(60), - keep_alive_timeout: 10, + keep_alive_interval_secs: Some(60), + keep_alive_timeout_secs: 10, keep_alive_while_idle: true, accept_invalid_certs: false, root_cert_paths: None, }; - assert_eq!(config.connect_timeout, 30); + assert_eq!(config.connect_timeout_secs, 30); assert_eq!(config.adaptive_window, true); } diff --git a/crates/forge_config/src/retry_config.rs b/crates/forge_config/src/retry_config.rs index 38f481a3e8..469a1ac0c3 100644 --- a/crates/forge_config/src/retry_config.rs +++ b/crates/forge_config/src/retry_config.rs @@ -2,7 +2,7 @@ use serde::{Deserialize, Serialize}; /// Configuration for retry mechanism. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, fake::Dummy)] -#[serde(rename_all = "camelCase")] +#[serde(rename_all = "snake_case")] pub struct RetryConfig { /// Initial backoff delay in milliseconds for retry operations pub initial_backoff_ms: u64, @@ -13,7 +13,7 @@ pub struct RetryConfig { /// Maximum number of retry attempts pub max_retry_attempts: usize, /// HTTP status codes that should trigger retries - pub retry_status_codes: Vec, + pub status_codes: Vec, /// Maximum delay between retries in seconds pub max_delay: Option, /// Whether to suppress retry error logging and events @@ -33,7 +33,7 @@ mod tests { min_delay_ms: 1000, backoff_factor: 2, max_retry_attempts: 8, - retry_status_codes: vec![429, 500, 502, 503, 504, 408, 522, 520, 529], + status_codes: vec![429, 500, 502, 503, 504, 408, 522, 520, 529], max_delay: None, suppress_retry_errors: false, }; From 3da57540fa0ab1f30b1b5bc3fe89d9fe7fe426ad Mon Sep 17 00:00:00 2001 From: Tushar Date: Tue, 24 Mar 2026 15:48:50 +0530 Subject: [PATCH 03/67] refactor(forge_config): load global config with oncecell panic-on-fail --- crates/forge_config/.config.json | 4 +- crates/forge_config/src/forge_config.rs | 120 +++++++----------------- 2 files changed, 35 insertions(+), 89 deletions(-) diff --git a/crates/forge_config/.config.json b/crates/forge_config/.config.json index 4e244b9605..f21c322137 100644 --- a/crates/forge_config/.config.json +++ b/crates/forge_config/.config.json @@ -1,6 +1,4 @@ { - "shell": "bash", - "forge_api_url": "https://example.com", "retry_config": { "initial_backoff_ms": 200, "min_delay_ms": 1000, @@ -45,7 +43,7 @@ "max_conversations": 100, "max_sem_search_results": 100, "sem_search_top_k": 10, - "workspace_server_url": "http://localhost:8080", + "workspace_server_url": "https://api.forgecode.dev/", "max_extensions": 15, "auto_dump": null, "parallel_file_reads": 64, diff --git a/crates/forge_config/src/forge_config.rs b/crates/forge_config/src/forge_config.rs index 9f4ac8dbfa..6defa06235 100644 --- a/crates/forge_config/src/forge_config.rs +++ b/crates/forge_config/src/forge_config.rs @@ -1,4 +1,5 @@ use std::path::PathBuf; +use std::sync::OnceLock; use derive_setters::Setters; use serde::Deserialize; @@ -10,33 +11,33 @@ use crate::{AutoDumpFormat, HttpConfig, RetryConfig}; /// /// # Field Naming Convention /// -/// Fields follow these rules to make units and semantics unambiguous at the call-site: +/// Fields follow these rules to make units and semantics unambiguous at the +/// call-site: /// -/// - **Unit suffixes are mandatory** for any numeric field that carries a physical unit: +/// - **Unit suffixes are mandatory** for any numeric field that carries a +/// physical unit: /// - `_ms` — duration in milliseconds /// - `_secs` — duration in seconds /// - `_bytes` — size in bytes /// - `_lines` — count of text lines /// - `_chars` — count of characters -/// - Pure counts / dimensionless values (e.g. `max_redirects`) carry no suffix. +/// - Pure counts / dimensionless values (e.g. `max_redirects`) carry no +/// suffix. /// /// - **`max_` is always a prefix**, never embedded mid-name: /// - Correct: `max_stdout_prefix_lines` /// - Incorrect: `stdout_max_prefix_length` /// -/// - **No redundant struct-name prefixes inside a sub-struct**: fields inside `RetryConfig` -/// must not repeat `retry_` (e.g. use `status_codes`, not `retry_status_codes`). +/// - **No redundant struct-name prefixes inside a sub-struct**: fields inside +/// `RetryConfig` must not repeat `retry_` (e.g. use `status_codes`, not +/// `retry_status_codes`). /// -/// - **`_limit` is avoided**; prefer the explicit `max_` prefix + unit suffix instead. +/// - **`_limit` is avoided**; prefer the explicit `max_` prefix + unit suffix +/// instead. #[derive(Debug, Setters, Clone, PartialEq, Deserialize, fake::Dummy)] #[serde(rename_all = "snake_case")] #[setters(strip_option)] pub struct ForgeConfig { - /// The shell being used - pub shell: String, - /// Base URL for Forge's backend APIs - #[dummy(expr = "url::Url::parse(\"https://example.com\").unwrap()")] - pub forge_api_url: Url, /// Configuration for the retry mechanism pub retry_config: RetryConfig, /// The maximum number of lines returned for FSSearch @@ -90,91 +91,38 @@ pub struct ForgeConfig { pub model_cache_ttl_secs: u64, } +static CONFIG: OnceLock = OnceLock::new(); + impl ForgeConfig { - /// Load configuration from the embedded config file using the config crate. - pub fn get() -> Result { - let config = config::Config::builder() - .add_source(config::File::from_str( - include_str!("../.config.json"), - config::FileFormat::Json, - )) - .build()?; + /// Get the global ForgeConfig instance, loading from the embedded config + /// file on first access. + /// + /// # Panics + /// + /// Panics if the configuration cannot be loaded. + pub fn get() -> &'static ForgeConfig { + CONFIG.get_or_init(|| { + let config = config::Config::builder() + .add_source(config::File::from_str( + include_str!("../.config.json"), + config::FileFormat::Json, + )) + .build() + .expect("Failed to build config"); - config.try_deserialize() + config + .try_deserialize() + .expect("Failed to deserialize config") + }) } } #[cfg(test)] mod tests { - use pretty_assertions::assert_eq; - use super::*; - use crate::TlsBackend; - - #[test] - fn test_forge_config_fields() { - let config = ForgeConfig { - shell: "zsh".to_string(), - forge_api_url: "https://api.example.com".parse().unwrap(), - retry_config: RetryConfig { - initial_backoff_ms: 200, - min_delay_ms: 1000, - backoff_factor: 2, - max_retry_attempts: 8, - status_codes: vec![429, 500, 502, 503, 504], - max_delay: None, - suppress_retry_errors: false, - }, - max_search_lines: 1000, - max_search_result_bytes: 10240, - max_fetch_chars: 50000, - max_stdout_prefix_lines: 100, - max_stdout_suffix_lines: 100, - max_stdout_line_chars: 500, - max_line_chars: 2000, - max_read_lines: 2000, - max_file_read_batch_size: 50, - http: HttpConfig { - connect_timeout_secs: 30, - read_timeout_secs: 900, - pool_idle_timeout_secs: 90, - pool_max_idle_per_host: 5, - max_redirects: 10, - hickory: false, - tls_backend: TlsBackend::Default, - min_tls_version: None, - max_tls_version: None, - adaptive_window: true, - keep_alive_interval_secs: Some(60), - keep_alive_timeout_secs: 10, - keep_alive_while_idle: true, - accept_invalid_certs: false, - root_cert_paths: None, - }, - max_file_size_bytes: 104857600, - tool_timeout_secs: 300, - auto_open_dump: false, - debug_requests: None, - custom_history_path: None, - max_conversations: 100, - max_sem_search_results: 100, - sem_search_top_k: 10, - max_image_size_bytes: 262144, - workspace_server_url: "http://localhost:8080".parse().unwrap(), - max_extensions: 15, - auto_dump: None, - parallel_file_reads: 64, - model_cache_ttl_secs: 604_800, - }; - - assert_eq!(config.shell, "zsh"); - assert_eq!(config.max_search_lines, 1000); - } #[test] fn test_forge_config_get() { - let config = ForgeConfig::get().expect("Failed to load config"); - assert_eq!(config.shell, "bash"); - assert_eq!(config.tool_timeout_secs, 300); + let _ = ForgeConfig::get(); } } From c1b73e627dc88b874580800096fce95dfa1b9cbe Mon Sep 17 00:00:00 2001 From: Tushar Date: Tue, 24 Mar 2026 16:07:17 +0530 Subject: [PATCH 04/67] feat(forge_config): load user config from home directory --- Cargo.lock | 1 + crates/forge_config/Cargo.toml | 1 + crates/forge_config/src/forge_config.rs | 15 +++++++++++++-- 3 files changed, 15 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 430b27feb0..791f2de308 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1943,6 +1943,7 @@ dependencies = [ "config", "derive_more", "derive_setters", + "dirs", "fake", "pretty_assertions", "serde", diff --git a/crates/forge_config/Cargo.toml b/crates/forge_config/Cargo.toml index 9baf68115b..9c0fa0c00b 100644 --- a/crates/forge_config/Cargo.toml +++ b/crates/forge_config/Cargo.toml @@ -9,6 +9,7 @@ anyhow.workspace = true config = "0.15" derive_more.workspace = true derive_setters.workspace = true +dirs.workspace = true serde.workspace = true url.workspace = true fake = { version = "5.1.0", features = ["derive"] } diff --git a/crates/forge_config/src/forge_config.rs b/crates/forge_config/src/forge_config.rs index 6defa06235..75fdea74d5 100644 --- a/crates/forge_config/src/forge_config.rs +++ b/crates/forge_config/src/forge_config.rs @@ -2,6 +2,7 @@ use std::path::PathBuf; use std::sync::OnceLock; use derive_setters::Setters; +use dirs; use serde::Deserialize; use url::Url; @@ -102,11 +103,21 @@ impl ForgeConfig { /// Panics if the configuration cannot be loaded. pub fn get() -> &'static ForgeConfig { CONFIG.get_or_init(|| { - let config = config::Config::builder() + let mut builder = config::Config::builder() .add_source(config::File::from_str( include_str!("../.config.json"), config::FileFormat::Json, - )) + )); + + // Add user config from home directory if it exists + if let Some(config_dir) = dirs::home_dir() { + let user_config_path = config_dir.join("forge").join(".config.json"); + if user_config_path.exists() { + builder = builder.add_source(config::File::from(user_config_path)); + } + } + + let config = builder .build() .expect("Failed to build config"); From a15ffe02b1d70ad6118dec51d7e2b1e72dbc4f08 Mon Sep 17 00:00:00 2001 From: Tushar Date: Tue, 24 Mar 2026 16:13:22 +0530 Subject: [PATCH 05/67] refactor(forge_config): rename retry and parallel read config fields --- crates/forge_config/.config.json | 10 +++++----- crates/forge_config/src/forge_config.rs | 4 ++-- crates/forge_config/src/retry_config.rs | 14 +++++++------- 3 files changed, 14 insertions(+), 14 deletions(-) diff --git a/crates/forge_config/.config.json b/crates/forge_config/.config.json index f21c322137..319f4a7d23 100644 --- a/crates/forge_config/.config.json +++ b/crates/forge_config/.config.json @@ -1,12 +1,12 @@ { - "retry_config": { + "retry": { "initial_backoff_ms": 200, "min_delay_ms": 1000, "backoff_factor": 2, - "max_retry_attempts": 8, + "max_attempts": 8, "status_codes": [429, 500, 502, 503, 504, 408, 522, 520, 529], - "max_delay": null, - "suppress_retry_errors": false + "max_delay_secs": null, + "suppress_errors": false }, "max_search_lines": 1000, "max_search_result_bytes": 10240, @@ -46,6 +46,6 @@ "workspace_server_url": "https://api.forgecode.dev/", "max_extensions": 15, "auto_dump": null, - "parallel_file_reads": 64, + "max_parallel_file_reads": 64, "model_cache_ttl_secs": 604800 } diff --git a/crates/forge_config/src/forge_config.rs b/crates/forge_config/src/forge_config.rs index 75fdea74d5..0287098cd2 100644 --- a/crates/forge_config/src/forge_config.rs +++ b/crates/forge_config/src/forge_config.rs @@ -40,7 +40,7 @@ use crate::{AutoDumpFormat, HttpConfig, RetryConfig}; #[setters(strip_option)] pub struct ForgeConfig { /// Configuration for the retry mechanism - pub retry_config: RetryConfig, + pub retry: RetryConfig, /// The maximum number of lines returned for FSSearch pub max_search_lines: usize, /// Maximum bytes allowed for search results @@ -87,7 +87,7 @@ pub struct ForgeConfig { /// Format for automatically creating a dump when a task is completed pub auto_dump: Option, /// Maximum number of files read concurrently in parallel operations - pub parallel_file_reads: usize, + pub max_parallel_file_reads: usize, /// TTL in seconds for the model API list cache pub model_cache_ttl_secs: u64, } diff --git a/crates/forge_config/src/retry_config.rs b/crates/forge_config/src/retry_config.rs index 469a1ac0c3..c5fc8fa6b6 100644 --- a/crates/forge_config/src/retry_config.rs +++ b/crates/forge_config/src/retry_config.rs @@ -11,13 +11,13 @@ pub struct RetryConfig { /// Backoff multiplication factor for each retry attempt pub backoff_factor: u64, /// Maximum number of retry attempts - pub max_retry_attempts: usize, + pub max_attempts: usize, /// HTTP status codes that should trigger retries pub status_codes: Vec, /// Maximum delay between retries in seconds - pub max_delay: Option, + pub max_delay_secs: Option, /// Whether to suppress retry error logging and events - pub suppress_retry_errors: bool, + pub suppress_errors: bool, } #[cfg(test)] @@ -32,12 +32,12 @@ mod tests { initial_backoff_ms: 200, min_delay_ms: 1000, backoff_factor: 2, - max_retry_attempts: 8, + max_attempts: 8, status_codes: vec![429, 500, 502, 503, 504, 408, 522, 520, 529], - max_delay: None, - suppress_retry_errors: false, + max_delay_secs: None, + suppress_errors: false, }; assert_eq!(config.initial_backoff_ms, 200); - assert_eq!(config.suppress_retry_errors, false); + assert_eq!(config.suppress_errors, false); } } From db68f4c473d6b8cd2b924a58577a5205f27b66eb Mon Sep 17 00:00:00 2001 From: Tushar Date: Tue, 24 Mar 2026 16:23:10 +0530 Subject: [PATCH 06/67] refactor(forge_config): add provider and model selection fields --- crates/forge_config/src/forge_config.rs | 33 ++++++++++++- crates/forge_config/src/lib.rs | 2 + crates/forge_config/src/model_config.rs | 61 +++++++++++++++++++++++++ 3 files changed, 95 insertions(+), 1 deletion(-) create mode 100644 crates/forge_config/src/model_config.rs diff --git a/crates/forge_config/src/forge_config.rs b/crates/forge_config/src/forge_config.rs index 0287098cd2..6154d78b23 100644 --- a/crates/forge_config/src/forge_config.rs +++ b/crates/forge_config/src/forge_config.rs @@ -1,3 +1,4 @@ +use std::collections::HashMap; use std::path::PathBuf; use std::sync::OnceLock; @@ -6,7 +7,7 @@ use dirs; use serde::Deserialize; use url::Url; -use crate::{AutoDumpFormat, HttpConfig, RetryConfig}; +use crate::{AutoDumpFormat, HttpConfig, ModelConfig, ModelId, ProviderId, RetryConfig}; /// Forge configuration containing all the fields from the Environment struct. /// @@ -90,6 +91,36 @@ pub struct ForgeConfig { pub max_parallel_file_reads: usize, /// TTL in seconds for the model API list cache pub model_cache_ttl_secs: u64, + /// Default provider ID to use for AI operations + #[serde(default)] + pub provider: Option, + /// Map of provider ID to model ID for per-provider model selection + #[serde(default)] + pub model: HashMap, + /// Provider and model to use for commit message generation + #[serde(default)] + pub commit: Option, + /// Provider and model to use for shell command suggestion generation + #[serde(default)] + pub suggest: Option, + /// API key for Forge authentication + #[serde(default)] + pub api_key: Option, + /// Display name of the API key + #[serde(default)] + pub api_key_name: Option, + /// Masked representation of the API key for display purposes + #[serde(default)] + pub api_key_masked: Option, + /// Email address associated with the Forge account + #[serde(default)] + pub email: Option, + /// Display name of the authenticated user + #[serde(default)] + pub name: Option, + /// Identifier of the authentication provider used for login + #[serde(default)] + pub auth_provider_id: Option, } static CONFIG: OnceLock = OnceLock::new(); diff --git a/crates/forge_config/src/lib.rs b/crates/forge_config/src/lib.rs index e398c32276..246e4aef1d 100644 --- a/crates/forge_config/src/lib.rs +++ b/crates/forge_config/src/lib.rs @@ -1,9 +1,11 @@ mod auto_dump; mod forge_config; mod http_config; +mod model_config; mod retry_config; pub use auto_dump::*; pub use forge_config::*; pub use http_config::*; +pub use model_config::*; pub use retry_config::*; diff --git a/crates/forge_config/src/model_config.rs b/crates/forge_config/src/model_config.rs new file mode 100644 index 0000000000..31e48a7cd0 --- /dev/null +++ b/crates/forge_config/src/model_config.rs @@ -0,0 +1,61 @@ +use derive_more::{AsRef, Deref, Display, From}; +use serde::{Deserialize, Serialize}; + +/// A newtype wrapper for a provider identifier string. +#[derive( + Debug, + Clone, + PartialEq, + Eq, + Hash, + Serialize, + Deserialize, + Display, + From, + AsRef, + Deref, + fake::Dummy, +)] +pub struct ProviderId(String); + +impl ProviderId { + /// Creates a new `ProviderId` from the given string value. + pub fn new(value: impl Into) -> Self { + Self(value.into()) + } +} + +/// A newtype wrapper for a model identifier string. +#[derive( + Debug, + Clone, + PartialEq, + Eq, + Hash, + Serialize, + Deserialize, + Display, + From, + AsRef, + Deref, + fake::Dummy, +)] +pub struct ModelId(String); + +impl ModelId { + /// Creates a new `ModelId` from the given string value. + pub fn new(value: impl Into) -> Self { + Self(value.into()) + } +} + +/// Pairs a provider and model together for a specific operation. +#[derive(Debug, Clone, PartialEq, Deserialize, fake::Dummy)] +pub struct ModelConfig { + /// The provider to use for this operation. + #[serde(rename = "provider")] + pub provider_id: ProviderId, + /// The model to use for this operation. + #[serde(rename = "model")] + pub model_id: ModelId, +} From ad171e2aed06c5c8be7eeac3f1f5cb9812596bce Mon Sep 17 00:00:00 2001 From: Tushar Date: Tue, 24 Mar 2026 16:34:34 +0530 Subject: [PATCH 07/67] feat(forge_config): add FORGE env var overrides for config --- crates/forge_config/src/forge_config.rs | 22 ++++++++++++++-------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/crates/forge_config/src/forge_config.rs b/crates/forge_config/src/forge_config.rs index 6154d78b23..22fd928001 100644 --- a/crates/forge_config/src/forge_config.rs +++ b/crates/forge_config/src/forge_config.rs @@ -134,11 +134,10 @@ impl ForgeConfig { /// Panics if the configuration cannot be loaded. pub fn get() -> &'static ForgeConfig { CONFIG.get_or_init(|| { - let mut builder = config::Config::builder() - .add_source(config::File::from_str( - include_str!("../.config.json"), - config::FileFormat::Json, - )); + let mut builder = config::Config::builder().add_source(config::File::from_str( + include_str!("../.config.json"), + config::FileFormat::Json, + )); // Add user config from home directory if it exists if let Some(config_dir) = dirs::home_dir() { @@ -148,9 +147,16 @@ impl ForgeConfig { } } - let config = builder - .build() - .expect("Failed to build config"); + // Add environment variable overrides with FORGE__ prefix. + // Double underscore separates nested keys, e.g. + // FORGE__MAX_SEARCH_LINES overrides `max_search_lines`. + builder = builder.add_source( + config::Environment::with_prefix("FORGE") + .separator("_") + .try_parsing(true), + ); + + let config = builder.build().expect("Failed to build config"); config .try_deserialize() From a5f8b28a49ec4a9215a444ac5309dc6e24028b69 Mon Sep 17 00:00:00 2001 From: Tushar Date: Tue, 24 Mar 2026 16:39:00 +0530 Subject: [PATCH 08/67] refactor(forge_config): switch config loading from json to toml --- Cargo.lock | 1 + Cargo.toml | 1 + crates/forge_config/.config.json | 51 ------------------------- crates/forge_config/Cargo.toml | 3 +- crates/forge_config/src/forge_config.rs | 6 +-- 5 files changed, 7 insertions(+), 55 deletions(-) delete mode 100644 crates/forge_config/.config.json diff --git a/Cargo.lock b/Cargo.lock index 791f2de308..bc2b218668 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1947,6 +1947,7 @@ dependencies = [ "fake", "pretty_assertions", "serde", + "toml_edit", "url", ] diff --git a/Cargo.toml b/Cargo.toml index 1e68ece5df..60a52ef73a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -91,6 +91,7 @@ tempfile = "3.27.0" termimad = "0.34.1" syntect = { version = "5", default-features = false, features = ["default-syntaxes", "default-themes", "regex-onig"] } thiserror = "2.0.18" +toml_edit = { version = "0.22", features = ["serde"] } tokio = { version = "1.50.0", features = [ "macros", "rt-multi-thread", diff --git a/crates/forge_config/.config.json b/crates/forge_config/.config.json deleted file mode 100644 index 319f4a7d23..0000000000 --- a/crates/forge_config/.config.json +++ /dev/null @@ -1,51 +0,0 @@ -{ - "retry": { - "initial_backoff_ms": 200, - "min_delay_ms": 1000, - "backoff_factor": 2, - "max_attempts": 8, - "status_codes": [429, 500, 502, 503, 504, 408, 522, 520, 529], - "max_delay_secs": null, - "suppress_errors": false - }, - "max_search_lines": 1000, - "max_search_result_bytes": 10240, - "max_fetch_chars": 50000, - "max_stdout_prefix_lines": 100, - "max_stdout_suffix_lines": 100, - "max_stdout_line_chars": 500, - "max_line_chars": 2000, - "max_read_lines": 2000, - "max_file_read_batch_size": 50, - "http": { - "connect_timeout_secs": 30, - "read_timeout_secs": 900, - "pool_idle_timeout_secs": 90, - "pool_max_idle_per_host": 5, - "max_redirects": 10, - "hickory": false, - "tls_backend": "default", - "min_tls_version": null, - "max_tls_version": null, - "adaptive_window": true, - "keep_alive_interval_secs": 60, - "keep_alive_timeout_secs": 10, - "keep_alive_while_idle": true, - "accept_invalid_certs": false, - "root_cert_paths": null - }, - "max_file_size_bytes": 104857600, - "max_image_size_bytes": 262144, - "tool_timeout_secs": 300, - "auto_open_dump": false, - "debug_requests": null, - "custom_history_path": null, - "max_conversations": 100, - "max_sem_search_results": 100, - "sem_search_top_k": 10, - "workspace_server_url": "https://api.forgecode.dev/", - "max_extensions": 15, - "auto_dump": null, - "max_parallel_file_reads": 64, - "model_cache_ttl_secs": 604800 -} diff --git a/crates/forge_config/Cargo.toml b/crates/forge_config/Cargo.toml index 9c0fa0c00b..19ccbab46d 100644 --- a/crates/forge_config/Cargo.toml +++ b/crates/forge_config/Cargo.toml @@ -6,7 +6,8 @@ rust-version.workspace = true [dependencies] anyhow.workspace = true -config = "0.15" +config = { version = "0.15", features = ["toml"] } +toml_edit.workspace = true derive_more.workspace = true derive_setters.workspace = true dirs.workspace = true diff --git a/crates/forge_config/src/forge_config.rs b/crates/forge_config/src/forge_config.rs index 22fd928001..ec50ee12fb 100644 --- a/crates/forge_config/src/forge_config.rs +++ b/crates/forge_config/src/forge_config.rs @@ -135,13 +135,13 @@ impl ForgeConfig { pub fn get() -> &'static ForgeConfig { CONFIG.get_or_init(|| { let mut builder = config::Config::builder().add_source(config::File::from_str( - include_str!("../.config.json"), - config::FileFormat::Json, + include_str!("../.config.toml"), + config::FileFormat::Toml, )); // Add user config from home directory if it exists if let Some(config_dir) = dirs::home_dir() { - let user_config_path = config_dir.join("forge").join(".config.json"); + let user_config_path = config_dir.join("forge").join(".config.toml"); if user_config_path.exists() { builder = builder.add_source(config::File::from(user_config_path)); } From 26cd9c94e9b2ea367331be60f294442735df7256 Mon Sep 17 00:00:00 2001 From: Tushar Date: Tue, 24 Mar 2026 16:39:48 +0530 Subject: [PATCH 09/67] chore(forge_config): add default toml config for tool limits --- crates/forge_config/.config.toml | 42 +++++++++++++++++++ .../src/{forge_config.rs => config.rs} | 0 2 files changed, 42 insertions(+) create mode 100644 crates/forge_config/.config.toml rename crates/forge_config/src/{forge_config.rs => config.rs} (100%) diff --git a/crates/forge_config/.config.toml b/crates/forge_config/.config.toml new file mode 100644 index 0000000000..cdcc8e663e --- /dev/null +++ b/crates/forge_config/.config.toml @@ -0,0 +1,42 @@ +max_search_lines = 1000 +max_search_result_bytes = 10240 +max_fetch_chars = 50000 +max_stdout_prefix_lines = 100 +max_stdout_suffix_lines = 100 +max_stdout_line_chars = 500 +max_line_chars = 2000 +max_read_lines = 2000 +max_file_read_batch_size = 50 +max_file_size_bytes = 104857600 +max_image_size_bytes = 262144 +tool_timeout_secs = 300 +auto_open_dump = false +max_conversations = 100 +max_sem_search_results = 100 +sem_search_top_k = 10 +workspace_server_url = "https://api.forgecode.dev/" +max_extensions = 15 +max_parallel_file_reads = 64 +model_cache_ttl_secs = 604800 + +[retry] +initial_backoff_ms = 200 +min_delay_ms = 1000 +backoff_factor = 2 +max_attempts = 8 +status_codes = [429, 500, 502, 503, 504, 408, 522, 520, 529] +suppress_errors = false + +[http] +connect_timeout_secs = 30 +read_timeout_secs = 900 +pool_idle_timeout_secs = 90 +pool_max_idle_per_host = 5 +max_redirects = 10 +hickory = false +tls_backend = "default" +adaptive_window = true +keep_alive_interval_secs = 60 +keep_alive_timeout_secs = 10 +keep_alive_while_idle = true +accept_invalid_certs = false diff --git a/crates/forge_config/src/forge_config.rs b/crates/forge_config/src/config.rs similarity index 100% rename from crates/forge_config/src/forge_config.rs rename to crates/forge_config/src/config.rs From 23819a9e2d7a11a13d4a731af21ce583a28a68d6 Mon Sep 17 00:00:00 2001 From: Tushar Date: Tue, 24 Mar 2026 16:56:48 +0530 Subject: [PATCH 10/67] refactor(forge_config): rename forge_config module to config --- crates/forge_config/src/lib.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/forge_config/src/lib.rs b/crates/forge_config/src/lib.rs index 246e4aef1d..4d45b3b701 100644 --- a/crates/forge_config/src/lib.rs +++ b/crates/forge_config/src/lib.rs @@ -1,11 +1,11 @@ mod auto_dump; -mod forge_config; +mod config; mod http_config; mod model_config; mod retry_config; pub use auto_dump::*; -pub use forge_config::*; +pub use config::*; pub use http_config::*; pub use model_config::*; pub use retry_config::*; From d6cd99e0e6544fa4c428cec1773642371701e965 Mon Sep 17 00:00:00 2001 From: Tushar Date: Tue, 24 Mar 2026 17:04:02 +0530 Subject: [PATCH 11/67] refactor(forge_config): switch to async config reader/writer --- Cargo.lock | 4 +-- crates/forge_config/Cargo.toml | 4 +-- crates/forge_config/src/config.rs | 57 ++++++++----------------------- crates/forge_config/src/error.rs | 15 ++++++++ crates/forge_config/src/lib.rs | 7 ++++ crates/forge_config/src/reader.rs | 23 +++++++++++++ crates/forge_config/src/writer.rs | 24 +++++++++++++ 7 files changed, 87 insertions(+), 47 deletions(-) create mode 100644 crates/forge_config/src/error.rs create mode 100644 crates/forge_config/src/reader.rs create mode 100644 crates/forge_config/src/writer.rs diff --git a/Cargo.lock b/Cargo.lock index bc2b218668..23dd854c5d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1939,15 +1939,15 @@ dependencies = [ name = "forge_config" version = "0.1.0" dependencies = [ - "anyhow", "config", "derive_more", "derive_setters", "dirs", "fake", + "merge", "pretty_assertions", "serde", - "toml_edit", + "thiserror 2.0.18", "url", ] diff --git a/crates/forge_config/Cargo.toml b/crates/forge_config/Cargo.toml index 19ccbab46d..fc389e2263 100644 --- a/crates/forge_config/Cargo.toml +++ b/crates/forge_config/Cargo.toml @@ -5,9 +5,9 @@ edition.workspace = true rust-version.workspace = true [dependencies] -anyhow.workspace = true +merge.workspace = true +thiserror.workspace = true config = { version = "0.15", features = ["toml"] } -toml_edit.workspace = true derive_more.workspace = true derive_setters.workspace = true dirs.workspace = true diff --git a/crates/forge_config/src/config.rs b/crates/forge_config/src/config.rs index ec50ee12fb..cc7abc6b39 100644 --- a/crates/forge_config/src/config.rs +++ b/crates/forge_config/src/config.rs @@ -1,13 +1,14 @@ use std::collections::HashMap; use std::path::PathBuf; -use std::sync::OnceLock; use derive_setters::Setters; -use dirs; use serde::Deserialize; use url::Url; -use crate::{AutoDumpFormat, HttpConfig, ModelConfig, ModelId, ProviderId, RetryConfig}; +use crate::{ + AutoDumpFormat, HttpConfig, ModelConfig, ModelId, ProviderId, RetryConfig, + reader::ConfigReader, writer::ConfigWriter, +}; /// Forge configuration containing all the fields from the Environment struct. /// @@ -123,8 +124,6 @@ pub struct ForgeConfig { pub auth_provider_id: Option, } -static CONFIG: OnceLock = OnceLock::new(); - impl ForgeConfig { /// Get the global ForgeConfig instance, loading from the embedded config /// file on first access. @@ -132,45 +131,17 @@ impl ForgeConfig { /// # Panics /// /// Panics if the configuration cannot be loaded. - pub fn get() -> &'static ForgeConfig { - CONFIG.get_or_init(|| { - let mut builder = config::Config::builder().add_source(config::File::from_str( - include_str!("../.config.toml"), - config::FileFormat::Toml, - )); - - // Add user config from home directory if it exists - if let Some(config_dir) = dirs::home_dir() { - let user_config_path = config_dir.join("forge").join(".config.toml"); - if user_config_path.exists() { - builder = builder.add_source(config::File::from(user_config_path)); - } - } - - // Add environment variable overrides with FORGE__ prefix. - // Double underscore separates nested keys, e.g. - // FORGE__MAX_SEARCH_LINES overrides `max_search_lines`. - builder = builder.add_source( - config::Environment::with_prefix("FORGE") - .separator("_") - .try_parsing(true), - ); - - let config = builder.build().expect("Failed to build config"); - - config - .try_deserialize() - .expect("Failed to deserialize config") - }) + pub async fn read() -> ForgeConfig { + ConfigReader::new().read().await } -} - -#[cfg(test)] -mod tests { - use super::*; - #[test] - fn test_forge_config_get() { - let _ = ForgeConfig::get(); + /// Writes the configuration to the user config file. + /// + /// # Errors + /// + /// Returns an error if the configuration cannot be serialized or written to + /// disk. + pub async fn write(&self) -> crate::Result<()> { + ConfigWriter::new(self.clone()).write().await } } diff --git a/crates/forge_config/src/error.rs b/crates/forge_config/src/error.rs new file mode 100644 index 0000000000..f3529d5b93 --- /dev/null +++ b/crates/forge_config/src/error.rs @@ -0,0 +1,15 @@ +/// Errors produced by the `forge_config` crate. +#[derive(Debug, thiserror::Error)] +pub enum Error { + /// Failed to read or parse configuration from a file or environment. + #[error("Config error: {0}")] + Config(#[from] config::ConfigError), + + /// Failed to serialize or write configuration. + #[error("Serialization error: {0}")] + Serialization(String), + + /// An I/O error occurred while reading or writing configuration files. + #[error("IO error: {0}")] + Io(#[from] std::io::Error), +} diff --git a/crates/forge_config/src/lib.rs b/crates/forge_config/src/lib.rs index 4d45b3b701..83d4320f66 100644 --- a/crates/forge_config/src/lib.rs +++ b/crates/forge_config/src/lib.rs @@ -1,11 +1,18 @@ mod auto_dump; mod config; +mod error; mod http_config; mod model_config; +mod reader; mod retry_config; +mod writer; pub use auto_dump::*; pub use config::*; +pub use error::Error; pub use http_config::*; pub use model_config::*; pub use retry_config::*; + +/// A `Result` type alias for this crate's [`Error`] type. +pub type Result = std::result::Result; diff --git a/crates/forge_config/src/reader.rs b/crates/forge_config/src/reader.rs new file mode 100644 index 0000000000..8d887d2861 --- /dev/null +++ b/crates/forge_config/src/reader.rs @@ -0,0 +1,23 @@ +use crate::ForgeConfig; + +/// Reads and merges [`ForgeConfig`] from multiple sources: embedded defaults, +/// home directory file, current working directory file, and environment +/// variables. +pub struct ConfigReader {} + +impl ConfigReader { + /// Creates a new `ConfigReader`. + pub fn new() -> Self { + Self {} + } + + /// Reads and merges configuration from all sources, returning the resolved + /// [`ForgeConfig`]. + /// + /// # Panics + /// + /// Panics if the embedded default configuration cannot be parsed. + pub async fn read(&self) -> ForgeConfig { + todo!("implement multi-source config loading") + } +} diff --git a/crates/forge_config/src/writer.rs b/crates/forge_config/src/writer.rs new file mode 100644 index 0000000000..adaaf112d1 --- /dev/null +++ b/crates/forge_config/src/writer.rs @@ -0,0 +1,24 @@ +use crate::ForgeConfig; + +/// Writes a [`ForgeConfig`] to the user configuration file on disk. +pub struct ConfigWriter { + config: ForgeConfig, +} + +impl ConfigWriter { + /// Creates a new `ConfigWriter` for the given configuration. + pub fn new(config: ForgeConfig) -> Self { + Self { config } + } + + /// Serializes and writes the configuration to the user config file. + /// + /// # Errors + /// + /// Returns an error if the configuration cannot be serialized or the file + /// cannot be written. + pub async fn write(&self) -> crate::Result<()> { + let _ = &self.config; + Ok(()) + } +} From c8404a83041470ddce92a084a4459973d0b0c230 Mon Sep 17 00:00:00 2001 From: Tushar Date: Tue, 24 Mar 2026 17:16:37 +0530 Subject: [PATCH 12/67] feat(forge_config): add merge strategies to config structs --- crates/forge_config/src/config.rs | 41 +++++++++++++++++++++++-- crates/forge_config/src/http_config.rs | 18 ++++++++++- crates/forge_config/src/lib.rs | 1 + crates/forge_config/src/model_config.rs | 5 ++- crates/forge_config/src/reader.rs | 12 +++++++- crates/forge_config/src/retry_config.rs | 10 +++++- 6 files changed, 81 insertions(+), 6 deletions(-) diff --git a/crates/forge_config/src/config.rs b/crates/forge_config/src/config.rs index cc7abc6b39..8a9449c1c2 100644 --- a/crates/forge_config/src/config.rs +++ b/crates/forge_config/src/config.rs @@ -2,6 +2,7 @@ use std::collections::HashMap; use std::path::PathBuf; use derive_setters::Setters; +use merge::Merge; use serde::Deserialize; use url::Url; @@ -37,90 +38,123 @@ use crate::{ /// /// - **`_limit` is avoided**; prefer the explicit `max_` prefix + unit suffix /// instead. -#[derive(Debug, Setters, Clone, PartialEq, Deserialize, fake::Dummy)] +#[derive(Debug, Setters, Clone, PartialEq, Deserialize, fake::Dummy, Merge)] #[serde(rename_all = "snake_case")] #[setters(strip_option)] pub struct ForgeConfig { /// Configuration for the retry mechanism pub retry: RetryConfig, /// The maximum number of lines returned for FSSearch + #[merge(strategy = merge::num::overwrite_zero)] pub max_search_lines: usize, /// Maximum bytes allowed for search results + #[merge(strategy = merge::num::overwrite_zero)] pub max_search_result_bytes: usize, /// Maximum characters for fetch content + #[merge(strategy = merge::num::overwrite_zero)] pub max_fetch_chars: usize, /// Maximum lines for shell output prefix + #[merge(strategy = merge::num::overwrite_zero)] pub max_stdout_prefix_lines: usize, /// Maximum lines for shell output suffix + #[merge(strategy = merge::num::overwrite_zero)] pub max_stdout_suffix_lines: usize, /// Maximum characters per line for shell output + #[merge(strategy = merge::num::overwrite_zero)] pub max_stdout_line_chars: usize, /// Maximum characters per line for file read operations + #[merge(strategy = merge::num::overwrite_zero)] pub max_line_chars: usize, /// Maximum number of lines to read from a file + #[merge(strategy = merge::num::overwrite_zero)] pub max_read_lines: u64, /// Maximum number of files that can be read in a single batch operation + #[merge(strategy = merge::num::overwrite_zero)] pub max_file_read_batch_size: usize, /// HTTP configuration pub http: HttpConfig, /// Maximum file size in bytes for operations + #[merge(strategy = merge::num::overwrite_zero)] pub max_file_size_bytes: u64, /// Maximum image file size in bytes for binary read operations + #[merge(strategy = merge::num::overwrite_zero)] pub max_image_size_bytes: u64, /// Maximum execution time in seconds for a single tool call + #[merge(strategy = merge::num::overwrite_zero)] pub tool_timeout_secs: u64, /// Whether to automatically open HTML dump files in the browser + #[merge(strategy = merge::bool::overwrite_false)] pub auto_open_dump: bool, /// Path where debug request files should be written + #[merge(strategy = merge::option::overwrite_none)] pub debug_requests: Option, /// Custom history file path + #[merge(strategy = merge::option::overwrite_none)] pub custom_history_path: Option, /// Maximum number of conversations to show in list + #[merge(strategy = merge::num::overwrite_zero)] pub max_conversations: usize, /// Maximum number of results to return from initial vector search + #[merge(strategy = merge::num::overwrite_zero)] pub max_sem_search_results: usize, /// Top-k parameter for relevance filtering during semantic search + #[merge(strategy = merge::num::overwrite_zero)] pub sem_search_top_k: usize, /// URL for the indexing server #[dummy(expr = "url::Url::parse(\"http://localhost:8080\").unwrap()")] + #[merge(strategy = crate::merge::overwrite)] pub workspace_server_url: Url, /// Maximum number of file extensions to include in the system prompt + #[merge(strategy = merge::num::overwrite_zero)] pub max_extensions: usize, /// Format for automatically creating a dump when a task is completed + #[merge(strategy = merge::option::overwrite_none)] pub auto_dump: Option, /// Maximum number of files read concurrently in parallel operations + #[merge(strategy = merge::num::overwrite_zero)] pub max_parallel_file_reads: usize, /// TTL in seconds for the model API list cache + #[merge(strategy = merge::num::overwrite_zero)] pub model_cache_ttl_secs: u64, /// Default provider ID to use for AI operations #[serde(default)] + #[merge(strategy = merge::option::overwrite_none)] pub provider: Option, /// Map of provider ID to model ID for per-provider model selection #[serde(default)] + #[merge(strategy = merge::hashmap::overwrite)] pub model: HashMap, /// Provider and model to use for commit message generation #[serde(default)] + #[merge(strategy = merge::option::overwrite_none)] pub commit: Option, /// Provider and model to use for shell command suggestion generation #[serde(default)] + #[merge(strategy = merge::option::overwrite_none)] pub suggest: Option, /// API key for Forge authentication #[serde(default)] + #[merge(strategy = merge::option::overwrite_none)] pub api_key: Option, /// Display name of the API key #[serde(default)] + #[merge(strategy = merge::option::overwrite_none)] pub api_key_name: Option, /// Masked representation of the API key for display purposes #[serde(default)] + #[merge(strategy = merge::option::overwrite_none)] pub api_key_masked: Option, /// Email address associated with the Forge account #[serde(default)] + #[merge(strategy = merge::option::overwrite_none)] pub email: Option, /// Display name of the authenticated user #[serde(default)] + #[merge(strategy = merge::option::overwrite_none)] pub name: Option, /// Identifier of the authentication provider used for login #[serde(default)] + #[merge(strategy = merge::option::overwrite_none)] pub auth_provider_id: Option, } @@ -132,7 +166,10 @@ impl ForgeConfig { /// /// Panics if the configuration cannot be loaded. pub async fn read() -> ForgeConfig { - ConfigReader::new().read().await + ConfigReader::new() + .read() + .await + .expect("Failed to load configuration") } /// Writes the configuration to the user config file. diff --git a/crates/forge_config/src/http_config.rs b/crates/forge_config/src/http_config.rs index e25916d458..a4414b1e53 100644 --- a/crates/forge_config/src/http_config.rs +++ b/crates/forge_config/src/http_config.rs @@ -1,3 +1,4 @@ +use merge::Merge; use serde::{Deserialize, Serialize}; /// TLS version enum for configuring TLS protocol versions. @@ -25,31 +26,46 @@ pub enum TlsBackend { } /// HTTP client configuration. -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, fake::Dummy)] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, fake::Dummy, Merge)] #[serde(rename_all = "snake_case")] pub struct HttpConfig { + #[merge(strategy = merge::num::overwrite_zero)] pub connect_timeout_secs: u64, + #[merge(strategy = merge::num::overwrite_zero)] pub read_timeout_secs: u64, + #[merge(strategy = merge::num::overwrite_zero)] pub pool_idle_timeout_secs: u64, + #[merge(strategy = merge::num::overwrite_zero)] pub pool_max_idle_per_host: usize, + #[merge(strategy = merge::num::overwrite_zero)] pub max_redirects: usize, + #[merge(strategy = merge::bool::overwrite_false)] pub hickory: bool, + #[merge(strategy = crate::merge::overwrite)] pub tls_backend: TlsBackend, /// Minimum TLS protocol version to use + #[merge(strategy = merge::option::overwrite_none)] pub min_tls_version: Option, /// Maximum TLS protocol version to use + #[merge(strategy = merge::option::overwrite_none)] pub max_tls_version: Option, /// Adaptive window sizing for improved flow control + #[merge(strategy = merge::bool::overwrite_false)] pub adaptive_window: bool, /// Keep-alive interval in seconds + #[merge(strategy = merge::option::overwrite_none)] pub keep_alive_interval_secs: Option, /// Keep-alive timeout in seconds + #[merge(strategy = merge::num::overwrite_zero)] pub keep_alive_timeout_secs: u64, /// Keep-alive while connection is idle + #[merge(strategy = merge::bool::overwrite_false)] pub keep_alive_while_idle: bool, /// Accept invalid certificates + #[merge(strategy = merge::bool::overwrite_false)] pub accept_invalid_certs: bool, /// Paths to root certificate files + #[merge(strategy = merge::option::overwrite_none)] pub root_cert_paths: Option>, } diff --git a/crates/forge_config/src/lib.rs b/crates/forge_config/src/lib.rs index 83d4320f66..d3a55578c3 100644 --- a/crates/forge_config/src/lib.rs +++ b/crates/forge_config/src/lib.rs @@ -2,6 +2,7 @@ mod auto_dump; mod config; mod error; mod http_config; +mod merge; mod model_config; mod reader; mod retry_config; diff --git a/crates/forge_config/src/model_config.rs b/crates/forge_config/src/model_config.rs index 31e48a7cd0..09ea0e9990 100644 --- a/crates/forge_config/src/model_config.rs +++ b/crates/forge_config/src/model_config.rs @@ -1,4 +1,5 @@ use derive_more::{AsRef, Deref, Display, From}; +use merge::Merge; use serde::{Deserialize, Serialize}; /// A newtype wrapper for a provider identifier string. @@ -50,12 +51,14 @@ impl ModelId { } /// Pairs a provider and model together for a specific operation. -#[derive(Debug, Clone, PartialEq, Deserialize, fake::Dummy)] +#[derive(Debug, Clone, PartialEq, Deserialize, fake::Dummy, Merge)] pub struct ModelConfig { /// The provider to use for this operation. #[serde(rename = "provider")] + #[merge(strategy = crate::merge::overwrite)] pub provider_id: ProviderId, /// The model to use for this operation. #[serde(rename = "model")] + #[merge(strategy = crate::merge::overwrite)] pub model_id: ModelId, } diff --git a/crates/forge_config/src/reader.rs b/crates/forge_config/src/reader.rs index 8d887d2861..8f553d45c1 100644 --- a/crates/forge_config/src/reader.rs +++ b/crates/forge_config/src/reader.rs @@ -1,3 +1,5 @@ +use config::Config; + use crate::ForgeConfig; /// Reads and merges [`ForgeConfig`] from multiple sources: embedded defaults, @@ -17,7 +19,15 @@ impl ConfigReader { /// # Panics /// /// Panics if the embedded default configuration cannot be parsed. - pub async fn read(&self) -> ForgeConfig { + pub async fn read(&self) -> crate::Result { + let config = Config::builder() + .add_source( + config::Environment::with_prefix("FORGE") + .try_parsing(true) + .separator("_") + .list_separator(","), + ) + .build()?; todo!("implement multi-source config loading") } } diff --git a/crates/forge_config/src/retry_config.rs b/crates/forge_config/src/retry_config.rs index c5fc8fa6b6..3ca1142147 100644 --- a/crates/forge_config/src/retry_config.rs +++ b/crates/forge_config/src/retry_config.rs @@ -1,22 +1,30 @@ +use merge::Merge; use serde::{Deserialize, Serialize}; /// Configuration for retry mechanism. -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, fake::Dummy)] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, fake::Dummy, Merge)] #[serde(rename_all = "snake_case")] pub struct RetryConfig { /// Initial backoff delay in milliseconds for retry operations + #[merge(strategy = merge::num::overwrite_zero)] pub initial_backoff_ms: u64, /// Minimum delay in milliseconds between retry attempts + #[merge(strategy = merge::num::overwrite_zero)] pub min_delay_ms: u64, /// Backoff multiplication factor for each retry attempt + #[merge(strategy = merge::num::overwrite_zero)] pub backoff_factor: u64, /// Maximum number of retry attempts + #[merge(strategy = merge::num::overwrite_zero)] pub max_attempts: usize, /// HTTP status codes that should trigger retries + #[merge(strategy = merge::vec::append)] pub status_codes: Vec, /// Maximum delay between retries in seconds + #[merge(strategy = merge::option::overwrite_none)] pub max_delay_secs: Option, /// Whether to suppress retry error logging and events + #[merge(strategy = merge::bool::overwrite_false)] pub suppress_errors: bool, } From 2b0377db5123044ca82fe3ebfe9b69ef253cf780 Mon Sep 17 00:00:00 2001 From: Tushar Date: Tue, 24 Mar 2026 17:28:34 +0530 Subject: [PATCH 13/67] feat(forge_config): implement multi-source config loading order --- Cargo.lock | 1 + crates/forge_config/Cargo.toml | 1 + crates/forge_config/src/merge.rs | 4 +++ crates/forge_config/src/reader.rs | 42 ++++++++++++++++++++++--------- 4 files changed, 36 insertions(+), 12 deletions(-) create mode 100644 crates/forge_config/src/merge.rs diff --git a/Cargo.lock b/Cargo.lock index 23dd854c5d..43e7114d13 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1948,6 +1948,7 @@ dependencies = [ "pretty_assertions", "serde", "thiserror 2.0.18", + "tokio", "url", ] diff --git a/crates/forge_config/Cargo.toml b/crates/forge_config/Cargo.toml index fc389e2263..4e8c620595 100644 --- a/crates/forge_config/Cargo.toml +++ b/crates/forge_config/Cargo.toml @@ -11,6 +11,7 @@ config = { version = "0.15", features = ["toml"] } derive_more.workspace = true derive_setters.workspace = true dirs.workspace = true +tokio = { workspace = true, features = ["fs"] } serde.workspace = true url.workspace = true fake = { version = "5.1.0", features = ["derive"] } diff --git a/crates/forge_config/src/merge.rs b/crates/forge_config/src/merge.rs new file mode 100644 index 0000000000..da86448611 --- /dev/null +++ b/crates/forge_config/src/merge.rs @@ -0,0 +1,4 @@ +/// Overwrites `base` with `other`. +pub fn overwrite(base: &mut T, other: T) { + *base = other; +} diff --git a/crates/forge_config/src/reader.rs b/crates/forge_config/src/reader.rs index 8f553d45c1..2b32a5d3fc 100644 --- a/crates/forge_config/src/reader.rs +++ b/crates/forge_config/src/reader.rs @@ -16,18 +16,36 @@ impl ConfigReader { /// Reads and merges configuration from all sources, returning the resolved /// [`ForgeConfig`]. /// - /// # Panics - /// - /// Panics if the embedded default configuration cannot be parsed. + /// Sources are applied in increasing priority order: embedded defaults, + /// `~/.forge/.forge.toml`, then environment variables prefixed with + /// `FORGE_`. pub async fn read(&self) -> crate::Result { - let config = Config::builder() - .add_source( - config::Environment::with_prefix("FORGE") - .try_parsing(true) - .separator("_") - .list_separator(","), - ) - .build()?; - todo!("implement multi-source config loading") + // Embed the default config at compile time as the lowest-priority base. + let defaults = include_str!("../.config.toml"); + let mut builder = Config::builder(); + + // Load default + builder = builder.add_source(config::File::from_str(defaults, config::FileFormat::Toml)); + + // Load ~/.forge/.forge.toml + if let Some(home_dir) = dirs::home_dir() { + let home_config_path = home_dir.join(".forge").join(".forge.toml"); + if tokio::fs::try_exists(&home_config_path).await? { + let contents = tokio::fs::read_to_string(&home_config_path).await?; + builder = + builder.add_source(config::File::from_str(&contents, config::FileFormat::Toml)); + } + } + + // Load from environment + builder = builder.add_source( + config::Environment::with_prefix("FORGE") + .try_parsing(true) + .separator("_") + .list_separator(","), + ); + + let config = builder.build()?; + Ok(config.try_deserialize()?) } } From 7e5a7a9ee563d8d249a442dd9b5591b546b2ec44 Mon Sep 17 00:00:00 2001 From: Tushar Date: Tue, 24 Mar 2026 17:38:23 +0530 Subject: [PATCH 14/67] refactor(forge_config): remove merge derive and use toml_edit serialization --- Cargo.lock | 2 +- crates/forge_config/Cargo.toml | 2 +- crates/forge_config/src/config.rs | 38 ++----------------------- crates/forge_config/src/error.rs | 2 +- crates/forge_config/src/http_config.rs | 18 +----------- crates/forge_config/src/lib.rs | 1 - crates/forge_config/src/merge.rs | 4 --- crates/forge_config/src/model_config.rs | 5 +--- crates/forge_config/src/retry_config.rs | 10 +------ crates/forge_config/src/writer.rs | 16 ++++++++++- 10 files changed, 23 insertions(+), 75 deletions(-) delete mode 100644 crates/forge_config/src/merge.rs diff --git a/Cargo.lock b/Cargo.lock index 43e7114d13..5ccfce5f77 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1944,11 +1944,11 @@ dependencies = [ "derive_setters", "dirs", "fake", - "merge", "pretty_assertions", "serde", "thiserror 2.0.18", "tokio", + "toml_edit", "url", ] diff --git a/crates/forge_config/Cargo.toml b/crates/forge_config/Cargo.toml index 4e8c620595..2f7a043d51 100644 --- a/crates/forge_config/Cargo.toml +++ b/crates/forge_config/Cargo.toml @@ -5,7 +5,6 @@ edition.workspace = true rust-version.workspace = true [dependencies] -merge.workspace = true thiserror.workspace = true config = { version = "0.15", features = ["toml"] } derive_more.workspace = true @@ -13,6 +12,7 @@ derive_setters.workspace = true dirs.workspace = true tokio = { workspace = true, features = ["fs"] } serde.workspace = true +toml_edit = { workspace = true } url.workspace = true fake = { version = "5.1.0", features = ["derive"] } diff --git a/crates/forge_config/src/config.rs b/crates/forge_config/src/config.rs index 8a9449c1c2..22584d2c36 100644 --- a/crates/forge_config/src/config.rs +++ b/crates/forge_config/src/config.rs @@ -2,8 +2,7 @@ use std::collections::HashMap; use std::path::PathBuf; use derive_setters::Setters; -use merge::Merge; -use serde::Deserialize; +use serde::{Deserialize, Serialize}; use url::Url; use crate::{ @@ -38,123 +37,90 @@ use crate::{ /// /// - **`_limit` is avoided**; prefer the explicit `max_` prefix + unit suffix /// instead. -#[derive(Debug, Setters, Clone, PartialEq, Deserialize, fake::Dummy, Merge)] +#[derive(Debug, Setters, Clone, PartialEq, Serialize, Deserialize, fake::Dummy)] #[serde(rename_all = "snake_case")] #[setters(strip_option)] pub struct ForgeConfig { /// Configuration for the retry mechanism pub retry: RetryConfig, /// The maximum number of lines returned for FSSearch - #[merge(strategy = merge::num::overwrite_zero)] pub max_search_lines: usize, /// Maximum bytes allowed for search results - #[merge(strategy = merge::num::overwrite_zero)] pub max_search_result_bytes: usize, /// Maximum characters for fetch content - #[merge(strategy = merge::num::overwrite_zero)] pub max_fetch_chars: usize, /// Maximum lines for shell output prefix - #[merge(strategy = merge::num::overwrite_zero)] pub max_stdout_prefix_lines: usize, /// Maximum lines for shell output suffix - #[merge(strategy = merge::num::overwrite_zero)] pub max_stdout_suffix_lines: usize, /// Maximum characters per line for shell output - #[merge(strategy = merge::num::overwrite_zero)] pub max_stdout_line_chars: usize, /// Maximum characters per line for file read operations - #[merge(strategy = merge::num::overwrite_zero)] pub max_line_chars: usize, /// Maximum number of lines to read from a file - #[merge(strategy = merge::num::overwrite_zero)] pub max_read_lines: u64, /// Maximum number of files that can be read in a single batch operation - #[merge(strategy = merge::num::overwrite_zero)] pub max_file_read_batch_size: usize, /// HTTP configuration pub http: HttpConfig, /// Maximum file size in bytes for operations - #[merge(strategy = merge::num::overwrite_zero)] pub max_file_size_bytes: u64, /// Maximum image file size in bytes for binary read operations - #[merge(strategy = merge::num::overwrite_zero)] pub max_image_size_bytes: u64, /// Maximum execution time in seconds for a single tool call - #[merge(strategy = merge::num::overwrite_zero)] pub tool_timeout_secs: u64, /// Whether to automatically open HTML dump files in the browser - #[merge(strategy = merge::bool::overwrite_false)] pub auto_open_dump: bool, /// Path where debug request files should be written - #[merge(strategy = merge::option::overwrite_none)] pub debug_requests: Option, /// Custom history file path - #[merge(strategy = merge::option::overwrite_none)] pub custom_history_path: Option, /// Maximum number of conversations to show in list - #[merge(strategy = merge::num::overwrite_zero)] pub max_conversations: usize, /// Maximum number of results to return from initial vector search - #[merge(strategy = merge::num::overwrite_zero)] pub max_sem_search_results: usize, /// Top-k parameter for relevance filtering during semantic search - #[merge(strategy = merge::num::overwrite_zero)] pub sem_search_top_k: usize, /// URL for the indexing server #[dummy(expr = "url::Url::parse(\"http://localhost:8080\").unwrap()")] - #[merge(strategy = crate::merge::overwrite)] pub workspace_server_url: Url, /// Maximum number of file extensions to include in the system prompt - #[merge(strategy = merge::num::overwrite_zero)] pub max_extensions: usize, /// Format for automatically creating a dump when a task is completed - #[merge(strategy = merge::option::overwrite_none)] pub auto_dump: Option, /// Maximum number of files read concurrently in parallel operations - #[merge(strategy = merge::num::overwrite_zero)] pub max_parallel_file_reads: usize, /// TTL in seconds for the model API list cache - #[merge(strategy = merge::num::overwrite_zero)] pub model_cache_ttl_secs: u64, /// Default provider ID to use for AI operations #[serde(default)] - #[merge(strategy = merge::option::overwrite_none)] pub provider: Option, /// Map of provider ID to model ID for per-provider model selection #[serde(default)] - #[merge(strategy = merge::hashmap::overwrite)] pub model: HashMap, /// Provider and model to use for commit message generation #[serde(default)] - #[merge(strategy = merge::option::overwrite_none)] pub commit: Option, /// Provider and model to use for shell command suggestion generation #[serde(default)] - #[merge(strategy = merge::option::overwrite_none)] pub suggest: Option, /// API key for Forge authentication #[serde(default)] - #[merge(strategy = merge::option::overwrite_none)] pub api_key: Option, /// Display name of the API key #[serde(default)] - #[merge(strategy = merge::option::overwrite_none)] pub api_key_name: Option, /// Masked representation of the API key for display purposes #[serde(default)] - #[merge(strategy = merge::option::overwrite_none)] pub api_key_masked: Option, /// Email address associated with the Forge account #[serde(default)] - #[merge(strategy = merge::option::overwrite_none)] pub email: Option, /// Display name of the authenticated user #[serde(default)] - #[merge(strategy = merge::option::overwrite_none)] pub name: Option, /// Identifier of the authentication provider used for login #[serde(default)] - #[merge(strategy = merge::option::overwrite_none)] pub auth_provider_id: Option, } diff --git a/crates/forge_config/src/error.rs b/crates/forge_config/src/error.rs index f3529d5b93..6fde9b5883 100644 --- a/crates/forge_config/src/error.rs +++ b/crates/forge_config/src/error.rs @@ -7,7 +7,7 @@ pub enum Error { /// Failed to serialize or write configuration. #[error("Serialization error: {0}")] - Serialization(String), + Serialization(#[from] toml_edit::ser::Error), /// An I/O error occurred while reading or writing configuration files. #[error("IO error: {0}")] diff --git a/crates/forge_config/src/http_config.rs b/crates/forge_config/src/http_config.rs index a4414b1e53..e25916d458 100644 --- a/crates/forge_config/src/http_config.rs +++ b/crates/forge_config/src/http_config.rs @@ -1,4 +1,3 @@ -use merge::Merge; use serde::{Deserialize, Serialize}; /// TLS version enum for configuring TLS protocol versions. @@ -26,46 +25,31 @@ pub enum TlsBackend { } /// HTTP client configuration. -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, fake::Dummy, Merge)] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, fake::Dummy)] #[serde(rename_all = "snake_case")] pub struct HttpConfig { - #[merge(strategy = merge::num::overwrite_zero)] pub connect_timeout_secs: u64, - #[merge(strategy = merge::num::overwrite_zero)] pub read_timeout_secs: u64, - #[merge(strategy = merge::num::overwrite_zero)] pub pool_idle_timeout_secs: u64, - #[merge(strategy = merge::num::overwrite_zero)] pub pool_max_idle_per_host: usize, - #[merge(strategy = merge::num::overwrite_zero)] pub max_redirects: usize, - #[merge(strategy = merge::bool::overwrite_false)] pub hickory: bool, - #[merge(strategy = crate::merge::overwrite)] pub tls_backend: TlsBackend, /// Minimum TLS protocol version to use - #[merge(strategy = merge::option::overwrite_none)] pub min_tls_version: Option, /// Maximum TLS protocol version to use - #[merge(strategy = merge::option::overwrite_none)] pub max_tls_version: Option, /// Adaptive window sizing for improved flow control - #[merge(strategy = merge::bool::overwrite_false)] pub adaptive_window: bool, /// Keep-alive interval in seconds - #[merge(strategy = merge::option::overwrite_none)] pub keep_alive_interval_secs: Option, /// Keep-alive timeout in seconds - #[merge(strategy = merge::num::overwrite_zero)] pub keep_alive_timeout_secs: u64, /// Keep-alive while connection is idle - #[merge(strategy = merge::bool::overwrite_false)] pub keep_alive_while_idle: bool, /// Accept invalid certificates - #[merge(strategy = merge::bool::overwrite_false)] pub accept_invalid_certs: bool, /// Paths to root certificate files - #[merge(strategy = merge::option::overwrite_none)] pub root_cert_paths: Option>, } diff --git a/crates/forge_config/src/lib.rs b/crates/forge_config/src/lib.rs index d3a55578c3..83d4320f66 100644 --- a/crates/forge_config/src/lib.rs +++ b/crates/forge_config/src/lib.rs @@ -2,7 +2,6 @@ mod auto_dump; mod config; mod error; mod http_config; -mod merge; mod model_config; mod reader; mod retry_config; diff --git a/crates/forge_config/src/merge.rs b/crates/forge_config/src/merge.rs deleted file mode 100644 index da86448611..0000000000 --- a/crates/forge_config/src/merge.rs +++ /dev/null @@ -1,4 +0,0 @@ -/// Overwrites `base` with `other`. -pub fn overwrite(base: &mut T, other: T) { - *base = other; -} diff --git a/crates/forge_config/src/model_config.rs b/crates/forge_config/src/model_config.rs index 09ea0e9990..93634b9821 100644 --- a/crates/forge_config/src/model_config.rs +++ b/crates/forge_config/src/model_config.rs @@ -1,5 +1,4 @@ use derive_more::{AsRef, Deref, Display, From}; -use merge::Merge; use serde::{Deserialize, Serialize}; /// A newtype wrapper for a provider identifier string. @@ -51,14 +50,12 @@ impl ModelId { } /// Pairs a provider and model together for a specific operation. -#[derive(Debug, Clone, PartialEq, Deserialize, fake::Dummy, Merge)] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, fake::Dummy)] pub struct ModelConfig { /// The provider to use for this operation. #[serde(rename = "provider")] - #[merge(strategy = crate::merge::overwrite)] pub provider_id: ProviderId, /// The model to use for this operation. #[serde(rename = "model")] - #[merge(strategy = crate::merge::overwrite)] pub model_id: ModelId, } diff --git a/crates/forge_config/src/retry_config.rs b/crates/forge_config/src/retry_config.rs index 3ca1142147..c5fc8fa6b6 100644 --- a/crates/forge_config/src/retry_config.rs +++ b/crates/forge_config/src/retry_config.rs @@ -1,30 +1,22 @@ -use merge::Merge; use serde::{Deserialize, Serialize}; /// Configuration for retry mechanism. -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, fake::Dummy, Merge)] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, fake::Dummy)] #[serde(rename_all = "snake_case")] pub struct RetryConfig { /// Initial backoff delay in milliseconds for retry operations - #[merge(strategy = merge::num::overwrite_zero)] pub initial_backoff_ms: u64, /// Minimum delay in milliseconds between retry attempts - #[merge(strategy = merge::num::overwrite_zero)] pub min_delay_ms: u64, /// Backoff multiplication factor for each retry attempt - #[merge(strategy = merge::num::overwrite_zero)] pub backoff_factor: u64, /// Maximum number of retry attempts - #[merge(strategy = merge::num::overwrite_zero)] pub max_attempts: usize, /// HTTP status codes that should trigger retries - #[merge(strategy = merge::vec::append)] pub status_codes: Vec, /// Maximum delay between retries in seconds - #[merge(strategy = merge::option::overwrite_none)] pub max_delay_secs: Option, /// Whether to suppress retry error logging and events - #[merge(strategy = merge::bool::overwrite_false)] pub suppress_errors: bool, } diff --git a/crates/forge_config/src/writer.rs b/crates/forge_config/src/writer.rs index adaaf112d1..f309f78bd6 100644 --- a/crates/forge_config/src/writer.rs +++ b/crates/forge_config/src/writer.rs @@ -13,12 +13,26 @@ impl ConfigWriter { /// Serializes and writes the configuration to the user config file. /// + /// Writes to `~/.forge/.forge.toml`, creating the parent directory if it + /// does not already exist. + /// /// # Errors /// /// Returns an error if the configuration cannot be serialized or the file /// cannot be written. pub async fn write(&self) -> crate::Result<()> { - let _ = &self.config; + let home_dir = dirs::home_dir().ok_or_else(|| { + std::io::Error::new(std::io::ErrorKind::NotFound, "home directory not found") + })?; + let config_dir = home_dir.join(".forge"); + let config_path = config_dir.join(".forge.toml"); + + tokio::fs::create_dir_all(&config_dir).await?; + + let contents = toml_edit::ser::to_string_pretty(&self.config)?; + + tokio::fs::write(&config_path, contents).await?; + Ok(()) } } From a0acd57eb7f699f17a1703c334d497e7d042d8a8 Mon Sep 17 00:00:00 2001 From: Tushar Date: Tue, 24 Mar 2026 17:42:04 +0530 Subject: [PATCH 15/67] refactor(forge_config): load config from resolved path --- crates/forge_config/src/config.rs | 32 +++++++++++++++++++++---------- crates/forge_config/src/reader.rs | 20 +++++++++---------- crates/forge_config/src/writer.rs | 22 +++++++++------------ 3 files changed, 40 insertions(+), 34 deletions(-) diff --git a/crates/forge_config/src/config.rs b/crates/forge_config/src/config.rs index 22584d2c36..755cc9c497 100644 --- a/crates/forge_config/src/config.rs +++ b/crates/forge_config/src/config.rs @@ -125,17 +125,28 @@ pub struct ForgeConfig { } impl ForgeConfig { - /// Get the global ForgeConfig instance, loading from the embedded config - /// file on first access. + /// Returns the path to the user configuration file: `~/.forge/.forge.toml`. /// - /// # Panics + /// # Errors + /// + /// Returns an error if the home directory cannot be determined. + pub fn config_path() -> crate::Result { + let home_dir = dirs::home_dir().ok_or_else(|| { + std::io::Error::new(std::io::ErrorKind::NotFound, "home directory not found") + })?; + Ok(home_dir.join(".forge").join(".forge.toml")) + } + + /// Reads and merges configuration from all sources, returning the resolved + /// [`ForgeConfig`]. + /// + /// # Errors /// - /// Panics if the configuration cannot be loaded. - pub async fn read() -> ForgeConfig { - ConfigReader::new() - .read() - .await - .expect("Failed to load configuration") + /// Returns an error if the config path cannot be resolved, the file cannot + /// be read, or the configuration cannot be deserialized. + pub async fn read() -> crate::Result { + let path = Self::config_path()?; + ConfigReader::new().read(&path).await } /// Writes the configuration to the user config file. @@ -145,6 +156,7 @@ impl ForgeConfig { /// Returns an error if the configuration cannot be serialized or written to /// disk. pub async fn write(&self) -> crate::Result<()> { - ConfigWriter::new(self.clone()).write().await + let path = Self::config_path()?; + ConfigWriter::new(self.clone()).write(&path).await } } diff --git a/crates/forge_config/src/reader.rs b/crates/forge_config/src/reader.rs index 2b32a5d3fc..50daf1c335 100644 --- a/crates/forge_config/src/reader.rs +++ b/crates/forge_config/src/reader.rs @@ -1,3 +1,5 @@ +use std::path::Path; + use config::Config; use crate::ForgeConfig; @@ -17,9 +19,8 @@ impl ConfigReader { /// [`ForgeConfig`]. /// /// Sources are applied in increasing priority order: embedded defaults, - /// `~/.forge/.forge.toml`, then environment variables prefixed with - /// `FORGE_`. - pub async fn read(&self) -> crate::Result { + /// the file at `path`, then environment variables prefixed with `FORGE_`. + pub async fn read(&self, path: &Path) -> crate::Result { // Embed the default config at compile time as the lowest-priority base. let defaults = include_str!("../.config.toml"); let mut builder = Config::builder(); @@ -27,14 +28,11 @@ impl ConfigReader { // Load default builder = builder.add_source(config::File::from_str(defaults, config::FileFormat::Toml)); - // Load ~/.forge/.forge.toml - if let Some(home_dir) = dirs::home_dir() { - let home_config_path = home_dir.join(".forge").join(".forge.toml"); - if tokio::fs::try_exists(&home_config_path).await? { - let contents = tokio::fs::read_to_string(&home_config_path).await?; - builder = - builder.add_source(config::File::from_str(&contents, config::FileFormat::Toml)); - } + // Load from path + if tokio::fs::try_exists(path).await? { + let contents = tokio::fs::read_to_string(path).await?; + builder = + builder.add_source(config::File::from_str(&contents, config::FileFormat::Toml)); } // Load from environment diff --git a/crates/forge_config/src/writer.rs b/crates/forge_config/src/writer.rs index f309f78bd6..b8ddf61092 100644 --- a/crates/forge_config/src/writer.rs +++ b/crates/forge_config/src/writer.rs @@ -1,3 +1,5 @@ +use std::path::Path; + use crate::ForgeConfig; /// Writes a [`ForgeConfig`] to the user configuration file on disk. @@ -11,27 +13,21 @@ impl ConfigWriter { Self { config } } - /// Serializes and writes the configuration to the user config file. - /// - /// Writes to `~/.forge/.forge.toml`, creating the parent directory if it - /// does not already exist. + /// Serializes and writes the configuration to `path`, creating all parent + /// directories recursively if they do not already exist. /// /// # Errors /// /// Returns an error if the configuration cannot be serialized or the file /// cannot be written. - pub async fn write(&self) -> crate::Result<()> { - let home_dir = dirs::home_dir().ok_or_else(|| { - std::io::Error::new(std::io::ErrorKind::NotFound, "home directory not found") - })?; - let config_dir = home_dir.join(".forge"); - let config_path = config_dir.join(".forge.toml"); - - tokio::fs::create_dir_all(&config_dir).await?; + pub async fn write(&self, path: &Path) -> crate::Result<()> { + if let Some(parent) = path.parent() { + tokio::fs::create_dir_all(parent).await?; + } let contents = toml_edit::ser::to_string_pretty(&self.config)?; - tokio::fs::write(&config_path, contents).await?; + tokio::fs::write(path, contents).await?; Ok(()) } From 2d5c69f0a52f5f7f6bc294f6427b8b81569cf220 Mon Sep 17 00:00:00 2001 From: Tushar Date: Tue, 24 Mar 2026 17:57:45 +0530 Subject: [PATCH 16/67] feat(forge_config): add temperature, sampling, update, compact types --- Cargo.lock | 3 + crates/forge_config/Cargo.toml | 3 + crates/forge_config/src/compact.rs | 511 ++++++++++++++++++ crates/forge_config/src/config.rs | 78 ++- .../src/{http_config.rs => http.rs} | 0 crates/forge_config/src/lib.rs | 14 +- .../src/{model_config.rs => model.rs} | 0 .../src/{retry_config.rs => retry.rs} | 0 8 files changed, 601 insertions(+), 8 deletions(-) create mode 100644 crates/forge_config/src/compact.rs rename crates/forge_config/src/{http_config.rs => http.rs} (100%) rename crates/forge_config/src/{model_config.rs => model.rs} (100%) rename crates/forge_config/src/{retry_config.rs => retry.rs} (100%) diff --git a/Cargo.lock b/Cargo.lock index 5ccfce5f77..614eee70dc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1944,8 +1944,11 @@ dependencies = [ "derive_setters", "dirs", "fake", + "merge", "pretty_assertions", + "schemars 1.2.1", "serde", + "serde_json", "thiserror 2.0.18", "tokio", "toml_edit", diff --git a/crates/forge_config/Cargo.toml b/crates/forge_config/Cargo.toml index 2f7a043d51..06be3ca7d0 100644 --- a/crates/forge_config/Cargo.toml +++ b/crates/forge_config/Cargo.toml @@ -12,9 +12,12 @@ derive_setters.workspace = true dirs.workspace = true tokio = { workspace = true, features = ["fs"] } serde.workspace = true +serde_json.workspace = true toml_edit = { workspace = true } url.workspace = true fake = { version = "5.1.0", features = ["derive"] } +schemars.workspace = true +merge.workspace = true [dev-dependencies] pretty_assertions.workspace = true diff --git a/crates/forge_config/src/compact.rs b/crates/forge_config/src/compact.rs new file mode 100644 index 0000000000..4b587856ca --- /dev/null +++ b/crates/forge_config/src/compact.rs @@ -0,0 +1,511 @@ +use std::fmt; +use std::ops::Deref; +use std::time::Duration; + +use derive_setters::Setters; +use schemars::JsonSchema; +use serde::{Deserialize, Deserializer, Serialize, Serializer}; + +/// A newtype for temperature values with built-in validation +/// +/// Temperature controls the randomness in the model's output: +/// - Lower values (e.g., 0.1) make responses more focused, deterministic, and +/// coherent +/// - Higher values (e.g., 0.8) make responses more creative, diverse, and +/// exploratory +/// - Valid range is 0.0 to 2.0 +#[derive(Debug, Clone, Copy, PartialEq, PartialOrd, JsonSchema)] +pub struct Temperature(f32); + +impl Temperature { + /// Creates a new Temperature value, returning an error if outside the valid + /// range (0.0 to 2.0) + pub fn new(value: f32) -> Result { + if Self::is_valid(value) { + Ok(Self(value)) + } else { + Err(format!( + "temperature must be between 0.0 and 2.0, got {value}" + )) + } + } + + /// Creates a new Temperature value without validation + /// + /// # Safety + /// This function should only be used when the value is known to be valid + pub fn new_unchecked(value: f32) -> Self { + debug_assert!(Self::is_valid(value), "invalid temperature: {value}"); + Self(value) + } + + /// Returns true if the temperature value is within the valid range (0.0 to + /// 2.0) + pub fn is_valid(value: f32) -> bool { + (0.0..=2.0).contains(&value) + } + + /// Returns the inner f32 value + pub fn value(&self) -> f32 { + self.0 + } +} + +impl Deref for Temperature { + type Target = f32; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl From for f32 { + fn from(temp: Temperature) -> Self { + temp.0 + } +} + +impl From for Temperature { + fn from(value: f32) -> Self { + Temperature::new_unchecked(value) + } +} + +impl fmt::Display for Temperature { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.0) + } +} + +impl Serialize for Temperature { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + let formatted = format!("{:.1}", self.0); + let value = formatted.parse::().unwrap(); + serializer.serialize_f32(value) + } +} + +impl<'de> Deserialize<'de> for Temperature { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + use serde::de::Error; + let value = f32::deserialize(deserializer)?; + if Self::is_valid(value) { + Ok(Self(value)) + } else { + Err(Error::custom(format!( + "temperature must be between 0.0 and 2.0, got {value}" + ))) + } + } +} + +/// A newtype for top_p values with built-in validation +/// +/// Top-p (nucleus sampling) controls the diversity of the model's output: +/// - Lower values (e.g., 0.1) make responses more focused by considering only +/// the most probable tokens +/// - Higher values (e.g., 0.9) make responses more diverse by considering a +/// broader range of tokens +/// - Valid range is 0.0 to 1.0 +#[derive(Debug, Clone, Copy, PartialEq, PartialOrd, JsonSchema)] +pub struct TopP(f32); + +impl TopP { + /// Creates a new TopP value, returning an error if outside the valid + /// range (0.0 to 1.0) + pub fn new(value: f32) -> Result { + if Self::is_valid(value) { + Ok(Self(value)) + } else { + Err(format!("top_p must be between 0.0 and 1.0, got {value}")) + } + } + + /// Creates a new TopP value without validation + /// + /// # Safety + /// This function should only be used when the value is known to be valid + pub fn new_unchecked(value: f32) -> Self { + debug_assert!(Self::is_valid(value), "invalid top_p: {value}"); + Self(value) + } + + /// Returns true if the top_p value is within the valid range (0.0 to 1.0) + pub fn is_valid(value: f32) -> bool { + (0.0..=1.0).contains(&value) + } + + /// Returns the inner f32 value + pub fn value(&self) -> f32 { + self.0 + } +} + +impl Deref for TopP { + type Target = f32; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl From for f32 { + fn from(top_p: TopP) -> Self { + top_p.0 + } +} + +impl fmt::Display for TopP { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.0) + } +} + +impl Serialize for TopP { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + let formatted = format!("{:.2}", self.0); + let value = formatted.parse::().unwrap(); + serializer.serialize_f32(value) + } +} + +impl<'de> Deserialize<'de> for TopP { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + use serde::de::Error; + let value = f32::deserialize(deserializer)?; + if Self::is_valid(value) { + Ok(Self(value)) + } else { + Err(Error::custom(format!( + "top_p must be between 0.0 and 1.0, got {value}" + ))) + } + } +} + +/// A newtype for top_k values with built-in validation +/// +/// Top-k controls the number of highest probability vocabulary tokens to keep: +/// - Lower values (e.g., 10) make responses more focused by considering only +/// the top K most likely tokens +/// - Higher values (e.g., 100) make responses more diverse by considering more +/// token options +/// - Valid range is 1 to 1000 (inclusive) +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, JsonSchema)] +pub struct TopK(u32); + +impl TopK { + /// Creates a new TopK value, returning an error if outside the valid + /// range (1 to 1000) + pub fn new(value: u32) -> Result { + if Self::is_valid(value) { + Ok(Self(value)) + } else { + Err(format!("top_k must be between 1 and 1000, got {value}")) + } + } + + /// Creates a new TopK value without validation + /// + /// # Safety + /// This function should only be used when the value is known to be valid + pub fn new_unchecked(value: u32) -> Self { + debug_assert!(Self::is_valid(value), "invalid top_k: {value}"); + Self(value) + } + + /// Returns true if the top_k value is within the valid range (1 to 1000) + pub fn is_valid(value: u32) -> bool { + (1..=1000).contains(&value) + } + + /// Returns the inner u32 value + pub fn value(&self) -> u32 { + self.0 + } +} + +impl Deref for TopK { + type Target = u32; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl From for u32 { + fn from(top_k: TopK) -> Self { + top_k.0 + } +} + +impl fmt::Display for TopK { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.0) + } +} + +impl Serialize for TopK { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + serializer.serialize_u32(self.0) + } +} + +impl<'de> Deserialize<'de> for TopK { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + use serde::de::Error; + let value = u32::deserialize(deserializer)?; + if Self::is_valid(value) { + Ok(Self(value)) + } else { + Err(Error::custom(format!( + "top_k must be between 1 and 1000, got {value}" + ))) + } + } +} + +/// A newtype for max_tokens values with built-in validation +/// +/// Max tokens controls the maximum number of tokens the model can generate: +/// - Lower values (e.g., 100) limit response length for concise outputs +/// - Higher values (e.g., 4000) allow for longer, more detailed responses +/// - Valid range is 1 to 100,000 (reasonable upper bound for most models) +/// - If not specified, the model provider's default will be used +#[derive(Debug, Clone, Copy, PartialEq, PartialOrd, JsonSchema)] +pub struct MaxTokens(u32); + +impl MaxTokens { + /// Creates a new MaxTokens value, returning an error if outside the valid + /// range (1 to 100,000) + pub fn new(value: u32) -> Result { + if Self::is_valid(value) { + Ok(Self(value)) + } else { + Err(format!( + "max_tokens must be between 1 and 100000, got {value}" + )) + } + } + + /// Creates a new MaxTokens value without validation + /// + /// # Safety + /// This function should only be used when the value is known to be valid + pub fn new_unchecked(value: u32) -> Self { + debug_assert!(Self::is_valid(value), "invalid max_tokens: {value}"); + Self(value) + } + + /// Returns true if the max_tokens value is within the valid range (1 to + /// 100,000) + pub fn is_valid(value: u32) -> bool { + (1..=100_000).contains(&value) + } + + /// Returns the inner u32 value + pub fn value(&self) -> u32 { + self.0 + } +} + +impl Deref for MaxTokens { + type Target = u32; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl From for u32 { + fn from(max_tokens: MaxTokens) -> Self { + max_tokens.0 + } +} + +impl fmt::Display for MaxTokens { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.0) + } +} + +impl Serialize for MaxTokens { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + serializer.serialize_u32(self.0) + } +} + +impl<'de> Deserialize<'de> for MaxTokens { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + use serde::de::Error; + let value = u32::deserialize(deserializer)?; + if Self::is_valid(value) { + Ok(Self(value)) + } else { + Err(Error::custom(format!( + "max_tokens must be between 1 and 100000, got {value}" + ))) + } + } +} + +/// Frequency at which forge checks for updates +#[derive(Default, Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)] +#[serde(rename_all = "snake_case")] +pub enum UpdateFrequency { + Daily, + Weekly, + #[default] + Always, +} + +impl From for Duration { + fn from(val: UpdateFrequency) -> Self { + match val { + UpdateFrequency::Daily => Duration::from_secs(60 * 60 * 24), + UpdateFrequency::Weekly => Duration::from_secs(60 * 60 * 24 * 7), + UpdateFrequency::Always => Duration::ZERO, + } + } +} + +/// Configuration for automatic forge updates +#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema, Setters, PartialEq)] +#[setters(strip_option, into)] +pub struct Update { + /// How frequently forge checks for updates + pub frequency: Option, + /// Whether to automatically install updates without prompting + pub auto_update: Option, +} + +fn deserialize_percentage<'de, D>(deserializer: D) -> Result +where + D: serde::Deserializer<'de>, +{ + use serde::de::Error; + + let value = f64::deserialize(deserializer)?; + if !(0.0..=1.0).contains(&value) { + return Err(Error::custom(format!( + "percentage must be between 0.0 and 1.0, got {value}" + ))); + } + Ok(value) +} + +/// Optional tag name used when extracting summarized content during compaction +#[derive(Serialize, Deserialize, Debug, Clone, JsonSchema, PartialEq)] +#[serde(transparent)] +pub struct SummaryTag(String); + +impl Default for SummaryTag { + fn default() -> Self { + SummaryTag("forge_context_summary".to_string()) + } +} + +impl SummaryTag { + /// Returns the inner string slice + pub fn as_str(&self) -> &str { + self.0.as_str() + } +} + +/// Configuration for automatic context compaction for all agents +#[derive(Debug, Clone, Serialize, Deserialize, Setters, JsonSchema, PartialEq)] +#[setters(strip_option, into)] +pub struct Compact { + /// Number of most recent messages to preserve during compaction. + /// These messages won't be considered for summarization. Works alongside + /// eviction_window - the more conservative limit (fewer messages to + /// compact) takes precedence. + #[serde(default)] + pub retention_window: usize, + + /// Maximum percentage of the context that can be summarized during + /// compaction. Valid values are between 0.0 and 1.0, where 0.0 means no + /// compaction and 1.0 allows summarizing all messages. Works alongside + /// retention_window - the more conservative limit (fewer messages to + /// compact) takes precedence. + #[serde(default, deserialize_with = "deserialize_percentage")] + pub eviction_window: f64, + + /// Maximum number of tokens to keep after compaction + #[serde(skip_serializing_if = "Option::is_none")] + pub max_tokens: Option, + + /// Maximum number of tokens before triggering compaction + #[serde(skip_serializing_if = "Option::is_none")] + pub token_threshold: Option, + + /// Maximum number of conversation turns before triggering compaction + #[serde(skip_serializing_if = "Option::is_none")] + pub turn_threshold: Option, + + /// Maximum number of messages before triggering compaction + #[serde(skip_serializing_if = "Option::is_none")] + pub message_threshold: Option, + + /// Model ID to use for compaction, useful when compacting with a + /// cheaper/faster model. If not specified, the root level model will be + /// used. + #[serde(skip_serializing_if = "Option::is_none")] + pub model: Option, + + /// Optional tag name to extract content from when summarizing (e.g., + /// "summary") + #[serde(skip_serializing_if = "Option::is_none")] + pub summary_tag: Option, + + /// Whether to trigger compaction when the last message is from a user + #[serde(default, skip_serializing_if = "Option::is_none")] + pub on_turn_end: Option, +} + +impl Default for Compact { + fn default() -> Self { + Self::new() + } +} + +impl Compact { + /// Creates a new compaction configuration with all optional fields unset + pub fn new() -> Self { + Self { + max_tokens: None, + token_threshold: None, + turn_threshold: None, + message_threshold: None, + summary_tag: None, + model: None, + eviction_window: 0.2, + retention_window: 0, + on_turn_end: None, + } + } +} diff --git a/crates/forge_config/src/config.rs b/crates/forge_config/src/config.rs index 755cc9c497..f647c72a25 100644 --- a/crates/forge_config/src/config.rs +++ b/crates/forge_config/src/config.rs @@ -6,8 +6,8 @@ use serde::{Deserialize, Serialize}; use url::Url; use crate::{ - AutoDumpFormat, HttpConfig, ModelConfig, ModelId, ProviderId, RetryConfig, - reader::ConfigReader, writer::ConfigWriter, + AutoDumpFormat, Compact, HttpConfig, MaxTokens, ModelConfig, ModelId, ProviderId, RetryConfig, + Temperature, TopK, TopP, Update, reader::ConfigReader, writer::ConfigWriter, }; /// Forge configuration containing all the fields from the Environment struct. @@ -122,6 +122,80 @@ pub struct ForgeConfig { /// Identifier of the authentication provider used for login #[serde(default)] pub auth_provider_id: Option, + + // --- Workflow fields --- + + /// Configuration for automatic forge updates + #[serde(skip_serializing_if = "Option::is_none")] + #[dummy(default)] + pub updates: Option, + + /// Temperature used for all agents. + /// + /// Temperature controls the randomness in the model's output. + /// - Lower values (e.g., 0.1) make responses more focused, deterministic, + /// and coherent + /// - Higher values (e.g., 0.8) make responses more creative, diverse, and + /// exploratory + /// - Valid range is 0.0 to 2.0 + /// - If not specified, each agent's individual setting or the model + /// provider's default will be used + #[serde(default, skip_serializing_if = "Option::is_none")] + #[dummy(expr = "Some(Temperature::new(1.0).unwrap())")] + pub temperature: Option, + + /// Top-p (nucleus sampling) used for all agents. + /// + /// Controls the diversity of the model's output by considering only the + /// most probable tokens up to a cumulative probability threshold. + /// - Lower values (e.g., 0.1) make responses more focused + /// - Higher values (e.g., 0.9) make responses more diverse + /// - Valid range is 0.0 to 1.0 + /// - If not specified, each agent's individual setting or the model + /// provider's default will be used + #[serde(default, skip_serializing_if = "Option::is_none")] + #[dummy(expr = "Some(TopP::new(0.9).unwrap())")] + pub top_p: Option, + + /// Top-k used for all agents. + /// + /// Controls the number of highest probability vocabulary tokens to keep. + /// - Lower values (e.g., 10) make responses more focused + /// - Higher values (e.g., 100) make responses more diverse + /// - Valid range is 1 to 1000 + /// - If not specified, each agent's individual setting or the model + /// provider's default will be used + #[serde(default, skip_serializing_if = "Option::is_none")] + #[dummy(expr = "Some(TopK::new(50).unwrap())")] + pub top_k: Option, + + /// Maximum number of tokens the model can generate for all agents. + /// + /// Controls the maximum length of the model's response. + /// - Lower values (e.g., 100) limit response length for concise outputs + /// - Higher values (e.g., 4000) allow for longer, more detailed responses + /// - Valid range is 1 to 100,000 + /// - If not specified, each agent's individual setting or the model + /// provider's default will be used + #[serde(default, skip_serializing_if = "Option::is_none")] + #[dummy(expr = "Some(MaxTokens::new(4000).unwrap())")] + pub max_tokens: Option, + + /// Maximum number of times a tool can fail before the orchestrator + /// forces the completion. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub max_tool_failure_per_turn: Option, + + /// Maximum number of requests that can be made in a single turn. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub max_requests_per_turn: Option, + + /// Configuration for automatic context compaction for all agents. + /// If specified, this will be applied to all agents in the workflow. + /// If not specified, each agent's individual setting will be used. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[dummy(default)] + pub compact: Option, } impl ForgeConfig { diff --git a/crates/forge_config/src/http_config.rs b/crates/forge_config/src/http.rs similarity index 100% rename from crates/forge_config/src/http_config.rs rename to crates/forge_config/src/http.rs diff --git a/crates/forge_config/src/lib.rs b/crates/forge_config/src/lib.rs index 83d4320f66..13e201f3fe 100644 --- a/crates/forge_config/src/lib.rs +++ b/crates/forge_config/src/lib.rs @@ -1,18 +1,20 @@ mod auto_dump; +mod compact; mod config; mod error; -mod http_config; -mod model_config; +mod http; +mod model; mod reader; -mod retry_config; +mod retry; mod writer; pub use auto_dump::*; +pub use compact::*; pub use config::*; pub use error::Error; -pub use http_config::*; -pub use model_config::*; -pub use retry_config::*; +pub use http::*; +pub use model::*; +pub use retry::*; /// A `Result` type alias for this crate's [`Error`] type. pub type Result = std::result::Result; diff --git a/crates/forge_config/src/model_config.rs b/crates/forge_config/src/model.rs similarity index 100% rename from crates/forge_config/src/model_config.rs rename to crates/forge_config/src/model.rs diff --git a/crates/forge_config/src/retry_config.rs b/crates/forge_config/src/retry.rs similarity index 100% rename from crates/forge_config/src/retry_config.rs rename to crates/forge_config/src/retry.rs From 859812f4d10b9c671fde2a81a7e51befe64d12a2 Mon Sep 17 00:00:00 2001 From: Tushar Date: Tue, 24 Mar 2026 18:07:31 +0530 Subject: [PATCH 17/67] feat(forge_config): add tool limits and compact/update config --- crates/forge_config/.config.toml | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/crates/forge_config/.config.toml b/crates/forge_config/.config.toml index cdcc8e663e..678f6b283e 100644 --- a/crates/forge_config/.config.toml +++ b/crates/forge_config/.config.toml @@ -18,6 +18,11 @@ workspace_server_url = "https://api.forgecode.dev/" max_extensions = 15 max_parallel_file_reads = 64 model_cache_ttl_secs = 604800 +max_requests_per_turn = 100 +max_tool_failure_per_turn = 3 +top_p = 0.8 +top_k = 30 +max_tokens = 20480 [retry] initial_backoff_ms = 200 @@ -40,3 +45,15 @@ keep_alive_interval_secs = 60 keep_alive_timeout_secs = 10 keep_alive_while_idle = true accept_invalid_certs = false + +[compact] +max_tokens = 2000 +token_threshold = 100000 +retention_window = 6 +message_threshold = 200 +eviction_window = 0.2 +on_turn_end = false + +[updates] +frequency = "daily" +auto_update = true From 83c777fddfdabe1c31fa9cc79baf519e4bd04e0f Mon Sep 17 00:00:00 2001 From: Tushar Date: Tue, 24 Mar 2026 18:09:56 +0530 Subject: [PATCH 18/67] refactor(forge_config): make config path optional in reader --- .../{.config.toml => .forge.toml} | 0 crates/forge_config/src/config.rs | 2 +- crates/forge_config/src/reader.rs | 29 ++++++++++++++----- 3 files changed, 22 insertions(+), 9 deletions(-) rename crates/forge_config/{.config.toml => .forge.toml} (100%) diff --git a/crates/forge_config/.config.toml b/crates/forge_config/.forge.toml similarity index 100% rename from crates/forge_config/.config.toml rename to crates/forge_config/.forge.toml diff --git a/crates/forge_config/src/config.rs b/crates/forge_config/src/config.rs index f647c72a25..87264967d3 100644 --- a/crates/forge_config/src/config.rs +++ b/crates/forge_config/src/config.rs @@ -220,7 +220,7 @@ impl ForgeConfig { /// be read, or the configuration cannot be deserialized. pub async fn read() -> crate::Result { let path = Self::config_path()?; - ConfigReader::new().read(&path).await + ConfigReader::new().read(Some(&path)).await } /// Writes the configuration to the user config file. diff --git a/crates/forge_config/src/reader.rs b/crates/forge_config/src/reader.rs index 50daf1c335..2010b88814 100644 --- a/crates/forge_config/src/reader.rs +++ b/crates/forge_config/src/reader.rs @@ -19,20 +19,22 @@ impl ConfigReader { /// [`ForgeConfig`]. /// /// Sources are applied in increasing priority order: embedded defaults, - /// the file at `path`, then environment variables prefixed with `FORGE_`. - pub async fn read(&self, path: &Path) -> crate::Result { - // Embed the default config at compile time as the lowest-priority base. - let defaults = include_str!("../.config.toml"); + /// the optional file at `path` (skipped when `None`), then environment + /// variables prefixed with `FORGE_`. + pub async fn read(&self, path: Option<&Path>) -> crate::Result { + let defaults = include_str!("../.forge.toml"); let mut builder = Config::builder(); // Load default builder = builder.add_source(config::File::from_str(defaults, config::FileFormat::Toml)); // Load from path - if tokio::fs::try_exists(path).await? { - let contents = tokio::fs::read_to_string(path).await?; - builder = - builder.add_source(config::File::from_str(&contents, config::FileFormat::Toml)); + if let Some(path) = path { + if tokio::fs::try_exists(path).await? { + let contents = tokio::fs::read_to_string(path).await?; + builder = + builder.add_source(config::File::from_str(&contents, config::FileFormat::Toml)); + } } // Load from environment @@ -47,3 +49,14 @@ impl ConfigReader { Ok(config.try_deserialize()?) } } + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn test_read_parses_without_error() { + let actual = ConfigReader::new().read(None).await; + assert!(actual.is_ok(), "read() failed: {:?}", actual.err()); + } +} From b8e81d166c9e4700b673c2cca86ae28a9e8c3194 Mon Sep 17 00:00:00 2001 From: Tushar Date: Tue, 24 Mar 2026 18:34:58 +0530 Subject: [PATCH 19/67] refactor(forge_repo): decouple disk appconfig from domain type --- crates/forge_repo/src/app_config.rs | 83 ++++++++++++++++++++++------- 1 file changed, 64 insertions(+), 19 deletions(-) diff --git a/crates/forge_repo/src/app_config.rs b/crates/forge_repo/src/app_config.rs index 65e9367a9a..32ddef37c5 100644 --- a/crates/forge_repo/src/app_config.rs +++ b/crates/forge_repo/src/app_config.rs @@ -1,11 +1,56 @@ +use std::collections::HashMap; use std::sync::Arc; use anyhow::bail; use bytes::Bytes; use forge_app::{EnvironmentInfra, FileReaderInfra, FileWriterInfra}; -use forge_domain::{AppConfig, AppConfigRepository, ModelId, ProviderId}; +use forge_domain::{AppConfigRepository, CommitConfig, ModelId, ProviderId, SuggestConfig}; +use serde::{Deserialize, Serialize}; use tokio::sync::Mutex; +/// Local representation of the application configuration stored on disk. +/// +/// This mirrors `forge_domain::AppConfig` but is kept private to the repository +/// crate so that the serialization format is decoupled from the domain type. +/// Use `From for forge_domain::AppConfig` to convert after reading. +#[derive(Default, Clone, Serialize, Deserialize, Debug, PartialEq)] +#[serde(rename_all = "camelCase")] +struct AppConfig { + pub key_info: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub provider: Option, + #[serde(default, skip_serializing_if = "HashMap::is_empty")] + pub model: HashMap, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub commit: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub suggest: Option, +} + +impl From for forge_domain::AppConfig { + fn from(value: AppConfig) -> Self { + forge_domain::AppConfig { + key_info: value.key_info, + provider: value.provider, + model: value.model, + commit: value.commit, + suggest: value.suggest, + } + } +} + +impl From for AppConfig { + fn from(value: forge_domain::AppConfig) -> Self { + AppConfig { + key_info: value.key_info, + provider: value.provider, + model: value.model, + commit: value.commit, + suggest: value.suggest, + } + } +} + /// Repository for managing application configuration with caching support. /// /// This repository uses infrastructure traits for file I/O operations and @@ -117,12 +162,12 @@ impl AppConfigRepositor impl AppConfigRepository for AppConfigRepositoryImpl { - async fn get_app_config(&self) -> anyhow::Result { + async fn get_app_config(&self) -> anyhow::Result { // Check cache first let cache = self.cache.lock().await; if let Some(ref cached_config) = *cache { // Apply overrides even to cached config since overrides can change via env vars - return Ok(self.apply_overrides(cached_config.clone())); + return Ok(self.apply_overrides(cached_config.clone()).into()); } drop(cache); @@ -134,17 +179,17 @@ impl AppC *cache = Some(config.clone()); // Apply overrides to the config before returning - Ok(self.apply_overrides(config)) + Ok(self.apply_overrides(config).into()) } - async fn set_app_config(&self, config: &AppConfig) -> anyhow::Result<()> { + async fn set_app_config(&self, config: &forge_domain::AppConfig) -> anyhow::Result<()> { let (model, provider) = self.get_overrides(); if model.is_some() || provider.is_some() { bail!("Could not save configuration: Model or Provider was overridden") } - self.write(config).await?; + self.write(&AppConfig::from(config.clone())).await?; // Bust the cache after successful write let mut cache = self.cache.lock().await; @@ -164,7 +209,7 @@ mod tests { use bytes::Bytes; use forge_app::{EnvironmentInfra, FileReaderInfra, FileWriterInfra}; - use forge_domain::{AppConfig, Environment, ProviderId}; + use forge_domain::{Environment, ProviderId}; use pretty_assertions::assert_eq; use tempfile::TempDir; @@ -265,7 +310,7 @@ mod tests { let config_path = temp_dir.path().join(".config.json"); // Create a config file with default config - let config = AppConfig::default(); + let config = forge_domain::AppConfig::default(); let content = serde_json::to_string_pretty(&config).unwrap(); let infra = Arc::new(MockInfra::new(config_path.clone())); @@ -276,7 +321,7 @@ mod tests { #[tokio::test] async fn test_get_app_config_exists() { - let expected = AppConfig::default(); + let expected = forge_domain::AppConfig::default(); let (repo, _temp_dir) = repository_with_config_fixture(); let actual = repo.get_app_config().await.unwrap(); @@ -291,13 +336,13 @@ mod tests { let actual = repo.get_app_config().await.unwrap(); // Should return default config when file doesn't exist - let expected = AppConfig::default(); + let expected = forge_domain::AppConfig::default(); assert_eq!(actual, expected); } #[tokio::test] async fn test_set_app_config() { - let fixture = AppConfig::default(); + let fixture = forge_domain::AppConfig::default(); let (repo, _temp_dir) = repository_fixture(); let actual = repo.set_app_config(&fixture).await; @@ -321,7 +366,7 @@ mod tests { assert_eq!(first_read, second_read); // Write new config should bust cache - let new_config = AppConfig::default(); + let new_config = forge_domain::AppConfig::default(); repo.set_app_config(&new_config).await.unwrap(); // Next read should get fresh data @@ -349,7 +394,7 @@ mod tests { let actual = repo.get_app_config().await.unwrap(); - let expected = AppConfig { + let expected = forge_domain::AppConfig { provider: Some(ProviderId::from_str("xyz").unwrap()), ..Default::default() }; @@ -363,7 +408,7 @@ mod tests { let config = repo.get_app_config().await.unwrap(); // Config should be the default - assert_eq!(config, AppConfig::default()); + assert_eq!(config, forge_domain::AppConfig::default()); } #[tokio::test] @@ -372,7 +417,7 @@ mod tests { let config_path = temp_dir.path().join(".config.json"); // Set up a config with a specific model - let mut config = AppConfig::default(); + let mut config = forge_domain::AppConfig::default(); config.model.insert( ProviderId::ANTHROPIC, ModelId::new("claude-3-5-sonnet-20241022"), @@ -399,7 +444,7 @@ mod tests { let config_path = temp_dir.path().join(".config.json"); // Set up a config with a specific provider - let config = AppConfig { provider: Some(ProviderId::ANTHROPIC), ..Default::default() }; + let config = forge_domain::AppConfig { provider: Some(ProviderId::ANTHROPIC), ..Default::default() }; let content = serde_json::to_string_pretty(&config).unwrap(); let infra = Arc::new(MockInfra::new(config_path.clone())); @@ -422,7 +467,7 @@ mod tests { AppConfigRepositoryImpl::new(infra).override_model(ModelId::new("override-model")); // Attempting to write config when override is set should fail - let config = AppConfig::default(); + let config = forge_domain::AppConfig::default(); let actual = repo.set_app_config(&config).await; assert!(actual.is_err()); @@ -515,7 +560,7 @@ mod tests { let expected = ModelId::new("override-model"); // Set up config with provider but no model - let config = AppConfig { provider: Some(ProviderId::ANTHROPIC), ..Default::default() }; + let config = forge_domain::AppConfig { provider: Some(ProviderId::ANTHROPIC), ..Default::default() }; let content = serde_json::to_string_pretty(&config).unwrap(); let infra = Arc::new(MockInfra::new(config_path.clone())); @@ -566,6 +611,6 @@ mod tests { let repo = AppConfigRepositoryImpl::new(infra); let actual = repo.get_app_config().await.unwrap(); - assert_eq!(actual, AppConfig::default()); + assert_eq!(actual, forge_domain::AppConfig::default()); } } From 8b46e616beaafaf8c4bfa7fd6d41cc725215e65d Mon Sep 17 00:00:00 2001 From: Tushar Date: Tue, 24 Mar 2026 18:35:33 +0530 Subject: [PATCH 20/67] refactor(domain): rename AppConfig to ForgeConfig everywhere --- crates/forge_domain/src/app_config.rs | 2 +- crates/forge_domain/src/repo.rs | 6 ++--- crates/forge_repo/src/app_config.rs | 36 ++++++++++++------------- crates/forge_repo/src/forge_repo.rs | 6 ++--- crates/forge_services/src/app_config.rs | 14 +++++----- 5 files changed, 32 insertions(+), 32 deletions(-) diff --git a/crates/forge_domain/src/app_config.rs b/crates/forge_domain/src/app_config.rs index 9f1fc3894c..0b8cf05ddf 100644 --- a/crates/forge_domain/src/app_config.rs +++ b/crates/forge_domain/src/app_config.rs @@ -15,7 +15,7 @@ pub struct InitAuth { #[derive(Default, Clone, Serialize, Deserialize, Debug, PartialEq)] #[serde(rename_all = "camelCase")] -pub struct AppConfig { +pub struct ForgeConfig { pub key_info: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub provider: Option, diff --git a/crates/forge_domain/src/repo.rs b/crates/forge_domain/src/repo.rs index e3602f71ce..187def12d9 100644 --- a/crates/forge_domain/src/repo.rs +++ b/crates/forge_domain/src/repo.rs @@ -4,7 +4,7 @@ use anyhow::Result; use url::Url; use crate::{ - AnyProvider, AppConfig, AuthCredential, ChatCompletionMessage, Context, Conversation, + AnyProvider, ForgeConfig, AuthCredential, ChatCompletionMessage, Context, Conversation, ConversationId, MigrationResult, Model, ModelId, Provider, ProviderId, ProviderTemplate, ResultStream, SearchMatch, Skill, Snapshot, WorkspaceAuth, WorkspaceId, }; @@ -91,8 +91,8 @@ pub trait ConversationRepository: Send + Sync { #[async_trait::async_trait] pub trait AppConfigRepository: Send + Sync { - async fn get_app_config(&self) -> anyhow::Result; - async fn set_app_config(&self, config: &AppConfig) -> anyhow::Result<()>; + async fn get_app_config(&self) -> anyhow::Result; + async fn set_app_config(&self, config: &ForgeConfig) -> anyhow::Result<()>; } #[async_trait::async_trait] diff --git a/crates/forge_repo/src/app_config.rs b/crates/forge_repo/src/app_config.rs index 32ddef37c5..05bd866540 100644 --- a/crates/forge_repo/src/app_config.rs +++ b/crates/forge_repo/src/app_config.rs @@ -27,9 +27,9 @@ struct AppConfig { pub suggest: Option, } -impl From for forge_domain::AppConfig { +impl From for forge_domain::ForgeConfig { fn from(value: AppConfig) -> Self { - forge_domain::AppConfig { + forge_domain::ForgeConfig { key_info: value.key_info, provider: value.provider, model: value.model, @@ -39,8 +39,8 @@ impl From for forge_domain::AppConfig { } } -impl From for AppConfig { - fn from(value: forge_domain::AppConfig) -> Self { +impl From for AppConfig { + fn from(value: forge_domain::ForgeConfig) -> Self { AppConfig { key_info: value.key_info, provider: value.provider, @@ -162,7 +162,7 @@ impl AppConfigRepositor impl AppConfigRepository for AppConfigRepositoryImpl { - async fn get_app_config(&self) -> anyhow::Result { + async fn get_app_config(&self) -> anyhow::Result { // Check cache first let cache = self.cache.lock().await; if let Some(ref cached_config) = *cache { @@ -182,7 +182,7 @@ impl AppC Ok(self.apply_overrides(config).into()) } - async fn set_app_config(&self, config: &forge_domain::AppConfig) -> anyhow::Result<()> { + async fn set_app_config(&self, config: &forge_domain::ForgeConfig) -> anyhow::Result<()> { let (model, provider) = self.get_overrides(); if model.is_some() || provider.is_some() { @@ -310,7 +310,7 @@ mod tests { let config_path = temp_dir.path().join(".config.json"); // Create a config file with default config - let config = forge_domain::AppConfig::default(); + let config = forge_domain::ForgeConfig::default(); let content = serde_json::to_string_pretty(&config).unwrap(); let infra = Arc::new(MockInfra::new(config_path.clone())); @@ -321,7 +321,7 @@ mod tests { #[tokio::test] async fn test_get_app_config_exists() { - let expected = forge_domain::AppConfig::default(); + let expected = forge_domain::ForgeConfig::default(); let (repo, _temp_dir) = repository_with_config_fixture(); let actual = repo.get_app_config().await.unwrap(); @@ -336,13 +336,13 @@ mod tests { let actual = repo.get_app_config().await.unwrap(); // Should return default config when file doesn't exist - let expected = forge_domain::AppConfig::default(); + let expected = forge_domain::ForgeConfig::default(); assert_eq!(actual, expected); } #[tokio::test] async fn test_set_app_config() { - let fixture = forge_domain::AppConfig::default(); + let fixture = forge_domain::ForgeConfig::default(); let (repo, _temp_dir) = repository_fixture(); let actual = repo.set_app_config(&fixture).await; @@ -366,7 +366,7 @@ mod tests { assert_eq!(first_read, second_read); // Write new config should bust cache - let new_config = forge_domain::AppConfig::default(); + let new_config = forge_domain::ForgeConfig::default(); repo.set_app_config(&new_config).await.unwrap(); // Next read should get fresh data @@ -394,7 +394,7 @@ mod tests { let actual = repo.get_app_config().await.unwrap(); - let expected = forge_domain::AppConfig { + let expected = forge_domain::ForgeConfig { provider: Some(ProviderId::from_str("xyz").unwrap()), ..Default::default() }; @@ -408,7 +408,7 @@ mod tests { let config = repo.get_app_config().await.unwrap(); // Config should be the default - assert_eq!(config, forge_domain::AppConfig::default()); + assert_eq!(config, forge_domain::ForgeConfig::default()); } #[tokio::test] @@ -417,7 +417,7 @@ mod tests { let config_path = temp_dir.path().join(".config.json"); // Set up a config with a specific model - let mut config = forge_domain::AppConfig::default(); + let mut config = forge_domain::ForgeConfig::default(); config.model.insert( ProviderId::ANTHROPIC, ModelId::new("claude-3-5-sonnet-20241022"), @@ -444,7 +444,7 @@ mod tests { let config_path = temp_dir.path().join(".config.json"); // Set up a config with a specific provider - let config = forge_domain::AppConfig { provider: Some(ProviderId::ANTHROPIC), ..Default::default() }; + let config = forge_domain::ForgeConfig { provider: Some(ProviderId::ANTHROPIC), ..Default::default() }; let content = serde_json::to_string_pretty(&config).unwrap(); let infra = Arc::new(MockInfra::new(config_path.clone())); @@ -467,7 +467,7 @@ mod tests { AppConfigRepositoryImpl::new(infra).override_model(ModelId::new("override-model")); // Attempting to write config when override is set should fail - let config = forge_domain::AppConfig::default(); + let config = forge_domain::ForgeConfig::default(); let actual = repo.set_app_config(&config).await; assert!(actual.is_err()); @@ -560,7 +560,7 @@ mod tests { let expected = ModelId::new("override-model"); // Set up config with provider but no model - let config = forge_domain::AppConfig { provider: Some(ProviderId::ANTHROPIC), ..Default::default() }; + let config = forge_domain::ForgeConfig { provider: Some(ProviderId::ANTHROPIC), ..Default::default() }; let content = serde_json::to_string_pretty(&config).unwrap(); let infra = Arc::new(MockInfra::new(config_path.clone())); @@ -611,6 +611,6 @@ mod tests { let repo = AppConfigRepositoryImpl::new(infra); let actual = repo.get_app_config().await.unwrap(); - assert_eq!(actual, forge_domain::AppConfig::default()); + assert_eq!(actual, forge_domain::ForgeConfig::default()); } } diff --git a/crates/forge_repo/src/forge_repo.rs b/crates/forge_repo/src/forge_repo.rs index dde20149e0..22f6c4a28d 100644 --- a/crates/forge_repo/src/forge_repo.rs +++ b/crates/forge_repo/src/forge_repo.rs @@ -9,7 +9,7 @@ use forge_app::{ KVStore, McpServerInfra, StrategyFactory, UserInfra, WalkedFile, Walker, WalkerInfra, }; use forge_domain::{ - AnyProvider, AppConfig, AppConfigRepository, AuthCredential, ChatCompletionMessage, + AnyProvider, ForgeConfig, AppConfigRepository, AuthCredential, ChatCompletionMessage, ChatRepository, CommandOutput, Context, Conversation, ConversationId, ConversationRepository, Environment, FileInfo, FuzzySearchRepository, McpServerConfig, MigrationResult, Model, ModelId, Provider, ProviderId, ProviderRepository, ResultStream, SearchMatch, Skill, SkillRepository, @@ -205,11 +205,11 @@ impl AppConfigRepository for ForgeRepo { - async fn get_app_config(&self) -> anyhow::Result { + async fn get_app_config(&self) -> anyhow::Result { self.app_config_repository.get_app_config().await } - async fn set_app_config(&self, config: &AppConfig) -> anyhow::Result<()> { + async fn set_app_config(&self, config: &ForgeConfig) -> anyhow::Result<()> { self.app_config_repository.set_app_config(config).await } } diff --git a/crates/forge_services/src/app_config.rs b/crates/forge_services/src/app_config.rs index 7e7e851c1f..192c83dc66 100644 --- a/crates/forge_services/src/app_config.rs +++ b/crates/forge_services/src/app_config.rs @@ -1,7 +1,7 @@ use std::sync::Arc; use forge_app::AppConfigService; -use forge_domain::{AppConfig, AppConfigRepository, ModelId, ProviderId, ProviderRepository}; +use forge_domain::{ForgeConfig, AppConfigRepository, ModelId, ProviderId, ProviderRepository}; /// Service for managing user preferences for default providers and models. pub struct ForgeAppConfigService { @@ -19,7 +19,7 @@ impl ForgeAppConfigService { /// Helper method to update app configuration atomically. async fn update(&self, updater: U) -> anyhow::Result<()> where - U: FnOnce(&mut AppConfig), + U: FnOnce(&mut ForgeConfig), { let mut config = self.infra.get_app_config().await?; updater(&mut config); @@ -118,7 +118,7 @@ mod tests { use std::sync::Mutex; use forge_domain::{ - AnyProvider, AppConfig, ChatRepository, InputModality, MigrationResult, Model, ModelSource, + AnyProvider, ForgeConfig, ChatRepository, InputModality, MigrationResult, Model, ModelSource, Provider, ProviderId, ProviderResponse, ProviderTemplate, }; use pretty_assertions::assert_eq; @@ -128,14 +128,14 @@ mod tests { #[derive(Clone)] struct MockInfra { - app_config: Arc>, + app_config: Arc>, providers: Vec>, } impl MockInfra { fn new() -> Self { Self { - app_config: Arc::new(Mutex::new(AppConfig::default())), + app_config: Arc::new(Mutex::new(ForgeConfig::default())), providers: vec![ Provider { id: ProviderId::OPENAI, @@ -196,11 +196,11 @@ mod tests { #[async_trait::async_trait] impl AppConfigRepository for MockInfra { - async fn get_app_config(&self) -> anyhow::Result { + async fn get_app_config(&self) -> anyhow::Result { Ok(self.app_config.lock().unwrap().clone()) } - async fn set_app_config(&self, config: &AppConfig) -> anyhow::Result<()> { + async fn set_app_config(&self, config: &ForgeConfig) -> anyhow::Result<()> { *self.app_config.lock().unwrap() = config.clone(); Ok(()) } From e1e509d695ed67c72fc391aded7aca4b78fa8287 Mon Sep 17 00:00:00 2001 From: Tushar Date: Tue, 24 Mar 2026 18:59:59 +0530 Subject: [PATCH 21/67] refactor(forge_config): simplify provider/model config types --- Cargo.lock | 1 - crates/forge_config/Cargo.toml | 1 - crates/forge_config/src/config.rs | 10 ++---- crates/forge_config/src/model.rs | 55 ++++--------------------------- 4 files changed, 9 insertions(+), 58 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 614eee70dc..59eb0669f0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1940,7 +1940,6 @@ name = "forge_config" version = "0.1.0" dependencies = [ "config", - "derive_more", "derive_setters", "dirs", "fake", diff --git a/crates/forge_config/Cargo.toml b/crates/forge_config/Cargo.toml index 06be3ca7d0..40d4d5f501 100644 --- a/crates/forge_config/Cargo.toml +++ b/crates/forge_config/Cargo.toml @@ -7,7 +7,6 @@ rust-version.workspace = true [dependencies] thiserror.workspace = true config = { version = "0.15", features = ["toml"] } -derive_more.workspace = true derive_setters.workspace = true dirs.workspace = true tokio = { workspace = true, features = ["fs"] } diff --git a/crates/forge_config/src/config.rs b/crates/forge_config/src/config.rs index 87264967d3..618f3d42e9 100644 --- a/crates/forge_config/src/config.rs +++ b/crates/forge_config/src/config.rs @@ -6,8 +6,8 @@ use serde::{Deserialize, Serialize}; use url::Url; use crate::{ - AutoDumpFormat, Compact, HttpConfig, MaxTokens, ModelConfig, ModelId, ProviderId, RetryConfig, - Temperature, TopK, TopP, Update, reader::ConfigReader, writer::ConfigWriter, + AutoDumpFormat, Compact, HttpConfig, MaxTokens, ModelConfig, RetryConfig, Temperature, TopK, + TopP, Update, reader::ConfigReader, writer::ConfigWriter, }; /// Forge configuration containing all the fields from the Environment struct. @@ -92,12 +92,9 @@ pub struct ForgeConfig { pub max_parallel_file_reads: usize, /// TTL in seconds for the model API list cache pub model_cache_ttl_secs: u64, - /// Default provider ID to use for AI operations - #[serde(default)] - pub provider: Option, /// Map of provider ID to model ID for per-provider model selection #[serde(default)] - pub model: HashMap, + pub model: Option, /// Provider and model to use for commit message generation #[serde(default)] pub commit: Option, @@ -124,7 +121,6 @@ pub struct ForgeConfig { pub auth_provider_id: Option, // --- Workflow fields --- - /// Configuration for automatic forge updates #[serde(skip_serializing_if = "Option::is_none")] #[dummy(default)] diff --git a/crates/forge_config/src/model.rs b/crates/forge_config/src/model.rs index 93634b9821..198ffd0a53 100644 --- a/crates/forge_config/src/model.rs +++ b/crates/forge_config/src/model.rs @@ -1,61 +1,18 @@ -use derive_more::{AsRef, Deref, Display, From}; use serde::{Deserialize, Serialize}; -/// A newtype wrapper for a provider identifier string. -#[derive( - Debug, - Clone, - PartialEq, - Eq, - Hash, - Serialize, - Deserialize, - Display, - From, - AsRef, - Deref, - fake::Dummy, -)] -pub struct ProviderId(String); +/// A type alias for a provider identifier string. +pub type ProviderId = String; -impl ProviderId { - /// Creates a new `ProviderId` from the given string value. - pub fn new(value: impl Into) -> Self { - Self(value.into()) - } -} - -/// A newtype wrapper for a model identifier string. -#[derive( - Debug, - Clone, - PartialEq, - Eq, - Hash, - Serialize, - Deserialize, - Display, - From, - AsRef, - Deref, - fake::Dummy, -)] -pub struct ModelId(String); - -impl ModelId { - /// Creates a new `ModelId` from the given string value. - pub fn new(value: impl Into) -> Self { - Self(value.into()) - } -} +/// A type alias for a model identifier string. +pub type ModelId = String; /// Pairs a provider and model together for a specific operation. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, fake::Dummy)] pub struct ModelConfig { /// The provider to use for this operation. #[serde(rename = "provider")] - pub provider_id: ProviderId, + pub provider_id: String, /// The model to use for this operation. #[serde(rename = "model")] - pub model_id: ModelId, + pub model_id: String, } From d05e59956d12692b54b0454a416882811dbb45ce Mon Sep 17 00:00:00 2001 From: Tushar Date: Tue, 24 Mar 2026 19:00:14 +0530 Subject: [PATCH 22/67] refactor(forge_config): rename ForgeConfig to AppConfig type --- crates/forge_config/src/config.rs | 6 +++--- crates/forge_config/src/reader.rs | 4 ++-- crates/forge_config/src/writer.rs | 6 +++--- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/crates/forge_config/src/config.rs b/crates/forge_config/src/config.rs index 618f3d42e9..cfd8e54b6c 100644 --- a/crates/forge_config/src/config.rs +++ b/crates/forge_config/src/config.rs @@ -40,7 +40,7 @@ use crate::{ #[derive(Debug, Setters, Clone, PartialEq, Serialize, Deserialize, fake::Dummy)] #[serde(rename_all = "snake_case")] #[setters(strip_option)] -pub struct ForgeConfig { +pub struct AppConfig { /// Configuration for the retry mechanism pub retry: RetryConfig, /// The maximum number of lines returned for FSSearch @@ -194,7 +194,7 @@ pub struct ForgeConfig { pub compact: Option, } -impl ForgeConfig { +impl AppConfig { /// Returns the path to the user configuration file: `~/.forge/.forge.toml`. /// /// # Errors @@ -214,7 +214,7 @@ impl ForgeConfig { /// /// Returns an error if the config path cannot be resolved, the file cannot /// be read, or the configuration cannot be deserialized. - pub async fn read() -> crate::Result { + pub async fn read() -> crate::Result { let path = Self::config_path()?; ConfigReader::new().read(Some(&path)).await } diff --git a/crates/forge_config/src/reader.rs b/crates/forge_config/src/reader.rs index 2010b88814..fd911c2047 100644 --- a/crates/forge_config/src/reader.rs +++ b/crates/forge_config/src/reader.rs @@ -2,7 +2,7 @@ use std::path::Path; use config::Config; -use crate::ForgeConfig; +use crate::AppConfig; /// Reads and merges [`ForgeConfig`] from multiple sources: embedded defaults, /// home directory file, current working directory file, and environment @@ -21,7 +21,7 @@ impl ConfigReader { /// Sources are applied in increasing priority order: embedded defaults, /// the optional file at `path` (skipped when `None`), then environment /// variables prefixed with `FORGE_`. - pub async fn read(&self, path: Option<&Path>) -> crate::Result { + pub async fn read(&self, path: Option<&Path>) -> crate::Result { let defaults = include_str!("../.forge.toml"); let mut builder = Config::builder(); diff --git a/crates/forge_config/src/writer.rs b/crates/forge_config/src/writer.rs index b8ddf61092..4787edd9b5 100644 --- a/crates/forge_config/src/writer.rs +++ b/crates/forge_config/src/writer.rs @@ -1,15 +1,15 @@ use std::path::Path; -use crate::ForgeConfig; +use crate::AppConfig; /// Writes a [`ForgeConfig`] to the user configuration file on disk. pub struct ConfigWriter { - config: ForgeConfig, + config: AppConfig, } impl ConfigWriter { /// Creates a new `ConfigWriter` for the given configuration. - pub fn new(config: ForgeConfig) -> Self { + pub fn new(config: AppConfig) -> Self { Self { config } } From 7943361aa0d34112286a9133a2cac015905c6e37 Mon Sep 17 00:00:00 2001 From: Tushar Date: Tue, 24 Mar 2026 19:00:26 +0530 Subject: [PATCH 23/67] refactor(domain): rename ForgeConfig to AppConfig type --- crates/forge_domain/src/app_config.rs | 2 +- crates/forge_domain/src/repo.rs | 6 ++--- crates/forge_repo/src/app_config.rs | 36 ++++++++++++------------- crates/forge_repo/src/forge_repo.rs | 6 ++--- crates/forge_services/src/app_config.rs | 14 +++++----- 5 files changed, 32 insertions(+), 32 deletions(-) diff --git a/crates/forge_domain/src/app_config.rs b/crates/forge_domain/src/app_config.rs index 0b8cf05ddf..9f1fc3894c 100644 --- a/crates/forge_domain/src/app_config.rs +++ b/crates/forge_domain/src/app_config.rs @@ -15,7 +15,7 @@ pub struct InitAuth { #[derive(Default, Clone, Serialize, Deserialize, Debug, PartialEq)] #[serde(rename_all = "camelCase")] -pub struct ForgeConfig { +pub struct AppConfig { pub key_info: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub provider: Option, diff --git a/crates/forge_domain/src/repo.rs b/crates/forge_domain/src/repo.rs index 187def12d9..e3602f71ce 100644 --- a/crates/forge_domain/src/repo.rs +++ b/crates/forge_domain/src/repo.rs @@ -4,7 +4,7 @@ use anyhow::Result; use url::Url; use crate::{ - AnyProvider, ForgeConfig, AuthCredential, ChatCompletionMessage, Context, Conversation, + AnyProvider, AppConfig, AuthCredential, ChatCompletionMessage, Context, Conversation, ConversationId, MigrationResult, Model, ModelId, Provider, ProviderId, ProviderTemplate, ResultStream, SearchMatch, Skill, Snapshot, WorkspaceAuth, WorkspaceId, }; @@ -91,8 +91,8 @@ pub trait ConversationRepository: Send + Sync { #[async_trait::async_trait] pub trait AppConfigRepository: Send + Sync { - async fn get_app_config(&self) -> anyhow::Result; - async fn set_app_config(&self, config: &ForgeConfig) -> anyhow::Result<()>; + async fn get_app_config(&self) -> anyhow::Result; + async fn set_app_config(&self, config: &AppConfig) -> anyhow::Result<()>; } #[async_trait::async_trait] diff --git a/crates/forge_repo/src/app_config.rs b/crates/forge_repo/src/app_config.rs index 05bd866540..32ddef37c5 100644 --- a/crates/forge_repo/src/app_config.rs +++ b/crates/forge_repo/src/app_config.rs @@ -27,9 +27,9 @@ struct AppConfig { pub suggest: Option, } -impl From for forge_domain::ForgeConfig { +impl From for forge_domain::AppConfig { fn from(value: AppConfig) -> Self { - forge_domain::ForgeConfig { + forge_domain::AppConfig { key_info: value.key_info, provider: value.provider, model: value.model, @@ -39,8 +39,8 @@ impl From for forge_domain::ForgeConfig { } } -impl From for AppConfig { - fn from(value: forge_domain::ForgeConfig) -> Self { +impl From for AppConfig { + fn from(value: forge_domain::AppConfig) -> Self { AppConfig { key_info: value.key_info, provider: value.provider, @@ -162,7 +162,7 @@ impl AppConfigRepositor impl AppConfigRepository for AppConfigRepositoryImpl { - async fn get_app_config(&self) -> anyhow::Result { + async fn get_app_config(&self) -> anyhow::Result { // Check cache first let cache = self.cache.lock().await; if let Some(ref cached_config) = *cache { @@ -182,7 +182,7 @@ impl AppC Ok(self.apply_overrides(config).into()) } - async fn set_app_config(&self, config: &forge_domain::ForgeConfig) -> anyhow::Result<()> { + async fn set_app_config(&self, config: &forge_domain::AppConfig) -> anyhow::Result<()> { let (model, provider) = self.get_overrides(); if model.is_some() || provider.is_some() { @@ -310,7 +310,7 @@ mod tests { let config_path = temp_dir.path().join(".config.json"); // Create a config file with default config - let config = forge_domain::ForgeConfig::default(); + let config = forge_domain::AppConfig::default(); let content = serde_json::to_string_pretty(&config).unwrap(); let infra = Arc::new(MockInfra::new(config_path.clone())); @@ -321,7 +321,7 @@ mod tests { #[tokio::test] async fn test_get_app_config_exists() { - let expected = forge_domain::ForgeConfig::default(); + let expected = forge_domain::AppConfig::default(); let (repo, _temp_dir) = repository_with_config_fixture(); let actual = repo.get_app_config().await.unwrap(); @@ -336,13 +336,13 @@ mod tests { let actual = repo.get_app_config().await.unwrap(); // Should return default config when file doesn't exist - let expected = forge_domain::ForgeConfig::default(); + let expected = forge_domain::AppConfig::default(); assert_eq!(actual, expected); } #[tokio::test] async fn test_set_app_config() { - let fixture = forge_domain::ForgeConfig::default(); + let fixture = forge_domain::AppConfig::default(); let (repo, _temp_dir) = repository_fixture(); let actual = repo.set_app_config(&fixture).await; @@ -366,7 +366,7 @@ mod tests { assert_eq!(first_read, second_read); // Write new config should bust cache - let new_config = forge_domain::ForgeConfig::default(); + let new_config = forge_domain::AppConfig::default(); repo.set_app_config(&new_config).await.unwrap(); // Next read should get fresh data @@ -394,7 +394,7 @@ mod tests { let actual = repo.get_app_config().await.unwrap(); - let expected = forge_domain::ForgeConfig { + let expected = forge_domain::AppConfig { provider: Some(ProviderId::from_str("xyz").unwrap()), ..Default::default() }; @@ -408,7 +408,7 @@ mod tests { let config = repo.get_app_config().await.unwrap(); // Config should be the default - assert_eq!(config, forge_domain::ForgeConfig::default()); + assert_eq!(config, forge_domain::AppConfig::default()); } #[tokio::test] @@ -417,7 +417,7 @@ mod tests { let config_path = temp_dir.path().join(".config.json"); // Set up a config with a specific model - let mut config = forge_domain::ForgeConfig::default(); + let mut config = forge_domain::AppConfig::default(); config.model.insert( ProviderId::ANTHROPIC, ModelId::new("claude-3-5-sonnet-20241022"), @@ -444,7 +444,7 @@ mod tests { let config_path = temp_dir.path().join(".config.json"); // Set up a config with a specific provider - let config = forge_domain::ForgeConfig { provider: Some(ProviderId::ANTHROPIC), ..Default::default() }; + let config = forge_domain::AppConfig { provider: Some(ProviderId::ANTHROPIC), ..Default::default() }; let content = serde_json::to_string_pretty(&config).unwrap(); let infra = Arc::new(MockInfra::new(config_path.clone())); @@ -467,7 +467,7 @@ mod tests { AppConfigRepositoryImpl::new(infra).override_model(ModelId::new("override-model")); // Attempting to write config when override is set should fail - let config = forge_domain::ForgeConfig::default(); + let config = forge_domain::AppConfig::default(); let actual = repo.set_app_config(&config).await; assert!(actual.is_err()); @@ -560,7 +560,7 @@ mod tests { let expected = ModelId::new("override-model"); // Set up config with provider but no model - let config = forge_domain::ForgeConfig { provider: Some(ProviderId::ANTHROPIC), ..Default::default() }; + let config = forge_domain::AppConfig { provider: Some(ProviderId::ANTHROPIC), ..Default::default() }; let content = serde_json::to_string_pretty(&config).unwrap(); let infra = Arc::new(MockInfra::new(config_path.clone())); @@ -611,6 +611,6 @@ mod tests { let repo = AppConfigRepositoryImpl::new(infra); let actual = repo.get_app_config().await.unwrap(); - assert_eq!(actual, forge_domain::ForgeConfig::default()); + assert_eq!(actual, forge_domain::AppConfig::default()); } } diff --git a/crates/forge_repo/src/forge_repo.rs b/crates/forge_repo/src/forge_repo.rs index 22f6c4a28d..dde20149e0 100644 --- a/crates/forge_repo/src/forge_repo.rs +++ b/crates/forge_repo/src/forge_repo.rs @@ -9,7 +9,7 @@ use forge_app::{ KVStore, McpServerInfra, StrategyFactory, UserInfra, WalkedFile, Walker, WalkerInfra, }; use forge_domain::{ - AnyProvider, ForgeConfig, AppConfigRepository, AuthCredential, ChatCompletionMessage, + AnyProvider, AppConfig, AppConfigRepository, AuthCredential, ChatCompletionMessage, ChatRepository, CommandOutput, Context, Conversation, ConversationId, ConversationRepository, Environment, FileInfo, FuzzySearchRepository, McpServerConfig, MigrationResult, Model, ModelId, Provider, ProviderId, ProviderRepository, ResultStream, SearchMatch, Skill, SkillRepository, @@ -205,11 +205,11 @@ impl AppConfigRepository for ForgeRepo { - async fn get_app_config(&self) -> anyhow::Result { + async fn get_app_config(&self) -> anyhow::Result { self.app_config_repository.get_app_config().await } - async fn set_app_config(&self, config: &ForgeConfig) -> anyhow::Result<()> { + async fn set_app_config(&self, config: &AppConfig) -> anyhow::Result<()> { self.app_config_repository.set_app_config(config).await } } diff --git a/crates/forge_services/src/app_config.rs b/crates/forge_services/src/app_config.rs index 192c83dc66..7e7e851c1f 100644 --- a/crates/forge_services/src/app_config.rs +++ b/crates/forge_services/src/app_config.rs @@ -1,7 +1,7 @@ use std::sync::Arc; use forge_app::AppConfigService; -use forge_domain::{ForgeConfig, AppConfigRepository, ModelId, ProviderId, ProviderRepository}; +use forge_domain::{AppConfig, AppConfigRepository, ModelId, ProviderId, ProviderRepository}; /// Service for managing user preferences for default providers and models. pub struct ForgeAppConfigService { @@ -19,7 +19,7 @@ impl ForgeAppConfigService { /// Helper method to update app configuration atomically. async fn update(&self, updater: U) -> anyhow::Result<()> where - U: FnOnce(&mut ForgeConfig), + U: FnOnce(&mut AppConfig), { let mut config = self.infra.get_app_config().await?; updater(&mut config); @@ -118,7 +118,7 @@ mod tests { use std::sync::Mutex; use forge_domain::{ - AnyProvider, ForgeConfig, ChatRepository, InputModality, MigrationResult, Model, ModelSource, + AnyProvider, AppConfig, ChatRepository, InputModality, MigrationResult, Model, ModelSource, Provider, ProviderId, ProviderResponse, ProviderTemplate, }; use pretty_assertions::assert_eq; @@ -128,14 +128,14 @@ mod tests { #[derive(Clone)] struct MockInfra { - app_config: Arc>, + app_config: Arc>, providers: Vec>, } impl MockInfra { fn new() -> Self { Self { - app_config: Arc::new(Mutex::new(ForgeConfig::default())), + app_config: Arc::new(Mutex::new(AppConfig::default())), providers: vec![ Provider { id: ProviderId::OPENAI, @@ -196,11 +196,11 @@ mod tests { #[async_trait::async_trait] impl AppConfigRepository for MockInfra { - async fn get_app_config(&self) -> anyhow::Result { + async fn get_app_config(&self) -> anyhow::Result { Ok(self.app_config.lock().unwrap().clone()) } - async fn set_app_config(&self, config: &ForgeConfig) -> anyhow::Result<()> { + async fn set_app_config(&self, config: &AppConfig) -> anyhow::Result<()> { *self.app_config.lock().unwrap() = config.clone(); Ok(()) } From f12fe6203d2b74e25719313ebe670b0c7c998f5e Mon Sep 17 00:00:00 2001 From: Tushar Date: Tue, 24 Mar 2026 19:00:47 +0530 Subject: [PATCH 24/67] refactor(forge_config): use shared ConfigReader/ConfigWriter imports --- crates/forge_config/src/config.rs | 4 +++- crates/forge_repo/src/app_config.rs | 6 ++++-- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/crates/forge_config/src/config.rs b/crates/forge_config/src/config.rs index cfd8e54b6c..5c02db39f4 100644 --- a/crates/forge_config/src/config.rs +++ b/crates/forge_config/src/config.rs @@ -5,9 +5,11 @@ use derive_setters::Setters; use serde::{Deserialize, Serialize}; use url::Url; +use crate::reader::ConfigReader; +use crate::writer::ConfigWriter; use crate::{ AutoDumpFormat, Compact, HttpConfig, MaxTokens, ModelConfig, RetryConfig, Temperature, TopK, - TopP, Update, reader::ConfigReader, writer::ConfigWriter, + TopP, Update, }; /// Forge configuration containing all the fields from the Environment struct. diff --git a/crates/forge_repo/src/app_config.rs b/crates/forge_repo/src/app_config.rs index 32ddef37c5..e2d7d51fd5 100644 --- a/crates/forge_repo/src/app_config.rs +++ b/crates/forge_repo/src/app_config.rs @@ -444,7 +444,8 @@ mod tests { let config_path = temp_dir.path().join(".config.json"); // Set up a config with a specific provider - let config = forge_domain::AppConfig { provider: Some(ProviderId::ANTHROPIC), ..Default::default() }; + let config = + forge_domain::AppConfig { provider: Some(ProviderId::ANTHROPIC), ..Default::default() }; let content = serde_json::to_string_pretty(&config).unwrap(); let infra = Arc::new(MockInfra::new(config_path.clone())); @@ -560,7 +561,8 @@ mod tests { let expected = ModelId::new("override-model"); // Set up config with provider but no model - let config = forge_domain::AppConfig { provider: Some(ProviderId::ANTHROPIC), ..Default::default() }; + let config = + forge_domain::AppConfig { provider: Some(ProviderId::ANTHROPIC), ..Default::default() }; let content = serde_json::to_string_pretty(&config).unwrap(); let infra = Arc::new(MockInfra::new(config_path.clone())); From f2e52ceef033a9e5f3f05f3a6bf859ef8dd4851b Mon Sep 17 00:00:00 2001 From: Tushar Date: Tue, 24 Mar 2026 19:02:55 +0530 Subject: [PATCH 25/67] refactor(forge_config): simplify config reader path existence check --- crates/forge_config/src/config.rs | 1 - crates/forge_config/src/reader.rs | 5 ++--- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/crates/forge_config/src/config.rs b/crates/forge_config/src/config.rs index 5c02db39f4..cf89314f26 100644 --- a/crates/forge_config/src/config.rs +++ b/crates/forge_config/src/config.rs @@ -1,4 +1,3 @@ -use std::collections::HashMap; use std::path::PathBuf; use derive_setters::Setters; diff --git a/crates/forge_config/src/reader.rs b/crates/forge_config/src/reader.rs index fd911c2047..8a25eddf2a 100644 --- a/crates/forge_config/src/reader.rs +++ b/crates/forge_config/src/reader.rs @@ -29,13 +29,12 @@ impl ConfigReader { builder = builder.add_source(config::File::from_str(defaults, config::FileFormat::Toml)); // Load from path - if let Some(path) = path { - if tokio::fs::try_exists(path).await? { + if let Some(path) = path + && tokio::fs::try_exists(path).await? { let contents = tokio::fs::read_to_string(path).await?; builder = builder.add_source(config::File::from_str(&contents, config::FileFormat::Toml)); } - } // Load from environment builder = builder.add_source( From e04e13489ba8a947c3978789ae67847e9498ab17 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Tue, 24 Mar 2026 13:35:53 +0000 Subject: [PATCH 26/67] [autofix.ci] apply automated fixes --- crates/forge_config/src/reader.rs | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/crates/forge_config/src/reader.rs b/crates/forge_config/src/reader.rs index 8a25eddf2a..fe25e32d80 100644 --- a/crates/forge_config/src/reader.rs +++ b/crates/forge_config/src/reader.rs @@ -30,11 +30,12 @@ impl ConfigReader { // Load from path if let Some(path) = path - && tokio::fs::try_exists(path).await? { - let contents = tokio::fs::read_to_string(path).await?; - builder = - builder.add_source(config::File::from_str(&contents, config::FileFormat::Toml)); - } + && tokio::fs::try_exists(path).await? + { + let contents = tokio::fs::read_to_string(path).await?; + builder = + builder.add_source(config::File::from_str(&contents, config::FileFormat::Toml)); + } // Load from environment builder = builder.add_source( From 711698dea8eab6d0700e65a6de75f8cd7926fa18 Mon Sep 17 00:00:00 2001 From: Tushar Date: Tue, 24 Mar 2026 19:04:47 +0530 Subject: [PATCH 27/67] refactor(forge_config): rename AppConfig to ForgeConfig type --- crates/forge_config/src/config.rs | 6 +++--- crates/forge_config/src/reader.rs | 4 ++-- crates/forge_config/src/writer.rs | 6 +++--- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/crates/forge_config/src/config.rs b/crates/forge_config/src/config.rs index cf89314f26..84195d9bdb 100644 --- a/crates/forge_config/src/config.rs +++ b/crates/forge_config/src/config.rs @@ -41,7 +41,7 @@ use crate::{ #[derive(Debug, Setters, Clone, PartialEq, Serialize, Deserialize, fake::Dummy)] #[serde(rename_all = "snake_case")] #[setters(strip_option)] -pub struct AppConfig { +pub struct ForgeConfig { /// Configuration for the retry mechanism pub retry: RetryConfig, /// The maximum number of lines returned for FSSearch @@ -195,7 +195,7 @@ pub struct AppConfig { pub compact: Option, } -impl AppConfig { +impl ForgeConfig { /// Returns the path to the user configuration file: `~/.forge/.forge.toml`. /// /// # Errors @@ -215,7 +215,7 @@ impl AppConfig { /// /// Returns an error if the config path cannot be resolved, the file cannot /// be read, or the configuration cannot be deserialized. - pub async fn read() -> crate::Result { + pub async fn read() -> crate::Result { let path = Self::config_path()?; ConfigReader::new().read(Some(&path)).await } diff --git a/crates/forge_config/src/reader.rs b/crates/forge_config/src/reader.rs index fe25e32d80..c6453f047a 100644 --- a/crates/forge_config/src/reader.rs +++ b/crates/forge_config/src/reader.rs @@ -2,7 +2,7 @@ use std::path::Path; use config::Config; -use crate::AppConfig; +use crate::ForgeConfig; /// Reads and merges [`ForgeConfig`] from multiple sources: embedded defaults, /// home directory file, current working directory file, and environment @@ -21,7 +21,7 @@ impl ConfigReader { /// Sources are applied in increasing priority order: embedded defaults, /// the optional file at `path` (skipped when `None`), then environment /// variables prefixed with `FORGE_`. - pub async fn read(&self, path: Option<&Path>) -> crate::Result { + pub async fn read(&self, path: Option<&Path>) -> crate::Result { let defaults = include_str!("../.forge.toml"); let mut builder = Config::builder(); diff --git a/crates/forge_config/src/writer.rs b/crates/forge_config/src/writer.rs index 4787edd9b5..b8ddf61092 100644 --- a/crates/forge_config/src/writer.rs +++ b/crates/forge_config/src/writer.rs @@ -1,15 +1,15 @@ use std::path::Path; -use crate::AppConfig; +use crate::ForgeConfig; /// Writes a [`ForgeConfig`] to the user configuration file on disk. pub struct ConfigWriter { - config: AppConfig, + config: ForgeConfig, } impl ConfigWriter { /// Creates a new `ConfigWriter` for the given configuration. - pub fn new(config: AppConfig) -> Self { + pub fn new(config: ForgeConfig) -> Self { Self { config } } From 427a69e93aeafaadea412e0abd0961a7c5466d88 Mon Sep 17 00:00:00 2001 From: Tushar Date: Tue, 24 Mar 2026 21:09:58 +0530 Subject: [PATCH 28/67] refactor(forge_repo): decouple disk appconfig from domain type --- crates/forge_repo/src/app_config.rs | 57 +++-------------------------- 1 file changed, 6 insertions(+), 51 deletions(-) diff --git a/crates/forge_repo/src/app_config.rs b/crates/forge_repo/src/app_config.rs index e2d7d51fd5..c8955aa91b 100644 --- a/crates/forge_repo/src/app_config.rs +++ b/crates/forge_repo/src/app_config.rs @@ -1,56 +1,11 @@ -use std::collections::HashMap; use std::sync::Arc; use anyhow::bail; use bytes::Bytes; use forge_app::{EnvironmentInfra, FileReaderInfra, FileWriterInfra}; -use forge_domain::{AppConfigRepository, CommitConfig, ModelId, ProviderId, SuggestConfig}; -use serde::{Deserialize, Serialize}; +use forge_domain::{AppConfig, AppConfigRepository, ModelId, ProviderId}; use tokio::sync::Mutex; -/// Local representation of the application configuration stored on disk. -/// -/// This mirrors `forge_domain::AppConfig` but is kept private to the repository -/// crate so that the serialization format is decoupled from the domain type. -/// Use `From for forge_domain::AppConfig` to convert after reading. -#[derive(Default, Clone, Serialize, Deserialize, Debug, PartialEq)] -#[serde(rename_all = "camelCase")] -struct AppConfig { - pub key_info: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub provider: Option, - #[serde(default, skip_serializing_if = "HashMap::is_empty")] - pub model: HashMap, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub commit: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub suggest: Option, -} - -impl From for forge_domain::AppConfig { - fn from(value: AppConfig) -> Self { - forge_domain::AppConfig { - key_info: value.key_info, - provider: value.provider, - model: value.model, - commit: value.commit, - suggest: value.suggest, - } - } -} - -impl From for AppConfig { - fn from(value: forge_domain::AppConfig) -> Self { - AppConfig { - key_info: value.key_info, - provider: value.provider, - model: value.model, - commit: value.commit, - suggest: value.suggest, - } - } -} - /// Repository for managing application configuration with caching support. /// /// This repository uses infrastructure traits for file I/O operations and @@ -162,12 +117,12 @@ impl AppConfigRepositor impl AppConfigRepository for AppConfigRepositoryImpl { - async fn get_app_config(&self) -> anyhow::Result { + async fn get_app_config(&self) -> anyhow::Result { // Check cache first let cache = self.cache.lock().await; if let Some(ref cached_config) = *cache { // Apply overrides even to cached config since overrides can change via env vars - return Ok(self.apply_overrides(cached_config.clone()).into()); + return Ok(self.apply_overrides(cached_config.clone())); } drop(cache); @@ -179,17 +134,17 @@ impl AppC *cache = Some(config.clone()); // Apply overrides to the config before returning - Ok(self.apply_overrides(config).into()) + Ok(self.apply_overrides(config)) } - async fn set_app_config(&self, config: &forge_domain::AppConfig) -> anyhow::Result<()> { + async fn set_app_config(&self, config: &AppConfig) -> anyhow::Result<()> { let (model, provider) = self.get_overrides(); if model.is_some() || provider.is_some() { bail!("Could not save configuration: Model or Provider was overridden") } - self.write(&AppConfig::from(config.clone())).await?; + self.write(config).await?; // Bust the cache after successful write let mut cache = self.cache.lock().await; From 5522893a0dfb350e2525e626eb877f8d6ca0a3ab Mon Sep 17 00:00:00 2001 From: Tushar Date: Wed, 25 Mar 2026 11:11:57 +0530 Subject: [PATCH 29/67] feat(forge_config): add read_str and ConfigWriter to_string --- Cargo.lock | 1 + crates/forge_config/src/lib.rs | 2 + crates/forge_config/src/reader.rs | 27 + crates/forge_config/src/writer.rs | 9 + crates/forge_domain/src/app_config.rs | 7 +- crates/forge_repo/Cargo.toml | 1 + crates/forge_repo/src/app_config.rs | 775 ++++++++++++++++---------- crates/forge_repo/src/forge_repo.rs | 8 +- 8 files changed, 525 insertions(+), 305 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 59eb0669f0..e5689bed23 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2190,6 +2190,7 @@ dependencies = [ "eventsource-stream", "fake", "forge_app", + "forge_config", "forge_domain", "forge_fs", "forge_infra", diff --git a/crates/forge_config/src/lib.rs b/crates/forge_config/src/lib.rs index 13e201f3fe..24f0d13fd4 100644 --- a/crates/forge_config/src/lib.rs +++ b/crates/forge_config/src/lib.rs @@ -14,7 +14,9 @@ pub use config::*; pub use error::Error; pub use http::*; pub use model::*; +pub use reader::ConfigReader; pub use retry::*; +pub use writer::ConfigWriter; /// A `Result` type alias for this crate's [`Error`] type. pub type Result = std::result::Result; diff --git a/crates/forge_config/src/reader.rs b/crates/forge_config/src/reader.rs index c6453f047a..0bf2efa161 100644 --- a/crates/forge_config/src/reader.rs +++ b/crates/forge_config/src/reader.rs @@ -48,6 +48,33 @@ impl ConfigReader { let config = builder.build()?; Ok(config.try_deserialize()?) } + + /// Reads and merges configuration from the embedded defaults and the given + /// TOML string, returning the resolved [`ForgeConfig`]. + /// + /// Unlike [`read`], this method accepts already-loaded TOML content and + /// does not touch the filesystem or environment variables. This is + /// appropriate when the caller has already read the raw file content via + /// its own I/O abstraction. + pub fn read_str(&self, contents: &str) -> crate::Result { + let defaults = include_str!("../.forge.toml"); + let config = Config::builder() + .add_source(config::File::from_str(defaults, config::FileFormat::Toml)) + .add_source(config::File::from_str(contents, config::FileFormat::Toml)) + .build()?; + Ok(config.try_deserialize()?) + } + + /// Returns the [`ForgeConfig`] built from the embedded defaults only, + /// without reading any file or environment variables. + pub fn read_defaults(&self) -> ForgeConfig { + let defaults = include_str!("../.forge.toml"); + Config::builder() + .add_source(config::File::from_str(defaults, config::FileFormat::Toml)) + .build() + .and_then(|c| c.try_deserialize()) + .expect("embedded .forge.toml defaults must always be valid") + } } #[cfg(test)] diff --git a/crates/forge_config/src/writer.rs b/crates/forge_config/src/writer.rs index b8ddf61092..b377ac9dde 100644 --- a/crates/forge_config/src/writer.rs +++ b/crates/forge_config/src/writer.rs @@ -13,6 +13,15 @@ impl ConfigWriter { Self { config } } + /// Serializes the configuration to a TOML string. + /// + /// # Errors + /// + /// Returns an error if the configuration cannot be serialized. + pub fn to_string(&self) -> crate::Result { + Ok(toml_edit::ser::to_string_pretty(&self.config)?) + } + /// Serializes and writes the configuration to `path`, creating all parent /// directories recursively if they do not already exist. /// diff --git a/crates/forge_domain/src/app_config.rs b/crates/forge_domain/src/app_config.rs index 9f1fc3894c..d4dc39883e 100644 --- a/crates/forge_domain/src/app_config.rs +++ b/crates/forge_domain/src/app_config.rs @@ -13,17 +13,12 @@ pub struct InitAuth { pub token: String, } -#[derive(Default, Clone, Serialize, Deserialize, Debug, PartialEq)] -#[serde(rename_all = "camelCase")] +#[derive(Default, Clone, Debug, PartialEq)] pub struct AppConfig { pub key_info: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] pub provider: Option, - #[serde(default, skip_serializing_if = "HashMap::is_empty")] pub model: HashMap, - #[serde(default, skip_serializing_if = "Option::is_none")] pub commit: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] pub suggest: Option, } diff --git a/crates/forge_repo/Cargo.toml b/crates/forge_repo/Cargo.toml index ed9cc726a9..0e38fd986b 100644 --- a/crates/forge_repo/Cargo.toml +++ b/crates/forge_repo/Cargo.toml @@ -6,6 +6,7 @@ rust-version.workspace = true [dependencies] forge_app.workspace = true +forge_config.workspace = true forge_domain.workspace = true forge_infra.workspace = true forge_snaps.workspace = true diff --git a/crates/forge_repo/src/app_config.rs b/crates/forge_repo/src/app_config.rs index c8955aa91b..3c15d8c144 100644 --- a/crates/forge_repo/src/app_config.rs +++ b/crates/forge_repo/src/app_config.rs @@ -1,75 +1,151 @@ use std::sync::Arc; use anyhow::bail; -use bytes::Bytes; -use forge_app::{EnvironmentInfra, FileReaderInfra, FileWriterInfra}; -use forge_domain::{AppConfig, AppConfigRepository, ModelId, ProviderId}; +use forge_config::{ForgeConfig, ModelConfig}; +use forge_domain::{ + AppConfig, AppConfigRepository, CommitConfig, LoginInfo, ModelId, ProviderId, SuggestConfig, +}; use tokio::sync::Mutex; +/// Converts a [`ForgeConfig`] into an [`AppConfig`]. +/// +/// `ForgeConfig` flattens login info as top-level fields and represents the +/// active model as a single [`ModelConfig`]. This conversion reconstructs the +/// nested [`LoginInfo`] and per-provider model map used by the domain. +fn forge_config_to_app_config(fc: ForgeConfig) -> AppConfig { + let key_info = fc.api_key.map(|api_key| LoginInfo { + api_key, + api_key_name: fc.api_key_name.unwrap_or_default(), + api_key_masked: fc.api_key_masked.unwrap_or_default(), + email: fc.email, + name: fc.name, + auth_provider_id: fc.auth_provider_id, + }); + + let (provider, model) = match fc.model { + Some(mc) => { + let provider_id = ProviderId::from(mc.provider_id); + let model_id = ModelId::new(mc.model_id); + let mut map = std::collections::HashMap::new(); + map.insert(provider_id.clone(), model_id); + (Some(provider_id), map) + } + None => (None, std::collections::HashMap::new()), + }; + + let commit = fc.commit.map(|mc| CommitConfig { + provider: Some(ProviderId::from(mc.provider_id)), + model: Some(ModelId::new(mc.model_id)), + }); + + let suggest = fc.suggest.map(|mc| SuggestConfig { + provider: ProviderId::from(mc.provider_id), + model: ModelId::new(mc.model_id), + }); + + AppConfig { key_info, provider, model, commit, suggest } +} + +/// Overlays the [`AppConfig`] fields onto an existing [`ForgeConfig`], +/// preserving all other fields (retry, http, limits, etc.). +fn app_config_to_forge_config(app: &AppConfig, mut fc: ForgeConfig) -> ForgeConfig { + // Login info — flattened into top-level fields + match &app.key_info { + Some(info) => { + fc.api_key = Some(info.api_key.clone()); + fc.api_key_name = Some(info.api_key_name.clone()); + fc.api_key_masked = Some(info.api_key_masked.clone()); + fc.email = info.email.clone(); + fc.name = info.name.clone(); + fc.auth_provider_id = info.auth_provider_id.clone(); + } + None => { + fc.api_key = None; + fc.api_key_name = None; + fc.api_key_masked = None; + fc.email = None; + fc.name = None; + fc.auth_provider_id = None; + } + } + + // Active model — use the provider's entry from the model map + fc.model = app.provider.as_ref().and_then(|pid| { + app.model.get(pid).map(|mid| ModelConfig { + provider_id: pid.as_ref().to_string(), + model_id: mid.to_string(), + }) + }); + + fc.commit = app.commit.as_ref().and_then(|cc| { + cc.provider + .as_ref() + .zip(cc.model.as_ref()) + .map(|(pid, mid)| ModelConfig { + provider_id: pid.as_ref().to_string(), + model_id: mid.to_string(), + }) + }); + + fc.suggest = app.suggest.as_ref().map(|sc| ModelConfig { + provider_id: sc.provider.as_ref().to_string(), + model_id: sc.model.to_string(), + }); + + fc +} + /// Repository for managing application configuration with caching support. /// -/// This repository uses infrastructure traits for file I/O operations and -/// maintains an in-memory cache to reduce file system access. The configuration -/// file path is automatically inferred from the environment. -#[derive(derive_setters::Setters)] -#[setters(into)] -pub struct AppConfigRepositoryImpl { - infra: Arc, +/// Uses [`ForgeConfig::read`] and [`ForgeConfig::write`] for all file I/O and +/// maintains an in-memory cache to reduce disk access. +pub struct AppConfigRepositoryImpl { cache: Arc>>, override_model: Option, override_provider: Option, } -impl AppConfigRepositoryImpl { - pub fn new(infra: Arc) -> Self { +impl AppConfigRepositoryImpl { + pub fn new() -> Self { Self { - infra, cache: Arc::new(Mutex::new(None)), override_model: None, override_provider: None, } } -} -impl AppConfigRepositoryImpl { - /// Reads configuration from the JSON file with fallback strategies: + /// Overrides the active model returned by [`get_app_config`]. + pub fn override_model(mut self, model: Option>) -> Self { + self.override_model = model.map(Into::into); + self + } + + /// Overrides the active provider returned by [`get_app_config`]. + pub fn override_provider(mut self, provider: Option>) -> Self { + self.override_provider = provider.map(Into::into); + self + } + + /// Reads [`AppConfig`] from disk via [`ForgeConfig::read`]. async fn read(&self) -> AppConfig { - let path = self.infra.get_environment().app_config(); - let content = match self.infra.read_utf8(&path).await { - Ok(content) => content, + match ForgeConfig::read().await { + Ok(fc) => forge_config_to_app_config(fc), Err(e) => { - tracing::error!( - path = %path.display(), - error = %e, - "Failed to read config file. Using default config." - ); - return AppConfig::default(); + tracing::error!(error = %e, "Failed to read config file. Using default config."); + AppConfig::default() } - }; - - // Strategy 1: Try normal parsing - serde_json::from_str::(&content) - .or_else(|_| { - // Strategy 2: Try JSON repair for syntactically broken JSON - tracing::warn!(path = %path.display(), "Failed to parse config file, attempting repair..."); - forge_json_repair::json_repair::(&content).inspect(|_| { - tracing::info!(path = %path.display(), "Successfully repaired config file"); - }) - }) - .inspect_err(|e| { - tracing::error!( - path = %path.display(), - error = %e, - "Failed to repair config file. Using default config." - ); - }) - .unwrap_or_default() + } } + /// Writes [`AppConfig`] to disk via [`ForgeConfig::write`], preserving all + /// non-`AppConfig` fields from the existing file. async fn write(&self, config: &AppConfig) -> anyhow::Result<()> { - let path = self.infra.get_environment().app_config(); - let content = serde_json::to_string_pretty(config)?; - self.infra.write(&path, Bytes::from(content)).await?; + let existing = ForgeConfig::read().await.unwrap_or_else(|e| { + tracing::warn!(error = %e, "Could not read existing config; defaults will be used."); + forge_config::ConfigReader::new().read_defaults() + }); + let updated = app_config_to_forge_config(config, existing); + updated.write().await?; Ok(()) } @@ -114,9 +190,7 @@ impl AppConfigRepositor } #[async_trait::async_trait] -impl AppConfigRepository - for AppConfigRepositoryImpl -{ +impl AppConfigRepository for AppConfigRepositoryImpl { async fn get_app_config(&self) -> anyhow::Result { // Check cache first let cache = self.cache.lock().await; @@ -157,148 +231,349 @@ impl AppC #[cfg(test)] mod tests { - use std::collections::{BTreeMap, HashMap}; - use std::path::{Path, PathBuf}; + use std::collections::HashMap; + use std::path::PathBuf; use std::str::FromStr; use std::sync::Mutex; - use bytes::Bytes; - use forge_app::{EnvironmentInfra, FileReaderInfra, FileWriterInfra}; - use forge_domain::{Environment, ProviderId}; + use forge_domain::ProviderId; use pretty_assertions::assert_eq; use tempfile::TempDir; use super::*; - /// Mock infrastructure for testing that stores files in memory - #[derive(Clone)] - struct MockInfra { - files: Arc>>, - config_path: PathBuf, + /// Mutex to serialize all tests that mutate the `HOME` env var, preventing + /// races when multiple tests run concurrently in the same process. + static HOME_MUTEX: Mutex<()> = Mutex::new(()); + + /// Guard type that holds both the mutex guard and the temp dir, ensuring + /// the temp directory outlives the mutex release. + struct HomeGuard { + _lock: std::sync::MutexGuard<'static, ()>, + _dir: TempDir, } - impl MockInfra { - fn new(config_path: PathBuf) -> Self { - Self { files: Arc::new(Mutex::new(HashMap::new())), config_path } - } + /// Sets HOME to a fresh temp directory so that [`ForgeConfig::read`] and + /// [`ForgeConfig::write`] operate on an isolated `~/.forge/.forge.toml`. + /// Acquires the [`HOME_MUTEX`] and holds it for the lifetime of the returned + /// guard. + fn temp_home() -> HomeGuard { + let lock = HOME_MUTEX.lock().unwrap_or_else(|e| e.into_inner()); + let dir = tempfile::tempdir().unwrap(); + // SAFETY: tests are serialized by HOME_MUTEX, so no concurrent HOME reads occur. + unsafe { std::env::set_var("HOME", dir.path()) }; + HomeGuard { _lock: lock, _dir: dir } } - impl EnvironmentInfra for MockInfra { - fn get_environment(&self) -> Environment { - use fake::{Fake, Faker}; - let mut env: Environment = Faker.fake(); - env = env.base_path(self.config_path.parent().unwrap().to_path_buf()); - env + impl std::ops::Deref for HomeGuard { + type Target = TempDir; + fn deref(&self) -> &TempDir { + &self._dir } + } - fn get_env_var(&self, _key: &str) -> Option { - None - } + /// Returns the path to `.forge.toml` inside a temp home directory. + fn forge_toml_path(home: &HomeGuard) -> PathBuf { + home.path().join(".forge").join(".forge.toml") + } - fn get_env_vars(&self) -> BTreeMap { - BTreeMap::new() - } + /// Writes a TOML string to the forge config path, creating parent dirs. + fn write_toml(home: &HomeGuard, toml: &str) { + let path = forge_toml_path(home); + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(path, toml).unwrap(); + } - fn is_restricted(&self) -> bool { - false - } + fn repository_fixture(_home: &HomeGuard) -> AppConfigRepositoryImpl { + AppConfigRepositoryImpl::new() } - #[async_trait::async_trait] - impl FileReaderInfra for MockInfra { - async fn read_utf8(&self, path: &Path) -> anyhow::Result { - self.files - .lock() - .unwrap() - .get(path) - .cloned() - .ok_or_else(|| anyhow::anyhow!("File not found")) - } + /// Returns a [`ForgeConfig`] built from embedded defaults only, as a + /// clean starting point for conversion fixtures. + fn forge_config_defaults() -> ForgeConfig { + forge_config::ConfigReader::new().read_defaults() + } - fn read_batch_utf8( - &self, - _batch_size: usize, - _paths: Vec, - ) -> impl futures::Stream)> + Send { - futures::stream::empty() - } + // ------------------------------------------------------------------------- + // forge_config_to_app_config + // ------------------------------------------------------------------------- - async fn read(&self, _path: &Path) -> anyhow::Result> { - unimplemented!() - } + #[test] + fn test_forge_config_to_app_config_empty() { + let fixture = forge_config_defaults(); - async fn range_read_utf8( - &self, - _path: &Path, - _start_line: u64, - _end_line: u64, - ) -> anyhow::Result<(String, forge_domain::FileInfo)> { - unimplemented!() - } + let actual = forge_config_to_app_config(fixture); + + let expected = AppConfig::default(); + assert_eq!(actual, expected); } - #[async_trait::async_trait] - impl FileWriterInfra for MockInfra { - async fn write(&self, path: &Path, contents: Bytes) -> anyhow::Result<()> { - let content = String::from_utf8(contents.to_vec())?; - self.files - .lock() - .unwrap() - .insert(path.to_path_buf(), content); - Ok(()) - } + #[test] + fn test_forge_config_to_app_config_with_model() { + let mut fixture = forge_config_defaults(); + fixture.model = Some(ModelConfig { + provider_id: "anthropic".to_string(), + model_id: "claude-3-5-sonnet-20241022".to_string(), + }); + + let actual = forge_config_to_app_config(fixture); + + let expected = AppConfig { + provider: Some(ProviderId::ANTHROPIC), + model: HashMap::from([( + ProviderId::ANTHROPIC, + ModelId::new("claude-3-5-sonnet-20241022"), + )]), + ..Default::default() + }; + assert_eq!(actual, expected); + } - async fn write_temp(&self, _: &str, _: &str, _: &str) -> anyhow::Result { - unimplemented!() - } + #[test] + fn test_forge_config_to_app_config_with_login_info() { + let mut fixture = forge_config_defaults(); + fixture.api_key = Some("sk-test-key".to_string()); + fixture.api_key_name = Some("my-key".to_string()); + fixture.api_key_masked = Some("sk-***".to_string()); + fixture.email = Some("user@example.com".to_string()); + fixture.name = Some("Alice".to_string()); + fixture.auth_provider_id = Some("github".to_string()); + + let actual = forge_config_to_app_config(fixture); + + let expected = AppConfig { + key_info: Some(LoginInfo { + api_key: "sk-test-key".to_string(), + api_key_name: "my-key".to_string(), + api_key_masked: "sk-***".to_string(), + email: Some("user@example.com".to_string()), + name: Some("Alice".to_string()), + auth_provider_id: Some("github".to_string()), + }), + ..Default::default() + }; + assert_eq!(actual, expected); } - fn repository_fixture() -> (AppConfigRepositoryImpl, TempDir) { - let temp_dir = tempfile::tempdir().unwrap(); - let config_path = temp_dir.path().join(".config.json"); - let infra = Arc::new(MockInfra::new(config_path)); - (AppConfigRepositoryImpl::new(infra), temp_dir) + #[test] + fn test_forge_config_to_app_config_no_login_info_when_api_key_absent() { + let fixture = forge_config_defaults(); + // api_key is None → key_info must be None even if other fields are set + + let actual = forge_config_to_app_config(fixture); + + assert_eq!(actual.key_info, None); } - fn repository_with_config_fixture() -> (AppConfigRepositoryImpl, TempDir) { - let temp_dir = tempfile::tempdir().unwrap(); - let config_path = temp_dir.path().join(".config.json"); + #[test] + fn test_forge_config_to_app_config_with_commit() { + let mut fixture = forge_config_defaults(); + fixture.commit = Some(ModelConfig { + provider_id: "openai".to_string(), + model_id: "gpt-4o".to_string(), + }); - // Create a config file with default config - let config = forge_domain::AppConfig::default(); - let content = serde_json::to_string_pretty(&config).unwrap(); + let actual = forge_config_to_app_config(fixture); + + let expected = CommitConfig { + provider: Some(ProviderId::OPENAI), + model: Some(ModelId::new("gpt-4o")), + }; + assert_eq!(actual.commit, Some(expected)); + } + + #[test] + fn test_forge_config_to_app_config_with_suggest() { + let mut fixture = forge_config_defaults(); + fixture.suggest = Some(ModelConfig { + provider_id: "openai".to_string(), + model_id: "gpt-4o-mini".to_string(), + }); - let infra = Arc::new(MockInfra::new(config_path.clone())); - infra.files.lock().unwrap().insert(config_path, content); + let actual = forge_config_to_app_config(fixture); - (AppConfigRepositoryImpl::new(infra), temp_dir) + let expected = SuggestConfig { + provider: ProviderId::OPENAI, + model: ModelId::new("gpt-4o-mini"), + }; + assert_eq!(actual.suggest, Some(expected)); } - #[tokio::test] - async fn test_get_app_config_exists() { - let expected = forge_domain::AppConfig::default(); - let (repo, _temp_dir) = repository_with_config_fixture(); + // ------------------------------------------------------------------------- + // app_config_to_forge_config + // ------------------------------------------------------------------------- - let actual = repo.get_app_config().await.unwrap(); + #[test] + fn test_app_config_to_forge_config_empty() { + let app = AppConfig::default(); + let base = forge_config_defaults(); - assert_eq!(actual, expected); + let actual = app_config_to_forge_config(&app, base.clone()); + + // All AppConfig-owned fields must be cleared; unrelated fields preserved. + assert_eq!(actual.api_key, None); + assert_eq!(actual.model, None); + assert_eq!(actual.commit, None); + assert_eq!(actual.suggest, None); + // Non-AppConfig field is unchanged + assert_eq!(actual.retry, base.retry); + } + + #[test] + fn test_app_config_to_forge_config_with_model() { + let app = AppConfig { + provider: Some(ProviderId::ANTHROPIC), + model: HashMap::from([( + ProviderId::ANTHROPIC, + ModelId::new("claude-3-5-sonnet-20241022"), + )]), + ..Default::default() + }; + let base = forge_config_defaults(); + + let actual = app_config_to_forge_config(&app, base); + + let expected = ModelConfig { + provider_id: "anthropic".to_string(), + model_id: "claude-3-5-sonnet-20241022".to_string(), + }; + assert_eq!(actual.model, Some(expected)); + } + + #[test] + fn test_app_config_to_forge_config_model_uses_active_provider() { + // Two providers in the map; only the active provider's model is written. + let app = AppConfig { + provider: Some(ProviderId::OPENAI), + model: HashMap::from([ + (ProviderId::OPENAI, ModelId::new("gpt-4o")), + ( + ProviderId::ANTHROPIC, + ModelId::new("claude-3-5-sonnet-20241022"), + ), + ]), + ..Default::default() + }; + let base = forge_config_defaults(); + + let actual = app_config_to_forge_config(&app, base); + + let expected = ModelConfig { + provider_id: "openai".to_string(), + model_id: "gpt-4o".to_string(), + }; + assert_eq!(actual.model, Some(expected)); + } + + #[test] + fn test_app_config_to_forge_config_no_model_when_provider_missing() { + // Model map has an entry but no active provider → model field is None. + let app = AppConfig { + provider: None, + model: HashMap::from([(ProviderId::OPENAI, ModelId::new("gpt-4o"))]), + ..Default::default() + }; + let base = forge_config_defaults(); + + let actual = app_config_to_forge_config(&app, base); + + assert_eq!(actual.model, None); + } + + #[test] + fn test_app_config_to_forge_config_with_login_info() { + let app = AppConfig { + key_info: Some(LoginInfo { + api_key: "sk-test-key".to_string(), + api_key_name: "my-key".to_string(), + api_key_masked: "sk-***".to_string(), + email: Some("user@example.com".to_string()), + name: Some("Alice".to_string()), + auth_provider_id: Some("github".to_string()), + }), + ..Default::default() + }; + let base = forge_config_defaults(); + + let actual = app_config_to_forge_config(&app, base); + + assert_eq!(actual.api_key, Some("sk-test-key".to_string())); + assert_eq!(actual.api_key_name, Some("my-key".to_string())); + assert_eq!(actual.api_key_masked, Some("sk-***".to_string())); + assert_eq!(actual.email, Some("user@example.com".to_string())); + assert_eq!(actual.name, Some("Alice".to_string())); + assert_eq!(actual.auth_provider_id, Some("github".to_string())); + } + + #[test] + fn test_app_config_to_forge_config_clears_login_info_when_absent() { + // Start with a base that has login fields set, then overlay an AppConfig + // with no key_info — all login fields must be cleared. + let mut base = forge_config_defaults(); + base.api_key = Some("old-key".to_string()); + base.email = Some("old@example.com".to_string()); + + let app = AppConfig::default(); // key_info is None + + let actual = app_config_to_forge_config(&app, base); + + assert_eq!(actual.api_key, None); + assert_eq!(actual.email, None); + } + + #[test] + fn test_app_config_to_forge_config_preserves_unrelated_fields() { + // Non-AppConfig fields (retry, http, limits, …) must survive a round-trip. + let app = AppConfig::default(); + let base = forge_config_defaults(); + let expected_retry = base.retry.clone(); + let expected_max_search = base.max_search_lines; + + let actual = app_config_to_forge_config(&app, base); + + assert_eq!(actual.retry, expected_retry); + assert_eq!(actual.max_search_lines, expected_max_search); + } + + #[test] + fn test_round_trip_forge_config_to_app_config_and_back() { + let mut original = forge_config_defaults(); + original.api_key = Some("sk-test".to_string()); + original.api_key_name = Some("test-key".to_string()); + original.api_key_masked = Some("sk-***".to_string()); + original.model = Some(ModelConfig { + provider_id: "anthropic".to_string(), + model_id: "claude-3-5-sonnet-20241022".to_string(), + }); + original.commit = Some(ModelConfig { + provider_id: "openai".to_string(), + model_id: "gpt-4o".to_string(), + }); + + let app = forge_config_to_app_config(original.clone()); + let actual = app_config_to_forge_config(&app, original.clone()); + + assert_eq!(actual.api_key, original.api_key); + assert_eq!(actual.api_key_name, original.api_key_name); + assert_eq!(actual.model, original.model); + assert_eq!(actual.commit, original.commit); } #[tokio::test] async fn test_get_app_config_not_exists() { - let (repo, _temp_dir) = repository_fixture(); + let _home = temp_home(); + let repo = repository_fixture(&_home); let actual = repo.get_app_config().await.unwrap(); - // Should return default config when file doesn't exist - let expected = forge_domain::AppConfig::default(); - assert_eq!(actual, expected); + assert_eq!(actual, forge_domain::AppConfig::default()); } #[tokio::test] async fn test_set_app_config() { + let _home = temp_home(); let fixture = forge_domain::AppConfig::default(); - let (repo, _temp_dir) = repository_fixture(); + let repo = repository_fixture(&_home); let actual = repo.set_app_config(&fixture).await; @@ -311,7 +586,9 @@ mod tests { #[tokio::test] async fn test_cache_behavior() { - let (repo, _temp_dir) = repository_with_config_fixture(); + let _home = temp_home(); + write_toml(&_home, ""); + let repo = repository_fixture(&_home); // First read should populate cache let first_read = repo.get_app_config().await.unwrap(); @@ -331,62 +608,36 @@ mod tests { #[tokio::test] async fn test_read_handles_custom_provider() { - let fixture = r#"{ - "provider": "xyz", - "model": {} - }"#; - let temp_dir = tempfile::tempdir().unwrap(); - let config_path = temp_dir.path().join(".config.json"); - - let infra = Arc::new(MockInfra::new(config_path.clone())); - infra - .files - .lock() - .unwrap() - .insert(config_path, fixture.to_string()); - - let repo = AppConfigRepositoryImpl::new(infra); + let _home = temp_home(); + write_toml( + &_home, + r#" +[model] +provider = "xyz" +model = "some-model" +"#, + ); + let repo = repository_fixture(&_home); let actual = repo.get_app_config().await.unwrap(); - let expected = forge_domain::AppConfig { - provider: Some(ProviderId::from_str("xyz").unwrap()), - ..Default::default() - }; - assert_eq!(actual, expected); - } - - #[tokio::test] - async fn test_read_returns_default_if_not_exists() { - let (repo, _temp_dir) = repository_fixture(); - - let config = repo.get_app_config().await.unwrap(); - - // Config should be the default - assert_eq!(config, forge_domain::AppConfig::default()); + assert_eq!(actual.provider, Some(ProviderId::from_str("xyz").unwrap())); } #[tokio::test] async fn test_override_model() { - let temp_dir = tempfile::tempdir().unwrap(); - let config_path = temp_dir.path().join(".config.json"); - - // Set up a config with a specific model - let mut config = forge_domain::AppConfig::default(); - config.model.insert( - ProviderId::ANTHROPIC, - ModelId::new("claude-3-5-sonnet-20241022"), + let _home = temp_home(); + write_toml( + &_home, + r#" +[model] +provider = "anthropic" +model = "claude-3-5-sonnet-20241022" +"#, ); - let content = serde_json::to_string_pretty(&config).unwrap(); - - let infra = Arc::new(MockInfra::new(config_path.clone())); - infra.files.lock().unwrap().insert(config_path, content); - - let repo = - AppConfigRepositoryImpl::new(infra).override_model(ModelId::new("override-model")); + let repo = repository_fixture(&_home).override_model(Some(ModelId::new("override-model"))); let actual = repo.get_app_config().await.unwrap(); - // The override model should be applied to all providers assert_eq!( actual.model.get(&ProviderId::ANTHROPIC), Some(&ModelId::new("override-model")) @@ -395,34 +646,26 @@ mod tests { #[tokio::test] async fn test_override_provider() { - let temp_dir = tempfile::tempdir().unwrap(); - let config_path = temp_dir.path().join(".config.json"); - - // Set up a config with a specific provider - let config = - forge_domain::AppConfig { provider: Some(ProviderId::ANTHROPIC), ..Default::default() }; - let content = serde_json::to_string_pretty(&config).unwrap(); - - let infra = Arc::new(MockInfra::new(config_path.clone())); - infra.files.lock().unwrap().insert(config_path, content); - - let repo = AppConfigRepositoryImpl::new(infra).override_provider(ProviderId::OPENAI); + let _home = temp_home(); + write_toml( + &_home, + r#" +[model] +provider = "anthropic" +model = "claude-3-5-sonnet-20241022" +"#, + ); + let repo = repository_fixture(&_home).override_provider(Some(ProviderId::OPENAI)); let actual = repo.get_app_config().await.unwrap(); - // The override provider should be applied assert_eq!(actual.provider, Some(ProviderId::OPENAI)); } #[tokio::test] async fn test_override_prevents_config_write() { - let temp_dir = tempfile::tempdir().unwrap(); - let config_path = temp_dir.path().join(".config.json"); - - let infra = Arc::new(MockInfra::new(config_path)); - let repo = - AppConfigRepositoryImpl::new(infra).override_model(ModelId::new("override-model")); + let _home = temp_home(); + let repo = repository_fixture(&_home).override_model(Some(ModelId::new("override-model"))); - // Attempting to write config when override is set should fail let config = forge_domain::AppConfig::default(); let actual = repo.set_app_config(&config).await; @@ -437,14 +680,11 @@ mod tests { #[tokio::test] async fn test_provider_override_applied_with_no_config() { - let temp_dir = tempfile::tempdir().unwrap(); - let config_path = temp_dir.path().join(".config.json"); + let _home = temp_home(); let expected = ProviderId::from_str("open_router").unwrap(); - - let infra = Arc::new(MockInfra::new(config_path)); - let repo = AppConfigRepositoryImpl::new(infra) - .override_provider(expected.clone()) - .override_model(ModelId::new("test-model")); + let repo = repository_fixture(&_home) + .override_provider(Some(expected.clone())) + .override_model(Some(ModelId::new("test-model"))); let actual = repo.get_app_config().await.unwrap(); @@ -453,15 +693,12 @@ mod tests { #[tokio::test] async fn test_model_override_applied_with_no_config() { - let temp_dir = tempfile::tempdir().unwrap(); - let config_path = temp_dir.path().join(".config.json"); + let _home = temp_home(); let provider = ProviderId::OPENAI; let expected = ModelId::new("gpt-4-test"); - - let infra = Arc::new(MockInfra::new(config_path)); - let repo = AppConfigRepositoryImpl::new(infra) - .override_provider(provider.clone()) - .override_model(expected.clone()); + let repo = repository_fixture(&_home) + .override_provider(Some(provider.clone())) + .override_model(Some(expected.clone())); let actual = repo.get_app_config().await.unwrap(); @@ -470,14 +707,11 @@ mod tests { #[tokio::test] async fn test_provider_override_on_cached_config() { - let temp_dir = tempfile::tempdir().unwrap(); - let config_path = temp_dir.path().join(".config.json"); + let _home = temp_home(); let expected = ProviderId::ANTHROPIC; - - let infra = Arc::new(MockInfra::new(config_path)); - let repo = AppConfigRepositoryImpl::new(infra) - .override_provider(expected.clone()) - .override_model(ModelId::new("test-model")); + let repo = repository_fixture(&_home) + .override_provider(Some(expected.clone())) + .override_model(Some(ModelId::new("test-model"))); // First call populates cache repo.get_app_config().await.unwrap(); @@ -490,15 +724,12 @@ mod tests { #[tokio::test] async fn test_model_override_on_cached_config() { - let temp_dir = tempfile::tempdir().unwrap(); - let config_path = temp_dir.path().join(".config.json"); + let _home = temp_home(); let provider = ProviderId::OPENAI; let expected = ModelId::new("gpt-4-cached"); - - let infra = Arc::new(MockInfra::new(config_path)); - let repo = AppConfigRepositoryImpl::new(infra) - .override_provider(provider.clone()) - .override_model(expected.clone()); + let repo = repository_fixture(&_home) + .override_provider(Some(provider.clone())) + .override_model(Some(expected.clone())); // First call populates cache repo.get_app_config().await.unwrap(); @@ -511,63 +742,19 @@ mod tests { #[tokio::test] async fn test_model_override_with_existing_provider() { - let temp_dir = tempfile::tempdir().unwrap(); - let config_path = temp_dir.path().join(".config.json"); + let _home = temp_home(); + write_toml( + &_home, + r#" +[model] +provider = "anthropic" +model = "claude-3-opus" +"#, + ); let expected = ModelId::new("override-model"); - - // Set up config with provider but no model - let config = - forge_domain::AppConfig { provider: Some(ProviderId::ANTHROPIC), ..Default::default() }; - let content = serde_json::to_string_pretty(&config).unwrap(); - - let infra = Arc::new(MockInfra::new(config_path.clone())); - infra.files.lock().unwrap().insert(config_path, content); - - let repo = AppConfigRepositoryImpl::new(infra).override_model(expected.clone()); + let repo = repository_fixture(&_home).override_model(Some(expected.clone())); let actual = repo.get_app_config().await.unwrap(); assert_eq!(actual.model.get(&ProviderId::ANTHROPIC), Some(&expected)); } - - #[tokio::test] - async fn test_read_repairs_invalid_json() { - let temp_dir = tempfile::tempdir().unwrap(); - let config_path = temp_dir.path().join(".config.json"); - - // Invalid JSON with trailing comma - let json = r#"{"provider": "openai",}"#; - - let infra = Arc::new(MockInfra::new(config_path.clone())); - infra - .files - .lock() - .unwrap() - .insert(config_path, json.to_string()); - - let repo = AppConfigRepositoryImpl::new(infra); - let actual = repo.get_app_config().await.unwrap(); - - assert_eq!(actual.provider, Some(ProviderId::OPENAI)); - } - - #[tokio::test] - async fn test_read_returns_default_on_unrepairable_json() { - let temp_dir = tempfile::tempdir().unwrap(); - let config_path = temp_dir.path().join(".config.json"); - - // JSON that can't be repaired to AppConfig - let json = r#"["this", "is", "an", "array"]"#; - - let infra = Arc::new(MockInfra::new(config_path.clone())); - infra - .files - .lock() - .unwrap() - .insert(config_path, json.to_string()); - - let repo = AppConfigRepositoryImpl::new(infra); - let actual = repo.get_app_config().await.unwrap(); - - assert_eq!(actual, forge_domain::AppConfig::default()); - } } diff --git a/crates/forge_repo/src/forge_repo.rs b/crates/forge_repo/src/forge_repo.rs index dde20149e0..d3176f072c 100644 --- a/crates/forge_repo/src/forge_repo.rs +++ b/crates/forge_repo/src/forge_repo.rs @@ -42,7 +42,7 @@ pub struct ForgeRepo { infra: Arc, file_snapshot_service: Arc, conversation_repository: Arc, - app_config_repository: Arc>, + app_config_repository: Arc, mcp_cache_repository: Arc, provider_repository: Arc>, chat_repository: Arc>, @@ -69,7 +69,7 @@ impl AppConfigRepository - for ForgeRepo -{ +impl AppConfigRepository for ForgeRepo { async fn get_app_config(&self) -> anyhow::Result { self.app_config_repository.get_app_config().await } From 8997451b2dd342b15977192baacaecdfd878177d Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Wed, 25 Mar 2026 05:44:41 +0000 Subject: [PATCH 30/67] [autofix.ci] apply automated fixes --- crates/forge_repo/src/app_config.rs | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/crates/forge_repo/src/app_config.rs b/crates/forge_repo/src/app_config.rs index 3c15d8c144..341cefdb4d 100644 --- a/crates/forge_repo/src/app_config.rs +++ b/crates/forge_repo/src/app_config.rs @@ -255,12 +255,13 @@ mod tests { /// Sets HOME to a fresh temp directory so that [`ForgeConfig::read`] and /// [`ForgeConfig::write`] operate on an isolated `~/.forge/.forge.toml`. - /// Acquires the [`HOME_MUTEX`] and holds it for the lifetime of the returned - /// guard. + /// Acquires the [`HOME_MUTEX`] and holds it for the lifetime of the + /// returned guard. fn temp_home() -> HomeGuard { let lock = HOME_MUTEX.lock().unwrap_or_else(|e| e.into_inner()); let dir = tempfile::tempdir().unwrap(); - // SAFETY: tests are serialized by HOME_MUTEX, so no concurrent HOME reads occur. + // SAFETY: tests are serialized by HOME_MUTEX, so no concurrent HOME reads + // occur. unsafe { std::env::set_var("HOME", dir.path()) }; HomeGuard { _lock: lock, _dir: dir } } From 510374a5e7d26c0fa152918413e8ea638ace7a0a Mon Sep 17 00:00:00 2001 From: Tushar Date: Wed, 25 Mar 2026 11:47:49 +0530 Subject: [PATCH 31/67] feat(forge_config): add default provider option to config --- crates/forge_config/src/config.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/crates/forge_config/src/config.rs b/crates/forge_config/src/config.rs index 84195d9bdb..744030eaba 100644 --- a/crates/forge_config/src/config.rs +++ b/crates/forge_config/src/config.rs @@ -93,6 +93,9 @@ pub struct ForgeConfig { pub max_parallel_file_reads: usize, /// TTL in seconds for the model API list cache pub model_cache_ttl_secs: u64, + /// Default provider_id to use for all models if not specified + #[serde(default)] + pub provider: Option, /// Map of provider ID to model ID for per-provider model selection #[serde(default)] pub model: Option, @@ -205,7 +208,7 @@ impl ForgeConfig { let home_dir = dirs::home_dir().ok_or_else(|| { std::io::Error::new(std::io::ErrorKind::NotFound, "home directory not found") })?; - Ok(home_dir.join(".forge").join(".forge.toml")) + Ok(home_dir.join("forge").join(".forge.toml")) } /// Reads and merges configuration from all sources, returning the resolved From 466408bff56f315b1e886acc9fce1dda0bc785d8 Mon Sep 17 00:00:00 2001 From: Tushar Date: Wed, 25 Mar 2026 11:47:59 +0530 Subject: [PATCH 32/67] chore(forge_tracker): remove success debug log from tracing init --- crates/forge_tracker/src/log.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/crates/forge_tracker/src/log.rs b/crates/forge_tracker/src/log.rs index a2e97c1ec7..df4cda283d 100644 --- a/crates/forge_tracker/src/log.rs +++ b/crates/forge_tracker/src/log.rs @@ -33,7 +33,6 @@ pub fn init_tracing(log_path: PathBuf, tracker: Tracker) -> anyhow::Result Date: Wed, 25 Mar 2026 12:09:03 +0530 Subject: [PATCH 33/67] refactor(forge_config): rename model to default config option --- crates/forge_config/src/config.rs | 4 ++-- crates/forge_config/src/model.rs | 2 -- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/crates/forge_config/src/config.rs b/crates/forge_config/src/config.rs index 744030eaba..6d19a800d5 100644 --- a/crates/forge_config/src/config.rs +++ b/crates/forge_config/src/config.rs @@ -96,9 +96,9 @@ pub struct ForgeConfig { /// Default provider_id to use for all models if not specified #[serde(default)] pub provider: Option, - /// Map of provider ID to model ID for per-provider model selection + /// Default model and provider configuration to use for all operations if not specified #[serde(default)] - pub model: Option, + pub default: Option, /// Provider and model to use for commit message generation #[serde(default)] pub commit: Option, diff --git a/crates/forge_config/src/model.rs b/crates/forge_config/src/model.rs index 198ffd0a53..928abd636b 100644 --- a/crates/forge_config/src/model.rs +++ b/crates/forge_config/src/model.rs @@ -10,9 +10,7 @@ pub type ModelId = String; #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, fake::Dummy)] pub struct ModelConfig { /// The provider to use for this operation. - #[serde(rename = "provider")] pub provider_id: String, /// The model to use for this operation. - #[serde(rename = "model")] pub model_id: String, } From 5dd0eab858288b62c388b18e783a93689c472123 Mon Sep 17 00:00:00 2001 From: Tushar Date: Wed, 25 Mar 2026 12:10:53 +0530 Subject: [PATCH 34/67] refactor(forge_config): remove default provider option field --- crates/forge_config/src/config.rs | 3 --- 1 file changed, 3 deletions(-) diff --git a/crates/forge_config/src/config.rs b/crates/forge_config/src/config.rs index 6d19a800d5..587e057037 100644 --- a/crates/forge_config/src/config.rs +++ b/crates/forge_config/src/config.rs @@ -93,9 +93,6 @@ pub struct ForgeConfig { pub max_parallel_file_reads: usize, /// TTL in seconds for the model API list cache pub model_cache_ttl_secs: u64, - /// Default provider_id to use for all models if not specified - #[serde(default)] - pub provider: Option, /// Default model and provider configuration to use for all operations if not specified #[serde(default)] pub default: Option, From 49d428084c5d9ef2a844d1a05f6f61e015adcdcc Mon Sep 17 00:00:00 2001 From: Tushar Date: Wed, 25 Mar 2026 12:11:41 +0530 Subject: [PATCH 35/67] refactor(app_config): remove model/provider CLI overrides --- crates/forge_api/src/forge_api.rs | 13 +- crates/forge_main/src/cli.rs | 16 -- crates/forge_main/src/main.rs | 11 +- crates/forge_repo/src/app_config.rs | 247 +++--------------------- crates/forge_repo/src/forge_repo.rs | 12 +- crates/forge_services/src/app_config.rs | 2 + 6 files changed, 30 insertions(+), 271 deletions(-) diff --git a/crates/forge_api/src/forge_api.rs b/crates/forge_api/src/forge_api.rs index 037ae8565f..a2dc7847a7 100644 --- a/crates/forge_api/src/forge_api.rs +++ b/crates/forge_api/src/forge_api.rs @@ -41,18 +41,9 @@ impl ForgeAPI { } impl ForgeAPI>, ForgeRepo> { - pub fn init( - restricted: bool, - cwd: PathBuf, - override_model: Option, - override_provider: Option, - ) -> Self { + pub fn init(restricted: bool, cwd: PathBuf) -> Self { let infra = Arc::new(ForgeInfra::new(restricted, cwd)); - let repo = Arc::new(ForgeRepo::new( - infra.clone(), - override_model, - override_provider, - )); + let repo = Arc::new(ForgeRepo::new(infra.clone())); let app = Arc::new(ForgeServices::new(repo.clone())); ForgeAPI::new(app, repo) } diff --git a/crates/forge_main/src/cli.rs b/crates/forge_main/src/cli.rs index c3f93a1db0..13d6845afc 100644 --- a/crates/forge_main/src/cli.rs +++ b/crates/forge_main/src/cli.rs @@ -63,22 +63,6 @@ pub struct Cli { #[arg(long, alias = "aid")] pub agent: Option, - /// Override the model to use for this session. - /// - /// When provided, uses this model instead of the configured default. - /// This is a runtime override and does not change the permanent - /// configuration. - #[arg(long)] - pub model: Option, - - /// Override the provider to use for this session. - /// - /// When provided, uses this provider instead of the configured default. - /// This is a runtime override and does not change the permanent - /// configuration. - #[arg(long)] - pub provider: Option, - /// Top-level subcommands. #[command(subcommand)] pub subcommands: Option, diff --git a/crates/forge_main/src/main.rs b/crates/forge_main/src/main.rs index c9b14c9d1c..02967526ee 100644 --- a/crates/forge_main/src/main.rs +++ b/crates/forge_main/src/main.rs @@ -60,16 +60,7 @@ async fn main() -> Result<()> { // Initialize the ForgeAPI with the restricted mode if specified let restricted = cli.restricted; - let cli_model = cli.model.clone(); - let cli_provider = cli.provider.clone(); - let mut ui = UI::init(cli, move || { - ForgeAPI::init( - restricted, - cwd.clone(), - cli_model.clone(), - cli_provider.clone(), - ) - })?; + let mut ui = UI::init(cli, move || ForgeAPI::init(restricted, cwd.clone()))?; ui.run().await; Ok(()) diff --git a/crates/forge_repo/src/app_config.rs b/crates/forge_repo/src/app_config.rs index 341cefdb4d..896b19921c 100644 --- a/crates/forge_repo/src/app_config.rs +++ b/crates/forge_repo/src/app_config.rs @@ -1,11 +1,11 @@ use std::sync::Arc; -use anyhow::bail; use forge_config::{ForgeConfig, ModelConfig}; use forge_domain::{ AppConfig, AppConfigRepository, CommitConfig, LoginInfo, ModelId, ProviderId, SuggestConfig, }; use tokio::sync::Mutex; +use tracing::debug; /// Converts a [`ForgeConfig`] into an [`AppConfig`]. /// @@ -22,7 +22,7 @@ fn forge_config_to_app_config(fc: ForgeConfig) -> AppConfig { auth_provider_id: fc.auth_provider_id, }); - let (provider, model) = match fc.model { + let (provider, model) = match fc.default { Some(mc) => { let provider_id = ProviderId::from(mc.provider_id); let model_id = ModelId::new(mc.model_id); @@ -70,7 +70,7 @@ fn app_config_to_forge_config(app: &AppConfig, mut fc: ForgeConfig) -> ForgeConf } // Active model — use the provider's entry from the model map - fc.model = app.provider.as_ref().and_then(|pid| { + fc.default = app.provider.as_ref().and_then(|pid| { app.model.get(pid).map(|mid| ModelConfig { provider_id: pid.as_ref().to_string(), model_id: mid.to_string(), @@ -101,29 +101,11 @@ fn app_config_to_forge_config(app: &AppConfig, mut fc: ForgeConfig) -> ForgeConf /// maintains an in-memory cache to reduce disk access. pub struct AppConfigRepositoryImpl { cache: Arc>>, - override_model: Option, - override_provider: Option, } impl AppConfigRepositoryImpl { pub fn new() -> Self { - Self { - cache: Arc::new(Mutex::new(None)), - override_model: None, - override_provider: None, - } - } - - /// Overrides the active model returned by [`get_app_config`]. - pub fn override_model(mut self, model: Option>) -> Self { - self.override_model = model.map(Into::into); - self - } - - /// Overrides the active provider returned by [`get_app_config`]. - pub fn override_provider(mut self, provider: Option>) -> Self { - self.override_provider = provider.map(Into::into); - self + Self { cache: Arc::new(Mutex::new(None)) } } /// Reads [`AppConfig`] from disk via [`ForgeConfig::read`]. @@ -145,48 +127,10 @@ impl AppConfigRepositoryImpl { forge_config::ConfigReader::new().read_defaults() }); let updated = app_config_to_forge_config(config, existing); + debug!(config = ?updated, "Writing updated config to disk"); updated.write().await?; Ok(()) } - - fn get_overrides(&self) -> (Option, Option) { - (self.override_model.clone(), self.override_provider.clone()) - } - - fn apply_overrides(&self, mut config: AppConfig) -> AppConfig { - let (model, provider) = self.get_overrides(); - - // Override the default provider first - if let Some(ref provider_id) = provider { - config.provider = Some(provider_id.clone()); - - // If we have both provider and model overrides, ensure the model is set for - // this provider - if let Some(ref model_id) = model { - config.model.insert(provider_id.clone(), model_id.clone()); - } - } - - // If only model override (no provider override), update existing provider - // models - if provider.is_none() - && let Some(model_id) = model - { - if config.model.is_empty() { - // If no models configured but we have a default provider, set the model for it - if let Some(ref default_provider) = config.provider { - config.model.insert(default_provider.clone(), model_id); - } - } else { - // Update all existing provider models - for (_, mut_model_id) in config.model.iter_mut() { - *mut_model_id = model_id.clone(); - } - } - } - - config - } } #[async_trait::async_trait] @@ -195,29 +139,20 @@ impl AppConfigRepository for AppConfigRepositoryImpl { // Check cache first let cache = self.cache.lock().await; if let Some(ref cached_config) = *cache { - // Apply overrides even to cached config since overrides can change via env vars - return Ok(self.apply_overrides(cached_config.clone())); + return Ok(cached_config.clone()); } drop(cache); // Cache miss, read from file let config = self.read().await; - // Update cache with the newly read config (without overrides) let mut cache = self.cache.lock().await; *cache = Some(config.clone()); - // Apply overrides to the config before returning - Ok(self.apply_overrides(config)) + Ok(config) } async fn set_app_config(&self, config: &AppConfig) -> anyhow::Result<()> { - let (model, provider) = self.get_overrides(); - - if model.is_some() || provider.is_some() { - bail!("Could not save configuration: Model or Provider was overridden") - } - self.write(config).await?; // Bust the cache after successful write @@ -312,7 +247,7 @@ mod tests { #[test] fn test_forge_config_to_app_config_with_model() { let mut fixture = forge_config_defaults(); - fixture.model = Some(ModelConfig { + fixture.default = Some(ModelConfig { provider_id: "anthropic".to_string(), model_id: "claude-3-5-sonnet-20241022".to_string(), }); @@ -413,7 +348,7 @@ mod tests { // All AppConfig-owned fields must be cleared; unrelated fields preserved. assert_eq!(actual.api_key, None); - assert_eq!(actual.model, None); + assert_eq!(actual.default, None); assert_eq!(actual.commit, None); assert_eq!(actual.suggest, None); // Non-AppConfig field is unchanged @@ -434,11 +369,11 @@ mod tests { let actual = app_config_to_forge_config(&app, base); - let expected = ModelConfig { + let expected_model = ModelConfig { provider_id: "anthropic".to_string(), model_id: "claude-3-5-sonnet-20241022".to_string(), }; - assert_eq!(actual.model, Some(expected)); + assert_eq!(actual.default, Some(expected_model)); } #[test] @@ -463,7 +398,7 @@ mod tests { provider_id: "openai".to_string(), model_id: "gpt-4o".to_string(), }; - assert_eq!(actual.model, Some(expected)); + assert_eq!(actual.default, Some(expected)); } #[test] @@ -478,7 +413,7 @@ mod tests { let actual = app_config_to_forge_config(&app, base); - assert_eq!(actual.model, None); + assert_eq!(actual.default, None); } #[test] @@ -542,7 +477,7 @@ mod tests { original.api_key = Some("sk-test".to_string()); original.api_key_name = Some("test-key".to_string()); original.api_key_masked = Some("sk-***".to_string()); - original.model = Some(ModelConfig { + original.default = Some(ModelConfig { provider_id: "anthropic".to_string(), model_id: "claude-3-5-sonnet-20241022".to_string(), }); @@ -556,7 +491,7 @@ mod tests { assert_eq!(actual.api_key, original.api_key); assert_eq!(actual.api_key_name, original.api_key_name); - assert_eq!(actual.model, original.model); + assert_eq!(actual.default, original.default); assert_eq!(actual.commit, original.commit); } @@ -607,155 +542,19 @@ mod tests { assert_eq!(third_read, new_config); } - #[tokio::test] - async fn test_read_handles_custom_provider() { - let _home = temp_home(); - write_toml( - &_home, - r#" + #[test] + fn test_read_handles_custom_provider() { + // Verify the full parse path for a custom provider value — uses + // ConfigReader::read_str to avoid any real filesystem dependency. + let toml = r#" [model] provider = "xyz" model = "some-model" -"#, - ); - let repo = repository_fixture(&_home); +"#; + let fc = forge_config::ConfigReader::new().read_str(toml).unwrap(); - let actual = repo.get_app_config().await.unwrap(); + let actual = forge_config_to_app_config(fc); assert_eq!(actual.provider, Some(ProviderId::from_str("xyz").unwrap())); } - - #[tokio::test] - async fn test_override_model() { - let _home = temp_home(); - write_toml( - &_home, - r#" -[model] -provider = "anthropic" -model = "claude-3-5-sonnet-20241022" -"#, - ); - let repo = repository_fixture(&_home).override_model(Some(ModelId::new("override-model"))); - let actual = repo.get_app_config().await.unwrap(); - - assert_eq!( - actual.model.get(&ProviderId::ANTHROPIC), - Some(&ModelId::new("override-model")) - ); - } - - #[tokio::test] - async fn test_override_provider() { - let _home = temp_home(); - write_toml( - &_home, - r#" -[model] -provider = "anthropic" -model = "claude-3-5-sonnet-20241022" -"#, - ); - let repo = repository_fixture(&_home).override_provider(Some(ProviderId::OPENAI)); - let actual = repo.get_app_config().await.unwrap(); - - assert_eq!(actual.provider, Some(ProviderId::OPENAI)); - } - - #[tokio::test] - async fn test_override_prevents_config_write() { - let _home = temp_home(); - let repo = repository_fixture(&_home).override_model(Some(ModelId::new("override-model"))); - - let config = forge_domain::AppConfig::default(); - let actual = repo.set_app_config(&config).await; - - assert!(actual.is_err()); - assert!( - actual - .unwrap_err() - .to_string() - .contains("Model or Provider was overridden") - ); - } - - #[tokio::test] - async fn test_provider_override_applied_with_no_config() { - let _home = temp_home(); - let expected = ProviderId::from_str("open_router").unwrap(); - let repo = repository_fixture(&_home) - .override_provider(Some(expected.clone())) - .override_model(Some(ModelId::new("test-model"))); - - let actual = repo.get_app_config().await.unwrap(); - - assert_eq!(actual.provider, Some(expected)); - } - - #[tokio::test] - async fn test_model_override_applied_with_no_config() { - let _home = temp_home(); - let provider = ProviderId::OPENAI; - let expected = ModelId::new("gpt-4-test"); - let repo = repository_fixture(&_home) - .override_provider(Some(provider.clone())) - .override_model(Some(expected.clone())); - - let actual = repo.get_app_config().await.unwrap(); - - assert_eq!(actual.model.get(&provider), Some(&expected)); - } - - #[tokio::test] - async fn test_provider_override_on_cached_config() { - let _home = temp_home(); - let expected = ProviderId::ANTHROPIC; - let repo = repository_fixture(&_home) - .override_provider(Some(expected.clone())) - .override_model(Some(ModelId::new("test-model"))); - - // First call populates cache - repo.get_app_config().await.unwrap(); - - // Second call should still apply override to cached config - let actual = repo.get_app_config().await.unwrap(); - - assert_eq!(actual.provider, Some(expected)); - } - - #[tokio::test] - async fn test_model_override_on_cached_config() { - let _home = temp_home(); - let provider = ProviderId::OPENAI; - let expected = ModelId::new("gpt-4-cached"); - let repo = repository_fixture(&_home) - .override_provider(Some(provider.clone())) - .override_model(Some(expected.clone())); - - // First call populates cache - repo.get_app_config().await.unwrap(); - - // Second call should still apply override to cached config - let actual = repo.get_app_config().await.unwrap(); - - assert_eq!(actual.model.get(&provider), Some(&expected)); - } - - #[tokio::test] - async fn test_model_override_with_existing_provider() { - let _home = temp_home(); - write_toml( - &_home, - r#" -[model] -provider = "anthropic" -model = "claude-3-opus" -"#, - ); - let expected = ModelId::new("override-model"); - let repo = repository_fixture(&_home).override_model(Some(expected.clone())); - let actual = repo.get_app_config().await.unwrap(); - - assert_eq!(actual.model.get(&ProviderId::ANTHROPIC), Some(&expected)); - } } diff --git a/crates/forge_repo/src/forge_repo.rs b/crates/forge_repo/src/forge_repo.rs index d3176f072c..04f0bd37d8 100644 --- a/crates/forge_repo/src/forge_repo.rs +++ b/crates/forge_repo/src/forge_repo.rs @@ -54,11 +54,7 @@ pub struct ForgeRepo { } impl ForgeRepo { - pub fn new( - infra: Arc, - override_model: Option, - override_provider: Option, - ) -> Self { + pub fn new(infra: Arc) -> Self { let env = infra.get_environment(); let file_snapshot_service = Arc::new(ForgeFileSnapshotService::new(env.clone())); let db_pool = @@ -68,11 +64,7 @@ impl { @@ -23,6 +24,7 @@ impl ForgeAppConfigService { { let mut config = self.infra.get_app_config().await?; updater(&mut config); + debug!(config = ?config, "Updating app config"); self.infra.set_app_config(&config).await?; Ok(()) } From 38e4c5eb108f6f4371513c0b1bc08b49fffcc53b Mon Sep 17 00:00:00 2001 From: Tushar Date: Wed, 25 Mar 2026 12:11:56 +0530 Subject: [PATCH 36/67] refactor(forge_config): adjust default field comment wrap --- crates/forge_config/src/config.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/forge_config/src/config.rs b/crates/forge_config/src/config.rs index 587e057037..266bd38abe 100644 --- a/crates/forge_config/src/config.rs +++ b/crates/forge_config/src/config.rs @@ -93,7 +93,8 @@ pub struct ForgeConfig { pub max_parallel_file_reads: usize, /// TTL in seconds for the model API list cache pub model_cache_ttl_secs: u64, - /// Default model and provider configuration to use for all operations if not specified + /// Default model and provider configuration to use for all operations if + /// not specified #[serde(default)] pub default: Option, /// Provider and model to use for commit message generation From 1a470a97e0bd9f3cd38f61f1a545183a12422967 Mon Sep 17 00:00:00 2001 From: Tushar Date: Wed, 25 Mar 2026 12:24:44 +0530 Subject: [PATCH 37/67] refactor(forge_config): rename default model to session --- crates/forge_config/src/config.rs | 2 +- crates/forge_repo/src/app_config.rs | 18 +++++++++--------- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/crates/forge_config/src/config.rs b/crates/forge_config/src/config.rs index 266bd38abe..e5cfe8ce10 100644 --- a/crates/forge_config/src/config.rs +++ b/crates/forge_config/src/config.rs @@ -96,7 +96,7 @@ pub struct ForgeConfig { /// Default model and provider configuration to use for all operations if /// not specified #[serde(default)] - pub default: Option, + pub session: Option, /// Provider and model to use for commit message generation #[serde(default)] pub commit: Option, diff --git a/crates/forge_repo/src/app_config.rs b/crates/forge_repo/src/app_config.rs index 896b19921c..9f4075c301 100644 --- a/crates/forge_repo/src/app_config.rs +++ b/crates/forge_repo/src/app_config.rs @@ -22,7 +22,7 @@ fn forge_config_to_app_config(fc: ForgeConfig) -> AppConfig { auth_provider_id: fc.auth_provider_id, }); - let (provider, model) = match fc.default { + let (provider, model) = match fc.session { Some(mc) => { let provider_id = ProviderId::from(mc.provider_id); let model_id = ModelId::new(mc.model_id); @@ -70,7 +70,7 @@ fn app_config_to_forge_config(app: &AppConfig, mut fc: ForgeConfig) -> ForgeConf } // Active model — use the provider's entry from the model map - fc.default = app.provider.as_ref().and_then(|pid| { + fc.session = app.provider.as_ref().and_then(|pid| { app.model.get(pid).map(|mid| ModelConfig { provider_id: pid.as_ref().to_string(), model_id: mid.to_string(), @@ -247,7 +247,7 @@ mod tests { #[test] fn test_forge_config_to_app_config_with_model() { let mut fixture = forge_config_defaults(); - fixture.default = Some(ModelConfig { + fixture.session = Some(ModelConfig { provider_id: "anthropic".to_string(), model_id: "claude-3-5-sonnet-20241022".to_string(), }); @@ -348,7 +348,7 @@ mod tests { // All AppConfig-owned fields must be cleared; unrelated fields preserved. assert_eq!(actual.api_key, None); - assert_eq!(actual.default, None); + assert_eq!(actual.session, None); assert_eq!(actual.commit, None); assert_eq!(actual.suggest, None); // Non-AppConfig field is unchanged @@ -373,7 +373,7 @@ mod tests { provider_id: "anthropic".to_string(), model_id: "claude-3-5-sonnet-20241022".to_string(), }; - assert_eq!(actual.default, Some(expected_model)); + assert_eq!(actual.session, Some(expected_model)); } #[test] @@ -398,7 +398,7 @@ mod tests { provider_id: "openai".to_string(), model_id: "gpt-4o".to_string(), }; - assert_eq!(actual.default, Some(expected)); + assert_eq!(actual.session, Some(expected)); } #[test] @@ -413,7 +413,7 @@ mod tests { let actual = app_config_to_forge_config(&app, base); - assert_eq!(actual.default, None); + assert_eq!(actual.session, None); } #[test] @@ -477,7 +477,7 @@ mod tests { original.api_key = Some("sk-test".to_string()); original.api_key_name = Some("test-key".to_string()); original.api_key_masked = Some("sk-***".to_string()); - original.default = Some(ModelConfig { + original.session = Some(ModelConfig { provider_id: "anthropic".to_string(), model_id: "claude-3-5-sonnet-20241022".to_string(), }); @@ -491,7 +491,7 @@ mod tests { assert_eq!(actual.api_key, original.api_key); assert_eq!(actual.api_key_name, original.api_key_name); - assert_eq!(actual.default, original.default); + assert_eq!(actual.session, original.session); assert_eq!(actual.commit, original.commit); } From a4978b1b2cdb05c626e6e8a468e06a4d6f3367ae Mon Sep 17 00:00:00 2001 From: Tushar Date: Wed, 25 Mar 2026 13:51:33 +0530 Subject: [PATCH 38/67] refactor(app_config): improve .forge.toml read/write tracing --- crates/forge_repo/src/app_config.rs | 26 ++++++++++++++++---------- 1 file changed, 16 insertions(+), 10 deletions(-) diff --git a/crates/forge_repo/src/app_config.rs b/crates/forge_repo/src/app_config.rs index 9f4075c301..8be162cde2 100644 --- a/crates/forge_repo/src/app_config.rs +++ b/crates/forge_repo/src/app_config.rs @@ -5,7 +5,7 @@ use forge_domain::{ AppConfig, AppConfigRepository, CommitConfig, LoginInfo, ModelId, ProviderId, SuggestConfig, }; use tokio::sync::Mutex; -use tracing::debug; +use tracing::{debug, error}; /// Converts a [`ForgeConfig`] into an [`AppConfig`]. /// @@ -110,10 +110,15 @@ impl AppConfigRepositoryImpl { /// Reads [`AppConfig`] from disk via [`ForgeConfig::read`]. async fn read(&self) -> AppConfig { - match ForgeConfig::read().await { - Ok(fc) => forge_config_to_app_config(fc), + let config = ForgeConfig::read().await; + + match config { + Ok(config) => { + debug!(config = ?config, "read .forge.toml"); + forge_config_to_app_config(config) + } Err(e) => { - tracing::error!(error = %e, "Failed to read config file. Using default config."); + error!(error = ?e, "Failed to read config file. Using default config."); AppConfig::default() } } @@ -126,9 +131,10 @@ impl AppConfigRepositoryImpl { tracing::warn!(error = %e, "Could not read existing config; defaults will be used."); forge_config::ConfigReader::new().read_defaults() }); - let updated = app_config_to_forge_config(config, existing); - debug!(config = ?updated, "Writing updated config to disk"); - updated.write().await?; + let config = app_config_to_forge_config(config, existing); + + config.write().await?; + debug!(config = ?config, "written .forge.toml"); Ok(()) } } @@ -547,9 +553,9 @@ mod tests { // Verify the full parse path for a custom provider value — uses // ConfigReader::read_str to avoid any real filesystem dependency. let toml = r#" -[model] -provider = "xyz" -model = "some-model" +[session] +provider_id = "xyz" +model_id = "some-model" "#; let fc = forge_config::ConfigReader::new().read_str(toml).unwrap(); From 2c46da4656b79d8e9d0f6738ef45b14c58b83be9 Mon Sep 17 00:00:00 2001 From: Tushar Date: Wed, 25 Mar 2026 13:57:58 +0530 Subject: [PATCH 39/67] feat(forge_config): parse env list keys with custom separators --- crates/forge_config/src/reader.rs | 32 +++++++++++++++++++++++++++++-- 1 file changed, 30 insertions(+), 2 deletions(-) diff --git a/crates/forge_config/src/reader.rs b/crates/forge_config/src/reader.rs index 0bf2efa161..8b71195ede 100644 --- a/crates/forge_config/src/reader.rs +++ b/crates/forge_config/src/reader.rs @@ -40,9 +40,12 @@ impl ConfigReader { // Load from environment builder = builder.add_source( config::Environment::with_prefix("FORGE") + .prefix_separator("_") + .separator("__") .try_parsing(true) - .separator("_") - .list_separator(","), + .list_separator(",") + .with_list_parse_key("retry.status_codes") + .with_list_parse_key("http.root_cert_paths"), ); let config = builder.build()?; @@ -79,11 +82,36 @@ impl ConfigReader { #[cfg(test)] mod tests { + use pretty_assertions::assert_eq; + use super::*; + use crate::ModelConfig; #[tokio::test] async fn test_read_parses_without_error() { let actual = ConfigReader::new().read(None).await; assert!(actual.is_ok(), "read() failed: {:?}", actual.err()); } + + #[test] + fn test_read_session_from_env_vars() { + let fixture = HashMap::from([ + ( + "FORGE_SESSION__PROVIDER_ID".to_string(), + "fake-provider".to_string(), + ), + ( + "FORGE_SESSION__MODEL_ID".to_string(), + "fake-model".to_string(), + ), + ]); + + let actual = ConfigReader::new().read(None).unwrap(); + + let expected = Some(ModelConfig { + provider_id: "fake-provider".to_string(), + model_id: "fake-model".to_string(), + }); + assert_eq!(actual.session, expected); + } } From 6ea1fa86a0478b04300c2b0ce90fbf5c1da636bf Mon Sep 17 00:00:00 2001 From: Tushar Date: Wed, 25 Mar 2026 14:01:32 +0530 Subject: [PATCH 40/67] test(forge_config): serialize env var mutations in reader tests --- crates/forge_config/src/reader.rs | 53 ++++++++++++++++++++++++------- 1 file changed, 41 insertions(+), 12 deletions(-) diff --git a/crates/forge_config/src/reader.rs b/crates/forge_config/src/reader.rs index 8b71195ede..9640a07133 100644 --- a/crates/forge_config/src/reader.rs +++ b/crates/forge_config/src/reader.rs @@ -82,31 +82,60 @@ impl ConfigReader { #[cfg(test)] mod tests { + use std::sync::{Mutex, MutexGuard}; + use pretty_assertions::assert_eq; use super::*; use crate::ModelConfig; + /// Serializes tests that mutate environment variables to prevent races. + static ENV_MUTEX: Mutex<()> = Mutex::new(()); + + /// Guard that holds a set of environment variables for the duration of a + /// test, removing them all on drop. Also holds the [`ENV_MUTEX`] lock to + /// prevent concurrent env mutations across tests. + struct EnvGuard { + keys: Vec<&'static str>, + _lock: MutexGuard<'static, ()>, + } + + impl EnvGuard { + /// Sets each `(key, value)` pair in the process environment and returns + /// a guard that removes all those keys when dropped. + #[must_use] + fn set(pairs: &[(&'static str, &str)]) -> Self { + let lock = ENV_MUTEX.lock().unwrap_or_else(|e| e.into_inner()); + let keys = pairs.iter().map(|(k, _)| *k).collect(); + for (key, value) in pairs { + unsafe { std::env::set_var(key, value) }; + } + Self { keys, _lock: lock } + } + } + + impl Drop for EnvGuard { + fn drop(&mut self) { + for key in &self.keys { + unsafe { std::env::remove_var(key) }; + } + } + } + #[tokio::test] async fn test_read_parses_without_error() { let actual = ConfigReader::new().read(None).await; assert!(actual.is_ok(), "read() failed: {:?}", actual.err()); } - #[test] - fn test_read_session_from_env_vars() { - let fixture = HashMap::from([ - ( - "FORGE_SESSION__PROVIDER_ID".to_string(), - "fake-provider".to_string(), - ), - ( - "FORGE_SESSION__MODEL_ID".to_string(), - "fake-model".to_string(), - ), + #[tokio::test] + async fn test_read_session_from_env_vars() { + let _ = EnvGuard::set(&[ + ("FORGE_SESSION__PROVIDER_ID", "fake-provider"), + ("FORGE_SESSION__MODEL_ID", "fake-model"), ]); - let actual = ConfigReader::new().read(None).unwrap(); + let actual = ConfigReader::new().read(None).await.unwrap(); let expected = Some(ModelConfig { provider_id: "fake-provider".to_string(), From 17aa7ea23156a950232d561754568c4da349e4f9 Mon Sep 17 00:00:00 2001 From: Tushar Date: Wed, 25 Mar 2026 14:05:46 +0530 Subject: [PATCH 41/67] refactor(forge_repo): rename AppConfigRepository to ForgeConfigRepository --- crates/forge_repo/src/app_config.rs | 10 +++++----- crates/forge_repo/src/forge_repo.rs | 12 ++++++------ 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/crates/forge_repo/src/app_config.rs b/crates/forge_repo/src/app_config.rs index 8be162cde2..4a366cd6db 100644 --- a/crates/forge_repo/src/app_config.rs +++ b/crates/forge_repo/src/app_config.rs @@ -99,11 +99,11 @@ fn app_config_to_forge_config(app: &AppConfig, mut fc: ForgeConfig) -> ForgeConf /// /// Uses [`ForgeConfig::read`] and [`ForgeConfig::write`] for all file I/O and /// maintains an in-memory cache to reduce disk access. -pub struct AppConfigRepositoryImpl { +pub struct ForgeConfigRepository { cache: Arc>>, } -impl AppConfigRepositoryImpl { +impl ForgeConfigRepository { pub fn new() -> Self { Self { cache: Arc::new(Mutex::new(None)) } } @@ -140,7 +140,7 @@ impl AppConfigRepositoryImpl { } #[async_trait::async_trait] -impl AppConfigRepository for AppConfigRepositoryImpl { +impl AppConfigRepository for ForgeConfigRepository { async fn get_app_config(&self) -> anyhow::Result { // Check cache first let cache = self.cache.lock().await; @@ -226,8 +226,8 @@ mod tests { std::fs::write(path, toml).unwrap(); } - fn repository_fixture(_home: &HomeGuard) -> AppConfigRepositoryImpl { - AppConfigRepositoryImpl::new() + fn repository_fixture(_home: &HomeGuard) -> ForgeConfigRepository { + ForgeConfigRepository::new() } /// Returns a [`ForgeConfig`] built from embedded defaults only, as a diff --git a/crates/forge_repo/src/forge_repo.rs b/crates/forge_repo/src/forge_repo.rs index 04f0bd37d8..18bf3ed206 100644 --- a/crates/forge_repo/src/forge_repo.rs +++ b/crates/forge_repo/src/forge_repo.rs @@ -23,7 +23,7 @@ use reqwest_eventsource::EventSource; use url::Url; use crate::agent::ForgeAgentRepository; -use crate::app_config::AppConfigRepositoryImpl; +use crate::app_config::ForgeConfigRepository; use crate::context_engine::ForgeContextEngineRepository; use crate::conversation::ConversationRepositoryImpl; use crate::database::{DatabasePool, PoolConfig}; @@ -42,7 +42,7 @@ pub struct ForgeRepo { infra: Arc, file_snapshot_service: Arc, conversation_repository: Arc, - app_config_repository: Arc, + config_repository: Arc, mcp_cache_repository: Arc, provider_repository: Arc>, chat_repository: Arc>, @@ -64,7 +64,7 @@ impl AppConfigRepository for ForgeRepo { async fn get_app_config(&self) -> anyhow::Result { - self.app_config_repository.get_app_config().await + self.config_repository.get_app_config().await } async fn set_app_config(&self, config: &AppConfig) -> anyhow::Result<()> { - self.app_config_repository.set_app_config(config).await + self.config_repository.set_app_config(config).await } } From 7980b5843142478a06ec46fc13fb1662d939d8d0 Mon Sep 17 00:00:00 2001 From: Tushar Date: Wed, 25 Mar 2026 14:14:47 +0530 Subject: [PATCH 42/67] refactor(forge_config): make config fields optional and default derive --- crates/forge_config/src/config.rs | 15 ++++----------- crates/forge_repo/src/app_config.rs | 15 ++++++++------- 2 files changed, 12 insertions(+), 18 deletions(-) diff --git a/crates/forge_config/src/config.rs b/crates/forge_config/src/config.rs index e5cfe8ce10..581f054f0d 100644 --- a/crates/forge_config/src/config.rs +++ b/crates/forge_config/src/config.rs @@ -38,12 +38,12 @@ use crate::{ /// /// - **`_limit` is avoided**; prefer the explicit `max_` prefix + unit suffix /// instead. -#[derive(Debug, Setters, Clone, PartialEq, Serialize, Deserialize, fake::Dummy)] +#[derive(Default, Debug, Setters, Clone, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] #[setters(strip_option)] pub struct ForgeConfig { /// Configuration for the retry mechanism - pub retry: RetryConfig, + pub retry: Option, /// The maximum number of lines returned for FSSearch pub max_search_lines: usize, /// Maximum bytes allowed for search results @@ -63,7 +63,7 @@ pub struct ForgeConfig { /// Maximum number of files that can be read in a single batch operation pub max_file_read_batch_size: usize, /// HTTP configuration - pub http: HttpConfig, + pub http: Option, /// Maximum file size in bytes for operations pub max_file_size_bytes: u64, /// Maximum image file size in bytes for binary read operations @@ -83,8 +83,7 @@ pub struct ForgeConfig { /// Top-k parameter for relevance filtering during semantic search pub sem_search_top_k: usize, /// URL for the indexing server - #[dummy(expr = "url::Url::parse(\"http://localhost:8080\").unwrap()")] - pub workspace_server_url: Url, + pub workspace_server_url: Option, /// Maximum number of file extensions to include in the system prompt pub max_extensions: usize, /// Format for automatically creating a dump when a task is completed @@ -125,7 +124,6 @@ pub struct ForgeConfig { // --- Workflow fields --- /// Configuration for automatic forge updates #[serde(skip_serializing_if = "Option::is_none")] - #[dummy(default)] pub updates: Option, /// Temperature used for all agents. @@ -139,7 +137,6 @@ pub struct ForgeConfig { /// - If not specified, each agent's individual setting or the model /// provider's default will be used #[serde(default, skip_serializing_if = "Option::is_none")] - #[dummy(expr = "Some(Temperature::new(1.0).unwrap())")] pub temperature: Option, /// Top-p (nucleus sampling) used for all agents. @@ -152,7 +149,6 @@ pub struct ForgeConfig { /// - If not specified, each agent's individual setting or the model /// provider's default will be used #[serde(default, skip_serializing_if = "Option::is_none")] - #[dummy(expr = "Some(TopP::new(0.9).unwrap())")] pub top_p: Option, /// Top-k used for all agents. @@ -164,7 +160,6 @@ pub struct ForgeConfig { /// - If not specified, each agent's individual setting or the model /// provider's default will be used #[serde(default, skip_serializing_if = "Option::is_none")] - #[dummy(expr = "Some(TopK::new(50).unwrap())")] pub top_k: Option, /// Maximum number of tokens the model can generate for all agents. @@ -176,7 +171,6 @@ pub struct ForgeConfig { /// - If not specified, each agent's individual setting or the model /// provider's default will be used #[serde(default, skip_serializing_if = "Option::is_none")] - #[dummy(expr = "Some(MaxTokens::new(4000).unwrap())")] pub max_tokens: Option, /// Maximum number of times a tool can fail before the orchestrator @@ -192,7 +186,6 @@ pub struct ForgeConfig { /// If specified, this will be applied to all agents in the workflow. /// If not specified, each agent's individual setting will be used. #[serde(default, skip_serializing_if = "Option::is_none")] - #[dummy(default)] pub compact: Option, } diff --git a/crates/forge_repo/src/app_config.rs b/crates/forge_repo/src/app_config.rs index 4a366cd6db..e704db551a 100644 --- a/crates/forge_repo/src/app_config.rs +++ b/crates/forge_repo/src/app_config.rs @@ -100,7 +100,7 @@ fn app_config_to_forge_config(app: &AppConfig, mut fc: ForgeConfig) -> ForgeConf /// Uses [`ForgeConfig::read`] and [`ForgeConfig::write`] for all file I/O and /// maintains an in-memory cache to reduce disk access. pub struct ForgeConfigRepository { - cache: Arc>>, + cache: Arc>>, } impl ForgeConfigRepository { @@ -109,17 +109,18 @@ impl ForgeConfigRepository { } /// Reads [`AppConfig`] from disk via [`ForgeConfig::read`]. - async fn read(&self) -> AppConfig { + async fn read(&self) -> ForgeConfig { let config = ForgeConfig::read().await; match config { Ok(config) => { debug!(config = ?config, "read .forge.toml"); - forge_config_to_app_config(config) + config } Err(e) => { + // NOTE: This should never-happen error!(error = ?e, "Failed to read config file. Using default config."); - AppConfig::default() + Default::default() } } } @@ -144,8 +145,8 @@ impl AppConfigRepository for ForgeConfigRepository { async fn get_app_config(&self) -> anyhow::Result { // Check cache first let cache = self.cache.lock().await; - if let Some(ref cached_config) = *cache { - return Ok(cached_config.clone()); + if let Some(ref config) = *cache { + return Ok(forge_config_to_app_config(config.clone())); } drop(cache); @@ -155,7 +156,7 @@ impl AppConfigRepository for ForgeConfigRepository { let mut cache = self.cache.lock().await; *cache = Some(config.clone()); - Ok(config) + Ok(forge_config_to_app_config(config)) } async fn set_app_config(&self, config: &AppConfig) -> anyhow::Result<()> { From a09d380220e5730adb26e016f59121073fdcdd83 Mon Sep 17 00:00:00 2001 From: Tushar Date: Wed, 25 Mar 2026 14:19:12 +0530 Subject: [PATCH 43/67] refactor(forge_repo): return ForgeConfig from app config write --- crates/forge_repo/src/app_config.rs | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/crates/forge_repo/src/app_config.rs b/crates/forge_repo/src/app_config.rs index e704db551a..04f48bfb5a 100644 --- a/crates/forge_repo/src/app_config.rs +++ b/crates/forge_repo/src/app_config.rs @@ -127,7 +127,8 @@ impl ForgeConfigRepository { /// Writes [`AppConfig`] to disk via [`ForgeConfig::write`], preserving all /// non-`AppConfig` fields from the existing file. - async fn write(&self, config: &AppConfig) -> anyhow::Result<()> { + async fn write(&self, config: &AppConfig) -> anyhow::Result { + debug!(config = ?config, "writing app-config"); let existing = ForgeConfig::read().await.unwrap_or_else(|e| { tracing::warn!(error = %e, "Could not read existing config; defaults will be used."); forge_config::ConfigReader::new().read_defaults() @@ -136,7 +137,7 @@ impl ForgeConfigRepository { config.write().await?; debug!(config = ?config, "written .forge.toml"); - Ok(()) + Ok(config) } } @@ -160,11 +161,11 @@ impl AppConfigRepository for ForgeConfigRepository { } async fn set_app_config(&self, config: &AppConfig) -> anyhow::Result<()> { - self.write(config).await?; + let config = self.write(config).await?; // Bust the cache after successful write let mut cache = self.cache.lock().await; - *cache = None; + *cache = Some(config); Ok(()) } From b216e95811cb5d5a8a12028b91a1af4e27e5935b Mon Sep 17 00:00:00 2001 From: Tushar Date: Wed, 25 Mar 2026 16:07:27 +0530 Subject: [PATCH 44/67] refactor(forge_config): make ModelConfig fields optional with setters --- crates/forge_config/src/model.rs | 8 +- crates/forge_repo/src/app_config.rs | 114 ++++++++++++++++++++-------- 2 files changed, 87 insertions(+), 35 deletions(-) diff --git a/crates/forge_config/src/model.rs b/crates/forge_config/src/model.rs index 928abd636b..7097759003 100644 --- a/crates/forge_config/src/model.rs +++ b/crates/forge_config/src/model.rs @@ -1,3 +1,4 @@ +use derive_setters::Setters; use serde::{Deserialize, Serialize}; /// A type alias for a provider identifier string. @@ -7,10 +8,11 @@ pub type ProviderId = String; pub type ModelId = String; /// Pairs a provider and model together for a specific operation. -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, fake::Dummy)] +#[derive(Default, Debug, Setters, Clone, PartialEq, Serialize, Deserialize, fake::Dummy)] +#[setters(strip_option, into)] pub struct ModelConfig { /// The provider to use for this operation. - pub provider_id: String, + pub provider_id: Option, /// The model to use for this operation. - pub model_id: String, + pub model_id: Option, } diff --git a/crates/forge_repo/src/app_config.rs b/crates/forge_repo/src/app_config.rs index 04f48bfb5a..29cbe74f32 100644 --- a/crates/forge_repo/src/app_config.rs +++ b/crates/forge_repo/src/app_config.rs @@ -24,23 +24,29 @@ fn forge_config_to_app_config(fc: ForgeConfig) -> AppConfig { let (provider, model) = match fc.session { Some(mc) => { - let provider_id = ProviderId::from(mc.provider_id); - let model_id = ModelId::new(mc.model_id); + let provider_id = mc.provider_id.map(ProviderId::from); let mut map = std::collections::HashMap::new(); - map.insert(provider_id.clone(), model_id); - (Some(provider_id), map) + if let (Some(ref pid), Some(mid)) = (provider_id.clone(), mc.model_id.map(ModelId::new)) + { + map.insert(pid.clone(), mid); + } + (provider_id, map) } None => (None, std::collections::HashMap::new()), }; let commit = fc.commit.map(|mc| CommitConfig { - provider: Some(ProviderId::from(mc.provider_id)), - model: Some(ModelId::new(mc.model_id)), + provider: mc.provider_id.map(ProviderId::from), + model: mc.model_id.map(ModelId::new), }); - let suggest = fc.suggest.map(|mc| SuggestConfig { - provider: ProviderId::from(mc.provider_id), - model: ModelId::new(mc.model_id), + let suggest = fc.suggest.and_then(|mc| { + mc.provider_id + .zip(mc.model_id) + .map(|(pid, mid)| SuggestConfig { + provider: ProviderId::from(pid), + model: ModelId::new(mid), + }) }); AppConfig { key_info, provider, model, commit, suggest } @@ -70,11 +76,13 @@ fn app_config_to_forge_config(app: &AppConfig, mut fc: ForgeConfig) -> ForgeConf } // Active model — use the provider's entry from the model map - fc.session = app.provider.as_ref().and_then(|pid| { - app.model.get(pid).map(|mid| ModelConfig { - provider_id: pid.as_ref().to_string(), - model_id: mid.to_string(), - }) + fc.session = app.provider.as_ref().map(|pid| { + let mut config = ModelConfig::default().provider_id(pid.as_ref().to_string()); + if let Some(mid) = app.model.get(pid) { + config = config.model_id(mid.to_string()); + } + + config }); fc.commit = app.commit.as_ref().and_then(|cc| { @@ -82,14 +90,14 @@ fn app_config_to_forge_config(app: &AppConfig, mut fc: ForgeConfig) -> ForgeConf .as_ref() .zip(cc.model.as_ref()) .map(|(pid, mid)| ModelConfig { - provider_id: pid.as_ref().to_string(), - model_id: mid.to_string(), + provider_id: Some(pid.as_ref().to_string()), + model_id: Some(mid.to_string()), }) }); fc.suggest = app.suggest.as_ref().map(|sc| ModelConfig { - provider_id: sc.provider.as_ref().to_string(), - model_id: sc.model.to_string(), + provider_id: Some(sc.provider.as_ref().to_string()), + model_id: Some(sc.model.to_string()), }); fc @@ -256,8 +264,8 @@ mod tests { fn test_forge_config_to_app_config_with_model() { let mut fixture = forge_config_defaults(); fixture.session = Some(ModelConfig { - provider_id: "anthropic".to_string(), - model_id: "claude-3-5-sonnet-20241022".to_string(), + provider_id: Some("anthropic".to_string()), + model_id: Some("claude-3-5-sonnet-20241022".to_string()), }); let actual = forge_config_to_app_config(fixture); @@ -313,8 +321,8 @@ mod tests { fn test_forge_config_to_app_config_with_commit() { let mut fixture = forge_config_defaults(); fixture.commit = Some(ModelConfig { - provider_id: "openai".to_string(), - model_id: "gpt-4o".to_string(), + provider_id: Some("openai".to_string()), + model_id: Some("gpt-4o".to_string()), }); let actual = forge_config_to_app_config(fixture); @@ -330,8 +338,8 @@ mod tests { fn test_forge_config_to_app_config_with_suggest() { let mut fixture = forge_config_defaults(); fixture.suggest = Some(ModelConfig { - provider_id: "openai".to_string(), - model_id: "gpt-4o-mini".to_string(), + provider_id: Some("openai".to_string()), + model_id: Some("gpt-4o-mini".to_string()), }); let actual = forge_config_to_app_config(fixture); @@ -343,6 +351,32 @@ mod tests { assert_eq!(actual.suggest, Some(expected)); } + #[test] + fn test_forge_config_to_app_config_session_provider_only() { + let mut fixture = forge_config_defaults(); + fixture.session = + Some(ModelConfig { provider_id: Some("anthropic".to_string()), model_id: None }); + + let actual = forge_config_to_app_config(fixture); + + assert_eq!(actual.provider, Some(ProviderId::ANTHROPIC)); + assert!(actual.model.is_empty()); + } + + #[test] + fn test_forge_config_to_app_config_session_model_only() { + let mut fixture = forge_config_defaults(); + fixture.session = Some(ModelConfig { + provider_id: None, + model_id: Some("claude-3-5-sonnet-20241022".to_string()), + }); + + let actual = forge_config_to_app_config(fixture); + + assert_eq!(actual.provider, None); + assert!(actual.model.is_empty()); + } + // ------------------------------------------------------------------------- // app_config_to_forge_config // ------------------------------------------------------------------------- @@ -378,8 +412,8 @@ mod tests { let actual = app_config_to_forge_config(&app, base); let expected_model = ModelConfig { - provider_id: "anthropic".to_string(), - model_id: "claude-3-5-sonnet-20241022".to_string(), + provider_id: Some("anthropic".to_string()), + model_id: Some("claude-3-5-sonnet-20241022".to_string()), }; assert_eq!(actual.session, Some(expected_model)); } @@ -403,8 +437,8 @@ mod tests { let actual = app_config_to_forge_config(&app, base); let expected = ModelConfig { - provider_id: "openai".to_string(), - model_id: "gpt-4o".to_string(), + provider_id: Some("openai".to_string()), + model_id: Some("gpt-4o".to_string()), }; assert_eq!(actual.session, Some(expected)); } @@ -424,6 +458,22 @@ mod tests { assert_eq!(actual.session, None); } + #[test] + fn test_app_config_to_forge_config_provider_only_no_model_in_map() { + // Provider is set but model map has no entry for it → session has provider_id but no model_id. + let app = AppConfig { + provider: Some(ProviderId::ANTHROPIC), + model: HashMap::new(), + ..Default::default() + }; + let base = forge_config_defaults(); + + let actual = app_config_to_forge_config(&app, base); + + let expected = ModelConfig { provider_id: Some("anthropic".to_string()), model_id: None }; + assert_eq!(actual.session, Some(expected)); + } + #[test] fn test_app_config_to_forge_config_with_login_info() { let app = AppConfig { @@ -486,12 +536,12 @@ mod tests { original.api_key_name = Some("test-key".to_string()); original.api_key_masked = Some("sk-***".to_string()); original.session = Some(ModelConfig { - provider_id: "anthropic".to_string(), - model_id: "claude-3-5-sonnet-20241022".to_string(), + provider_id: Some("anthropic".to_string()), + model_id: Some("claude-3-5-sonnet-20241022".to_string()), }); original.commit = Some(ModelConfig { - provider_id: "openai".to_string(), - model_id: "gpt-4o".to_string(), + provider_id: Some("openai".to_string()), + model_id: Some("gpt-4o".to_string()), }); let app = forge_config_to_app_config(original.clone()); From d029dc96397e8589b4eb664faae40c4b3afbb844 Mon Sep 17 00:00:00 2001 From: Tushar Date: Wed, 25 Mar 2026 16:18:14 +0530 Subject: [PATCH 45/67] refactor(forge_repo): use ModelConfig default with setters --- crates/forge_repo/src/app_config.rs | 82 +++++++++++++++-------------- 1 file changed, 42 insertions(+), 40 deletions(-) diff --git a/crates/forge_repo/src/app_config.rs b/crates/forge_repo/src/app_config.rs index 29cbe74f32..6c6d09353d 100644 --- a/crates/forge_repo/src/app_config.rs +++ b/crates/forge_repo/src/app_config.rs @@ -89,15 +89,17 @@ fn app_config_to_forge_config(app: &AppConfig, mut fc: ForgeConfig) -> ForgeConf cc.provider .as_ref() .zip(cc.model.as_ref()) - .map(|(pid, mid)| ModelConfig { - provider_id: Some(pid.as_ref().to_string()), - model_id: Some(mid.to_string()), + .map(|(pid, mid)| { + ModelConfig::default() + .provider_id(pid.as_ref().to_string()) + .model_id(mid.to_string()) }) }); - fc.suggest = app.suggest.as_ref().map(|sc| ModelConfig { - provider_id: Some(sc.provider.as_ref().to_string()), - model_id: Some(sc.model.to_string()), + fc.suggest = app.suggest.as_ref().map(|sc| { + ModelConfig::default() + .provider_id(sc.provider.as_ref().to_string()) + .model_id(sc.model.to_string()) }); fc @@ -263,10 +265,11 @@ mod tests { #[test] fn test_forge_config_to_app_config_with_model() { let mut fixture = forge_config_defaults(); - fixture.session = Some(ModelConfig { - provider_id: Some("anthropic".to_string()), - model_id: Some("claude-3-5-sonnet-20241022".to_string()), - }); + fixture.session = Some( + ModelConfig::default() + .provider_id("anthropic".to_string()) + .model_id("claude-3-5-sonnet-20241022".to_string()), + ); let actual = forge_config_to_app_config(fixture); @@ -320,10 +323,11 @@ mod tests { #[test] fn test_forge_config_to_app_config_with_commit() { let mut fixture = forge_config_defaults(); - fixture.commit = Some(ModelConfig { - provider_id: Some("openai".to_string()), - model_id: Some("gpt-4o".to_string()), - }); + fixture.commit = Some( + ModelConfig::default() + .provider_id("openai".to_string()) + .model_id("gpt-4o".to_string()), + ); let actual = forge_config_to_app_config(fixture); @@ -337,10 +341,11 @@ mod tests { #[test] fn test_forge_config_to_app_config_with_suggest() { let mut fixture = forge_config_defaults(); - fixture.suggest = Some(ModelConfig { - provider_id: Some("openai".to_string()), - model_id: Some("gpt-4o-mini".to_string()), - }); + fixture.suggest = Some( + ModelConfig::default() + .provider_id("openai".to_string()) + .model_id("gpt-4o-mini".to_string()), + ); let actual = forge_config_to_app_config(fixture); @@ -355,7 +360,7 @@ mod tests { fn test_forge_config_to_app_config_session_provider_only() { let mut fixture = forge_config_defaults(); fixture.session = - Some(ModelConfig { provider_id: Some("anthropic".to_string()), model_id: None }); + Some(ModelConfig::default().provider_id("anthropic".to_string())); let actual = forge_config_to_app_config(fixture); @@ -366,10 +371,7 @@ mod tests { #[test] fn test_forge_config_to_app_config_session_model_only() { let mut fixture = forge_config_defaults(); - fixture.session = Some(ModelConfig { - provider_id: None, - model_id: Some("claude-3-5-sonnet-20241022".to_string()), - }); + fixture.session = Some(ModelConfig::default().model_id("claude-3-5-sonnet-20241022".to_string())); let actual = forge_config_to_app_config(fixture); @@ -411,10 +413,9 @@ mod tests { let actual = app_config_to_forge_config(&app, base); - let expected_model = ModelConfig { - provider_id: Some("anthropic".to_string()), - model_id: Some("claude-3-5-sonnet-20241022".to_string()), - }; + let expected_model = ModelConfig::default() + .provider_id("anthropic".to_string()) + .model_id("claude-3-5-sonnet-20241022".to_string()); assert_eq!(actual.session, Some(expected_model)); } @@ -436,10 +437,9 @@ mod tests { let actual = app_config_to_forge_config(&app, base); - let expected = ModelConfig { - provider_id: Some("openai".to_string()), - model_id: Some("gpt-4o".to_string()), - }; + let expected = ModelConfig::default() + .provider_id("openai".to_string()) + .model_id("gpt-4o".to_string()); assert_eq!(actual.session, Some(expected)); } @@ -470,7 +470,7 @@ mod tests { let actual = app_config_to_forge_config(&app, base); - let expected = ModelConfig { provider_id: Some("anthropic".to_string()), model_id: None }; + let expected = ModelConfig::default().provider_id("anthropic".to_string()); assert_eq!(actual.session, Some(expected)); } @@ -535,14 +535,16 @@ mod tests { original.api_key = Some("sk-test".to_string()); original.api_key_name = Some("test-key".to_string()); original.api_key_masked = Some("sk-***".to_string()); - original.session = Some(ModelConfig { - provider_id: Some("anthropic".to_string()), - model_id: Some("claude-3-5-sonnet-20241022".to_string()), - }); - original.commit = Some(ModelConfig { - provider_id: Some("openai".to_string()), - model_id: Some("gpt-4o".to_string()), - }); + original.session = Some( + ModelConfig::default() + .provider_id("anthropic".to_string()) + .model_id("claude-3-5-sonnet-20241022".to_string()), + ); + original.commit = Some( + ModelConfig::default() + .provider_id("openai".to_string()) + .model_id("gpt-4o".to_string()), + ); let app = forge_config_to_app_config(original.clone()); let actual = app_config_to_forge_config(&app, original.clone()); From fd94121589ef9a343c8d3f0781a7f69cdaa2b359 Mon Sep 17 00:00:00 2001 From: Tushar Date: Wed, 25 Mar 2026 16:23:52 +0530 Subject: [PATCH 46/67] test(forge_config): update reader test to use optional ModelConfig fields --- crates/forge_config/src/reader.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/forge_config/src/reader.rs b/crates/forge_config/src/reader.rs index 9640a07133..ab4856be74 100644 --- a/crates/forge_config/src/reader.rs +++ b/crates/forge_config/src/reader.rs @@ -138,8 +138,8 @@ mod tests { let actual = ConfigReader::new().read(None).await.unwrap(); let expected = Some(ModelConfig { - provider_id: "fake-provider".to_string(), - model_id: "fake-model".to_string(), + provider_id: Some("fake-provider".to_string()), + model_id: Some("fake-model".to_string()), }); assert_eq!(actual.session, expected); } From a53e008825a0d981826205e5bd38343b4344d480 Mon Sep 17 00:00:00 2001 From: Tushar Date: Wed, 25 Mar 2026 16:28:04 +0530 Subject: [PATCH 47/67] feat(forge_config): add legacy json config migration support --- Cargo.lock | 1 + crates/forge_config/Cargo.toml | 1 + crates/forge_config/src/reader.rs | 82 +++++++++++++++++++++++++++++-- 3 files changed, 81 insertions(+), 3 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index e5689bed23..26dd6f854e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1951,6 +1951,7 @@ dependencies = [ "thiserror 2.0.18", "tokio", "toml_edit", + "tracing", "url", ] diff --git a/crates/forge_config/Cargo.toml b/crates/forge_config/Cargo.toml index 40d4d5f501..5f63c90405 100644 --- a/crates/forge_config/Cargo.toml +++ b/crates/forge_config/Cargo.toml @@ -17,6 +17,7 @@ url.workspace = true fake = { version = "5.1.0", features = ["derive"] } schemars.workspace = true merge.workspace = true +tracing.workspace = true [dev-dependencies] pretty_assertions.workspace = true diff --git a/crates/forge_config/src/reader.rs b/crates/forge_config/src/reader.rs index ab4856be74..afe1a0b7ce 100644 --- a/crates/forge_config/src/reader.rs +++ b/crates/forge_config/src/reader.rs @@ -1,25 +1,84 @@ -use std::path::Path; +use std::collections::HashMap; +use std::path::{Path, PathBuf}; use config::Config; +use serde::Deserialize; +use tracing::debug; -use crate::ForgeConfig; +use crate::{ForgeConfig, ModelConfig}; /// Reads and merges [`ForgeConfig`] from multiple sources: embedded defaults, /// home directory file, current working directory file, and environment /// variables. pub struct ConfigReader {} +/// Intermediate representation of the legacy `~/forge/.config.json` format. +/// +/// This format stores the active provider as a top-level string and models as +/// a map from provider ID to model ID, which differs from the TOML config's +/// nested `session`, `commit`, and `suggest` sub-objects. +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct LegacyConfig { + /// The active provider ID (e.g. `"anthropic"`). + #[serde(default)] + provider: Option, + /// Map from provider ID to the model ID to use with that provider. + #[serde(default)] + model: HashMap, + /// Commit message generation provider/model pair. + #[serde(default)] + commit: Option, + /// Shell command suggestion provider/model pair. + #[serde(default)] + suggest: Option, +} + +/// A provider/model pair as expressed in the legacy JSON config. +#[derive(Debug, Deserialize)] +struct LegacyModelRef { + provider: Option, + model: Option, +} + +impl LegacyConfig { + /// Converts a [`LegacyConfig`] into the fields of [`ForgeConfig`] that it + /// covers, leaving all other fields at their defaults. + fn into_forge_config(self) -> ForgeConfig { + let session = self.provider.as_deref().map(|provider_id| { + let model_id = self.model.get(provider_id).cloned(); + ModelConfig { provider_id: Some(provider_id.to_string()), model_id } + }); + + let commit = self + .commit + .map(|c| ModelConfig { provider_id: c.provider, model_id: c.model }); + + let suggest = self + .suggest + .map(|s| ModelConfig { provider_id: s.provider, model_id: s.model }); + + ForgeConfig { session, commit, suggest, ..Default::default() } + } +} + impl ConfigReader { /// Creates a new `ConfigReader`. pub fn new() -> Self { Self {} } + /// Returns the path to the legacy JSON config file: `~/forge/.config.json`. + fn legacy_config_path() -> Option { + dirs::home_dir().map(|home| home.join("forge").join(".config.json")) + } + /// Reads and merges configuration from all sources, returning the resolved /// [`ForgeConfig`]. /// /// Sources are applied in increasing priority order: embedded defaults, - /// the optional file at `path` (skipped when `None`), then environment + /// `~/forge/.config.json` (legacy JSON config, skipped when absent), the + /// optional file at `path` (skipped when `None`), then environment /// variables prefixed with `FORGE_`. pub async fn read(&self, path: Option<&Path>) -> crate::Result { let defaults = include_str!("../.forge.toml"); @@ -28,6 +87,23 @@ impl ConfigReader { // Load default builder = builder.add_source(config::File::from_str(defaults, config::FileFormat::Toml)); + // Load from ~/forge/.config.json (legacy format) + if let Some(path) = Self::legacy_config_path() { + if tokio::fs::try_exists(&path).await? { + let json_contents = tokio::fs::read_to_string(&path).await?; + if let Ok(json_config) = serde_json::from_str::(&json_contents) { + let config = json_config.into_forge_config(); + let toml_contents = toml_edit::ser::to_string(&config).unwrap_or_default(); + builder = builder.add_source(config::File::from_str( + &toml_contents, + config::FileFormat::Toml, + )); + } + } else { + debug!("Legacy config file not found at {:?}, skipping", path); + } + } + // Load from path if let Some(path) = path && tokio::fs::try_exists(path).await? From 3b7e1ec233f66a0255ce07a560dc2299e9bfa354 Mon Sep 17 00:00:00 2001 From: Tushar Date: Wed, 25 Mar 2026 16:37:00 +0530 Subject: [PATCH 48/67] refactor(shell-plugin): use env vars for session model/provider --- shell-plugin/forge.theme.zsh | 4 ++-- shell-plugin/lib/actions/config.zsh | 4 +--- shell-plugin/lib/helpers.zsh | 8 ++++---- 3 files changed, 7 insertions(+), 9 deletions(-) diff --git a/shell-plugin/forge.theme.zsh b/shell-plugin/forge.theme.zsh index b2f3ef6858..065f275a0b 100644 --- a/shell-plugin/forge.theme.zsh +++ b/shell-plugin/forge.theme.zsh @@ -14,9 +14,9 @@ function _forge_prompt_info() { # reflects the active session override rather than global config. local -a forge_cmd forge_cmd=("$forge_bin") - [[ -n "$_FORGE_SESSION_MODEL" ]] && forge_cmd+=(--model "$_FORGE_SESSION_MODEL") - [[ -n "$_FORGE_SESSION_PROVIDER" ]] && forge_cmd+=(--provider "$_FORGE_SESSION_PROVIDER") forge_cmd+=(zsh rprompt) + [[ -n "$_FORGE_SESSION_MODEL" ]] && local -x FORGE_SESSION__MODEL_ID="$_FORGE_SESSION_MODEL" + [[ -n "$_FORGE_SESSION_PROVIDER" ]] && local -x FORGE_SESSION__PROVIDER_ID="$_FORGE_SESSION_PROVIDER" _FORGE_CONVERSATION_ID=$_FORGE_CONVERSATION_ID _FORGE_ACTIVE_AGENT=$_FORGE_ACTIVE_AGENT "${forge_cmd[@]}" } diff --git a/shell-plugin/lib/actions/config.zsh b/shell-plugin/lib/actions/config.zsh index 324cac1b24..a1f773562c 100644 --- a/shell-plugin/lib/actions/config.zsh +++ b/shell-plugin/lib/actions/config.zsh @@ -175,9 +175,7 @@ function _forge_action_model() { if [[ -n "$provider_display" && "$provider_display" != "$current_provider" ]]; then _forge_exec_interactive config set provider "$provider_id" --model "$model_id" return - fi - - _forge_exec config set model "$model_id" + fi fi ) } diff --git a/shell-plugin/lib/helpers.zsh b/shell-plugin/lib/helpers.zsh index e0a017282e..03bcece244 100644 --- a/shell-plugin/lib/helpers.zsh +++ b/shell-plugin/lib/helpers.zsh @@ -22,9 +22,9 @@ function _forge_exec() { local agent_id="${_FORGE_ACTIVE_AGENT:-forge}" local -a cmd cmd=($_FORGE_BIN --agent "$agent_id") - [[ -n "$_FORGE_SESSION_MODEL" ]] && cmd+=(--model "$_FORGE_SESSION_MODEL") - [[ -n "$_FORGE_SESSION_PROVIDER" ]] && cmd+=(--provider "$_FORGE_SESSION_PROVIDER") cmd+=("$@") + [[ -n "$_FORGE_SESSION_MODEL" ]] && local -x FORGE_SESSION__MODEL_ID="$_FORGE_SESSION_MODEL" + [[ -n "$_FORGE_SESSION_PROVIDER" ]] && local -x FORGE_SESSION__PROVIDER_ID="$_FORGE_SESSION_PROVIDER" "${cmd[@]}" } @@ -38,9 +38,9 @@ function _forge_exec_interactive() { local agent_id="${_FORGE_ACTIVE_AGENT:-forge}" local -a cmd cmd=($_FORGE_BIN --agent "$agent_id") - [[ -n "$_FORGE_SESSION_MODEL" ]] && cmd+=(--model "$_FORGE_SESSION_MODEL") - [[ -n "$_FORGE_SESSION_PROVIDER" ]] && cmd+=(--provider "$_FORGE_SESSION_PROVIDER") cmd+=("$@") + [[ -n "$_FORGE_SESSION_MODEL" ]] && local -x FORGE_SESSION__MODEL_ID="$_FORGE_SESSION_MODEL" + [[ -n "$_FORGE_SESSION_PROVIDER" ]] && local -x FORGE_SESSION__PROVIDER_ID="$_FORGE_SESSION_PROVIDER" "${cmd[@]}" /dev/tty } From ef28bfa5fcd1b33fe505acd438131816f3e9f80d Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Wed, 25 Mar 2026 11:08:58 +0000 Subject: [PATCH 49/67] [autofix.ci] apply automated fixes --- crates/forge_repo/src/app_config.rs | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/crates/forge_repo/src/app_config.rs b/crates/forge_repo/src/app_config.rs index 6c6d09353d..5831078c4e 100644 --- a/crates/forge_repo/src/app_config.rs +++ b/crates/forge_repo/src/app_config.rs @@ -359,8 +359,7 @@ mod tests { #[test] fn test_forge_config_to_app_config_session_provider_only() { let mut fixture = forge_config_defaults(); - fixture.session = - Some(ModelConfig::default().provider_id("anthropic".to_string())); + fixture.session = Some(ModelConfig::default().provider_id("anthropic".to_string())); let actual = forge_config_to_app_config(fixture); @@ -371,7 +370,8 @@ mod tests { #[test] fn test_forge_config_to_app_config_session_model_only() { let mut fixture = forge_config_defaults(); - fixture.session = Some(ModelConfig::default().model_id("claude-3-5-sonnet-20241022".to_string())); + fixture.session = + Some(ModelConfig::default().model_id("claude-3-5-sonnet-20241022".to_string())); let actual = forge_config_to_app_config(fixture); @@ -460,7 +460,8 @@ mod tests { #[test] fn test_app_config_to_forge_config_provider_only_no_model_in_map() { - // Provider is set but model map has no entry for it → session has provider_id but no model_id. + // Provider is set but model map has no entry for it → session has provider_id + // but no model_id. let app = AppConfig { provider: Some(ProviderId::ANTHROPIC), model: HashMap::new(), From 6effa054ddafa3ede31a1a5cc012f85cc265b045 Mon Sep 17 00:00:00 2001 From: Tushar Date: Wed, 25 Mar 2026 16:42:32 +0530 Subject: [PATCH 50/67] feat(shell-plugin): add config-edit action to open forge toml --- crates/forge_main/src/built_in_commands.json | 4 ++ shell-plugin/lib/actions/config.zsh | 42 ++++++++++++++++++++ shell-plugin/lib/dispatcher.zsh | 3 ++ 3 files changed, 49 insertions(+) diff --git a/crates/forge_main/src/built_in_commands.json b/crates/forge_main/src/built_in_commands.json index 8584b2cd85..b57f5b0dc8 100644 --- a/crates/forge_main/src/built_in_commands.json +++ b/crates/forge_main/src/built_in_commands.json @@ -35,6 +35,10 @@ "command": "config", "description": "List current configuration values" }, + { + "command": "config-edit", + "description": "Open the global forge config file (~/forge/.forge.toml) in an editor [alias: ce]" + }, { "command": "new", "description": "Start new conversation [alias: n]" diff --git a/shell-plugin/lib/actions/config.zsh b/shell-plugin/lib/actions/config.zsh index a1f773562c..d6a8dcca82 100644 --- a/shell-plugin/lib/actions/config.zsh +++ b/shell-plugin/lib/actions/config.zsh @@ -380,6 +380,48 @@ function _forge_action_config() { $_FORGE_BIN config list } +# Action handler: Open the global forge config file in an editor +function _forge_action_config_edit() { + echo + + # Determine editor in order of preference: FORGE_EDITOR > EDITOR > nano + local editor_cmd="${FORGE_EDITOR:-${EDITOR:-nano}}" + + # Validate editor exists + if ! command -v "${editor_cmd%% *}" &>/dev/null; then + _forge_log error "Editor not found: $editor_cmd (set FORGE_EDITOR or EDITOR)" + return 1 + fi + + local config_file="${HOME}/forge/.forge.toml" + + # Ensure the config directory exists + if [[ ! -d "${HOME}/forge" ]]; then + mkdir -p "${HOME}/forge" || { + _forge_log error "Failed to create ~/forge directory" + return 1 + } + fi + + # Create the config file if it does not yet exist + if [[ ! -f "$config_file" ]]; then + touch "$config_file" || { + _forge_log error "Failed to create $config_file" + return 1 + } + fi + + # Open editor with its own TTY session + (eval "$editor_cmd '$config_file'" /dev/tty 2>&1) + local exit_code=$? + + if [[ $exit_code -ne 0 ]]; then + _forge_log error "Editor exited with error code $exit_code" + fi + + _forge_reset +} + # Action handler: Show tools function _forge_action_tools() { echo diff --git a/shell-plugin/lib/dispatcher.zsh b/shell-plugin/lib/dispatcher.zsh index 1f4b539b45..e162f28dcf 100644 --- a/shell-plugin/lib/dispatcher.zsh +++ b/shell-plugin/lib/dispatcher.zsh @@ -190,6 +190,9 @@ function forge-accept-line() { config) _forge_action_config ;; + config-edit|ce) + _forge_action_config_edit + ;; skill) _forge_action_skill ;; From 0c94f95234e37b83735aefd89a68c240ab5f60ad Mon Sep 17 00:00:00 2001 From: Tushar Date: Wed, 25 Mar 2026 17:17:37 +0530 Subject: [PATCH 51/67] fix(app_config): correct forge config directory path in test helper --- crates/forge_repo/src/app_config.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/forge_repo/src/app_config.rs b/crates/forge_repo/src/app_config.rs index 5831078c4e..0a15336682 100644 --- a/crates/forge_repo/src/app_config.rs +++ b/crates/forge_repo/src/app_config.rs @@ -228,7 +228,7 @@ mod tests { /// Returns the path to `.forge.toml` inside a temp home directory. fn forge_toml_path(home: &HomeGuard) -> PathBuf { - home.path().join(".forge").join(".forge.toml") + home.path().join("forge").join(".forge.toml") } /// Writes a TOML string to the forge config path, creating parent dirs. From 79b2515a7e4ced79019eb67bd3fd65b4bb3ffbdf Mon Sep 17 00:00:00 2001 From: Tushar Date: Wed, 25 Mar 2026 18:07:54 +0530 Subject: [PATCH 52/67] refactor(forge_config): derive default for config reader --- crates/forge_config/src/config.rs | 2 +- crates/forge_config/src/reader.rs | 10 +++------- 2 files changed, 4 insertions(+), 8 deletions(-) diff --git a/crates/forge_config/src/config.rs b/crates/forge_config/src/config.rs index 581f054f0d..88e7741345 100644 --- a/crates/forge_config/src/config.rs +++ b/crates/forge_config/src/config.rs @@ -211,7 +211,7 @@ impl ForgeConfig { /// be read, or the configuration cannot be deserialized. pub async fn read() -> crate::Result { let path = Self::config_path()?; - ConfigReader::new().read(Some(&path)).await + ConfigReader::default().read(Some(&path)).await } /// Writes the configuration to the user config file. diff --git a/crates/forge_config/src/reader.rs b/crates/forge_config/src/reader.rs index afe1a0b7ce..040a9f7347 100644 --- a/crates/forge_config/src/reader.rs +++ b/crates/forge_config/src/reader.rs @@ -10,6 +10,7 @@ use crate::{ForgeConfig, ModelConfig}; /// Reads and merges [`ForgeConfig`] from multiple sources: embedded defaults, /// home directory file, current working directory file, and environment /// variables. +#[derive(Default)] pub struct ConfigReader {} /// Intermediate representation of the legacy `~/forge/.config.json` format. @@ -63,11 +64,6 @@ impl LegacyConfig { } impl ConfigReader { - /// Creates a new `ConfigReader`. - pub fn new() -> Self { - Self {} - } - /// Returns the path to the legacy JSON config file: `~/forge/.config.json`. fn legacy_config_path() -> Option { dirs::home_dir().map(|home| home.join("forge").join(".config.json")) @@ -200,7 +196,7 @@ mod tests { #[tokio::test] async fn test_read_parses_without_error() { - let actual = ConfigReader::new().read(None).await; + let actual = ConfigReader::default().read(None).await; assert!(actual.is_ok(), "read() failed: {:?}", actual.err()); } @@ -211,7 +207,7 @@ mod tests { ("FORGE_SESSION__MODEL_ID", "fake-model"), ]); - let actual = ConfigReader::new().read(None).await.unwrap(); + let actual = ConfigReader::default().read(None).await.unwrap(); let expected = Some(ModelConfig { provider_id: Some("fake-provider".to_string()), From 05e4a0e86d28ac48735c183ad16cdfcebe121745 Mon Sep 17 00:00:00 2001 From: Tushar Date: Wed, 25 Mar 2026 18:11:17 +0530 Subject: [PATCH 53/67] feat(app_config): add app config operations update API --- crates/forge_domain/src/app_config.rs | 34 ++++++++++++++++ crates/forge_domain/src/repo.rs | 21 ++++++++-- crates/forge_repo/src/app_config.rs | 33 +++++++++------ crates/forge_repo/src/forge_repo.rs | 14 +++---- crates/forge_services/src/app_config.rs | 53 ++++++++++--------------- crates/forge_services/src/auth.rs | 9 ++--- 6 files changed, 104 insertions(+), 60 deletions(-) diff --git a/crates/forge_domain/src/app_config.rs b/crates/forge_domain/src/app_config.rs index d4dc39883e..4c43fc864d 100644 --- a/crates/forge_domain/src/app_config.rs +++ b/crates/forge_domain/src/app_config.rs @@ -35,3 +35,37 @@ pub struct LoginInfo { #[serde(default, skip_serializing_if = "Option::is_none")] pub auth_provider_id: Option, } + +/// All discrete mutations that can be applied to an [`AppConfig`]. +/// +/// Instead of replacing the entire config, callers describe exactly which field +/// they want to change. Implementations receive a list of operations, apply +/// each in order, and persist the result atomically. +#[derive(Debug, Clone, PartialEq)] +pub enum AppConfigOperation { + /// Set or clear the authentication token. + KeyInfo(Option), + /// Set the active provider. + SetProvider(ProviderId), + /// Set the model for the given provider. + SetModel(ProviderId, ModelId), + /// Set the commit-message generation configuration. + SetCommitConfig(CommitConfig), + /// Set the shell-command suggestion configuration. + SetSuggestConfig(SuggestConfig), +} + +impl AppConfigOperation { + /// Applies this operation to `config` in-place. + pub fn apply(self, config: &mut AppConfig) { + match self { + AppConfigOperation::KeyInfo(info) => config.key_info = info, + AppConfigOperation::SetProvider(provider_id) => config.provider = Some(provider_id), + AppConfigOperation::SetModel(provider_id, model_id) => { + config.model.insert(provider_id, model_id); + } + AppConfigOperation::SetCommitConfig(commit) => config.commit = Some(commit), + AppConfigOperation::SetSuggestConfig(suggest) => config.suggest = Some(suggest), + } + } +} diff --git a/crates/forge_domain/src/repo.rs b/crates/forge_domain/src/repo.rs index e3602f71ce..2f68b00beb 100644 --- a/crates/forge_domain/src/repo.rs +++ b/crates/forge_domain/src/repo.rs @@ -4,9 +4,9 @@ use anyhow::Result; use url::Url; use crate::{ - AnyProvider, AppConfig, AuthCredential, ChatCompletionMessage, Context, Conversation, - ConversationId, MigrationResult, Model, ModelId, Provider, ProviderId, ProviderTemplate, - ResultStream, SearchMatch, Skill, Snapshot, WorkspaceAuth, WorkspaceId, + AnyProvider, AppConfig, AppConfigOperation, AuthCredential, ChatCompletionMessage, Context, + Conversation, ConversationId, MigrationResult, Model, ModelId, Provider, ProviderId, + ProviderTemplate, ResultStream, SearchMatch, Skill, Snapshot, WorkspaceAuth, WorkspaceId, }; /// Repository for managing file snapshots @@ -91,8 +91,21 @@ pub trait ConversationRepository: Send + Sync { #[async_trait::async_trait] pub trait AppConfigRepository: Send + Sync { + /// Retrieves the current application configuration. + /// + /// # Errors + /// Returns an error if the configuration cannot be read. async fn get_app_config(&self) -> anyhow::Result; - async fn set_app_config(&self, config: &AppConfig) -> anyhow::Result<()>; + + /// Applies a list of configuration operations to the persisted config. + /// + /// Implementations should load the current config, apply each operation in + /// order via [`AppConfigOperation::apply`], and persist the result + /// atomically. + /// + /// # Errors + /// Returns an error if the configuration cannot be read or written. + async fn update_app_config(&self, ops: Vec) -> anyhow::Result<()>; } #[async_trait::async_trait] diff --git a/crates/forge_repo/src/app_config.rs b/crates/forge_repo/src/app_config.rs index 0a15336682..e7dde32c07 100644 --- a/crates/forge_repo/src/app_config.rs +++ b/crates/forge_repo/src/app_config.rs @@ -2,7 +2,8 @@ use std::sync::Arc; use forge_config::{ForgeConfig, ModelConfig}; use forge_domain::{ - AppConfig, AppConfigRepository, CommitConfig, LoginInfo, ModelId, ProviderId, SuggestConfig, + AppConfig, AppConfigOperation, AppConfigRepository, CommitConfig, LoginInfo, ModelId, + ProviderId, SuggestConfig, }; use tokio::sync::Mutex; use tracing::{debug, error}; @@ -141,7 +142,7 @@ impl ForgeConfigRepository { debug!(config = ?config, "writing app-config"); let existing = ForgeConfig::read().await.unwrap_or_else(|e| { tracing::warn!(error = %e, "Could not read existing config; defaults will be used."); - forge_config::ConfigReader::new().read_defaults() + forge_config::ConfigReader::default().read_defaults() }); let config = app_config_to_forge_config(config, existing); @@ -170,12 +171,16 @@ impl AppConfigRepository for ForgeConfigRepository { Ok(forge_config_to_app_config(config)) } - async fn set_app_config(&self, config: &AppConfig) -> anyhow::Result<()> { - let config = self.write(config).await?; + async fn update_app_config(&self, ops: Vec) -> anyhow::Result<()> { + let mut config = self.get_app_config().await?; + for op in ops { + op.apply(&mut config); + } + let written = self.write(&config).await?; // Bust the cache after successful write let mut cache = self.cache.lock().await; - *cache = Some(config); + *cache = Some(written); Ok(()) } @@ -245,7 +250,7 @@ mod tests { /// Returns a [`ForgeConfig`] built from embedded defaults only, as a /// clean starting point for conversion fixtures. fn forge_config_defaults() -> ForgeConfig { - forge_config::ConfigReader::new().read_defaults() + forge_config::ConfigReader::default().read_defaults() } // ------------------------------------------------------------------------- @@ -569,16 +574,17 @@ mod tests { #[tokio::test] async fn test_set_app_config() { let _home = temp_home(); - let fixture = forge_domain::AppConfig::default(); let repo = repository_fixture(&_home); - let actual = repo.set_app_config(&fixture).await; + let actual = repo + .update_app_config(vec![AppConfigOperation::SetProvider(ProviderId::ANTHROPIC)]) + .await; assert!(actual.is_ok()); // Verify the config was actually written by reading it back let read_config = repo.get_app_config().await.unwrap(); - assert_eq!(read_config, fixture); + assert_eq!(read_config.provider, Some(ProviderId::ANTHROPIC)); } #[tokio::test] @@ -595,12 +601,13 @@ mod tests { assert_eq!(first_read, second_read); // Write new config should bust cache - let new_config = forge_domain::AppConfig::default(); - repo.set_app_config(&new_config).await.unwrap(); + repo.update_app_config(vec![AppConfigOperation::SetProvider(ProviderId::OPENAI)]) + .await + .unwrap(); // Next read should get fresh data let third_read = repo.get_app_config().await.unwrap(); - assert_eq!(third_read, new_config); + assert_eq!(third_read.provider, Some(ProviderId::OPENAI)); } #[test] @@ -612,7 +619,7 @@ mod tests { provider_id = "xyz" model_id = "some-model" "#; - let fc = forge_config::ConfigReader::new().read_str(toml).unwrap(); + let fc = forge_config::ConfigReader::default().read_str(toml).unwrap(); let actual = forge_config_to_app_config(fc); diff --git a/crates/forge_repo/src/forge_repo.rs b/crates/forge_repo/src/forge_repo.rs index 18bf3ed206..c0b101c14f 100644 --- a/crates/forge_repo/src/forge_repo.rs +++ b/crates/forge_repo/src/forge_repo.rs @@ -9,11 +9,11 @@ use forge_app::{ KVStore, McpServerInfra, StrategyFactory, UserInfra, WalkedFile, Walker, WalkerInfra, }; use forge_domain::{ - AnyProvider, AppConfig, AppConfigRepository, AuthCredential, ChatCompletionMessage, - ChatRepository, CommandOutput, Context, Conversation, ConversationId, ConversationRepository, - Environment, FileInfo, FuzzySearchRepository, McpServerConfig, MigrationResult, Model, ModelId, - Provider, ProviderId, ProviderRepository, ResultStream, SearchMatch, Skill, SkillRepository, - Snapshot, SnapshotRepository, + AnyProvider, AppConfig, AppConfigOperation, AppConfigRepository, AuthCredential, + ChatCompletionMessage, ChatRepository, CommandOutput, Context, Conversation, ConversationId, + ConversationRepository, Environment, FileInfo, FuzzySearchRepository, McpServerConfig, + MigrationResult, Model, ModelId, Provider, ProviderId, ProviderRepository, ResultStream, + SearchMatch, Skill, SkillRepository, Snapshot, SnapshotRepository, }; // Re-export CacacheStorage from forge_infra pub use forge_infra::CacacheStorage; @@ -199,8 +199,8 @@ impl AppConfigRepository for ForgeRepo { self.config_repository.get_app_config().await } - async fn set_app_config(&self, config: &AppConfig) -> anyhow::Result<()> { - self.config_repository.set_app_config(config).await + async fn update_app_config(&self, ops: Vec) -> anyhow::Result<()> { + self.config_repository.update_app_config(ops).await } } diff --git a/crates/forge_services/src/app_config.rs b/crates/forge_services/src/app_config.rs index 73bd2701cb..6359037f37 100644 --- a/crates/forge_services/src/app_config.rs +++ b/crates/forge_services/src/app_config.rs @@ -1,7 +1,9 @@ use std::sync::Arc; use forge_app::AppConfigService; -use forge_domain::{AppConfig, AppConfigRepository, ModelId, ProviderId, ProviderRepository}; +use forge_domain::{ + AppConfigOperation, AppConfigRepository, ModelId, ProviderId, ProviderRepository, +}; use tracing::debug; /// Service for managing user preferences for default providers and models. @@ -17,16 +19,10 @@ impl ForgeAppConfigService { } impl ForgeAppConfigService { - /// Helper method to update app configuration atomically. - async fn update(&self, updater: U) -> anyhow::Result<()> - where - U: FnOnce(&mut AppConfig), - { - let mut config = self.infra.get_app_config().await?; - updater(&mut config); - debug!(config = ?config, "Updating app config"); - self.infra.set_app_config(&config).await?; - Ok(()) + /// Helper method to apply a config operation atomically. + async fn update(&self, op: AppConfigOperation) -> anyhow::Result<()> { + debug!(op = ?op, "Updating app config"); + self.infra.update_app_config(vec![op]).await } } @@ -42,10 +38,8 @@ impl AppConfigService } async fn set_default_provider(&self, provider_id: ProviderId) -> anyhow::Result<()> { - self.update(|config| { - config.provider = Some(provider_id); - }) - .await + self.update(AppConfigOperation::SetProvider(provider_id)) + .await } async fn get_provider_model( @@ -77,10 +71,8 @@ impl AppConfigService .provider .ok_or(forge_domain::Error::NoDefaultProvider)?; - self.update(|config| { - config.model.insert(provider_id, model.clone()); - }) - .await + self.update(AppConfigOperation::SetModel(provider_id, model)) + .await } async fn get_commit_config(&self) -> anyhow::Result> { @@ -92,10 +84,8 @@ impl AppConfigService &self, commit_config: forge_domain::CommitConfig, ) -> anyhow::Result<()> { - self.update(|config| { - config.commit = Some(commit_config); - }) - .await + self.update(AppConfigOperation::SetCommitConfig(commit_config)) + .await } async fn get_suggest_config(&self) -> anyhow::Result> { @@ -107,10 +97,8 @@ impl AppConfigService &self, suggest_config: forge_domain::SuggestConfig, ) -> anyhow::Result<()> { - self.update(|config| { - config.suggest = Some(suggest_config); - }) - .await + self.update(AppConfigOperation::SetSuggestConfig(suggest_config)) + .await } } @@ -120,8 +108,8 @@ mod tests { use std::sync::Mutex; use forge_domain::{ - AnyProvider, AppConfig, ChatRepository, InputModality, MigrationResult, Model, ModelSource, - Provider, ProviderId, ProviderResponse, ProviderTemplate, + AnyProvider, AppConfig, AppConfigOperation, ChatRepository, InputModality, MigrationResult, + Model, ModelSource, Provider, ProviderId, ProviderResponse, ProviderTemplate, }; use pretty_assertions::assert_eq; use url::Url; @@ -202,8 +190,11 @@ mod tests { Ok(self.app_config.lock().unwrap().clone()) } - async fn set_app_config(&self, config: &AppConfig) -> anyhow::Result<()> { - *self.app_config.lock().unwrap() = config.clone(); + async fn update_app_config(&self, ops: Vec) -> anyhow::Result<()> { + let mut config = self.app_config.lock().unwrap(); + for op in ops { + op.apply(&mut config); + } Ok(()) } } diff --git a/crates/forge_services/src/auth.rs b/crates/forge_services/src/auth.rs index 93a775ac62..6d0dee37bf 100644 --- a/crates/forge_services/src/auth.rs +++ b/crates/forge_services/src/auth.rs @@ -3,7 +3,7 @@ use std::sync::Arc; use anyhow::bail; use bytes::Bytes; use forge_app::{AuthService, EnvironmentInfra, Error, HttpInfra, User, UserUsage}; -use forge_domain::{AppConfigRepository, InitAuth, LoginInfo}; +use forge_domain::{AppConfigOperation, AppConfigRepository, InitAuth, LoginInfo}; use reqwest::Url; use reqwest::header::{AUTHORIZATION, HeaderMap, HeaderValue}; @@ -102,10 +102,9 @@ impl ForgeAuthService } async fn set_auth_token(&self, login: Option) -> anyhow::Result<()> { - let mut config = self.infra.get_app_config().await?; - config.key_info = login; - self.infra.set_app_config(&config).await?; - Ok(()) + self.infra + .update_app_config(vec![AppConfigOperation::KeyInfo(login)]) + .await } } From 7b8da8079b53ba75d9c33bb90c5caf8c30865ab4 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Wed, 25 Mar 2026 12:46:49 +0000 Subject: [PATCH 54/67] [autofix.ci] apply automated fixes --- crates/forge_repo/src/app_config.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/crates/forge_repo/src/app_config.rs b/crates/forge_repo/src/app_config.rs index e7dde32c07..5d6da04b74 100644 --- a/crates/forge_repo/src/app_config.rs +++ b/crates/forge_repo/src/app_config.rs @@ -619,7 +619,9 @@ mod tests { provider_id = "xyz" model_id = "some-model" "#; - let fc = forge_config::ConfigReader::default().read_str(toml).unwrap(); + let fc = forge_config::ConfigReader::default() + .read_str(toml) + .unwrap(); let actual = forge_config_to_app_config(fc); From 8c23d3b19ff52e757a00b2bf1e0a4c910edc4bcc Mon Sep 17 00:00:00 2001 From: Tushar Date: Wed, 25 Mar 2026 18:16:45 +0530 Subject: [PATCH 55/67] feat(forge_config): add read_global and read_path helpers --- crates/forge_config/src/config.rs | 15 +++++++++++++++ crates/forge_config/src/reader.rs | 17 +++++++++++++++++ 2 files changed, 32 insertions(+) diff --git a/crates/forge_config/src/config.rs b/crates/forge_config/src/config.rs index 88e7741345..d233cde0f9 100644 --- a/crates/forge_config/src/config.rs +++ b/crates/forge_config/src/config.rs @@ -214,6 +214,21 @@ impl ForgeConfig { ConfigReader::default().read(Some(&path)).await } + /// Reads and merges configuration from the global config file path, + /// returning the resolved [`ForgeConfig`]. + /// + /// Delegates to [`ConfigReader::read_path`] using the path returned by + /// [`ForgeConfig::config_path`]. + /// + /// # Errors + /// + /// Returns an error if the config path cannot be resolved, the file cannot + /// be read, or the configuration cannot be deserialized. + pub async fn read_global() -> crate::Result { + let path = Self::config_path()?; + ConfigReader::default().read_path(&path).await + } + /// Writes the configuration to the user config file. /// /// # Errors diff --git a/crates/forge_config/src/reader.rs b/crates/forge_config/src/reader.rs index 040a9f7347..01bc39df52 100644 --- a/crates/forge_config/src/reader.rs +++ b/crates/forge_config/src/reader.rs @@ -140,6 +140,23 @@ impl ConfigReader { Ok(config.try_deserialize()?) } + /// Reads and merges configuration from the embedded defaults, the legacy + /// JSON config, and the file at the given `path`, returning the resolved + /// [`ForgeConfig`]. + /// + /// Unlike [`read`], this method requires an explicit path and always + /// attempts to read from it (returning an error if the path does not + /// exist or cannot be read). Environment variables prefixed with `FORGE_` + /// are not consulted. + /// + /// # Errors + /// + /// Returns an error if the file at `path` cannot be read or if the + /// configuration cannot be deserialized. + pub async fn read_path(&self, path: &Path) -> crate::Result { + self.read(Some(path)).await + } + /// Returns the [`ForgeConfig`] built from the embedded defaults only, /// without reading any file or environment variables. pub fn read_defaults(&self) -> ForgeConfig { From 848c09a53e6c84954b6bf34b26890f10bb91df8d Mon Sep 17 00:00:00 2001 From: Tushar Date: Wed, 25 Mar 2026 18:21:07 +0530 Subject: [PATCH 56/67] refactor(forge_repo): apply app config ops directly --- crates/forge_domain/src/app_config.rs | 15 -- crates/forge_repo/src/app_config.rs | 300 +++++------------------- crates/forge_services/src/app_config.rs | 12 +- 3 files changed, 73 insertions(+), 254 deletions(-) diff --git a/crates/forge_domain/src/app_config.rs b/crates/forge_domain/src/app_config.rs index 4c43fc864d..886df2730c 100644 --- a/crates/forge_domain/src/app_config.rs +++ b/crates/forge_domain/src/app_config.rs @@ -54,18 +54,3 @@ pub enum AppConfigOperation { /// Set the shell-command suggestion configuration. SetSuggestConfig(SuggestConfig), } - -impl AppConfigOperation { - /// Applies this operation to `config` in-place. - pub fn apply(self, config: &mut AppConfig) { - match self { - AppConfigOperation::KeyInfo(info) => config.key_info = info, - AppConfigOperation::SetProvider(provider_id) => config.provider = Some(provider_id), - AppConfigOperation::SetModel(provider_id, model_id) => { - config.model.insert(provider_id, model_id); - } - AppConfigOperation::SetCommitConfig(commit) => config.commit = Some(commit), - AppConfigOperation::SetSuggestConfig(suggest) => config.suggest = Some(suggest), - } - } -} diff --git a/crates/forge_repo/src/app_config.rs b/crates/forge_repo/src/app_config.rs index 5d6da04b74..5450a59a64 100644 --- a/crates/forge_repo/src/app_config.rs +++ b/crates/forge_repo/src/app_config.rs @@ -53,20 +53,19 @@ fn forge_config_to_app_config(fc: ForgeConfig) -> AppConfig { AppConfig { key_info, provider, model, commit, suggest } } -/// Overlays the [`AppConfig`] fields onto an existing [`ForgeConfig`], -/// preserving all other fields (retry, http, limits, etc.). -fn app_config_to_forge_config(app: &AppConfig, mut fc: ForgeConfig) -> ForgeConfig { - // Login info — flattened into top-level fields - match &app.key_info { - Some(info) => { - fc.api_key = Some(info.api_key.clone()); - fc.api_key_name = Some(info.api_key_name.clone()); - fc.api_key_masked = Some(info.api_key_masked.clone()); - fc.email = info.email.clone(); - fc.name = info.name.clone(); - fc.auth_provider_id = info.auth_provider_id.clone(); +/// Applies a single [`AppConfigOperation`] directly onto a [`ForgeConfig`] +/// in-place, bypassing the intermediate [`AppConfig`] representation. +fn apply_op(op: AppConfigOperation, fc: &mut ForgeConfig) { + match op { + AppConfigOperation::KeyInfo(Some(info)) => { + fc.api_key = Some(info.api_key); + fc.api_key_name = Some(info.api_key_name); + fc.api_key_masked = Some(info.api_key_masked); + fc.email = info.email; + fc.name = info.name; + fc.auth_provider_id = info.auth_provider_id; } - None => { + AppConfigOperation::KeyInfo(None) => { fc.api_key = None; fc.api_key_name = None; fc.api_key_masked = None; @@ -74,36 +73,38 @@ fn app_config_to_forge_config(app: &AppConfig, mut fc: ForgeConfig) -> ForgeConf fc.name = None; fc.auth_provider_id = None; } - } - - // Active model — use the provider's entry from the model map - fc.session = app.provider.as_ref().map(|pid| { - let mut config = ModelConfig::default().provider_id(pid.as_ref().to_string()); - if let Some(mid) = app.model.get(pid) { - config = config.model_id(mid.to_string()); + AppConfigOperation::SetProvider(provider_id) => { + let pid = provider_id.as_ref().to_string(); + fc.session = Some(match fc.session.take() { + Some(mc) => mc.provider_id(pid), + None => ModelConfig::default().provider_id(pid), + }); } - - config - }); - - fc.commit = app.commit.as_ref().and_then(|cc| { - cc.provider - .as_ref() - .zip(cc.model.as_ref()) - .map(|(pid, mid)| { + AppConfigOperation::SetModel(provider_id, model_id) => { + let pid = provider_id.as_ref().to_string(); + let mid = model_id.to_string(); + fc.session = Some(match fc.session.take() { + Some(mc) if mc.provider_id.as_deref() == Some(&pid) => mc.model_id(mid), + _ => ModelConfig::default().provider_id(pid).model_id(mid), + }); + } + AppConfigOperation::SetCommitConfig(commit) => { + fc.commit = commit.provider.as_ref().zip(commit.model.as_ref()).map( + |(pid, mid)| { + ModelConfig::default() + .provider_id(pid.as_ref().to_string()) + .model_id(mid.to_string()) + }, + ); + } + AppConfigOperation::SetSuggestConfig(suggest) => { + fc.suggest = Some( ModelConfig::default() - .provider_id(pid.as_ref().to_string()) - .model_id(mid.to_string()) - }) - }); - - fc.suggest = app.suggest.as_ref().map(|sc| { - ModelConfig::default() - .provider_id(sc.provider.as_ref().to_string()) - .model_id(sc.model.to_string()) - }); - - fc + .provider_id(suggest.provider.as_ref().to_string()) + .model_id(suggest.model.to_string()), + ); + } + } } /// Repository for managing application configuration with caching support. @@ -135,21 +136,6 @@ impl ForgeConfigRepository { } } } - - /// Writes [`AppConfig`] to disk via [`ForgeConfig::write`], preserving all - /// non-`AppConfig` fields from the existing file. - async fn write(&self, config: &AppConfig) -> anyhow::Result { - debug!(config = ?config, "writing app-config"); - let existing = ForgeConfig::read().await.unwrap_or_else(|e| { - tracing::warn!(error = %e, "Could not read existing config; defaults will be used."); - forge_config::ConfigReader::default().read_defaults() - }); - let config = app_config_to_forge_config(config, existing); - - config.write().await?; - debug!(config = ?config, "written .forge.toml"); - Ok(config) - } } #[async_trait::async_trait] @@ -172,15 +158,30 @@ impl AppConfigRepository for ForgeConfigRepository { } async fn update_app_config(&self, ops: Vec) -> anyhow::Result<()> { - let mut config = self.get_app_config().await?; + // Load the current ForgeConfig (from cache or disk) + let mut fc = { + let cache = self.cache.lock().await; + match cache.as_ref() { + Some(cached) => cached.clone(), + None => { + drop(cache); + self.read().await + } + } + }; + + // Apply each operation directly onto ForgeConfig for op in ops { - op.apply(&mut config); + apply_op(op, &mut fc); } - let written = self.write(&config).await?; - // Bust the cache after successful write + // Persist + fc.write().await?; + debug!(config = ?fc, "written .forge.toml"); + + // Update cache let mut cache = self.cache.lock().await; - *cache = Some(written); + *cache = Some(fc); Ok(()) } @@ -384,183 +385,6 @@ mod tests { assert!(actual.model.is_empty()); } - // ------------------------------------------------------------------------- - // app_config_to_forge_config - // ------------------------------------------------------------------------- - - #[test] - fn test_app_config_to_forge_config_empty() { - let app = AppConfig::default(); - let base = forge_config_defaults(); - - let actual = app_config_to_forge_config(&app, base.clone()); - - // All AppConfig-owned fields must be cleared; unrelated fields preserved. - assert_eq!(actual.api_key, None); - assert_eq!(actual.session, None); - assert_eq!(actual.commit, None); - assert_eq!(actual.suggest, None); - // Non-AppConfig field is unchanged - assert_eq!(actual.retry, base.retry); - } - - #[test] - fn test_app_config_to_forge_config_with_model() { - let app = AppConfig { - provider: Some(ProviderId::ANTHROPIC), - model: HashMap::from([( - ProviderId::ANTHROPIC, - ModelId::new("claude-3-5-sonnet-20241022"), - )]), - ..Default::default() - }; - let base = forge_config_defaults(); - - let actual = app_config_to_forge_config(&app, base); - - let expected_model = ModelConfig::default() - .provider_id("anthropic".to_string()) - .model_id("claude-3-5-sonnet-20241022".to_string()); - assert_eq!(actual.session, Some(expected_model)); - } - - #[test] - fn test_app_config_to_forge_config_model_uses_active_provider() { - // Two providers in the map; only the active provider's model is written. - let app = AppConfig { - provider: Some(ProviderId::OPENAI), - model: HashMap::from([ - (ProviderId::OPENAI, ModelId::new("gpt-4o")), - ( - ProviderId::ANTHROPIC, - ModelId::new("claude-3-5-sonnet-20241022"), - ), - ]), - ..Default::default() - }; - let base = forge_config_defaults(); - - let actual = app_config_to_forge_config(&app, base); - - let expected = ModelConfig::default() - .provider_id("openai".to_string()) - .model_id("gpt-4o".to_string()); - assert_eq!(actual.session, Some(expected)); - } - - #[test] - fn test_app_config_to_forge_config_no_model_when_provider_missing() { - // Model map has an entry but no active provider → model field is None. - let app = AppConfig { - provider: None, - model: HashMap::from([(ProviderId::OPENAI, ModelId::new("gpt-4o"))]), - ..Default::default() - }; - let base = forge_config_defaults(); - - let actual = app_config_to_forge_config(&app, base); - - assert_eq!(actual.session, None); - } - - #[test] - fn test_app_config_to_forge_config_provider_only_no_model_in_map() { - // Provider is set but model map has no entry for it → session has provider_id - // but no model_id. - let app = AppConfig { - provider: Some(ProviderId::ANTHROPIC), - model: HashMap::new(), - ..Default::default() - }; - let base = forge_config_defaults(); - - let actual = app_config_to_forge_config(&app, base); - - let expected = ModelConfig::default().provider_id("anthropic".to_string()); - assert_eq!(actual.session, Some(expected)); - } - - #[test] - fn test_app_config_to_forge_config_with_login_info() { - let app = AppConfig { - key_info: Some(LoginInfo { - api_key: "sk-test-key".to_string(), - api_key_name: "my-key".to_string(), - api_key_masked: "sk-***".to_string(), - email: Some("user@example.com".to_string()), - name: Some("Alice".to_string()), - auth_provider_id: Some("github".to_string()), - }), - ..Default::default() - }; - let base = forge_config_defaults(); - - let actual = app_config_to_forge_config(&app, base); - - assert_eq!(actual.api_key, Some("sk-test-key".to_string())); - assert_eq!(actual.api_key_name, Some("my-key".to_string())); - assert_eq!(actual.api_key_masked, Some("sk-***".to_string())); - assert_eq!(actual.email, Some("user@example.com".to_string())); - assert_eq!(actual.name, Some("Alice".to_string())); - assert_eq!(actual.auth_provider_id, Some("github".to_string())); - } - - #[test] - fn test_app_config_to_forge_config_clears_login_info_when_absent() { - // Start with a base that has login fields set, then overlay an AppConfig - // with no key_info — all login fields must be cleared. - let mut base = forge_config_defaults(); - base.api_key = Some("old-key".to_string()); - base.email = Some("old@example.com".to_string()); - - let app = AppConfig::default(); // key_info is None - - let actual = app_config_to_forge_config(&app, base); - - assert_eq!(actual.api_key, None); - assert_eq!(actual.email, None); - } - - #[test] - fn test_app_config_to_forge_config_preserves_unrelated_fields() { - // Non-AppConfig fields (retry, http, limits, …) must survive a round-trip. - let app = AppConfig::default(); - let base = forge_config_defaults(); - let expected_retry = base.retry.clone(); - let expected_max_search = base.max_search_lines; - - let actual = app_config_to_forge_config(&app, base); - - assert_eq!(actual.retry, expected_retry); - assert_eq!(actual.max_search_lines, expected_max_search); - } - - #[test] - fn test_round_trip_forge_config_to_app_config_and_back() { - let mut original = forge_config_defaults(); - original.api_key = Some("sk-test".to_string()); - original.api_key_name = Some("test-key".to_string()); - original.api_key_masked = Some("sk-***".to_string()); - original.session = Some( - ModelConfig::default() - .provider_id("anthropic".to_string()) - .model_id("claude-3-5-sonnet-20241022".to_string()), - ); - original.commit = Some( - ModelConfig::default() - .provider_id("openai".to_string()) - .model_id("gpt-4o".to_string()), - ); - - let app = forge_config_to_app_config(original.clone()); - let actual = app_config_to_forge_config(&app, original.clone()); - - assert_eq!(actual.api_key, original.api_key); - assert_eq!(actual.api_key_name, original.api_key_name); - assert_eq!(actual.session, original.session); - assert_eq!(actual.commit, original.commit); - } - #[tokio::test] async fn test_get_app_config_not_exists() { let _home = temp_home(); diff --git a/crates/forge_services/src/app_config.rs b/crates/forge_services/src/app_config.rs index 6359037f37..1f7d42eb78 100644 --- a/crates/forge_services/src/app_config.rs +++ b/crates/forge_services/src/app_config.rs @@ -193,7 +193,17 @@ mod tests { async fn update_app_config(&self, ops: Vec) -> anyhow::Result<()> { let mut config = self.app_config.lock().unwrap(); for op in ops { - op.apply(&mut config); + match op { + AppConfigOperation::KeyInfo(info) => config.key_info = info, + AppConfigOperation::SetProvider(pid) => config.provider = Some(pid), + AppConfigOperation::SetModel(pid, mid) => { + config.model.insert(pid, mid); + } + AppConfigOperation::SetCommitConfig(commit) => config.commit = Some(commit), + AppConfigOperation::SetSuggestConfig(suggest) => { + config.suggest = Some(suggest) + } + } } Ok(()) } From 72d8e15d900790434593848d293e2a0ebf067106 Mon Sep 17 00:00:00 2001 From: Tushar Date: Wed, 25 Mar 2026 18:22:48 +0530 Subject: [PATCH 57/67] refactor(forge_repo): load global config and reset cache on update --- crates/forge_repo/src/app_config.rs | 27 ++++++++++----------------- 1 file changed, 10 insertions(+), 17 deletions(-) diff --git a/crates/forge_repo/src/app_config.rs b/crates/forge_repo/src/app_config.rs index 5450a59a64..4d8d977980 100644 --- a/crates/forge_repo/src/app_config.rs +++ b/crates/forge_repo/src/app_config.rs @@ -89,13 +89,15 @@ fn apply_op(op: AppConfigOperation, fc: &mut ForgeConfig) { }); } AppConfigOperation::SetCommitConfig(commit) => { - fc.commit = commit.provider.as_ref().zip(commit.model.as_ref()).map( - |(pid, mid)| { + fc.commit = commit + .provider + .as_ref() + .zip(commit.model.as_ref()) + .map(|(pid, mid)| { ModelConfig::default() .provider_id(pid.as_ref().to_string()) .model_id(mid.to_string()) - }, - ); + }); } AppConfigOperation::SetSuggestConfig(suggest) => { fc.suggest = Some( @@ -158,17 +160,8 @@ impl AppConfigRepository for ForgeConfigRepository { } async fn update_app_config(&self, ops: Vec) -> anyhow::Result<()> { - // Load the current ForgeConfig (from cache or disk) - let mut fc = { - let cache = self.cache.lock().await; - match cache.as_ref() { - Some(cached) => cached.clone(), - None => { - drop(cache); - self.read().await - } - } - }; + // Load the global config + let mut fc = ForgeConfig::read_global().await?; // Apply each operation directly onto ForgeConfig for op in ops { @@ -179,9 +172,9 @@ impl AppConfigRepository for ForgeConfigRepository { fc.write().await?; debug!(config = ?fc, "written .forge.toml"); - // Update cache + // Reset cache let mut cache = self.cache.lock().await; - *cache = Some(fc); + *cache = None; Ok(()) } From 35301711dc21aeb4cdb59f2f06470893a6de1eef Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Wed, 25 Mar 2026 12:55:09 +0000 Subject: [PATCH 58/67] [autofix.ci] apply automated fixes --- crates/forge_services/src/app_config.rs | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/crates/forge_services/src/app_config.rs b/crates/forge_services/src/app_config.rs index 1f7d42eb78..d4737c27e6 100644 --- a/crates/forge_services/src/app_config.rs +++ b/crates/forge_services/src/app_config.rs @@ -200,9 +200,7 @@ mod tests { config.model.insert(pid, mid); } AppConfigOperation::SetCommitConfig(commit) => config.commit = Some(commit), - AppConfigOperation::SetSuggestConfig(suggest) => { - config.suggest = Some(suggest) - } + AppConfigOperation::SetSuggestConfig(suggest) => config.suggest = Some(suggest), } } Ok(()) From 87b7a32dbbea29c47fa35b0ec1c99e5c05a1ef63 Mon Sep 17 00:00:00 2001 From: Tushar Date: Wed, 25 Mar 2026 18:36:39 +0530 Subject: [PATCH 59/67] refactor(forge_config): expose config read helpers on forgeconfig --- crates/forge_config/src/config.rs | 8 ++++++++ crates/forge_config/src/lib.rs | 2 -- crates/forge_config/src/reader.rs | 15 ++++++++++++--- crates/forge_config/src/writer.rs | 9 --------- 4 files changed, 20 insertions(+), 14 deletions(-) diff --git a/crates/forge_config/src/config.rs b/crates/forge_config/src/config.rs index d233cde0f9..869c9b2aed 100644 --- a/crates/forge_config/src/config.rs +++ b/crates/forge_config/src/config.rs @@ -239,4 +239,12 @@ impl ForgeConfig { let path = Self::config_path()?; ConfigWriter::new(self.clone()).write(&path).await } + + pub fn read_defaults() -> ForgeConfig { + ConfigReader::default().read_defaults() + } + + pub fn read_str(toml: &str) -> crate::Result { + ConfigReader::default().read_str(toml) + } } diff --git a/crates/forge_config/src/lib.rs b/crates/forge_config/src/lib.rs index 24f0d13fd4..13e201f3fe 100644 --- a/crates/forge_config/src/lib.rs +++ b/crates/forge_config/src/lib.rs @@ -14,9 +14,7 @@ pub use config::*; pub use error::Error; pub use http::*; pub use model::*; -pub use reader::ConfigReader; pub use retry::*; -pub use writer::ConfigWriter; /// A `Result` type alias for this crate's [`Error`] type. pub type Result = std::result::Result; diff --git a/crates/forge_config/src/reader.rs b/crates/forge_config/src/reader.rs index 01bc39df52..778c8d105f 100644 --- a/crates/forge_config/src/reader.rs +++ b/crates/forge_config/src/reader.rs @@ -77,11 +77,13 @@ impl ConfigReader { /// optional file at `path` (skipped when `None`), then environment /// variables prefixed with `FORGE_`. pub async fn read(&self, path: Option<&Path>) -> crate::Result { - let defaults = include_str!("../.forge.toml"); let mut builder = Config::builder(); // Load default - builder = builder.add_source(config::File::from_str(defaults, config::FileFormat::Toml)); + builder = builder.add_source(config::File::from_str( + include_str!("../.forge.toml"), + config::FileFormat::Toml, + )); // Load from ~/forge/.config.json (legacy format) if let Some(path) = Self::legacy_config_path() { @@ -154,7 +156,14 @@ impl ConfigReader { /// Returns an error if the file at `path` cannot be read or if the /// configuration cannot be deserialized. pub async fn read_path(&self, path: &Path) -> crate::Result { - self.read(Some(path)).await + let mut builder = Config::builder(); + if tokio::fs::try_exists(path).await? { + let contents = tokio::fs::read_to_string(path).await?; + builder = + builder.add_source(config::File::from_str(&contents, config::FileFormat::Toml)); + } + + Ok(builder.build()?.try_deserialize()?) } /// Returns the [`ForgeConfig`] built from the embedded defaults only, diff --git a/crates/forge_config/src/writer.rs b/crates/forge_config/src/writer.rs index b377ac9dde..b8ddf61092 100644 --- a/crates/forge_config/src/writer.rs +++ b/crates/forge_config/src/writer.rs @@ -13,15 +13,6 @@ impl ConfigWriter { Self { config } } - /// Serializes the configuration to a TOML string. - /// - /// # Errors - /// - /// Returns an error if the configuration cannot be serialized. - pub fn to_string(&self) -> crate::Result { - Ok(toml_edit::ser::to_string_pretty(&self.config)?) - } - /// Serializes and writes the configuration to `path`, creating all parent /// directories recursively if they do not already exist. /// From 2669f19a63b17afdf1eb295f33920df3a96767b8 Mon Sep 17 00:00:00 2001 From: Tushar Date: Wed, 25 Mar 2026 20:00:57 +0530 Subject: [PATCH 60/67] refactor(forge_config): streamline config reader builder --- crates/forge_config/src/config.rs | 47 +---- crates/forge_config/src/lib.rs | 2 + crates/forge_config/src/reader.rs | 170 ++++++++--------- crates/forge_repo/src/app_config.rs | 275 +--------------------------- 4 files changed, 91 insertions(+), 403 deletions(-) diff --git a/crates/forge_config/src/config.rs b/crates/forge_config/src/config.rs index 869c9b2aed..d2c74a4bd4 100644 --- a/crates/forge_config/src/config.rs +++ b/crates/forge_config/src/config.rs @@ -190,18 +190,6 @@ pub struct ForgeConfig { } impl ForgeConfig { - /// Returns the path to the user configuration file: `~/.forge/.forge.toml`. - /// - /// # Errors - /// - /// Returns an error if the home directory cannot be determined. - pub fn config_path() -> crate::Result { - let home_dir = dirs::home_dir().ok_or_else(|| { - std::io::Error::new(std::io::ErrorKind::NotFound, "home directory not found") - })?; - Ok(home_dir.join("forge").join(".forge.toml")) - } - /// Reads and merges configuration from all sources, returning the resolved /// [`ForgeConfig`]. /// @@ -209,24 +197,13 @@ impl ForgeConfig { /// /// Returns an error if the config path cannot be resolved, the file cannot /// be read, or the configuration cannot be deserialized. - pub async fn read() -> crate::Result { - let path = Self::config_path()?; - ConfigReader::default().read(Some(&path)).await - } - - /// Reads and merges configuration from the global config file path, - /// returning the resolved [`ForgeConfig`]. - /// - /// Delegates to [`ConfigReader::read_path`] using the path returned by - /// [`ForgeConfig::config_path`]. - /// - /// # Errors - /// - /// Returns an error if the config path cannot be resolved, the file cannot - /// be read, or the configuration cannot be deserialized. - pub async fn read_global() -> crate::Result { - let path = Self::config_path()?; - ConfigReader::default().read_path(&path).await + pub fn read() -> crate::Result { + Ok(ConfigReader::default() + .read_defaults() + .read_legacy() + .read_global() + .read_env() + .build()?) } /// Writes the configuration to the user config file. @@ -236,15 +213,7 @@ impl ForgeConfig { /// Returns an error if the configuration cannot be serialized or written to /// disk. pub async fn write(&self) -> crate::Result<()> { - let path = Self::config_path()?; + let path = ConfigReader::config_path(); ConfigWriter::new(self.clone()).write(&path).await } - - pub fn read_defaults() -> ForgeConfig { - ConfigReader::default().read_defaults() - } - - pub fn read_str(toml: &str) -> crate::Result { - ConfigReader::default().read_str(toml) - } } diff --git a/crates/forge_config/src/lib.rs b/crates/forge_config/src/lib.rs index 13e201f3fe..a59ccf556c 100644 --- a/crates/forge_config/src/lib.rs +++ b/crates/forge_config/src/lib.rs @@ -14,7 +14,9 @@ pub use config::*; pub use error::Error; pub use http::*; pub use model::*; +pub use reader::*; pub use retry::*; +pub use writer::*; /// A `Result` type alias for this crate's [`Error`] type. pub type Result = std::result::Result; diff --git a/crates/forge_config/src/reader.rs b/crates/forge_config/src/reader.rs index 778c8d105f..40b97ec0e2 100644 --- a/crates/forge_config/src/reader.rs +++ b/crates/forge_config/src/reader.rs @@ -1,7 +1,8 @@ use std::collections::HashMap; -use std::path::{Path, PathBuf}; +use std::path::PathBuf; -use config::Config; +use config::ConfigBuilder; +use config::builder::DefaultState; use serde::Deserialize; use tracing::debug; @@ -11,7 +12,9 @@ use crate::{ForgeConfig, ModelConfig}; /// home directory file, current working directory file, and environment /// variables. #[derive(Default)] -pub struct ConfigReader {} +pub struct ConfigReader { + builder: ConfigBuilder, +} /// Intermediate representation of the legacy `~/forge/.config.json` format. /// @@ -64,55 +67,44 @@ impl LegacyConfig { } impl ConfigReader { - /// Returns the path to the legacy JSON config file: `~/forge/.config.json`. - fn legacy_config_path() -> Option { - dirs::home_dir().map(|home| home.join("forge").join(".config.json")) + pub fn config_legacy_path() -> PathBuf { + Self::base_path().join(".config.json") + } + + pub fn config_path() -> PathBuf { + Self::base_path().join(".forge.toml") + } + + pub fn base_path() -> PathBuf { + dirs::home_dir().unwrap_or(PathBuf::from(".")).join("forge") } - /// Reads and merges configuration from all sources, returning the resolved - /// [`ForgeConfig`]. + /// Reads and merges configuration from the embedded defaults and the given + /// TOML string, returning the resolved [`ForgeConfig`]. /// - /// Sources are applied in increasing priority order: embedded defaults, - /// `~/forge/.config.json` (legacy JSON config, skipped when absent), the - /// optional file at `path` (skipped when `None`), then environment - /// variables prefixed with `FORGE_`. - pub async fn read(&self, path: Option<&Path>) -> crate::Result { - let mut builder = Config::builder(); - - // Load default - builder = builder.add_source(config::File::from_str( - include_str!("../.forge.toml"), - config::FileFormat::Toml, - )); - - // Load from ~/forge/.config.json (legacy format) - if let Some(path) = Self::legacy_config_path() { - if tokio::fs::try_exists(&path).await? { - let json_contents = tokio::fs::read_to_string(&path).await?; - if let Ok(json_config) = serde_json::from_str::(&json_contents) { - let config = json_config.into_forge_config(); - let toml_contents = toml_edit::ser::to_string(&config).unwrap_or_default(); - builder = builder.add_source(config::File::from_str( - &toml_contents, - config::FileFormat::Toml, - )); - } - } else { - debug!("Legacy config file not found at {:?}, skipping", path); - } - } + /// Unlike [`read`], this method accepts already-loaded TOML content and + /// does not touch the filesystem or environment variables. This is + /// appropriate when the caller has already read the raw file content via + /// its own I/O abstraction. + pub fn read_toml(mut self, contents: &str) -> Self { + self.builder = self + .builder + .add_source(config::File::from_str(contents, config::FileFormat::Toml)); - // Load from path - if let Some(path) = path - && tokio::fs::try_exists(path).await? - { - let contents = tokio::fs::read_to_string(path).await?; - builder = - builder.add_source(config::File::from_str(&contents, config::FileFormat::Toml)); - } + self + } + + /// Returns the [`ForgeConfig`] built from the embedded defaults only, + /// without reading any file or environment variables. + pub fn read_defaults(self) -> Self { + let defaults = include_str!("../.forge.toml"); - // Load from environment - builder = builder.add_source( + self.read_toml(defaults) + } + + /// Adds environment variables prefixed with `FORGE_` as a source. + pub fn read_env(mut self) -> Self { + self.builder = self.builder.add_source( config::Environment::with_prefix("FORGE") .prefix_separator("_") .separator("__") @@ -122,59 +114,43 @@ impl ConfigReader { .with_list_parse_key("http.root_cert_paths"), ); - let config = builder.build()?; - Ok(config.try_deserialize()?) + self } - /// Reads and merges configuration from the embedded defaults and the given - /// TOML string, returning the resolved [`ForgeConfig`]. + /// Builds and returns the merged [`ForgeConfig`] from all accumulated sources. /// - /// Unlike [`read`], this method accepts already-loaded TOML content and - /// does not touch the filesystem or environment variables. This is - /// appropriate when the caller has already read the raw file content via - /// its own I/O abstraction. - pub fn read_str(&self, contents: &str) -> crate::Result { - let defaults = include_str!("../.forge.toml"); - let config = Config::builder() - .add_source(config::File::from_str(defaults, config::FileFormat::Toml)) - .add_source(config::File::from_str(contents, config::FileFormat::Toml)) - .build()?; - Ok(config.try_deserialize()?) + /// # Errors + /// + /// Returns an error if the configuration cannot be built or deserialized. + pub fn build(self) -> crate::Result { + let config = self.builder.build()?; + Ok(config.try_deserialize::()?) } - /// Reads and merges configuration from the embedded defaults, the legacy - /// JSON config, and the file at the given `path`, returning the resolved - /// [`ForgeConfig`]. + /// Reads `~/.forge/.forge.toml` and adds it as a config source. /// - /// Unlike [`read`], this method requires an explicit path and always - /// attempts to read from it (returning an error if the path does not - /// exist or cannot be read). Environment variables prefixed with `FORGE_` - /// are not consulted. + /// If the file does not exist it is silently skipped. If the file cannot + /// be read or parsed the error is propagated. /// /// # Errors /// - /// Returns an error if the file at `path` cannot be read or if the - /// configuration cannot be deserialized. - pub async fn read_path(&self, path: &Path) -> crate::Result { - let mut builder = Config::builder(); - if tokio::fs::try_exists(path).await? { - let contents = tokio::fs::read_to_string(path).await?; - builder = - builder.add_source(config::File::from_str(&contents, config::FileFormat::Toml)); - } + /// Returns an error if the file exists but cannot be read or deserialized. - Ok(builder.build()?.try_deserialize()?) + pub fn read_global(mut self) -> Self { + let path = Self::config_path(); + self.builder = self.builder.add_source(config::File::from(path)); + self } - /// Returns the [`ForgeConfig`] built from the embedded defaults only, - /// without reading any file or environment variables. - pub fn read_defaults(&self) -> ForgeConfig { - let defaults = include_str!("../.forge.toml"); - Config::builder() - .add_source(config::File::from_str(defaults, config::FileFormat::Toml)) - .build() - .and_then(|c| c.try_deserialize()) - .expect("embedded .forge.toml defaults must always be valid") + /// Reads `~/.forge/.config.json` (the legacy JSON format), converts it to + /// a [`ForgeConfig`], and adds it as a config source. + /// + /// If the file does not exist or cannot be parsed it is silently skipped. + pub fn read_legacy(mut self) -> Self { + let path = Self::config_legacy_path(); + self.builder = self.builder.add_source(config::File::from(path)); + + self } } @@ -220,20 +196,24 @@ mod tests { } } - #[tokio::test] - async fn test_read_parses_without_error() { - let actual = ConfigReader::default().read(None).await; + #[test] + fn test_read_parses_without_error() { + let actual = ConfigReader::default().read_defaults().build(); assert!(actual.is_ok(), "read() failed: {:?}", actual.err()); } - #[tokio::test] - async fn test_read_session_from_env_vars() { - let _ = EnvGuard::set(&[ + #[test] + fn test_read_session_from_env_vars() { + let _guard = EnvGuard::set(&[ ("FORGE_SESSION__PROVIDER_ID", "fake-provider"), ("FORGE_SESSION__MODEL_ID", "fake-model"), ]); - let actual = ConfigReader::default().read(None).await.unwrap(); + let actual = ConfigReader::default() + .read_defaults() + .read_env() + .build() + .unwrap(); let expected = Some(ModelConfig { provider_id: Some("fake-provider".to_string()), diff --git a/crates/forge_repo/src/app_config.rs b/crates/forge_repo/src/app_config.rs index 4d8d977980..ab89bcf59c 100644 --- a/crates/forge_repo/src/app_config.rs +++ b/crates/forge_repo/src/app_config.rs @@ -1,6 +1,6 @@ use std::sync::Arc; -use forge_config::{ForgeConfig, ModelConfig}; +use forge_config::{ConfigReader, ForgeConfig, ModelConfig}; use forge_domain::{ AppConfig, AppConfigOperation, AppConfigRepository, CommitConfig, LoginInfo, ModelId, ProviderId, SuggestConfig, @@ -124,7 +124,7 @@ impl ForgeConfigRepository { /// Reads [`AppConfig`] from disk via [`ForgeConfig::read`]. async fn read(&self) -> ForgeConfig { - let config = ForgeConfig::read().await; + let config = ForgeConfig::read(); match config { Ok(config) => { @@ -161,9 +161,12 @@ impl AppConfigRepository for ForgeConfigRepository { async fn update_app_config(&self, ops: Vec) -> anyhow::Result<()> { // Load the global config - let mut fc = ForgeConfig::read_global().await?; + let mut fc = ConfigReader::default().read_global().build()?; + + debug!(config = ?fc, "loaded config for update"); // Apply each operation directly onto ForgeConfig + debug!(?ops, "applying app config operations"); for op in ops { apply_op(op, &mut fc); } @@ -179,269 +182,3 @@ impl AppConfigRepository for ForgeConfigRepository { Ok(()) } } - -#[cfg(test)] -mod tests { - - use std::collections::HashMap; - use std::path::PathBuf; - use std::str::FromStr; - use std::sync::Mutex; - - use forge_domain::ProviderId; - use pretty_assertions::assert_eq; - use tempfile::TempDir; - - use super::*; - - /// Mutex to serialize all tests that mutate the `HOME` env var, preventing - /// races when multiple tests run concurrently in the same process. - static HOME_MUTEX: Mutex<()> = Mutex::new(()); - - /// Guard type that holds both the mutex guard and the temp dir, ensuring - /// the temp directory outlives the mutex release. - struct HomeGuard { - _lock: std::sync::MutexGuard<'static, ()>, - _dir: TempDir, - } - - /// Sets HOME to a fresh temp directory so that [`ForgeConfig::read`] and - /// [`ForgeConfig::write`] operate on an isolated `~/.forge/.forge.toml`. - /// Acquires the [`HOME_MUTEX`] and holds it for the lifetime of the - /// returned guard. - fn temp_home() -> HomeGuard { - let lock = HOME_MUTEX.lock().unwrap_or_else(|e| e.into_inner()); - let dir = tempfile::tempdir().unwrap(); - // SAFETY: tests are serialized by HOME_MUTEX, so no concurrent HOME reads - // occur. - unsafe { std::env::set_var("HOME", dir.path()) }; - HomeGuard { _lock: lock, _dir: dir } - } - - impl std::ops::Deref for HomeGuard { - type Target = TempDir; - fn deref(&self) -> &TempDir { - &self._dir - } - } - - /// Returns the path to `.forge.toml` inside a temp home directory. - fn forge_toml_path(home: &HomeGuard) -> PathBuf { - home.path().join("forge").join(".forge.toml") - } - - /// Writes a TOML string to the forge config path, creating parent dirs. - fn write_toml(home: &HomeGuard, toml: &str) { - let path = forge_toml_path(home); - std::fs::create_dir_all(path.parent().unwrap()).unwrap(); - std::fs::write(path, toml).unwrap(); - } - - fn repository_fixture(_home: &HomeGuard) -> ForgeConfigRepository { - ForgeConfigRepository::new() - } - - /// Returns a [`ForgeConfig`] built from embedded defaults only, as a - /// clean starting point for conversion fixtures. - fn forge_config_defaults() -> ForgeConfig { - forge_config::ConfigReader::default().read_defaults() - } - - // ------------------------------------------------------------------------- - // forge_config_to_app_config - // ------------------------------------------------------------------------- - - #[test] - fn test_forge_config_to_app_config_empty() { - let fixture = forge_config_defaults(); - - let actual = forge_config_to_app_config(fixture); - - let expected = AppConfig::default(); - assert_eq!(actual, expected); - } - - #[test] - fn test_forge_config_to_app_config_with_model() { - let mut fixture = forge_config_defaults(); - fixture.session = Some( - ModelConfig::default() - .provider_id("anthropic".to_string()) - .model_id("claude-3-5-sonnet-20241022".to_string()), - ); - - let actual = forge_config_to_app_config(fixture); - - let expected = AppConfig { - provider: Some(ProviderId::ANTHROPIC), - model: HashMap::from([( - ProviderId::ANTHROPIC, - ModelId::new("claude-3-5-sonnet-20241022"), - )]), - ..Default::default() - }; - assert_eq!(actual, expected); - } - - #[test] - fn test_forge_config_to_app_config_with_login_info() { - let mut fixture = forge_config_defaults(); - fixture.api_key = Some("sk-test-key".to_string()); - fixture.api_key_name = Some("my-key".to_string()); - fixture.api_key_masked = Some("sk-***".to_string()); - fixture.email = Some("user@example.com".to_string()); - fixture.name = Some("Alice".to_string()); - fixture.auth_provider_id = Some("github".to_string()); - - let actual = forge_config_to_app_config(fixture); - - let expected = AppConfig { - key_info: Some(LoginInfo { - api_key: "sk-test-key".to_string(), - api_key_name: "my-key".to_string(), - api_key_masked: "sk-***".to_string(), - email: Some("user@example.com".to_string()), - name: Some("Alice".to_string()), - auth_provider_id: Some("github".to_string()), - }), - ..Default::default() - }; - assert_eq!(actual, expected); - } - - #[test] - fn test_forge_config_to_app_config_no_login_info_when_api_key_absent() { - let fixture = forge_config_defaults(); - // api_key is None → key_info must be None even if other fields are set - - let actual = forge_config_to_app_config(fixture); - - assert_eq!(actual.key_info, None); - } - - #[test] - fn test_forge_config_to_app_config_with_commit() { - let mut fixture = forge_config_defaults(); - fixture.commit = Some( - ModelConfig::default() - .provider_id("openai".to_string()) - .model_id("gpt-4o".to_string()), - ); - - let actual = forge_config_to_app_config(fixture); - - let expected = CommitConfig { - provider: Some(ProviderId::OPENAI), - model: Some(ModelId::new("gpt-4o")), - }; - assert_eq!(actual.commit, Some(expected)); - } - - #[test] - fn test_forge_config_to_app_config_with_suggest() { - let mut fixture = forge_config_defaults(); - fixture.suggest = Some( - ModelConfig::default() - .provider_id("openai".to_string()) - .model_id("gpt-4o-mini".to_string()), - ); - - let actual = forge_config_to_app_config(fixture); - - let expected = SuggestConfig { - provider: ProviderId::OPENAI, - model: ModelId::new("gpt-4o-mini"), - }; - assert_eq!(actual.suggest, Some(expected)); - } - - #[test] - fn test_forge_config_to_app_config_session_provider_only() { - let mut fixture = forge_config_defaults(); - fixture.session = Some(ModelConfig::default().provider_id("anthropic".to_string())); - - let actual = forge_config_to_app_config(fixture); - - assert_eq!(actual.provider, Some(ProviderId::ANTHROPIC)); - assert!(actual.model.is_empty()); - } - - #[test] - fn test_forge_config_to_app_config_session_model_only() { - let mut fixture = forge_config_defaults(); - fixture.session = - Some(ModelConfig::default().model_id("claude-3-5-sonnet-20241022".to_string())); - - let actual = forge_config_to_app_config(fixture); - - assert_eq!(actual.provider, None); - assert!(actual.model.is_empty()); - } - - #[tokio::test] - async fn test_get_app_config_not_exists() { - let _home = temp_home(); - let repo = repository_fixture(&_home); - - let actual = repo.get_app_config().await.unwrap(); - - assert_eq!(actual, forge_domain::AppConfig::default()); - } - - #[tokio::test] - async fn test_set_app_config() { - let _home = temp_home(); - let repo = repository_fixture(&_home); - - let actual = repo - .update_app_config(vec![AppConfigOperation::SetProvider(ProviderId::ANTHROPIC)]) - .await; - - assert!(actual.is_ok()); - - // Verify the config was actually written by reading it back - let read_config = repo.get_app_config().await.unwrap(); - assert_eq!(read_config.provider, Some(ProviderId::ANTHROPIC)); - } - - #[tokio::test] - async fn test_cache_behavior() { - let _home = temp_home(); - write_toml(&_home, ""); - let repo = repository_fixture(&_home); - - // First read should populate cache - let first_read = repo.get_app_config().await.unwrap(); - - // Second read should use cache (no file system access) - let second_read = repo.get_app_config().await.unwrap(); - assert_eq!(first_read, second_read); - - // Write new config should bust cache - repo.update_app_config(vec![AppConfigOperation::SetProvider(ProviderId::OPENAI)]) - .await - .unwrap(); - - // Next read should get fresh data - let third_read = repo.get_app_config().await.unwrap(); - assert_eq!(third_read.provider, Some(ProviderId::OPENAI)); - } - - #[test] - fn test_read_handles_custom_provider() { - // Verify the full parse path for a custom provider value — uses - // ConfigReader::read_str to avoid any real filesystem dependency. - let toml = r#" -[session] -provider_id = "xyz" -model_id = "some-model" -"#; - let fc = forge_config::ConfigReader::default() - .read_str(toml) - .unwrap(); - - let actual = forge_config_to_app_config(fc); - - assert_eq!(actual.provider, Some(ProviderId::from_str("xyz").unwrap())); - } -} From 0ae178d2f1d9be06bcee3d850179de11e6127f30 Mon Sep 17 00:00:00 2001 From: Tushar Date: Wed, 25 Mar 2026 20:07:29 +0530 Subject: [PATCH 61/67] feat(forge_config): add legacy json config migration support --- crates/forge_config/src/error.rs | 3 +++ crates/forge_config/src/reader.rs | 29 ++++++++++++++++++----------- 2 files changed, 21 insertions(+), 11 deletions(-) diff --git a/crates/forge_config/src/error.rs b/crates/forge_config/src/error.rs index 6fde9b5883..ed1b867b70 100644 --- a/crates/forge_config/src/error.rs +++ b/crates/forge_config/src/error.rs @@ -12,4 +12,7 @@ pub enum Error { /// An I/O error occurred while reading or writing configuration files. #[error("IO error: {0}")] Io(#[from] std::io::Error), + + #[error("JSON error: {0}")] + Serde(#[from] serde_json::Error), } diff --git a/crates/forge_config/src/reader.rs b/crates/forge_config/src/reader.rs index 40b97ec0e2..ceb539ff16 100644 --- a/crates/forge_config/src/reader.rs +++ b/crates/forge_config/src/reader.rs @@ -1,12 +1,9 @@ -use std::collections::HashMap; -use std::path::PathBuf; - +use crate::{ForgeConfig, ModelConfig}; use config::ConfigBuilder; use config::builder::DefaultState; use serde::Deserialize; -use tracing::debug; - -use crate::{ForgeConfig, ModelConfig}; +use std::collections::HashMap; +use std::path::PathBuf; /// Reads and merges [`ForgeConfig`] from multiple sources: embedded defaults, /// home directory file, current working directory file, and environment @@ -45,6 +42,14 @@ struct LegacyModelRef { model: Option, } +fn read_legacy_config(path: &PathBuf) -> crate::Result { + let contents = std::fs::read_to_string(path)?; + let config = serde_json::from_str::(&contents)?; + let forge_config = config.into_forge_config(); + let content = toml_edit::ser::to_string_pretty(&forge_config)?; + Ok(content) +} + impl LegacyConfig { /// Converts a [`LegacyConfig`] into the fields of [`ForgeConfig`] that it /// covers, leaving all other fields at their defaults. @@ -146,11 +151,13 @@ impl ConfigReader { /// a [`ForgeConfig`], and adds it as a config source. /// /// If the file does not exist or cannot be parsed it is silently skipped. - pub fn read_legacy(mut self) -> Self { - let path = Self::config_legacy_path(); - self.builder = self.builder.add_source(config::File::from(path)); - - self + pub fn read_legacy(self) -> Self { + let content = read_legacy_config(&Self::config_legacy_path()); + if let Ok(content) = content { + self.read_toml(&content) + } else { + self + } } } From ebc632c309ebf453294a94531ed63a81f648784b Mon Sep 17 00:00:00 2001 From: Tushar Date: Wed, 25 Mar 2026 20:10:18 +0530 Subject: [PATCH 62/67] refactor(forge_config): move legacy config parser to module --- crates/forge_config/src/legacy.rs | 69 +++++++++++++++++++++++++++++++ crates/forge_config/src/lib.rs | 1 + crates/forge_config/src/reader.rs | 65 ++--------------------------- 3 files changed, 73 insertions(+), 62 deletions(-) create mode 100644 crates/forge_config/src/legacy.rs diff --git a/crates/forge_config/src/legacy.rs b/crates/forge_config/src/legacy.rs new file mode 100644 index 0000000000..bf23c92325 --- /dev/null +++ b/crates/forge_config/src/legacy.rs @@ -0,0 +1,69 @@ +use crate::{ForgeConfig, ModelConfig}; +use serde::Deserialize; +use std::collections::HashMap; +use std::path::PathBuf; + +/// Intermediate representation of the legacy `~/forge/.config.json` format. +/// +/// This format stores the active provider as a top-level string and models as +/// a map from provider ID to model ID, which differs from the TOML config's +/// nested `session`, `commit`, and `suggest` sub-objects. +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct LegacyConfig { + /// The active provider ID (e.g. `"anthropic"`). + #[serde(default)] + provider: Option, + /// Map from provider ID to the model ID to use with that provider. + #[serde(default)] + model: HashMap, + /// Commit message generation provider/model pair. + #[serde(default)] + commit: Option, + /// Shell command suggestion provider/model pair. + #[serde(default)] + suggest: Option, +} + +/// A provider/model pair as expressed in the legacy JSON config. +#[derive(Debug, Deserialize)] +struct LegacyModelRef { + provider: Option, + model: Option, +} + +impl LegacyConfig { + /// Reads the legacy `~/forge/.config.json` file at `path`, parses it, and + /// returns the equivalent TOML representation as a [`String`]. + /// + /// # Errors + /// + /// Returns an error if the file cannot be read, the JSON is invalid, or the + /// resulting config cannot be serialized to TOML. + pub(crate) fn read(path: &PathBuf) -> crate::Result { + let contents = std::fs::read_to_string(path)?; + let config = serde_json::from_str::(&contents)?; + let forge_config = config.into_forge_config(); + let content = toml_edit::ser::to_string_pretty(&forge_config)?; + Ok(content) + } + + /// Converts a [`LegacyConfig`] into the fields of [`ForgeConfig`] that it + /// covers, leaving all other fields at their defaults. + fn into_forge_config(self) -> ForgeConfig { + let session = self.provider.as_deref().map(|provider_id| { + let model_id = self.model.get(provider_id).cloned(); + ModelConfig { provider_id: Some(provider_id.to_string()), model_id } + }); + + let commit = self + .commit + .map(|c| ModelConfig { provider_id: c.provider, model_id: c.model }); + + let suggest = self + .suggest + .map(|s| ModelConfig { provider_id: s.provider, model_id: s.model }); + + ForgeConfig { session, commit, suggest, ..Default::default() } + } +} diff --git a/crates/forge_config/src/lib.rs b/crates/forge_config/src/lib.rs index a59ccf556c..b0ba37a4b3 100644 --- a/crates/forge_config/src/lib.rs +++ b/crates/forge_config/src/lib.rs @@ -3,6 +3,7 @@ mod compact; mod config; mod error; mod http; +mod legacy; mod model; mod reader; mod retry; diff --git a/crates/forge_config/src/reader.rs b/crates/forge_config/src/reader.rs index ceb539ff16..6c85ab2a1d 100644 --- a/crates/forge_config/src/reader.rs +++ b/crates/forge_config/src/reader.rs @@ -1,8 +1,7 @@ -use crate::{ForgeConfig, ModelConfig}; +use crate::ForgeConfig; +use crate::legacy::LegacyConfig; use config::ConfigBuilder; use config::builder::DefaultState; -use serde::Deserialize; -use std::collections::HashMap; use std::path::PathBuf; /// Reads and merges [`ForgeConfig`] from multiple sources: embedded defaults, @@ -13,64 +12,6 @@ pub struct ConfigReader { builder: ConfigBuilder, } -/// Intermediate representation of the legacy `~/forge/.config.json` format. -/// -/// This format stores the active provider as a top-level string and models as -/// a map from provider ID to model ID, which differs from the TOML config's -/// nested `session`, `commit`, and `suggest` sub-objects. -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -struct LegacyConfig { - /// The active provider ID (e.g. `"anthropic"`). - #[serde(default)] - provider: Option, - /// Map from provider ID to the model ID to use with that provider. - #[serde(default)] - model: HashMap, - /// Commit message generation provider/model pair. - #[serde(default)] - commit: Option, - /// Shell command suggestion provider/model pair. - #[serde(default)] - suggest: Option, -} - -/// A provider/model pair as expressed in the legacy JSON config. -#[derive(Debug, Deserialize)] -struct LegacyModelRef { - provider: Option, - model: Option, -} - -fn read_legacy_config(path: &PathBuf) -> crate::Result { - let contents = std::fs::read_to_string(path)?; - let config = serde_json::from_str::(&contents)?; - let forge_config = config.into_forge_config(); - let content = toml_edit::ser::to_string_pretty(&forge_config)?; - Ok(content) -} - -impl LegacyConfig { - /// Converts a [`LegacyConfig`] into the fields of [`ForgeConfig`] that it - /// covers, leaving all other fields at their defaults. - fn into_forge_config(self) -> ForgeConfig { - let session = self.provider.as_deref().map(|provider_id| { - let model_id = self.model.get(provider_id).cloned(); - ModelConfig { provider_id: Some(provider_id.to_string()), model_id } - }); - - let commit = self - .commit - .map(|c| ModelConfig { provider_id: c.provider, model_id: c.model }); - - let suggest = self - .suggest - .map(|s| ModelConfig { provider_id: s.provider, model_id: s.model }); - - ForgeConfig { session, commit, suggest, ..Default::default() } - } -} - impl ConfigReader { pub fn config_legacy_path() -> PathBuf { Self::base_path().join(".config.json") @@ -152,7 +93,7 @@ impl ConfigReader { /// /// If the file does not exist or cannot be parsed it is silently skipped. pub fn read_legacy(self) -> Self { - let content = read_legacy_config(&Self::config_legacy_path()); + let content = LegacyConfig::read(&Self::config_legacy_path()); if let Ok(content) = content { self.read_toml(&content) } else { From 63f03c2a0ad72e278b30b060de44ba4bdf45ec86 Mon Sep 17 00:00:00 2001 From: Tushar Date: Wed, 25 Mar 2026 20:14:02 +0530 Subject: [PATCH 63/67] refactor(forge_config): simplify config docs and reader comments --- crates/forge_config/src/config.rs | 86 ++++--------------------------- crates/forge_config/src/reader.rs | 44 +++++----------- 2 files changed, 23 insertions(+), 107 deletions(-) diff --git a/crates/forge_config/src/config.rs b/crates/forge_config/src/config.rs index d2c74a4bd4..6ba4f25404 100644 --- a/crates/forge_config/src/config.rs +++ b/crates/forge_config/src/config.rs @@ -11,33 +11,7 @@ use crate::{ TopP, Update, }; -/// Forge configuration containing all the fields from the Environment struct. -/// -/// # Field Naming Convention -/// -/// Fields follow these rules to make units and semantics unambiguous at the -/// call-site: -/// -/// - **Unit suffixes are mandatory** for any numeric field that carries a -/// physical unit: -/// - `_ms` — duration in milliseconds -/// - `_secs` — duration in seconds -/// - `_bytes` — size in bytes -/// - `_lines` — count of text lines -/// - `_chars` — count of characters -/// - Pure counts / dimensionless values (e.g. `max_redirects`) carry no -/// suffix. -/// -/// - **`max_` is always a prefix**, never embedded mid-name: -/// - Correct: `max_stdout_prefix_lines` -/// - Incorrect: `stdout_max_prefix_length` -/// -/// - **No redundant struct-name prefixes inside a sub-struct**: fields inside -/// `RetryConfig` must not repeat `retry_` (e.g. use `status_codes`, not -/// `retry_status_codes`). -/// -/// - **`_limit` is avoided**; prefer the explicit `max_` prefix + unit suffix -/// instead. +/// Top-level Forge configuration merged from all sources (defaults, file, environment). #[derive(Default, Debug, Setters, Clone, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] #[setters(strip_option)] @@ -92,8 +66,7 @@ pub struct ForgeConfig { pub max_parallel_file_reads: usize, /// TTL in seconds for the model API list cache pub model_cache_ttl_secs: u64, - /// Default model and provider configuration to use for all operations if - /// not specified + /// Default model and provider configuration used when not overridden by individual agents. #[serde(default)] pub session: Option, /// Provider and model to use for commit message generation @@ -126,55 +99,23 @@ pub struct ForgeConfig { #[serde(skip_serializing_if = "Option::is_none")] pub updates: Option, - /// Temperature used for all agents. - /// - /// Temperature controls the randomness in the model's output. - /// - Lower values (e.g., 0.1) make responses more focused, deterministic, - /// and coherent - /// - Higher values (e.g., 0.8) make responses more creative, diverse, and - /// exploratory - /// - Valid range is 0.0 to 2.0 - /// - If not specified, each agent's individual setting or the model - /// provider's default will be used + /// Output randomness for all agents; lower values are deterministic, higher values are creative (0.0–2.0). #[serde(default, skip_serializing_if = "Option::is_none")] pub temperature: Option, - /// Top-p (nucleus sampling) used for all agents. - /// - /// Controls the diversity of the model's output by considering only the - /// most probable tokens up to a cumulative probability threshold. - /// - Lower values (e.g., 0.1) make responses more focused - /// - Higher values (e.g., 0.9) make responses more diverse - /// - Valid range is 0.0 to 1.0 - /// - If not specified, each agent's individual setting or the model - /// provider's default will be used + /// Nucleus sampling threshold for all agents; limits token selection to the top cumulative probability mass (0.0–1.0). #[serde(default, skip_serializing_if = "Option::is_none")] pub top_p: Option, - /// Top-k used for all agents. - /// - /// Controls the number of highest probability vocabulary tokens to keep. - /// - Lower values (e.g., 10) make responses more focused - /// - Higher values (e.g., 100) make responses more diverse - /// - Valid range is 1 to 1000 - /// - If not specified, each agent's individual setting or the model - /// provider's default will be used + /// Top-k vocabulary cutoff for all agents; restricts sampling to the k highest-probability tokens (1–1000). #[serde(default, skip_serializing_if = "Option::is_none")] pub top_k: Option, - /// Maximum number of tokens the model can generate for all agents. - /// - /// Controls the maximum length of the model's response. - /// - Lower values (e.g., 100) limit response length for concise outputs - /// - Higher values (e.g., 4000) allow for longer, more detailed responses - /// - Valid range is 1 to 100,000 - /// - If not specified, each agent's individual setting or the model - /// provider's default will be used + /// Maximum tokens the model may generate per response for all agents (1–100,000). #[serde(default, skip_serializing_if = "Option::is_none")] pub max_tokens: Option, - /// Maximum number of times a tool can fail before the orchestrator - /// forces the completion. + /// Maximum tool failures per turn before the orchestrator forces completion. #[serde(default, skip_serializing_if = "Option::is_none")] pub max_tool_failure_per_turn: Option, @@ -182,21 +123,17 @@ pub struct ForgeConfig { #[serde(default, skip_serializing_if = "Option::is_none")] pub max_requests_per_turn: Option, - /// Configuration for automatic context compaction for all agents. - /// If specified, this will be applied to all agents in the workflow. - /// If not specified, each agent's individual setting will be used. + /// Context compaction settings applied to all agents; falls back to each agent's individual setting when absent. #[serde(default, skip_serializing_if = "Option::is_none")] pub compact: Option, } impl ForgeConfig { - /// Reads and merges configuration from all sources, returning the resolved - /// [`ForgeConfig`]. + /// Reads and merges configuration from all sources, returning the resolved [`ForgeConfig`]. /// /// # Errors /// - /// Returns an error if the config path cannot be resolved, the file cannot - /// be read, or the configuration cannot be deserialized. + /// Returns an error if the config path cannot be resolved, the file cannot be read, or deserialization fails. pub fn read() -> crate::Result { Ok(ConfigReader::default() .read_defaults() @@ -210,8 +147,7 @@ impl ForgeConfig { /// /// # Errors /// - /// Returns an error if the configuration cannot be serialized or written to - /// disk. + /// Returns an error if the configuration cannot be serialized or written to disk. pub async fn write(&self) -> crate::Result<()> { let path = ConfigReader::config_path(); ConfigWriter::new(self.clone()).write(&path).await diff --git a/crates/forge_config/src/reader.rs b/crates/forge_config/src/reader.rs index 6c85ab2a1d..3b3278675f 100644 --- a/crates/forge_config/src/reader.rs +++ b/crates/forge_config/src/reader.rs @@ -4,34 +4,29 @@ use config::ConfigBuilder; use config::builder::DefaultState; use std::path::PathBuf; -/// Reads and merges [`ForgeConfig`] from multiple sources: embedded defaults, -/// home directory file, current working directory file, and environment -/// variables. +/// Merges [`ForgeConfig`] from layered sources using a builder pattern. #[derive(Default)] pub struct ConfigReader { builder: ConfigBuilder, } impl ConfigReader { + /// Returns the path to the legacy JSON config file (`~/.forge/.config.json`). pub fn config_legacy_path() -> PathBuf { Self::base_path().join(".config.json") } + /// Returns the path to the primary TOML config file (`~/.forge/.forge.toml`). pub fn config_path() -> PathBuf { Self::base_path().join(".forge.toml") } + /// Returns the base directory for all Forge config files (`~/forge`). pub fn base_path() -> PathBuf { dirs::home_dir().unwrap_or(PathBuf::from(".")).join("forge") } - /// Reads and merges configuration from the embedded defaults and the given - /// TOML string, returning the resolved [`ForgeConfig`]. - /// - /// Unlike [`read`], this method accepts already-loaded TOML content and - /// does not touch the filesystem or environment variables. This is - /// appropriate when the caller has already read the raw file content via - /// its own I/O abstraction. + /// Adds the provided TOML string as a config source without touching the filesystem. pub fn read_toml(mut self, contents: &str) -> Self { self.builder = self .builder @@ -40,15 +35,14 @@ impl ConfigReader { self } - /// Returns the [`ForgeConfig`] built from the embedded defaults only, - /// without reading any file or environment variables. + /// Adds the embedded default config (`../.forge.toml`) as a source. pub fn read_defaults(self) -> Self { let defaults = include_str!("../.forge.toml"); self.read_toml(defaults) } - /// Adds environment variables prefixed with `FORGE_` as a source. + /// Adds `FORGE_`-prefixed environment variables as a config source. pub fn read_env(mut self) -> Self { self.builder = self.builder.add_source( config::Environment::with_prefix("FORGE") @@ -63,7 +57,7 @@ impl ConfigReader { self } - /// Builds and returns the merged [`ForgeConfig`] from all accumulated sources. + /// Builds and deserializes all accumulated sources into a [`ForgeConfig`]. /// /// # Errors /// @@ -73,25 +67,14 @@ impl ConfigReader { Ok(config.try_deserialize::()?) } - /// Reads `~/.forge/.forge.toml` and adds it as a config source. - /// - /// If the file does not exist it is silently skipped. If the file cannot - /// be read or parsed the error is propagated. - /// - /// # Errors - /// - /// Returns an error if the file exists but cannot be read or deserialized. - + /// Adds `~/.forge/.forge.toml` as a config source, silently skipping if absent. pub fn read_global(mut self) -> Self { let path = Self::config_path(); self.builder = self.builder.add_source(config::File::from(path)); self } - /// Reads `~/.forge/.config.json` (the legacy JSON format), converts it to - /// a [`ForgeConfig`], and adds it as a config source. - /// - /// If the file does not exist or cannot be parsed it is silently skipped. + /// Reads `~/.forge/.config.json` (legacy format) and adds it as a source, silently skipping errors. pub fn read_legacy(self) -> Self { let content = LegacyConfig::read(&Self::config_legacy_path()); if let Ok(content) = content { @@ -114,17 +97,14 @@ mod tests { /// Serializes tests that mutate environment variables to prevent races. static ENV_MUTEX: Mutex<()> = Mutex::new(()); - /// Guard that holds a set of environment variables for the duration of a - /// test, removing them all on drop. Also holds the [`ENV_MUTEX`] lock to - /// prevent concurrent env mutations across tests. + /// Holds env vars set for a test's duration and removes them on drop, while holding [`ENV_MUTEX`]. struct EnvGuard { keys: Vec<&'static str>, _lock: MutexGuard<'static, ()>, } impl EnvGuard { - /// Sets each `(key, value)` pair in the process environment and returns - /// a guard that removes all those keys when dropped. + /// Sets each `(key, value)` pair in the environment, returning a guard that cleans them up on drop. #[must_use] fn set(pairs: &[(&'static str, &str)]) -> Self { let lock = ENV_MUTEX.lock().unwrap_or_else(|e| e.into_inner()); From 24aa1c9187022d077b95625608c8ff20d7eac842 Mon Sep 17 00:00:00 2001 From: Tushar Date: Wed, 25 Mar 2026 20:19:36 +0530 Subject: [PATCH 64/67] refactor(forge_config): load env files and make config write sync --- Cargo.lock | 2 +- crates/forge_config/Cargo.toml | 2 +- crates/forge_config/src/config.rs | 4 ++-- crates/forge_config/src/reader.rs | 27 +++++++++++++++++++++++++++ crates/forge_config/src/writer.rs | 6 +++--- crates/forge_repo/src/app_config.rs | 2 +- 6 files changed, 35 insertions(+), 8 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 26dd6f854e..57e7e0d4fb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1942,6 +1942,7 @@ dependencies = [ "config", "derive_setters", "dirs", + "dotenvy", "fake", "merge", "pretty_assertions", @@ -1949,7 +1950,6 @@ dependencies = [ "serde", "serde_json", "thiserror 2.0.18", - "tokio", "toml_edit", "tracing", "url", diff --git a/crates/forge_config/Cargo.toml b/crates/forge_config/Cargo.toml index 5f63c90405..a9b2acfb7a 100644 --- a/crates/forge_config/Cargo.toml +++ b/crates/forge_config/Cargo.toml @@ -9,7 +9,7 @@ thiserror.workspace = true config = { version = "0.15", features = ["toml"] } derive_setters.workspace = true dirs.workspace = true -tokio = { workspace = true, features = ["fs"] } +dotenvy.workspace = true serde.workspace = true serde_json.workspace = true toml_edit = { workspace = true } diff --git a/crates/forge_config/src/config.rs b/crates/forge_config/src/config.rs index 6ba4f25404..077cba55ce 100644 --- a/crates/forge_config/src/config.rs +++ b/crates/forge_config/src/config.rs @@ -148,8 +148,8 @@ impl ForgeConfig { /// # Errors /// /// Returns an error if the configuration cannot be serialized or written to disk. - pub async fn write(&self) -> crate::Result<()> { + pub fn write(&self) -> crate::Result<()> { let path = ConfigReader::config_path(); - ConfigWriter::new(self.clone()).write(&path).await + ConfigWriter::new(self.clone()).write(&path) } } diff --git a/crates/forge_config/src/reader.rs b/crates/forge_config/src/reader.rs index 3b3278675f..2780f607cc 100644 --- a/crates/forge_config/src/reader.rs +++ b/crates/forge_config/src/reader.rs @@ -3,6 +3,29 @@ use crate::legacy::LegacyConfig; use config::ConfigBuilder; use config::builder::DefaultState; use std::path::PathBuf; +use std::sync::LazyLock; + +/// Loads all `.env` files found while walking up from the current working directory to the root, +/// with priority given to closer (lower) directories. Executed at most once per process. +static LOAD_DOT_ENV: LazyLock<()> = LazyLock::new(|| { + let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")); + let mut paths = vec![]; + let mut current = PathBuf::new(); + + for component in cwd.components() { + current.push(component); + paths.push(current.clone()); + } + + paths.reverse(); + + for path in paths { + let env_file = path.join(".env"); + if env_file.is_file() { + dotenvy::from_path(&env_file).ok(); + } + } +}); /// Merges [`ForgeConfig`] from layered sources using a builder pattern. #[derive(Default)] @@ -59,10 +82,14 @@ impl ConfigReader { /// Builds and deserializes all accumulated sources into a [`ForgeConfig`]. /// + /// Triggers `.env` file loading (at most once per process) by walking up the directory tree + /// from the current working directory, with closer directories taking priority. + /// /// # Errors /// /// Returns an error if the configuration cannot be built or deserialized. pub fn build(self) -> crate::Result { + let _ = *LOAD_DOT_ENV; let config = self.builder.build()?; Ok(config.try_deserialize::()?) } diff --git a/crates/forge_config/src/writer.rs b/crates/forge_config/src/writer.rs index b8ddf61092..e02ba43795 100644 --- a/crates/forge_config/src/writer.rs +++ b/crates/forge_config/src/writer.rs @@ -20,14 +20,14 @@ impl ConfigWriter { /// /// Returns an error if the configuration cannot be serialized or the file /// cannot be written. - pub async fn write(&self, path: &Path) -> crate::Result<()> { + pub fn write(&self, path: &Path) -> crate::Result<()> { if let Some(parent) = path.parent() { - tokio::fs::create_dir_all(parent).await?; + std::fs::create_dir_all(parent)?; } let contents = toml_edit::ser::to_string_pretty(&self.config)?; - tokio::fs::write(path, contents).await?; + std::fs::write(path, contents)?; Ok(()) } diff --git a/crates/forge_repo/src/app_config.rs b/crates/forge_repo/src/app_config.rs index ab89bcf59c..f78da14802 100644 --- a/crates/forge_repo/src/app_config.rs +++ b/crates/forge_repo/src/app_config.rs @@ -172,7 +172,7 @@ impl AppConfigRepository for ForgeConfigRepository { } // Persist - fc.write().await?; + fc.write()?; debug!(config = ?fc, "written .forge.toml"); // Reset cache From cd337b3113aaa64d2823043621e3a6ae59bf4e57 Mon Sep 17 00:00:00 2001 From: Tushar Date: Wed, 25 Mar 2026 20:28:04 +0530 Subject: [PATCH 65/67] test(app_config): add coverage for configuration mapping and operations --- crates/forge_repo/src/app_config.rs | 308 ++++++++++++++++++++++++++++ 1 file changed, 308 insertions(+) diff --git a/crates/forge_repo/src/app_config.rs b/crates/forge_repo/src/app_config.rs index f78da14802..26367325d3 100644 --- a/crates/forge_repo/src/app_config.rs +++ b/crates/forge_repo/src/app_config.rs @@ -109,6 +109,314 @@ fn apply_op(op: AppConfigOperation, fc: &mut ForgeConfig) { } } +#[cfg(test)] +mod tests { + use std::collections::HashMap; + + use forge_config::{ForgeConfig, ModelConfig}; + use forge_domain::{ + AppConfig, AppConfigOperation, CommitConfig, LoginInfo, ModelId, ProviderId, SuggestConfig, + }; + use pretty_assertions::assert_eq; + + use super::{apply_op, forge_config_to_app_config}; + + // ── forge_config_to_app_config ──────────────────────────────────────────── + + #[test] + fn test_empty_forge_config_produces_empty_app_config() { + let fixture = ForgeConfig::default(); + let actual = forge_config_to_app_config(fixture); + let expected = AppConfig::default(); + assert_eq!(actual, expected); + } + + #[test] + fn test_full_login_info_is_mapped() { + let fixture = ForgeConfig::default() + .api_key("key-abc".to_string()) + .api_key_name("My Key".to_string()) + .api_key_masked("key-***".to_string()) + .email("user@example.com".to_string()) + .name("Alice".to_string()) + .auth_provider_id("github".to_string()); + let actual = forge_config_to_app_config(fixture); + let expected = AppConfig { + key_info: Some(LoginInfo { + api_key: "key-abc".to_string(), + api_key_name: "My Key".to_string(), + api_key_masked: "key-***".to_string(), + email: Some("user@example.com".to_string()), + name: Some("Alice".to_string()), + auth_provider_id: Some("github".to_string()), + }), + ..Default::default() + }; + assert_eq!(actual, expected); + } + + #[test] + fn test_session_with_provider_and_model() { + let fixture = ForgeConfig { + session: Some( + ModelConfig::default() + .provider_id("anthropic".to_string()) + .model_id("claude-3".to_string()), + ), + ..Default::default() + }; + let actual = forge_config_to_app_config(fixture); + let provider = ProviderId::from("anthropic".to_string()); + let expected = AppConfig { + provider: Some(provider.clone()), + model: HashMap::from([(provider, ModelId::new("claude-3"))]), + ..Default::default() + }; + assert_eq!(actual, expected); + } + + #[test] + fn test_session_with_only_provider_leaves_model_map_empty() { + let fixture = ForgeConfig { + session: Some(ModelConfig::default().provider_id("openai".to_string())), + ..Default::default() + }; + let actual = forge_config_to_app_config(fixture); + let expected = AppConfig { + provider: Some(ProviderId::from("openai".to_string())), + model: HashMap::new(), + ..Default::default() + }; + assert_eq!(actual, expected); + } + + #[test] + fn test_commit_config_is_mapped() { + let fixture = ForgeConfig { + commit: Some( + ModelConfig::default() + .provider_id("openai".to_string()) + .model_id("gpt-4o".to_string()), + ), + ..Default::default() + }; + let actual = forge_config_to_app_config(fixture); + let expected = AppConfig { + commit: Some(CommitConfig { + provider: Some(ProviderId::from("openai".to_string())), + model: Some(ModelId::new("gpt-4o")), + }), + ..Default::default() + }; + assert_eq!(actual, expected); + } + + #[test] + fn test_suggest_config_requires_both_provider_and_model() { + let fixture_provider_only = ForgeConfig { + suggest: Some(ModelConfig::default().provider_id("openai".to_string())), + ..Default::default() + }; + assert_eq!(forge_config_to_app_config(fixture_provider_only).suggest, None); + + let fixture_model_only = ForgeConfig { + suggest: Some(ModelConfig { model_id: Some("gpt-4o".to_string()), provider_id: None }), + ..Default::default() + }; + assert_eq!(forge_config_to_app_config(fixture_model_only).suggest, None); + } + + #[test] + fn test_suggest_config_with_both_fields_is_mapped() { + let fixture = ForgeConfig { + suggest: Some( + ModelConfig::default() + .provider_id("openai".to_string()) + .model_id("gpt-4o-mini".to_string()), + ), + ..Default::default() + }; + let actual = forge_config_to_app_config(fixture); + let expected = AppConfig { + suggest: Some(SuggestConfig { + provider: ProviderId::from("openai".to_string()), + model: ModelId::new("gpt-4o-mini"), + }), + ..Default::default() + }; + assert_eq!(actual, expected); + } + + // ── apply_op ────────────────────────────────────────────────────────────── + + #[test] + fn test_apply_op_key_info_some_sets_all_fields() { + let mut fixture = ForgeConfig::default(); + let login = LoginInfo { + api_key: "key-123".to_string(), + api_key_name: "prod".to_string(), + api_key_masked: "key-***".to_string(), + email: Some("dev@forge.dev".to_string()), + name: Some("Bob".to_string()), + auth_provider_id: Some("google".to_string()), + }; + apply_op(AppConfigOperation::KeyInfo(Some(login)), &mut fixture); + let expected = ForgeConfig::default() + .api_key("key-123".to_string()) + .api_key_name("prod".to_string()) + .api_key_masked("key-***".to_string()) + .email("dev@forge.dev".to_string()) + .name("Bob".to_string()) + .auth_provider_id("google".to_string()); + assert_eq!(fixture, expected); + } + + #[test] + fn test_apply_op_key_info_none_clears_all_fields() { + let mut fixture = ForgeConfig::default() + .api_key("key-abc".to_string()) + .api_key_name("old".to_string()) + .api_key_masked("old-***".to_string()) + .email("old@example.com".to_string()) + .name("Old Name".to_string()) + .auth_provider_id("github".to_string()); + apply_op(AppConfigOperation::KeyInfo(None), &mut fixture); + assert_eq!(fixture, ForgeConfig::default()); + } + + #[test] + fn test_apply_op_set_provider_creates_session_when_absent() { + let mut fixture = ForgeConfig::default(); + apply_op( + AppConfigOperation::SetProvider(ProviderId::from("anthropic".to_string())), + &mut fixture, + ); + let expected = ForgeConfig { + session: Some(ModelConfig::default().provider_id("anthropic".to_string())), + ..Default::default() + }; + assert_eq!(fixture, expected); + } + + #[test] + fn test_apply_op_set_provider_updates_existing_session_keeping_model() { + let mut fixture = ForgeConfig { + session: Some( + ModelConfig::default() + .provider_id("openai".to_string()) + .model_id("gpt-4".to_string()), + ), + ..Default::default() + }; + apply_op( + AppConfigOperation::SetProvider(ProviderId::from("anthropic".to_string())), + &mut fixture, + ); + let expected = ForgeConfig { + session: Some( + ModelConfig::default() + .provider_id("anthropic".to_string()) + .model_id("gpt-4".to_string()), + ), + ..Default::default() + }; + assert_eq!(fixture, expected); + } + + #[test] + fn test_apply_op_set_model_for_matching_provider_updates_model() { + let mut fixture = ForgeConfig { + session: Some( + ModelConfig::default() + .provider_id("openai".to_string()) + .model_id("gpt-3.5".to_string()), + ), + ..Default::default() + }; + apply_op( + AppConfigOperation::SetModel( + ProviderId::from("openai".to_string()), + ModelId::new("gpt-4"), + ), + &mut fixture, + ); + let expected = ForgeConfig { + session: Some( + ModelConfig::default() + .provider_id("openai".to_string()) + .model_id("gpt-4".to_string()), + ), + ..Default::default() + }; + assert_eq!(fixture, expected); + } + + #[test] + fn test_apply_op_set_model_for_different_provider_replaces_session() { + let mut fixture = ForgeConfig { + session: Some( + ModelConfig::default() + .provider_id("openai".to_string()) + .model_id("gpt-4".to_string()), + ), + ..Default::default() + }; + apply_op( + AppConfigOperation::SetModel( + ProviderId::from("anthropic".to_string()), + ModelId::new("claude-3"), + ), + &mut fixture, + ); + let expected = ForgeConfig { + session: Some( + ModelConfig::default() + .provider_id("anthropic".to_string()) + .model_id("claude-3".to_string()), + ), + ..Default::default() + }; + assert_eq!(fixture, expected); + } + + #[test] + fn test_apply_op_set_commit_config() { + let mut fixture = ForgeConfig::default(); + let commit = CommitConfig::default() + .provider(ProviderId::from("openai".to_string())) + .model(ModelId::new("gpt-4o")); + apply_op(AppConfigOperation::SetCommitConfig(commit), &mut fixture); + let expected = ForgeConfig { + commit: Some( + ModelConfig::default() + .provider_id("openai".to_string()) + .model_id("gpt-4o".to_string()), + ), + ..Default::default() + }; + assert_eq!(fixture, expected); + } + + #[test] + fn test_apply_op_set_suggest_config() { + let mut fixture = ForgeConfig::default(); + let suggest = SuggestConfig { + provider: ProviderId::from("anthropic".to_string()), + model: ModelId::new("claude-3-haiku"), + }; + apply_op(AppConfigOperation::SetSuggestConfig(suggest), &mut fixture); + let expected = ForgeConfig { + suggest: Some( + ModelConfig::default() + .provider_id("anthropic".to_string()) + .model_id("claude-3-haiku".to_string()), + ), + ..Default::default() + }; + assert_eq!(fixture, expected); + } +} + /// Repository for managing application configuration with caching support. /// /// Uses [`ForgeConfig::read`] and [`ForgeConfig::write`] for all file I/O and From 45d333ea46298aa7da670cee97d90906f02a5062 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Wed, 25 Mar 2026 15:00:11 +0000 Subject: [PATCH 66/67] [autofix.ci] apply automated fixes --- crates/forge_config/src/config.rs | 37 ++++--- crates/forge_config/src/legacy.rs | 6 +- crates/forge_config/src/reader.rs | 43 +++++--- crates/forge_repo/src/app_config.rs | 153 ++++++++++++++-------------- 4 files changed, 133 insertions(+), 106 deletions(-) diff --git a/crates/forge_config/src/config.rs b/crates/forge_config/src/config.rs index 077cba55ce..37862df654 100644 --- a/crates/forge_config/src/config.rs +++ b/crates/forge_config/src/config.rs @@ -11,7 +11,8 @@ use crate::{ TopP, Update, }; -/// Top-level Forge configuration merged from all sources (defaults, file, environment). +/// Top-level Forge configuration merged from all sources (defaults, file, +/// environment). #[derive(Default, Debug, Setters, Clone, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] #[setters(strip_option)] @@ -66,7 +67,8 @@ pub struct ForgeConfig { pub max_parallel_file_reads: usize, /// TTL in seconds for the model API list cache pub model_cache_ttl_secs: u64, - /// Default model and provider configuration used when not overridden by individual agents. + /// Default model and provider configuration used when not overridden by + /// individual agents. #[serde(default)] pub session: Option, /// Provider and model to use for commit message generation @@ -99,23 +101,28 @@ pub struct ForgeConfig { #[serde(skip_serializing_if = "Option::is_none")] pub updates: Option, - /// Output randomness for all agents; lower values are deterministic, higher values are creative (0.0–2.0). + /// Output randomness for all agents; lower values are deterministic, higher + /// values are creative (0.0–2.0). #[serde(default, skip_serializing_if = "Option::is_none")] pub temperature: Option, - /// Nucleus sampling threshold for all agents; limits token selection to the top cumulative probability mass (0.0–1.0). + /// Nucleus sampling threshold for all agents; limits token selection to the + /// top cumulative probability mass (0.0–1.0). #[serde(default, skip_serializing_if = "Option::is_none")] pub top_p: Option, - /// Top-k vocabulary cutoff for all agents; restricts sampling to the k highest-probability tokens (1–1000). + /// Top-k vocabulary cutoff for all agents; restricts sampling to the k + /// highest-probability tokens (1–1000). #[serde(default, skip_serializing_if = "Option::is_none")] pub top_k: Option, - /// Maximum tokens the model may generate per response for all agents (1–100,000). + /// Maximum tokens the model may generate per response for all agents + /// (1–100,000). #[serde(default, skip_serializing_if = "Option::is_none")] pub max_tokens: Option, - /// Maximum tool failures per turn before the orchestrator forces completion. + /// Maximum tool failures per turn before the orchestrator forces + /// completion. #[serde(default, skip_serializing_if = "Option::is_none")] pub max_tool_failure_per_turn: Option, @@ -123,31 +130,35 @@ pub struct ForgeConfig { #[serde(default, skip_serializing_if = "Option::is_none")] pub max_requests_per_turn: Option, - /// Context compaction settings applied to all agents; falls back to each agent's individual setting when absent. + /// Context compaction settings applied to all agents; falls back to each + /// agent's individual setting when absent. #[serde(default, skip_serializing_if = "Option::is_none")] pub compact: Option, } impl ForgeConfig { - /// Reads and merges configuration from all sources, returning the resolved [`ForgeConfig`]. + /// Reads and merges configuration from all sources, returning the resolved + /// [`ForgeConfig`]. /// /// # Errors /// - /// Returns an error if the config path cannot be resolved, the file cannot be read, or deserialization fails. + /// Returns an error if the config path cannot be resolved, the file cannot + /// be read, or deserialization fails. pub fn read() -> crate::Result { - Ok(ConfigReader::default() + ConfigReader::default() .read_defaults() .read_legacy() .read_global() .read_env() - .build()?) + .build() } /// Writes the configuration to the user config file. /// /// # Errors /// - /// Returns an error if the configuration cannot be serialized or written to disk. + /// Returns an error if the configuration cannot be serialized or written to + /// disk. pub fn write(&self) -> crate::Result<()> { let path = ConfigReader::config_path(); ConfigWriter::new(self.clone()).write(&path) diff --git a/crates/forge_config/src/legacy.rs b/crates/forge_config/src/legacy.rs index bf23c92325..7310333814 100644 --- a/crates/forge_config/src/legacy.rs +++ b/crates/forge_config/src/legacy.rs @@ -1,8 +1,10 @@ -use crate::{ForgeConfig, ModelConfig}; -use serde::Deserialize; use std::collections::HashMap; use std::path::PathBuf; +use serde::Deserialize; + +use crate::{ForgeConfig, ModelConfig}; + /// Intermediate representation of the legacy `~/forge/.config.json` format. /// /// This format stores the active provider as a top-level string and models as diff --git a/crates/forge_config/src/reader.rs b/crates/forge_config/src/reader.rs index 2780f607cc..786496bc08 100644 --- a/crates/forge_config/src/reader.rs +++ b/crates/forge_config/src/reader.rs @@ -1,12 +1,15 @@ -use crate::ForgeConfig; -use crate::legacy::LegacyConfig; -use config::ConfigBuilder; -use config::builder::DefaultState; use std::path::PathBuf; use std::sync::LazyLock; -/// Loads all `.env` files found while walking up from the current working directory to the root, -/// with priority given to closer (lower) directories. Executed at most once per process. +use config::ConfigBuilder; +use config::builder::DefaultState; + +use crate::ForgeConfig; +use crate::legacy::LegacyConfig; + +/// Loads all `.env` files found while walking up from the current working +/// directory to the root, with priority given to closer (lower) directories. +/// Executed at most once per process. static LOAD_DOT_ENV: LazyLock<()> = LazyLock::new(|| { let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")); let mut paths = vec![]; @@ -34,12 +37,14 @@ pub struct ConfigReader { } impl ConfigReader { - /// Returns the path to the legacy JSON config file (`~/.forge/.config.json`). + /// Returns the path to the legacy JSON config file + /// (`~/.forge/.config.json`). pub fn config_legacy_path() -> PathBuf { Self::base_path().join(".config.json") } - /// Returns the path to the primary TOML config file (`~/.forge/.forge.toml`). + /// Returns the path to the primary TOML config file + /// (`~/.forge/.forge.toml`). pub fn config_path() -> PathBuf { Self::base_path().join(".forge.toml") } @@ -49,7 +54,8 @@ impl ConfigReader { dirs::home_dir().unwrap_or(PathBuf::from(".")).join("forge") } - /// Adds the provided TOML string as a config source without touching the filesystem. + /// Adds the provided TOML string as a config source without touching the + /// filesystem. pub fn read_toml(mut self, contents: &str) -> Self { self.builder = self .builder @@ -82,26 +88,29 @@ impl ConfigReader { /// Builds and deserializes all accumulated sources into a [`ForgeConfig`]. /// - /// Triggers `.env` file loading (at most once per process) by walking up the directory tree - /// from the current working directory, with closer directories taking priority. + /// Triggers `.env` file loading (at most once per process) by walking up + /// the directory tree from the current working directory, with closer + /// directories taking priority. /// /// # Errors /// /// Returns an error if the configuration cannot be built or deserialized. pub fn build(self) -> crate::Result { - let _ = *LOAD_DOT_ENV; + *LOAD_DOT_ENV; let config = self.builder.build()?; Ok(config.try_deserialize::()?) } - /// Adds `~/.forge/.forge.toml` as a config source, silently skipping if absent. + /// Adds `~/.forge/.forge.toml` as a config source, silently skipping if + /// absent. pub fn read_global(mut self) -> Self { let path = Self::config_path(); self.builder = self.builder.add_source(config::File::from(path)); self } - /// Reads `~/.forge/.config.json` (legacy format) and adds it as a source, silently skipping errors. + /// Reads `~/.forge/.config.json` (legacy format) and adds it as a source, + /// silently skipping errors. pub fn read_legacy(self) -> Self { let content = LegacyConfig::read(&Self::config_legacy_path()); if let Ok(content) = content { @@ -124,14 +133,16 @@ mod tests { /// Serializes tests that mutate environment variables to prevent races. static ENV_MUTEX: Mutex<()> = Mutex::new(()); - /// Holds env vars set for a test's duration and removes them on drop, while holding [`ENV_MUTEX`]. + /// Holds env vars set for a test's duration and removes them on drop, while + /// holding [`ENV_MUTEX`]. struct EnvGuard { keys: Vec<&'static str>, _lock: MutexGuard<'static, ()>, } impl EnvGuard { - /// Sets each `(key, value)` pair in the environment, returning a guard that cleans them up on drop. + /// Sets each `(key, value)` pair in the environment, returning a guard + /// that cleans them up on drop. #[must_use] fn set(pairs: &[(&'static str, &str)]) -> Self { let lock = ENV_MUTEX.lock().unwrap_or_else(|e| e.into_inner()); diff --git a/crates/forge_repo/src/app_config.rs b/crates/forge_repo/src/app_config.rs index 26367325d3..44b67ba017 100644 --- a/crates/forge_repo/src/app_config.rs +++ b/crates/forge_repo/src/app_config.rs @@ -109,6 +109,80 @@ fn apply_op(op: AppConfigOperation, fc: &mut ForgeConfig) { } } +/// Repository for managing application configuration with caching support. +/// +/// Uses [`ForgeConfig::read`] and [`ForgeConfig::write`] for all file I/O and +/// maintains an in-memory cache to reduce disk access. +pub struct ForgeConfigRepository { + cache: Arc>>, +} + +impl ForgeConfigRepository { + pub fn new() -> Self { + Self { cache: Arc::new(Mutex::new(None)) } + } + + /// Reads [`AppConfig`] from disk via [`ForgeConfig::read`]. + async fn read(&self) -> ForgeConfig { + let config = ForgeConfig::read(); + + match config { + Ok(config) => { + debug!(config = ?config, "read .forge.toml"); + config + } + Err(e) => { + // NOTE: This should never-happen + error!(error = ?e, "Failed to read config file. Using default config."); + Default::default() + } + } + } +} + +#[async_trait::async_trait] +impl AppConfigRepository for ForgeConfigRepository { + async fn get_app_config(&self) -> anyhow::Result { + // Check cache first + let cache = self.cache.lock().await; + if let Some(ref config) = *cache { + return Ok(forge_config_to_app_config(config.clone())); + } + drop(cache); + + // Cache miss, read from file + let config = self.read().await; + + let mut cache = self.cache.lock().await; + *cache = Some(config.clone()); + + Ok(forge_config_to_app_config(config)) + } + + async fn update_app_config(&self, ops: Vec) -> anyhow::Result<()> { + // Load the global config + let mut fc = ConfigReader::default().read_global().build()?; + + debug!(config = ?fc, "loaded config for update"); + + // Apply each operation directly onto ForgeConfig + debug!(?ops, "applying app config operations"); + for op in ops { + apply_op(op, &mut fc); + } + + // Persist + fc.write()?; + debug!(config = ?fc, "written .forge.toml"); + + // Reset cache + let mut cache = self.cache.lock().await; + *cache = None; + + Ok(()) + } +} + #[cfg(test)] mod tests { use std::collections::HashMap; @@ -217,7 +291,10 @@ mod tests { suggest: Some(ModelConfig::default().provider_id("openai".to_string())), ..Default::default() }; - assert_eq!(forge_config_to_app_config(fixture_provider_only).suggest, None); + assert_eq!( + forge_config_to_app_config(fixture_provider_only).suggest, + None + ); let fixture_model_only = ForgeConfig { suggest: Some(ModelConfig { model_id: Some("gpt-4o".to_string()), provider_id: None }), @@ -416,77 +493,3 @@ mod tests { assert_eq!(fixture, expected); } } - -/// Repository for managing application configuration with caching support. -/// -/// Uses [`ForgeConfig::read`] and [`ForgeConfig::write`] for all file I/O and -/// maintains an in-memory cache to reduce disk access. -pub struct ForgeConfigRepository { - cache: Arc>>, -} - -impl ForgeConfigRepository { - pub fn new() -> Self { - Self { cache: Arc::new(Mutex::new(None)) } - } - - /// Reads [`AppConfig`] from disk via [`ForgeConfig::read`]. - async fn read(&self) -> ForgeConfig { - let config = ForgeConfig::read(); - - match config { - Ok(config) => { - debug!(config = ?config, "read .forge.toml"); - config - } - Err(e) => { - // NOTE: This should never-happen - error!(error = ?e, "Failed to read config file. Using default config."); - Default::default() - } - } - } -} - -#[async_trait::async_trait] -impl AppConfigRepository for ForgeConfigRepository { - async fn get_app_config(&self) -> anyhow::Result { - // Check cache first - let cache = self.cache.lock().await; - if let Some(ref config) = *cache { - return Ok(forge_config_to_app_config(config.clone())); - } - drop(cache); - - // Cache miss, read from file - let config = self.read().await; - - let mut cache = self.cache.lock().await; - *cache = Some(config.clone()); - - Ok(forge_config_to_app_config(config)) - } - - async fn update_app_config(&self, ops: Vec) -> anyhow::Result<()> { - // Load the global config - let mut fc = ConfigReader::default().read_global().build()?; - - debug!(config = ?fc, "loaded config for update"); - - // Apply each operation directly onto ForgeConfig - debug!(?ops, "applying app config operations"); - for op in ops { - apply_op(op, &mut fc); - } - - // Persist - fc.write()?; - debug!(config = ?fc, "written .forge.toml"); - - // Reset cache - let mut cache = self.cache.lock().await; - *cache = None; - - Ok(()) - } -} From 20529e9876be863a859ef36e40ab768e5d2eb1f2 Mon Sep 17 00:00:00 2001 From: Tushar Date: Wed, 25 Mar 2026 20:42:25 +0530 Subject: [PATCH 67/67] refactor(shell-plugin): execute model configuration set directly --- shell-plugin/lib/actions/config.zsh | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/shell-plugin/lib/actions/config.zsh b/shell-plugin/lib/actions/config.zsh index d6a8dcca82..a05a331b72 100644 --- a/shell-plugin/lib/actions/config.zsh +++ b/shell-plugin/lib/actions/config.zsh @@ -175,7 +175,8 @@ function _forge_action_model() { if [[ -n "$provider_display" && "$provider_display" != "$current_provider" ]]; then _forge_exec_interactive config set provider "$provider_id" --model "$model_id" return - fi + fi + _forge_exec config set model "$model_id" fi ) }