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//! - [`deploy::DeployOrchestrator`] — full `cloud deploy` pipeline (pre-sync,
12//!   artifact validation, image build/push, secret provisioning, deploy) behind
13//!   a [`deploy::DeployProgress`] rendering seam.
14//! - [`SyncApiClient`] — low-level HTTP client for the cloud API.
15//! - [`ContentLocalSync`] — disk ↔ database sync for content.
16//! - [`ContentDiffCalculator`] — pure diff computation.
17//! - [`SyncError`] / [`SyncResult`] — typed error returned by every public
18//!   function in this crate.
19//!
20//! # Feature flags
21//!
22//! This crate has no Cargo features.
23//!
24//! Copyright (c) systemprompt.io — Business Source License 1.1.
25//! See <https://systemprompt.io> for licensing details.
26
27mod config;
28mod result;
29
30pub mod api_client;
31pub mod crate_deploy;
32pub mod database;
33pub mod deploy;
34pub mod diff;
35pub mod error;
36pub mod export;
37pub mod files;
38pub mod jobs;
39pub mod local;
40pub mod models;
41
42use serde::{Deserialize, Serialize};
43
44pub use api_client::SyncApiClient;
45pub use config::{SyncConfig, SyncConfigBuilder};
46pub use database::{ContextExport, DatabaseExport, DatabaseSyncService};
47pub use diff::{ContentDiffCalculator, compute_content_hash};
48pub use error::{SyncError, SyncResult};
49pub use export::{escape_yaml, export_content_to_file, generate_content_markdown};
50pub use files::{
51    FileBundle, FileDiffStatus, FileEntry, FileManifest, FileSyncService, PullDownload,
52    SyncDiffEntry, SyncDiffResult,
53};
54pub use jobs::{AccessControlSyncJob, ContentSyncJob};
55pub use local::{AccessControlLocalSync, ContentDiffEntry, ContentLocalSync};
56pub use models::{
57    ContentDiffItem, ContentDiffResult, DiffStatus, DiskContent, LocalSyncDirection,
58    LocalSyncResult,
59};
60pub use result::{SyncOpState, SyncOperationResult};
61
62#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
63pub enum SyncDirection {
64    Push,
65    Pull,
66}
67
68#[derive(Debug)]
69pub struct SyncService {
70    config: SyncConfig,
71    api_client: SyncApiClient,
72}
73
74impl SyncService {
75    pub fn new(config: SyncConfig) -> SyncResult<Self> {
76        let api_client = SyncApiClient::new(&config.api_url, &config.api_token)?
77            .with_direct_sync(config.hostname.clone());
78        Ok(Self { config, api_client })
79    }
80
81    pub async fn sync_files(&self) -> SyncResult<SyncOperationResult> {
82        let service = FileSyncService::new(self.config.clone(), self.api_client.clone());
83        service.sync().await
84    }
85
86    pub async fn sync_database(&self) -> SyncResult<SyncOperationResult> {
87        let local_db_url = self.config.local_database_url.as_ref().ok_or_else(|| {
88            SyncError::MissingConfig("local_database_url not configured".to_owned())
89        })?;
90
91        let cloud_db_url = self
92            .api_client
93            .get_database_url(&self.config.tenant_id)
94            .await
95            .map_err(|e| SyncError::ApiError {
96                status: 500,
97                message: format!("Failed to get cloud database URL: {e}"),
98            })?;
99
100        let service = DatabaseSyncService::new(
101            self.config.direction,
102            self.config.dry_run,
103            local_db_url,
104            &cloud_db_url,
105        );
106
107        service.sync().await
108    }
109
110    pub async fn sync_all(&self) -> SyncResult<Vec<SyncOperationResult>> {
111        let mut results = Vec::new();
112
113        let files_result = self.sync_files().await?;
114        results.push(files_result);
115
116        match self.sync_database().await {
117            Ok(db_result) => results.push(db_result),
118            Err(e) => results.push(database_failure_result(&e)),
119        }
120
121        Ok(results)
122    }
123}
124
125fn database_failure_result(error: &SyncError) -> SyncOperationResult {
126    tracing::warn!(error = %error, "Database sync failed");
127    let (state, items_synced) = match error {
128        SyncError::MissingConfig(_) => (SyncOpState::NotStarted, 0),
129        SyncError::PartialImport {
130            completed, total, ..
131        } => (
132            SyncOpState::Partial {
133                completed: *completed,
134                total: *total,
135            },
136            *completed,
137        ),
138        _ => (SyncOpState::Failed, 0),
139    };
140    SyncOperationResult {
141        operation: "database".to_owned(),
142        success: false,
143        items_synced,
144        items_skipped: 0,
145        errors: vec![error.to_string()],
146        details: None,
147        state,
148    }
149}