Skip to main content

waterui_cli/platforming/
package_output.rs

1//! Moving packaged artifacts into a project's output directory.
2
3use std::{fs, io::ErrorKind, path::Path};
4
5use eyre::{Result, WrapErr};
6
7use crate::{device::Artifact, project::Project};
8
9/// Move a packaged artifact out of the build cache into the project's
10/// `target/package/` directory and return the artifact at its new path.
11///
12/// # Errors
13/// Returns an error if the output directory cannot be created, the artifact
14/// cannot be moved or copied, or its path has no file name.
15pub 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        // A macOS `.app` carries symlinks by construction — every embedded
96        // framework has `Versions/Current` and the shims beside it — so a
97        // bundle that crosses a device boundary must keep them as symlinks.
98        // Following them would inflate the bundle and break code signing;
99        // refusing them would fail every framework-bearing bundle.
100        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/// Recreates `source`'s symlink at `destination`, target text unchanged.
115#[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/// Windows needs a privilege to create symlinks and its bundles do not rely
121/// on them, so a symlink there is reported rather than silently dereferenced.
122#[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    /// A `.app` that crosses a device boundary keeps the framework symlinks
136    /// every macOS bundle is built from.
137    #[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}