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        self.api_client
69            .upload_files(&self.config.tenant_id, data)
70            .await?;
71
72        Ok(SyncOperationResult::success("files_push", file_count))
73    }
74
75    async fn pull(&self) -> SyncResult<SyncOperationResult> {
76        let services_path = PathBuf::from(&self.config.services_path);
77        let data = self
78            .api_client
79            .download_files(&self.config.tenant_id)
80            .await?;
81
82        if self.config.dry_run {
83            let manifest = Self::peek_manifest(&data)?;
84            return Ok(SyncOperationResult::dry_run(
85                "files_pull",
86                manifest.files.len(),
87                serde_json::to_value(&manifest)?,
88            ));
89        }
90
91        let count = Self::extract_tarball(&data, &services_path)?;
92        Ok(SyncOperationResult::success("files_pull", count))
93    }
94
95    fn collect_files(services_path: &Path) -> SyncResult<FileBundle> {
96        let mut files = vec![];
97        let include_dirs = ["agents", "skills", "content", "web", "config", "profiles"];
98
99        for dir in include_dirs {
100            let dir_path = services_path.join(dir);
101            if dir_path.exists() {
102                Self::collect_dir(&dir_path, services_path, &mut files)?;
103            }
104        }
105
106        let mut hasher = Sha256::new();
107        for file_entry in &files {
108            hasher.update(&file_entry.checksum);
109        }
110        let checksum = format!("{:x}", hasher.finalize());
111
112        Ok(FileBundle {
113            manifest: FileManifest {
114                files,
115                timestamp: chrono::Utc::now(),
116                checksum,
117            },
118            data: vec![],
119        })
120    }
121
122    fn collect_dir(dir: &Path, base: &Path, files: &mut Vec<FileEntry>) -> SyncResult<()> {
123        for entry in fs::read_dir(dir)? {
124            let entry = entry?;
125            let path = entry.path();
126
127            if path.is_dir() {
128                Self::collect_dir(&path, base, files)?;
129            } else if path.is_file() {
130                let relative = path.strip_prefix(base)?;
131                let content = fs::read(&path)?;
132                let checksum = format!("{:x}", Sha256::digest(&content));
133
134                files.push(FileEntry {
135                    path: relative.to_string_lossy().to_string(),
136                    checksum,
137                    size: content.len() as u64,
138                });
139            }
140        }
141        Ok(())
142    }
143
144    fn create_tarball(base: &Path, manifest: &FileManifest) -> SyncResult<Vec<u8>> {
145        let mut encoder = GzEncoder::new(Vec::new(), Compression::default());
146        {
147            let mut tar = Builder::new(&mut encoder);
148            for file in &manifest.files {
149                let full_path = base.join(&file.path);
150                tar.append_path_with_name(&full_path, &file.path)?;
151            }
152            tar.finish()?;
153        }
154        Ok(encoder.finish()?)
155    }
156
157    fn extract_tarball(data: &[u8], target: &Path) -> SyncResult<usize> {
158        let decoder = GzDecoder::new(data);
159        let mut archive = Archive::new(decoder);
160        let mut count = 0;
161
162        for entry in archive.entries()? {
163            let mut entry = entry?;
164            let path = target.join(entry.path()?);
165            if let Some(parent) = path.parent() {
166                fs::create_dir_all(parent)?;
167            }
168            entry.unpack(&path)?;
169            count += 1;
170        }
171
172        Ok(count)
173    }
174
175    fn peek_manifest(data: &[u8]) -> SyncResult<FileManifest> {
176        let decoder = GzDecoder::new(data);
177        let mut archive = Archive::new(decoder);
178        let mut files = vec![];
179
180        for entry in archive.entries()? {
181            let entry = entry?;
182            files.push(FileEntry {
183                path: entry.path()?.to_string_lossy().to_string(),
184                checksum: String::new(),
185                size: entry.size(),
186            });
187        }
188
189        Ok(FileManifest {
190            files,
191            timestamp: chrono::Utc::now(),
192            checksum: String::new(),
193        })
194    }
195}