systemprompt-sync 0.2.1

Cloud sync services for systemprompt.io AI governance infrastructure. File, database, and crate deployment across governance tenants in the MCP governance pipeline.
Documentation
use super::{compute_db_skill_hash, compute_skill_hash};
use crate::models::{DiffStatus, DiskSkill, SkillDiffItem, SkillsDiffResult};
use anyhow::{Result, anyhow};
use std::collections::HashMap;
use std::path::Path;
use systemprompt_agent::models::Skill;
use systemprompt_agent::repository::content::SkillRepository;
use systemprompt_database::DbPool;
use systemprompt_identifiers::SkillId;
use systemprompt_models::{DiskSkillConfig, SKILL_CONFIG_FILENAME, strip_frontmatter};
use tracing::warn;

#[derive(Debug)]
pub struct SkillsDiffCalculator {
    skill_repo: SkillRepository,
}

impl SkillsDiffCalculator {
    pub fn new(db: &DbPool) -> Result<Self> {
        Ok(Self {
            skill_repo: SkillRepository::new(db)?,
        })
    }

    pub async fn calculate_diff(&self, skills_path: &Path) -> Result<SkillsDiffResult> {
        let db_skills = self.skill_repo.list_all().await?;
        let db_map: HashMap<SkillId, Skill> =
            db_skills.into_iter().map(|s| (s.id.clone(), s)).collect();

        let disk_skills = Self::scan_disk_skills(skills_path)?;

        let mut result = SkillsDiffResult::default();

        for (skill_id, disk_skill) in &disk_skills {
            let disk_hash = compute_skill_hash(disk_skill);

            match db_map.get(skill_id) {
                None => {
                    result.added.push(SkillDiffItem {
                        skill_id: skill_id.clone(),
                        file_path: disk_skill.file_path.clone(),
                        status: DiffStatus::Added,
                        disk_hash: Some(disk_hash),
                        db_hash: None,
                        name: Some(disk_skill.name.clone()),
                    });
                },
                Some(db_skill) => {
                    let db_hash = compute_db_skill_hash(db_skill);
                    if db_hash == disk_hash {
                        result.unchanged += 1;
                    } else {
                        result.modified.push(SkillDiffItem {
                            skill_id: skill_id.clone(),
                            file_path: disk_skill.file_path.clone(),
                            status: DiffStatus::Modified,
                            disk_hash: Some(disk_hash),
                            db_hash: Some(db_hash),
                            name: Some(disk_skill.name.clone()),
                        });
                    }
                },
            }
        }

        for (skill_id, db_skill) in &db_map {
            if !disk_skills.contains_key(skill_id) {
                result.removed.push(SkillDiffItem {
                    skill_id: skill_id.clone(),
                    file_path: db_skill.file_path.clone(),
                    status: DiffStatus::Removed,
                    disk_hash: None,
                    db_hash: Some(compute_db_skill_hash(db_skill)),
                    name: Some(db_skill.name.clone()),
                });
            }
        }

        Ok(result)
    }

    fn scan_disk_skills(path: &Path) -> Result<HashMap<SkillId, DiskSkill>> {
        let mut skills = HashMap::new();

        if !path.exists() {
            return Ok(skills);
        }

        for entry in std::fs::read_dir(path)? {
            let entry = entry?;
            let skill_path = entry.path();

            if !skill_path.is_dir() {
                continue;
            }

            let config_path = skill_path.join(SKILL_CONFIG_FILENAME);
            if !config_path.exists() {
                continue;
            }

            match parse_skill_file(&config_path, &skill_path) {
                Ok(skill) => {
                    skills.insert(skill.skill_id.clone(), skill);
                },
                Err(e) => {
                    warn!("Failed to parse skill at {}: {}", skill_path.display(), e);
                },
            }
        }

        Ok(skills)
    }
}

fn parse_skill_file(config_path: &Path, skill_dir: &Path) -> Result<DiskSkill> {
    let config_text = std::fs::read_to_string(config_path)?;
    let config: DiskSkillConfig = serde_yaml::from_str(&config_text)?;

    let dir_name = skill_dir
        .file_name()
        .and_then(|n| n.to_str())
        .ok_or_else(|| anyhow!("Invalid skill directory name"))?;

    let content_path = skill_dir.join(config.content_file());

    let skill_id = if config.id.as_str().is_empty() {
        SkillId::new(dir_name.replace('-', "_"))
    } else {
        config.id.clone()
    };

    let name = if config.name.is_empty() {
        dir_name.replace('_', " ")
    } else {
        config.name
    };

    let instructions = if content_path.exists() {
        let raw = std::fs::read_to_string(&content_path)?;
        strip_frontmatter(&raw)
    } else {
        String::new()
    };

    Ok(DiskSkill {
        skill_id,
        name,
        description: config.description,
        instructions,
        file_path: content_path.to_string_lossy().to_string(),
    })
}