waterui_cli/platforming/
package_output.rs1use std::{fs, io::ErrorKind, path::Path};
4
5use eyre::{Result, WrapErr};
6
7use crate::{device::Artifact, project::Project};
8
9pub async fn place_in_project(project: &Project, artifact: Artifact) -> Result<Artifact> {
16 let dest_dir = project.root().join("target").join("package");
17 place_in_dir(&dest_dir, artifact).await
18}
19
20pub(crate) async fn place_in_dir(dest_dir: &Path, artifact: Artifact) -> Result<Artifact> {
21 let bundle_id = artifact.bundle_id().to_owned();
22 let source = artifact.path().to_owned();
23 let file_name = source
24 .file_name()
25 .ok_or_else(|| {
26 eyre::eyre!(
27 "packaged artifact path has no file name: {}",
28 source.display()
29 )
30 })?
31 .to_owned();
32 let source_for_log = source.clone();
33 let dest_dir = dest_dir.to_owned();
34 let (destination, strategy) = smol::unblock(move || -> Result<(_, &'static str)> {
35 fs::create_dir_all(&dest_dir).wrap_err_with(|| {
36 format!("failed to create package directory {}", dest_dir.display())
37 })?;
38 let destination = dest_dir.join(file_name);
39 if source == destination {
40 return Ok((destination, "rename"));
41 }
42 if destination.exists() {
43 remove_existing(&destination).wrap_err_with(|| {
44 format!(
45 "failed to replace previous package output {}",
46 destination.display()
47 )
48 })?;
49 }
50 match fs::rename(&source, &destination) {
51 Ok(()) => Ok((destination, "rename")),
52 Err(error) if error.kind() == ErrorKind::CrossesDevices => {
53 copy_recursively(&source, &destination).wrap_err_with(|| {
54 format!(
55 "failed to copy packaged artifact from {} to {}",
56 source.display(),
57 destination.display()
58 )
59 })?;
60 remove_existing(&source).wrap_err_with(|| {
61 format!("failed to remove packaged artifact {}", source.display())
62 })?;
63 Ok((destination, "copy"))
64 }
65 Err(error) => Err(error).wrap_err_with(|| {
66 format!(
67 "failed to move packaged artifact from {} to {}",
68 source.display(),
69 destination.display()
70 )
71 }),
72 }
73 })
74 .await?;
75 tracing::info!(
76 from = %source_for_log.display(),
77 to = %destination.display(),
78 strategy,
79 "placed packaged artifact"
80 );
81 Ok(Artifact::new(bundle_id, destination))
82}
83
84fn remove_existing(path: &Path) -> std::io::Result<()> {
85 if path.is_dir() {
86 fs::remove_dir_all(path)
87 } else {
88 fs::remove_file(path)
89 }
90}
91
92fn copy_recursively(source: &Path, destination: &Path) -> std::io::Result<()> {
93 let metadata = fs::symlink_metadata(source)?;
94 if metadata.file_type().is_symlink() {
95 return copy_symlink(source, destination);
101 }
102 if metadata.is_dir() {
103 fs::create_dir_all(destination)?;
104 for entry in fs::read_dir(source)? {
105 let entry = entry?;
106 copy_recursively(&entry.path(), &destination.join(entry.file_name()))?;
107 }
108 } else {
109 fs::copy(source, destination)?;
110 }
111 Ok(())
112}
113
114#[cfg(unix)]
116fn copy_symlink(source: &Path, destination: &Path) -> std::io::Result<()> {
117 std::os::unix::fs::symlink(fs::read_link(source)?, destination)
118}
119
120#[cfg(not(unix))]
123fn copy_symlink(source: &Path, _destination: &Path) -> std::io::Result<()> {
124 Err(std::io::Error::new(
125 ErrorKind::InvalidInput,
126 format!("cannot copy symlink packaged artifact {}", source.display()),
127 ))
128}
129
130#[cfg(test)]
131mod tests {
132 use super::*;
133 use tempfile::tempdir;
134
135 #[cfg(unix)]
138 #[test]
139 fn copies_a_bundle_symlink_as_a_symlink() {
140 let temp = tempdir().expect("tempdir");
141 let versions = temp.path().join("App.app").join("Versions");
142 fs::create_dir_all(versions.join("A")).expect("bundle");
143 fs::write(versions.join("A").join("App"), b"binary").expect("bundle binary");
144 std::os::unix::fs::symlink("A", versions.join("Current")).expect("framework symlink");
145
146 let copied = temp.path().join("copied.app");
147 copy_recursively(&temp.path().join("App.app"), &copied).expect("bundle must copy");
148
149 let link = copied.join("Versions").join("Current");
150 assert!(
151 fs::symlink_metadata(&link)
152 .expect("copied link")
153 .file_type()
154 .is_symlink(),
155 "the copy must stay a symlink, not a second copy of the version directory"
156 );
157 assert_eq!(fs::read_link(&link).expect("link target"), Path::new("A"));
158 }
159
160 #[test]
161 fn moves_file_and_replaces_stale_file() {
162 smol::block_on(async {
163 let temp = tempdir().expect("tempdir");
164 let source = temp.path().join("cache").join("app");
165 let destination_dir = temp.path().join("project").join("target").join("package");
166 fs::create_dir_all(source.parent().expect("source parent")).expect("cache directory");
167 fs::create_dir_all(&destination_dir).expect("destination directory");
168 fs::write(&source, b"new").expect("source file");
169 fs::write(destination_dir.join("app"), b"stale").expect("stale output");
170
171 let artifact = Artifact::new("dev.example.app", source.clone());
172 let placed = place_in_dir(&destination_dir, artifact)
173 .await
174 .expect("artifact must move");
175
176 assert!(!source.exists());
177 assert_eq!(placed.path(), destination_dir.join("app"));
178 assert_eq!(fs::read(placed.path()).expect("destination file"), b"new");
179 });
180 }
181
182 #[test]
183 fn moves_directory_tree_and_replaces_stale_directory() {
184 smol::block_on(async {
185 let temp = tempdir().expect("tempdir");
186 let source = temp.path().join("cache").join("app");
187 let destination_dir = temp.path().join("project").join("target").join("package");
188 fs::create_dir_all(source.join("nested")).expect("source directory");
189 fs::create_dir_all(destination_dir.join("app").join("old")).expect("stale destination");
190 fs::write(source.join("nested").join("data"), b"new").expect("source content");
191 fs::write(
192 destination_dir.join("app").join("old").join("data"),
193 b"stale",
194 )
195 .expect("stale content");
196
197 let artifact = Artifact::new("dev.example.app", source.clone());
198 let placed = place_in_dir(&destination_dir, artifact)
199 .await
200 .expect("artifact must move");
201
202 assert!(!source.exists());
203 assert_eq!(
204 fs::read(placed.path().join("nested").join("data")).expect("destination content"),
205 b"new"
206 );
207 assert!(!placed.path().join("old").exists());
208 });
209 }
210}