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