Skip to main content

vtcode_skills/
versioning.rs

1//! Skill version resolution
2//!
3//! Implements deterministic version resolution with support for:
4//! - `default_version` (stable, recommended for production)
5//! - `latest_version` (opt-in, newest available)
6//! - Lockfile-based pinning for reproducibility
7//! - Fallback to manifest `version` field
8
9use 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/// A fully resolved skill reference with concrete version
20#[derive(Debug, Clone, PartialEq, Eq)]
21pub struct ResolvedSkillRef {
22    /// Skill name
23    name: String,
24    /// What was originally requested
25    requested: SkillVersion,
26    /// Concrete resolved version string
27    resolved: String,
28    /// Where the skill was found
29    source: SkillSource,
30}
31
32/// Where a resolved skill comes from
33#[derive(Debug, Clone, PartialEq, Eq)]
34pub enum SkillSource {
35    /// On-disk skill directory
36    LocalDir(PathBuf),
37    /// Imported bundle from skill store
38    ImportedBundle(PathBuf),
39    /// Inline bundle (temporary)
40    InlineBundle(PathBuf),
41}
42
43/// Lockfile for reproducible skill version resolution
44#[derive(Debug, Clone, Serialize, Deserialize, Default)]
45pub struct SkillLockfile {
46    /// Locked skill versions: name -> version
47    locked: HashMap<String, String>,
48    /// When the lockfile was last updated
49    #[serde(skip_serializing_if = "Option::is_none")]
50    updated_at: Option<String>,
51}
52
53const LOCKFILE_NAME: &str = "skills.lock";
54
55impl SkillLockfile {
56    /// Load lockfile from a directory (repo or user level)
57    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    /// Save lockfile to a directory
68    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    /// Get locked version for a skill
79    fn get_locked(&self, name: &str) -> Option<&str> {
80        self.locked.get(name).map(|s| s.as_str())
81    }
82
83    /// Lock a skill to a specific version
84    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    /// Check if any skills are locked
90    fn is_empty(&self) -> bool {
91        self.locked.is_empty()
92    }
93}
94
95/// Resolve a skill version based on manifest, lockfile, and request
96///
97/// Resolution order:
98/// 1. If `Specific(v)` requested: use that exact version
99/// 2. If lockfile has an entry: use locked version
100/// 3. If `Latest` requested: use `manifest.latest_version` or `manifest.version`
101/// 4. Otherwise: use `manifest.default_version` or `manifest.version`
102pub 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
136/// Resolve version using default_version semantics (no version specified by user)
137pub 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}