Skip to main content

vtcode_core/exec/
tool_versioning.rs

1//! Tool versioning and compatibility management
2//!
3//! Implements semantic versioning for MCP tools with support for:
4//! - Breaking change tracking
5//! - Deprecation management
6//! - Automatic skill migration
7//! - Compatibility checking
8
9use anyhow::{Result, anyhow};
10use chrono::{DateTime, Utc};
11use hashbrown::HashMap;
12use serde::{Deserialize, Serialize};
13use std::fmt::Write;
14use tracing::debug;
15use vtcode_commons::MultiErrors;
16
17#[cfg(test)]
18use crate::config::constants::tools;
19
20/// Represents a specific version of a tool
21#[derive(Debug, Clone, Serialize, Deserialize)]
22pub struct ToolVersion {
23    /// Tool name
24    pub name: String,
25    /// Semantic version components
26    pub major: u32,
27    /// Minor version component.
28    pub minor: u32,
29    /// Patch version component.
30    pub patch: u32,
31    /// When this version was released
32    pub released: DateTime<Utc>,
33    /// Human-readable description
34    pub description: String,
35    /// Input parameter schema
36    pub input_schema: serde_json::Value,
37    /// Output schema
38    pub output_schema: serde_json::Value,
39    /// Breaking changes from previous version
40    pub breaking_changes: Vec<BreakingChange>,
41    /// Deprecated fields
42    pub deprecations: Vec<Deprecation>,
43    /// Migration guide text
44    pub migration_guide: Option<String>,
45}
46
47impl ToolVersion {
48    /// Format the version as `"major.minor.patch"`.
49    pub fn version_string(&self) -> String {
50        format!("{}.{}.{}", self.major, self.minor, self.patch)
51    }
52
53    /// Parse version string "1.2.3"
54    pub fn from_string(s: &str) -> Result<(u32, u32, u32)> {
55        let parts: Vec<&str> = s.split('.').collect();
56        if parts.len() != 3 {
57            return Err(anyhow!("Invalid version format: {s}"));
58        }
59        Ok((parts[0].parse()?, parts[1].parse()?, parts[2].parse()?))
60    }
61}
62
63/// Represents a breaking change in a tool version
64#[derive(Debug, Clone, Serialize, Deserialize)]
65pub struct BreakingChange {
66    /// Field name that changed
67    pub field: String,
68    /// Old type/format
69    pub old_type: String,
70    /// New type/format
71    pub new_type: String,
72    /// Why this change was made
73    pub reason: String,
74    /// How to migrate code
75    pub migration_code: String,
76}
77
78/// Represents a deprecated field
79#[derive(Debug, Clone, Serialize, Deserialize)]
80pub struct Deprecation {
81    /// Field name that's deprecated
82    pub field: String,
83    /// Replacement field if any
84    pub replacement: Option<String>,
85    /// Version in which it will be removed
86    pub removed_in: String,
87    /// Guidance for migration
88    pub guidance: String,
89}
90
91/// Tool dependency in a skill
92#[derive(Debug, Clone, Serialize, Deserialize)]
93pub struct ToolDependency {
94    /// Tool name
95    pub name: String,
96    /// Required version (e.g., "1.2" for 1.2.x)
97    pub version: String,
98    /// Where this tool is used
99    pub usage: Vec<String>,
100}
101
102/// Result of compatibility checking
103#[derive(Debug, Clone, Serialize, Deserialize)]
104pub struct CompatibilityReport {
105    /// Whether the skill is fully compatible with available tool versions.
106    pub compatible: bool,
107    /// Non-fatal warnings (e.g., available version is newer than required).
108    pub warnings: Vec<String>,
109    /// Fatal errors preventing compatibility.
110    pub errors: MultiErrors<String>,
111    /// Migrations that must be applied to resolve incompatibilities.
112    pub migrations: Vec<Migration>,
113}
114
115/// A migration that needs to be applied
116#[derive(Debug, Clone, Serialize, Deserialize)]
117pub struct Migration {
118    /// Name of the skill that needs migration.
119    pub skill_name: String,
120    /// Name of the tool whose version changed.
121    pub tool: String,
122    /// Version the skill was written against.
123    pub from_version: String,
124    /// Current available version of the tool.
125    pub to_version: String,
126    /// Code transformations to apply.
127    pub transformations: Vec<CodeTransformation>,
128}
129
130/// A specific code transformation
131#[derive(Debug, Clone, Serialize, Deserialize)]
132pub struct CodeTransformation {
133    /// 1-based line number in the skill source code.
134    pub line_number: usize,
135    /// Original code line.
136    pub old_code: String,
137    /// Replacement code line.
138    pub new_code: String,
139    /// Explanation of why this change is needed.
140    pub reason: String,
141}
142
143/// Checks tool version compatibility
144pub enum VersionCompatibility {
145    /// Versions are fully compatible.
146    Compatible,
147    /// Compatible but with a non-fatal warning message.
148    Warning(String),
149    /// Requires a migration to become compatible.
150    RequiresMigration,
151    /// Not compatible; includes an error message.
152    Incompatible(String),
153}
154
155/// Checks if a skill is compatible with current tools
156pub struct SkillCompatibilityChecker {
157    /// Name of the skill being checked.
158    skill_name: String,
159    tool_dependencies: Vec<ToolDependency>,
160    /// Current tool versions available
161    tool_versions: HashMap<String, ToolVersion>,
162}
163
164impl SkillCompatibilityChecker {
165    /// Create a new compatibility checker
166    pub fn new(
167        skill_name: String,
168        tool_dependencies: Vec<ToolDependency>,
169        tool_versions: HashMap<String, ToolVersion>,
170    ) -> Self {
171        Self { skill_name, tool_dependencies, tool_versions }
172    }
173
174    /// Check if skill is compatible with current tools
175    pub fn check_compatibility(&self) -> Result<CompatibilityReport> {
176        let mut report = CompatibilityReport {
177            compatible: true,
178            warnings: vec![],
179            errors: MultiErrors::new(),
180            migrations: vec![],
181        };
182
183        for dep in &self.tool_dependencies {
184            let current_tool = match self.tool_versions.get(&dep.name) {
185                Some(v) => v,
186                None => {
187                    report.compatible = false;
188                    report.errors.push(format!("Tool not found: {}", dep.name));
189                    continue;
190                }
191            };
192
193            match self.check_version_compatibility(&dep.version, &current_tool.version_string())? {
194                VersionCompatibility::Compatible => {
195                    debug!("Tool {} version {} is compatible", dep.name, dep.version);
196                }
197                VersionCompatibility::Warning(msg) => {
198                    report.warnings.push(msg.clone());
199                    debug!("Compatibility warning for {}: {}", dep.name, msg);
200                }
201                VersionCompatibility::RequiresMigration => {
202                    report.compatible = false;
203                    // Would generate migration here
204                    report.migrations.push(Migration {
205                        skill_name: self.skill_name.clone(),
206                        tool: dep.name.clone(),
207                        from_version: dep.version.clone(),
208                        to_version: current_tool.version_string(),
209                        transformations: vec![],
210                    });
211                    debug!("Migration required for {} in {}", dep.name, self.skill_name);
212                }
213                VersionCompatibility::Incompatible(msg) => {
214                    report.compatible = false;
215                    report.errors.push(msg.clone());
216                    debug!("Incompatibility error for {}: {}", dep.name, msg);
217                }
218            }
219        }
220
221        Ok(report)
222    }
223
224    /// Check semantic version compatibility
225    fn check_version_compatibility(&self, required: &str, available: &str) -> Result<VersionCompatibility> {
226        // required format: "1.2" (accepts 1.2.x)
227        // available format: "1.2.3" (current version)
228
229        let req_parts: Vec<&str> = required.split('.').collect();
230        if req_parts.is_empty() || req_parts.len() > 2 {
231            return Err(anyhow!("Invalid required version format: {required}"));
232        }
233
234        let req_major: u32 = req_parts[0].parse()?;
235        let req_minor: u32 = if req_parts.len() == 2 { req_parts[1].parse()? } else { 0 };
236
237        let (avail_major, avail_minor, _avail_patch) = ToolVersion::from_string(available)?;
238
239        let compat = match (req_major == avail_major, req_minor == avail_minor) {
240            (true, true) => {
241                // Major and minor match: compatible
242                VersionCompatibility::Compatible
243            }
244            (true, false) if avail_minor > req_minor => {
245                // Major matches, available minor is newer: warning
246                VersionCompatibility::Warning(format!(
247                    "Tool available version {available} is newer than required {required}"
248                ))
249            }
250            (true, false) if avail_minor < req_minor => {
251                // Major matches, available minor is older: requires migration
252                VersionCompatibility::RequiresMigration
253            }
254            (false, _) if avail_major > req_major => {
255                // Major version upgrade: usually breaking
256                VersionCompatibility::Incompatible(format!(
257                    "Tool major version changed from {req_major} to {avail_major}"
258                ))
259            }
260            _ => {
261                // Anything else is incompatible
262                VersionCompatibility::Incompatible(format!(
263                    "Tool version {available} not compatible with required {required}"
264                ))
265            }
266        };
267
268        Ok(compat)
269    }
270
271    /// Get detailed compatibility errors
272    pub fn detailed_report(&self) -> Result<String> {
273        let report = self.check_compatibility()?;
274
275        let mut output = format!("Skill: {}\n", self.skill_name);
276        let _ = writeln!(output, "Compatible: {}", report.compatible);
277
278        if !report.warnings.is_empty() {
279            output.push_str("\nWarnings:\n");
280            for warning in &report.warnings {
281                let _ = writeln!(output, "  - {warning}");
282            }
283        }
284
285        if !report.errors.is_empty() {
286            output.push_str("\nErrors:\n");
287            for error in report.errors.iter() {
288                let _ = writeln!(output, "  - {error}");
289            }
290        }
291
292        if !report.migrations.is_empty() {
293            output.push_str("\nRequired Migrations:\n");
294            for migration in &report.migrations {
295                let _ =
296                    writeln!(output, "  - {}: {} -> {}", migration.tool, migration.from_version, migration.to_version);
297            }
298        }
299
300        Ok(output)
301    }
302}
303
304#[cfg(test)]
305mod tests {
306    use super::*;
307
308    fn create_test_tool(name: &str, version: &str) -> ToolVersion {
309        let (major, minor, patch) = ToolVersion::from_string(version).unwrap();
310        ToolVersion {
311            name: name.to_owned(),
312            major,
313            minor,
314            patch,
315            released: Utc::now(),
316            description: format!("Test tool {version}"),
317            input_schema: serde_json::json!({}),
318            output_schema: serde_json::json!({}),
319            breaking_changes: vec![],
320            deprecations: vec![],
321            migration_guide: None,
322        }
323    }
324
325    #[test]
326    fn test_version_parsing() {
327        let (major, minor, patch) = ToolVersion::from_string("1.2.3").unwrap();
328        assert_eq!(major, 1);
329        assert_eq!(minor, 2);
330        assert_eq!(patch, 3);
331
332        // Invalid formats
333        ToolVersion::from_string("1.2").unwrap_err();
334        ToolVersion::from_string("invalid").unwrap_err();
335    }
336
337    #[test]
338    fn test_exact_version_compatibility() {
339        let mut tools = HashMap::new();
340        tools.insert("read_file".to_owned(), create_test_tool("read_file", "1.2.3"));
341
342        let deps = vec![ToolDependency {
343            name: "read_file".to_owned(),
344            version: "1.2".to_owned(),
345            usage: vec!["test".to_owned()],
346        }];
347
348        let checker = SkillCompatibilityChecker::new("test_skill".to_owned(), deps, tools);
349        let report = checker.check_compatibility().unwrap();
350
351        assert!(report.compatible);
352        assert!(report.errors.is_empty());
353    }
354
355    #[test]
356    fn test_missing_tool() {
357        let tools = HashMap::new(); // No tools defined
358
359        let deps = vec![ToolDependency {
360            name: "nonexistent_tool".to_owned(),
361            version: "1.0".to_owned(),
362            usage: vec![],
363        }];
364
365        let checker = SkillCompatibilityChecker::new("test_skill".to_owned(), deps, tools);
366        let report = checker.check_compatibility().unwrap();
367
368        assert!(!report.compatible);
369        assert!(!report.errors.is_empty());
370    }
371
372    #[test]
373    fn test_minor_version_upgrade_warning() {
374        let mut tools = HashMap::new();
375        // Tool is at 1.3.0 but skill requires 1.2
376        tools.insert(tools::LIST_FILES.to_owned(), create_test_tool(tools::LIST_FILES, "1.3.0"));
377
378        let deps = vec![ToolDependency {
379            name: tools::LIST_FILES.to_owned(),
380            version: "1.2".to_owned(),
381            usage: vec![],
382        }];
383
384        let checker = SkillCompatibilityChecker::new("test_skill".to_owned(), deps, tools);
385        let report = checker.check_compatibility().unwrap();
386
387        assert!(report.compatible);
388        assert!(!report.warnings.is_empty());
389    }
390
391    #[test]
392    fn test_major_version_incompatibility() {
393        let mut tools = HashMap::new();
394        // Tool upgraded to 2.0.0, skill requires 1.2
395        tools.insert(tools::GREP_FILE.to_owned(), create_test_tool(tools::GREP_FILE, "2.0.0"));
396
397        let deps = vec![ToolDependency {
398            name: tools::GREP_FILE.to_owned(),
399            version: "1.2".to_owned(),
400            usage: vec![],
401        }];
402
403        let checker = SkillCompatibilityChecker::new("test_skill".to_owned(), deps, tools);
404        let report = checker.check_compatibility().unwrap();
405
406        assert!(!report.compatible);
407        assert!(!report.errors.is_empty());
408    }
409
410    #[test]
411    fn test_detailed_report() {
412        let mut tools = HashMap::new();
413        tools.insert("read_file".to_owned(), create_test_tool("read_file", "1.2.3"));
414
415        let deps = vec![ToolDependency {
416            name: "read_file".to_owned(),
417            version: "1.2".to_owned(),
418            usage: vec!["main".to_owned()],
419        }];
420
421        let checker = SkillCompatibilityChecker::new("filter_skill".to_owned(), deps, tools);
422        let report = checker.detailed_report().unwrap();
423
424        assert!(report.contains("filter_skill"));
425        assert!(report.contains("Compatible: true"));
426    }
427
428    #[test]
429    fn test_skill_compatible_with_newer_patch_version() {
430        // Skill was written for list_files 1.2.0, but 1.2.5 is available
431        // Should be compatible (patch version changes are backward compatible)
432        let mut tools = HashMap::new();
433        tools.insert(tools::LIST_FILES.to_owned(), create_test_tool(tools::LIST_FILES, "1.2.5"));
434
435        let deps = vec![ToolDependency {
436            name: tools::LIST_FILES.to_owned(),
437            version: "1.2".to_owned(),
438            usage: vec!["main".to_owned()],
439        }];
440
441        let checker = SkillCompatibilityChecker::new("filter_skill".to_owned(), deps, tools);
442        let report = checker.check_compatibility().unwrap();
443
444        assert!(report.compatible, "Should be compatible with patch version upgrade");
445        assert!(report.errors.is_empty());
446    }
447
448    #[test]
449    fn test_multiple_tool_dependencies() {
450        // Skill depends on multiple tools with different version compatibility
451        let mut tools = HashMap::new();
452        tools.insert("read_file".to_owned(), create_test_tool("read_file", "1.2.0"));
453        tools.insert("write_file".to_owned(), create_test_tool("write_file", "2.0.0"));
454        tools.insert(tools::LIST_FILES.to_owned(), create_test_tool(tools::LIST_FILES, "1.3.0"));
455
456        let deps = vec![
457            ToolDependency {
458                name: "read_file".to_owned(),
459                version: "1.2".to_owned(),
460                usage: vec!["read_input".to_owned()],
461            },
462            ToolDependency {
463                name: "write_file".to_owned(),
464                version: "1.0".to_owned(),
465                usage: vec!["write_output".to_owned()],
466            },
467            ToolDependency {
468                name: tools::LIST_FILES.to_owned(),
469                version: "1.2".to_owned(),
470                usage: vec!["scan_directory".to_owned()],
471            },
472        ];
473
474        let checker = SkillCompatibilityChecker::new("complex_skill".to_owned(), deps, tools);
475        let report = checker.check_compatibility().unwrap();
476
477        // Should be compatible for read_file and list_files, but need migration for write_file
478        assert!(!report.compatible, "Should not be fully compatible due to write_file");
479        assert!(!report.errors.is_empty());
480    }
481}