migrator.rs

   1//! ## When to create a migration and why?
   2//! A migration is necessary when keymap actions or settings are renamed or transformed (e.g., from an array to a string, a string to an array, a boolean to an enum, etc.).
   3//!
   4//! This ensures that users with outdated settings are automatically updated to use the corresponding new settings internally.
   5//! It also provides a quick way to migrate their existing settings to the latest state using button in UI.
   6//!
   7//! ## How to create a migration?
   8//! Migrations use Tree-sitter to query commonly used patterns, such as actions with a string or actions with an array where the second argument is an object, etc.
   9//! Once queried, *you can filter out the modified items* and write the replacement logic.
  10//!
  11//! You *must not* modify previous migrations; always create new ones instead.
  12//! This is important because if a user is in an intermediate state, they can smoothly transition to the latest state.
  13//! Modifying existing migrations means they will only work for users upgrading from version x-1 to x, but not from x-2 to x, and so on, where x is the latest version.
  14//!
  15//! You only need to write replacement logic for x-1 to x because you can be certain that, internally, every user will be at x-1, regardless of their on disk state.
  16
  17use anyhow::{Context as _, Result};
  18use std::{cmp::Reverse, ops::Range, sync::LazyLock};
  19use streaming_iterator::StreamingIterator;
  20use tree_sitter::{Query, QueryMatch};
  21
  22use patterns::SETTINGS_NESTED_KEY_VALUE_PATTERN;
  23
  24mod migrations;
  25mod patterns;
  26
  27fn migrate(text: &str, patterns: MigrationPatterns, query: &Query) -> Result<Option<String>> {
  28    let mut parser = tree_sitter::Parser::new();
  29    parser.set_language(&tree_sitter_json::LANGUAGE.into())?;
  30    let syntax_tree = parser
  31        .parse(text, None)
  32        .context("failed to parse settings")?;
  33
  34    let mut cursor = tree_sitter::QueryCursor::new();
  35    let mut matches = cursor.matches(query, syntax_tree.root_node(), text.as_bytes());
  36
  37    let mut edits = vec![];
  38    while let Some(mat) = matches.next() {
  39        if let Some((_, callback)) = patterns.get(mat.pattern_index) {
  40            edits.extend(callback(text, mat, query));
  41        }
  42    }
  43
  44    edits.sort_by_key(|(range, _)| (range.start, Reverse(range.end)));
  45    edits.dedup_by(|(range_b, _), (range_a, _)| {
  46        range_a.contains(&range_b.start) || range_a.contains(&range_b.end)
  47    });
  48
  49    if edits.is_empty() {
  50        Ok(None)
  51    } else {
  52        let mut new_text = text.to_string();
  53        for (range, replacement) in edits.iter().rev() {
  54            new_text.replace_range(range.clone(), replacement);
  55        }
  56        if new_text == text {
  57            log::error!(
  58                "Edits computed for configuration migration do not cause a change: {:?}",
  59                edits
  60            );
  61            Ok(None)
  62        } else {
  63            Ok(Some(new_text))
  64        }
  65    }
  66}
  67
  68fn run_migrations(text: &str, migrations: &[MigrationType]) -> Result<Option<String>> {
  69    let mut current_text = text.to_string();
  70    let mut result: Option<String> = None;
  71    for migration in migrations.iter() {
  72        let migrated_text = match migration {
  73            MigrationType::TreeSitter(patterns, query) => migrate(&current_text, patterns, query)?,
  74            MigrationType::Json(callback) => {
  75                let old_content: serde_json_lenient::Value =
  76                    settings::parse_json_with_comments(&current_text)?;
  77                let old_value = serde_json::to_value(&old_content).unwrap();
  78                let mut new_value = old_value.clone();
  79                callback(&mut new_value)?;
  80                if new_value != old_value {
  81                    let mut current = current_text.clone();
  82                    let mut edits = vec![];
  83                    settings::update_value_in_json_text(
  84                        &mut current,
  85                        &mut vec![],
  86                        2,
  87                        &old_value,
  88                        &new_value,
  89                        &mut edits,
  90                    );
  91                    let mut migrated_text = current_text.clone();
  92                    for (range, replacement) in edits.into_iter() {
  93                        migrated_text.replace_range(range, &replacement);
  94                    }
  95                    Some(migrated_text)
  96                } else {
  97                    None
  98                }
  99            }
 100        };
 101        if let Some(migrated_text) = migrated_text {
 102            current_text = migrated_text.clone();
 103            result = Some(migrated_text);
 104        }
 105    }
 106    Ok(result.filter(|new_text| text != new_text))
 107}
 108
 109pub fn migrate_keymap(text: &str) -> Result<Option<String>> {
 110    let migrations: &[MigrationType] = &[
 111        MigrationType::TreeSitter(
 112            migrations::m_2025_01_29::KEYMAP_PATTERNS,
 113            &KEYMAP_QUERY_2025_01_29,
 114        ),
 115        MigrationType::TreeSitter(
 116            migrations::m_2025_01_30::KEYMAP_PATTERNS,
 117            &KEYMAP_QUERY_2025_01_30,
 118        ),
 119        MigrationType::TreeSitter(
 120            migrations::m_2025_03_03::KEYMAP_PATTERNS,
 121            &KEYMAP_QUERY_2025_03_03,
 122        ),
 123        MigrationType::TreeSitter(
 124            migrations::m_2025_03_06::KEYMAP_PATTERNS,
 125            &KEYMAP_QUERY_2025_03_06,
 126        ),
 127        MigrationType::TreeSitter(
 128            migrations::m_2025_04_15::KEYMAP_PATTERNS,
 129            &KEYMAP_QUERY_2025_04_15,
 130        ),
 131    ];
 132    run_migrations(text, migrations)
 133}
 134
 135enum MigrationType<'a> {
 136    TreeSitter(MigrationPatterns, &'a Query),
 137    Json(fn(&mut serde_json::Value) -> Result<()>),
 138}
 139
 140pub fn migrate_settings(text: &str) -> Result<Option<String>> {
 141    let migrations: &[MigrationType] = &[
 142        MigrationType::TreeSitter(
 143            migrations::m_2025_01_02::SETTINGS_PATTERNS,
 144            &SETTINGS_QUERY_2025_01_02,
 145        ),
 146        MigrationType::TreeSitter(
 147            migrations::m_2025_01_29::SETTINGS_PATTERNS,
 148            &SETTINGS_QUERY_2025_01_29,
 149        ),
 150        MigrationType::TreeSitter(
 151            migrations::m_2025_01_30::SETTINGS_PATTERNS,
 152            &SETTINGS_QUERY_2025_01_30,
 153        ),
 154        MigrationType::TreeSitter(
 155            migrations::m_2025_03_29::SETTINGS_PATTERNS,
 156            &SETTINGS_QUERY_2025_03_29,
 157        ),
 158        MigrationType::TreeSitter(
 159            migrations::m_2025_04_15::SETTINGS_PATTERNS,
 160            &SETTINGS_QUERY_2025_04_15,
 161        ),
 162        MigrationType::TreeSitter(
 163            migrations::m_2025_04_21::SETTINGS_PATTERNS,
 164            &SETTINGS_QUERY_2025_04_21,
 165        ),
 166        MigrationType::TreeSitter(
 167            migrations::m_2025_04_23::SETTINGS_PATTERNS,
 168            &SETTINGS_QUERY_2025_04_23,
 169        ),
 170        MigrationType::TreeSitter(
 171            migrations::m_2025_05_05::SETTINGS_PATTERNS,
 172            &SETTINGS_QUERY_2025_05_05,
 173        ),
 174        MigrationType::TreeSitter(
 175            migrations::m_2025_05_08::SETTINGS_PATTERNS,
 176            &SETTINGS_QUERY_2025_05_08,
 177        ),
 178        MigrationType::TreeSitter(
 179            migrations::m_2025_05_29::SETTINGS_PATTERNS,
 180            &SETTINGS_QUERY_2025_05_29,
 181        ),
 182        MigrationType::TreeSitter(
 183            migrations::m_2025_06_16::SETTINGS_PATTERNS,
 184            &SETTINGS_QUERY_2025_06_16,
 185        ),
 186        MigrationType::TreeSitter(
 187            migrations::m_2025_06_25::SETTINGS_PATTERNS,
 188            &SETTINGS_QUERY_2025_06_25,
 189        ),
 190        MigrationType::TreeSitter(
 191            migrations::m_2025_06_27::SETTINGS_PATTERNS,
 192            &SETTINGS_QUERY_2025_06_27,
 193        ),
 194        MigrationType::TreeSitter(
 195            migrations::m_2025_07_08::SETTINGS_PATTERNS,
 196            &SETTINGS_QUERY_2025_07_08,
 197        ),
 198        MigrationType::TreeSitter(
 199            migrations::m_2025_10_01::SETTINGS_PATTERNS,
 200            &SETTINGS_QUERY_2025_10_01,
 201        ),
 202        MigrationType::Json(migrations::m_2025_10_02::remove_formatters_on_save),
 203        MigrationType::TreeSitter(
 204            migrations::m_2025_10_03::SETTINGS_PATTERNS,
 205            &SETTINGS_QUERY_2025_10_03,
 206        ),
 207    ];
 208    run_migrations(text, migrations)
 209}
 210
 211pub fn migrate_edit_prediction_provider_settings(text: &str) -> Result<Option<String>> {
 212    migrate(
 213        text,
 214        &[(
 215            SETTINGS_NESTED_KEY_VALUE_PATTERN,
 216            migrations::m_2025_01_29::replace_edit_prediction_provider_setting,
 217        )],
 218        &EDIT_PREDICTION_SETTINGS_MIGRATION_QUERY,
 219    )
 220}
 221
 222pub type MigrationPatterns = &'static [(
 223    &'static str,
 224    fn(&str, &QueryMatch, &Query) -> Option<(Range<usize>, String)>,
 225)];
 226
 227macro_rules! define_query {
 228    ($var_name:ident, $patterns_path:path) => {
 229        static $var_name: LazyLock<Query> = LazyLock::new(|| {
 230            Query::new(
 231                &tree_sitter_json::LANGUAGE.into(),
 232                &$patterns_path
 233                    .iter()
 234                    .map(|pattern| pattern.0)
 235                    .collect::<String>(),
 236            )
 237            .unwrap()
 238        });
 239    };
 240}
 241
 242// keymap
 243define_query!(
 244    KEYMAP_QUERY_2025_01_29,
 245    migrations::m_2025_01_29::KEYMAP_PATTERNS
 246);
 247define_query!(
 248    KEYMAP_QUERY_2025_01_30,
 249    migrations::m_2025_01_30::KEYMAP_PATTERNS
 250);
 251define_query!(
 252    KEYMAP_QUERY_2025_03_03,
 253    migrations::m_2025_03_03::KEYMAP_PATTERNS
 254);
 255define_query!(
 256    KEYMAP_QUERY_2025_03_06,
 257    migrations::m_2025_03_06::KEYMAP_PATTERNS
 258);
 259define_query!(
 260    KEYMAP_QUERY_2025_04_15,
 261    migrations::m_2025_04_15::KEYMAP_PATTERNS
 262);
 263
 264// settings
 265define_query!(
 266    SETTINGS_QUERY_2025_01_02,
 267    migrations::m_2025_01_02::SETTINGS_PATTERNS
 268);
 269define_query!(
 270    SETTINGS_QUERY_2025_01_29,
 271    migrations::m_2025_01_29::SETTINGS_PATTERNS
 272);
 273define_query!(
 274    SETTINGS_QUERY_2025_01_30,
 275    migrations::m_2025_01_30::SETTINGS_PATTERNS
 276);
 277define_query!(
 278    SETTINGS_QUERY_2025_03_29,
 279    migrations::m_2025_03_29::SETTINGS_PATTERNS
 280);
 281define_query!(
 282    SETTINGS_QUERY_2025_04_15,
 283    migrations::m_2025_04_15::SETTINGS_PATTERNS
 284);
 285define_query!(
 286    SETTINGS_QUERY_2025_04_21,
 287    migrations::m_2025_04_21::SETTINGS_PATTERNS
 288);
 289define_query!(
 290    SETTINGS_QUERY_2025_04_23,
 291    migrations::m_2025_04_23::SETTINGS_PATTERNS
 292);
 293define_query!(
 294    SETTINGS_QUERY_2025_05_05,
 295    migrations::m_2025_05_05::SETTINGS_PATTERNS
 296);
 297define_query!(
 298    SETTINGS_QUERY_2025_05_08,
 299    migrations::m_2025_05_08::SETTINGS_PATTERNS
 300);
 301define_query!(
 302    SETTINGS_QUERY_2025_05_29,
 303    migrations::m_2025_05_29::SETTINGS_PATTERNS
 304);
 305define_query!(
 306    SETTINGS_QUERY_2025_06_16,
 307    migrations::m_2025_06_16::SETTINGS_PATTERNS
 308);
 309define_query!(
 310    SETTINGS_QUERY_2025_06_25,
 311    migrations::m_2025_06_25::SETTINGS_PATTERNS
 312);
 313define_query!(
 314    SETTINGS_QUERY_2025_06_27,
 315    migrations::m_2025_06_27::SETTINGS_PATTERNS
 316);
 317define_query!(
 318    SETTINGS_QUERY_2025_07_08,
 319    migrations::m_2025_07_08::SETTINGS_PATTERNS
 320);
 321define_query!(
 322    SETTINGS_QUERY_2025_10_01,
 323    migrations::m_2025_10_01::SETTINGS_PATTERNS
 324);
 325define_query!(
 326    SETTINGS_QUERY_2025_10_03,
 327    migrations::m_2025_10_03::SETTINGS_PATTERNS
 328);
 329
 330// custom query
 331static EDIT_PREDICTION_SETTINGS_MIGRATION_QUERY: LazyLock<Query> = LazyLock::new(|| {
 332    Query::new(
 333        &tree_sitter_json::LANGUAGE.into(),
 334        SETTINGS_NESTED_KEY_VALUE_PATTERN,
 335    )
 336    .unwrap()
 337});
 338
 339#[cfg(test)]
 340mod tests {
 341    use super::*;
 342    use unindent::Unindent as _;
 343
 344    fn assert_migrated_correctly(migrated: Option<String>, expected: Option<&str>) {
 345        match (&migrated, &expected) {
 346            (Some(migrated), Some(expected)) => {
 347                pretty_assertions::assert_str_eq!(migrated, expected);
 348            }
 349            _ => {
 350                pretty_assertions::assert_eq!(migrated.as_deref(), expected);
 351            }
 352        }
 353    }
 354
 355    fn assert_migrate_keymap(input: &str, output: Option<&str>) {
 356        let migrated = migrate_keymap(input).unwrap();
 357        pretty_assertions::assert_eq!(migrated.as_deref(), output);
 358    }
 359
 360    fn assert_migrate_settings(input: &str, output: Option<&str>) {
 361        let migrated = migrate_settings(input).unwrap();
 362        assert_migrated_correctly(migrated, output);
 363    }
 364
 365    fn assert_migrate_settings_with_migrations(
 366        migrations: &[MigrationType],
 367        input: &str,
 368        output: Option<&str>,
 369    ) {
 370        let migrated = run_migrations(input, migrations).unwrap();
 371        assert_migrated_correctly(migrated, output);
 372    }
 373
 374    #[test]
 375    fn test_replace_array_with_single_string() {
 376        assert_migrate_keymap(
 377            r#"
 378            [
 379                {
 380                    "bindings": {
 381                        "cmd-1": ["workspace::ActivatePaneInDirection", "Up"]
 382                    }
 383                }
 384            ]
 385            "#,
 386            Some(
 387                r#"
 388            [
 389                {
 390                    "bindings": {
 391                        "cmd-1": "workspace::ActivatePaneUp"
 392                    }
 393                }
 394            ]
 395            "#,
 396            ),
 397        )
 398    }
 399
 400    #[test]
 401    fn test_replace_action_argument_object_with_single_value() {
 402        assert_migrate_keymap(
 403            r#"
 404            [
 405                {
 406                    "bindings": {
 407                        "cmd-1": ["editor::FoldAtLevel", { "level": 1 }]
 408                    }
 409                }
 410            ]
 411            "#,
 412            Some(
 413                r#"
 414            [
 415                {
 416                    "bindings": {
 417                        "cmd-1": ["editor::FoldAtLevel", 1]
 418                    }
 419                }
 420            ]
 421            "#,
 422            ),
 423        )
 424    }
 425
 426    #[test]
 427    fn test_replace_action_argument_object_with_single_value_2() {
 428        assert_migrate_keymap(
 429            r#"
 430            [
 431                {
 432                    "bindings": {
 433                        "cmd-1": ["vim::PushOperator", { "Object": { "some" : "value" } }]
 434                    }
 435                }
 436            ]
 437            "#,
 438            Some(
 439                r#"
 440            [
 441                {
 442                    "bindings": {
 443                        "cmd-1": ["vim::PushObject", { "some" : "value" }]
 444                    }
 445                }
 446            ]
 447            "#,
 448            ),
 449        )
 450    }
 451
 452    #[test]
 453    fn test_rename_string_action() {
 454        assert_migrate_keymap(
 455            r#"
 456                [
 457                    {
 458                        "bindings": {
 459                            "cmd-1": "inline_completion::ToggleMenu"
 460                        }
 461                    }
 462                ]
 463            "#,
 464            Some(
 465                r#"
 466                [
 467                    {
 468                        "bindings": {
 469                            "cmd-1": "edit_prediction::ToggleMenu"
 470                        }
 471                    }
 472                ]
 473            "#,
 474            ),
 475        )
 476    }
 477
 478    #[test]
 479    fn test_rename_context_key() {
 480        assert_migrate_keymap(
 481            r#"
 482                [
 483                    {
 484                        "context": "Editor && inline_completion && !showing_completions"
 485                    }
 486                ]
 487            "#,
 488            Some(
 489                r#"
 490                [
 491                    {
 492                        "context": "Editor && edit_prediction && !showing_completions"
 493                    }
 494                ]
 495            "#,
 496            ),
 497        )
 498    }
 499
 500    #[test]
 501    fn test_incremental_migrations() {
 502        // Here string transforms to array internally. Then, that array transforms back to string.
 503        assert_migrate_keymap(
 504            r#"
 505                [
 506                    {
 507                        "bindings": {
 508                            "ctrl-q": "editor::GoToHunk", // should remain same
 509                            "ctrl-w": "editor::GoToPrevHunk", // should rename
 510                            "ctrl-q": ["editor::GoToHunk", { "center_cursor": true }], // should transform
 511                            "ctrl-w": ["editor::GoToPreviousHunk", { "center_cursor": true }] // should transform
 512                        }
 513                    }
 514                ]
 515            "#,
 516            Some(
 517                r#"
 518                [
 519                    {
 520                        "bindings": {
 521                            "ctrl-q": "editor::GoToHunk", // should remain same
 522                            "ctrl-w": "editor::GoToPreviousHunk", // should rename
 523                            "ctrl-q": "editor::GoToHunk", // should transform
 524                            "ctrl-w": "editor::GoToPreviousHunk" // should transform
 525                        }
 526                    }
 527                ]
 528            "#,
 529            ),
 530        )
 531    }
 532
 533    #[test]
 534    fn test_action_argument_snake_case() {
 535        // First performs transformations, then replacements
 536        assert_migrate_keymap(
 537            r#"
 538            [
 539                {
 540                    "bindings": {
 541                        "cmd-1": ["vim::PushOperator", { "Object": { "around": false } }],
 542                        "cmd-3": ["pane::CloseActiveItem", { "saveIntent": "saveAll" }],
 543                        "cmd-2": ["vim::NextWordStart", { "ignorePunctuation": true }],
 544                        "cmd-4": ["task::Spawn", { "task_name": "a b" }] // should remain as it is
 545                    }
 546                }
 547            ]
 548            "#,
 549            Some(
 550                r#"
 551            [
 552                {
 553                    "bindings": {
 554                        "cmd-1": ["vim::PushObject", { "around": false }],
 555                        "cmd-3": ["pane::CloseActiveItem", { "save_intent": "save_all" }],
 556                        "cmd-2": ["vim::NextWordStart", { "ignore_punctuation": true }],
 557                        "cmd-4": ["task::Spawn", { "task_name": "a b" }] // should remain as it is
 558                    }
 559                }
 560            ]
 561            "#,
 562            ),
 563        )
 564    }
 565
 566    #[test]
 567    fn test_replace_setting_name() {
 568        assert_migrate_settings(
 569            r#"
 570                {
 571                    "show_inline_completions_in_menu": true,
 572                    "show_inline_completions": true,
 573                    "inline_completions_disabled_in": ["string"],
 574                    "inline_completions": { "some" : "value" }
 575                }
 576            "#,
 577            Some(
 578                r#"
 579                {
 580                    "show_edit_predictions_in_menu": true,
 581                    "show_edit_predictions": true,
 582                    "edit_predictions_disabled_in": ["string"],
 583                    "edit_predictions": { "some" : "value" }
 584                }
 585            "#,
 586            ),
 587        )
 588    }
 589
 590    #[test]
 591    fn test_nested_string_replace_for_settings() {
 592        assert_migrate_settings(
 593            r#"
 594                {
 595                    "features": {
 596                        "inline_completion_provider": "zed"
 597                    },
 598                }
 599            "#,
 600            Some(
 601                r#"
 602                {
 603                    "features": {
 604                        "edit_prediction_provider": "zed"
 605                    },
 606                }
 607            "#,
 608            ),
 609        )
 610    }
 611
 612    #[test]
 613    fn test_replace_settings_in_languages() {
 614        assert_migrate_settings(
 615            r#"
 616                {
 617                    "languages": {
 618                        "Astro": {
 619                            "show_inline_completions": true
 620                        }
 621                    }
 622                }
 623            "#,
 624            Some(
 625                r#"
 626                {
 627                    "languages": {
 628                        "Astro": {
 629                            "show_edit_predictions": true
 630                        }
 631                    }
 632                }
 633            "#,
 634            ),
 635        )
 636    }
 637
 638    #[test]
 639    fn test_replace_settings_value() {
 640        assert_migrate_settings(
 641            r#"
 642                {
 643                    "scrollbar": {
 644                        "diagnostics": true
 645                    },
 646                    "chat_panel": {
 647                        "button": true
 648                    }
 649                }
 650            "#,
 651            Some(
 652                r#"
 653                {
 654                    "scrollbar": {
 655                        "diagnostics": "all"
 656                    },
 657                    "chat_panel": {
 658                        "button": "always"
 659                    }
 660                }
 661            "#,
 662            ),
 663        )
 664    }
 665
 666    #[test]
 667    fn test_replace_settings_name_and_value() {
 668        assert_migrate_settings(
 669            r#"
 670                {
 671                    "tabs": {
 672                        "always_show_close_button": true
 673                    }
 674                }
 675            "#,
 676            Some(
 677                r#"
 678                {
 679                    "tabs": {
 680                        "show_close_button": "always"
 681                    }
 682                }
 683            "#,
 684            ),
 685        )
 686    }
 687
 688    #[test]
 689    fn test_replace_bash_with_terminal_in_profiles() {
 690        assert_migrate_settings(
 691            r#"
 692                {
 693                    "assistant": {
 694                        "profiles": {
 695                            "custom": {
 696                                "name": "Custom",
 697                                "tools": {
 698                                    "bash": true,
 699                                    "diagnostics": true
 700                                }
 701                            }
 702                        }
 703                    }
 704                }
 705            "#,
 706            Some(
 707                r#"
 708                {
 709                    "agent": {
 710                        "profiles": {
 711                            "custom": {
 712                                "name": "Custom",
 713                                "tools": {
 714                                    "terminal": true,
 715                                    "diagnostics": true
 716                                }
 717                            }
 718                        }
 719                    }
 720                }
 721            "#,
 722            ),
 723        )
 724    }
 725
 726    #[test]
 727    fn test_replace_bash_false_with_terminal_in_profiles() {
 728        assert_migrate_settings(
 729            r#"
 730                {
 731                    "assistant": {
 732                        "profiles": {
 733                            "custom": {
 734                                "name": "Custom",
 735                                "tools": {
 736                                    "bash": false,
 737                                    "diagnostics": true
 738                                }
 739                            }
 740                        }
 741                    }
 742                }
 743            "#,
 744            Some(
 745                r#"
 746                {
 747                    "agent": {
 748                        "profiles": {
 749                            "custom": {
 750                                "name": "Custom",
 751                                "tools": {
 752                                    "terminal": false,
 753                                    "diagnostics": true
 754                                }
 755                            }
 756                        }
 757                    }
 758                }
 759            "#,
 760            ),
 761        )
 762    }
 763
 764    #[test]
 765    fn test_no_bash_in_profiles() {
 766        assert_migrate_settings(
 767            r#"
 768                {
 769                    "assistant": {
 770                        "profiles": {
 771                            "custom": {
 772                                "name": "Custom",
 773                                "tools": {
 774                                    "diagnostics": true,
 775                                    "find_path": true,
 776                                    "read_file": true
 777                                }
 778                            }
 779                        }
 780                    }
 781                }
 782            "#,
 783            Some(
 784                r#"
 785                {
 786                    "agent": {
 787                        "profiles": {
 788                            "custom": {
 789                                "name": "Custom",
 790                                "tools": {
 791                                    "diagnostics": true,
 792                                    "find_path": true,
 793                                    "read_file": true
 794                                }
 795                            }
 796                        }
 797                    }
 798                }
 799            "#,
 800            ),
 801        )
 802    }
 803
 804    #[test]
 805    fn test_rename_path_search_to_find_path() {
 806        assert_migrate_settings(
 807            r#"
 808                {
 809                    "assistant": {
 810                        "profiles": {
 811                            "default": {
 812                                "tools": {
 813                                    "path_search": true,
 814                                    "read_file": true
 815                                }
 816                            }
 817                        }
 818                    }
 819                }
 820            "#,
 821            Some(
 822                r#"
 823                {
 824                    "agent": {
 825                        "profiles": {
 826                            "default": {
 827                                "tools": {
 828                                    "find_path": true,
 829                                    "read_file": true
 830                                }
 831                            }
 832                        }
 833                    }
 834                }
 835            "#,
 836            ),
 837        );
 838    }
 839
 840    #[test]
 841    fn test_rename_assistant() {
 842        assert_migrate_settings(
 843            r#"{
 844                "assistant": {
 845                    "foo": "bar"
 846                },
 847                "edit_predictions": {
 848                    "enabled_in_assistant": false,
 849                }
 850            }"#,
 851            Some(
 852                r#"{
 853                "agent": {
 854                    "foo": "bar"
 855                },
 856                "edit_predictions": {
 857                    "enabled_in_text_threads": false,
 858                }
 859            }"#,
 860            ),
 861        );
 862    }
 863
 864    #[test]
 865    fn test_comment_duplicated_agent() {
 866        assert_migrate_settings(
 867            r#"{
 868                "agent": {
 869                    "name": "assistant-1",
 870                "model": "gpt-4", // weird formatting
 871                    "utf8": "привіт"
 872                },
 873                "something": "else",
 874                "agent": {
 875                    "name": "assistant-2",
 876                    "model": "gemini-pro"
 877                }
 878            }
 879        "#,
 880            Some(
 881                r#"{
 882                /* Duplicated key auto-commented: "agent": {
 883                    "name": "assistant-1",
 884                "model": "gpt-4", // weird formatting
 885                    "utf8": "привіт"
 886                }, */
 887                "something": "else",
 888                "agent": {
 889                    "name": "assistant-2",
 890                    "model": "gemini-pro"
 891                }
 892            }
 893        "#,
 894            ),
 895        );
 896    }
 897
 898    #[test]
 899    fn test_preferred_completion_mode_migration() {
 900        assert_migrate_settings(
 901            r#"{
 902                "agent": {
 903                    "preferred_completion_mode": "max",
 904                    "enabled": true
 905                }
 906            }"#,
 907            Some(
 908                r#"{
 909                "agent": {
 910                    "preferred_completion_mode": "burn",
 911                    "enabled": true
 912                }
 913            }"#,
 914            ),
 915        );
 916
 917        assert_migrate_settings(
 918            r#"{
 919                "agent": {
 920                    "preferred_completion_mode": "normal",
 921                    "enabled": true
 922                }
 923            }"#,
 924            None,
 925        );
 926
 927        assert_migrate_settings(
 928            r#"{
 929                "agent": {
 930                    "preferred_completion_mode": "burn",
 931                    "enabled": true
 932                }
 933            }"#,
 934            None,
 935        );
 936
 937        assert_migrate_settings(
 938            r#"{
 939                "other_section": {
 940                    "preferred_completion_mode": "max"
 941                },
 942                "agent": {
 943                    "preferred_completion_mode": "max"
 944                }
 945            }"#,
 946            Some(
 947                r#"{
 948                "other_section": {
 949                    "preferred_completion_mode": "max"
 950                },
 951                "agent": {
 952                    "preferred_completion_mode": "burn"
 953                }
 954            }"#,
 955            ),
 956        );
 957    }
 958
 959    #[test]
 960    fn test_mcp_settings_migration() {
 961        assert_migrate_settings_with_migrations(
 962            &[MigrationType::TreeSitter(
 963                migrations::m_2025_06_16::SETTINGS_PATTERNS,
 964                &SETTINGS_QUERY_2025_06_16,
 965            )],
 966            r#"{
 967    "context_servers": {
 968        "empty_server": {},
 969        "extension_server": {
 970            "settings": {
 971                "foo": "bar"
 972            }
 973        },
 974        "custom_server": {
 975            "command": {
 976                "path": "foo",
 977                "args": ["bar"],
 978                "env": {
 979                    "FOO": "BAR"
 980                }
 981            }
 982        },
 983        "invalid_server": {
 984            "command": {
 985                "path": "foo",
 986                "args": ["bar"],
 987                "env": {
 988                    "FOO": "BAR"
 989                }
 990            },
 991            "settings": {
 992                "foo": "bar"
 993            }
 994        },
 995        "empty_server2": {},
 996        "extension_server2": {
 997            "foo": "bar",
 998            "settings": {
 999                "foo": "bar"
1000            },
1001            "bar": "foo"
1002        },
1003        "custom_server2": {
1004            "foo": "bar",
1005            "command": {
1006                "path": "foo",
1007                "args": ["bar"],
1008                "env": {
1009                    "FOO": "BAR"
1010                }
1011            },
1012            "bar": "foo"
1013        },
1014        "invalid_server2": {
1015            "foo": "bar",
1016            "command": {
1017                "path": "foo",
1018                "args": ["bar"],
1019                "env": {
1020                    "FOO": "BAR"
1021                }
1022            },
1023            "bar": "foo",
1024            "settings": {
1025                "foo": "bar"
1026            }
1027        }
1028    }
1029}"#,
1030            Some(
1031                r#"{
1032    "context_servers": {
1033        "empty_server": {
1034            "source": "extension",
1035            "settings": {}
1036        },
1037        "extension_server": {
1038            "source": "extension",
1039            "settings": {
1040                "foo": "bar"
1041            }
1042        },
1043        "custom_server": {
1044            "source": "custom",
1045            "command": {
1046                "path": "foo",
1047                "args": ["bar"],
1048                "env": {
1049                    "FOO": "BAR"
1050                }
1051            }
1052        },
1053        "invalid_server": {
1054            "source": "custom",
1055            "command": {
1056                "path": "foo",
1057                "args": ["bar"],
1058                "env": {
1059                    "FOO": "BAR"
1060                }
1061            },
1062            "settings": {
1063                "foo": "bar"
1064            }
1065        },
1066        "empty_server2": {
1067            "source": "extension",
1068            "settings": {}
1069        },
1070        "extension_server2": {
1071            "source": "extension",
1072            "foo": "bar",
1073            "settings": {
1074                "foo": "bar"
1075            },
1076            "bar": "foo"
1077        },
1078        "custom_server2": {
1079            "source": "custom",
1080            "foo": "bar",
1081            "command": {
1082                "path": "foo",
1083                "args": ["bar"],
1084                "env": {
1085                    "FOO": "BAR"
1086                }
1087            },
1088            "bar": "foo"
1089        },
1090        "invalid_server2": {
1091            "source": "custom",
1092            "foo": "bar",
1093            "command": {
1094                "path": "foo",
1095                "args": ["bar"],
1096                "env": {
1097                    "FOO": "BAR"
1098                }
1099            },
1100            "bar": "foo",
1101            "settings": {
1102                "foo": "bar"
1103            }
1104        }
1105    }
1106}"#,
1107            ),
1108        );
1109    }
1110
1111    #[test]
1112    fn test_mcp_settings_migration_doesnt_change_valid_settings() {
1113        let settings = r#"{
1114    "context_servers": {
1115        "empty_server": {
1116            "source": "extension",
1117            "settings": {}
1118        },
1119        "extension_server": {
1120            "source": "extension",
1121            "settings": {
1122                "foo": "bar"
1123            }
1124        },
1125        "custom_server": {
1126            "source": "custom",
1127            "command": {
1128                "path": "foo",
1129                "args": ["bar"],
1130                "env": {
1131                    "FOO": "BAR"
1132                }
1133            }
1134        },
1135        "invalid_server": {
1136            "source": "custom",
1137            "command": {
1138                "path": "foo",
1139                "args": ["bar"],
1140                "env": {
1141                    "FOO": "BAR"
1142                }
1143            },
1144            "settings": {
1145                "foo": "bar"
1146            }
1147        }
1148    }
1149}"#;
1150        assert_migrate_settings_with_migrations(
1151            &[MigrationType::TreeSitter(
1152                migrations::m_2025_06_16::SETTINGS_PATTERNS,
1153                &SETTINGS_QUERY_2025_06_16,
1154            )],
1155            settings,
1156            None,
1157        );
1158    }
1159
1160    #[test]
1161    fn test_remove_version_fields() {
1162        assert_migrate_settings(
1163            r#"{
1164    "language_models": {
1165        "anthropic": {
1166            "version": "1",
1167            "api_url": "https://api.anthropic.com"
1168        },
1169        "openai": {
1170            "version": "1",
1171            "api_url": "https://api.openai.com/v1"
1172        }
1173    },
1174    "agent": {
1175        "version": "2",
1176        "enabled": true,
1177        "preferred_completion_mode": "normal",
1178        "button": true,
1179        "dock": "right",
1180        "default_width": 640,
1181        "default_height": 320,
1182        "default_model": {
1183            "provider": "zed.dev",
1184            "model": "claude-sonnet-4"
1185        }
1186    }
1187}"#,
1188            Some(
1189                r#"{
1190    "language_models": {
1191        "anthropic": {
1192            "api_url": "https://api.anthropic.com"
1193        },
1194        "openai": {
1195            "api_url": "https://api.openai.com/v1"
1196        }
1197    },
1198    "agent": {
1199        "enabled": true,
1200        "preferred_completion_mode": "normal",
1201        "button": true,
1202        "dock": "right",
1203        "default_width": 640,
1204        "default_height": 320,
1205        "default_model": {
1206            "provider": "zed.dev",
1207            "model": "claude-sonnet-4"
1208        }
1209    }
1210}"#,
1211            ),
1212        );
1213
1214        // Test that version fields in other contexts are not removed
1215        assert_migrate_settings(
1216            r#"{
1217    "language_models": {
1218        "other_provider": {
1219            "version": "1",
1220            "api_url": "https://api.example.com"
1221        }
1222    },
1223    "other_section": {
1224        "version": "1"
1225    }
1226}"#,
1227            None,
1228        );
1229    }
1230
1231    #[test]
1232    fn test_flatten_context_server_command() {
1233        assert_migrate_settings(
1234            r#"{
1235    "context_servers": {
1236        "some-mcp-server": {
1237            "source": "custom",
1238            "command": {
1239                "path": "npx",
1240                "args": [
1241                    "-y",
1242                    "@supabase/mcp-server-supabase@latest",
1243                    "--read-only",
1244                    "--project-ref=<project-ref>"
1245                ],
1246                "env": {
1247                    "SUPABASE_ACCESS_TOKEN": "<personal-access-token>"
1248                }
1249            }
1250        }
1251    }
1252}"#,
1253            Some(
1254                r#"{
1255    "context_servers": {
1256        "some-mcp-server": {
1257            "source": "custom",
1258            "command": "npx",
1259            "args": [
1260                "-y",
1261                "@supabase/mcp-server-supabase@latest",
1262                "--read-only",
1263                "--project-ref=<project-ref>"
1264            ],
1265            "env": {
1266                "SUPABASE_ACCESS_TOKEN": "<personal-access-token>"
1267            }
1268        }
1269    }
1270}"#,
1271            ),
1272        );
1273
1274        // Test with additional keys in server object
1275        assert_migrate_settings(
1276            r#"{
1277    "context_servers": {
1278        "server-with-extras": {
1279            "source": "custom",
1280            "command": {
1281                "path": "/usr/bin/node",
1282                "args": ["server.js"]
1283            },
1284            "settings": {}
1285        }
1286    }
1287}"#,
1288            Some(
1289                r#"{
1290    "context_servers": {
1291        "server-with-extras": {
1292            "source": "custom",
1293            "command": "/usr/bin/node",
1294            "args": ["server.js"],
1295            "settings": {}
1296        }
1297    }
1298}"#,
1299            ),
1300        );
1301
1302        // Test command without args or env
1303        assert_migrate_settings(
1304            r#"{
1305    "context_servers": {
1306        "simple-server": {
1307            "source": "custom",
1308            "command": {
1309                "path": "simple-mcp-server"
1310            }
1311        }
1312    }
1313}"#,
1314            Some(
1315                r#"{
1316    "context_servers": {
1317        "simple-server": {
1318            "source": "custom",
1319            "command": "simple-mcp-server"
1320        }
1321    }
1322}"#,
1323            ),
1324        );
1325    }
1326
1327    #[test]
1328    fn test_flatten_code_action_formatters_basic_array() {
1329        assert_migrate_settings(
1330            &r#"{
1331                "formatter": [
1332                  {
1333                      "code_actions": {
1334                          "included-1": true,
1335                          "included-2": true,
1336                          "excluded": false,
1337                      }
1338                  }
1339                ]
1340            }"#
1341            .unindent(),
1342            Some(
1343                &r#"{
1344                "formatter": [
1345                  { "code_action": "included-1" },
1346                  { "code_action": "included-2" }
1347                ]
1348            }"#
1349                .unindent(),
1350            ),
1351        );
1352    }
1353
1354    #[test]
1355    fn test_flatten_code_action_formatters_basic_object() {
1356        assert_migrate_settings(
1357            &r#"{
1358                "formatter": {
1359                    "code_actions": {
1360                        "included-1": true,
1361                        "excluded": false,
1362                        "included-2": true
1363                    }
1364                }
1365            }"#
1366            .unindent(),
1367            Some(
1368                &r#"{
1369                    "formatter": [
1370                      { "code_action": "included-1" },
1371                      { "code_action": "included-2" }
1372                    ]
1373                }"#
1374                .unindent(),
1375            ),
1376        );
1377    }
1378
1379    #[test]
1380    fn test_flatten_code_action_formatters_array_with_multiple_action_blocks() {
1381        assert_migrate_settings(
1382            r#"{
1383                "formatter": [
1384                  {
1385                      "code_actions": {
1386                          "included-1": true,
1387                          "included-2": true,
1388                          "excluded": false,
1389                      }
1390                  },
1391                  {
1392                    "language_server": "ruff"
1393                  },
1394                  {
1395                      "code_actions": {
1396                          "excluded": false,
1397                          "excluded-2": false,
1398                      }
1399                  }
1400                  // some comment
1401                  ,
1402                  {
1403                      "code_actions": {
1404                        "excluded": false,
1405                        "included-3": true,
1406                        "included-4": true,
1407                      }
1408                  },
1409                ]
1410            }"#,
1411            Some(
1412                r#"{
1413                "formatter": [
1414                  { "code_action": "included-1" },
1415                  { "code_action": "included-2" },
1416                  {
1417                    "language_server": "ruff"
1418                  },
1419                  { "code_action": "included-3" },
1420                  { "code_action": "included-4" },
1421                ]
1422            }"#,
1423            ),
1424        );
1425    }
1426
1427    #[test]
1428    fn test_flatten_code_action_formatters_array_with_multiple_action_blocks_in_languages() {
1429        assert_migrate_settings(
1430            &r#"{
1431                "languages": {
1432                    "Rust": {
1433                        "formatter": [
1434                          {
1435                              "code_actions": {
1436                                  "included-1": true,
1437                                  "included-2": true,
1438                                  "excluded": false,
1439                              }
1440                          },
1441                          {
1442                              "language_server": "ruff"
1443                          },
1444                          {
1445                              "code_actions": {
1446                                  "excluded": false,
1447                                  "excluded-2": false,
1448                              }
1449                          }
1450                          // some comment
1451                          ,
1452                          {
1453                              "code_actions": {
1454                                  "excluded": false,
1455                                  "included-3": true,
1456                                  "included-4": true,
1457                              }
1458                          },
1459                        ]
1460                    }
1461                }
1462            }"#
1463            .unindent(),
1464            Some(
1465                &r#"{
1466                    "languages": {
1467                        "Rust": {
1468                            "formatter": [
1469                              { "code_action": "included-1" },
1470                              { "code_action": "included-2" },
1471                              {
1472                                  "language_server": "ruff"
1473                              },
1474                              { "code_action": "included-3" },
1475                              { "code_action": "included-4" },
1476                            ]
1477                        }
1478                    }
1479                }"#
1480                .unindent(),
1481            ),
1482        );
1483    }
1484
1485    #[test]
1486    fn test_flatten_code_action_formatters_array_with_multiple_action_blocks_in_defaults_and_multiple_languages()
1487     {
1488        assert_migrate_settings(
1489            &r#"{
1490                "formatter": {
1491                    "code_actions": {
1492                        "default-1": true,
1493                        "default-2": true,
1494                        "default-3": true,
1495                        "default-4": true,
1496                    }
1497                },
1498                "languages": {
1499                    "Rust": {
1500                        "formatter": [
1501                          {
1502                              "code_actions": {
1503                                  "included-1": true,
1504                                  "included-2": true,
1505                                  "excluded": false,
1506                              }
1507                          },
1508                          {
1509                              "language_server": "ruff"
1510                          },
1511                          {
1512                              "code_actions": {
1513                                  "excluded": false,
1514                                  "excluded-2": false,
1515                              }
1516                          }
1517                          // some comment
1518                          ,
1519                          {
1520                              "code_actions": {
1521                                  "excluded": false,
1522                                  "included-3": true,
1523                                  "included-4": true,
1524                              }
1525                          },
1526                        ]
1527                    },
1528                    "Python": {
1529                        "formatter": [
1530                          {
1531                              "language_server": "ruff"
1532                          },
1533                          {
1534                              "code_actions": {
1535                                  "excluded": false,
1536                                  "excluded-2": false,
1537                              }
1538                          }
1539                          // some comment
1540                          ,
1541                          {
1542                              "code_actions": {
1543                                  "excluded": false,
1544                                  "included-3": true,
1545                                  "included-4": true,
1546                              }
1547                          },
1548                        ]
1549                    }
1550                }
1551            }"#
1552            .unindent(),
1553            Some(
1554                &r#"{
1555                    "formatter": [
1556                      { "code_action": "default-1" },
1557                      { "code_action": "default-2" },
1558                      { "code_action": "default-3" },
1559                      { "code_action": "default-4" }
1560                    ],
1561                    "languages": {
1562                        "Rust": {
1563                            "formatter": [
1564                              { "code_action": "included-1" },
1565                              { "code_action": "included-2" },
1566                              {
1567                                  "language_server": "ruff"
1568                              },
1569                              { "code_action": "included-3" },
1570                              { "code_action": "included-4" },
1571                            ]
1572                        },
1573                        "Python": {
1574                            "formatter": [
1575                              {
1576                                  "language_server": "ruff"
1577                              },
1578                              { "code_action": "included-3" },
1579                              { "code_action": "included-4" },
1580                            ]
1581                        }
1582                    }
1583                }"#
1584                .unindent(),
1585            ),
1586        );
1587    }
1588
1589    #[test]
1590    fn test_flatten_code_action_formatters_array_with_format_on_save_and_multiple_languages() {
1591        assert_migrate_settings_with_migrations(
1592            &[MigrationType::TreeSitter(
1593                migrations::m_2025_10_01::SETTINGS_PATTERNS,
1594                &SETTINGS_QUERY_2025_10_01,
1595            )],
1596            &r#"{
1597                "formatter": {
1598                    "code_actions": {
1599                        "default-1": true,
1600                        "default-2": true,
1601                        "default-3": true,
1602                        "default-4": true,
1603                    }
1604                },
1605                "format_on_save": [
1606                  {
1607                      "code_actions": {
1608                          "included-1": true,
1609                          "included-2": true,
1610                          "excluded": false,
1611                      }
1612                  },
1613                  {
1614                      "language_server": "ruff"
1615                  },
1616                  {
1617                      "code_actions": {
1618                          "excluded": false,
1619                          "excluded-2": false,
1620                      }
1621                  }
1622                  // some comment
1623                  ,
1624                  {
1625                      "code_actions": {
1626                          "excluded": false,
1627                          "included-3": true,
1628                          "included-4": true,
1629                      }
1630                  },
1631                ],
1632                "languages": {
1633                    "Rust": {
1634                        "format_on_save": "prettier",
1635                        "formatter": [
1636                          {
1637                              "code_actions": {
1638                                  "included-1": true,
1639                                  "included-2": true,
1640                                  "excluded": false,
1641                              }
1642                          },
1643                          {
1644                              "language_server": "ruff"
1645                          },
1646                          {
1647                              "code_actions": {
1648                                  "excluded": false,
1649                                  "excluded-2": false,
1650                              }
1651                          }
1652                          // some comment
1653                          ,
1654                          {
1655                              "code_actions": {
1656                                  "excluded": false,
1657                                  "included-3": true,
1658                                  "included-4": true,
1659                              }
1660                          },
1661                        ]
1662                    },
1663                    "Python": {
1664                        "format_on_save": {
1665                            "code_actions": {
1666                                "on-save-1": true,
1667                                "on-save-2": true,
1668                            }
1669                        },
1670                        "formatter": [
1671                          {
1672                              "language_server": "ruff"
1673                          },
1674                          {
1675                              "code_actions": {
1676                                  "excluded": false,
1677                                  "excluded-2": false,
1678                              }
1679                          }
1680                          // some comment
1681                          ,
1682                          {
1683                              "code_actions": {
1684                                  "excluded": false,
1685                                  "included-3": true,
1686                                  "included-4": true,
1687                              }
1688                          },
1689                        ]
1690                    }
1691                }
1692            }"#
1693            .unindent(),
1694            Some(
1695                &r#"{
1696                    "formatter": [
1697                      { "code_action": "default-1" },
1698                      { "code_action": "default-2" },
1699                      { "code_action": "default-3" },
1700                      { "code_action": "default-4" }
1701                    ],
1702                    "format_on_save": [
1703                      { "code_action": "included-1" },
1704                      { "code_action": "included-2" },
1705                      {
1706                          "language_server": "ruff"
1707                      },
1708                      { "code_action": "included-3" },
1709                      { "code_action": "included-4" },
1710                    ],
1711                    "languages": {
1712                        "Rust": {
1713                            "format_on_save": "prettier",
1714                            "formatter": [
1715                              { "code_action": "included-1" },
1716                              { "code_action": "included-2" },
1717                              {
1718                                  "language_server": "ruff"
1719                              },
1720                              { "code_action": "included-3" },
1721                              { "code_action": "included-4" },
1722                            ]
1723                        },
1724                        "Python": {
1725                            "format_on_save": [
1726                              { "code_action": "on-save-1" },
1727                              { "code_action": "on-save-2" }
1728                            ],
1729                            "formatter": [
1730                              {
1731                                  "language_server": "ruff"
1732                              },
1733                              { "code_action": "included-3" },
1734                              { "code_action": "included-4" },
1735                            ]
1736                        }
1737                    }
1738                }"#
1739                .unindent(),
1740            ),
1741        );
1742    }
1743
1744    #[test]
1745    fn test_format_on_save_formatter_migration_basic() {
1746        assert_migrate_settings_with_migrations(
1747            &[MigrationType::Json(
1748                migrations::m_2025_10_02::remove_formatters_on_save,
1749            )],
1750            &r#"{
1751                  "format_on_save": "prettier"
1752              }"#
1753            .unindent(),
1754            Some(
1755                &r#"{
1756                      "formatter": "prettier",
1757                      "format_on_save": "on"
1758                  }"#
1759                .unindent(),
1760            ),
1761        );
1762    }
1763
1764    #[test]
1765    fn test_format_on_save_formatter_migration_array() {
1766        assert_migrate_settings_with_migrations(
1767            &[MigrationType::Json(
1768                migrations::m_2025_10_02::remove_formatters_on_save,
1769            )],
1770            &r#"{
1771                "format_on_save": ["prettier", {"language_server": "eslint"}]
1772            }"#
1773            .unindent(),
1774            Some(
1775                &r#"{
1776                    "formatter": [
1777                        "prettier",
1778                        {
1779                            "language_server": "eslint"
1780                        }
1781                    ],
1782                    "format_on_save": "on"
1783                }"#
1784                .unindent(),
1785            ),
1786        );
1787    }
1788
1789    #[test]
1790    fn test_format_on_save_on_off_unchanged() {
1791        assert_migrate_settings_with_migrations(
1792            &[MigrationType::Json(
1793                migrations::m_2025_10_02::remove_formatters_on_save,
1794            )],
1795            &r#"{
1796                "format_on_save": "on"
1797            }"#
1798            .unindent(),
1799            None,
1800        );
1801
1802        assert_migrate_settings_with_migrations(
1803            &[MigrationType::Json(
1804                migrations::m_2025_10_02::remove_formatters_on_save,
1805            )],
1806            &r#"{
1807                "format_on_save": "off"
1808            }"#
1809            .unindent(),
1810            None,
1811        );
1812    }
1813
1814    #[test]
1815    fn test_format_on_save_formatter_migration_in_languages() {
1816        assert_migrate_settings_with_migrations(
1817            &[MigrationType::Json(
1818                migrations::m_2025_10_02::remove_formatters_on_save,
1819            )],
1820            &r#"{
1821                "languages": {
1822                    "Rust": {
1823                        "format_on_save": "rust-analyzer"
1824                    },
1825                    "Python": {
1826                        "format_on_save": ["ruff", "black"]
1827                    }
1828                }
1829            }"#
1830            .unindent(),
1831            Some(
1832                &r#"{
1833                    "languages": {
1834                        "Rust": {
1835                            "formatter": "rust-analyzer",
1836                            "format_on_save": "on"
1837                        },
1838                        "Python": {
1839                            "formatter": [
1840                                "ruff",
1841                                "black"
1842                            ],
1843                            "format_on_save": "on"
1844                        }
1845                    }
1846                }"#
1847                .unindent(),
1848            ),
1849        );
1850    }
1851
1852    #[test]
1853    fn test_format_on_save_formatter_migration_mixed_global_and_languages() {
1854        assert_migrate_settings_with_migrations(
1855            &[MigrationType::Json(
1856                migrations::m_2025_10_02::remove_formatters_on_save,
1857            )],
1858            &r#"{
1859                "format_on_save": "prettier",
1860                "languages": {
1861                    "Rust": {
1862                        "format_on_save": "rust-analyzer"
1863                    },
1864                    "Python": {
1865                        "format_on_save": "on"
1866                    }
1867                }
1868            }"#
1869            .unindent(),
1870            Some(
1871                &r#"{
1872                    "formatter": "prettier",
1873                    "format_on_save": "on",
1874                    "languages": {
1875                        "Rust": {
1876                            "formatter": "rust-analyzer",
1877                            "format_on_save": "on"
1878                        },
1879                        "Python": {
1880                            "format_on_save": "on"
1881                        }
1882                    }
1883                }"#
1884                .unindent(),
1885            ),
1886        );
1887    }
1888
1889    #[test]
1890    fn test_format_on_save_no_migration_when_no_format_on_save() {
1891        assert_migrate_settings_with_migrations(
1892            &[MigrationType::Json(
1893                migrations::m_2025_10_02::remove_formatters_on_save,
1894            )],
1895            &r#"{
1896                "formatter": ["prettier"]
1897            }"#
1898            .unindent(),
1899            None,
1900        );
1901    }
1902}