Skip to main content

systemprompt_sync/
files.rs

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