Skip to main content

weaveffi_core/
package.rs

1//! The packaging layer: the data a backend needs to assemble a distributable
2//! package, and the driver that materializes one to disk.
3//!
4//! `weaveffi generate` emits binding *source* that a consumer must compile or
5//! point at a native library themselves. `weaveffi package` produces the next
6//! artifact up: a ready-to-publish package for an ecosystem (an npm tarball
7//! tree, a NuGet-ready project, a Python wheel tree, …) with a prebuilt native
8//! library bundled for each [`Platform`](crate::platform::Platform) so
9//! `npm install` / `pip install` / `dotnet add package` "just works" with no
10//! local toolchain.
11//!
12//! A backend opts in by overriding
13//! [`LanguageBackend::package`](crate::backend::LanguageBackend::package),
14//! returning the full set of [`PackagedFile`]s that make up the package. The
15//! [`write_package`] driver then writes the rendered text and copies the
16//! bundled binaries into place. Rendering stays pure (it returns values, it
17//! does no I/O), so package layouts are snapshot-testable exactly like
18//! generated source.
19
20use anyhow::{Context, Result};
21use camino::{Utf8Path, Utf8PathBuf};
22
23use crate::platform::BinarySet;
24
25/// The contents of one [`PackagedFile`]: either rendered text or a native
26/// binary to copy in from elsewhere on disk.
27#[derive(Debug, Clone, PartialEq, Eq)]
28pub enum FileContent {
29    /// Rendered text (a manifest, loader, README, or binding source file)
30    /// written verbatim.
31    Text(String),
32    /// A native library copied byte-for-byte from this source path. Used for
33    /// the prebuilt shared libraries a package bundles; keeping them out of
34    /// [`Text`](Self::Text) means package rendering never has to hold a
35    /// multi-megabyte binary in memory as a `String`.
36    Copy(Utf8PathBuf),
37}
38
39/// One file in a packaged artifact: where to write it and what it contains.
40///
41/// `path` is the full destination path (anchored under the package output
42/// directory), mirroring [`OutputFile`](crate::backend::OutputFile) so backends
43/// build paths with the same `out_dir.join(...)` idiom they already use in
44/// [`files`](crate::backend::LanguageBackend::files).
45#[derive(Debug, Clone, PartialEq, Eq)]
46pub struct PackagedFile {
47    /// Full destination path, anchored under the package output directory, with
48    /// `/` separators on every host (see [`text`](Self::text)).
49    pub path: Utf8PathBuf,
50    /// What to materialize at `path`.
51    pub content: FileContent,
52}
53
54impl PackagedFile {
55    /// A file whose contents are rendered text.
56    ///
57    /// `path` separators are normalized to `/` on every host, so package
58    /// layouts stay identical across platforms.
59    pub fn text(path: impl Into<Utf8PathBuf>, contents: impl Into<String>) -> Self {
60        Self {
61            path: normalize_separators(path.into()),
62            content: FileContent::Text(contents.into()),
63        }
64    }
65
66    /// A file copied from a prebuilt native library at `source`.
67    ///
68    /// The destination `path` is normalized to `/` separators; `source` is left
69    /// as-is, since it is a host path read directly off disk.
70    pub fn copy(path: impl Into<Utf8PathBuf>, source: impl Into<Utf8PathBuf>) -> Self {
71        Self {
72            path: normalize_separators(path.into()),
73            content: FileContent::Copy(source.into()),
74        }
75    }
76
77    /// True when this entry copies in a native binary rather than writing text.
78    pub fn is_binary(&self) -> bool {
79        matches!(self.content, FileContent::Copy(_))
80    }
81}
82
83/// Normalize a package destination path to use `/` separators on every host.
84///
85/// Backends build paths with `out_dir.join(...)`, which uses the host separator
86/// (`\` on Windows). A package layout is logically `/`-separated, though: it is
87/// published and consumed identically on every OS, and `std::fs` accepts `/` on
88/// Windows so [`write_package`] still writes correctly. Doing this once here
89/// keeps [`PackagedFile::path`] stable across platforms (so package layouts are
90/// snapshot-testable like generated source) and mirrors how
91/// [`output_files`](crate::backend::output_files) presents generated paths.
92fn normalize_separators(path: Utf8PathBuf) -> Utf8PathBuf {
93    if cfg!(windows) {
94        Utf8PathBuf::from(path.into_string().replace('\\', "/"))
95    } else {
96        path
97    }
98}
99
100/// Everything a backend's [`package`](crate::backend::LanguageBackend::package)
101/// hook needs beyond the [`Api`](weaveffi_ir::ir::Api) and
102/// [`BindingModel`](crate::model::BindingModel) it already receives.
103///
104/// The prebuilt libraries to bundle live in [`binaries`](Self::binaries);
105/// `input_basename` is the IDL file stem, used (as in `generate`) as the
106/// fallback package name when the IDL omits a `package:` block.
107#[derive(Debug, Clone, Copy)]
108pub struct PackageContext<'a> {
109    /// The prebuilt native libraries to bundle, one per platform, plus the
110    /// logical library base name every loader and bundled filename derives
111    /// from.
112    pub binaries: &'a BinarySet,
113    /// The IDL file stem, used as the fallback package name. `None` when the
114    /// package identity comes entirely from the `package:` block or a config
115    /// override.
116    pub input_basename: Option<&'a str>,
117}
118
119/// Write a rendered package to disk: create parent directories, write every
120/// [`FileContent::Text`] verbatim, and copy every [`FileContent::Copy`] native
121/// binary into place.
122///
123/// # Errors
124///
125/// Returns an error if a parent directory cannot be created, a text file cannot
126/// be written, or a bundled binary's source path cannot be read or copied.
127pub fn write_package(files: &[PackagedFile]) -> Result<()> {
128    for file in files {
129        if let Some(parent) = file.path.parent() {
130            std::fs::create_dir_all(parent.as_std_path())
131                .with_context(|| format!("failed to create directory {parent}"))?;
132        }
133        match &file.content {
134            FileContent::Text(contents) => {
135                std::fs::write(file.path.as_std_path(), contents)
136                    .with_context(|| format!("failed to write {}", file.path))?;
137            }
138            FileContent::Copy(source) => copy_binary(source, &file.path)?,
139        }
140    }
141    Ok(())
142}
143
144fn copy_binary(source: &Utf8Path, dest: &Utf8Path) -> Result<()> {
145    std::fs::copy(source.as_std_path(), dest.as_std_path())
146        .with_context(|| format!("failed to copy native library {source} -> {dest}"))?;
147    Ok(())
148}
149
150/// Count the text files and bundled binaries in a rendered package, for the
151/// CLI's end-of-run summary.
152///
153/// Returns `(text_files, bundled_binaries)`.
154pub fn summarize(files: &[PackagedFile]) -> (usize, usize) {
155    let binaries = files.iter().filter(|f| f.is_binary()).count();
156    (files.len() - binaries, binaries)
157}
158
159#[cfg(test)]
160mod tests {
161    use super::*;
162
163    #[test]
164    fn write_package_writes_text_and_copies_binaries() {
165        let dir = tempfile::tempdir().unwrap();
166        let root = Utf8Path::from_path(dir.path()).unwrap();
167
168        // A source binary to copy.
169        let src = root.join("src-lib.bin");
170        std::fs::write(src.as_std_path(), b"\x00native\x01").unwrap();
171
172        let files = vec![
173            PackagedFile::text(root.join("pkg/manifest.json"), "{\"name\":\"x\"}"),
174            PackagedFile::copy(root.join("pkg/native/lib.bin"), src.clone()),
175        ];
176        write_package(&files).unwrap();
177
178        assert_eq!(
179            std::fs::read_to_string(root.join("pkg/manifest.json")).unwrap(),
180            "{\"name\":\"x\"}"
181        );
182        assert_eq!(
183            std::fs::read(root.join("pkg/native/lib.bin")).unwrap(),
184            b"\x00native\x01"
185        );
186        assert_eq!(summarize(&files), (1, 1));
187    }
188
189    #[test]
190    fn destination_paths_are_forward_slashed() {
191        // Built with `join` (host separator on Windows) but stored
192        // `/`-normalized, so a package layout matches on every OS. On Windows
193        // this exercises the `\` -> `/` rewrite; elsewhere it confirms the
194        // happy path is left intact.
195        let text = PackagedFile::text(Utf8Path::new("out").join("dotnet").join("x.cs"), "x");
196        assert_eq!(text.path.as_str(), "out/dotnet/x.cs");
197
198        let copied = PackagedFile::copy(
199            Utf8Path::new("out")
200                .join("runtimes")
201                .join("osx-arm64")
202                .join("native"),
203            "/src/libcalculator.dylib",
204        );
205        assert_eq!(copied.path.as_str(), "out/runtimes/osx-arm64/native");
206    }
207
208    #[test]
209    fn missing_binary_source_is_an_error() {
210        let dir = tempfile::tempdir().unwrap();
211        let root = Utf8Path::from_path(dir.path()).unwrap();
212        let files = vec![PackagedFile::copy(
213            root.join("pkg/native/lib.bin"),
214            root.join("does-not-exist.bin"),
215        )];
216        let err = write_package(&files).unwrap_err();
217        assert!(err.to_string().contains("failed to copy native library"));
218    }
219}