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