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