1use 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#[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#[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 #[serde(default)]
50 pub depth: u32,
51 #[serde(default)]
53 pub parent_skill: Option<String>,
54}
55
56#[derive(Debug, Clone, Serialize, Deserialize)]
58pub struct ProjectSkillsLock {
59 pub metadata: ProjectLockMetadata,
60 #[serde(default)]
61 pub skills: Vec<ProjectLockedSkillEntry>,
62}
63
64pub 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#[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#[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#[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 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#[derive(Debug, Clone)]
331pub struct LockMismatch {
332 pub skill_id: String,
333 pub reason: String,
334}
335
336#[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 #[error("Lock file is held by another process: {0}")]
354 FileLocked(PathBuf),
355
356 #[error("Global config directory unavailable: {0}")]
358 GlobalConfigUnavailable(String),
359}
360
361pub 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
372pub 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
383fn 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 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 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 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 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 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 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 let result = global_lock_path();
638 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 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 let contender = std::fs::OpenOptions::new()
674 .create(true)
675 .write(true)
676 .truncate(false)
677 .open(&sidecar)
678 .unwrap();
679
680 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}