Repository: alacritty/alacritty (language: rust)

Issue:
Move `GENERAL` options into `[general]` section
The fact that this section should be at the top is confusing for some users, thus make a dedicated section for it and write an automatic migration rules to move things into it.

Certain fields, like `shell` could be moved into other sections, like `[terminal]`, however things like `live_config_reload` can not, and are enabled for the whole instance.

Maybe `[instance]` section could be used instead, but it feels not general enough.


Discussion / hints:

Fix patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 4d64bdf6897..dc858911b81 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -17,12 +17,16 @@ Notable changes to the `alacritty_terminal` crate are documented in its
 ### Added
 
 - Support relative path imports from config files
+- `alacritty migrate` support for TOML configuration changes
 
 ### Changed
 
 - Pressing `Alt` with unicode input will now add `ESC` like for ASCII input
 - Decorations use opaque style and system window background on macOS
 - No longer source `~/.zshenv` on macOS
+- Moved config options `import`, `working_directory`, `live_config_reload`, and `ipc_socket`
+    to the new `general` section
+- Moved config option `shell` to `terminal.shell`
 
 ### Fixed
 
diff --git a/Cargo.lock b/Cargo.lock
index 566dbc08d55..39a672fd9fb 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -58,7 +58,9 @@ dependencies = [
  "serde",
  "serde_json",
  "serde_yaml",
+ "tempfile",
  "toml",
+ "toml_edit 0.22.21",
  "unicode-width",
  "windows-sys 0.52.0",
  "winit",
@@ -891,9 +893,9 @@ dependencies = [
 
 [[package]]
 name = "indexmap"
-version = "2.2.6"
+version = "2.5.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "168fb715dda47215e360912c096649d23d58bf392ac62f73919e831745e40f26"
+checksum = "68b900aa2f7301e21c36462b170ee99994de34dff39a4a6a528e80e7376d07e5"
 dependencies = [
  "equivalent",
  "hashbrown",
@@ -1749,9 +1751,9 @@ dependencies = [
 
 [[package]]
 name = "serde_spanned"
-version = "0.6.6"
+version = "0.6.7"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "79e674e01f999af37c49f70a6ede167a8a60b2503e56c5599532a65baa5969a0"
+checksum = "eb5b1b31579f3811bf615c144393417496f152e12ac8b7663bf664f4a815306d"
 dependencies = [
  "serde",
 ]
@@ -1877,6 +1879,19 @@ dependencies = [
  "unicode-ident",
 ]
 
+[[package]]
+name = "tempfile"
+version = "3.12.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "04cbcdd0c794ebb0d4cf35e88edd2f7d2c4c3e9a5a6dab322839b321c6a87a64"
+dependencies = [
+ "cfg-if",
+ "fastrand",
+ "once_cell",
+ "rustix",
+ "windows-sys 0.59.0",
+]
+
 [[package]]
 name = "thiserror"
 version = "1.0.62"
@@ -1931,14 +1946,14 @@ dependencies = [
  "serde",
  "serde_spanned",
  "toml_datetime",
- "toml_edit 0.22.15",
+ "toml_edit 0.22.21",
 ]
 
 [[package]]
 name = "toml_datetime"
-version = "0.6.6"
+version = "0.6.8"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "4badfd56924ae69bcc9039335b2e017639ce3f9b001c393c1b2d1ef846ce2cbf"
+checksum = "0dd7358ecb8fc2f8d014bf86f6f638ce72ba252a2c3a2572f2a795f1d23efb41"
 dependencies = [
  "serde",
 ]
@@ -1956,15 +1971,15 @@ dependencies = [
 
 [[package]]
 name = "toml_edit"
-version = "0.22.15"
+version = "0.22.21"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d59a3a72298453f564e2b111fa896f8d07fabb36f51f06d7e875fc5e0b5a3ef1"
+checksum = "3b072cee73c449a636ffd6f32bd8de3a9f7119139aff882f44943ce2986dc5cf"
 dependencies = [
  "indexmap",
  "serde",
  "serde_spanned",
  "toml_datetime",
- "winnow 0.6.13",
+ "winnow 0.6.18",
 ]
 
 [[package]]
@@ -2332,6 +2347,15 @@ dependencies = [
  "windows-targets 0.52.6",
 ]
 
+[[package]]
+name = "windows-sys"
+version = "0.59.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b"
+dependencies = [
+ "windows-targets 0.52.6",
+]
+
 [[package]]
 name = "windows-targets"
 version = "0.42.2"
@@ -2574,9 +2598,9 @@ dependencies = [
 
 [[package]]
 name = "winnow"
-version = "0.6.13"
+version = "0.6.18"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "59b5e5f6c299a3c7890b876a2a587f3115162487e704907d9b6cd29473052ba1"
+checksum = "68a9bda4691f099d435ad181000724da8e5899daa10713c2d432552b9ccd3a6f"
 dependencies = [
  "memchr",
 ]
diff --git a/alacritty/Cargo.toml b/alacritty/Cargo.toml
index e8739049f1c..ab44b0468bd 100644
--- a/alacritty/Cargo.toml
+++ b/alacritty/Cargo.toml
@@ -34,10 +34,12 @@ libc = "0.2"
 log = { version = "0.4", features = ["std", "serde"] }
 notify = "6.1.1"
 parking_lot = "0.12.0"
-serde = { version = "1", features = ["derive"] }
 serde_json = "1"
+serde = { version = "1", features = ["derive"] }
 serde_yaml = "0.9.25"
+tempfile = "3.12.0"
 toml = "0.8.2"
+toml_edit = "0.22.21"
 unicode-width = "0.1"
 winit = { version = "0.30.4", default-features = false, features = ["rwh_06", "serde"] }
 
diff --git a/alacritty/src/cli.rs b/alacritty/src/cli.rs
index f0c9be7ee3a..2b4afa02db2 100644
--- a/alacritty/src/cli.rs
+++ b/alacritty/src/cli.rs
@@ -88,8 +88,8 @@ impl Options {
     /// Override configuration file with options from the CLI.
     pub fn override_config(&mut self, config: &mut UiConfig) {
         #[cfg(unix)]
-        {
-            config.ipc_socket |= self.socket.is_some();
+        if self.socket.is_some() {
+            config.ipc_socket = Some(true);
         }
 
         config.window.embed = self.embed.as_ref().and_then(|embed| parse_hex_or_decimal(embed));
diff --git a/alacritty/src/config/general.rs b/alacritty/src/config/general.rs
new file mode 100644
index 00000000000..ba559262bb7
--- /dev/null
+++ b/alacritty/src/config/general.rs
@@ -0,0 +1,39 @@
+//! Miscellaneous configuration options.
+
+use std::path::PathBuf;
+
+use alacritty_config_derive::ConfigDeserialize;
+
+/// General config section.
+///
+/// This section is for fields which can not be easily categorized,
+/// to avoid common TOML issues with root-level fields.
+#[derive(ConfigDeserialize, Clone, PartialEq, Debug)]
+pub struct General {
+    /// Configuration file imports.
+    ///
+    /// This is never read since the field is directly accessed through the config's
+    /// [`toml::Value`], but still present to prevent unused field warnings.
+    pub import: Vec<String>,
+
+    /// Shell startup directory.
+    pub working_directory: Option<PathBuf>,
+
+    /// Live config reload.
+    pub live_config_reload: bool,
+
+    /// Offer IPC through a unix socket.
+    #[allow(unused)]
+    pub ipc_socket: bool,
+}
+
+impl Default for General {
+    fn default() -> Self {
+        Self {
+            live_config_reload: true,
+            ipc_socket: true,
+            working_directory: Default::default(),
+            import: Default::default(),
+        }
+    }
+}
diff --git a/alacritty/src/config/mod.rs b/alacritty/src/config/mod.rs
index f8fccb13130..ba9d674deb9 100644
--- a/alacritty/src/config/mod.rs
+++ b/alacritty/src/config/mod.rs
@@ -15,6 +15,7 @@ pub mod color;
 pub mod cursor;
 pub mod debug;
 pub mod font;
+pub mod general;
 pub mod monitor;
 pub mod scrolling;
 pub mod selection;
@@ -278,15 +279,15 @@ fn load_imports(
     merged
 }
 
-// TODO: Merge back with `load_imports` once `alacritty migrate` is dropped.
-//
 /// Get all import paths for a configuration.
 pub fn imports(
     config: &Value,
     base_path: &Path,
     recursion_limit: usize,
 ) -> StdResult<Vec<StdResult<PathBuf, String>>, String> {
-    let imports = match config.get("import") {
+    let imports =
+        config.get("import").or_else(|| config.get("general").and_then(|g| g.get("import")));
+    let imports = match imports {
         Some(Value::Array(imports)) => imports,
         Some(_) => return Err("Invalid import type: expected a sequence".into()),
         None => return Ok(Vec::new()),
@@ -300,7 +301,7 @@ pub fn imports(
     let mut import_paths = Vec::new();
 
     for import in imports {
-        let mut path = match import {
+        let path = match import {
             Value::String(path) => PathBuf::from(path),
             _ => {
                 import_paths.push(Err("Invalid import element type: expected path string".into()));
@@ -308,23 +309,32 @@ pub fn imports(
             },
         };
 
-        // Resolve paths relative to user's home directory.
-        if let (Ok(stripped), Some(home_dir)) = (path.strip_prefix("~/"), home::home_dir()) {
-            path = home_dir.join(stripped);
-        }
-
-        if path.is_relative() {
-            if let Some(base_path) = base_path.parent() {
-                path = base_path.join(path)
-            }
-        }
+        let normalized = normalize_import(base_path, path);
 
-        import_paths.push(Ok(path));
+        import_paths.push(Ok(normalized));
     }
 
     Ok(import_paths)
 }
 
+/// Normalize import paths.
+pub fn normalize_import(base_config_path: &Path, import_path: impl Into<PathBuf>) -> PathBuf {
+    let mut import_path = import_path.into();
+
+    // Resolve paths relative to user's home directory.
+    if let (Ok(stripped), Some(home_dir)) = (import_path.strip_prefix("~/"), home::home_dir()) {
+        import_path = home_dir.join(stripped);
+    }
+
+    if import_path.is_relative() {
+        if let Some(base_config_dir) = base_config_path.parent() {
+            import_path = base_config_dir.join(import_path)
+        }
+    }
+
+    import_path
+}
+
 /// Prune the nulls from the YAML to ensure TOML compatibility.
 fn prune_yaml_nulls(value: &mut serde_yaml::Value, warn_pruned: bool) {
     fn walk(value: &mut serde_yaml::Value, warn_pruned: bool) -> bool {
diff --git a/alacritty/src/config/terminal.rs b/alacritty/src/config/terminal.rs
index b41af5db640..d0c0d9da49d 100644
--- a/alacritty/src/config/terminal.rs
+++ b/alacritty/src/config/terminal.rs
@@ -4,12 +4,14 @@ use toml::Value;
 use alacritty_config_derive::{ConfigDeserialize, SerdeReplace};
 use alacritty_terminal::term::Osc52;
 
-use crate::config::ui_config::StringVisitor;
+use crate::config::ui_config::{Program, StringVisitor};
 
-#[derive(ConfigDeserialize, Default, Copy, Clone, Debug, PartialEq)]
+#[derive(ConfigDeserialize, Default, Clone, Debug, PartialEq)]
 pub struct Terminal {
     /// OSC52 support mode.
     pub osc52: SerdeOsc52,
+    /// Path to a shell program to run on startup.
+    pub shell: Option<Program>,
 }
 
 #[derive(SerdeReplace, Default, Copy, Clone, Debug, PartialEq)]
diff --git a/alacritty/src/config/ui_config.rs b/alacritty/src/config/ui_config.rs
index a40dcaf8874..a6155b8adf6 100644
--- a/alacritty/src/config/ui_config.rs
+++ b/alacritty/src/config/ui_config.rs
@@ -26,7 +26,8 @@ use crate::config::color::Colors;
 use crate::config::cursor::Cursor;
 use crate::config::debug::Debug;
 use crate::config::font::Font;
-use crate::config::mouse::{Mouse, MouseBindings};
+use crate::config::general::General;
+use crate::config::mouse::Mouse;
 use crate::config::scrolling::Scrolling;
 use crate::config::selection::Selection;
 use crate::config::terminal::Terminal;
@@ -38,8 +39,11 @@ use crate::config::LOG_TARGET_CONFIG;
 const URL_REGEX: &str = "(ipfs:|ipns:|magnet:|mailto:|gemini://|gopher://|https://|http://|news:|file:|git://|ssh:|ftp://)\
                          [^\u{0000}-\u{001F}\u{007F}-\u{009F}<>\"\\s{-}\\^⟨⟩`]+";
 
-#[derive(ConfigDeserialize, Clone, Debug, PartialEq)]
+#[derive(ConfigDeserialize, Default, Clone, Debug, PartialEq)]
 pub struct UiConfig {
+    /// Miscellaneous configuration options.
+    pub general: General,
+
     /// Extra environment variables.
     pub env: HashMap<String, String>,
 
@@ -64,14 +68,6 @@ pub struct UiConfig {
     /// Debug options.
     pub debug: Debug,
 
-    /// Send escape sequences using the alt key.
-    #[config(removed = "It's now always set to 'true'. If you're on macOS use \
-                        'window.option_as_alt' to alter behavior of Option")]
-    pub alt_send_esc: Option<bool>,
-
-    /// Live config reload.
-    pub live_config_reload: bool,
-
     /// Bell configuration.
     pub bell: BellConfig,
 
@@ -85,70 +81,35 @@ pub struct UiConfig {
     /// Regex hints for interacting with terminal content.
     pub hints: Hints,
 
-    /// Offer IPC through a unix socket.
-    #[cfg(unix)]
-    pub ipc_socket: bool,
-
     /// Config for the alacritty_terminal itself.
     pub terminal: Terminal,
 
-    /// Path to a shell program to run on startup.
-    pub shell: Option<Program>,
-
-    /// Shell startup directory.
-    pub working_directory: Option<PathBuf>,
-
     /// Keyboard configuration.
     keyboard: Keyboard,
 
-    /// Should draw bold text with brighter colors instead of bold font.
-    #[config(deprecated = "use colors.draw_bold_text_with_bright_colors instead")]
-    draw_bold_text_with_bright_colors: bool,
-
-    /// Keybindings.
-    #[config(deprecated = "use keyboard.bindings instead")]
-    key_bindings: Option<KeyBindings>,
-
-    /// Bindings for the mouse.
-    #[config(deprecated = "use mouse.bindings instead")]
-    mouse_bindings: Option<MouseBindings>,
+    /// Path to a shell program to run on startup.
+    #[config(deprecated = "use terminal.shell instead")]
+    shell: Option<Program>,
 
     /// Configuration file imports.
     ///
     /// This is never read since the field is directly accessed through the config's
     /// [`toml::Value`], but still present to prevent unused field warnings.
-    import: Vec<String>,
-}
+    #[config(deprecated = "use general.import instead")]
+    import: Option<Vec<String>>,
 
-impl Default for UiConfig {
-    fn default() -> Self {
-        Self {
-            live_config_reload: true,
-            #[cfg(unix)]
-            ipc_socket: true,
-            draw_bold_text_with_bright_colors: Default::default(),
-            working_directory: Default::default(),
-            mouse_bindings: Default::default(),
-            config_paths: Default::default(),
-            key_bindings: Default::default(),
-            alt_send_esc: Default::default(),
-            scrolling: Default::default(),
-            selection: Default::default(),
-            keyboard: Default::default(),
-            terminal: Default::default(),
-            import: Default::default(),
-            cursor: Default::default(),
-            window: Default::default(),
-            colors: Default::default(),
-            shell: Default::default(),
-            mouse: Default::default(),
-            debug: Default::default(),
-            hints: Default::default(),
-            font: Default::default(),
-            bell: Default::default(),
-            env: Default::default(),
-        }
-    }
+    /// Shell startup directory.
+    #[config(deprecated = "use general.working_directory instead")]
+    working_directory: Option<PathBuf>,
+
+    /// Live config reload.
+    #[config(deprecated = "use general.live_config_reload instead")]
+    live_config_reload: Option<bool>,
+
+    /// Offer IPC through a unix socket.
+    #[cfg(unix)]
+    #[config(deprecated = "use general.ipc_socket instead")]
+    pub ipc_socket: Option<bool>,
 }
 
 impl UiConfig {
@@ -166,26 +127,14 @@ impl UiConfig {
 
     /// Derive [`PtyOptions`] from the config.
     pub fn pty_config(&self) -> PtyOptions {
-        let shell = self.shell.clone().map(Into::into);
-        PtyOptions {
-            shell,
-            working_directory: self.working_directory.clone(),
-            hold: false,
-            env: HashMap::new(),
-        }
+        let shell = self.terminal.shell.clone().or_else(|| self.shell.clone()).map(Into::into);
+        let working_directory =
+            self.working_directory.clone().or_else(|| self.general.working_directory.clone());
+        PtyOptions { working_directory, shell, hold: false, env: HashMap::new() }
     }
 
     /// Generate key bindings for all keyboard hints.
     pub fn generate_hint_bindings(&mut self) {
-        // Check which key bindings is most likely to be the user's configuration.
-        //
-        // Both will be non-empty due to the presence of the default keybindings.
-        let key_bindings = if let Some(key_bindings) = self.key_bindings.as_mut() {
-            &mut key_bindings.0
-        } else {
-            &mut self.keyboard.bindings.0
-        };
-
         for hint in &self.hints.enabled {
             let binding = match &hint.binding {
                 Some(binding) => binding,
@@ -200,7 +149,7 @@ impl UiConfig {
                 action: Action::Hint(hint.clone()),
             };
 
-            key_bindings.push(binding);
+            self.keyboard.bindings.0.push(binding);
         }
     }
 
@@ -211,25 +160,23 @@ impl UiConfig {
 
     #[inline]
     pub fn key_bindings(&self) -> &[KeyBinding] {
-        if let Some(key_bindings) = self.key_bindings.as_ref() {
-            &key_bindings.0
-        } else {
-            &self.keyboard.bindings.0
-        }
+        &self.keyboard.bindings.0
     }
 
     #[inline]
     pub fn mouse_bindings(&self) -> &[MouseBinding] {
-        if let Some(mouse_bindings) = self.mouse_bindings.as_ref() {
-            &mouse_bindings.0
-        } else {
-            &self.mouse.bindings.0
-        }
+        &self.mouse.bindings.0
     }
 
     #[inline]
-    pub fn draw_bold_text_with_bright_colors(&self) -> bool {
-        self.colors.draw_bold_text_with_bright_colors || self.draw_bold_text_with_bright_colors
+    pub fn live_config_reload(&self) -> bool {
+        self.live_config_reload.unwrap_or(self.general.live_config_reload)
+    }
+
+    #[cfg(unix)]
+    #[inline]
+    pub fn ipc_socket(&self) -> bool {
+        self.ipc_socket.unwrap_or(self.general.ipc_socket)
     }
 }
 
diff --git a/alacritty/src/display/content.rs b/alacritty/src/display/content.rs
index 30439fc050e..0e06b3ca913 100644
--- a/alacritty/src/display/content.rs
+++ b/alacritty/src/display/content.rs
@@ -328,7 +328,7 @@ impl RenderableCell {
                 _ => rgb.into(),
             },
             Color::Named(ansi) => {
-                match (config.draw_bold_text_with_bright_colors(), flags & Flags::DIM_BOLD) {
+                match (config.colors.draw_bold_text_with_bright_colors, flags & Flags::DIM_BOLD) {
                     // If no bright foreground is set, treat it like the BOLD flag doesn't exist.
                     (_, Flags::DIM_BOLD)
                         if ansi == NamedColor::Foreground
@@ -348,7 +348,7 @@ impl RenderableCell {
             },
             Color::Indexed(idx) => {
                 let idx = match (
-                    config.draw_bold_text_with_bright_colors(),
+                    config.colors.draw_bold_text_with_bright_colors,
                     flags & Flags::DIM_BOLD,
                     idx,
                 ) {
diff --git a/alacritty/src/event.rs b/alacritty/src/event.rs
index f10346af540..b4600ae3e8d 100644
--- a/alacritty/src/event.rs
+++ b/alacritty/src/event.rs
@@ -110,7 +110,7 @@ impl Processor {
         // The monitor watches the config file for changes and reloads it. Pending
         // config changes are processed in the main loop.
         let mut config_monitor = None;
-        if config.live_config_reload {
+        if config.live_config_reload() {
             config_monitor =
                 ConfigMonitor::new(config.config_paths.clone(), event_loop.create_proxy());
         }
diff --git a/alacritty/src/main.rs b/alacritty/src/main.rs
index da50c3e412b..6bbf8dfd5d3 100644
--- a/alacritty/src/main.rs
+++ b/alacritty/src/main.rs
@@ -174,7 +174,7 @@ fn alacritty(mut options: Options) -> Result<(), Box<dyn Error>> {
 
     // Create the IPC socket listener.
     #[cfg(unix)]
-    let socket_path = if config.ipc_socket {
+    let socket_path = if config.ipc_socket() {
         ipc::spawn_ipc_socket(&options, window_event_loop.create_proxy())
     } else {
         None
diff --git a/alacritty/src/migrate.rs b/alacritty/src/migrate.rs
deleted file mode 100644
index 6d116858b06..00000000000
--- a/alacritty/src/migrate.rs
+++ /dev/null
@@ -1,267 +0,0 @@
-//! Configuration file migration.
-
-use std::fs;
-use std::path::Path;
-
-use toml::map::Entry;
-use toml::{Table, Value};
-
-use crate::cli::MigrateOptions;
-use crate::config;
-
-/// Handle migration.
-pub fn migrate(options: MigrateOptions) {
-    // Find configuration file path.
-    let config_path = options
-        .config_file
-        .clone()
-        .or_else(|| config::installed_config("toml"))
-        .or_else(|| config::installed_config("yml"));
-
-    // Abort if system has no installed configuration.
-    let config_path = match config_path {
-        Some(config_path) => config_path,
-        None => {
-            eprintln!("No configuration file found");
-            std::process::exit(1);
-        },
-    };
-
-    // If we're doing a wet run, perform a dry run first for safety.
-    if !options.dry_run {
-        #[allow(clippy::redundant_clone)]
-        let mut options = options.clone();
-        options.silent = true;
-        options.dry_run = true;
-        if let Err(err) = migrate_config(&options, &config_path, config::IMPORT_RECURSION_LIMIT) {
-            eprintln!("Configuration file migration failed:");
-            eprintln!("    {config_path:?}: {err}");
-            std::process::exit(1);
-        }
-    }
-
-    // Migrate the root config.
-    match migrate_config(&options, &config_path, config::IMPORT_RECURSION_LIMIT) {
-        Ok(new_path) => {
-            if !options.silent {
-                println!("Successfully migrated {config_path:?} to {new_path:?}");
-            }
-        },
-        Err(err) => {
-            eprintln!("Configuration file migration failed:");
-            eprintln!("    {config_path:?}: {err}");
-            std::process::exit(1);
-        },
-    }
-}
-
-/// Migrate a specific configuration file.
-fn migrate_config(
-    options: &MigrateOptions,
-    path: &Path,
-    recursion_limit: usize,
-) -> Result<String, String> {
-    // Ensure configuration file has an extension.
-    let path_str = path.to_string_lossy();
-    let (prefix, suffix) = match path_str.rsplit_once('.') {
-        Some((prefix, suffix)) => (prefix, suffix),
-        None => return Err("missing file extension".to_string()),
-    };
-
-    // Abort if config is already toml.
-    if suffix == "toml" {
-        return Err("already in TOML format".to_string());
-    }
-
-    // Try to parse the configuration file.
-    let mut config = match config::deserialize_config(path, !options.dry_run) {
-        Ok(config) => config,
-        Err(err) => return Err(format!("parsing error: {err}")),
-    };
-
-    // Migrate config imports.
-    if !options.skip_imports {
-        migrate_imports(options, &mut config, path, recursion_limit)?;
-    }
-
-    // Migrate deprecated field names to their new location.
-    if !options.skip_renames {
-        migrate_renames(&mut config)?;
-    }
-
-    // Convert to TOML format.
-    let toml = toml::to_string(&config).map_err(|err| format!("conversion error: {err}"))?;
-    let new_path = format!("{prefix}.toml");
-
-    if options.dry_run && !options.silent {
-        // Output new content to STDOUT.
-        println!(
-            "\nv-----Start TOML for {path:?}-----v\n\n{toml}\n^-----End TOML for {path:?}-----^\n"
-        );
-    } else if !options.dry_run {
-        // Write the new toml configuration.
-        fs::write(&new_path, toml).map_err(|err| format!("filesystem error: {err}"))?;
-    }
-
-    Ok(new_path)
-}
-
-/// Migrate the imports of a config.
-fn migrate_imports(
-    options: &MigrateOptions,
-    config: &mut Value,
-    base_path: &Path,
-    recursion_limit: usize,
-) -> Result<(), String> {
-    let imports = match config::imports(config, base_path, recursion_limit) {
-        Ok(imports) => imports,
-        Err(err) => return Err(format!("import error: {err}")),
-    };
-
-    // Migrate the individual imports.
-    let mut new_imports = Vec::new();
-    for import in imports {
-        let import = match import {
-            Ok(import) => import,
-            Err(err) => return Err(format!("import error: {err}")),
-        };
-
-        // Keep yaml import if path does not exist.
-        if !import.exists() {
-            if options.dry_run {
-                eprintln!("Keeping yaml config for nonexistent import: {import:?}");
-            }
-            new_imports.push(Value::String(import.to_string_lossy().into()));
-            continue;
-        }
-
-        let new_path = migrate_config(options, &import, recursion_limit - 1)?;
-
-        // Print new import path.
-        if options.dry_run {
-            println!("Successfully migrated import {import:?} to {new_path:?}");
-        }
-
-        new_imports.push(Value::String(new_path));
-    }
-
-    // Update the imports field.
-    if let Some(import) = config.get_mut("import") {
-        *import = Value::Array(new_imports);
-    }
-
-    Ok(())
-}
-
-/// Migrate deprecated fields.
-fn migrate_renames(config: &mut Value) -> Result<(), String> {
-    let config_table = match config.as_table_mut() {
-        Some(config_table) => config_table,
-        None => return Ok(()),
-    };
-
-    // draw_bold_text_with_bright_colors -> colors.draw_bold_text_with_bright_colors
-    move_value(config_table, &["draw_bold_text_with_bright_colors"], &[
-        "colors",
-        "draw_bold_text_with_bright_colors",
-    ])?;
-
-    // key_bindings -> keyboard.bindings
-    move_value(config_table, &["key_bindings"], &["keyboard", "bindings"])?;
-
-    // mouse_bindings -> mouse.bindings
-    move_value(config_table, &["mouse_bindings"], &["mouse", "bindings"])?;
-
-    Ok(())
-}
-
-/// Move a toml value from one map to another.
-fn move_value(config_table: &mut Table, origin: &[&str], target: &[&str]) -> Result<(), String> {
-    if let Some(value) = remove_node(config_table, origin)? {
-        if !insert_node_if_empty(config_table, target, value)? {
-            return Err(format!(
-                "conflict: both `{}` and `{}` are set",
-                origin.join("."),
-                target.join(".")
-            ));
-        }
-    }
-
-    Ok(())
-}
-
-/// Remove a node from a tree of tables.
-fn remove_node(table: &mut Table, path: &[&str]) -> Result<Option<Value>, String> {
-    if path.len() == 1 {
-        Ok(table.remove(path[0]))
-    } else {
-        let next_table_value = match table.get_mut(path[0]) {
-            Some(next_table_value) => next_table_value,
-            None => return Ok(None),
-        };
-
-        let next_table = match next_table_value.as_table_mut() {
-            Some(next_table) => next_table,
-            None => return Err(format!("invalid `{}` table", path[0])),
-        };
-
-        remove_node(next_table, &path[1..])
-    }
-}
-
-/// Try to insert a node into a tree of tables.
-///
-/// Returns `false` if the node already exists.
-fn insert_node_if_empty(table: &mut Table, path: &[&str], node: Value) -> Result<bool, String> {
-    if path.len() == 1 {
-        match table.entry(path[0]) {
-            Entry::Vacant(vacant_entry) => {
-                vacant_entry.insert(node);
-                Ok(true)
-            },
-            Entry::Occupied(_) => Ok(false),
-        }
-    } else {
-        let next_table_value = table.entry(path[0]).or_insert_with(|| Value::Table(Table::new()));
-
-        let next_table = match next_table_value.as_table_mut() {
-            Some(next_table) => next_table,
-            None => return Err(format!("invalid `{}` table", path[0])),
-        };
-
-        insert_node_if_empty(next_table, &path[1..], node)
-    }
-}
-
-#[cfg(test)]
-mod tests {
-    use super::*;
-
-    #[test]
-    fn move_values() {
-        let input = r#"
-root_value = 3
-
-[table]
-table_value = 5
-
-[preexisting]
-not_moved = 9
-        "#;
-
-        let mut value: Value = toml::from_str(input).unwrap();
-        let table = value.as_table_mut().unwrap();
-
-        move_value(table, &["root_value"], &["new_table", "root_value"]).unwrap();
-        move_value(table, &["table", "table_value"], &["preexisting", "subtable", "new_name"])
-            .unwrap();
-
-        let output = toml::to_string(table).unwrap();
-
-        assert_eq!(
-            output,
-            "[new_table]\nroot_value = 3\n\n[preexisting]\nnot_moved = \
-             9\n\n[preexisting.subtable]\nnew_name = 5\n\n[table]\n"
-        );
-    }
-}
diff --git a/alacritty/src/migrate/mod.rs b/alacritty/src/migrate/mod.rs
new file mode 100644
index 00000000000..ffd0d4b43a4
--- /dev/null
+++ b/alacritty/src/migrate/mod.rs
@@ -0,0 +1,318 @@
+//! Configuration file migration.
+
+use std::fmt::Debug;
+use std::path::Path;
+use std::{fs, mem};
+
+use tempfile::NamedTempFile;
+use toml_edit::{DocumentMut, Item};
+
+use crate::cli::MigrateOptions;
+use crate::config;
+
+mod yaml;
+
+/// Handle migration.
+pub fn migrate(options: MigrateOptions) {
+    // Find configuration file path.
+    let config_path = options
+        .config_file
+        .clone()
+        .or_else(|| config::installed_config("toml"))
+        .or_else(|| config::installed_config("yml"));
+
+    // Abort if system has no installed configuration.
+    let config_path = match config_path {
+        Some(config_path) => config_path,
+        None => {
+            eprintln!("No configuration file found");
+            std::process::exit(1);
+        },
+    };
+
+    // If we're doing a wet run, perform a dry run first for safety.
+    if !options.dry_run {
+        #[allow(clippy::redundant_clone)]
+        let mut options = options.clone();
+        options.silent = true;
+        options.dry_run = true;
+        if let Err(err) = migrate_config(&options, &config_path, config::IMPORT_RECURSION_LIMIT) {
+            eprintln!("Configuration file migration failed:");
+            eprintln!("    {config_path:?}: {err}");
+            std::process::exit(1);
+        }
+    }
+
+    // Migrate the root config.
+    match migrate_config(&options, &config_path, config::IMPORT_RECURSION_LIMIT) {
+        Ok(migration) => {
+            if !options.silent {
+                println!("{}", migration.success_message(false));
+            }
+        },
+        Err(err) => {
+            eprintln!("Configuration file migration failed:");
+            eprintln!("    {config_path:?}: {err}");
+            std::process::exit(1);
+        },
+    }
+}
+
+/// Migrate a specific configuration file.
+fn migrate_config<'a>(
+    options: &MigrateOptions,
+    path: &'a Path,
+    recursion_limit: usize,
+) -> Result<Migration<'a>, String> {
+    // Ensure configuration file has an extension.
+    let path_str = path.to_string_lossy();
+    let (prefix, suffix) = match path_str.rsplit_once('.') {
+        Some((prefix, suffix)) => (prefix, suffix),
+        None => return Err("missing file extension".to_string()),
+    };
+
+    // Handle legacy YAML files.
+    if suffix == "yml" {
+        let new_path = yaml::migrate(options, path, recursion_limit, prefix)?;
+        return Ok(Migration::Yaml((path, new_path)));
+    }
+
+    // TOML only does renames, so return early if they are disabled.
+    if options.skip_renames {
+        if options.dry_run {
+            eprintln!("Ignoring TOML file {path:?} since `--skip-renames` was supplied");
+        }
+        return Ok(Migration::Toml(path));
+    }
+
+    // Read TOML file and perform all in-file migrations.
+    let toml = fs::read_to_string(path).map_err(|err| format!("{err}"))?;
+    let mut migrated = migrate_toml(toml)?;
+
+    // Recursively migrate imports.
+    migrate_imports(options, path, &mut migrated, recursion_limit)?;
+
+    // Write migrated TOML file.
+    write_results(options, path, &migrated.to_string())?;
+
+    Ok(Migration::Toml(path))
+}
+
+/// Migrate TOML config to the latest version.
+fn migrate_toml(toml: String) -> Result<DocumentMut, String> {
+    // Parse TOML file.
+    let mut document = match toml.parse::<DocumentMut>() {
+        Ok(document) => document,
+        Err(err) => return Err(format!("TOML parsing error: {err}")),
+    };
+
+    // Move `draw_bold_text_with_bright_colors` to its own section.
+    move_value(&mut document, &["draw_bold_text_with_bright_colors"], &[
+        "colors",
+        "draw_bold_text_with_bright_colors",
+    ])?;
+
+    // Move bindings to their own section.
+    move_value(&mut document, &["key_bindings"], &["keyboard", "bindings"])?;
+    move_value(&mut document, &["mouse_bindings"], &["mouse", "bindings"])?;
+
+    // Avoid warnings due to introduction of the new `general` section.
+    move_value(&mut document, &["live_config_reload"], &["general", "live_config_reload"])?;
+    move_value(&mut document, &["working_directory"], &["general", "working_directory"])?;
+    move_value(&mut document, &["ipc_socket"], &["general", "ipc_socket"])?;
+    move_value(&mut document, &["import"], &["general", "import"])?;
+    move_value(&mut document, &["shell"], &["terminal", "shell"])?;
+
+    Ok(document)
+}
+
+/// Migrate TOML imports to the latest version.
+fn migrate_imports(
+    options: &MigrateOptions,
+    path: &Path,
+    document: &mut DocumentMut,
+    recursion_limit: usize,
+) -> Result<(), String> {
+    // Check if any imports need to be processed.
+    let imports = match document["general"].get("import").and_then(|i| i.as_array()) {
+        Some(array) if !array.is_empty() => array,
+        _ => return Ok(()),
+    };
+
+    // Abort once recursion limit is exceeded.
+    if recursion_limit == 0 {
+        return Err("Exceeded maximum configuration import depth".into());
+    }
+
+    // Migrate each import.
+    for import in imports.into_iter().filter_map(|item| item.as_str()) {
+        let normalized_path = config::normalize_import(path, import);
+        let migration = migrate_config(options, &normalized_path, recursion_limit)?;
+        if options.dry_run {
+            println!("{}", migration.success_message(true));
+        }
+    }
+
+    Ok(())
+}
+
+/// Move a TOML value from one map to another.
+fn move_value(document: &mut DocumentMut, origin: &[&str], target: &[&str]) -> Result<(), String> {
+    // Find and remove the original item.
+    let (mut origin_key, mut origin_item) = (None, document.as_item_mut());
+    for element in origin {
+        let table = match origin_item.as_table_like_mut() {
+            Some(table) => table,
+            None => panic!("Moving from unsupported TOML structure"),
+        };
+
+        let (key, item) = match table.get_key_value_mut(element) {
+            Some((key, item)) => (key, item),
+            None => return Ok(()),
+        };
+
+        dbg!(&key);
+        origin_key = Some(key);
+        origin_item = item;
+
+        // Ensure no empty tables are left behind.
+        if let Some(table) = origin_item.as_table_mut() {
+            table.set_implicit(true)
+        }
+    }
+
+    let origin_key_decor =
+        origin_key.map(|key| (key.leaf_decor().clone(), key.dotted_decor().clone()));
+    let origin_item = mem::replace(origin_item, Item::None);
+
+    // Create all dependencies for the new location.
+    let mut target_item = document.as_item_mut();
+    for (i, element) in target.iter().enumerate() {
+        let table = match target_item.as_table_like_mut() {
+            Some(table) => table,
+            None => panic!("Moving into unsupported TOML structure"),
+        };
+
+        if i + 1 == target.len() {
+            table.insert(element, origin_item);
+            // Move original key decorations.
+            if let Some((leaf, dotted)) = origin_key_decor {
+                let mut key = table.key_mut(element).unwrap();
+                *key.leaf_decor_mut() = leaf;
+                *key.dotted_decor_mut() = dotted;
+            }
+
+            break;
+        } else {
+            // Create missing parent tables.
+            target_item = target_item[element].or_insert(toml_edit::table());
+        }
+    }
+
+    Ok(())
+}
+
+/// Write migrated TOML to its target location.
+fn write_results<P>(options: &MigrateOptions, path: P, toml: &str) -> Result<(), String>
+where
+    P: AsRef<Path> + Debug,
+{
+    let path = path.as_ref();
+    if options.dry_run && !options.silent {
+        // Output new content to STDOUT.
+        println!(
+            "\nv-----Start TOML for {path:?}-----v\n\n{toml}\n^-----End TOML for {path:?}-----^\n"
+        );
+    } else if !options.dry_run {
+        // Atomically replace the configuration file.
+        let tmp = NamedTempFile::new_in(path.parent().unwrap())
+            .map_err(|err| format!("could not create temporary file: {err}"))?;
+        fs::write(tmp.path(), toml).map_err(|err| format!("filesystem error: {err}"))?;
+        tmp.persist(path).map_err(|err| format!("atomic replacement failed: {err}"))?;
+    }
+    Ok(())
+}
+
+/// Performed migration mode.
+enum Migration<'a> {
+    /// In-place TOML migration.
+    Toml(&'a Path),
+    /// YAML to TOML migration.
+    Yaml((&'a Path, String)),
+}
+
+impl<'a> Migration<'a> {
+    /// Get the success message for this migration.
+    fn success_message(&self, import: bool) -> String {
+        match self {
+            Self::Yaml((original_path, new_path)) if import => {
+                format!("Successfully migrated import {original_path:?} to {new_path:?}")
+            },
+            Self::Yaml((original_path, new_path)) => {
+                format!("Successfully migrated {original_path:?} to {new_path:?}")
+            },
+            Self::Toml(original_path) if import => {
+                format!("Successfully migrated import {original_path:?}")
+            },
+            Self::Toml(original_path) => format!("Successfully migrated {original_path:?}"),
+        }
+    }
+
+    /// Get the file path after migration.
+    fn new_path(&self) -> String {
+        match self {
+            Self::Toml(path) => path.to_string_lossy().into(),
+            Self::Yaml((_, path)) => path.into(),
+        }
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn move_values() {
+        let input = r#"
+# This is a root_value.
+#
+# Use it with care.
+root_value = 3
+
+[table]
+table_value = 5
+
+[preexisting]
+not_moved = 9
+        "#;
+
+        let mut document = input.parse::<DocumentMut>().unwrap();
+
+        move_value(&mut document, &["root_value"], &["new_table", "root_value"]).unwrap();
+        move_value(&mut document, &["table", "table_value"], &[
+            "preexisting",
+            "subtable",
+            "new_name",
+        ])
+        .unwrap();
+
+        let output = document.to_string();
+
+        let expected = r#"
+[preexisting]
+not_moved = 9
+
+[preexisting.subtable]
+new_name = 5
+
+[new_table]
+
+# This is a root_value.
+#
+# Use it with care.
+root_value = 3
+        "#;
+
+        assert_eq!(output, expected);
+    }
+}
diff --git a/alacritty/src/migrate/yaml.rs b/alacritty/src/migrate/yaml.rs
new file mode 100644
index 00000000000..9607e95e463
--- /dev/null
+++ b/alacritty/src/migrate/yaml.rs
@@ -0,0 +1,87 @@
+//! Migration of legacy YAML files to TOML.
+
+use std::path::Path;
+
+use toml::Value;
+
+use crate::cli::MigrateOptions;
+use crate::config;
+use crate::migrate::{migrate_config, migrate_toml, write_results};
+
+/// Migrate a legacy YAML config to TOML.
+pub fn migrate(
+    options: &MigrateOptions,
+    path: &Path,
+    recursion_limit: usize,
+    prefix: &str,
+) -> Result<String, String> {
+    // Try to parse the configuration file.
+    let mut config = match config::deserialize_config(path, !options.dry_run) {
+        Ok(config) => config,
+        Err(err) => return Err(format!("YAML parsing error: {err}")),
+    };
+
+    // Migrate config imports.
+    if !options.skip_imports {
+        migrate_imports(options, &mut config, path, recursion_limit)?;
+    }
+
+    // Convert to TOML format.
+    let mut toml = toml::to_string(&config).map_err(|err| format!("conversion error: {err}"))?;
+    let new_path = format!("{prefix}.toml");
+
+    // Apply TOML migration, without recursing through imports.
+    toml = migrate_toml(toml)?.to_string();
+
+    // Write migrated TOML config.
+    write_results(options, &new_path, &toml)?;
+
+    Ok(new_path)
+}
+
+/// Migrate the imports of a config.
+fn migrate_imports(
+    options: &MigrateOptions,
+    config: &mut Value,
+    base_path: &Path,
+    recursion_limit: usize,
+) -> Result<(), String> {
+    let imports = match config::imports(config, base_path, recursion_limit) {
+        Ok(imports) => imports,
+        Err(err) => return Err(format!("import error: {err}")),
+    };
+
+    // Migrate the individual imports.
+    let mut new_imports = Vec::new();
+    for import in imports {
+        let import = match import {
+            Ok(import) => import,
+            Err(err) => return Err(format!("import error: {err}")),
+        };
+
+        // Keep yaml import if path does not exist.
+        if !import.exists() {
+            if options.dry_run {
+                eprintln!("Keeping yaml config for nonexistent import: {import:?}");
+            }
+            new_imports.push(Value::String(import.to_string_lossy().into()));
+            continue;
+        }
+
+        let migration = migrate_config(options, &import, recursion_limit - 1)?;
+
+        // Print success message.
+        if options.dry_run {
+            println!("{}", migration.success_message(true));
+        }
+
+        new_imports.push(Value::String(migration.new_path()));
+    }
+
+    // Update the imports field.
+    if let Some(import) = config.get_mut("import") {
+        *import = Value::Array(new_imports);
+    }
+
+    Ok(())
+}
diff --git a/alacritty_config_derive/src/config_deserialize/de_struct.rs b/alacritty_config_derive/src/config_deserialize/de_struct.rs
index d2a7dd82af4..ad38863ed4c 100644
--- a/alacritty_config_derive/src/config_deserialize/de_struct.rs
+++ b/alacritty_config_derive/src/config_deserialize/de_struct.rs
@@ -155,6 +155,7 @@ fn field_deserializer(field_streams: &mut FieldStreams, field: &Field) -> Result
                 if let Some(warning) = parsed.param {
                     message = format!("{}; {}", message, warning.value());
                 }
+                message.push_str("\nUse `alacritty migrate` to automatically resolve it");
 
                 // Append stream to log deprecation/removal warning.
                 match_assignment_stream.extend(quote! {
diff --git a/alacritty_terminal/src/tty/mod.rs b/alacritty_terminal/src/tty/mod.rs
index 55d263ca5b1..2bcc5da9972 100644
--- a/alacritty_terminal/src/tty/mod.rs
+++ b/alacritty_terminal/src/tty/mod.rs
@@ -50,9 +50,10 @@ impl Shell {
     }
 }
 
-/// This trait defines the behaviour needed to read and/or write to a stream.
-/// It defines an abstraction over polling's interface in order to allow either one
-/// read/write object or a separate read and write object.
+/// Stream read and/or write behavior.
+///
+/// This defines an abstraction over polling's interface in order to allow either
+/// one read/write object or a separate read and write object.
 pub trait EventedReadWrite {
     type Reader: io::Read;
     type Writer: io::Write;
diff --git a/extra/man/alacritty.5.scd b/extra/man/alacritty.5.scd
index f1efc781d6e..1c0aa1d139d 100644
--- a/extra/man/alacritty.5.scd
+++ b/extra/man/alacritty.5.scd
@@ -25,7 +25,7 @@ On Windows, the config file will be looked for in:
 
 # GENERAL
 
-This section documents the root level of the configuration file.
+This section documents the *[general]* table of the configuration file.
 
 *import* = [_"<string>"_,]
 
@@ -46,20 +46,6 @@ This section documents the root level of the configuration file.
 	_"alacritty-theme/themes/gruvbox_dark.toml"_,++
 ]
 
-*shell* = _"<string>"_ | { program = _"<string>"_, args = [_"<string>"_,] }
-
-	You can set _shell.program_ to the path of your favorite shell, e.g.
-	_/bin/zsh_. Entries in _shell.args_ are passed as arguments to the shell.
-
-	Default:
-		Linux/BSD/macOS: _$SHELL_ or the user's login shell, if _$SHELL_ is unset++
-Windows: _"powershell"_
-
-	Example:
-		*[shell]*++
-program = _"/bin/zsh"_++
-args = [_"-l"_]
-
 *working_directory* = _"<string>"_ | _"None"_
 
 	Directory the shell is started in. When this is unset, or _"None"_, the
@@ -605,6 +591,20 @@ This section documents the *[cursor]* table of the configuration file.
 
 This section documents the *[terminal]* table of the configuration file.
 
+*shell* = _"<string>"_ | { program = _"<string>"_, args = [_"<string>"_,] }
+
+	You can set _shell.program_ to the path of your favorite shell, e.g.
+	_/bin/zsh_. Entries in _shell.args_ are passed as arguments to the shell.
+
+	Default:
+		Linux/BSD/macOS: _$SHELL_ or the user's login shell, if _$SHELL_ is unset++
+Windows: _"powershell"_
+
+	Example:
+		*[shell]*++
+program = _"/bin/zsh"_++
+args = [_"-l"_]
+
 *osc52* = _"Disabled"_ | _"OnlyCopy"_ | _"OnlyPaste"_ | _"CopyPaste"_
 
 	Controls the ability to write to the system clipboard with the _OSC 52_


Test patch:
diff --git a/alacritty_config_derive/tests/config.rs b/alacritty_config_derive/tests/config.rs
index 27a968ede7b..be140cbe5e4 100644
--- a/alacritty_config_derive/tests/config.rs
+++ b/alacritty_config_derive/tests/config.rs
@@ -1,4 +1,4 @@
-use std::sync::{Arc, Mutex};
+use std::sync::{Arc, Mutex, OnceLock};
 
 use log::{Level, Log, Metadata, Record};
 use serde::Deserialize;
@@ -83,10 +83,8 @@ struct NewType(usize);
 
 #[test]
 fn config_deserialize() {
-    let logger = unsafe {
-        LOGGER = Some(Logger::default());
-        LOGGER.as_mut().unwrap()
-    };
+    static LOGGER: OnceLock<Logger> = OnceLock::new();
+    let logger = LOGGER.get_or_init(Logger::default);
 
     log::set_logger(logger).unwrap();
     log::set_max_level(log::LevelFilter::Warn);
@@ -134,15 +132,16 @@ fn config_deserialize() {
     ]);
     let warn_logs = logger.warn_logs.lock().unwrap();
     assert_eq!(warn_logs.as_slice(), [
-        "Config warning: field1 has been deprecated; use field2 instead",
-        "Config warning: enom_error has been deprecated",
-        "Config warning: gone has been removed; it's gone",
+        "Config warning: field1 has been deprecated; use field2 instead\nUse `alacritty migrate` \
+         to automatically resolve it",
+        "Config warning: enom_error has been deprecated\nUse `alacritty migrate` to automatically \
+         resolve it",
+        "Config warning: gone has been removed; it's gone\nUse `alacritty migrate` to \
+         automatically resolve it",
         "Unused config key: field3",
     ]);
 }
 
-static mut LOGGER: Option<Logger> = None;
-
 /// Logger storing all messages for later validation.
 #[derive(Default)]
 struct Logger {