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