Skip to main content

systemprompt_sync/diff/
skills.rs

1//! Compute the diff between skills stored on disk and in the database.
2
3use super::{compute_db_skill_hash, compute_skill_hash};
4use crate::error::{SyncError, SyncResult};
5use crate::models::{DiffStatus, DiskSkill, SkillDiffItem, SkillsDiffResult};
6use std::collections::HashMap;
7use std::path::Path;
8use systemprompt_agent::models::Skill;
9use systemprompt_agent::repository::content::SkillRepository;
10use systemprompt_database::DbPool;
11use systemprompt_identifiers::SkillId;
12use systemprompt_models::{DiskSkillConfig, SKILL_CONFIG_FILENAME, strip_frontmatter};
13use tracing::warn;
14
15#[derive(Debug)]
16pub struct SkillsDiffCalculator {
17    skill_repo: SkillRepository,
18}
19
20impl SkillsDiffCalculator {
21    pub fn new(db: &DbPool) -> SyncResult<Self> {
22        Ok(Self {
23            skill_repo: SkillRepository::new(db).map_err(SyncError::internal)?,
24        })
25    }
26
27    pub async fn calculate_diff(&self, skills_path: &Path) -> SyncResult<SkillsDiffResult> {
28        let db_skills = self
29            .skill_repo
30            .list_all()
31            .await
32            .map_err(SyncError::internal)?;
33        let db_map: HashMap<SkillId, Skill> =
34            db_skills.into_iter().map(|s| (s.id.clone(), s)).collect();
35
36        let disk_skills = Self::scan_disk_skills(skills_path)?;
37
38        let mut result = SkillsDiffResult::default();
39
40        for (skill_id, disk_skill) in &disk_skills {
41            let disk_hash = compute_skill_hash(disk_skill);
42
43            match db_map.get(skill_id) {
44                None => {
45                    result.added.push(SkillDiffItem {
46                        skill_id: skill_id.clone(),
47                        file_path: disk_skill.file_path.clone(),
48                        status: DiffStatus::Added,
49                        disk_hash: Some(disk_hash),
50                        db_hash: None,
51                        name: Some(disk_skill.name.clone()),
52                    });
53                },
54                Some(db_skill) => {
55                    let db_hash = compute_db_skill_hash(db_skill);
56                    if db_hash == disk_hash {
57                        result.unchanged += 1;
58                    } else {
59                        result.modified.push(SkillDiffItem {
60                            skill_id: skill_id.clone(),
61                            file_path: disk_skill.file_path.clone(),
62                            status: DiffStatus::Modified,
63                            disk_hash: Some(disk_hash),
64                            db_hash: Some(db_hash),
65                            name: Some(disk_skill.name.clone()),
66                        });
67                    }
68                },
69            }
70        }
71
72        for (skill_id, db_skill) in &db_map {
73            if !disk_skills.contains_key(skill_id) {
74                result.removed.push(SkillDiffItem {
75                    skill_id: skill_id.clone(),
76                    file_path: db_skill.file_path.clone(),
77                    status: DiffStatus::Removed,
78                    disk_hash: None,
79                    db_hash: Some(compute_db_skill_hash(db_skill)),
80                    name: Some(db_skill.name.clone()),
81                });
82            }
83        }
84
85        Ok(result)
86    }
87
88    fn scan_disk_skills(path: &Path) -> SyncResult<HashMap<SkillId, DiskSkill>> {
89        let mut skills = HashMap::new();
90
91        if !path.exists() {
92            return Ok(skills);
93        }
94
95        for entry in std::fs::read_dir(path)? {
96            let entry = entry?;
97            let skill_path = entry.path();
98
99            if !skill_path.is_dir() {
100                continue;
101            }
102
103            let config_path = skill_path.join(SKILL_CONFIG_FILENAME);
104            if !config_path.exists() {
105                continue;
106            }
107
108            match parse_skill_file(&config_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(config_path: &Path, skill_dir: &Path) -> SyncResult<DiskSkill> {
123    let config_text = std::fs::read_to_string(config_path)?;
124    let config: DiskSkillConfig = serde_yaml::from_str(&config_text)?;
125
126    let dir_name = skill_dir
127        .file_name()
128        .and_then(|n| n.to_str())
129        .ok_or_else(|| SyncError::invalid_input("Invalid skill directory name"))?;
130
131    let content_path = skill_dir.join(config.content_file());
132
133    let skill_id = if config.id.as_str().is_empty() {
134        SkillId::new(dir_name.replace('-', "_"))
135    } else {
136        config.id.clone()
137    };
138
139    let name = if config.name.is_empty() {
140        dir_name.replace('_', " ")
141    } else {
142        config.name
143    };
144
145    let instructions = if content_path.exists() {
146        let raw = std::fs::read_to_string(&content_path)?;
147        strip_frontmatter(&raw)
148    } else {
149        String::new()
150    };
151
152    Ok(DiskSkill {
153        skill_id,
154        name,
155        description: config.description,
156        instructions,
157        file_path: content_path.to_string_lossy().to_string(),
158    })
159}