systemprompt_sync/diff/
skills.rs1use super::{compute_db_skill_hash, compute_skill_hash};
4use crate::error::{SyncError, SyncResult};
5use crate::models::{DiffStatus, DiskSkill, SkillDiffItem, SkillsDiffResult};
6use std::collections::HashMap;
7use std::path::Path;
8use systemprompt_agent::models::Skill;
9use systemprompt_agent::repository::content::SkillRepository;
10use systemprompt_database::DbPool;
11use systemprompt_identifiers::SkillId;
12use systemprompt_models::{DiskSkillConfig, SKILL_CONFIG_FILENAME, strip_frontmatter};
13use tracing::warn;
14
15#[derive(Debug)]
16pub struct SkillsDiffCalculator {
17 skill_repo: SkillRepository,
18}
19
20impl SkillsDiffCalculator {
21 pub fn new(db: &DbPool) -> SyncResult<Self> {
22 Ok(Self {
23 skill_repo: SkillRepository::new(db).map_err(SyncError::internal)?,
24 })
25 }
26
27 pub async fn calculate_diff(&self, skills_path: &Path) -> SyncResult<SkillsDiffResult> {
28 let db_skills = self
29 .skill_repo
30 .list_all()
31 .await
32 .map_err(SyncError::internal)?;
33 let db_map: HashMap<SkillId, Skill> =
34 db_skills.into_iter().map(|s| (s.id.clone(), s)).collect();
35
36 let disk_skills = Self::scan_disk_skills(skills_path)?;
37
38 let mut result = SkillsDiffResult::default();
39
40 for (skill_id, disk_skill) in &disk_skills {
41 let disk_hash = compute_skill_hash(disk_skill);
42
43 match db_map.get(skill_id) {
44 None => {
45 result.added.push(SkillDiffItem {
46 skill_id: skill_id.clone(),
47 file_path: disk_skill.file_path.clone(),
48 status: DiffStatus::Added,
49 disk_hash: Some(disk_hash),
50 db_hash: None,
51 name: Some(disk_skill.name.clone()),
52 });
53 },
54 Some(db_skill) => {
55 let db_hash = compute_db_skill_hash(db_skill);
56 if db_hash == disk_hash {
57 result.unchanged += 1;
58 } else {
59 result.modified.push(SkillDiffItem {
60 skill_id: skill_id.clone(),
61 file_path: disk_skill.file_path.clone(),
62 status: DiffStatus::Modified,
63 disk_hash: Some(disk_hash),
64 db_hash: Some(db_hash),
65 name: Some(disk_skill.name.clone()),
66 });
67 }
68 },
69 }
70 }
71
72 for (skill_id, db_skill) in &db_map {
73 if !disk_skills.contains_key(skill_id) {
74 result.removed.push(SkillDiffItem {
75 skill_id: skill_id.clone(),
76 file_path: db_skill.file_path.clone(),
77 status: DiffStatus::Removed,
78 disk_hash: None,
79 db_hash: Some(compute_db_skill_hash(db_skill)),
80 name: Some(db_skill.name.clone()),
81 });
82 }
83 }
84
85 Ok(result)
86 }
87
88 fn scan_disk_skills(path: &Path) -> SyncResult<HashMap<SkillId, DiskSkill>> {
89 let mut skills = HashMap::new();
90
91 if !path.exists() {
92 return Ok(skills);
93 }
94
95 for entry in std::fs::read_dir(path)? {
96 let entry = entry?;
97 let skill_path = entry.path();
98
99 if !skill_path.is_dir() {
100 continue;
101 }
102
103 let config_path = skill_path.join(SKILL_CONFIG_FILENAME);
104 if !config_path.exists() {
105 continue;
106 }
107
108 match parse_skill_file(&config_path, &skill_path) {
109 Ok(skill) => {
110 skills.insert(skill.skill_id.clone(), skill);
111 },
112 Err(e) => {
113 warn!("Failed to parse skill at {}: {}", skill_path.display(), e);
114 },
115 }
116 }
117
118 Ok(skills)
119 }
120}
121
122fn parse_skill_file(config_path: &Path, skill_dir: &Path) -> SyncResult<DiskSkill> {
123 let config_text = std::fs::read_to_string(config_path)?;
124 let config: DiskSkillConfig = serde_yaml::from_str(&config_text)?;
125
126 let dir_name = skill_dir
127 .file_name()
128 .and_then(|n| n.to_str())
129 .ok_or_else(|| SyncError::invalid_input("Invalid skill directory name"))?;
130
131 let content_path = skill_dir.join(config.content_file());
132
133 let skill_id = if config.id.as_str().is_empty() {
134 SkillId::new(dir_name.replace('-', "_"))
135 } else {
136 config.id.clone()
137 };
138
139 let name = if config.name.is_empty() {
140 dir_name.replace('_', " ")
141 } else {
142 config.name
143 };
144
145 let instructions = if content_path.exists() {
146 let raw = std::fs::read_to_string(&content_path)?;
147 strip_frontmatter(&raw)
148 } else {
149 String::new()
150 };
151
152 Ok(DiskSkill {
153 skill_id,
154 name,
155 description: config.description,
156 instructions,
157 file_path: content_path.to_string_lossy().to_string(),
158 })
159}