upstream_rs/application/operations/
export_operation.rs1use crate::{
2 models::upstream::PackageReference,
3 services::packaging::{OperationPhase, OperationProgressEvent},
4 services::storage::package_storage::PackageStorage,
5 utils::static_paths::UpstreamPaths,
6};
7use anyhow::{Context, Result, anyhow};
8use flate2::Compression;
9use flate2::write::GzEncoder;
10use serde::Serialize;
11use std::path::PathBuf;
12use std::{fs, path::Path};
13use tar::Builder;
14use walkdir::WalkDir;
15
16#[derive(Serialize)]
18pub struct ExportManifest {
19 pub version: u32,
20 pub exported_at: String,
21 pub packages: Vec<PackageReference>,
22}
23
24pub struct ExportOperation<'a> {
25 package_storage: &'a PackageStorage,
26 paths: &'a UpstreamPaths,
27}
28
29impl<'a> ExportOperation<'a> {
30 pub fn new(package_storage: &'a PackageStorage, paths: &'a UpstreamPaths) -> Self {
31 Self {
32 package_storage,
33 paths,
34 }
35 }
36
37 pub fn export_manifest<P>(&self, output: &Path, progress_callback: &mut Option<P>) -> Result<()>
39 where
40 P: FnMut(OperationProgressEvent),
41 {
42 let packages = self.package_storage.get_all_packages();
43
44 let references: Vec<PackageReference> = packages
45 .iter()
46 .filter(|p| p.install_path.is_some())
47 .map(|p| PackageReference::from_package(p.clone()))
48 .collect();
49
50 if references.is_empty() {
51 return Err(anyhow!("No installed packages to export"));
52 }
53
54 if let Some(cb) = progress_callback.as_mut() {
55 cb(OperationProgressEvent::Phase(
56 OperationPhase::SerializingManifest,
57 ));
58 }
59
60 let manifest = ExportManifest {
61 version: 1,
62 exported_at: chrono::Utc::now().to_rfc3339(),
63 packages: references,
64 };
65
66 let json =
67 serde_json::to_string_pretty(&manifest).context("Failed to serialise manifest")?;
68
69 if let Some(cb) = progress_callback.as_mut() {
70 cb(OperationProgressEvent::Phase(
71 OperationPhase::WritingManifest,
72 ));
73 }
74
75 fs::write(output, json).context(format!(
76 "Failed to write manifest to '{}'",
77 output.display()
78 ))
79 }
80
81 pub fn export_snapshot<P>(&self, output: &Path, progress_callback: &mut Option<P>) -> Result<()>
83 where
84 P: FnMut(OperationProgressEvent),
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) = progress_callback.as_mut() {
96 cb(OperationProgressEvent::Phase(OperationPhase::ScanningFiles));
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.as_mut() {
109 cb(OperationProgressEvent::Count {
110 done: 0,
111 total: total_files,
112 });
113 }
114
115 let file = fs::File::create(output).context(format!(
116 "Failed to create archive at '{}'",
117 output.display()
118 ))?;
119
120 let gz = GzEncoder::new(file, Compression::default());
121 let mut tar = Builder::new(gz);
122
123 let mut processed = 0u64;
124
125 for entry in entries {
126 let path = entry.path();
127
128 let rel_path = path
129 .strip_prefix(upstream_dir)
130 .context("Failed to compute relative path")?;
131
132 let mut archive_path = PathBuf::from("upstream");
133 if !rel_path.as_os_str().is_empty() {
134 archive_path.push(rel_path);
135 }
136
137 if entry.file_type().is_dir() {
138 tar.append_dir(&archive_path, path)
139 .context(format!("Failed to append directory '{}'", path.display()))?;
140 } else if entry.file_type().is_file() {
141 if let Some(cb) = progress_callback.as_mut() {
142 cb(OperationProgressEvent::Detail(format!(
143 "Archiving {}",
144 rel_path.display()
145 )));
146 }
147
148 let mut file = fs::File::open(path)
149 .context(format!("Failed to open file '{}'", path.display()))?;
150
151 tar.append_file(&archive_path, &mut file)
152 .context(format!("Failed to append file '{}'", path.display()))?;
153
154 processed += 1;
155
156 if let Some(cb) = progress_callback.as_mut() {
157 cb(OperationProgressEvent::Count {
158 done: processed,
159 total: total_files,
160 });
161 }
162 }
163 }
164
165 if let Some(cb) = progress_callback.as_mut() {
166 cb(OperationProgressEvent::Phase(
167 OperationPhase::FinalizingArchive,
168 ));
169 }
170
171 tar.finish().context("Failed to finalise snapshot archive")
172 }
173}
174
175#[cfg(test)]
176mod tests {
177 use super::ExportOperation;
178 use crate::models::common::enums::{Channel, Filetype, Provider};
179 use crate::models::upstream::Package;
180 use crate::services::storage::package_storage::PackageStorage;
181 use crate::utils::test_support;
182 use std::path::Path;
183 use std::{fs, io};
184
185 fn temp_root(name: &str) -> std::path::PathBuf {
186 test_support::temp_root("upstream-export-test", name)
187 }
188
189 fn test_paths(root: &Path) -> crate::utils::static_paths::UpstreamPaths {
190 test_support::upstream_paths(root)
191 }
192
193 fn cleanup(path: &Path) -> io::Result<()> {
194 fs::remove_dir_all(path)
195 }
196
197 #[test]
198 fn export_manifest_fails_when_no_installed_packages_exist() {
199 let root = temp_root("empty");
200 let paths = test_paths(&root);
201 fs::create_dir_all(paths.config.packages_file.parent().expect("parent"))
202 .expect("create metadata dir");
203 let storage = PackageStorage::new(&paths.config.packages_file).expect("storage");
204 let operation = ExportOperation::new(&storage, &paths);
205 let output = root.join("manifest.json");
206 let mut progress: Option<fn(crate::services::packaging::OperationProgressEvent)> = None;
207
208 let err = operation
209 .export_manifest(&output, &mut progress)
210 .expect_err("no installed packages");
211 assert!(err.to_string().contains("No installed packages"));
212
213 cleanup(&root).expect("cleanup");
214 }
215
216 #[test]
217 fn export_manifest_writes_installed_package_references() {
218 let root = temp_root("manifest");
219 let paths = test_paths(&root);
220 fs::create_dir_all(paths.config.packages_file.parent().expect("parent"))
221 .expect("create metadata dir");
222 let mut storage = PackageStorage::new(&paths.config.packages_file).expect("storage");
223 let mut pkg = Package::with_defaults(
224 "tool".to_string(),
225 "owner/tool".to_string(),
226 Filetype::Binary,
227 None,
228 None,
229 Channel::Stable,
230 Provider::Github,
231 None,
232 );
233 pkg.install_path = Some(paths.install.binaries_dir.join("tool"));
234 pkg.build_branch = Some("main".to_string());
235 pkg.build_commit = Some("abc123".to_string());
236 storage
237 .add_or_update_package(pkg)
238 .expect("store installed package");
239
240 let operation = ExportOperation::new(&storage, &paths);
241 let output = root.join("manifest.json");
242 let mut progress: Option<fn(crate::services::packaging::OperationProgressEvent)> = None;
243 operation
244 .export_manifest(&output, &mut progress)
245 .expect("export manifest");
246
247 let content = fs::read_to_string(&output).expect("read manifest");
248 assert!(content.contains("\"version\": 1"));
249 assert!(content.contains("\"name\": \"tool\""));
250 assert!(content.contains("\"repo_slug\": \"owner/tool\""));
251 assert!(content.contains("\"build_branch\": \"main\""));
252 assert!(content.contains("\"build_commit\": \"abc123\""));
253
254 cleanup(&root).expect("cleanup");
255 }
256}