use anyhow::{Result, anyhow};
use chrono::{DateTime, Utc};
use hashbrown::HashMap;
use serde::{Deserialize, Serialize};
use std::fmt::Write;
use tracing::debug;
#[cfg(test)]
use crate::config::constants::tools;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ToolVersion {
pub name: String,
pub major: u32,
pub minor: u32,
pub patch: u32,
pub released: DateTime<Utc>,
pub description: String,
pub input_schema: serde_json::Value,
pub output_schema: serde_json::Value,
pub breaking_changes: Vec<BreakingChange>,
pub deprecations: Vec<Deprecation>,
pub migration_guide: Option<String>,
}
impl ToolVersion {
pub fn version_string(&self) -> String {
format!("{}.{}.{}", self.major, self.minor, self.patch)
}
pub fn from_string(s: &str) -> Result<(u32, u32, u32)> {
let parts: Vec<&str> = s.split('.').collect();
if parts.len() != 3 {
return Err(anyhow!("Invalid version format: {}", s));
}
Ok((parts[0].parse()?, parts[1].parse()?, parts[2].parse()?))
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BreakingChange {
pub field: String,
pub old_type: String,
pub new_type: String,
pub reason: String,
pub migration_code: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Deprecation {
pub field: String,
pub replacement: Option<String>,
pub removed_in: String,
pub guidance: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ToolDependency {
pub name: String,
pub version: String,
pub usage: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CompatibilityReport {
pub compatible: bool,
pub warnings: Vec<String>,
pub errors: Vec<String>,
pub migrations: Vec<Migration>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Migration {
pub skill_name: String,
pub tool: String,
pub from_version: String,
pub to_version: String,
pub transformations: Vec<CodeTransformation>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CodeTransformation {
pub line_number: usize,
pub old_code: String,
pub new_code: String,
pub reason: String,
}
pub enum VersionCompatibility {
Compatible,
Warning(String),
RequiresMigration,
Incompatible(String),
}
pub struct SkillCompatibilityChecker {
skill_name: String,
tool_dependencies: Vec<ToolDependency>,
tool_versions: HashMap<String, ToolVersion>,
}
impl SkillCompatibilityChecker {
pub fn new(
skill_name: String,
tool_dependencies: Vec<ToolDependency>,
tool_versions: HashMap<String, ToolVersion>,
) -> Self {
Self {
skill_name,
tool_dependencies,
tool_versions,
}
}
pub fn check_compatibility(&self) -> Result<CompatibilityReport> {
let mut report = CompatibilityReport {
compatible: true,
warnings: vec![],
errors: vec![],
migrations: vec![],
};
for dep in &self.tool_dependencies {
let current_tool = match self.tool_versions.get(&dep.name) {
Some(v) => v,
None => {
report.compatible = false;
report.errors.push(format!("Tool not found: {}", dep.name));
continue;
}
};
match self.check_version_compatibility(&dep.version, ¤t_tool.version_string())? {
VersionCompatibility::Compatible => {
debug!("Tool {} version {} is compatible", dep.name, dep.version);
}
VersionCompatibility::Warning(msg) => {
report.warnings.push(msg.clone());
debug!("Compatibility warning for {}: {}", dep.name, msg);
}
VersionCompatibility::RequiresMigration => {
report.compatible = false;
report.migrations.push(Migration {
skill_name: self.skill_name.clone(),
tool: dep.name.clone(),
from_version: dep.version.clone(),
to_version: current_tool.version_string(),
transformations: vec![],
});
debug!("Migration required for {} in {}", dep.name, self.skill_name);
}
VersionCompatibility::Incompatible(msg) => {
report.compatible = false;
report.errors.push(msg.clone());
debug!("Incompatibility error for {}: {}", dep.name, msg);
}
}
}
Ok(report)
}
fn check_version_compatibility(
&self,
required: &str,
available: &str,
) -> Result<VersionCompatibility> {
let req_parts: Vec<&str> = required.split('.').collect();
if req_parts.is_empty() || req_parts.len() > 2 {
return Err(anyhow!("Invalid required version format: {}", required));
}
let req_major: u32 = req_parts[0].parse()?;
let req_minor: u32 = if req_parts.len() == 2 {
req_parts[1].parse()?
} else {
0
};
let (avail_major, avail_minor, _avail_patch) = ToolVersion::from_string(available)?;
let compat = match (req_major == avail_major, req_minor == avail_minor) {
(true, true) => {
VersionCompatibility::Compatible
}
(true, false) if avail_minor > req_minor => {
VersionCompatibility::Warning(format!(
"Tool available version {} is newer than required {}",
available, required
))
}
(true, false) if avail_minor < req_minor => {
VersionCompatibility::RequiresMigration
}
(false, _) if avail_major > req_major => {
VersionCompatibility::Incompatible(format!(
"Tool major version changed from {} to {}",
req_major, avail_major
))
}
_ => {
VersionCompatibility::Incompatible(format!(
"Tool version {} not compatible with required {}",
available, required
))
}
};
Ok(compat)
}
pub fn detailed_report(&self) -> Result<String> {
let report = self.check_compatibility()?;
let mut output = format!("Skill: {}\n", self.skill_name);
let _ = writeln!(output, "Compatible: {}", report.compatible);
if !report.warnings.is_empty() {
output.push_str("\nWarnings:\n");
for warning in &report.warnings {
let _ = writeln!(output, " - {}", warning);
}
}
if !report.errors.is_empty() {
output.push_str("\nErrors:\n");
for error in &report.errors {
let _ = writeln!(output, " - {}", error);
}
}
if !report.migrations.is_empty() {
output.push_str("\nRequired Migrations:\n");
for migration in &report.migrations {
let _ = writeln!(
output,
" - {}: {} -> {}",
migration.tool, migration.from_version, migration.to_version
);
}
}
Ok(output)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn create_test_tool(name: &str, version: &str) -> ToolVersion {
let (major, minor, patch) = ToolVersion::from_string(version).unwrap();
ToolVersion {
name: name.to_owned(),
major,
minor,
patch,
released: Utc::now(),
description: format!("Test tool {}", version),
input_schema: serde_json::json!({}),
output_schema: serde_json::json!({}),
breaking_changes: vec![],
deprecations: vec![],
migration_guide: None,
}
}
#[test]
fn test_version_parsing() {
let (major, minor, patch) = ToolVersion::from_string("1.2.3").unwrap();
assert_eq!(major, 1);
assert_eq!(minor, 2);
assert_eq!(patch, 3);
assert!(ToolVersion::from_string("1.2").is_err());
assert!(ToolVersion::from_string("invalid").is_err());
}
#[test]
fn test_exact_version_compatibility() {
let mut tools = HashMap::new();
tools.insert(
"read_file".to_owned(),
create_test_tool("read_file", "1.2.3"),
);
let deps = vec![ToolDependency {
name: "read_file".to_owned(),
version: "1.2".to_owned(),
usage: vec!["test".to_owned()],
}];
let checker = SkillCompatibilityChecker::new("test_skill".to_owned(), deps, tools);
let report = checker.check_compatibility().unwrap();
assert!(report.compatible);
assert!(report.errors.is_empty());
}
#[test]
fn test_missing_tool() {
let tools = HashMap::new();
let deps = vec![ToolDependency {
name: "nonexistent_tool".to_owned(),
version: "1.0".to_owned(),
usage: vec![],
}];
let checker = SkillCompatibilityChecker::new("test_skill".to_owned(), deps, tools);
let report = checker.check_compatibility().unwrap();
assert!(!report.compatible);
assert!(!report.errors.is_empty());
}
#[test]
fn test_minor_version_upgrade_warning() {
let mut tools = HashMap::new();
tools.insert(
tools::LIST_FILES.to_owned(),
create_test_tool(tools::LIST_FILES, "1.3.0"),
);
let deps = vec![ToolDependency {
name: tools::LIST_FILES.to_owned(),
version: "1.2".to_owned(),
usage: vec![],
}];
let checker = SkillCompatibilityChecker::new("test_skill".to_owned(), deps, tools);
let report = checker.check_compatibility().unwrap();
assert!(report.compatible);
assert!(!report.warnings.is_empty());
}
#[test]
fn test_major_version_incompatibility() {
let mut tools = HashMap::new();
tools.insert(
tools::GREP_FILE.to_owned(),
create_test_tool(tools::GREP_FILE, "2.0.0"),
);
let deps = vec![ToolDependency {
name: tools::GREP_FILE.to_owned(),
version: "1.2".to_owned(),
usage: vec![],
}];
let checker = SkillCompatibilityChecker::new("test_skill".to_owned(), deps, tools);
let report = checker.check_compatibility().unwrap();
assert!(!report.compatible);
assert!(!report.errors.is_empty());
}
#[test]
fn test_detailed_report() {
let mut tools = HashMap::new();
tools.insert(
"read_file".to_owned(),
create_test_tool("read_file", "1.2.3"),
);
let deps = vec![ToolDependency {
name: "read_file".to_owned(),
version: "1.2".to_owned(),
usage: vec!["main".to_owned()],
}];
let checker = SkillCompatibilityChecker::new("filter_skill".to_owned(), deps, tools);
let report = checker.detailed_report().unwrap();
assert!(report.contains("filter_skill"));
assert!(report.contains("Compatible: true"));
}
#[test]
fn test_skill_compatible_with_newer_patch_version() {
let mut tools = HashMap::new();
tools.insert(
tools::LIST_FILES.to_owned(),
create_test_tool(tools::LIST_FILES, "1.2.5"),
);
let deps = vec![ToolDependency {
name: tools::LIST_FILES.to_owned(),
version: "1.2".to_owned(),
usage: vec!["main".to_owned()],
}];
let checker = SkillCompatibilityChecker::new("filter_skill".to_owned(), deps, tools);
let report = checker.check_compatibility().unwrap();
assert!(
report.compatible,
"Should be compatible with patch version upgrade"
);
assert!(report.errors.is_empty());
}
#[test]
fn test_multiple_tool_dependencies() {
let mut tools = HashMap::new();
tools.insert(
"read_file".to_owned(),
create_test_tool("read_file", "1.2.0"),
);
tools.insert(
"write_file".to_owned(),
create_test_tool("write_file", "2.0.0"),
);
tools.insert(
tools::LIST_FILES.to_owned(),
create_test_tool(tools::LIST_FILES, "1.3.0"),
);
let deps = vec![
ToolDependency {
name: "read_file".to_owned(),
version: "1.2".to_owned(),
usage: vec!["read_input".to_owned()],
},
ToolDependency {
name: "write_file".to_owned(),
version: "1.0".to_owned(),
usage: vec!["write_output".to_owned()],
},
ToolDependency {
name: tools::LIST_FILES.to_owned(),
version: "1.2".to_owned(),
usage: vec!["scan_directory".to_owned()],
},
];
let checker = SkillCompatibilityChecker::new("complex_skill".to_owned(), deps, tools);
let report = checker.check_compatibility().unwrap();
assert!(
!report.compatible,
"Should not be fully compatible due to write_file"
);
assert!(!report.errors.is_empty());
}
}