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