Skip to main content

vtcode_core/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 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/// A fully resolved skill reference with concrete version
22#[derive(Debug, Clone, PartialEq, Eq)]
23pub struct ResolvedSkillRef {
24    /// Skill name
25    pub name: String,
26    /// What was originally requested
27    pub requested: SkillVersion,
28    /// Concrete resolved version string
29    pub resolved: String,
30    /// Where the skill was found
31    pub source: SkillSource,
32}
33
34/// Where a resolved skill comes from
35#[derive(Debug, Clone, PartialEq, Eq)]
36pub enum SkillSource {
37    /// On-disk skill directory
38    LocalDir(PathBuf),
39    /// Imported bundle from skill store
40    ImportedBundle(PathBuf),
41    /// Inline bundle (temporary)
42    InlineBundle(PathBuf),
43}
44
45/// Lockfile for reproducible skill version resolution
46#[derive(Debug, Clone, Serialize, Deserialize, Default)]
47pub struct SkillLockfile {
48    /// Locked skill versions: name -> version
49    pub locked: HashMap<String, String>,
50    /// When the lockfile was last updated
51    #[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    /// Load lockfile from a directory (repo or user level)
59    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    /// Save lockfile to a directory
71    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    /// Get locked version for a skill
82    pub fn get_locked(&self, name: &str) -> Option<&str> {
83        self.locked.get(name).map(|s| s.as_str())
84    }
85
86    /// Lock a skill to a specific version
87    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    /// Check if any skills are locked
93    pub fn is_empty(&self) -> bool {
94        self.locked.is_empty()
95    }
96}
97
98/// Resolve a skill version based on manifest, lockfile, and request
99///
100/// Resolution order:
101/// 1. If `Specific(v)` requested: use that exact version
102/// 2. If lockfile has an entry: use locked version
103/// 3. If `Latest` requested: use `manifest.latest_version` or `manifest.version`
104/// 4. Otherwise: use `manifest.default_version` or `manifest.version`
105pub 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
151/// Resolve version using default_version semantics (no version specified by user)
152pub 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}