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::fs;
7use std::path::{Path, PathBuf};
8use tar::{Archive, Builder};
9
10use crate::api_client::SyncApiClient;
11use crate::error::SyncResult;
12use crate::{SyncConfig, SyncDirection, SyncOperationResult};
13
14#[derive(Clone, Debug, Serialize, Deserialize)]
15pub struct FileBundle {
16    pub manifest: FileManifest,
17    #[serde(skip)]
18    pub data: Vec<u8>,
19}
20
21#[derive(Clone, Debug, Serialize, Deserialize)]
22pub struct FileManifest {
23    pub files: Vec<FileEntry>,
24    pub timestamp: chrono::DateTime<chrono::Utc>,
25    pub checksum: String,
26}
27
28#[derive(Clone, Debug, Serialize, Deserialize)]
29pub struct FileEntry {
30    pub path: String,
31    pub checksum: String,
32    pub size: u64,
33}
34
35#[derive(Debug)]
36pub struct FileSyncService {
37    config: SyncConfig,
38    api_client: SyncApiClient,
39}
40
41impl FileSyncService {
42    pub const fn new(config: SyncConfig, api_client: SyncApiClient) -> Self {
43        Self { config, api_client }
44    }
45
46    pub async fn sync(&self) -> SyncResult<SyncOperationResult> {
47        match self.config.direction {
48            SyncDirection::Push => self.push().await,
49            SyncDirection::Pull => self.pull().await,
50        }
51    }
52
53    async fn push(&self) -> SyncResult<SyncOperationResult> {
54        let services_path = PathBuf::from(&self.config.services_path);
55        let bundle = Self::collect_files(&services_path)?;
56        let file_count = bundle.manifest.files.len();
57
58        if self.config.dry_run {
59            return Ok(SyncOperationResult::dry_run(
60                "files_push",
61                file_count,
62                serde_json::to_value(&bundle.manifest)?,
63            ));
64        }
65
66        let data = Self::create_tarball(&services_path, &bundle.manifest)?;
67
68        let upload = self
69            .api_client
70            .upload_files(&self.config.tenant_id, data)
71            .await?;
72
73        Ok(SyncOperationResult::success(
74            "files_push",
75            upload.files_uploaded,
76        ))
77    }
78
79    async fn pull(&self) -> SyncResult<SyncOperationResult> {
80        let services_path = PathBuf::from(&self.config.services_path);
81        let data = self
82            .api_client
83            .download_files(&self.config.tenant_id)
84            .await?;
85
86        if self.config.dry_run {
87            let manifest = Self::peek_manifest(&data)?;
88            return Ok(SyncOperationResult::dry_run(
89                "files_pull",
90                manifest.files.len(),
91                serde_json::to_value(&manifest)?,
92            ));
93        }
94
95        let count = Self::extract_tarball(&data, &services_path)?;
96        Ok(SyncOperationResult::success("files_pull", count))
97    }
98
99    fn collect_files(services_path: &Path) -> SyncResult<FileBundle> {
100        let mut files = vec![];
101        let include_dirs = ["agents", "skills", "content", "web", "config", "profiles"];
102
103        for dir in include_dirs {
104            let dir_path = services_path.join(dir);
105            if dir_path.exists() {
106                Self::collect_dir(&dir_path, services_path, &mut files)?;
107            }
108        }
109
110        let mut hasher = Sha256::new();
111        for file_entry in &files {
112            hasher.update(&file_entry.checksum);
113        }
114        let checksum = format!("{:x}", hasher.finalize());
115
116        Ok(FileBundle {
117            manifest: FileManifest {
118                files,
119                timestamp: chrono::Utc::now(),
120                checksum,
121            },
122            data: vec![],
123        })
124    }
125
126    fn collect_dir(dir: &Path, base: &Path, files: &mut Vec<FileEntry>) -> SyncResult<()> {
127        for entry in fs::read_dir(dir)? {
128            let entry = entry?;
129            let path = entry.path();
130
131            if path.is_dir() {
132                Self::collect_dir(&path, base, files)?;
133            } else if path.is_file() {
134                let relative = path.strip_prefix(base)?;
135                let content = fs::read(&path)?;
136                let checksum = format!("{:x}", Sha256::digest(&content));
137
138                files.push(FileEntry {
139                    path: relative.to_string_lossy().to_string(),
140                    checksum,
141                    size: content.len() as u64,
142                });
143            }
144        }
145        Ok(())
146    }
147
148    fn create_tarball(base: &Path, manifest: &FileManifest) -> SyncResult<Vec<u8>> {
149        let mut encoder = GzEncoder::new(Vec::new(), Compression::default());
150        {
151            let mut tar = Builder::new(&mut encoder);
152            for file in &manifest.files {
153                let full_path = base.join(&file.path);
154                tar.append_path_with_name(&full_path, &file.path)?;
155            }
156            tar.finish()?;
157        }
158        Ok(encoder.finish()?)
159    }
160
161    fn extract_tarball(data: &[u8], target: &Path) -> SyncResult<usize> {
162        let decoder = GzDecoder::new(data);
163        let mut archive = Archive::new(decoder);
164        let mut count = 0;
165
166        for entry in archive.entries()? {
167            let mut entry = entry?;
168            let path = target.join(entry.path()?);
169            if let Some(parent) = path.parent() {
170                fs::create_dir_all(parent)?;
171            }
172            entry.unpack(&path)?;
173            count += 1;
174        }
175
176        Ok(count)
177    }
178
179    fn peek_manifest(data: &[u8]) -> SyncResult<FileManifest> {
180        let decoder = GzDecoder::new(data);
181        let mut archive = Archive::new(decoder);
182        let mut files = vec![];
183
184        for entry in archive.entries()? {
185            let entry = entry?;
186            files.push(FileEntry {
187                path: entry.path()?.to_string_lossy().to_string(),
188                checksum: String::new(),
189                size: entry.size(),
190            });
191        }
192
193        Ok(FileManifest {
194            files,
195            timestamp: chrono::Utc::now(),
196            checksum: String::new(),
197        })
198    }
199}