Skip to main content

systemprompt_sync/local/
skills_sync.rs

1//! Two-way sync between skills stored on disk and the database via the
2//! `systemprompt-agent` skill ingestion + repository layers.
3
4use crate::diff::SkillsDiffCalculator;
5use crate::error::{SyncError, SyncResult};
6use crate::export::export_skill_to_disk;
7use crate::models::{LocalSyncDirection, LocalSyncResult, SkillsDiffResult};
8use std::path::PathBuf;
9use systemprompt_agent::repository::content::SkillRepository;
10use systemprompt_agent::services::SkillIngestionService;
11use systemprompt_database::DbPool;
12use systemprompt_identifiers::SourceId;
13use systemprompt_models::SkillsConfig;
14use tracing::info;
15
16#[derive(Debug)]
17pub struct SkillsLocalSync {
18    db: DbPool,
19    skills_path: PathBuf,
20}
21
22impl SkillsLocalSync {
23    pub const fn new(db: DbPool, skills_path: PathBuf) -> Self {
24        Self { db, skills_path }
25    }
26
27    pub async fn calculate_diff(&self) -> SyncResult<SkillsDiffResult> {
28        let calculator = SkillsDiffCalculator::new(&self.db).map_err(SyncError::internal)?;
29        calculator
30            .calculate_diff(&self.skills_path)
31            .await
32            .map_err(SyncError::internal)
33    }
34
35    pub async fn sync_to_disk(
36        &self,
37        diff: &SkillsDiffResult,
38        delete_orphans: bool,
39    ) -> SyncResult<LocalSyncResult> {
40        let skill_repo = SkillRepository::new(&self.db).map_err(SyncError::internal)?;
41        let mut result = LocalSyncResult {
42            direction: LocalSyncDirection::ToDisk,
43            ..Default::default()
44        };
45
46        for item in &diff.modified {
47            match skill_repo
48                .get_by_skill_id(&item.skill_id)
49                .await
50                .map_err(SyncError::internal)?
51            {
52                Some(skill) => {
53                    export_skill_to_disk(&skill, &self.skills_path)?;
54                    result.items_synced += 1;
55                    info!("Exported modified skill: {}", item.skill_id);
56                },
57                None => {
58                    result
59                        .errors
60                        .push(format!("Skill not found in DB: {}", item.skill_id));
61                },
62            }
63        }
64
65        for item in &diff.removed {
66            match skill_repo
67                .get_by_skill_id(&item.skill_id)
68                .await
69                .map_err(SyncError::internal)?
70            {
71                Some(skill) => {
72                    export_skill_to_disk(&skill, &self.skills_path)?;
73                    result.items_synced += 1;
74                    info!("Created skill on disk: {}", item.skill_id);
75                },
76                None => {
77                    result
78                        .errors
79                        .push(format!("Skill not found in DB: {}", item.skill_id));
80                },
81            }
82        }
83
84        if delete_orphans {
85            for item in &diff.added {
86                let skill_dir_name = item.skill_id.as_str().replace('_', "-");
87                let skill_dir = self.skills_path.join(&skill_dir_name);
88
89                if skill_dir.exists() {
90                    std::fs::remove_dir_all(&skill_dir)?;
91                    result.items_deleted += 1;
92                    info!("Deleted orphan skill: {}", item.skill_id);
93                }
94            }
95        } else {
96            result.items_skipped += diff.added.len();
97        }
98
99        Ok(result)
100    }
101
102    pub async fn sync_to_db(
103        &self,
104        diff: &SkillsDiffResult,
105        skills_config: &SkillsConfig,
106        delete_orphans: bool,
107    ) -> SyncResult<LocalSyncResult> {
108        let ingestion_service =
109            SkillIngestionService::new(&self.db).map_err(SyncError::internal)?;
110        let mut result = LocalSyncResult {
111            direction: LocalSyncDirection::ToDatabase,
112            ..Default::default()
113        };
114
115        let source_id = SourceId::new("skills");
116        let report = ingestion_service
117            .ingest_config(skills_config, source_id, true)
118            .await
119            .map_err(SyncError::internal)?;
120
121        result.items_synced += report.files_processed;
122
123        for error in report.errors {
124            result.errors.push(error);
125        }
126
127        info!("Ingested {} skills", report.files_processed);
128
129        if delete_orphans && !diff.removed.is_empty() {
130            tracing::warn!(
131                count = diff.removed.len(),
132                "Skill deletion from database not supported, skipping orphan removal"
133            );
134        }
135        result.items_skipped += diff.removed.len();
136
137        Ok(result)
138    }
139}