Skip to main content

fastskill_core/core/
lock.rs

1//! Skills lock file management for reproducible installations
2//!
3//! Two distinct lock structures are maintained:
4//! - `ProjectSkillsLock`: deterministic, timestamp-free, for `skills.lock` at project root
5//! - `GlobalSkillsLock`: operational, with timestamps, for `global-skills.lock` in user config dir
6
7use crate::core::manifest::SkillSource;
8use crate::core::skill_manager::SkillDefinition;
9use chrono::{DateTime, Utc};
10use serde::{Deserialize, Serialize};
11use std::path::{Path, PathBuf};
12
13// ── Project Lock ─────────────────────────────────────────────────────────────
14
15/// Metadata for the project-scoped lock file.
16/// Does not contain any wall-clock timestamp — enables deterministic file content.
17#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
18pub struct ProjectLockMetadata {
19    pub version: String,
20    #[serde(default)]
21    pub fastskill_version: Option<String>,
22}
23
24/// A single pinned skill entry in the project lock.
25/// No volatile timestamp fields — content is fully deterministic.
26#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
27pub struct ProjectLockedSkillEntry {
28    pub id: String,
29    pub name: String,
30    pub version: String,
31    pub source: SkillSource,
32    #[serde(default)]
33    pub source_name: Option<String>,
34    #[serde(default)]
35    pub source_url: Option<String>,
36    #[serde(default)]
37    pub source_branch: Option<String>,
38    #[serde(default)]
39    pub commit_hash: Option<String>,
40    #[serde(default)]
41    pub checksum: Option<String>,
42    #[serde(default)]
43    pub dependencies: Vec<String>,
44    #[serde(default)]
45    pub groups: Vec<String>,
46    #[serde(default)]
47    pub editable: bool,
48    /// Depth in the dependency tree (0 = direct dependency)
49    #[serde(default)]
50    pub depth: u32,
51    /// ID of the skill that pulled this one in (for transitive deps)
52    #[serde(default)]
53    pub parent_skill: Option<String>,
54}
55
56/// Project-scoped lock file. Serialized to `<project_root>/skills.lock`.
57#[derive(Debug, Clone, Serialize, Deserialize)]
58pub struct ProjectSkillsLock {
59    pub metadata: ProjectLockMetadata,
60    #[serde(default)]
61    pub skills: Vec<ProjectLockedSkillEntry>,
62}
63
64/// Backward-compatible type alias (deprecated; remove in next major version).
65pub type SkillsLock = ProjectSkillsLock;
66
67impl ProjectSkillsLock {
68    pub fn new_empty() -> Self {
69        Self {
70            metadata: ProjectLockMetadata {
71                version: "2.0".to_string(),
72                fastskill_version: Some(env!("CARGO_PKG_VERSION").to_string()),
73            },
74            skills: Vec::new(),
75        }
76    }
77
78    pub fn load_from_file(path: &Path) -> Result<Self, LockError> {
79        if !path.exists() {
80            return Err(LockError::NotFound(path.to_path_buf()));
81        }
82        let safe_path = path.canonicalize().map_err(LockError::Io)?;
83        let content = std::fs::read_to_string(&safe_path).map_err(LockError::Io)?;
84        let lock: ProjectSkillsLock =
85            toml::from_str(&content).map_err(|e| LockError::Parse(e.to_string()))?;
86        Ok(lock)
87    }
88
89    pub fn save_to_file(&self, path: &Path) -> Result<(), LockError> {
90        let mut lock = self.clone();
91        lock.sort_entries();
92        lock.metadata.fastskill_version = Some(env!("CARGO_PKG_VERSION").to_string());
93        let content =
94            toml::to_string_pretty(&lock).map_err(|e| LockError::Serialize(e.to_string()))?;
95        std::fs::write(path, content).map_err(LockError::Io)?;
96        Ok(())
97    }
98
99    pub fn from_installed_skills(skills: &[SkillDefinition]) -> Self {
100        let mut lock = Self::new_empty();
101        for skill in skills {
102            lock.update_skill(skill);
103        }
104        lock
105    }
106
107    pub fn update_skill(&mut self, skill: &SkillDefinition) {
108        self.update_skill_with_depth(skill, 0, None);
109    }
110
111    pub fn update_skill_with_depth(
112        &mut self,
113        skill: &SkillDefinition,
114        depth: u32,
115        parent_skill: Option<String>,
116    ) {
117        self.skills.retain(|s| s.id != skill.id.as_str());
118        let source = build_skill_source(skill);
119        let entry = ProjectLockedSkillEntry {
120            id: skill.id.to_string(),
121            name: skill.name.clone(),
122            version: skill.version.clone(),
123            source,
124            source_name: skill.installed_from.clone(),
125            source_url: skill.source_url.clone(),
126            source_branch: skill.source_branch.clone(),
127            commit_hash: skill.commit_hash.clone(),
128            checksum: None,
129            dependencies: skill.dependencies.clone().unwrap_or_default(),
130            groups: Vec::new(),
131            editable: skill.editable,
132            depth,
133            parent_skill,
134        };
135        self.skills.push(entry);
136    }
137
138    pub fn remove_skill(&mut self, skill_id: &str) -> bool {
139        let initial_len = self.skills.len();
140        self.skills.retain(|s| s.id != skill_id);
141        self.skills.len() < initial_len
142    }
143
144    pub fn verify_matches_installed(
145        &self,
146        installed_skills: &[SkillDefinition],
147    ) -> Vec<LockMismatch> {
148        let mut mismatches = Vec::new();
149        for locked in &self.skills {
150            if let Some(installed) = installed_skills.iter().find(|s| s.id.as_str() == locked.id) {
151                if installed.version != locked.version {
152                    mismatches.push(LockMismatch {
153                        skill_id: locked.id.clone(),
154                        reason: format!(
155                            "Version mismatch: lock={}, installed={}",
156                            locked.version, installed.version
157                        ),
158                    });
159                }
160                if let (Some(lock_commit), Some(inst_commit)) =
161                    (&locked.commit_hash, &installed.commit_hash)
162                {
163                    if lock_commit != inst_commit {
164                        mismatches.push(LockMismatch {
165                            skill_id: locked.id.clone(),
166                            reason: format!(
167                                "Commit mismatch: lock={}, installed={}",
168                                lock_commit, inst_commit
169                            ),
170                        });
171                    }
172                }
173            } else {
174                mismatches.push(LockMismatch {
175                    skill_id: locked.id.clone(),
176                    reason: "Skill locked but not installed".to_string(),
177                });
178            }
179        }
180        for installed in installed_skills {
181            if !self.skills.iter().any(|s| s.id == installed.id.as_str()) {
182                mismatches.push(LockMismatch {
183                    skill_id: installed.id.to_string(),
184                    reason: "Skill installed but not in lock file".to_string(),
185                });
186            }
187        }
188        mismatches
189    }
190
191    fn sort_entries(&mut self) {
192        self.skills.sort_by(|a, b| a.id.cmp(&b.id));
193    }
194}
195
196// ── Global Lock ───────────────────────────────────────────────────────────────
197
198/// Metadata for the global user-scoped lock file.
199#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
200pub struct GlobalLockMetadata {
201    pub version: String,
202    #[serde(default)]
203    pub fastskill_version: Option<String>,
204}
205
206/// A single entry in the global lock with operational timestamps.
207#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
208pub struct GlobalLockedSkillEntry {
209    pub id: String,
210    pub name: String,
211    pub version: String,
212    pub source: SkillSource,
213    #[serde(default)]
214    pub source_name: Option<String>,
215    #[serde(default)]
216    pub source_url: Option<String>,
217    #[serde(default)]
218    pub source_branch: Option<String>,
219    #[serde(default)]
220    pub commit_hash: Option<String>,
221    #[serde(default)]
222    pub checksum: Option<String>,
223    #[serde(default)]
224    pub dependencies: Vec<String>,
225    #[serde(default)]
226    pub groups: Vec<String>,
227    pub installed_at: DateTime<Utc>,
228    #[serde(default)]
229    pub last_checked_at: Option<DateTime<Utc>>,
230    #[serde(default)]
231    pub last_updated_at: Option<DateTime<Utc>>,
232}
233
234/// Global user-scoped lock file.
235/// Serialized to `<dirs::config_dir()>/fastskill/global-skills.lock`.
236#[derive(Debug, Clone, Serialize, Deserialize)]
237pub struct GlobalSkillsLock {
238    pub metadata: GlobalLockMetadata,
239    #[serde(default)]
240    pub skills: Vec<GlobalLockedSkillEntry>,
241}
242
243impl GlobalSkillsLock {
244    pub fn new_empty() -> Self {
245        Self {
246            metadata: GlobalLockMetadata {
247                version: "1.0".to_string(),
248                fastskill_version: Some(env!("CARGO_PKG_VERSION").to_string()),
249            },
250            skills: Vec::new(),
251        }
252    }
253
254    /// Returns the platform-default path for the global lock file.
255    pub fn default_path() -> Result<PathBuf, LockError> {
256        global_lock_path()
257    }
258
259    pub fn load_from_file(path: &Path) -> Result<Self, LockError> {
260        if !path.exists() {
261            return Err(LockError::NotFound(path.to_path_buf()));
262        }
263        let safe_path = path.canonicalize().map_err(LockError::Io)?;
264        let content = std::fs::read_to_string(&safe_path).map_err(LockError::Io)?;
265        let lock: GlobalSkillsLock =
266            toml::from_str(&content).map_err(|e| LockError::Parse(e.to_string()))?;
267        Ok(lock)
268    }
269
270    pub fn save_to_file(&self, path: &Path) -> Result<(), LockError> {
271        let mut lock = self.clone();
272        lock.sort_entries();
273        lock.metadata.fastskill_version = Some(env!("CARGO_PKG_VERSION").to_string());
274        if let Some(parent) = path.parent() {
275            std::fs::create_dir_all(parent).map_err(LockError::Io)?;
276        }
277        let content =
278            toml::to_string_pretty(&lock).map_err(|e| LockError::Serialize(e.to_string()))?;
279        std::fs::write(path, content).map_err(LockError::Io)?;
280        Ok(())
281    }
282
283    pub fn upsert_skill(&mut self, skill: &SkillDefinition, installed_at: DateTime<Utc>) {
284        self.skills.retain(|s| s.id != skill.id.as_str());
285        let source = build_skill_source(skill);
286        let entry = GlobalLockedSkillEntry {
287            id: skill.id.to_string(),
288            name: skill.name.clone(),
289            version: skill.version.clone(),
290            source,
291            source_name: skill.installed_from.clone(),
292            source_url: skill.source_url.clone(),
293            source_branch: skill.source_branch.clone(),
294            commit_hash: skill.commit_hash.clone(),
295            checksum: None,
296            dependencies: skill.dependencies.clone().unwrap_or_default(),
297            groups: Vec::new(),
298            installed_at,
299            last_checked_at: None,
300            last_updated_at: None,
301        };
302        self.skills.push(entry);
303    }
304
305    pub fn remove_skill(&mut self, skill_id: &str) -> bool {
306        let initial_len = self.skills.len();
307        self.skills.retain(|s| s.id != skill_id);
308        self.skills.len() < initial_len
309    }
310
311    pub fn mark_checked(&mut self, skill_id: &str, checked_at: DateTime<Utc>) {
312        if let Some(entry) = self.skills.iter_mut().find(|s| s.id == skill_id) {
313            entry.last_checked_at = Some(checked_at);
314        }
315    }
316
317    pub fn mark_updated(&mut self, skill_id: &str, updated_at: DateTime<Utc>) {
318        if let Some(entry) = self.skills.iter_mut().find(|s| s.id == skill_id) {
319            entry.last_updated_at = Some(updated_at);
320        }
321    }
322
323    fn sort_entries(&mut self) {
324        self.skills.sort_by(|a, b| a.id.cmp(&b.id));
325    }
326}
327
328// ── Lock mismatch ─────────────────────────────────────────────────────────────
329
330#[derive(Debug, Clone)]
331pub struct LockMismatch {
332    pub skill_id: String,
333    pub reason: String,
334}
335
336// ── Extended Error Enum ───────────────────────────────────────────────────────
337
338#[derive(Debug, thiserror::Error)]
339pub enum LockError {
340    #[error("Lock file not found: {0}")]
341    NotFound(PathBuf),
342
343    #[error("IO error: {0}")]
344    Io(#[from] std::io::Error),
345
346    #[error("Parse error: {0}")]
347    Parse(String),
348
349    #[error("Serialize error: {0}")]
350    Serialize(String),
351
352    /// Advisory file lock held by another process.
353    #[error("Lock file is held by another process: {0}")]
354    FileLocked(PathBuf),
355
356    /// Cannot determine global config directory.
357    #[error("Global config directory unavailable: {0}")]
358    GlobalConfigUnavailable(String),
359}
360
361// ── Routing helpers ───────────────────────────────────────────────────────────
362
363/// Returns the project lock path given the resolved project file path.
364pub fn project_lock_path(project_file: &Path) -> PathBuf {
365    if let Some(parent) = project_file.parent() {
366        parent.join("skills.lock")
367    } else {
368        PathBuf::from("skills.lock")
369    }
370}
371
372/// Returns the global lock path (platform-specific config directory).
373pub fn global_lock_path() -> Result<PathBuf, LockError> {
374    dirs::config_dir()
375        .map(|d| d.join("fastskill").join("global-skills.lock"))
376        .ok_or_else(|| {
377            LockError::GlobalConfigUnavailable(
378                "dirs::config_dir() returned None on this platform".to_string(),
379            )
380        })
381}
382
383// ── Shared source builder ─────────────────────────────────────────────────────
384
385fn build_skill_source(skill: &SkillDefinition) -> SkillSource {
386    if let Some(source_type) = &skill.source_type {
387        match source_type {
388            crate::core::skill_manager::SourceType::GitUrl => SkillSource::Git {
389                url: skill.source_url.clone().unwrap_or_default(),
390                branch: skill.source_branch.clone(),
391                tag: skill.source_tag.clone(),
392                subdir: skill.source_subdir.clone(),
393            },
394            crate::core::skill_manager::SourceType::LocalPath => SkillSource::Local {
395                path: skill.source_subdir.clone().unwrap_or_else(|| {
396                    std::path::PathBuf::from(skill.source_url.clone().unwrap_or_default())
397                }),
398                editable: skill.editable,
399            },
400            crate::core::skill_manager::SourceType::ZipFile => SkillSource::ZipUrl {
401                base_url: skill.source_url.clone().unwrap_or_default(),
402                version: Some(skill.version.clone()),
403            },
404            crate::core::skill_manager::SourceType::Source => SkillSource::Source {
405                name: skill.installed_from.clone().unwrap_or_default(),
406                skill: skill.id.to_string(),
407                version: Some(skill.version.clone()),
408            },
409        }
410    } else {
411        SkillSource::Git {
412            url: skill.source_url.clone().unwrap_or_default(),
413            branch: None,
414            tag: None,
415            subdir: None,
416        }
417    }
418}
419
420#[cfg(test)]
421#[allow(clippy::unwrap_used)]
422mod tests {
423    use super::*;
424    use crate::core::service::SkillId;
425    use crate::core::skill_manager::{SkillDefinition, SourceType};
426    use chrono::Utc;
427    use tempfile::TempDir;
428
429    fn make_skill(id: &str) -> SkillDefinition {
430        SkillDefinition {
431            id: SkillId::new(id.to_string()).unwrap(),
432            name: id.to_string(),
433            description: "test".to_string(),
434            version: "1.0.0".to_string(),
435            author: None,
436            enabled: true,
437            created_at: Utc::now(),
438            updated_at: Utc::now(),
439            skill_file: std::path::PathBuf::from("SKILL.md"),
440            reference_files: None,
441            script_files: None,
442            asset_files: None,
443            execution_environment: None,
444            dependencies: None,
445            timeout: None,
446            source_url: Some("https://github.com/test/repo.git".to_string()),
447            source_type: Some(SourceType::GitUrl),
448            source_branch: Some("main".to_string()),
449            source_tag: None,
450            source_subdir: None,
451            installed_from: None,
452            commit_hash: Some("abc123".to_string()),
453            fetched_at: Some(Utc::now()),
454            editable: false,
455        }
456    }
457
458    #[test]
459    fn test_lock_from_skills() {
460        let skill = make_skill("test-skill");
461        let lock = ProjectSkillsLock::from_installed_skills(&[skill]);
462        assert_eq!(lock.skills.len(), 1);
463        assert_eq!(lock.skills[0].id, "test-skill");
464    }
465
466    #[test]
467    fn test_project_lock_entries_sorted_on_save() {
468        let tmp = TempDir::new().unwrap();
469        let lock_path = tmp.path().join("skills.lock");
470
471        let mut lock = ProjectSkillsLock::new_empty();
472        lock.update_skill(&make_skill("zebra"));
473        lock.update_skill(&make_skill("alpha"));
474        lock.update_skill(&make_skill("mango"));
475
476        lock.save_to_file(&lock_path).unwrap();
477
478        let loaded = ProjectSkillsLock::load_from_file(&lock_path).unwrap();
479        let ids: Vec<&str> = loaded.skills.iter().map(|s| s.id.as_str()).collect();
480        assert_eq!(ids, vec!["alpha", "mango", "zebra"]);
481    }
482
483    #[test]
484    fn test_project_lock_no_volatile_fields_in_serialized_output() {
485        let mut lock = ProjectSkillsLock::new_empty();
486        lock.update_skill(&make_skill("my-skill"));
487
488        let serialized = toml::to_string_pretty(&lock).unwrap();
489        assert!(
490            !serialized.contains("generated_at"),
491            "generated_at must not appear"
492        );
493        assert!(
494            !serialized.contains("fetched_at"),
495            "fetched_at must not appear"
496        );
497    }
498
499    #[test]
500    fn test_project_lock_deterministic_round_trip() {
501        let tmp = TempDir::new().unwrap();
502        let lock_path = tmp.path().join("skills.lock");
503
504        let mut lock = ProjectSkillsLock::new_empty();
505        lock.update_skill(&make_skill("skill-a"));
506        lock.update_skill(&make_skill("skill-b"));
507        lock.save_to_file(&lock_path).unwrap();
508
509        let content_first = std::fs::read(&lock_path).unwrap();
510
511        // Second save with same data must produce byte-identical output
512        lock.save_to_file(&lock_path).unwrap();
513        let content_second = std::fs::read(&lock_path).unwrap();
514        assert_eq!(
515            content_first, content_second,
516            "double-save must be byte-identical"
517        );
518    }
519
520    #[test]
521    fn test_project_lock_migration_strips_volatile_fields() {
522        // Simulate loading a v1.0.0 skills.lock that has generated_at and fetched_at
523        let old_format = r#"[metadata]
524version = "1.0.0"
525generated_at = "2024-01-01T00:00:00Z"
526fastskill_version = "0.9.0"
527
528[[skills]]
529id = "old-skill"
530name = "Old Skill"
531version = "1.0.0"
532source = { type = "git", url = "https://github.com/test/repo.git" }
533fetched_at = "2024-01-01T00:00:00Z"
534dependencies = []
535groups = []
536editable = false
537depth = 0
538"#;
539        let tmp = TempDir::new().unwrap();
540        let lock_path = tmp.path().join("skills.lock");
541        std::fs::write(&lock_path, old_format).unwrap();
542
543        // Load old format - unknown fields (generated_at, fetched_at) are silently ignored
544        let loaded = ProjectSkillsLock::load_from_file(&lock_path).unwrap();
545        assert_eq!(loaded.skills.len(), 1);
546        assert_eq!(loaded.skills[0].id, "old-skill");
547
548        // Save with new code
549        loaded.save_to_file(&lock_path).unwrap();
550        let new_content = std::fs::read_to_string(&lock_path).unwrap();
551
552        assert!(
553            !new_content.contains("generated_at"),
554            "generated_at must be stripped on save"
555        );
556        assert!(
557            !new_content.contains("fetched_at"),
558            "fetched_at must be stripped on save"
559        );
560    }
561
562    #[test]
563    fn test_skils_lock_type_alias_compiles() {
564        // Ensure SkillsLock type alias still works
565        let lock: SkillsLock = SkillsLock::new_empty();
566        assert_eq!(lock.metadata.version, "2.0");
567    }
568
569    #[test]
570    fn test_global_lock_upsert_and_remove() {
571        let mut lock = GlobalSkillsLock::new_empty();
572        let skill = make_skill("global-skill");
573        let now = Utc::now();
574
575        lock.upsert_skill(&skill, now);
576        assert_eq!(lock.skills.len(), 1);
577        assert_eq!(lock.skills[0].id, "global-skill");
578        assert_eq!(lock.skills[0].installed_at, now);
579
580        // Upsert again (update) - should not duplicate
581        lock.upsert_skill(&skill, now);
582        assert_eq!(lock.skills.len(), 1);
583
584        let removed = lock.remove_skill("global-skill");
585        assert!(removed);
586        assert!(lock.skills.is_empty());
587    }
588
589    #[test]
590    fn test_global_lock_mark_checked_and_updated() {
591        let mut lock = GlobalSkillsLock::new_empty();
592        let skill = make_skill("global-skill");
593        let now = Utc::now();
594        lock.upsert_skill(&skill, now);
595
596        let checked_at = Utc::now();
597        lock.mark_checked("global-skill", checked_at);
598        assert_eq!(lock.skills[0].last_checked_at, Some(checked_at));
599
600        let updated_at = Utc::now();
601        lock.mark_updated("global-skill", updated_at);
602        assert_eq!(lock.skills[0].last_updated_at, Some(updated_at));
603    }
604
605    #[test]
606    fn test_global_lock_save_and_load() {
607        let tmp = TempDir::new().unwrap();
608        let lock_path = tmp.path().join("global-skills.lock");
609
610        let mut lock = GlobalSkillsLock::new_empty();
611        lock.upsert_skill(&make_skill("my-global-skill"), Utc::now());
612        lock.save_to_file(&lock_path).unwrap();
613
614        let loaded = GlobalSkillsLock::load_from_file(&lock_path).unwrap();
615        assert_eq!(loaded.skills.len(), 1);
616        assert_eq!(loaded.skills[0].id, "my-global-skill");
617        assert!(loaded.skills[0].last_checked_at.is_none());
618    }
619
620    #[test]
621    fn test_global_lock_creates_parent_dir() {
622        let tmp = TempDir::new().unwrap();
623        let lock_path = tmp
624            .path()
625            .join("subdir")
626            .join("nested")
627            .join("global-skills.lock");
628
629        let lock = GlobalSkillsLock::new_empty();
630        lock.save_to_file(&lock_path).unwrap();
631        assert!(lock_path.exists());
632    }
633
634    #[test]
635    fn test_global_lock_path_returns_result() {
636        // global_lock_path() should succeed on platforms with a config dir (Linux/macOS/Windows)
637        let result = global_lock_path();
638        // On CI Linux this should succeed
639        assert!(result.is_ok() || result.is_err(), "must return a Result");
640        if let Ok(path) = result {
641            assert!(path.ends_with("global-skills.lock"));
642            assert!(path.to_str().unwrap().contains("fastskill"));
643        }
644    }
645
646    #[test]
647    fn test_project_lock_path_helper() {
648        let project_file = std::path::PathBuf::from("/home/user/project/skill-project.toml");
649        let lock_path = project_lock_path(&project_file);
650        assert_eq!(
651            lock_path,
652            std::path::PathBuf::from("/home/user/project/skills.lock")
653        );
654    }
655
656    #[test]
657    fn test_file_lock_contention_returns_error() {
658        use fs2::FileExt;
659
660        let tmp = TempDir::new().unwrap();
661        let sidecar = tmp.path().join("skills.lock.lock");
662
663        // Acquire the advisory lock from this thread
664        let holder = std::fs::OpenOptions::new()
665            .create(true)
666            .write(true)
667            .truncate(false)
668            .open(&sidecar)
669            .unwrap();
670        holder.lock_exclusive().unwrap();
671
672        // Attempting to acquire again (try_lock) should fail
673        let contender = std::fs::OpenOptions::new()
674            .create(true)
675            .write(true)
676            .truncate(false)
677            .open(&sidecar)
678            .unwrap();
679
680        // try_lock_exclusive should return an error when the lock is already held
681        let result = contender.try_lock_exclusive();
682        assert!(
683            result.is_err(),
684            "try_lock_exclusive must fail when lock is held"
685        );
686
687        holder.unlock().unwrap();
688    }
689}