Skip to main content

systemprompt_sync/
lib.rs

1pub mod api_client;
2pub mod crate_deploy;
3pub mod database;
4pub mod diff;
5pub mod error;
6pub mod export;
7mod file_bundler;
8pub mod files;
9pub mod jobs;
10pub mod local;
11pub mod models;
12
13use serde::{Deserialize, Serialize};
14use systemprompt_identifiers::TenantId;
15
16pub use api_client::SyncApiClient;
17pub use database::{ContextExport, DatabaseExport, DatabaseSyncService, SkillExport};
18pub use diff::{
19    AgentsDiffCalculator, ContentDiffCalculator, SkillsDiffCalculator, compute_content_hash,
20};
21pub use error::{SyncError, SyncResult};
22pub use export::{
23    escape_yaml, export_agent_to_disk, export_content_to_file, export_skill_to_disk,
24    generate_agent_config, generate_agent_system_prompt, generate_content_markdown,
25    generate_skill_config, generate_skill_markdown,
26};
27pub use files::{
28    FileBundle, FileDiffStatus, FileEntry, FileManifest, FileSyncService, PullDownload,
29    SyncDiffEntry, SyncDiffResult,
30};
31pub use jobs::ContentSyncJob;
32pub use local::{AgentsLocalSync, ContentDiffEntry, ContentLocalSync, SkillsLocalSync};
33pub use models::{
34    AgentDiffItem, AgentsDiffResult, ContentDiffItem, ContentDiffResult, DiffStatus, DiskAgent,
35    DiskContent, DiskSkill, LocalSyncDirection, LocalSyncResult, SkillDiffItem, SkillsDiffResult,
36};
37
38#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
39pub enum SyncDirection {
40    Push,
41    Pull,
42}
43
44#[derive(Clone, Debug)]
45pub struct SyncConfig {
46    pub direction: SyncDirection,
47    pub dry_run: bool,
48    pub verbose: bool,
49    pub tenant_id: TenantId,
50    pub api_url: String,
51    pub api_token: String,
52    pub services_path: String,
53    pub hostname: Option<String>,
54    pub sync_token: Option<String>,
55    pub local_database_url: Option<String>,
56}
57
58#[derive(Debug)]
59pub struct SyncConfigBuilder {
60    direction: SyncDirection,
61    dry_run: bool,
62    verbose: bool,
63    tenant_id: TenantId,
64    api_url: String,
65    api_token: String,
66    services_path: String,
67    hostname: Option<String>,
68    sync_token: Option<String>,
69    local_database_url: Option<String>,
70}
71
72impl SyncConfigBuilder {
73    pub fn new(
74        tenant_id: impl Into<TenantId>,
75        api_url: impl Into<String>,
76        api_token: impl Into<String>,
77        services_path: impl Into<String>,
78    ) -> Self {
79        Self {
80            direction: SyncDirection::Push,
81            dry_run: false,
82            verbose: false,
83            tenant_id: tenant_id.into(),
84            api_url: api_url.into(),
85            api_token: api_token.into(),
86            services_path: services_path.into(),
87            hostname: None,
88            sync_token: None,
89            local_database_url: None,
90        }
91    }
92
93    pub const fn with_direction(mut self, direction: SyncDirection) -> Self {
94        self.direction = direction;
95        self
96    }
97
98    pub const fn with_dry_run(mut self, dry_run: bool) -> Self {
99        self.dry_run = dry_run;
100        self
101    }
102
103    pub const fn with_verbose(mut self, verbose: bool) -> Self {
104        self.verbose = verbose;
105        self
106    }
107
108    pub fn with_hostname(mut self, hostname: Option<String>) -> Self {
109        self.hostname = hostname;
110        self
111    }
112
113    pub fn with_sync_token(mut self, sync_token: Option<String>) -> Self {
114        self.sync_token = sync_token;
115        self
116    }
117
118    pub fn with_local_database_url(mut self, url: impl Into<String>) -> Self {
119        self.local_database_url = Some(url.into());
120        self
121    }
122
123    pub fn build(self) -> SyncConfig {
124        SyncConfig {
125            direction: self.direction,
126            dry_run: self.dry_run,
127            verbose: self.verbose,
128            tenant_id: self.tenant_id,
129            api_url: self.api_url,
130            api_token: self.api_token,
131            services_path: self.services_path,
132            hostname: self.hostname,
133            sync_token: self.sync_token,
134            local_database_url: self.local_database_url,
135        }
136    }
137}
138
139impl SyncConfig {
140    pub fn builder(
141        tenant_id: impl Into<TenantId>,
142        api_url: impl Into<String>,
143        api_token: impl Into<String>,
144        services_path: impl Into<String>,
145    ) -> SyncConfigBuilder {
146        SyncConfigBuilder::new(tenant_id, api_url, api_token, services_path)
147    }
148}
149
150#[derive(Clone, Copy, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
151#[serde(tag = "kind", rename_all = "snake_case")]
152pub enum SyncOpState {
153    NotStarted,
154    Partial {
155        completed: usize,
156        total: usize,
157    },
158    #[default]
159    Completed,
160    Failed,
161}
162
163#[derive(Clone, Debug, Serialize, Deserialize)]
164pub struct SyncOperationResult {
165    pub operation: String,
166    pub success: bool,
167    pub items_synced: usize,
168    pub items_skipped: usize,
169    pub errors: Vec<String>,
170    pub details: Option<serde_json::Value>,
171    #[serde(default)]
172    pub state: SyncOpState,
173}
174
175impl SyncOperationResult {
176    pub fn success(operation: &str, items_synced: usize) -> Self {
177        Self {
178            operation: operation.to_string(),
179            success: true,
180            items_synced,
181            items_skipped: 0,
182            errors: vec![],
183            details: None,
184            state: SyncOpState::Completed,
185        }
186    }
187
188    pub fn with_details(mut self, details: serde_json::Value) -> Self {
189        self.details = Some(details);
190        self
191    }
192
193    pub fn dry_run(operation: &str, items_skipped: usize, details: serde_json::Value) -> Self {
194        Self {
195            operation: operation.to_string(),
196            success: true,
197            items_synced: 0,
198            items_skipped,
199            errors: vec![],
200            details: Some(details),
201            state: SyncOpState::Completed,
202        }
203    }
204}
205
206#[derive(Debug)]
207pub struct SyncService {
208    config: SyncConfig,
209    api_client: SyncApiClient,
210}
211
212impl SyncService {
213    pub fn new(config: SyncConfig) -> SyncResult<Self> {
214        let api_client = SyncApiClient::new(&config.api_url, &config.api_token)?
215            .with_direct_sync(config.hostname.clone(), config.sync_token.clone());
216        Ok(Self { config, api_client })
217    }
218
219    pub async fn sync_files(&self) -> SyncResult<SyncOperationResult> {
220        let service = FileSyncService::new(self.config.clone(), self.api_client.clone());
221        service.sync().await
222    }
223
224    pub async fn sync_database(&self) -> SyncResult<SyncOperationResult> {
225        let local_db_url = self.config.local_database_url.as_ref().ok_or_else(|| {
226            SyncError::MissingConfig("local_database_url not configured".to_string())
227        })?;
228
229        let cloud_db_url = self
230            .api_client
231            .get_database_url(&self.config.tenant_id)
232            .await
233            .map_err(|e| SyncError::ApiError {
234                status: 500,
235                message: format!("Failed to get cloud database URL: {e}"),
236            })?;
237
238        let service = DatabaseSyncService::new(
239            self.config.direction,
240            self.config.dry_run,
241            local_db_url,
242            &cloud_db_url,
243        );
244
245        service.sync().await
246    }
247
248    pub async fn sync_all(&self) -> SyncResult<Vec<SyncOperationResult>> {
249        let mut results = Vec::new();
250
251        let files_result = self.sync_files().await?;
252        results.push(files_result);
253
254        match self.sync_database().await {
255            Ok(db_result) => results.push(db_result),
256            Err(e) => {
257                tracing::warn!(error = %e, "Database sync failed");
258                let (state, items_synced) = match &e {
259                    SyncError::MissingConfig(_) => (SyncOpState::NotStarted, 0),
260                    SyncError::PartialImport {
261                        completed, total, ..
262                    } => (
263                        SyncOpState::Partial {
264                            completed: *completed,
265                            total: *total,
266                        },
267                        *completed,
268                    ),
269                    _ => (SyncOpState::Failed, 0),
270                };
271                results.push(SyncOperationResult {
272                    operation: "database".to_string(),
273                    success: false,
274                    items_synced,
275                    items_skipped: 0,
276                    errors: vec![e.to_string()],
277                    details: None,
278                    state,
279                });
280            },
281        }
282
283        Ok(results)
284    }
285}