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 tracing::warn;
10
11#[derive(Debug)]
12pub struct SkillsDiffCalculator {
13 skill_repo: SkillRepository,
14}
15
16impl SkillsDiffCalculator {
17 pub fn new(db: &DbPool) -> Result<Self> {
18 Ok(Self {
19 skill_repo: SkillRepository::new(db)?,
20 })
21 }
22
23 pub async fn calculate_diff(&self, skills_path: &Path) -> Result<SkillsDiffResult> {
24 let db_skills = self.skill_repo.list_all().await?;
25 let db_map: HashMap<String, Skill> = db_skills
26 .into_iter()
27 .map(|s| (s.skill_id.as_str().to_string(), s))
28 .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.as_str()) {
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<String, 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 index_path = skill_path.join("index.md");
98 let skill_md_path = skill_path.join("SKILL.md");
99
100 let md_path = if index_path.exists() {
101 index_path
102 } else if skill_md_path.exists() {
103 skill_md_path
104 } else {
105 continue;
106 };
107
108 match parse_skill_file(&md_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(md_path: &Path, skill_dir: &Path) -> Result<DiskSkill> {
123 let content = std::fs::read_to_string(md_path)?;
124
125 let parts: Vec<&str> = content.splitn(3, "---").collect();
126 if parts.len() < 3 {
127 return Err(anyhow!("Invalid frontmatter format"));
128 }
129
130 let frontmatter: serde_yaml::Value = serde_yaml::from_str(parts[1])?;
131 let instructions = parts[2].trim().to_string();
132
133 let dir_name = skill_dir
134 .file_name()
135 .and_then(|n| n.to_str())
136 .ok_or_else(|| anyhow!("Invalid skill directory name"))?;
137
138 let skill_id = dir_name.replace('-', "_");
139
140 let name = frontmatter
141 .get("title")
142 .and_then(|v| v.as_str())
143 .ok_or_else(|| anyhow!("Missing title in frontmatter"))?
144 .to_string();
145
146 let description = frontmatter
147 .get("description")
148 .and_then(|v| v.as_str())
149 .ok_or_else(|| anyhow!("Missing description in frontmatter"))?
150 .to_string();
151
152 Ok(DiskSkill {
153 skill_id,
154 name,
155 description,
156 instructions,
157 file_path: md_path.to_string_lossy().to_string(),
158 })
159}