upstream_rs/application/operations/
export_operation.rs1use crate::{
2 models::upstream::PackageReference, services::storage::package_storage::PackageStorage,
3 utils::static_paths::UpstreamPaths,
4};
5use anyhow::{Context, Result, anyhow};
6use flate2::Compression;
7use flate2::write::GzEncoder;
8use serde::Serialize;
9use std::path::PathBuf;
10use std::{fs, path::Path};
11use tar::Builder;
12use walkdir::WalkDir;
13
14#[derive(Serialize)]
16pub struct ExportManifest {
17 pub version: u32,
18 pub exported_at: String,
19 pub packages: Vec<PackageReference>,
20}
21
22pub struct ExportOperation<'a> {
23 package_storage: &'a PackageStorage,
24 paths: &'a UpstreamPaths,
25}
26
27impl<'a> ExportOperation<'a> {
28 pub fn new(package_storage: &'a PackageStorage, paths: &'a UpstreamPaths) -> Self {
29 Self {
30 package_storage,
31 paths,
32 }
33 }
34
35 pub fn export_manifest<H>(&self, output: &Path, message_callback: &mut Option<H>) -> Result<()>
37 where
38 H: FnMut(&str),
39 {
40 let packages = self.package_storage.get_all_packages();
41
42 let references: Vec<PackageReference> = packages
43 .iter()
44 .filter(|p| p.install_path.is_some())
45 .map(|p| PackageReference::from_package(p.clone()))
46 .collect();
47
48 if references.is_empty() {
49 return Err(anyhow!("No installed packages to export"));
50 }
51
52 if let Some(cb) = message_callback {
53 cb("Serialising manifest...");
54 }
55
56 let manifest = ExportManifest {
57 version: 1,
58 exported_at: chrono::Utc::now().to_rfc3339(),
59 packages: references,
60 };
61
62 let json =
63 serde_json::to_string_pretty(&manifest).context("Failed to serialise manifest")?;
64
65 if let Some(cb) = message_callback {
66 cb("Writing manifest file...");
67 }
68
69 fs::write(output, json).context(format!(
70 "Failed to write manifest to '{}'",
71 output.display()
72 ))
73 }
74
75 pub fn export_snapshot<F, H>(
77 &self,
78 output: &Path,
79 progress_callback: &mut Option<F>,
80 message_callback: &mut Option<H>,
81 ) -> Result<()>
82 where
83 F: FnMut(u64, u64),
84 H: FnMut(&str),
85 {
86 let upstream_dir = &self.paths.dirs.data_dir;
87
88 if !upstream_dir.exists() {
89 return Err(anyhow!(
90 "Upstream directory '{}' does not exist — nothing to snapshot",
91 upstream_dir.display()
92 ));
93 }
94
95 if let Some(cb) = message_callback {
96 cb("Scanning files...");
97 }
98
99 let entries: Vec<_> = WalkDir::new(upstream_dir)
101 .follow_links(false)
102 .into_iter()
103 .collect::<Result<_, _>>()
104 .context("Failed while walking upstream directory")?;
105
106 let total_files = entries.iter().filter(|e| e.file_type().is_file()).count() as u64;
107
108 if let Some(cb) = progress_callback {
109 cb(0, total_files);
110 }
111
112 let file = fs::File::create(output).context(format!(
113 "Failed to create archive at '{}'",
114 output.display()
115 ))?;
116
117 let gz = GzEncoder::new(file, Compression::default());
118 let mut tar = Builder::new(gz);
119
120 let mut processed = 0u64;
121
122 for entry in entries {
123 let path = entry.path();
124
125 let rel_path = path
126 .strip_prefix(upstream_dir)
127 .context("Failed to compute relative path")?;
128
129 let mut archive_path = PathBuf::from("upstream");
130 if !rel_path.as_os_str().is_empty() {
131 archive_path.push(rel_path);
132 }
133
134 if entry.file_type().is_dir() {
135 tar.append_dir(&archive_path, path)
136 .context(format!("Failed to append directory '{}'", path.display()))?;
137 } else if entry.file_type().is_file() {
138 if let Some(cb) = message_callback {
139 cb(&format!("Archiving {}", rel_path.display()));
140 }
141
142 let mut file = fs::File::open(path)
143 .context(format!("Failed to open file '{}'", path.display()))?;
144
145 tar.append_file(&archive_path, &mut file)
146 .context(format!("Failed to append file '{}'", path.display()))?;
147
148 processed += 1;
149
150 if let Some(cb) = progress_callback {
151 cb(processed, total_files);
152 }
153 }
154 }
155
156 if let Some(cb) = message_callback {
157 cb("Finalising archive...");
158 }
159
160 tar.finish().context("Failed to finalise snapshot archive")
161 }
162}
163
164#[cfg(test)]
165mod tests {
166 use super::ExportOperation;
167 use crate::models::common::enums::{Channel, Filetype, Provider};
168 use crate::models::upstream::Package;
169 use crate::services::storage::package_storage::PackageStorage;
170 use crate::utils::static_paths::{
171 AppDirs, ConfigPaths, InstallPaths, IntegrationPaths, UpstreamPaths,
172 };
173 use std::path::{Path, PathBuf};
174 use std::time::{SystemTime, UNIX_EPOCH};
175 use std::{fs, io};
176
177 fn temp_root(name: &str) -> PathBuf {
178 let nanos = SystemTime::now()
179 .duration_since(UNIX_EPOCH)
180 .map(|d| d.as_nanos())
181 .unwrap_or(0);
182 std::env::temp_dir().join(format!("upstream-export-test-{name}-{nanos}"))
183 }
184
185 fn test_paths(root: &Path) -> UpstreamPaths {
186 let dirs = AppDirs {
187 user_dir: root.to_path_buf(),
188 config_dir: root.join("config"),
189 data_dir: root.join("data"),
190 metadata_dir: root.join("data/metadata"),
191 };
192
193 UpstreamPaths {
194 config: ConfigPaths {
195 config_file: dirs.config_dir.join("config.toml"),
196 packages_file: dirs.metadata_dir.join("packages.json"),
197 paths_file: dirs.metadata_dir.join("paths.sh"),
198 },
199 install: InstallPaths {
200 appimages_dir: dirs.data_dir.join("appimages"),
201 binaries_dir: dirs.data_dir.join("binaries"),
202 archives_dir: dirs.data_dir.join("archives"),
203 },
204 integration: IntegrationPaths {
205 symlinks_dir: dirs.data_dir.join("symlinks"),
206 xdg_applications_dir: dirs.user_dir.join(".local/share/applications"),
207 icons_dir: dirs.data_dir.join("icons"),
208 },
209 dirs,
210 }
211 }
212
213 fn cleanup(path: &Path) -> io::Result<()> {
214 fs::remove_dir_all(path)
215 }
216
217 #[test]
218 fn export_manifest_fails_when_no_installed_packages_exist() {
219 let root = temp_root("empty");
220 let paths = test_paths(&root);
221 fs::create_dir_all(paths.config.packages_file.parent().expect("parent"))
222 .expect("create metadata dir");
223 let storage = PackageStorage::new(&paths.config.packages_file).expect("storage");
224 let operation = ExportOperation::new(&storage, &paths);
225 let output = root.join("manifest.json");
226 let mut msg: Option<fn(&str)> = None;
227
228 let err = operation
229 .export_manifest(&output, &mut msg)
230 .expect_err("no installed packages");
231 assert!(err.to_string().contains("No installed packages"));
232
233 cleanup(&root).expect("cleanup");
234 }
235
236 #[test]
237 fn export_manifest_writes_installed_package_references() {
238 let root = temp_root("manifest");
239 let paths = test_paths(&root);
240 fs::create_dir_all(paths.config.packages_file.parent().expect("parent"))
241 .expect("create metadata dir");
242 let mut storage = PackageStorage::new(&paths.config.packages_file).expect("storage");
243 let mut pkg = Package::with_defaults(
244 "tool".to_string(),
245 "owner/tool".to_string(),
246 Filetype::Binary,
247 None,
248 None,
249 Channel::Stable,
250 Provider::Github,
251 None,
252 );
253 pkg.install_path = Some(paths.install.binaries_dir.join("tool"));
254 storage
255 .add_or_update_package(pkg)
256 .expect("store installed package");
257
258 let operation = ExportOperation::new(&storage, &paths);
259 let output = root.join("manifest.json");
260 let mut msg: Option<fn(&str)> = None;
261 operation
262 .export_manifest(&output, &mut msg)
263 .expect("export manifest");
264
265 let content = fs::read_to_string(&output).expect("read manifest");
266 assert!(content.contains("\"version\": 1"));
267 assert!(content.contains("\"name\": \"tool\""));
268 assert!(content.contains("\"repo_slug\": \"owner/tool\""));
269
270 cleanup(&root).expect("cleanup");
271 }
272}