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(
  69    text: &str,
  70    migrations: &[(MigrationPatterns, &Query)],
  71) -> Result<Option<String>> {
  72    let mut current_text = text.to_string();
  73    let mut result: Option<String> = None;
  74    for (patterns, query) in migrations.iter() {
  75        if let Some(migrated_text) = migrate(&current_text, patterns, query)? {
  76            current_text = migrated_text.clone();
  77            result = Some(migrated_text);
  78        }
  79    }
  80    Ok(result.filter(|new_text| text != new_text))
  81}
  82
  83pub fn migrate_keymap(text: &str) -> Result<Option<String>> {
  84    let migrations: &[(MigrationPatterns, &Query)] = &[
  85        (
  86            migrations::m_2025_01_29::KEYMAP_PATTERNS,
  87            &KEYMAP_QUERY_2025_01_29,
  88        ),
  89        (
  90            migrations::m_2025_01_30::KEYMAP_PATTERNS,
  91            &KEYMAP_QUERY_2025_01_30,
  92        ),
  93        (
  94            migrations::m_2025_03_03::KEYMAP_PATTERNS,
  95            &KEYMAP_QUERY_2025_03_03,
  96        ),
  97        (
  98            migrations::m_2025_03_06::KEYMAP_PATTERNS,
  99            &KEYMAP_QUERY_2025_03_06,
 100        ),
 101        (
 102            migrations::m_2025_04_15::KEYMAP_PATTERNS,
 103            &KEYMAP_QUERY_2025_04_15,
 104        ),
 105    ];
 106    run_migrations(text, migrations)
 107}
 108
 109pub fn migrate_settings(text: &str) -> Result<Option<String>> {
 110    let migrations: &[(MigrationPatterns, &Query)] = &[
 111        (
 112            migrations::m_2025_01_02::SETTINGS_PATTERNS,
 113            &SETTINGS_QUERY_2025_01_02,
 114        ),
 115        (
 116            migrations::m_2025_01_29::SETTINGS_PATTERNS,
 117            &SETTINGS_QUERY_2025_01_29,
 118        ),
 119        (
 120            migrations::m_2025_01_30::SETTINGS_PATTERNS,
 121            &SETTINGS_QUERY_2025_01_30,
 122        ),
 123        (
 124            migrations::m_2025_03_29::SETTINGS_PATTERNS,
 125            &SETTINGS_QUERY_2025_03_29,
 126        ),
 127        (
 128            migrations::m_2025_04_15::SETTINGS_PATTERNS,
 129            &SETTINGS_QUERY_2025_04_15,
 130        ),
 131        (
 132            migrations::m_2025_04_21::SETTINGS_PATTERNS,
 133            &SETTINGS_QUERY_2025_04_21,
 134        ),
 135        (
 136            migrations::m_2025_04_23::SETTINGS_PATTERNS,
 137            &SETTINGS_QUERY_2025_04_23,
 138        ),
 139        (
 140            migrations::m_2025_05_05::SETTINGS_PATTERNS,
 141            &SETTINGS_QUERY_2025_05_05,
 142        ),
 143        (
 144            migrations::m_2025_05_08::SETTINGS_PATTERNS,
 145            &SETTINGS_QUERY_2025_05_08,
 146        ),
 147        (
 148            migrations::m_2025_05_29::SETTINGS_PATTERNS,
 149            &SETTINGS_QUERY_2025_05_29,
 150        ),
 151        (
 152            migrations::m_2025_06_16::SETTINGS_PATTERNS,
 153            &SETTINGS_QUERY_2025_06_16,
 154        ),
 155        (
 156            migrations::m_2025_06_25::SETTINGS_PATTERNS,
 157            &SETTINGS_QUERY_2025_06_25,
 158        ),
 159        (
 160            migrations::m_2025_06_27::SETTINGS_PATTERNS,
 161            &SETTINGS_QUERY_2025_06_27,
 162        ),
 163    ];
 164    run_migrations(text, migrations)
 165}
 166
 167pub fn migrate_edit_prediction_provider_settings(text: &str) -> Result<Option<String>> {
 168    migrate(
 169        &text,
 170        &[(
 171            SETTINGS_NESTED_KEY_VALUE_PATTERN,
 172            migrations::m_2025_01_29::replace_edit_prediction_provider_setting,
 173        )],
 174        &EDIT_PREDICTION_SETTINGS_MIGRATION_QUERY,
 175    )
 176}
 177
 178pub type MigrationPatterns = &'static [(
 179    &'static str,
 180    fn(&str, &QueryMatch, &Query) -> Option<(Range<usize>, String)>,
 181)];
 182
 183macro_rules! define_query {
 184    ($var_name:ident, $patterns_path:path) => {
 185        static $var_name: LazyLock<Query> = LazyLock::new(|| {
 186            Query::new(
 187                &tree_sitter_json::LANGUAGE.into(),
 188                &$patterns_path
 189                    .iter()
 190                    .map(|pattern| pattern.0)
 191                    .collect::<String>(),
 192            )
 193            .unwrap()
 194        });
 195    };
 196}
 197
 198// keymap
 199define_query!(
 200    KEYMAP_QUERY_2025_01_29,
 201    migrations::m_2025_01_29::KEYMAP_PATTERNS
 202);
 203define_query!(
 204    KEYMAP_QUERY_2025_01_30,
 205    migrations::m_2025_01_30::KEYMAP_PATTERNS
 206);
 207define_query!(
 208    KEYMAP_QUERY_2025_03_03,
 209    migrations::m_2025_03_03::KEYMAP_PATTERNS
 210);
 211define_query!(
 212    KEYMAP_QUERY_2025_03_06,
 213    migrations::m_2025_03_06::KEYMAP_PATTERNS
 214);
 215define_query!(
 216    KEYMAP_QUERY_2025_04_15,
 217    migrations::m_2025_04_15::KEYMAP_PATTERNS
 218);
 219
 220// settings
 221define_query!(
 222    SETTINGS_QUERY_2025_01_02,
 223    migrations::m_2025_01_02::SETTINGS_PATTERNS
 224);
 225define_query!(
 226    SETTINGS_QUERY_2025_01_29,
 227    migrations::m_2025_01_29::SETTINGS_PATTERNS
 228);
 229define_query!(
 230    SETTINGS_QUERY_2025_01_30,
 231    migrations::m_2025_01_30::SETTINGS_PATTERNS
 232);
 233define_query!(
 234    SETTINGS_QUERY_2025_03_29,
 235    migrations::m_2025_03_29::SETTINGS_PATTERNS
 236);
 237define_query!(
 238    SETTINGS_QUERY_2025_04_15,
 239    migrations::m_2025_04_15::SETTINGS_PATTERNS
 240);
 241define_query!(
 242    SETTINGS_QUERY_2025_04_21,
 243    migrations::m_2025_04_21::SETTINGS_PATTERNS
 244);
 245define_query!(
 246    SETTINGS_QUERY_2025_04_23,
 247    migrations::m_2025_04_23::SETTINGS_PATTERNS
 248);
 249define_query!(
 250    SETTINGS_QUERY_2025_05_05,
 251    migrations::m_2025_05_05::SETTINGS_PATTERNS
 252);
 253define_query!(
 254    SETTINGS_QUERY_2025_05_08,
 255    migrations::m_2025_05_08::SETTINGS_PATTERNS
 256);
 257define_query!(
 258    SETTINGS_QUERY_2025_05_29,
 259    migrations::m_2025_05_29::SETTINGS_PATTERNS
 260);
 261define_query!(
 262    SETTINGS_QUERY_2025_06_16,
 263    migrations::m_2025_06_16::SETTINGS_PATTERNS
 264);
 265define_query!(
 266    SETTINGS_QUERY_2025_06_25,
 267    migrations::m_2025_06_25::SETTINGS_PATTERNS
 268);
 269define_query!(
 270    SETTINGS_QUERY_2025_06_27,
 271    migrations::m_2025_06_27::SETTINGS_PATTERNS
 272);
 273
 274// custom query
 275static EDIT_PREDICTION_SETTINGS_MIGRATION_QUERY: LazyLock<Query> = LazyLock::new(|| {
 276    Query::new(
 277        &tree_sitter_json::LANGUAGE.into(),
 278        SETTINGS_NESTED_KEY_VALUE_PATTERN,
 279    )
 280    .unwrap()
 281});
 282
 283#[cfg(test)]
 284mod tests {
 285    use super::*;
 286
 287    fn assert_migrate_keymap(input: &str, output: Option<&str>) {
 288        let migrated = migrate_keymap(&input).unwrap();
 289        pretty_assertions::assert_eq!(migrated.as_deref(), output);
 290    }
 291
 292    fn assert_migrate_settings(input: &str, output: Option<&str>) {
 293        let migrated = migrate_settings(&input).unwrap();
 294        pretty_assertions::assert_eq!(migrated.as_deref(), output);
 295    }
 296
 297    fn assert_migrate_settings_with_migrations(
 298        migrations: &[(MigrationPatterns, &Query)],
 299        input: &str,
 300        output: Option<&str>,
 301    ) {
 302        let migrated = run_migrations(input, migrations).unwrap();
 303        pretty_assertions::assert_eq!(migrated.as_deref(), output);
 304    }
 305
 306    #[test]
 307    fn test_replace_array_with_single_string() {
 308        assert_migrate_keymap(
 309            r#"
 310            [
 311                {
 312                    "bindings": {
 313                        "cmd-1": ["workspace::ActivatePaneInDirection", "Up"]
 314                    }
 315                }
 316            ]
 317            "#,
 318            Some(
 319                r#"
 320            [
 321                {
 322                    "bindings": {
 323                        "cmd-1": "workspace::ActivatePaneUp"
 324                    }
 325                }
 326            ]
 327            "#,
 328            ),
 329        )
 330    }
 331
 332    #[test]
 333    fn test_replace_action_argument_object_with_single_value() {
 334        assert_migrate_keymap(
 335            r#"
 336            [
 337                {
 338                    "bindings": {
 339                        "cmd-1": ["editor::FoldAtLevel", { "level": 1 }]
 340                    }
 341                }
 342            ]
 343            "#,
 344            Some(
 345                r#"
 346            [
 347                {
 348                    "bindings": {
 349                        "cmd-1": ["editor::FoldAtLevel", 1]
 350                    }
 351                }
 352            ]
 353            "#,
 354            ),
 355        )
 356    }
 357
 358    #[test]
 359    fn test_replace_action_argument_object_with_single_value_2() {
 360        assert_migrate_keymap(
 361            r#"
 362            [
 363                {
 364                    "bindings": {
 365                        "cmd-1": ["vim::PushOperator", { "Object": { "some" : "value" } }]
 366                    }
 367                }
 368            ]
 369            "#,
 370            Some(
 371                r#"
 372            [
 373                {
 374                    "bindings": {
 375                        "cmd-1": ["vim::PushObject", { "some" : "value" }]
 376                    }
 377                }
 378            ]
 379            "#,
 380            ),
 381        )
 382    }
 383
 384    #[test]
 385    fn test_rename_string_action() {
 386        assert_migrate_keymap(
 387            r#"
 388                [
 389                    {
 390                        "bindings": {
 391                            "cmd-1": "inline_completion::ToggleMenu"
 392                        }
 393                    }
 394                ]
 395            "#,
 396            Some(
 397                r#"
 398                [
 399                    {
 400                        "bindings": {
 401                            "cmd-1": "edit_prediction::ToggleMenu"
 402                        }
 403                    }
 404                ]
 405            "#,
 406            ),
 407        )
 408    }
 409
 410    #[test]
 411    fn test_rename_context_key() {
 412        assert_migrate_keymap(
 413            r#"
 414                [
 415                    {
 416                        "context": "Editor && inline_completion && !showing_completions"
 417                    }
 418                ]
 419            "#,
 420            Some(
 421                r#"
 422                [
 423                    {
 424                        "context": "Editor && edit_prediction && !showing_completions"
 425                    }
 426                ]
 427            "#,
 428            ),
 429        )
 430    }
 431
 432    #[test]
 433    fn test_incremental_migrations() {
 434        // Here string transforms to array internally. Then, that array transforms back to string.
 435        assert_migrate_keymap(
 436            r#"
 437                [
 438                    {
 439                        "bindings": {
 440                            "ctrl-q": "editor::GoToHunk", // should remain same
 441                            "ctrl-w": "editor::GoToPrevHunk", // should rename
 442                            "ctrl-q": ["editor::GoToHunk", { "center_cursor": true }], // should transform
 443                            "ctrl-w": ["editor::GoToPreviousHunk", { "center_cursor": true }] // should transform
 444                        }
 445                    }
 446                ]
 447            "#,
 448            Some(
 449                r#"
 450                [
 451                    {
 452                        "bindings": {
 453                            "ctrl-q": "editor::GoToHunk", // should remain same
 454                            "ctrl-w": "editor::GoToPreviousHunk", // should rename
 455                            "ctrl-q": "editor::GoToHunk", // should transform
 456                            "ctrl-w": "editor::GoToPreviousHunk" // should transform
 457                        }
 458                    }
 459                ]
 460            "#,
 461            ),
 462        )
 463    }
 464
 465    #[test]
 466    fn test_action_argument_snake_case() {
 467        // First performs transformations, then replacements
 468        assert_migrate_keymap(
 469            r#"
 470            [
 471                {
 472                    "bindings": {
 473                        "cmd-1": ["vim::PushOperator", { "Object": { "around": false } }],
 474                        "cmd-3": ["pane::CloseActiveItem", { "saveIntent": "saveAll" }],
 475                        "cmd-2": ["vim::NextWordStart", { "ignorePunctuation": true }],
 476                        "cmd-4": ["task::Spawn", { "task_name": "a b" }] // should remain as it is
 477                    }
 478                }
 479            ]
 480            "#,
 481            Some(
 482                r#"
 483            [
 484                {
 485                    "bindings": {
 486                        "cmd-1": ["vim::PushObject", { "around": false }],
 487                        "cmd-3": ["pane::CloseActiveItem", { "save_intent": "save_all" }],
 488                        "cmd-2": ["vim::NextWordStart", { "ignore_punctuation": true }],
 489                        "cmd-4": ["task::Spawn", { "task_name": "a b" }] // should remain as it is
 490                    }
 491                }
 492            ]
 493            "#,
 494            ),
 495        )
 496    }
 497
 498    #[test]
 499    fn test_replace_setting_name() {
 500        assert_migrate_settings(
 501            r#"
 502                {
 503                    "show_inline_completions_in_menu": true,
 504                    "show_inline_completions": true,
 505                    "inline_completions_disabled_in": ["string"],
 506                    "inline_completions": { "some" : "value" }
 507                }
 508            "#,
 509            Some(
 510                r#"
 511                {
 512                    "show_edit_predictions_in_menu": true,
 513                    "show_edit_predictions": true,
 514                    "edit_predictions_disabled_in": ["string"],
 515                    "edit_predictions": { "some" : "value" }
 516                }
 517            "#,
 518            ),
 519        )
 520    }
 521
 522    #[test]
 523    fn test_nested_string_replace_for_settings() {
 524        assert_migrate_settings(
 525            r#"
 526                {
 527                    "features": {
 528                        "inline_completion_provider": "zed"
 529                    },
 530                }
 531            "#,
 532            Some(
 533                r#"
 534                {
 535                    "features": {
 536                        "edit_prediction_provider": "zed"
 537                    },
 538                }
 539            "#,
 540            ),
 541        )
 542    }
 543
 544    #[test]
 545    fn test_replace_settings_in_languages() {
 546        assert_migrate_settings(
 547            r#"
 548                {
 549                    "languages": {
 550                        "Astro": {
 551                            "show_inline_completions": true
 552                        }
 553                    }
 554                }
 555            "#,
 556            Some(
 557                r#"
 558                {
 559                    "languages": {
 560                        "Astro": {
 561                            "show_edit_predictions": true
 562                        }
 563                    }
 564                }
 565            "#,
 566            ),
 567        )
 568    }
 569
 570    #[test]
 571    fn test_replace_settings_value() {
 572        assert_migrate_settings(
 573            r#"
 574                {
 575                    "scrollbar": {
 576                        "diagnostics": true
 577                    },
 578                    "chat_panel": {
 579                        "button": true
 580                    }
 581                }
 582            "#,
 583            Some(
 584                r#"
 585                {
 586                    "scrollbar": {
 587                        "diagnostics": "all"
 588                    },
 589                    "chat_panel": {
 590                        "button": "always"
 591                    }
 592                }
 593            "#,
 594            ),
 595        )
 596    }
 597
 598    #[test]
 599    fn test_replace_settings_name_and_value() {
 600        assert_migrate_settings(
 601            r#"
 602                {
 603                    "tabs": {
 604                        "always_show_close_button": true
 605                    }
 606                }
 607            "#,
 608            Some(
 609                r#"
 610                {
 611                    "tabs": {
 612                        "show_close_button": "always"
 613                    }
 614                }
 615            "#,
 616            ),
 617        )
 618    }
 619
 620    #[test]
 621    fn test_replace_bash_with_terminal_in_profiles() {
 622        assert_migrate_settings(
 623            r#"
 624                {
 625                    "assistant": {
 626                        "profiles": {
 627                            "custom": {
 628                                "name": "Custom",
 629                                "tools": {
 630                                    "bash": true,
 631                                    "diagnostics": true
 632                                }
 633                            }
 634                        }
 635                    }
 636                }
 637            "#,
 638            Some(
 639                r#"
 640                {
 641                    "agent": {
 642                        "profiles": {
 643                            "custom": {
 644                                "name": "Custom",
 645                                "tools": {
 646                                    "terminal": true,
 647                                    "diagnostics": true
 648                                }
 649                            }
 650                        }
 651                    }
 652                }
 653            "#,
 654            ),
 655        )
 656    }
 657
 658    #[test]
 659    fn test_replace_bash_false_with_terminal_in_profiles() {
 660        assert_migrate_settings(
 661            r#"
 662                {
 663                    "assistant": {
 664                        "profiles": {
 665                            "custom": {
 666                                "name": "Custom",
 667                                "tools": {
 668                                    "bash": false,
 669                                    "diagnostics": true
 670                                }
 671                            }
 672                        }
 673                    }
 674                }
 675            "#,
 676            Some(
 677                r#"
 678                {
 679                    "agent": {
 680                        "profiles": {
 681                            "custom": {
 682                                "name": "Custom",
 683                                "tools": {
 684                                    "terminal": false,
 685                                    "diagnostics": true
 686                                }
 687                            }
 688                        }
 689                    }
 690                }
 691            "#,
 692            ),
 693        )
 694    }
 695
 696    #[test]
 697    fn test_no_bash_in_profiles() {
 698        assert_migrate_settings(
 699            r#"
 700                {
 701                    "assistant": {
 702                        "profiles": {
 703                            "custom": {
 704                                "name": "Custom",
 705                                "tools": {
 706                                    "diagnostics": true,
 707                                    "find_path": true,
 708                                    "read_file": true
 709                                }
 710                            }
 711                        }
 712                    }
 713                }
 714            "#,
 715            Some(
 716                r#"
 717                {
 718                    "agent": {
 719                        "profiles": {
 720                            "custom": {
 721                                "name": "Custom",
 722                                "tools": {
 723                                    "diagnostics": true,
 724                                    "find_path": true,
 725                                    "read_file": true
 726                                }
 727                            }
 728                        }
 729                    }
 730                }
 731            "#,
 732            ),
 733        )
 734    }
 735
 736    #[test]
 737    fn test_rename_path_search_to_find_path() {
 738        assert_migrate_settings(
 739            r#"
 740                {
 741                    "assistant": {
 742                        "profiles": {
 743                            "default": {
 744                                "tools": {
 745                                    "path_search": true,
 746                                    "read_file": true
 747                                }
 748                            }
 749                        }
 750                    }
 751                }
 752            "#,
 753            Some(
 754                r#"
 755                {
 756                    "agent": {
 757                        "profiles": {
 758                            "default": {
 759                                "tools": {
 760                                    "find_path": true,
 761                                    "read_file": true
 762                                }
 763                            }
 764                        }
 765                    }
 766                }
 767            "#,
 768            ),
 769        );
 770    }
 771
 772    #[test]
 773    fn test_rename_assistant() {
 774        assert_migrate_settings(
 775            r#"{
 776                "assistant": {
 777                    "foo": "bar"
 778                },
 779                "edit_predictions": {
 780                    "enabled_in_assistant": false,
 781                }
 782            }"#,
 783            Some(
 784                r#"{
 785                "agent": {
 786                    "foo": "bar"
 787                },
 788                "edit_predictions": {
 789                    "enabled_in_text_threads": false,
 790                }
 791            }"#,
 792            ),
 793        );
 794    }
 795
 796    #[test]
 797    fn test_comment_duplicated_agent() {
 798        assert_migrate_settings(
 799            r#"{
 800                "agent": {
 801                    "name": "assistant-1",
 802                "model": "gpt-4", // weird formatting
 803                    "utf8": "привіт"
 804                },
 805                "something": "else",
 806                "agent": {
 807                    "name": "assistant-2",
 808                    "model": "gemini-pro"
 809                }
 810            }
 811        "#,
 812            Some(
 813                r#"{
 814                /* Duplicated key auto-commented: "agent": {
 815                    "name": "assistant-1",
 816                "model": "gpt-4", // weird formatting
 817                    "utf8": "привіт"
 818                }, */
 819                "something": "else",
 820                "agent": {
 821                    "name": "assistant-2",
 822                    "model": "gemini-pro"
 823                }
 824            }
 825        "#,
 826            ),
 827        );
 828    }
 829
 830    #[test]
 831    fn test_preferred_completion_mode_migration() {
 832        assert_migrate_settings(
 833            r#"{
 834                "agent": {
 835                    "preferred_completion_mode": "max",
 836                    "enabled": true
 837                }
 838            }"#,
 839            Some(
 840                r#"{
 841                "agent": {
 842                    "preferred_completion_mode": "burn",
 843                    "enabled": true
 844                }
 845            }"#,
 846            ),
 847        );
 848
 849        assert_migrate_settings(
 850            r#"{
 851                "agent": {
 852                    "preferred_completion_mode": "normal",
 853                    "enabled": true
 854                }
 855            }"#,
 856            None,
 857        );
 858
 859        assert_migrate_settings(
 860            r#"{
 861                "agent": {
 862                    "preferred_completion_mode": "burn",
 863                    "enabled": true
 864                }
 865            }"#,
 866            None,
 867        );
 868
 869        assert_migrate_settings(
 870            r#"{
 871                "other_section": {
 872                    "preferred_completion_mode": "max"
 873                },
 874                "agent": {
 875                    "preferred_completion_mode": "max"
 876                }
 877            }"#,
 878            Some(
 879                r#"{
 880                "other_section": {
 881                    "preferred_completion_mode": "max"
 882                },
 883                "agent": {
 884                    "preferred_completion_mode": "burn"
 885                }
 886            }"#,
 887            ),
 888        );
 889    }
 890
 891    #[test]
 892    fn test_mcp_settings_migration() {
 893        assert_migrate_settings_with_migrations(
 894            &[(
 895                migrations::m_2025_06_16::SETTINGS_PATTERNS,
 896                &SETTINGS_QUERY_2025_06_16,
 897            )],
 898            r#"{
 899    "context_servers": {
 900        "empty_server": {},
 901        "extension_server": {
 902            "settings": {
 903                "foo": "bar"
 904            }
 905        },
 906        "custom_server": {
 907            "command": {
 908                "path": "foo",
 909                "args": ["bar"],
 910                "env": {
 911                    "FOO": "BAR"
 912                }
 913            }
 914        },
 915        "invalid_server": {
 916            "command": {
 917                "path": "foo",
 918                "args": ["bar"],
 919                "env": {
 920                    "FOO": "BAR"
 921                }
 922            },
 923            "settings": {
 924                "foo": "bar"
 925            }
 926        },
 927        "empty_server2": {},
 928        "extension_server2": {
 929            "foo": "bar",
 930            "settings": {
 931                "foo": "bar"
 932            },
 933            "bar": "foo"
 934        },
 935        "custom_server2": {
 936            "foo": "bar",
 937            "command": {
 938                "path": "foo",
 939                "args": ["bar"],
 940                "env": {
 941                    "FOO": "BAR"
 942                }
 943            },
 944            "bar": "foo"
 945        },
 946        "invalid_server2": {
 947            "foo": "bar",
 948            "command": {
 949                "path": "foo",
 950                "args": ["bar"],
 951                "env": {
 952                    "FOO": "BAR"
 953                }
 954            },
 955            "bar": "foo",
 956            "settings": {
 957                "foo": "bar"
 958            }
 959        }
 960    }
 961}"#,
 962            Some(
 963                r#"{
 964    "context_servers": {
 965        "empty_server": {
 966            "source": "extension",
 967            "settings": {}
 968        },
 969        "extension_server": {
 970            "source": "extension",
 971            "settings": {
 972                "foo": "bar"
 973            }
 974        },
 975        "custom_server": {
 976            "source": "custom",
 977            "command": {
 978                "path": "foo",
 979                "args": ["bar"],
 980                "env": {
 981                    "FOO": "BAR"
 982                }
 983            }
 984        },
 985        "invalid_server": {
 986            "source": "custom",
 987            "command": {
 988                "path": "foo",
 989                "args": ["bar"],
 990                "env": {
 991                    "FOO": "BAR"
 992                }
 993            },
 994            "settings": {
 995                "foo": "bar"
 996            }
 997        },
 998        "empty_server2": {
 999            "source": "extension",
1000            "settings": {}
1001        },
1002        "extension_server2": {
1003            "source": "extension",
1004            "foo": "bar",
1005            "settings": {
1006                "foo": "bar"
1007            },
1008            "bar": "foo"
1009        },
1010        "custom_server2": {
1011            "source": "custom",
1012            "foo": "bar",
1013            "command": {
1014                "path": "foo",
1015                "args": ["bar"],
1016                "env": {
1017                    "FOO": "BAR"
1018                }
1019            },
1020            "bar": "foo"
1021        },
1022        "invalid_server2": {
1023            "source": "custom",
1024            "foo": "bar",
1025            "command": {
1026                "path": "foo",
1027                "args": ["bar"],
1028                "env": {
1029                    "FOO": "BAR"
1030                }
1031            },
1032            "bar": "foo",
1033            "settings": {
1034                "foo": "bar"
1035            }
1036        }
1037    }
1038}"#,
1039            ),
1040        );
1041    }
1042
1043    #[test]
1044    fn test_mcp_settings_migration_doesnt_change_valid_settings() {
1045        let settings = 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    }
1081}"#;
1082        assert_migrate_settings_with_migrations(
1083            &[(
1084                migrations::m_2025_06_16::SETTINGS_PATTERNS,
1085                &SETTINGS_QUERY_2025_06_16,
1086            )],
1087            settings,
1088            None,
1089        );
1090    }
1091
1092    #[test]
1093    fn test_remove_version_fields() {
1094        assert_migrate_settings(
1095            r#"{
1096    "language_models": {
1097        "anthropic": {
1098            "version": "1",
1099            "api_url": "https://api.anthropic.com"
1100        },
1101        "openai": {
1102            "version": "1",
1103            "api_url": "https://api.openai.com/v1"
1104        }
1105    },
1106    "agent": {
1107        "version": "2",
1108        "enabled": true,
1109        "preferred_completion_mode": "normal",
1110        "button": true,
1111        "dock": "right",
1112        "default_width": 640,
1113        "default_height": 320,
1114        "default_model": {
1115            "provider": "zed.dev",
1116            "model": "claude-sonnet-4"
1117        }
1118    }
1119}"#,
1120            Some(
1121                r#"{
1122    "language_models": {
1123        "anthropic": {
1124            "api_url": "https://api.anthropic.com"
1125        },
1126        "openai": {
1127            "api_url": "https://api.openai.com/v1"
1128        }
1129    },
1130    "agent": {
1131        "enabled": true,
1132        "preferred_completion_mode": "normal",
1133        "button": true,
1134        "dock": "right",
1135        "default_width": 640,
1136        "default_height": 320,
1137        "default_model": {
1138            "provider": "zed.dev",
1139            "model": "claude-sonnet-4"
1140        }
1141    }
1142}"#,
1143            ),
1144        );
1145
1146        // Test that version fields in other contexts are not removed
1147        assert_migrate_settings(
1148            r#"{
1149    "language_models": {
1150        "other_provider": {
1151            "version": "1",
1152            "api_url": "https://api.example.com"
1153        }
1154    },
1155    "other_section": {
1156        "version": "1"
1157    }
1158}"#,
1159            None,
1160        );
1161    }
1162
1163    #[test]
1164    fn test_flatten_context_server_command() {
1165        assert_migrate_settings(
1166            r#"{
1167    "context_servers": {
1168        "some-mcp-server": {
1169            "source": "custom",
1170            "command": {
1171                "path": "npx",
1172                "args": [
1173                    "-y",
1174                    "@supabase/mcp-server-supabase@latest",
1175                    "--read-only",
1176                    "--project-ref=<project-ref>"
1177                ],
1178                "env": {
1179                    "SUPABASE_ACCESS_TOKEN": "<personal-access-token>"
1180                }
1181            }
1182        }
1183    }
1184}"#,
1185            Some(
1186                r#"{
1187    "context_servers": {
1188        "some-mcp-server": {
1189            "source": "custom",
1190            "command": "npx",
1191            "args": [
1192                "-y",
1193                "@supabase/mcp-server-supabase@latest",
1194                "--read-only",
1195                "--project-ref=<project-ref>"
1196            ],
1197            "env": {
1198                "SUPABASE_ACCESS_TOKEN": "<personal-access-token>"
1199            }
1200        }
1201    }
1202}"#,
1203            ),
1204        );
1205
1206        // Test with additional keys in server object
1207        assert_migrate_settings(
1208            r#"{
1209    "context_servers": {
1210        "server-with-extras": {
1211            "source": "custom",
1212            "command": {
1213                "path": "/usr/bin/node",
1214                "args": ["server.js"]
1215            },
1216            "settings": {}
1217        }
1218    }
1219}"#,
1220            Some(
1221                r#"{
1222    "context_servers": {
1223        "server-with-extras": {
1224            "source": "custom",
1225            "command": "/usr/bin/node",
1226            "args": ["server.js"],
1227            "settings": {}
1228        }
1229    }
1230}"#,
1231            ),
1232        );
1233
1234        // Test command without args or env
1235        assert_migrate_settings(
1236            r#"{
1237    "context_servers": {
1238        "simple-server": {
1239            "source": "custom",
1240            "command": {
1241                "path": "simple-mcp-server"
1242            }
1243        }
1244    }
1245}"#,
1246            Some(
1247                r#"{
1248    "context_servers": {
1249        "simple-server": {
1250            "source": "custom",
1251            "command": "simple-mcp-server"
1252        }
1253    }
1254}"#,
1255            ),
1256        );
1257    }
1258}