Skip to main content

systemprompt_sync/
files.rs

1use flate2::read::GzDecoder;
2use flate2::write::GzEncoder;
3use flate2::Compression;
4use serde::{Deserialize, Serialize};
5use sha2::{Digest, Sha256};
6use std::collections::HashMap;
7use std::fs;
8use std::io::{Read, Write};
9use std::path::{Path, PathBuf};
10use tar::{Archive, Builder};
11use zip::write::SimpleFileOptions;
12use zip::ZipWriter;
13
14use crate::api_client::SyncApiClient;
15use crate::error::SyncResult;
16use crate::{SyncConfig, SyncDirection, SyncOperationResult};
17
18const INCLUDE_DIRS: [&str; 8] = [
19    "agents", "skills", "content", "web", "config", "profiles", "plugins", "hooks",
20];
21
22#[derive(Clone, Debug, Serialize, Deserialize)]
23pub struct FileBundle {
24    pub manifest: FileManifest,
25    #[serde(skip)]
26    pub data: Vec<u8>,
27}
28
29#[derive(Clone, Debug, Serialize, Deserialize)]
30pub struct FileManifest {
31    pub files: Vec<FileEntry>,
32    pub timestamp: chrono::DateTime<chrono::Utc>,
33    pub checksum: String,
34}
35
36#[derive(Clone, Debug, Serialize, Deserialize)]
37pub struct FileEntry {
38    pub path: String,
39    pub checksum: String,
40    pub size: u64,
41}
42
43#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
44pub enum FileDiffStatus {
45    Added,
46    Modified,
47    Deleted,
48    Unchanged,
49}
50
51#[derive(Clone, Debug, Serialize, Deserialize)]
52pub struct SyncDiffEntry {
53    pub path: String,
54    pub status: FileDiffStatus,
55    pub size: u64,
56}
57
58#[derive(Debug)]
59pub struct SyncDiffResult {
60    pub entries: Vec<SyncDiffEntry>,
61    pub added: usize,
62    pub modified: usize,
63    pub deleted: usize,
64    pub unchanged: usize,
65}
66
67impl SyncDiffResult {
68    pub const fn has_changes(&self) -> bool {
69        self.added > 0 || self.modified > 0 || self.deleted > 0
70    }
71
72    pub fn changed_paths(&self) -> Vec<String> {
73        self.entries
74            .iter()
75            .filter(|e| e.status != FileDiffStatus::Unchanged)
76            .map(|e| e.path.clone())
77            .collect()
78    }
79}
80
81#[derive(Debug)]
82pub struct PullDownload {
83    pub data: Vec<u8>,
84    pub diff: SyncDiffResult,
85}
86
87#[derive(Debug)]
88pub struct FileSyncService {
89    config: SyncConfig,
90    api_client: SyncApiClient,
91}
92
93impl FileSyncService {
94    pub const fn new(config: SyncConfig, api_client: SyncApiClient) -> Self {
95        Self { config, api_client }
96    }
97
98    pub async fn sync(&self) -> SyncResult<SyncOperationResult> {
99        match self.config.direction {
100            SyncDirection::Push => self.push().await,
101            SyncDirection::Pull => self.pull().await,
102        }
103    }
104
105    pub async fn download_and_diff(&self) -> SyncResult<PullDownload> {
106        let services_path = PathBuf::from(&self.config.services_path);
107        let data = self
108            .api_client
109            .download_files(&self.config.tenant_id)
110            .await?;
111
112        let diff = Self::compare_tarball_with_local(&data, &services_path)?;
113
114        Ok(PullDownload { data, diff })
115    }
116
117    pub fn backup_services(services_path: &Path) -> SyncResult<PathBuf> {
118        let project_root = services_path.parent().unwrap_or(services_path);
119        let backup_dir = project_root.join("backup");
120        fs::create_dir_all(&backup_dir)?;
121
122        let timestamp = chrono::Utc::now().format("%Y%m%d-%H%M%S");
123        let zip_path = backup_dir.join(format!("{timestamp}.zip"));
124
125        let file = fs::File::create(&zip_path)?;
126        let mut zip = ZipWriter::new(file);
127        let options =
128            SimpleFileOptions::default().compression_method(zip::CompressionMethod::Deflated);
129
130        for dir in INCLUDE_DIRS {
131            let dir_path = services_path.join(dir);
132            if dir_path.exists() {
133                Self::add_dir_to_zip(&mut zip, &dir_path, services_path, options)?;
134            }
135        }
136
137        zip.finish()?;
138        Ok(zip_path)
139    }
140
141    pub fn apply(data: &[u8], services_path: &Path, paths: Option<&[String]>) -> SyncResult<usize> {
142        paths.map_or_else(
143            || Self::extract_tarball(data, services_path),
144            |paths| Self::extract_tarball_selective(data, services_path, paths),
145        )
146    }
147
148    async fn push(&self) -> SyncResult<SyncOperationResult> {
149        let services_path = PathBuf::from(&self.config.services_path);
150        let bundle = Self::collect_files(&services_path)?;
151        let file_count = bundle.manifest.files.len();
152
153        if self.config.dry_run {
154            return Ok(SyncOperationResult::dry_run(
155                "files_push",
156                file_count,
157                serde_json::to_value(&bundle.manifest)?,
158            ));
159        }
160
161        let data = Self::create_tarball(&services_path, &bundle.manifest)?;
162
163        let upload = self
164            .api_client
165            .upload_files(&self.config.tenant_id, data)
166            .await?;
167
168        Ok(SyncOperationResult::success(
169            "files_push",
170            upload.files_uploaded,
171        ))
172    }
173
174    async fn pull(&self) -> SyncResult<SyncOperationResult> {
175        let services_path = PathBuf::from(&self.config.services_path);
176        let data = self
177            .api_client
178            .download_files(&self.config.tenant_id)
179            .await?;
180
181        if self.config.dry_run {
182            let manifest = Self::peek_manifest(&data)?;
183            return Ok(SyncOperationResult::dry_run(
184                "files_pull",
185                manifest.files.len(),
186                serde_json::to_value(&manifest)?,
187            ));
188        }
189
190        let count = Self::extract_tarball(&data, &services_path)?;
191        Ok(SyncOperationResult::success("files_pull", count))
192    }
193
194    fn collect_files(services_path: &Path) -> SyncResult<FileBundle> {
195        let mut files = vec![];
196
197        for dir in INCLUDE_DIRS {
198            let dir_path = services_path.join(dir);
199            if dir_path.exists() {
200                Self::collect_dir(&dir_path, services_path, &mut files)?;
201            }
202        }
203
204        let mut hasher = Sha256::new();
205        for file_entry in &files {
206            hasher.update(&file_entry.checksum);
207        }
208        let checksum = format!("{:x}", hasher.finalize());
209
210        Ok(FileBundle {
211            manifest: FileManifest {
212                files,
213                timestamp: chrono::Utc::now(),
214                checksum,
215            },
216            data: vec![],
217        })
218    }
219
220    fn collect_dir(dir: &Path, base: &Path, files: &mut Vec<FileEntry>) -> SyncResult<()> {
221        for entry in fs::read_dir(dir)? {
222            let entry = entry?;
223            let path = entry.path();
224
225            if path.is_dir() {
226                Self::collect_dir(&path, base, files)?;
227            } else if path.is_file() {
228                let relative = path.strip_prefix(base)?;
229                let content = fs::read(&path)?;
230                let checksum = format!("{:x}", Sha256::digest(&content));
231
232                files.push(FileEntry {
233                    path: relative.to_string_lossy().to_string(),
234                    checksum,
235                    size: content.len() as u64,
236                });
237            }
238        }
239        Ok(())
240    }
241
242    fn create_tarball(base: &Path, manifest: &FileManifest) -> SyncResult<Vec<u8>> {
243        let mut encoder = GzEncoder::new(Vec::new(), Compression::default());
244        {
245            let mut tar = Builder::new(&mut encoder);
246            for file in &manifest.files {
247                let full_path = base.join(&file.path);
248                tar.append_path_with_name(&full_path, &file.path)?;
249            }
250            tar.finish()?;
251        }
252        Ok(encoder.finish()?)
253    }
254
255    fn extract_tarball(data: &[u8], target: &Path) -> SyncResult<usize> {
256        let decoder = GzDecoder::new(data);
257        let mut archive = Archive::new(decoder);
258        let mut count = 0;
259
260        for entry in archive.entries()? {
261            let mut entry = entry?;
262            let path = target.join(entry.path()?);
263            if let Some(parent) = path.parent() {
264                fs::create_dir_all(parent)?;
265            }
266            entry.unpack(&path)?;
267            count += 1;
268        }
269
270        Ok(count)
271    }
272
273    fn extract_tarball_selective(
274        data: &[u8],
275        target: &Path,
276        paths_to_sync: &[String],
277    ) -> SyncResult<usize> {
278        let allowed: std::collections::HashSet<&str> =
279            paths_to_sync.iter().map(String::as_str).collect();
280
281        let decoder = GzDecoder::new(data);
282        let mut archive = Archive::new(decoder);
283        let mut count = 0;
284
285        for entry in archive.entries()? {
286            let mut entry = entry?;
287            let entry_path = entry.path()?.to_string_lossy().to_string();
288
289            if !allowed.contains(entry_path.as_str()) {
290                continue;
291            }
292
293            let path = target.join(&entry_path);
294            if let Some(parent) = path.parent() {
295                fs::create_dir_all(parent)?;
296            }
297            entry.unpack(&path)?;
298            count += 1;
299        }
300
301        Ok(count)
302    }
303
304    fn compare_tarball_with_local(data: &[u8], services_path: &Path) -> SyncResult<SyncDiffResult> {
305        let temp_dir = tempfile::tempdir()?;
306        Self::extract_tarball(data, temp_dir.path())?;
307
308        let mut remote_files: HashMap<String, (String, u64)> = HashMap::new();
309        for dir in INCLUDE_DIRS {
310            let dir_path = temp_dir.path().join(dir);
311            if dir_path.exists() {
312                let mut entries = vec![];
313                Self::collect_dir(&dir_path, temp_dir.path(), &mut entries)?;
314                for entry in entries {
315                    remote_files.insert(entry.path, (entry.checksum, entry.size));
316                }
317            }
318        }
319
320        let mut local_files: HashMap<String, String> = HashMap::new();
321        for dir in INCLUDE_DIRS {
322            let dir_path = services_path.join(dir);
323            if dir_path.exists() {
324                let mut entries = vec![];
325                Self::collect_dir(&dir_path, services_path, &mut entries)?;
326                for entry in entries {
327                    local_files.insert(entry.path, entry.checksum);
328                }
329            }
330        }
331
332        let mut entries = Vec::new();
333        let mut added = 0;
334        let mut modified = 0;
335        let mut unchanged = 0;
336
337        for (path, (remote_checksum, size)) in &remote_files {
338            match local_files.get(path) {
339                Some(local_checksum) if local_checksum == remote_checksum => {
340                    unchanged += 1;
341                    entries.push(SyncDiffEntry {
342                        path: path.clone(),
343                        status: FileDiffStatus::Unchanged,
344                        size: *size,
345                    });
346                },
347                Some(_) => {
348                    modified += 1;
349                    entries.push(SyncDiffEntry {
350                        path: path.clone(),
351                        status: FileDiffStatus::Modified,
352                        size: *size,
353                    });
354                },
355                None => {
356                    added += 1;
357                    entries.push(SyncDiffEntry {
358                        path: path.clone(),
359                        status: FileDiffStatus::Added,
360                        size: *size,
361                    });
362                },
363            }
364        }
365
366        let mut deleted = 0;
367        for path in local_files.keys() {
368            if !remote_files.contains_key(path) {
369                deleted += 1;
370                entries.push(SyncDiffEntry {
371                    path: path.clone(),
372                    status: FileDiffStatus::Deleted,
373                    size: 0,
374                });
375            }
376        }
377
378        entries.sort_by(|a, b| a.path.cmp(&b.path));
379
380        Ok(SyncDiffResult {
381            entries,
382            added,
383            modified,
384            deleted,
385            unchanged,
386        })
387    }
388
389    fn peek_manifest(data: &[u8]) -> SyncResult<FileManifest> {
390        let decoder = GzDecoder::new(data);
391        let mut archive = Archive::new(decoder);
392        let mut files = vec![];
393
394        for entry in archive.entries()? {
395            let entry = entry?;
396            files.push(FileEntry {
397                path: entry.path()?.to_string_lossy().to_string(),
398                checksum: String::new(),
399                size: entry.size(),
400            });
401        }
402
403        Ok(FileManifest {
404            files,
405            timestamp: chrono::Utc::now(),
406            checksum: String::new(),
407        })
408    }
409
410    fn add_dir_to_zip<W: Write + std::io::Seek>(
411        zip: &mut ZipWriter<W>,
412        dir: &Path,
413        base: &Path,
414        options: SimpleFileOptions,
415    ) -> SyncResult<()> {
416        for entry in fs::read_dir(dir)? {
417            let entry = entry?;
418            let path = entry.path();
419
420            if path.is_dir() {
421                Self::add_dir_to_zip(zip, &path, base, options)?;
422            } else if path.is_file() {
423                let relative = path.strip_prefix(base)?;
424                let name = relative.to_string_lossy().to_string();
425                zip.start_file(&name, options)?;
426                let mut file = fs::File::open(&path)?;
427                let mut buf = Vec::new();
428                file.read_to_end(&mut buf)?;
429                zip.write_all(&buf)?;
430            }
431        }
432        Ok(())
433    }
434}