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