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