Revert "settings: Remove `version` field migration" (#33729)

Peter Tripp created

- Reverts zed-industries/zed#33711

I think we should just make this a breaking change with v0.194.x.
Forwards compatibility is hard, we should build abstractions that make
this easier (next time).

See also:
- https://github.com/zed-industries/zed/pull/33372

Release Notes:

- N/A

Change summary

crates/migrator/src/migrations.rs                       |   6 
crates/migrator/src/migrations/m_2025_06_25/settings.rs | 133 +++++++++++
crates/migrator/src/migrator.rs                         |  79 ++++++
3 files changed, 218 insertions(+)

Detailed changes

crates/migrator/src/migrations.rs 🔗

@@ -82,6 +82,12 @@ pub(crate) mod m_2025_06_16 {
     pub(crate) use settings::SETTINGS_PATTERNS;
 }
 
+pub(crate) mod m_2025_06_25 {
+    mod settings;
+
+    pub(crate) use settings::SETTINGS_PATTERNS;
+}
+
 pub(crate) mod m_2025_06_27 {
     mod settings;
 

crates/migrator/src/migrations/m_2025_06_25/settings.rs 🔗

@@ -0,0 +1,133 @@
+use std::ops::Range;
+use tree_sitter::{Query, QueryMatch};
+
+use crate::MigrationPatterns;
+
+pub const SETTINGS_PATTERNS: MigrationPatterns = &[
+    (SETTINGS_VERSION_PATTERN, remove_version_fields),
+    (
+        SETTINGS_NESTED_VERSION_PATTERN,
+        remove_nested_version_fields,
+    ),
+];
+
+const SETTINGS_VERSION_PATTERN: &str = r#"(document
+    (object
+        (pair
+            key: (string (string_content) @key)
+            value: (object
+                (pair
+                    key: (string (string_content) @version_key)
+                    value: (_) @version_value
+                ) @version_pair
+            )
+        )
+    )
+    (#eq? @key "agent")
+    (#eq? @version_key "version")
+)"#;
+
+const SETTINGS_NESTED_VERSION_PATTERN: &str = r#"(document
+    (object
+        (pair
+            key: (string (string_content) @language_models)
+            value: (object
+                (pair
+                    key: (string (string_content) @provider)
+                    value: (object
+                        (pair
+                            key: (string (string_content) @version_key)
+                            value: (_) @version_value
+                        ) @version_pair
+                    )
+                )
+            )
+        )
+    )
+    (#eq? @language_models "language_models")
+    (#match? @provider "^(anthropic|openai)$")
+    (#eq? @version_key "version")
+)"#;
+
+fn remove_version_fields(
+    contents: &str,
+    mat: &QueryMatch,
+    query: &Query,
+) -> Option<(Range<usize>, String)> {
+    let version_pair_ix = query.capture_index_for_name("version_pair")?;
+    let version_pair_node = mat.nodes_for_capture_index(version_pair_ix).next()?;
+
+    remove_pair_with_whitespace(contents, version_pair_node)
+}
+
+fn remove_nested_version_fields(
+    contents: &str,
+    mat: &QueryMatch,
+    query: &Query,
+) -> Option<(Range<usize>, String)> {
+    let version_pair_ix = query.capture_index_for_name("version_pair")?;
+    let version_pair_node = mat.nodes_for_capture_index(version_pair_ix).next()?;
+
+    remove_pair_with_whitespace(contents, version_pair_node)
+}
+
+fn remove_pair_with_whitespace(
+    contents: &str,
+    pair_node: tree_sitter::Node,
+) -> Option<(Range<usize>, String)> {
+    let mut range_to_remove = pair_node.byte_range();
+
+    // Check if there's a comma after this pair
+    if let Some(next_sibling) = pair_node.next_sibling() {
+        if next_sibling.kind() == "," {
+            range_to_remove.end = next_sibling.end_byte();
+        }
+    } else {
+        // If no next sibling, check if there's a comma before
+        if let Some(prev_sibling) = pair_node.prev_sibling() {
+            if prev_sibling.kind() == "," {
+                range_to_remove.start = prev_sibling.start_byte();
+            }
+        }
+    }
+
+    // Include any leading whitespace/newline, including comments
+    let text_before = &contents[..range_to_remove.start];
+    if let Some(last_newline) = text_before.rfind('\n') {
+        let whitespace_start = last_newline + 1;
+        let potential_whitespace = &contents[whitespace_start..range_to_remove.start];
+
+        // Check if it's only whitespace or comments
+        let mut is_whitespace_or_comment = true;
+        let mut in_comment = false;
+        let mut chars = potential_whitespace.chars().peekable();
+
+        while let Some(ch) = chars.next() {
+            if in_comment {
+                if ch == '\n' {
+                    in_comment = false;
+                }
+            } else if ch == '/' && chars.peek() == Some(&'/') {
+                in_comment = true;
+                chars.next(); // Skip the second '/'
+            } else if !ch.is_whitespace() {
+                is_whitespace_or_comment = false;
+                break;
+            }
+        }
+
+        if is_whitespace_or_comment {
+            range_to_remove.start = whitespace_start;
+        }
+    }
+
+    // Also check if we need to include trailing whitespace up to the next line
+    let text_after = &contents[range_to_remove.end..];
+    if let Some(newline_pos) = text_after.find('\n') {
+        if text_after[..newline_pos].chars().all(|c| c.is_whitespace()) {
+            range_to_remove.end += newline_pos + 1;
+        }
+    }
+
+    Some((range_to_remove, String::new()))
+}

crates/migrator/src/migrator.rs 🔗

@@ -152,6 +152,10 @@ pub fn migrate_settings(text: &str) -> Result<Option<String>> {
             migrations::m_2025_06_16::SETTINGS_PATTERNS,
             &SETTINGS_QUERY_2025_06_16,
         ),
+        (
+            migrations::m_2025_06_25::SETTINGS_PATTERNS,
+            &SETTINGS_QUERY_2025_06_25,
+        ),
         (
             migrations::m_2025_06_27::SETTINGS_PATTERNS,
             &SETTINGS_QUERY_2025_06_27,
@@ -258,6 +262,10 @@ define_query!(
     SETTINGS_QUERY_2025_06_16,
     migrations::m_2025_06_16::SETTINGS_PATTERNS
 );
+define_query!(
+    SETTINGS_QUERY_2025_06_25,
+    migrations::m_2025_06_25::SETTINGS_PATTERNS
+);
 define_query!(
     SETTINGS_QUERY_2025_06_27,
     migrations::m_2025_06_27::SETTINGS_PATTERNS
@@ -1081,6 +1089,77 @@ mod tests {
         );
     }
 
+    #[test]
+    fn test_remove_version_fields() {
+        assert_migrate_settings(
+            r#"{
+    "language_models": {
+        "anthropic": {
+            "version": "1",
+            "api_url": "https://api.anthropic.com"
+        },
+        "openai": {
+            "version": "1",
+            "api_url": "https://api.openai.com/v1"
+        }
+    },
+    "agent": {
+        "version": "2",
+        "enabled": true,
+        "preferred_completion_mode": "normal",
+        "button": true,
+        "dock": "right",
+        "default_width": 640,
+        "default_height": 320,
+        "default_model": {
+            "provider": "zed.dev",
+            "model": "claude-sonnet-4"
+        }
+    }
+}"#,
+            Some(
+                r#"{
+    "language_models": {
+        "anthropic": {
+            "api_url": "https://api.anthropic.com"
+        },
+        "openai": {
+            "api_url": "https://api.openai.com/v1"
+        }
+    },
+    "agent": {
+        "enabled": true,
+        "preferred_completion_mode": "normal",
+        "button": true,
+        "dock": "right",
+        "default_width": 640,
+        "default_height": 320,
+        "default_model": {
+            "provider": "zed.dev",
+            "model": "claude-sonnet-4"
+        }
+    }
+}"#,
+            ),
+        );
+
+        // Test that version fields in other contexts are not removed
+        assert_migrate_settings(
+            r#"{
+    "language_models": {
+        "other_provider": {
+            "version": "1",
+            "api_url": "https://api.example.com"
+        }
+    },
+    "other_section": {
+        "version": "1"
+    }
+}"#,
+            None,
+        );
+    }
+
     #[test]
     fn test_flatten_context_server_command() {
         assert_migrate_settings(