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