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
21mod config;
22mod result;
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};
37
38pub use api_client::SyncApiClient;
39pub use config::{SyncConfig, SyncConfigBuilder};
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};
54pub use result::{SyncOpState, SyncOperationResult};
55
56#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
57pub enum SyncDirection {
58    Push,
59    Pull,
60}
61
62#[derive(Debug)]
63pub struct SyncService {
64    config: SyncConfig,
65    api_client: SyncApiClient,
66}
67
68impl SyncService {
69    pub fn new(config: SyncConfig) -> SyncResult<Self> {
70        let api_client = SyncApiClient::new(&config.api_url, &config.api_token)?
71            .with_direct_sync(config.hostname.clone(), config.sync_token.clone());
72        Ok(Self { config, api_client })
73    }
74
75    pub async fn sync_files(&self) -> SyncResult<SyncOperationResult> {
76        let service = FileSyncService::new(self.config.clone(), self.api_client.clone());
77        service.sync().await
78    }
79
80    pub async fn sync_database(&self) -> SyncResult<SyncOperationResult> {
81        let local_db_url = self.config.local_database_url.as_ref().ok_or_else(|| {
82            SyncError::MissingConfig("local_database_url not configured".to_string())
83        })?;
84
85        let cloud_db_url = self
86            .api_client
87            .get_database_url(&self.config.tenant_id)
88            .await
89            .map_err(|e| SyncError::ApiError {
90                status: 500,
91                message: format!("Failed to get cloud database URL: {e}"),
92            })?;
93
94        let service = DatabaseSyncService::new(
95            self.config.direction,
96            self.config.dry_run,
97            local_db_url,
98            &cloud_db_url,
99        );
100
101        service.sync().await
102    }
103
104    pub async fn sync_all(&self) -> SyncResult<Vec<SyncOperationResult>> {
105        let mut results = Vec::new();
106
107        let files_result = self.sync_files().await?;
108        results.push(files_result);
109
110        match self.sync_database().await {
111            Ok(db_result) => results.push(db_result),
112            Err(e) => results.push(database_failure_result(&e)),
113        }
114
115        Ok(results)
116    }
117}
118
119fn database_failure_result(error: &SyncError) -> SyncOperationResult {
120    tracing::warn!(error = %error, "Database sync failed");
121    let (state, items_synced) = match error {
122        SyncError::MissingConfig(_) => (SyncOpState::NotStarted, 0),
123        SyncError::PartialImport {
124            completed, total, ..
125        } => (
126            SyncOpState::Partial {
127                completed: *completed,
128                total: *total,
129            },
130            *completed,
131        ),
132        _ => (SyncOpState::Failed, 0),
133    };
134    SyncOperationResult {
135        operation: "database".to_string(),
136        success: false,
137        items_synced,
138        items_skipped: 0,
139        errors: vec![error.to_string()],
140        details: None,
141        state,
142    }
143}