Skip to main content

upstream_rs/application/operations/
export_operation.rs

1use 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/// The manifest written by a light export.
15#[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    /// Light export: write a JSON manifest of PackageReferences.
36    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    /// Full export: tarball the entire .upstream directory.
76    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        // Collect entries first to get deterministic total
100        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}