Skip to main content

systemprompt_sync/files/
mod.rs

1//! High-level push / pull / diff for the on-disk `services/` directory:
2//! bundles eligible files into a tarball, talks to the cloud, and reports a
3//! per-file diff.
4//!
5//! Copyright (c) systemprompt.io — Business Source License 1.1.
6//! See <https://systemprompt.io> for licensing details.
7
8use serde::{Deserialize, Serialize};
9use std::fs;
10use std::path::{Path, PathBuf};
11use zip::ZipWriter;
12use zip::write::SimpleFileOptions;
13
14mod file_bundler;
15
16use self::file_bundler::{
17    INCLUDE_DIRS, add_dir_to_zip, collect_files, compare_tarball_with_local, create_tarball,
18    extract_tarball, extract_tarball_selective, peek_manifest,
19};
20use crate::api_client::SyncApiClient;
21use crate::error::SyncResult;
22use crate::{SyncConfig, SyncDirection, SyncOperationResult};
23
24#[derive(Clone, Debug, Serialize, Deserialize)]
25pub struct FileBundle {
26    pub manifest: FileManifest,
27    #[serde(skip)]
28    pub data: Vec<u8>,
29}
30
31#[derive(Clone, Debug, Serialize, Deserialize)]
32pub struct FileManifest {
33    pub files: Vec<FileEntry>,
34    pub timestamp: chrono::DateTime<chrono::Utc>,
35    pub checksum: String,
36}
37
38#[derive(Clone, Debug, Serialize, Deserialize)]
39pub struct FileEntry {
40    pub path: String,
41    pub checksum: String,
42    pub size: u64,
43}
44
45#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
46pub enum FileDiffStatus {
47    Added,
48    Modified,
49    Deleted,
50    Unchanged,
51}
52
53#[derive(Clone, Debug, Serialize, Deserialize)]
54pub struct SyncDiffEntry {
55    pub path: String,
56    pub status: FileDiffStatus,
57    pub size: u64,
58}
59
60#[derive(Debug)]
61pub struct SyncDiffResult {
62    pub entries: Vec<SyncDiffEntry>,
63    pub added: usize,
64    pub modified: usize,
65    pub deleted: usize,
66    pub unchanged: usize,
67}
68
69impl SyncDiffResult {
70    pub const fn has_changes(&self) -> bool {
71        self.added > 0 || self.modified > 0 || self.deleted > 0
72    }
73
74    pub fn changed_paths(&self) -> Vec<String> {
75        self.entries
76            .iter()
77            .filter(|e| e.status != FileDiffStatus::Unchanged)
78            .map(|e| e.path.clone())
79            .collect()
80    }
81}
82
83#[derive(Debug)]
84pub struct PullDownload {
85    pub data: Vec<u8>,
86    pub diff: SyncDiffResult,
87}
88
89#[derive(Debug)]
90pub struct FileSyncService {
91    config: SyncConfig,
92    api_client: SyncApiClient,
93}
94
95impl FileSyncService {
96    pub const fn new(config: SyncConfig, api_client: SyncApiClient) -> Self {
97        Self { config, api_client }
98    }
99
100    pub async fn sync(&self) -> SyncResult<SyncOperationResult> {
101        match self.config.direction {
102            SyncDirection::Push => self.push().await,
103            SyncDirection::Pull => self.pull().await,
104        }
105    }
106
107    pub async fn download_and_diff(&self) -> SyncResult<PullDownload> {
108        let services_path = PathBuf::from(&self.config.services_path);
109        let data = self
110            .api_client
111            .download_files(&self.config.tenant_id)
112            .await?;
113
114        let diff = compare_tarball_with_local(&data, &services_path)?;
115
116        Ok(PullDownload { data, diff })
117    }
118
119    pub fn backup_services(services_path: &Path) -> SyncResult<PathBuf> {
120        let project_root = services_path.parent().unwrap_or(services_path);
121        let backup_dir = project_root.join("backup");
122        fs::create_dir_all(&backup_dir)?;
123
124        let timestamp = chrono::Utc::now().format("%Y%m%d-%H%M%S");
125        let zip_path = backup_dir.join(format!("{timestamp}.zip"));
126
127        let file = fs::File::create(&zip_path)?;
128        let mut zip = ZipWriter::new(file);
129        let options =
130            SimpleFileOptions::default().compression_method(zip::CompressionMethod::Deflated);
131
132        for dir in INCLUDE_DIRS {
133            let dir_path = services_path.join(dir);
134            if dir_path.exists() {
135                add_dir_to_zip(&mut zip, &dir_path, services_path, options)?;
136            }
137        }
138
139        zip.finish()?;
140        Ok(zip_path)
141    }
142
143    pub fn apply(data: &[u8], services_path: &Path, paths: Option<&[String]>) -> SyncResult<usize> {
144        paths.map_or_else(
145            || extract_tarball(data, services_path),
146            |paths| extract_tarball_selective(data, services_path, paths),
147        )
148    }
149
150    async fn push(&self) -> SyncResult<SyncOperationResult> {
151        let services_path = PathBuf::from(&self.config.services_path);
152        let bundle = collect_files(&services_path)?;
153        let file_count = bundle.manifest.files.len();
154
155        if self.config.dry_run {
156            return Ok(SyncOperationResult::dry_run(
157                "files_push",
158                file_count,
159                serde_json::to_value(&bundle.manifest)?,
160            ));
161        }
162
163        let data = create_tarball(&services_path, &bundle.manifest)?;
164
165        let upload = self
166            .api_client
167            .upload_files(&self.config.tenant_id, data)
168            .await?;
169
170        Ok(SyncOperationResult::success(
171            "files_push",
172            upload.files_uploaded,
173        ))
174    }
175
176    async fn pull(&self) -> SyncResult<SyncOperationResult> {
177        let services_path = PathBuf::from(&self.config.services_path);
178        let data = self
179            .api_client
180            .download_files(&self.config.tenant_id)
181            .await?;
182
183        if self.config.dry_run {
184            let manifest = peek_manifest(&data)?;
185            return Ok(SyncOperationResult::dry_run(
186                "files_pull",
187                manifest.files.len(),
188                serde_json::to_value(&manifest)?,
189            ));
190        }
191
192        let count = extract_tarball(&data, &services_path)?;
193        Ok(SyncOperationResult::success("files_pull", count))
194    }
195}