diff --git a/crates/forge_config/.forge.toml b/crates/forge_config/config.toml similarity index 100% rename from crates/forge_config/.forge.toml rename to crates/forge_config/config.toml diff --git a/crates/forge_config/src/legacy.rs b/crates/forge_config/src/legacy.rs index 22f35ce52b..75954ea98e 100644 --- a/crates/forge_config/src/legacy.rs +++ b/crates/forge_config/src/legacy.rs @@ -5,7 +5,7 @@ use serde::Deserialize; use crate::{ForgeConfig, ModelConfig}; -/// Intermediate representation of the legacy `~/forge/.config.json` format. +/// 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 @@ -35,7 +35,7 @@ struct LegacyModelRef { } impl LegacyConfig { - /// Reads the legacy `~/forge/.config.json` file at `path`, parses it, and + /// Reads the legacy `~/.forge/.config.json` file at `path`, parses it, and /// returns the equivalent TOML representation as a [`String`]. /// /// Because every field in [`ForgeConfig`] is `Option`, fields not covered diff --git a/crates/forge_config/src/reader.rs b/crates/forge_config/src/reader.rs index ea74746de6..c0f9710f29 100644 --- a/crates/forge_config/src/reader.rs +++ b/crates/forge_config/src/reader.rs @@ -1,8 +1,9 @@ -use std::path::PathBuf; -use std::sync::LazyLock; +use std::path::{Path, PathBuf}; +use std::sync::{LazyLock, OnceLock}; use config::ConfigBuilder; use config::builder::DefaultState; +use tracing::warn; use crate::ForgeConfig; use crate::legacy::LegacyConfig; @@ -30,6 +31,67 @@ static LOAD_DOT_ENV: LazyLock<()> = LazyLock::new(|| { } }); +static MIGRATE_CONFIG_PATHS: OnceLock<()> = OnceLock::new(); + +fn old_base_path() -> PathBuf { + dirs::home_dir().unwrap_or(PathBuf::from(".")).join("forge") +} + +fn new_base_path() -> PathBuf { + dirs::home_dir() + .unwrap_or(PathBuf::from(".")) + .join(".forge") +} + +fn migrate_file(old_path: &Path, new_path: &Path) -> std::io::Result<()> { + if new_path.exists() || !old_path.exists() { + return Ok(()); + } + + if let Some(parent) = new_path.parent() { + std::fs::create_dir_all(parent)?; + } + + std::fs::rename(old_path, new_path)?; + Ok(()) +} + +fn migrate_base_dir(old_base: &Path, new_base: &Path) -> std::io::Result<()> { + if new_base.exists() || !old_base.exists() { + return Ok(()); + } + + std::fs::rename(old_base, new_base)?; + Ok(()) +} + +fn migrate_paths() { + MIGRATE_CONFIG_PATHS.get_or_init(|| { + let old_base = old_base_path(); + let new_base = new_base_path(); + + if let Err(error) = migrate_base_dir(&old_base, &new_base) { + warn!( + error = ?error, + old_base = %old_base.display(), + new_base = %new_base.display(), + "Failed to migrate Forge base directory" + ); + } + + let old_config = new_base.join(".forge.toml"); + let new_config = new_base.join("config.toml"); + if let Err(error) = migrate_file(&old_config, &new_config) { + warn!( + error = ?error, + old_config = %old_config.display(), + new_config = %new_config.display(), + "Failed to migrate Forge config file" + ); + } + }); +} + /// Merges [`ForgeConfig`] from layered sources using a builder pattern. #[derive(Default)] pub struct ConfigReader { @@ -40,18 +102,21 @@ impl ConfigReader { /// Returns the path to the legacy JSON config file /// (`~/.forge/.config.json`). pub fn config_legacy_path() -> PathBuf { + migrate_paths(); Self::base_path().join(".config.json") } /// Returns the path to the primary TOML config file - /// (`~/.forge/.forge.toml`). + /// (`~/.forge/config.toml`). pub fn config_path() -> PathBuf { - Self::base_path().join(".forge.toml") + migrate_paths(); + Self::base_path().join("config.toml") } - /// Returns the base directory for all Forge config files (`~/forge`). + /// Returns the base directory for all Forge config files (`~/.forge`). pub fn base_path() -> PathBuf { - dirs::home_dir().unwrap_or(PathBuf::from(".")).join("forge") + migrate_paths(); + new_base_path() } /// Adds the provided TOML string as a config source without touching the @@ -64,9 +129,9 @@ impl ConfigReader { self } - /// Adds the embedded default config (`../.forge.toml`) as a source. + /// Adds the embedded default config (`../config.toml`) as a source. pub fn read_defaults(self) -> Self { - let defaults = include_str!("../.forge.toml"); + let defaults = include_str!("../config.toml"); self.read_toml(defaults) } @@ -101,7 +166,7 @@ impl ConfigReader { Ok(config.try_deserialize::()?) } - /// Adds `~/.forge/.forge.toml` as a config source, silently skipping if + /// Adds `~/.forge/config.toml` as a config source, silently skipping if /// absent. pub fn read_global(mut self) -> Self { let path = Self::config_path(); @@ -125,7 +190,9 @@ impl ConfigReader { #[cfg(test)] mod tests { + use std::fs; use std::sync::{Mutex, MutexGuard}; + use std::time::{SystemTime, UNIX_EPOCH}; use pretty_assertions::assert_eq; @@ -170,6 +237,52 @@ mod tests { assert!(actual.is_ok(), "read() failed: {:?}", actual.err()); } + fn temp_fixture_dir(name: &str) -> PathBuf { + let nonce = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + let dir = std::env::temp_dir().join(format!("forge-config-{name}-{nonce}")); + fs::create_dir_all(&dir).unwrap(); + dir + } + + #[test] + fn test_migrate_base_dir_moves_legacy_forge_directory() { + let fixture = temp_fixture_dir("base-dir"); + let old_base = fixture.join("forge"); + let new_base = fixture.join(".forge"); + fs::create_dir_all(&old_base).unwrap(); + fs::write(old_base.join("marker.txt"), "migrated").unwrap(); + + let actual = migrate_base_dir(&old_base, &new_base); + actual.unwrap(); + let expected = "migrated"; + + assert!(!old_base.exists()); + assert_eq!( + fs::read_to_string(new_base.join("marker.txt")).unwrap(), + expected + ); + fs::remove_dir_all(fixture).unwrap(); + } + + #[test] + fn test_migrate_file_renames_dot_forge_toml_to_config_toml() { + let fixture = temp_fixture_dir("config-file"); + let old_path = fixture.join(".forge.toml"); + let new_path = fixture.join("config.toml"); + fs::write(&old_path, "key = 'value'").unwrap(); + + let actual = migrate_file(&old_path, &new_path); + actual.unwrap(); + let expected = "key = 'value'"; + + assert!(!old_path.exists()); + assert_eq!(fs::read_to_string(new_path).unwrap(), expected); + fs::remove_dir_all(fixture).unwrap(); + } + #[test] fn test_legacy_layer_does_not_overwrite_defaults() { // Simulate what `read_legacy` does: serialize a ForgeConfig that only @@ -200,7 +313,7 @@ mod tests { }) ); - // Default values from .forge.toml must be retained, not reset to zero + // Default values from config.toml must be retained, not reset to zero assert_eq!(actual.max_parallel_file_reads, 64); assert_eq!(actual.max_read_lines, 2000); assert_eq!(actual.tool_timeout_secs, 300); diff --git a/crates/forge_domain/src/env.rs b/crates/forge_domain/src/env.rs index fac789da27..c69e0e4f66 100644 --- a/crates/forge_domain/src/env.rs +++ b/crates/forge_domain/src/env.rs @@ -124,7 +124,7 @@ impl Environment { self.base_path.join("cache") } - /// Returns the global skills directory path (~/forge/skills) + /// Returns the global skills directory path (~/.forge/skills) pub fn global_skills_path(&self) -> PathBuf { self.base_path.join("skills") } diff --git a/crates/forge_infra/src/env.rs b/crates/forge_infra/src/env.rs index 902198b447..3154be3392 100644 --- a/crates/forge_infra/src/env.rs +++ b/crates/forge_infra/src/env.rs @@ -14,6 +14,8 @@ use tracing::{debug, error}; /// configuration values are now accessed through /// `EnvironmentInfra::get_config()`. fn to_environment(cwd: PathBuf) -> Environment { + let base_path = ConfigReader::base_path(); + Environment { os: std::env::consts::OS.to_string(), pid: std::process::id(), @@ -24,9 +26,7 @@ fn to_environment(cwd: PathBuf) -> Environment { } else { std::env::var("SHELL").unwrap_or_else(|_| "/bin/sh".to_string()) }, - base_path: dirs::home_dir() - .map(|h| h.join("forge")) - .unwrap_or_else(|| PathBuf::from(".").join("forge")), + base_path, } } @@ -109,7 +109,7 @@ impl ForgeEnvironmentInfra { fn read_from_disk() -> ForgeConfig { match ForgeConfig::read() { Ok(config) => { - debug!(config = ?config, "read .forge.toml"); + debug!(config = ?config, "read config.toml"); config } Err(e) => { @@ -167,7 +167,7 @@ impl EnvironmentInfra for ForgeEnvironmentInfra { } fc.write()?; - debug!(config = ?fc, "written .forge.toml"); + debug!(config = ?fc, "written config.toml"); // Reset cache *self.cache.lock().expect("cache mutex poisoned") = None; @@ -192,6 +192,14 @@ mod tests { assert_eq!(actual.cwd, fixture_cwd); } + #[test] + fn test_to_environment_uses_forge_base_path() { + let fixture = PathBuf::from("/test/cwd"); + let actual = to_environment(fixture); + let expected = ConfigReader::base_path(); + assert_eq!(actual.base_path, expected); + } + #[test] fn test_apply_config_op_set_provider() { use forge_domain::ProviderId; diff --git a/crates/forge_main/src/built_in_commands.json b/crates/forge_main/src/built_in_commands.json index beead7cb9f..aa6008a84f 100644 --- a/crates/forge_main/src/built_in_commands.json +++ b/crates/forge_main/src/built_in_commands.json @@ -45,7 +45,7 @@ }, { "command": "config-edit", - "description": "Open the global forge config file (~/forge/.forge.toml) in an editor [alias: ce]" + "description": "Open the global forge config file (~/.forge/config.toml) in an editor [alias: ce]" }, { "command": "new", diff --git a/crates/forge_repo/src/skill.rs b/crates/forge_repo/src/skill.rs index fcbef3bc79..2fc156cb51 100644 --- a/crates/forge_repo/src/skill.rs +++ b/crates/forge_repo/src/skill.rs @@ -11,7 +11,7 @@ use serde::Deserialize; /// Repository implementation for loading skills from multiple sources: /// 1. Built-in skills (embedded in the application) -/// 2. Global custom skills (from ~/forge/skills/ directory) +/// 2. Global custom skills (from ~/.forge/skills/ directory) /// 3. Project-local skills (from .forge/skills/ directory in current working /// directory) /// @@ -24,7 +24,7 @@ use serde::Deserialize; /// /// ## Directory Resolution /// - **Built-in skills**: Embedded in application binary -/// - **Global skills**: `~/forge/skills//SKILL.md` +/// - **Global skills**: `~/.forge/skills//SKILL.md` /// - **CWD skills**: `./.forge/skills//SKILL.md` (relative to /// current working directory) /// diff --git a/shell-plugin/lib/actions/config.zsh b/shell-plugin/lib/actions/config.zsh index c7869e3c2a..aec4313fa3 100644 --- a/shell-plugin/lib/actions/config.zsh +++ b/shell-plugin/lib/actions/config.zsh @@ -421,7 +421,7 @@ function _forge_action_reasoning_effort() { # Action handler: Set reasoning effort in global config. # Calls `forge config set reasoning-effort ` on selection, -# writing the chosen effort level permanently to ~/forge/.forge.toml. +# writing the chosen effort level permanently to ~/.forge/config.toml. function _forge_action_config_reasoning_effort() { local input_text="$1" ( @@ -474,12 +474,12 @@ function _forge_action_config_edit() { return 1 fi - local config_file="${HOME}/forge/.forge.toml" + local config_file="${HOME}/.forge/config.toml" # Ensure the config directory exists - if [[ ! -d "${HOME}/forge" ]]; then - mkdir -p "${HOME}/forge" || { - _forge_log error "Failed to create ~/forge directory" + if [[ ! -d "${HOME}/.forge" ]]; then + mkdir -p "${HOME}/.forge" || { + _forge_log error "Failed to create ~/.forge directory" return 1 } fi