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