Skip to main content

systemprompt_sync/diff/
skills.rs

1use 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}