obelisk_component_builder/
lib.rs

1use cargo_metadata::camino::Utf8Path;
2use std::{
3    path::{Path, PathBuf},
4    process::Command,
5};
6
7const WASI_P2: &str = "wasm32-wasip2";
8const WASM_CORE_MODULE: &str = "wasm32-unknown-unknown";
9
10#[derive(Debug, Clone, Default)]
11pub struct BuildConfig {
12    pub profile: Option<String>,
13    pub custom_dst_target_dir: Option<PathBuf>,
14}
15impl BuildConfig {
16    pub fn profile(profile: impl Into<String>) -> Self {
17        Self {
18            profile: Some(profile.into()),
19            custom_dst_target_dir: None,
20        }
21    }
22    pub fn target_subdir(target_dir: impl Into<PathBuf>) -> Self {
23        Self {
24            profile: None,
25            custom_dst_target_dir: Some(get_target_dir().join(target_dir.into())),
26        }
27    }
28    pub fn new(
29        profile: Option<impl Into<String>>,
30        custom_dst_target_dir: Option<impl Into<PathBuf>>,
31    ) -> Self {
32        Self {
33            profile: profile.map(Into::into),
34            custom_dst_target_dir: custom_dst_target_dir.map(Into::into),
35        }
36    }
37}
38
39/// Build the parent activity WASM component and place it into the `target` directory.
40///
41/// This function must be called from `build.rs`. It reads the current package
42/// name and strips the `-builder` suffix to determine the target package name.
43/// Then, it runs `cargo build` with the appropriate target triple and sets
44/// the `--target` directory to the output of [`get_target_dir`].
45#[allow(clippy::must_use_candidate)]
46pub fn build_activity(conf: BuildConfig) -> PathBuf {
47    build_internal(WASI_P2, ComponentType::ActivityWasm, conf)
48}
49
50/// Build the parent webhook endpoint WASM component and place it into the `target` directory.
51///
52/// This function must be called from `build.rs`. It reads the current package
53/// name and strips the `-builder` suffix to determine the target package name.
54/// Then, it runs `cargo build` with the appropriate target triple and sets
55/// the `--target` directory to the output of [`get_target_dir`].
56#[allow(clippy::must_use_candidate)]
57pub fn build_webhook_endpoint(conf: BuildConfig) -> PathBuf {
58    build_internal(WASI_P2, ComponentType::WebhookEndpoint, conf)
59}
60
61/// Build the parent workflow WASM component and place it into the `target` directory.
62///
63/// This function must be called from `build.rs`. It reads the current package
64/// name and strips the `-builder` suffix to determine the target package name.
65/// Then, it runs `cargo build` with the appropriate target triple and sets
66/// the `--target` directory to the output of [`get_target_dir`].
67#[allow(clippy::must_use_candidate)]
68pub fn build_workflow(conf: BuildConfig) -> PathBuf {
69    build_internal(WASM_CORE_MODULE, ComponentType::Workflow, conf)
70}
71
72enum ComponentType {
73    ActivityWasm,
74    WebhookEndpoint,
75    Workflow,
76}
77
78fn to_snake_case(input: &str) -> String {
79    input.replace(['-', '.'], "_")
80}
81
82fn is_transformation_to_wasm_component_needed(target_tripple: &str) -> bool {
83    target_tripple == WASM_CORE_MODULE
84}
85
86/// Get the path to the target directory using `CARGO_WORKSPACE_DIR` environment variable.
87///
88/// To set the environment variable automatically, modify `.cargo/config.toml`:
89/// ```toml
90/// [env]
91/// # remove once stable https://doc.rust-lang.org/cargo/reference/environment-variables.html#environment-variables-cargo-reads
92/// CARGO_WORKSPACE_DIR = { value = "", relative = true }
93/// ```
94fn get_target_dir() -> PathBuf {
95    // Try to get `CARGO_WORKSPACE_DIR` from the environment
96    if let Ok(workspace_dir) = std::env::var("CARGO_WORKSPACE_DIR") {
97        Path::new(&workspace_dir).join("target")
98    } else {
99        unreachable!("CARGO_WORKSPACE_DIR must be set")
100    }
101}
102/// Get the `OUT_DIR` as a `PathBuf`.
103///
104/// The folder structure typically looks like this: `target/debug/build/<crate_name>-<hash>/out`.
105#[cfg(feature = "genrs")]
106fn get_out_dir() -> PathBuf {
107    PathBuf::from(std::env::var("OUT_DIR").expect("OUT_DIR environment variable not set"))
108}
109
110fn build_internal(
111    target_tripple: &str,
112    component_type: ComponentType,
113    conf: BuildConfig,
114) -> PathBuf {
115    let dst_target_dir = conf.custom_dst_target_dir.unwrap_or_else(get_target_dir);
116    let pkg_name = std::env::var("CARGO_PKG_NAME").unwrap();
117    let pkg_name = pkg_name.strip_suffix("-builder").unwrap();
118    let wasm_path = run_cargo_build(
119        &dst_target_dir,
120        pkg_name,
121        target_tripple,
122        conf.profile.as_deref(),
123    );
124    if std::env::var("RUST_LOG").is_ok() {
125        println!("cargo:warning=Built `{pkg_name}` - {wasm_path:?}");
126    }
127
128    generate_code(&wasm_path, pkg_name, component_type);
129
130    let meta = cargo_metadata::MetadataCommand::new().exec().unwrap();
131    let package = meta
132        .packages
133        .iter()
134        .find(|p| p.name.as_str() == pkg_name)
135        .unwrap_or_else(|| panic!("package `{pkg_name}` must exist"));
136
137    add_dependency(&package.manifest_path); // Cargo.toml
138    for src_path in package
139        .targets
140        .iter()
141        .map(|target| target.src_path.parent().unwrap())
142    {
143        add_dependency(src_path);
144    }
145    let wit_path = &package.manifest_path.parent().unwrap().join("wit");
146    if wit_path.exists() && wit_path.is_dir() {
147        add_dependency(wit_path);
148    }
149    wasm_path
150}
151
152#[cfg(not(feature = "genrs"))]
153fn generate_code(_wasm_path: &Path, _pkg_name: &str, _component_type: ComponentType) {}
154
155#[cfg(feature = "genrs")]
156impl From<ComponentType> for concepts::ComponentType {
157    fn from(value: ComponentType) -> Self {
158        match value {
159            ComponentType::ActivityWasm => Self::ActivityWasm,
160            ComponentType::Workflow => Self::Workflow,
161            ComponentType::WebhookEndpoint => Self::WebhookEndpoint,
162        }
163    }
164}
165
166#[cfg(feature = "genrs")]
167fn generate_code(wasm_path: &Path, pkg_name: &str, component_type: ComponentType) {
168    use concepts::FunctionMetadata;
169    use indexmap::IndexMap;
170    use std::fmt::Write as _;
171
172    enum Value {
173        Map(IndexMap<String, Value>),
174        Leaf(Vec<String>),
175    }
176
177    fn ser_map(map: &IndexMap<String, Value>, output: &mut String) {
178        for (k, v) in map {
179            match v {
180                Value::Leaf(vec) => {
181                    for line in vec {
182                        *output += line;
183                        *output += "\n";
184                    }
185                }
186                Value::Map(map) => {
187                    write!(output, "#[allow(clippy::all)]\npub mod r#{k} {{\n").unwrap();
188                    ser_map(map, output);
189                    *output += "}\n";
190                }
191            }
192        }
193    }
194
195    let mut generated_code = String::new();
196    writeln!(
197        generated_code,
198        "pub const {name_upper}: &str = {wasm_path:?};",
199        name_upper = to_snake_case(pkg_name).to_uppercase()
200    )
201    .unwrap();
202
203    let component = utils::wasm_tools::WasmComponent::new(wasm_path, component_type.into())
204        .expect("cannot decode wasm component");
205    generated_code += "pub mod exports {\n";
206    let mut outer_map: IndexMap<String, Value> = IndexMap::new();
207    for export in component.exim.get_exports_hierarchy_ext() {
208        let ifc_fqn_split = export
209            .ifc_fqn
210            .split_terminator([':', '/', '@'])
211            .map(to_snake_case);
212        let mut map = &mut outer_map;
213        for mut split in ifc_fqn_split {
214            if split.starts_with(|c: char| c.is_numeric()) {
215                split = format!("_{split}");
216            }
217            if let Value::Map(m) = map
218                .entry(split)
219                .or_insert_with(|| Value::Map(IndexMap::new()))
220            {
221                map = m;
222            } else {
223                unreachable!()
224            }
225        }
226        let vec = export
227                .fns
228                .iter()
229                .filter(| (_, FunctionMetadata { submittable,.. }) | *submittable )
230                .map(|(function_name, FunctionMetadata{parameter_types, return_type, ..})| {
231                    format!(
232                        "/// {fn}: func{parameter_types}{arrow_ret_type};\npub const r#{name_upper}: (&str, &str) = (\"{ifc}\", \"{fn}\");\n",
233                        name_upper = to_snake_case(function_name).to_uppercase(),
234                        ifc = export.ifc_fqn,
235                        fn = function_name,
236                        arrow_ret_type = if let Some(ret_type) = return_type { format!(" -> {ret_type}") } else { String::new() }
237                    )
238                })
239                .collect();
240        let old_val = map.insert(String::new(), Value::Leaf(vec));
241        assert!(old_val.is_none(), "same interface cannot appear twice");
242    }
243
244    ser_map(&outer_map, &mut generated_code);
245    generated_code += "}\n";
246    std::fs::write(get_out_dir().join("gen.rs"), generated_code).unwrap();
247}
248
249fn add_dependency(file: &Utf8Path) {
250    println!("cargo:rerun-if-changed={file}");
251}
252
253fn run_cargo_build(
254    dst_target_dir: &Path,
255    name: &str,
256    tripple: &str,
257    profile: Option<&str>,
258) -> PathBuf {
259    let mut cmd = Command::new("cargo");
260    let temp_str;
261    cmd.arg("build")
262        .arg(if let Some(profile) = profile {
263            temp_str = format!("--profile={profile}");
264            &temp_str
265        } else {
266            "--release"
267        })
268        .arg(format!("--target={tripple}"))
269        .arg(format!("--package={name}"))
270        .env("CARGO_TARGET_DIR", dst_target_dir)
271        .env("CARGO_PROFILE_RELEASE_DEBUG", "limited") // debug = 1, retain line numbers
272        .env_remove("CARGO_ENCODED_RUSTFLAGS")
273        .env_remove("CLIPPY_ARGS"); // do not pass clippy parameters
274    let status = cmd.status().unwrap();
275    assert!(status.success());
276    let name_snake_case = to_snake_case(name);
277    let target = dst_target_dir
278        .join(tripple)
279        .join("release")
280        .join(format!("{name_snake_case}.wasm",));
281    assert!(target.exists(), "Target path must exist: {target:?}");
282    if is_transformation_to_wasm_component_needed(tripple) {
283        let target_transformed = dst_target_dir
284            .join(tripple)
285            .join("release")
286            .join(format!("{name_snake_case}_component.wasm",));
287        let mut cmd = Command::new("wasm-tools");
288        cmd.arg("component")
289            .arg("new")
290            .arg(
291                target
292                    .to_str()
293                    .expect("only utf-8 encoded paths are supported"),
294            )
295            .arg("--output")
296            .arg(
297                target_transformed
298                    .to_str()
299                    .expect("only utf-8 encoded paths are supported"),
300            );
301        let status = cmd.status().unwrap();
302        assert!(status.success());
303        assert!(
304            target_transformed.exists(),
305            "Transformed target path must exist: {target_transformed:?}"
306        );
307        // mv target_transformed -> target
308        std::fs::remove_file(&target).expect("deletion must succeed");
309        std::fs::rename(target_transformed, &target).expect("rename must succeed");
310    }
311    target
312}