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(¤t_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(¤t_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}