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 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}