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