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}