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}