systemprompt_sync/
files.rs1use 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}