Skip to main content

upstream_rs/application/operations/
export_operation.rs

1use 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/// The manifest written by a light export.
17#[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    /// Light export: write a JSON manifest of PackageReferences.
38    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    /// Full export: tarball the entire .upstream directory.
82    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        // 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.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}