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