systemprompt-sync 0.2.0

Sync services for systemprompt.io - file, database, and crate deployment synchronization
Documentation
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::{Path, PathBuf};
use zip::ZipWriter;
use zip::write::SimpleFileOptions;

use crate::api_client::SyncApiClient;
use crate::error::SyncResult;
use crate::file_bundler::{
    INCLUDE_DIRS, add_dir_to_zip, collect_files, compare_tarball_with_local, create_tarball,
    extract_tarball, extract_tarball_selective, peek_manifest,
};
use crate::{SyncConfig, SyncDirection, SyncOperationResult};

#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct FileBundle {
    pub manifest: FileManifest,
    #[serde(skip)]
    pub data: Vec<u8>,
}

#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct FileManifest {
    pub files: Vec<FileEntry>,
    pub timestamp: chrono::DateTime<chrono::Utc>,
    pub checksum: String,
}

#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct FileEntry {
    pub path: String,
    pub checksum: String,
    pub size: u64,
}

#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum FileDiffStatus {
    Added,
    Modified,
    Deleted,
    Unchanged,
}

#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct SyncDiffEntry {
    pub path: String,
    pub status: FileDiffStatus,
    pub size: u64,
}

#[derive(Debug)]
pub struct SyncDiffResult {
    pub entries: Vec<SyncDiffEntry>,
    pub added: usize,
    pub modified: usize,
    pub deleted: usize,
    pub unchanged: usize,
}

impl SyncDiffResult {
    pub const fn has_changes(&self) -> bool {
        self.added > 0 || self.modified > 0 || self.deleted > 0
    }

    pub fn changed_paths(&self) -> Vec<String> {
        self.entries
            .iter()
            .filter(|e| e.status != FileDiffStatus::Unchanged)
            .map(|e| e.path.clone())
            .collect()
    }
}

#[derive(Debug)]
pub struct PullDownload {
    pub data: Vec<u8>,
    pub diff: SyncDiffResult,
}

#[derive(Debug)]
pub struct FileSyncService {
    config: SyncConfig,
    api_client: SyncApiClient,
}

impl FileSyncService {
    pub const fn new(config: SyncConfig, api_client: SyncApiClient) -> Self {
        Self { config, api_client }
    }

    pub async fn sync(&self) -> SyncResult<SyncOperationResult> {
        match self.config.direction {
            SyncDirection::Push => self.push().await,
            SyncDirection::Pull => self.pull().await,
        }
    }

    pub async fn download_and_diff(&self) -> SyncResult<PullDownload> {
        let services_path = PathBuf::from(&self.config.services_path);
        let data = self
            .api_client
            .download_files(&self.config.tenant_id)
            .await?;

        let diff = compare_tarball_with_local(&data, &services_path)?;

        Ok(PullDownload { data, diff })
    }

    pub fn backup_services(services_path: &Path) -> SyncResult<PathBuf> {
        let project_root = services_path.parent().unwrap_or(services_path);
        let backup_dir = project_root.join("backup");
        fs::create_dir_all(&backup_dir)?;

        let timestamp = chrono::Utc::now().format("%Y%m%d-%H%M%S");
        let zip_path = backup_dir.join(format!("{timestamp}.zip"));

        let file = fs::File::create(&zip_path)?;
        let mut zip = ZipWriter::new(file);
        let options =
            SimpleFileOptions::default().compression_method(zip::CompressionMethod::Deflated);

        for dir in INCLUDE_DIRS {
            let dir_path = services_path.join(dir);
            if dir_path.exists() {
                add_dir_to_zip(&mut zip, &dir_path, services_path, options)?;
            }
        }

        zip.finish()?;
        Ok(zip_path)
    }

    pub fn apply(data: &[u8], services_path: &Path, paths: Option<&[String]>) -> SyncResult<usize> {
        paths.map_or_else(
            || extract_tarball(data, services_path),
            |paths| extract_tarball_selective(data, services_path, paths),
        )
    }

    async fn push(&self) -> SyncResult<SyncOperationResult> {
        let services_path = PathBuf::from(&self.config.services_path);
        let bundle = collect_files(&services_path)?;
        let file_count = bundle.manifest.files.len();

        if self.config.dry_run {
            return Ok(SyncOperationResult::dry_run(
                "files_push",
                file_count,
                serde_json::to_value(&bundle.manifest)?,
            ));
        }

        let data = create_tarball(&services_path, &bundle.manifest)?;

        let upload = self
            .api_client
            .upload_files(&self.config.tenant_id, data)
            .await?;

        Ok(SyncOperationResult::success(
            "files_push",
            upload.files_uploaded,
        ))
    }

    async fn pull(&self) -> SyncResult<SyncOperationResult> {
        let services_path = PathBuf::from(&self.config.services_path);
        let data = self
            .api_client
            .download_files(&self.config.tenant_id)
            .await?;

        if self.config.dry_run {
            let manifest = peek_manifest(&data)?;
            return Ok(SyncOperationResult::dry_run(
                "files_pull",
                manifest.files.len(),
                serde_json::to_value(&manifest)?,
            ));
        }

        let count = extract_tarball(&data, &services_path)?;
        Ok(SyncOperationResult::success("files_pull", count))
    }
}