Skip to main content

systemprompt_sync/
lib.rs

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