vtcode_core/skills/
versioning.rs1use crate::utils::file_utils::{
10 ensure_dir_exists_sync, read_file_with_context_sync, write_file_with_context_sync,
11};
12use anyhow::{Context, Result};
13use hashbrown::HashMap;
14use serde::{Deserialize, Serialize};
15use std::path::{Path, PathBuf};
16use tracing::{debug, info, warn};
17
18use crate::skills::container::SkillVersion;
19use crate::skills::types::SkillManifest;
20
21#[derive(Debug, Clone, PartialEq, Eq)]
23pub struct ResolvedSkillRef {
24 pub name: String,
26 pub requested: SkillVersion,
28 pub resolved: String,
30 pub source: SkillSource,
32}
33
34#[derive(Debug, Clone, PartialEq, Eq)]
36pub enum SkillSource {
37 LocalDir(PathBuf),
39 ImportedBundle(PathBuf),
41 InlineBundle(PathBuf),
43}
44
45#[derive(Debug, Clone, Serialize, Deserialize, Default)]
47pub struct SkillLockfile {
48 pub locked: HashMap<String, String>,
50 #[serde(skip_serializing_if = "Option::is_none")]
52 pub updated_at: Option<String>,
53}
54
55const LOCKFILE_NAME: &str = "skills.lock";
56
57impl SkillLockfile {
58 pub fn load(dir: &Path) -> Result<Self> {
60 let path = dir.join(LOCKFILE_NAME);
61 if !path.exists() {
62 return Ok(Self::default());
63 }
64 let content = read_file_with_context_sync(&path, "skills lockfile")
65 .with_context(|| format!("Failed to read lockfile at {}", path.display()))?;
66 serde_json::from_str(&content)
67 .with_context(|| format!("Failed to parse lockfile at {}", path.display()))
68 }
69
70 pub fn save(&self, dir: &Path) -> Result<()> {
72 let path = dir.join(LOCKFILE_NAME);
73 ensure_dir_exists_sync(dir)?;
74 let content = serde_json::to_string_pretty(self)?;
75 write_file_with_context_sync(&path, &content, "skills lockfile")
76 .with_context(|| format!("Failed to write lockfile at {}", path.display()))?;
77 info!("Saved skill lockfile to {}", path.display());
78 Ok(())
79 }
80
81 pub fn get_locked(&self, name: &str) -> Option<&str> {
83 self.locked.get(name).map(|s| s.as_str())
84 }
85
86 pub fn lock(&mut self, name: String, version: String) {
88 self.locked.insert(name, version);
89 self.updated_at = Some(chrono::Utc::now().to_rfc3339());
90 }
91
92 pub fn is_empty(&self) -> bool {
94 self.locked.is_empty()
95 }
96}
97
98pub fn resolve_version(
106 manifest: &SkillManifest,
107 requested: &SkillVersion,
108 lockfile: Option<&SkillLockfile>,
109) -> Result<String> {
110 match requested {
111 SkillVersion::Specific(v) => {
112 debug!(
113 "Using specifically requested version '{}' for '{}'",
114 v, manifest.name
115 );
116 Ok(v.clone())
117 }
118 SkillVersion::Latest => {
119 if let Some(lock) = lockfile
120 && let Some(locked) = lock.get_locked(&manifest.name)
121 {
122 debug!(
123 "Using locked version '{}' for '{}' (Latest requested)",
124 locked, manifest.name
125 );
126 return Ok(locked.to_string());
127 }
128
129 if let Some(ref latest) = manifest.latest_version {
130 debug!("Resolved Latest to '{}' for '{}'", latest, manifest.name);
131 return Ok(latest.clone());
132 }
133
134 if let Some(ref version) = manifest.version {
135 debug!(
136 "Falling back to manifest version '{}' for '{}'",
137 version, manifest.name
138 );
139 return Ok(version.clone());
140 }
141
142 warn!(
143 "No version info available for '{}', using '0.0.0'",
144 manifest.name
145 );
146 Ok("0.0.0".to_string())
147 }
148 }
149}
150
151pub fn resolve_default_version(
153 manifest: &SkillManifest,
154 lockfile: Option<&SkillLockfile>,
155) -> String {
156 if let Some(lock) = lockfile
157 && let Some(locked) = lock.get_locked(&manifest.name)
158 {
159 return locked.to_string();
160 }
161
162 if let Some(ref default) = manifest.default_version {
163 return default.clone();
164 }
165
166 manifest
167 .version
168 .clone()
169 .unwrap_or_else(|| "0.0.0".to_string())
170}
171
172#[cfg(test)]
173mod tests {
174 use super::*;
175
176 fn test_manifest(name: &str) -> SkillManifest {
177 SkillManifest {
178 name: name.to_string(),
179 description: "Test skill".to_string(),
180 version: Some("1.0.0".to_string()),
181 ..Default::default()
182 }
183 }
184
185 #[test]
186 fn test_resolve_specific() {
187 let manifest = test_manifest("test");
188 let result = resolve_version(
189 &manifest,
190 &SkillVersion::Specific("2.0.0".to_string()),
191 None,
192 );
193 assert_eq!(result.unwrap(), "2.0.0");
194 }
195
196 #[test]
197 fn test_resolve_latest_with_latest_version() {
198 let mut manifest = test_manifest("test");
199 manifest.latest_version = Some("1.2.0".to_string());
200 let result = resolve_version(&manifest, &SkillVersion::Latest, None);
201 assert_eq!(result.unwrap(), "1.2.0");
202 }
203
204 #[test]
205 fn test_resolve_latest_fallback_to_version() {
206 let manifest = test_manifest("test");
207 let result = resolve_version(&manifest, &SkillVersion::Latest, None);
208 assert_eq!(result.unwrap(), "1.0.0");
209 }
210
211 #[test]
212 fn test_resolve_latest_with_lockfile() {
213 let manifest = test_manifest("test");
214 let mut lock = SkillLockfile::default();
215 lock.locked.insert("test".to_string(), "0.9.0".to_string());
216 let result = resolve_version(&manifest, &SkillVersion::Latest, Some(&lock));
217 assert_eq!(result.unwrap(), "0.9.0");
218 }
219
220 #[test]
221 fn test_resolve_default_version() {
222 let mut manifest = test_manifest("test");
223 manifest.default_version = Some("1.0.0".to_string());
224 manifest.latest_version = Some("1.2.0".to_string());
225 let result = resolve_default_version(&manifest, None);
226 assert_eq!(result, "1.0.0");
227 }
228
229 #[test]
230 fn test_lockfile_roundtrip() {
231 let mut lock = SkillLockfile::default();
232 lock.locked
233 .insert("skill-a".to_string(), "1.0.0".to_string());
234 lock.locked
235 .insert("skill-b".to_string(), "2.0.0".to_string());
236 let json = serde_json::to_string(&lock).unwrap();
237 let parsed: SkillLockfile = serde_json::from_str(&json).unwrap();
238 assert_eq!(parsed.locked.len(), 2);
239 assert_eq!(parsed.get_locked("skill-a"), Some("1.0.0"));
240 }
241
242 #[test]
243 fn test_lockfile_empty() {
244 let lock = SkillLockfile::default();
245 assert!(lock.is_empty());
246 }
247}