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::{Result, anyhow};
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::{DiskSkillConfig, SKILL_CONFIG_FILENAME, strip_frontmatter};
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> =
28            db_skills.into_iter().map(|s| (s.id.clone(), s)).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) {
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<SkillId, 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 config_path = skill_path.join(SKILL_CONFIG_FILENAME);
98            if !config_path.exists() {
99                continue;
100            }
101
102            match parse_skill_file(&config_path, &skill_path) {
103                Ok(skill) => {
104                    skills.insert(skill.skill_id.clone(), skill);
105                },
106                Err(e) => {
107                    warn!("Failed to parse skill at {}: {}", skill_path.display(), e);
108                },
109            }
110        }
111
112        Ok(skills)
113    }
114}
115
116fn parse_skill_file(config_path: &Path, skill_dir: &Path) -> Result<DiskSkill> {
117    let config_text = std::fs::read_to_string(config_path)?;
118    let config: DiskSkillConfig = serde_yaml::from_str(&config_text)?;
119
120    let dir_name = skill_dir
121        .file_name()
122        .and_then(|n| n.to_str())
123        .ok_or_else(|| anyhow!("Invalid skill directory name"))?;
124
125    let content_path = skill_dir.join(config.content_file());
126
127    let skill_id = if config.id.as_str().is_empty() {
128        SkillId::new(dir_name.replace('-', "_"))
129    } else {
130        config.id.clone()
131    };
132
133    let name = if config.name.is_empty() {
134        dir_name.replace('_', " ")
135    } else {
136        config.name
137    };
138
139    let instructions = if content_path.exists() {
140        let raw = std::fs::read_to_string(&content_path)?;
141        strip_frontmatter(&raw)
142    } else {
143        String::new()
144    };
145
146    Ok(DiskSkill {
147        skill_id,
148        name,
149        description: config.description,
150        instructions,
151        file_path: content_path.to_string_lossy().to_string(),
152    })
153}