Skip to main content

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