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