Skip to main content

hydro_lang/compile/trybuild/
generate.rs

1use std::fs::{self, File};
2use std::io::{Read, Seek, SeekFrom, Write};
3use std::path::{Path, PathBuf};
4
5#[cfg(any(feature = "deploy", feature = "maelstrom"))]
6use dfir_lang::diagnostic::Diagnostics;
7#[cfg(any(feature = "deploy", feature = "maelstrom"))]
8use dfir_lang::graph::{AsCodeOptions, DfirGraph};
9use sha2::{Digest, Sha256};
10#[cfg(any(feature = "deploy", feature = "maelstrom"))]
11use stageleft::internal::quote;
12use trybuild_internals_api::cargo::{self, Metadata};
13use trybuild_internals_api::env::Update;
14use trybuild_internals_api::normalize::Normalization;
15use trybuild_internals_api::run::{PathDependency, Project};
16use trybuild_internals_api::{Runner, dependencies, features, path};
17
18pub const HYDRO_RUNTIME_FEATURES: &[&str] = &[
19    "deploy_integration",
20    "runtime_measure",
21    "docker_runtime",
22    "ecs_runtime",
23    "maelstrom_runtime",
24    "sim_runtime",
25];
26
27#[cfg(any(feature = "deploy", feature = "maelstrom"))]
28/// Whether to use dynamic linking for the generated binary.
29/// - `Static`: Place in base crate examples (for remote/containerized deploys)
30/// - `Dynamic`: Place in dylib crate examples (for sim and localhost deploys)
31#[derive(Debug, Clone, Copy, PartialEq, Eq)]
32pub enum LinkingMode {
33    // `Static` is only constructed by the deploy backends; Maelstrom-only builds
34    // always use `Dynamic`.
35    #[cfg_attr(
36        not(feature = "deploy"),
37        expect(
38            dead_code,
39            reason = "only constructed by the deploy backends; Maelstrom-only builds use Dynamic"
40        )
41    )]
42    Static,
43    #[cfg(any(feature = "deploy", feature = "maelstrom"))]
44    Dynamic,
45}
46
47#[cfg(any(feature = "deploy", feature = "maelstrom"))]
48/// The deployment mode for code generation.
49#[derive(Debug, Clone, Copy, PartialEq, Eq)]
50pub enum DeployMode {
51    #[cfg(feature = "deploy")]
52    /// Standard `HydroDeploy`
53    HydroDeploy,
54    #[cfg(any(feature = "docker_deploy", feature = "ecs_deploy"))]
55    /// Containerized deployment (Docker/ECS)
56    Containerized,
57    #[cfg(feature = "maelstrom")]
58    /// Maelstrom deployment with stdin/stdout JSON protocol
59    Maelstrom,
60}
61
62pub(crate) static IS_TEST: std::sync::atomic::AtomicBool =
63    std::sync::atomic::AtomicBool::new(false);
64
65pub(crate) static CONCURRENT_TEST_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
66
67/// Enables "test mode" for Hydro, which makes it possible to compile Hydro programs written
68/// inside a `#[cfg(test)]` module. This should be enabled in a global [`ctor`] hook.
69///
70/// # Example
71/// ```ignore
72/// #[cfg(test)]
73/// mod test_init {
74///    #[ctor::ctor]
75///    fn init() {
76///        hydro_lang::compile::init_test();
77///    }
78/// }
79/// ```
80pub fn init_test() {
81    IS_TEST.store(true, std::sync::atomic::Ordering::Relaxed);
82}
83
84#[cfg(any(feature = "deploy", feature = "maelstrom"))]
85fn clean_bin_name_prefix(bin_name_prefix: &str) -> String {
86    bin_name_prefix
87        .replace("::", "__")
88        .replace([' ', ',', '<', '{', '}'], "_")
89        .replace(['>', '(', ')'], "")
90}
91
92#[derive(Debug, Clone)]
93pub struct TrybuildConfig {
94    pub project_dir: PathBuf,
95    pub target_dir: PathBuf,
96    /// The cargo package name of the generated project's `dylib` crate, which the dynamically
97    /// linked examples (sim and localhost deploys) load at runtime.
98    pub dylib_package_name: String,
99    pub features: Option<Vec<String>>,
100    #[cfg(any(feature = "deploy", feature = "maelstrom"))]
101    // Only the deploy backends read this field; Maelstrom-only builds derive the
102    // linking behavior directly.
103    #[cfg_attr(
104        not(feature = "deploy"),
105        expect(dead_code, reason = "only read by the deploy backends")
106    )]
107    /// Which crate within the workspace to use for examples.
108    /// - `Static`: base crate (for remote/containerized deploys)
109    /// - `Dynamic`: dylib-examples crate (for sim and localhost deploys)
110    pub linking_mode: LinkingMode,
111}
112
113#[cfg(any(feature = "deploy", feature = "maelstrom"))]
114pub fn create_graph_trybuild(
115    graph: DfirGraph,
116    extra_stmts: &[syn::Stmt],
117    sidecars: &[syn::Expr],
118    as_code_options: &AsCodeOptions,
119    bin_name_prefix: Option<&str>,
120    deploy_mode: DeployMode,
121    linking_mode: LinkingMode,
122) -> (String, TrybuildConfig) {
123    let source_dir = cargo::manifest_dir().unwrap();
124    let source_manifest = dependencies::get_manifest(&source_dir).unwrap();
125    let crate_name = source_manifest.package.name.replace('-', "_");
126
127    let is_test = IS_TEST.load(std::sync::atomic::Ordering::Relaxed);
128
129    let generated_code = {
130        let _span = tracing::debug_span!(target: "hydro_build", "graph_codegen").entered();
131        compile_graph_trybuild(
132            graph,
133            extra_stmts,
134            sidecars,
135            as_code_options,
136            &crate_name,
137            deploy_mode,
138        )
139    };
140
141    let source = {
142        let _span = tracing::debug_span!(target: "hydro_build", "unparse_source").entered();
143        prettyplease::unparse(&generated_code)
144    };
145
146    let hash = format!("{:X}", Sha256::digest(&source))
147        .chars()
148        .take(8)
149        .collect::<String>();
150
151    let bin_name = if let Some(bin_name_prefix) = bin_name_prefix {
152        format!("{}_{}", clean_bin_name_prefix(bin_name_prefix), hash)
153    } else {
154        hash
155    };
156
157    let (project_dir, target_dir, dylib_package_name, mut cur_bin_enabled_features) =
158        create_trybuild().unwrap();
159
160    // Determine which crate's examples folder to use based on linking mode
161    let examples_dir = match linking_mode {
162        LinkingMode::Static => path!(project_dir / "examples"),
163        #[cfg(any(feature = "deploy", feature = "maelstrom"))]
164        LinkingMode::Dynamic => path!(project_dir / "dylib-examples" / "examples"),
165    };
166
167    // TODO(shadaj): garbage collect this directory occasionally
168    fs::create_dir_all(&examples_dir).unwrap();
169
170    let out_path = path!(examples_dir / format!("{bin_name}.rs"));
171    {
172        let _span =
173            tracing::debug_span!(target: "hydro_build", "write_generated_sources").entered();
174        let _concurrent_test_lock = CONCURRENT_TEST_LOCK.lock().unwrap();
175        write_atomic(source.as_ref(), &out_path).unwrap();
176    }
177
178    if is_test {
179        write_staged_source_cached(source_dir.as_ref(), &crate_name, &project_dir);
180    }
181
182    if is_test {
183        if cur_bin_enabled_features.is_none() {
184            cur_bin_enabled_features = Some(vec![]);
185        }
186
187        cur_bin_enabled_features
188            .as_mut()
189            .unwrap()
190            .push("hydro___test".to_owned());
191    }
192
193    (
194        bin_name,
195        TrybuildConfig {
196            project_dir,
197            target_dir,
198            dylib_package_name,
199            features: cur_bin_enabled_features,
200            #[cfg(any(feature = "deploy", feature = "maelstrom"))]
201            linking_mode,
202        },
203    )
204}
205
206#[cfg(any(feature = "deploy", feature = "maelstrom"))]
207pub fn compile_graph_trybuild(
208    partitioned_graph: DfirGraph,
209    extra_stmts: &[syn::Stmt],
210    sidecars: &[syn::Expr],
211    as_code_options: &AsCodeOptions,
212    crate_name: &str,
213    deploy_mode: DeployMode,
214) -> syn::File {
215    use crate::staging_util::get_this_crate;
216
217    let mut diagnostics = Diagnostics::new();
218    let dfir_expr: syn::Expr = syn::parse2(
219        partitioned_graph
220            .as_code_with_options(
221                &quote! { __root_dfir_rs },
222                as_code_options,
223                quote!(),
224                &mut diagnostics,
225            )
226            .expect("DFIR code generation failed with diagnostics."),
227    )
228    .unwrap();
229
230    let orig_crate_name = quote::format_ident!("{}", crate_name);
231    let trybuild_crate_name_ident = quote::format_ident!("{}_hydro_trybuild", crate_name);
232    let root = get_this_crate();
233    let tokio_main_ident = format!("{}::runtime_support::tokio", root);
234    let dfir_ident = quote::format_ident!("{}", crate::compile::DFIR_IDENT);
235
236    let source_ast: syn::File = match deploy_mode {
237        #[cfg(any(feature = "docker_deploy", feature = "ecs_deploy"))]
238        DeployMode::Containerized => {
239            syn::parse_quote! {
240                #![allow(unused_imports, unused_crate_dependencies, missing_docs, non_snake_case, unexpected_cfgs, unfulfilled_lint_expectations)]
241                use #trybuild_crate_name_ident::__root as #orig_crate_name;
242                use #orig_crate_name::*;
243                use #orig_crate_name::__staged::__deps::*;
244                use #root::prelude::*;
245                use #root::runtime_support::dfir_rs as __root_dfir_rs;
246                pub use #orig_crate_name::__staged;
247
248                #[#root::runtime_support::tokio::main(crate = #tokio_main_ident, flavor = "current_thread")]
249                async fn main() {
250                    #root::telemetry::initialize_tracing();
251
252                    #( #extra_stmts )*
253
254                    let mut #dfir_ident = #dfir_expr;
255
256                    let local_set = #root::runtime_support::tokio::task::LocalSet::new();
257                    #(
258                        let _ = local_set.spawn_local( #sidecars ); // Uses #dfir_ident
259                    )*
260
261                    let _ = local_set.run_until(#dfir_ident.run()).await;
262                }
263            }
264        }
265        #[cfg(feature = "deploy")]
266        DeployMode::HydroDeploy => {
267            syn::parse_quote! {
268                #![allow(unused_imports, unused_crate_dependencies, missing_docs, non_snake_case, unexpected_cfgs, unfulfilled_lint_expectations)]
269                use #trybuild_crate_name_ident::__root as #orig_crate_name;
270                use #orig_crate_name::*;
271                use #orig_crate_name::__staged::__deps::*;
272                use #root::prelude::*;
273                use #root::runtime_support::dfir_rs as __root_dfir_rs;
274                pub use #orig_crate_name::__staged;
275
276                #[#root::runtime_support::tokio::main(crate = #tokio_main_ident, flavor = "current_thread")]
277                async fn main() {
278                    let __hydro_lang_trybuild_cli_owned: #root::runtime_support::hydro_deploy_integration::DeployPorts<#root::__staged::deploy::deploy_runtime::HydroMeta> = #root::runtime_support::launch::init_no_ack_start().await;
279                    let __hydro_lang_trybuild_cli = &__hydro_lang_trybuild_cli_owned;
280
281                    #( #extra_stmts )*
282
283                    let mut #dfir_ident = #dfir_expr;
284                    println!("ack start");
285
286                    // TODO(mingwei): initialize `tracing` at this point in execution.
287                    // After "ack start" is when we can print whatever we want.
288
289                    let local_set = #root::runtime_support::tokio::task::LocalSet::new();
290                    #(
291                        let _ = local_set.spawn_local( #sidecars ); // Uses #dfir_ident
292                    )*
293
294                    let _ = local_set.run_until(#root::runtime_support::launch::run_stdin_commands(
295                        async move {
296                            #dfir_ident.run().await
297                        }
298                    )).await;
299                }
300            }
301        }
302        #[cfg(feature = "maelstrom")]
303        DeployMode::Maelstrom => {
304            syn::parse_quote! {
305                #![allow(unused_imports, unused_crate_dependencies, missing_docs, non_snake_case, unexpected_cfgs, unfulfilled_lint_expectations)]
306                use #trybuild_crate_name_ident::__root as #orig_crate_name;
307                use #orig_crate_name::*;
308                use #orig_crate_name::__staged::__deps::*;
309                use #root::prelude::*;
310                use #root::runtime_support::dfir_rs as __root_dfir_rs;
311                pub use #orig_crate_name::__staged;
312
313                #[allow(unused)]
314                fn __hydro_runtime<'a>(
315                    __hydro_lang_maelstrom_meta: &'a #root::__staged::deploy::maelstrom::deploy_runtime_maelstrom::MaelstromMeta
316                )
317                    -> #root::runtime_support::dfir_rs::scheduled::context::Dfir<impl #root::runtime_support::dfir_rs::scheduled::context::TickClosure + 'a>
318                {
319                    #( #extra_stmts )*
320
321                    #dfir_expr
322                }
323
324                #[#root::runtime_support::tokio::main(crate = #tokio_main_ident, flavor = "current_thread")]
325                async fn main() {
326                    #root::telemetry::initialize_tracing();
327
328                    // Initialize Maelstrom protocol - read init message and send init_ok
329                    let __hydro_lang_maelstrom_meta = #root::__staged::deploy::maelstrom::deploy_runtime_maelstrom::maelstrom_init();
330
331                    let mut #dfir_ident = __hydro_runtime(&__hydro_lang_maelstrom_meta);
332
333                    __hydro_lang_maelstrom_meta.start_receiving(); // start receiving messages after initializing subscribers
334
335                    let local_set = #root::runtime_support::tokio::task::LocalSet::new();
336                    #(
337                        let _ = local_set.spawn_local( #sidecars ); // Uses #dfir_ident
338                    )*
339
340                    let _ = local_set.run_until(#dfir_ident.run()).await;
341                }
342            }
343        }
344    };
345    source_ast
346}
347
348/// Configuration for [`compile_trybuild_example`], the shared concurrent-build
349/// entrypoint used by both the simulator and the Maelstrom deployment target.
350#[cfg(any(feature = "sim", feature = "maelstrom"))]
351pub struct ExampleBuildConfig<'a> {
352    /// The trybuild project + target directories and enabled features.
353    pub trybuild: TrybuildConfig,
354    /// The generated example base name (a content hash). Used as the per-job
355    /// directory name and, when [`Self::set_trybuild_lib_name`] is set, as the
356    /// value of the `TRYBUILD_LIB_NAME` environment variable.
357    pub bin_name: String,
358    /// A runtime feature to enable in addition to [`TrybuildConfig::features`]
359    /// (e.g. `hydro___feature_sim_runtime` or `hydro___feature_maelstrom_runtime`).
360    pub runtime_feature: &'a str,
361    /// The cargo `--example` target to build. For the simulator this is the
362    /// fixed `sim-dylib` wrapper; for Maelstrom it is the generated `bin_name`.
363    pub example_name: String,
364    /// If `Some`, override the crate type on the command line (e.g. `cdylib`
365    /// for the simulator). `None` builds a normal executable example.
366    pub crate_type: Option<&'a str>,
367    /// Whether to set `TRYBUILD_LIB_NAME` to `bin_name` (the simulator uses this
368    /// for its `include!`-based indirection).
369    pub set_trybuild_lib_name: bool,
370    /// Whether to honor the `BOLERO_FUZZER` environment variable. Only the
371    /// simulator supports fuzzing; other targets should set this to `false`.
372    pub allow_fuzz: bool,
373}
374
375/// Returns the toolchain's target libdir (where the shared `libstd` lives), memoized.
376#[cfg(any(feature = "sim", feature = "maelstrom"))]
377fn rustc_target_libdir() -> Option<String> {
378    static LIBDIR: std::sync::OnceLock<Option<String>> = std::sync::OnceLock::new();
379    LIBDIR
380        .get_or_init(|| {
381            let rustc = std::env::var("RUSTC").unwrap_or_else(|_| "rustc".to_owned());
382            std::process::Command::new(rustc)
383                .args(["--print", "target-libdir"])
384                .output()
385                .ok()
386                .filter(|out| out.status.success())
387                .map(|out| String::from_utf8(out.stdout).unwrap().trim().to_owned())
388        })
389        .clone()
390}
391
392/// The built artifact returned by [`compile_trybuild_example`].
393///
394/// Outside coverage runs this is a delete-on-drop temporary copy: every build
395/// reuses the same example artifact path in the shared target dir (e.g. the
396/// simulator always builds `sim-dylib`, varying only `TRYBUILD_LIB_NAME`), so
397/// the caller gets a private copy that a later build cannot clobber.
398///
399/// In coverage runs the copy is instead persisted (keyed by the generated
400/// source's hash, which `bin_name` embeds, and by the toolchain fingerprint)
401/// under the shared target dir's `debug/deps`, and must outlive the process:
402/// the coverage mapping needed to resolve profile data lives in the artifact
403/// itself, and report-time tools (e.g. `grcov --binary-path target/debug` or
404/// `target/debug/deps`, both scanned recursively) run only after all test
405/// processes have exited.
406pub enum BuiltArtifact {
407    /// A delete-on-drop temporary copy of the artifact.
408    Temp(tempfile::TempPath),
409    /// A persistent copy that survives process exit, for coverage reporters.
410    Persisted(PathBuf),
411}
412
413impl std::ops::Deref for BuiltArtifact {
414    type Target = Path;
415
416    fn deref(&self) -> &Path {
417        match self {
418            BuiltArtifact::Temp(path) => path,
419            BuiltArtifact::Persisted(path) => path,
420        }
421    }
422}
423
424impl BuiltArtifact {
425    /// Persist the artifact at its current path, returning that path (the
426    /// temporary copy is kept rather than deleted on drop).
427    ///
428    /// Only the Maelstrom deploy path consumes this; gate it accordingly so
429    /// sim-only builds don't carry (and warn about) dead code.
430    #[cfg(feature = "maelstrom")]
431    pub fn keep(self) -> Result<PathBuf, std::io::Error> {
432        match self {
433            BuiltArtifact::Temp(path) => path.keep().map_err(|e| e.error),
434            BuiltArtifact::Persisted(path) => Ok(path),
435        }
436    }
437}
438
439/// The `CARGO_ENCODED_RUSTFLAGS` this crate was compiled with, captured by `build.rs`.
440///
441/// Generated Hydro programs are compiled by a child `cargo`, which is handed exactly these
442/// flags (see [`forward_rustflags`]) so the generated code matches the crate that produced it:
443/// same cfgs, same instrumentation, same codegen options. Unlike reading `RUSTFLAGS` from the
444/// process environment, this also sees flags injected through cargo config (`build.rustflags`,
445/// `target.*.rustflags`, `--config`), which never reach the test process's environment.
446///
447/// Also keys the trybuild dylib's target name (see [`create_trybuild`]).
448const ENCODED_RUSTFLAGS: &str = env!("HYDRO_ENCODED_RUSTFLAGS");
449
450/// Gives a child `cargo` the same rustflags as [`ENCODED_RUSTFLAGS`].
451///
452/// The flags are passed as CLI config (`--config build.rustflags=[...]`), not by setting
453/// `CARGO_ENCODED_RUSTFLAGS`/`RUSTFLAGS` in the child's environment, and the inherited
454/// environment is left untouched. Build scripts commonly emit
455/// `cargo:rerun-if-env-changed=CARGO_ENCODED_RUSTFLAGS` (e.g. `aws-lc-sys`), which cargo
456/// evaluates against *its own* process environment: a child whose environment differs from
457/// the outer build's — even by setting the variable to an empty string — gets different
458/// fingerprints and rebuilds those crates in the shared `deps/`, thrashing against the outer
459/// build and against sibling children that don't set it.
460///
461/// Precedence is consistent by construction: if `RUSTFLAGS` or `CARGO_ENCODED_RUSTFLAGS` is
462/// in the inherited environment it wins over this config in the child, but then it is also
463/// what the outer build's encoded flags were derived from, so the flags agree either way.
464/// Cargo merges `--config` arrays with those from config files, so a workspace whose
465/// `.cargo/config.toml` sets `build.rustflags` gets those flags twice in the child (once from
466/// the copied config, once forwarded); harmless to rustc, but the child's flag string then
467/// differs from the outer build's, so its dependency rlibs are not shared with it.
468#[cfg(any(feature = "sim", feature = "maelstrom"))]
469fn forward_rustflags(command: &mut std::process::Command) {
470    let flags = toml::Value::try_from(
471        ENCODED_RUSTFLAGS
472            .split('\x1f')
473            .filter(|flag| !flag.is_empty())
474            .collect::<Vec<_>>(),
475    )
476    .unwrap();
477    command.args(["--config", &format!("build.rustflags={flags}")]);
478}
479
480/// Compiles a generated trybuild example against the prebuilt dylib crate,
481/// using the shared parallel-compilation machinery (per-job target dirs with
482/// symlinked shared artifacts, plus a prebuild of the dylib dependencies).
483///
484/// Returns a [`BuiltArtifact`] handle to a copy of the built artifact (a
485/// `cdylib` for the simulator, or an executable for Maelstrom), which the
486/// caller can hold onto independently of the shared target directory.
487#[cfg(any(feature = "sim", feature = "maelstrom"))]
488pub fn compile_trybuild_example(config: ExampleBuildConfig<'_>) -> Result<BuiltArtifact, ()> {
489    use std::process::{Command, Stdio};
490
491    let ExampleBuildConfig {
492        trybuild,
493        bin_name,
494        runtime_feature,
495        example_name,
496        crate_type,
497        set_trybuild_lib_name,
498        allow_fuzz,
499    } = config;
500
501    let is_fuzz = allow_fuzz && std::env::var("BOLERO_FUZZER").is_ok();
502
503    // The parallel build machinery (per-job target dirs symlinking the shared `debug/`
504    // artifacts, fronted by a fingerprinted prebuild) assumes the host `debug/` layout and
505    // dynamically linked examples. Fuzz builds statically link examples from the base crate,
506    // and `CARGO_BUILD_TARGET` moves artifacts under `<triple>/debug/`; both build directly
507    // into the shared target dir instead, where cargo's own fingerprinting applies.
508    let use_prebuild = !is_fuzz && std::env::var_os("CARGO_BUILD_TARGET").is_none();
509
510    // Coverage builds (`-C instrument-coverage`) need their covmap-bearing artifact copied to
511    // a stable location where coverage reporters can find it (see below).
512    // See https://github.com/hydro-project/hydro/issues/3160.
513    let coverage_enabled =
514        rustflags::from_encoded(std::ffi::OsStr::new(ENCODED_RUSTFLAGS)).any(|flag| {
515            matches!(
516                flag,
517                rustflags::Flag::Codegen { opt, value }
518                    if opt == "instrument-coverage"
519                        && !matches!(value.as_deref(), Some("off" | "no" | "n" | "false" | "0"))
520            )
521        });
522
523    // Run from dylib-examples crate which has the dylib as a dev-dependency (only if not fuzzing)
524    let crate_to_compile = if is_fuzz {
525        trybuild.project_dir.clone()
526    } else {
527        path!(trybuild.project_dir / "dylib-examples")
528    };
529
530    let (final_target_dir, _prebuild_guard, _cargo_lock) = if use_prebuild {
531        let prebuild_span =
532            tracing::debug_span!(target: "hydro_build", "prebuild", bin_name = %bin_name).entered();
533        let shared_debug = trybuild.target_dir.join("debug");
534        let jobs_dir = trybuild.target_dir.join("jobs");
535        let per_job = hydro_concurrent_cargo::setup_job_dir(&jobs_dir, &bin_name, &shared_debug);
536
537        let mut features: Vec<String> = trybuild.features.clone().unwrap_or_default();
538        features.push(runtime_feature.to_owned());
539
540        let staged_paths = vec![
541            path!(trybuild.project_dir / "src" / "__staged.rs"),
542            path!(trybuild.project_dir / "Cargo.lock"),
543            std::env::current_exe().unwrap(),
544        ];
545
546        let features_for_closure = features.clone();
547
548        // The prebuild is fingerprinted on these features, our rustflags, and the compiler
549        // version: shared artifacts built under other flags or another toolchain are rebuilt
550        // (under the exclusive lock, after renaming the dylib for this configuration) before any
551        // final build links against them.
552        let (guard, cargo_lock) = hydro_concurrent_cargo::run_prebuild(
553            &trybuild.target_dir,
554            trybuild.project_dir.file_name().unwrap().to_str().unwrap(),
555            &features,
556            ENCODED_RUSTFLAGS,
557            &staged_paths,
558            |prebuild_target| {
559                hydro_concurrent_cargo::set_dylib_lib_name(
560                    &trybuild.project_dir,
561                    ENCODED_RUSTFLAGS,
562                );
563                let features_str = features_for_closure.join(",");
564
565                // Prebuild the lib that final builds will link against, which transitively
566                // builds the trybuild dylib *as a dependency*. This matters: cargo passes
567                // `-C prefer-dynamic` to dylib crates when they are built as dependencies
568                // (linking libstd dynamically), but *not* when they are the primary build
569                // target — and the two variants share a cargo fingerprint, so whichever is
570                // built first wins. Building the dylib as a primary target would poison the
571                // cache with a statically-linked-std variant that later fails to link into
572                // examples ("cannot satisfy dependencies so `std` only shows up once").
573                let mut lib_cmd = Command::new("cargo");
574                lib_cmd.current_dir(&crate_to_compile);
575                lib_cmd.args(["build", "--locked", "--lib"]);
576                lib_cmd.args(["--target-dir", prebuild_target.to_str().unwrap()]);
577                lib_cmd.arg("--no-default-features");
578                lib_cmd.args(["--features", &features_str]);
579                lib_cmd.args(["--config", "build.incremental = false"]);
580                lib_cmd.env("STAGELEFT_TRYBUILD_BUILD_STAGED", "1");
581                forward_rustflags(&mut lib_cmd);
582                let status = lib_cmd.stdin(Stdio::null()).status().unwrap();
583                assert!(status.success(), "dep prebuild failed")
584            },
585        );
586
587        // Close the prebuild span before returning the guards: the guards are held for the
588        // entire final build, but the prebuild phase (freshness check + possible dep build)
589        // ends here.
590        drop(prebuild_span);
591        (per_job, Some(guard), Some(cargo_lock))
592    } else {
593        (trybuild.target_dir.clone(), None, None)
594    };
595
596    // Populate per-job build/ dir right before final build. Hold guard for entire build.
597    let _job_build_guard = if use_prebuild {
598        let populate_span =
599            tracing::debug_span!(target: "hydro_build", "populate_job_dir", bin_name = %bin_name)
600                .entered();
601        let shared_debug = trybuild.target_dir.join("debug");
602        let guard = hydro_concurrent_cargo::populate_job_build_dir(
603            &final_target_dir.join("debug"),
604            &shared_debug,
605        );
606        // Close the populate span here: the returned guard is held until the final build
607        // finishes, but the population work itself ends here.
608        drop(populate_span);
609        Some(guard)
610    } else {
611        None
612    };
613
614    let final_build_span =
615        tracing::debug_span!(target: "hydro_build", "final_build", bin_name = %bin_name).entered();
616    let mut command = Command::new("cargo");
617    command.current_dir(&crate_to_compile);
618    command.args(["rustc", if use_prebuild { "--frozen" } else { "--locked" }]);
619    command.args(["--example", &example_name]);
620    command.args(["--target-dir", final_target_dir.to_str().unwrap()]);
621    // Never enable default features: the generated example gets exactly the
622    // features it needs via `--features` (plus the runtime feature). This keeps
623    // the feature set minimal and deterministic — matching what the deploy
624    // backends and the base trybuild crate (which carries the source crate's
625    // `default`) would otherwise pull in — and matters for the `is_fuzz` path
626    // that builds from the base crate directly.
627    command.arg("--no-default-features");
628    command.args([
629        "--features",
630        &trybuild
631            .features
632            .clone()
633            .into_iter()
634            .flatten()
635            .chain([runtime_feature.to_owned()])
636            .collect::<Vec<_>>()
637            .join(","),
638    ]);
639    command.args(["--config", "build.incremental = false"]);
640    forward_rustflags(&mut command);
641    if let Some(crate_type) = crate_type {
642        command.args(["--crate-type", crate_type]);
643    }
644    command.arg("--message-format=json-diagnostic-rendered-ansi");
645    command.env("STAGELEFT_TRYBUILD_BUILD_STAGED", "1");
646    if set_trybuild_lib_name {
647        command.env("TRYBUILD_LIB_NAME", &bin_name);
648    }
649
650    command.arg("--");
651
652    if cfg!(any(target_os = "linux", target_os = "macos")) {
653        let (debug_path, shared_debug_path) =
654            if let Ok(target) = std::env::var("CARGO_BUILD_TARGET") {
655                (
656                    path!(final_target_dir / target / "debug"),
657                    path!(trybuild.target_dir / target / "debug"),
658                )
659            } else {
660                (
661                    path!(final_target_dir / "debug"),
662                    path!(trybuild.target_dir / "debug"),
663                )
664            };
665
666        // The built example links the trybuild dylib dynamically. Bake rpath entries for
667        // where cargo places it (which depends on the build-dir layout, see
668        // `hydro_concurrent_cargo::dylib_search_dirs`) and for the toolchain's shared libstd,
669        // so the artifact can be loaded/run without LD_LIBRARY_PATH.
670        let mut rpaths = hydro_concurrent_cargo::dylib_search_dirs(
671            &trybuild.dylib_package_name,
672            &debug_path,
673            &shared_debug_path,
674        );
675        if let Some(libdir) = rustc_target_libdir() {
676            rpaths.push(PathBuf::from(libdir));
677        }
678        for rpath in rpaths {
679            if cfg!(target_os = "macos") {
680                // On macOS rustc may invoke the linker directly (`rust-lld -flavor darwin`),
681                // which rejects `-Wl,`-wrapped arguments. Use raw ld64 syntax (`-rpath <path>`
682                // as two arguments), which the clang driver also forwards to the linker.
683                command.args([
684                    "-Clink-arg=-rpath".to_owned(),
685                    format!("-Clink-arg={}", rpath.to_str().unwrap()),
686                ]);
687            } else {
688                command.args([format!("-Clink-arg=-Wl,-rpath,{}", rpath.to_str().unwrap())]);
689            }
690        }
691
692        if cfg!(all(target_os = "linux", target_env = "gnu")) {
693            command.arg(
694                // https://github.com/rust-lang/rust/issues/91979
695                "-Clink-args=-Wl,-z,nodelete",
696            );
697        }
698    }
699
700    if allow_fuzz && let Ok(fuzzer) = std::env::var("BOLERO_FUZZER") {
701        command.env_remove("BOLERO_FUZZER");
702
703        if fuzzer == "libfuzzer" {
704            #[cfg(target_os = "macos")]
705            {
706                command.args(["-Clink-arg=-undefined", "-Clink-arg=dynamic_lookup"]);
707            }
708
709            #[cfg(target_os = "linux")]
710            {
711                command.args(["-Clink-arg=-Wl,--unresolved-symbols=ignore-all"]);
712            }
713        }
714    }
715
716    tracing::debug!(
717        target: "hydro_build",
718        "final build command (cwd={}): {:?}",
719        crate_to_compile.display(),
720        command
721    );
722
723    let mut spawned = command
724        .stdout(Stdio::piped())
725        .stderr(Stdio::piped())
726        .stdin(Stdio::null())
727        .spawn()
728        .unwrap();
729    let reader = std::io::BufReader::new(spawned.stdout.take().unwrap());
730    let stderr_handle = spawned.stderr.take().unwrap();
731    let stderr_thread = std::thread::spawn(move || {
732        use std::io::Read;
733        let mut buf = String::new();
734        std::io::BufReader::new(stderr_handle)
735            .read_to_string(&mut buf)
736            .unwrap();
737        buf
738    });
739
740    let mut out = Err(());
741    for message in cargo_metadata::Message::parse_stream(reader) {
742        match message.unwrap() {
743            cargo_metadata::Message::CompilerArtifact(artifact) => {
744                // unlike dylib, cdylib only exports the explicitly exported symbols
745                let is_output = artifact.target.is_example();
746
747                if is_output {
748                    let path = artifact.filenames.first().unwrap();
749                    let path_buf: PathBuf = path.clone().into();
750                    out = Ok(path_buf);
751                }
752            }
753            cargo_metadata::Message::CompilerMessage(mut msg) => {
754                // Update the path displayed to enable clicking in IDE.
755                // TODO(mingwei): deduplicate code with hydro_deploy rust_crate/build.rs
756                if let Some(rendered) = msg.message.rendered.as_mut() {
757                    let file_names = msg
758                        .message
759                        .spans
760                        .iter()
761                        .map(|s| &s.file_name)
762                        .collect::<std::collections::BTreeSet<_>>();
763                    for file_name in file_names {
764                        *rendered = rendered.replace(
765                            file_name,
766                            &format!("(full path) {}/{file_name}", trybuild.project_dir.display()),
767                        )
768                    }
769                }
770                eprintln!("{}", msg.message);
771            }
772            cargo_metadata::Message::TextLine(line) => {
773                eprintln!("{}", line);
774            }
775            cargo_metadata::Message::BuildFinished(_) => {}
776            cargo_metadata::Message::BuildScriptExecuted(_) => {}
777            msg => panic!("Unexpected message type: {:?}", msg),
778        }
779    }
780
781    spawned.wait().unwrap();
782    let stderr_output = stderr_thread.join().unwrap();
783    drop(final_build_span);
784
785    // Check for unexpected recompilations — only dylib-examples should be compiled.
786    // (Only relevant when prebuild is active.)
787    if use_prebuild {
788        for line in stderr_output.lines() {
789            if line.contains("Compiling") {
790                assert!(
791                    line.contains("dylib-examples"),
792                    "unexpected recompilation in final build: {line}\nfull stderr:\n{stderr_output}"
793                );
794            }
795        }
796    }
797
798    assert!(
799        out.is_ok(),
800        "final build failed to produce binary.\nstderr:\n{stderr_output}"
801    );
802
803    if coverage_enabled {
804        // The coverage mapping needed to resolve profile data at report time lives in
805        // the artifact itself, and reporters run only after all test processes have
806        // exited — so the copy must be persistent, not a delete-on-drop temp file.
807        // Persist it keyed by the generated source's hash (embedded in `bin_name`;
808        // the shared artifact path itself is reused by every build and would be
809        // clobbered) and by the toolchain fingerprint (the same program built under
810        // other flags or another compiler carries a different coverage mapping and
811        // must not overwrite this one), under the shared target dir's `debug/deps` so
812        // reporters find every mapping whether they scan `target/debug` or the narrower
813        // `target/debug/deps` (both are common `grcov --binary-path` conventions,
814        // and both are scanned recursively). This persisted copy doubles as the
815        // artifact handed to the caller.
816        let coverage_dir = path!(trybuild.target_dir / "debug" / "deps" / "hydro-coverage");
817        fs::create_dir_all(&coverage_dir).unwrap();
818        let artifact_name = out.as_ref().unwrap().file_name().unwrap().to_str().unwrap();
819        let fingerprint = hydro_concurrent_cargo::toolchain_fingerprint(ENCODED_RUSTFLAGS);
820        let persisted = path!(coverage_dir / format!("{bin_name}-{fingerprint}-{artifact_name}"));
821        // Write via a unique temp file + rename so concurrent test processes
822        // building the same flow never observe a partially-copied artifact.
823        let staging = tempfile::NamedTempFile::new_in(&coverage_dir).unwrap();
824        fs::copy(out.as_ref().unwrap(), staging.path()).unwrap();
825        staging.persist(&persisted).unwrap();
826        return Ok(BuiltArtifact::Persisted(persisted));
827    }
828
829    let out_file = tempfile::NamedTempFile::new().unwrap().into_temp_path();
830    fs::copy(out.as_ref().unwrap(), &out_file).unwrap();
831    Ok(BuiltArtifact::Temp(out_file))
832}
833
834/// Generates the inlined `__staged.rs` source for the source crate and writes it into the
835/// trybuild project, caching the (expensive) generation across test processes.
836///
837/// The staged source is a pure function of the source crate's files, and cargo rebuilds the
838/// test executable whenever those change, so the identity (path + mtime) of
839/// [`std::env::current_exe`] is a sound freshness proxy. All tests in a run share the same
840/// executable, so only the first test per test binary pays the ~1s `syn` parse +
841/// `prettyplease` unparse; the rest hit the cache. The stamp holds a single entry — the last
842/// executable to write `__staged.rs` — so a different test binary of the same crate
843/// regenerates on its first test (a no-op rewrite when sources are unchanged). Keeping old
844/// entries around would risk a stale match (e.g. an executable restored with an old mtime
845/// after another binary regenerated `__staged.rs` from different sources).
846pub(crate) fn write_staged_source_cached(source_dir: &Path, crate_name: &str, project_dir: &Path) {
847    let _span = tracing::debug_span!(target: "hydro_build", "gen_staged").entered();
848
849    let staged_path = path!(project_dir / "src" / "__staged.rs");
850    let stamp_path = path!(project_dir / ".hydro-staged-stamp");
851
852    let exe_stamp = std::env::current_exe().ok().and_then(|exe| {
853        let mtime = fs::metadata(&exe)
854            .ok()?
855            .modified()
856            .ok()?
857            .duration_since(std::time::SystemTime::UNIX_EPOCH)
858            .ok()?
859            .as_nanos();
860        Some(format!("{}\t{}", exe.display(), mtime))
861    });
862
863    let _concurrent_test_lock = CONCURRENT_TEST_LOCK.lock().unwrap();
864
865    fs::create_dir_all(path!(project_dir / "src")).unwrap();
866
867    // Hold an exclusive lock on the stamp file for the entire check + generate: when many
868    // test processes start concurrently with a cold cache, the first one generates while the
869    // others block here and then hit the cache, instead of all doing the expensive work.
870    let mut stamp_file = File::options()
871        .read(true)
872        .write(true)
873        .create(true)
874        .truncate(false)
875        .open(&stamp_path)
876        .unwrap();
877    stamp_file.lock().unwrap();
878
879    let mut existing_stamp = String::new();
880    if stamp_file.read_to_string(&mut existing_stamp).is_err() {
881        existing_stamp.clear();
882    }
883
884    if let Some(stamp) = &exe_stamp
885        && existing_stamp == *stamp
886        && staged_path.exists()
887    {
888        return;
889    }
890
891    let raw_toml_manifest = toml::from_str::<toml::Value>(
892        &fs::read_to_string(path!(source_dir / "Cargo.toml")).unwrap(),
893    )
894    .unwrap();
895
896    let maybe_custom_lib_path = raw_toml_manifest
897        .get("lib")
898        .and_then(|lib| lib.get("path"))
899        .and_then(|path| path.as_str());
900
901    let mut gen_staged = stageleft_tool::gen_staged_trybuild(
902        &maybe_custom_lib_path
903            .map(|s| path!(source_dir / s))
904            .unwrap_or_else(|| path!(source_dir / "src" / "lib.rs")),
905        &path!(source_dir / "Cargo.toml"),
906        crate_name,
907        Some("hydro___test".to_owned()),
908    );
909
910    gen_staged.attrs.insert(
911        0,
912        syn::parse_quote! {
913            #![allow(
914                unused,
915                ambiguous_glob_reexports,
916                clippy::suspicious_else_formatting,
917                unexpected_cfgs,
918                reason = "generated code"
919            )]
920        },
921    );
922
923    let inlined_staged = prettyplease::unparse(&gen_staged);
924
925    write_atomic(inlined_staged.as_bytes(), &staged_path).unwrap();
926
927    if let Some(stamp) = exe_stamp {
928        stamp_file.set_len(0).unwrap();
929        stamp_file.seek(SeekFrom::Start(0)).unwrap();
930        stamp_file.write_all(stamp.as_bytes()).unwrap();
931    }
932}
933
934pub fn create_trybuild()
935-> Result<(PathBuf, PathBuf, String, Option<Vec<String>>), trybuild_internals_api::error::Error> {
936    let _span = tracing::debug_span!(target: "hydro_build", "create_trybuild").entered();
937    let Metadata {
938        target_directory: target_dir,
939        workspace_root: workspace,
940        packages,
941    } = {
942        let _span = tracing::debug_span!(target: "hydro_build", "cargo_metadata").entered();
943        cargo::metadata()?
944    };
945
946    let source_dir = cargo::manifest_dir()?;
947    let mut source_manifest = dependencies::get_manifest(&source_dir)?;
948
949    let mut dev_dependency_features = vec![];
950    source_manifest.dev_dependencies.retain(|k, v| {
951        if source_manifest.dependencies.contains_key(k) {
952            // already a non-dev dependency, so drop the dep and put the features under the test flag
953            for feat in &v.features {
954                dev_dependency_features.push(format!("{}/{}", k, feat));
955            }
956
957            false
958        } else {
959            // only enable this in test mode, so make it optional otherwise
960            dev_dependency_features.push(format!("dep:{k}"));
961
962            v.optional = true;
963            true
964        }
965    });
966
967    // When the example is re-executed from a test binary by `example_test` (signaled via this
968    // env var), skip feature discovery: it would pick up the *test* binary's features (e.g.
969    // test-only harness features). A real `cargo run --example` invocation has no fingerprint
970    // hash in `argv[0]`, so discovery finds nothing there; emulating that here ensures the test
971    // exercises the example the same way it actually runs.
972    let mut features = if std::env::var("RUNNING_AS_EXAMPLE_TEST").is_ok_and(|v| v == "1") {
973        None
974    } else {
975        features::find()
976    };
977
978    let path_dependencies = source_manifest
979        .dependencies
980        .iter()
981        .filter_map(|(name, dep)| {
982            let path = dep.path.as_ref()?;
983            if packages.iter().any(|p| &p.name == name) {
984                // Skip path dependencies coming from the workspace itself
985                None
986            } else {
987                Some(PathDependency {
988                    name: name.clone(),
989                    normalized_path: path.canonicalize().ok()?,
990                    normalization: Normalization::PathDependencies,
991                })
992            }
993        })
994        .collect();
995
996    let crate_name = source_manifest.package.name.clone();
997    let project_dir = path!(target_dir / "hydro_trybuild" / crate_name /);
998    fs::create_dir_all(&project_dir)?;
999
1000    let project_name = format!("{}-hydro-trybuild", crate_name);
1001    let dylib_package_name = format!("{project_name}-dylib");
1002    let mut manifest = Runner::make_manifest(
1003        &workspace,
1004        &project_name,
1005        &source_dir,
1006        &packages,
1007        &[],
1008        source_manifest,
1009    )?;
1010
1011    if let Some(enabled_features) = &mut features {
1012        enabled_features
1013            .retain(|feature| manifest.features.contains_key(feature) || feature == "default");
1014    }
1015
1016    for runtime_feature in HYDRO_RUNTIME_FEATURES {
1017        manifest.features.insert(
1018            format!("hydro___feature_{runtime_feature}"),
1019            vec![format!("hydro_lang/{runtime_feature}")],
1020        );
1021    }
1022
1023    manifest
1024        .dependencies
1025        .get_mut("hydro_lang")
1026        .unwrap()
1027        .features
1028        .push("runtime_support".to_owned());
1029
1030    manifest
1031        .features
1032        .insert("hydro___test".to_owned(), dev_dependency_features);
1033
1034    if manifest
1035        .workspace
1036        .as_ref()
1037        .is_some_and(|w| w.dependencies.is_empty())
1038    {
1039        manifest.workspace = None;
1040    }
1041
1042    let project = Project {
1043        dir: project_dir,
1044        source_dir,
1045        target_dir,
1046        name: project_name.clone(),
1047        update: Update::env()?,
1048        has_pass: false,
1049        has_compile_fail: false,
1050        features,
1051        workspace,
1052        path_dependencies,
1053        manifest,
1054        keep_going: false,
1055    };
1056
1057    {
1058        let _span = tracing::debug_span!(target: "hydro_build", "write_project_files").entered();
1059        let _concurrent_test_lock = CONCURRENT_TEST_LOCK.lock().unwrap();
1060
1061        let _project_lock = hydro_concurrent_cargo::lock_project(project.dir.as_ref());
1062
1063        fs::create_dir_all(path!(project.dir / "src"))?;
1064        fs::create_dir_all(path!(project.dir / "examples"))?;
1065
1066        let crate_name_ident = syn::Ident::new(
1067            &crate_name.replace('-', "_"),
1068            proc_macro2::Span::call_site(),
1069        );
1070
1071        write_atomic(
1072            prettyplease::unparse(&syn::parse_quote! {
1073                #![allow(unused_imports, unused_crate_dependencies, missing_docs, non_snake_case, unexpected_cfgs, unfulfilled_lint_expectations)]
1074
1075                pub mod __root {
1076                    pub use #crate_name_ident::*;
1077                    #[cfg(feature = "hydro___test")]
1078                    pub use super::__staged;
1079                }
1080
1081                #[cfg(feature = "hydro___test")]
1082                pub mod __staged;
1083            })
1084            .as_bytes(),
1085            &path!(project.dir / "src" / "lib.rs"),
1086        )
1087        .unwrap();
1088
1089        let base_manifest = toml::to_string(&project.manifest)?;
1090
1091        // Collect feature names for forwarding to dylib and dylib-examples crates
1092        let feature_names: Vec<_> = project.manifest.features.keys().cloned().collect();
1093
1094        // Create dylib crate directory
1095        let dylib_dir = path!(project.dir / "dylib");
1096        fs::create_dir_all(path!(dylib_dir / "src"))?;
1097
1098        let trybuild_crate_name_ident = syn::Ident::new(
1099            &project_name.replace('-', "_"),
1100            proc_macro2::Span::call_site(),
1101        );
1102        write_atomic(
1103            // The leading comment busts cargo's fingerprint for caches where the dylib was
1104            // built as a *primary* target (statically linking libstd); it must be built as a
1105            // dependency (with `-C prefer-dynamic`) for examples to link against it. The
1106            // linkage variant is not part of cargo's fingerprint, so a content change is
1107            // needed to force old caches to rebuild.
1108            [
1109                "// v2: dylib must be built as a dependency (prefer-dynamic).\n".as_bytes(),
1110                prettyplease::unparse(&syn::parse_quote! {
1111                    #![allow(unused_imports, unused_crate_dependencies, missing_docs, non_snake_case, unexpected_cfgs, unfulfilled_lint_expectations)]
1112                    pub use #trybuild_crate_name_ident::*;
1113                })
1114                .as_bytes(),
1115            ]
1116            .concat()
1117            .as_slice(),
1118            &path!(dylib_dir / "src" / "lib.rs"),
1119        )?;
1120
1121        let serialized_edition = toml::to_string(
1122            &vec![("edition", &project.manifest.package.edition)]
1123                .into_iter()
1124                .collect::<std::collections::HashMap<_, _>>(),
1125        )
1126        .unwrap();
1127
1128        // Dylib crate Cargo.toml - only dylib crate-type, with feature forwarding to base crate
1129        // On Windows, we currently disable dylib compilation due to https://github.com/bevyengine/bevy/pull/2016
1130        let dylib_features_section = feature_names
1131            .iter()
1132            .map(|f| format!("{f} = [\"{project_name}/{f}\"]"))
1133            .collect::<Vec<_>>()
1134            .join("\n");
1135
1136        // The dylib is the one shared artifact cargo does not name per configuration (unless
1137        // `__CARGO_DEFAULT_LIB_METADATA` is set), so `hydro_concurrent_cargo` decides its
1138        // `[lib] name` and only changes it under the exclusive prebuild lock, once every
1139        // in-flight final build that links the current name has finished. Reuse whatever name
1140        // the manifest has now; a fresh project gets this configuration's name. The package
1141        // name (and so `Cargo.lock`) never changes, and generated code never names the target
1142        // — it reaches the dylib through the `dylib-examples` dependency rename below.
1143        let dylib_lib_name = hydro_concurrent_cargo::current_dylib_lib_name(project.dir.as_ref())
1144            .unwrap_or_else(|| {
1145                hydro_concurrent_cargo::dylib_lib_name(&dylib_package_name, ENCODED_RUSTFLAGS)
1146            });
1147
1148        let dylib_manifest = format!(
1149            r#"[package]
1150name = "{dylib_package_name}"
1151version = "0.0.0"
1152{}
1153
1154[lib]
1155name = "{dylib_lib_name}"
1156crate-type = ["{}"]
1157
1158[dependencies]
1159{project_name} = {{ path = "..", default-features = false }}
1160
1161[features]
1162{dylib_features_section}
1163"#,
1164            serialized_edition,
1165            if cfg!(target_os = "windows") {
1166                "rlib"
1167            } else {
1168                "dylib"
1169            }
1170        );
1171        write_atomic(dylib_manifest.as_ref(), &path!(dylib_dir / "Cargo.toml"))?;
1172
1173        let dylib_examples_dir = path!(project.dir / "dylib-examples");
1174        fs::create_dir_all(path!(dylib_examples_dir / "src"))?;
1175        fs::create_dir_all(path!(dylib_examples_dir / "examples"))?;
1176
1177        write_atomic(
1178            b"#![allow(unused_crate_dependencies)]\n",
1179            &path!(dylib_examples_dir / "src" / "lib.rs"),
1180        )?;
1181
1182        // Build feature forwarding for dylib-examples - forward through the (renamed) dylib crate
1183        let features_section = feature_names
1184            .iter()
1185            .map(|f| format!("{f} = [\"{project_name}/{f}\"]"))
1186            .collect::<Vec<_>>()
1187            .join("\n");
1188
1189        // Dylib-examples crate Cargo.toml - depends *only* on the dylib crate, renamed to the
1190        // base crate's package name so that generated examples referencing
1191        // `{crate}_hydro_trybuild` resolve to the dylib. This is what makes dynamic linking
1192        // actually kick in: if the base crate were also a direct dependency, rustc would
1193        // statically link its rlib (and the entire dependency graph) into every example,
1194        // making the per-example "final compile" link take several seconds. With only the
1195        // dylib in scope, examples link against the prebuilt shared library instead.
1196        //
1197        // The dylib is a regular dependency (not a dev-dependency) so that prebuilding this
1198        // crate's (empty) lib builds the dylib as a dependency, which is required for cargo
1199        // to pass `-C prefer-dynamic` (see the prebuild in `compile_trybuild_example`).
1200        let dylib_examples_manifest = format!(
1201            r#"[package]
1202name = "{project_name}-dylib-examples"
1203version = "0.0.0"
1204{}
1205
1206[dependencies]
1207{project_name} = {{ package = "{project_name}-dylib", path = "../dylib", default-features = false }}
1208
1209[features]
1210{features_section}
1211
1212[[example]]
1213name = "sim-dylib"
1214crate-type = ["cdylib"]
1215"#,
1216            serialized_edition
1217        );
1218        write_atomic(
1219            dylib_examples_manifest.as_ref(),
1220            &path!(dylib_examples_dir / "Cargo.toml"),
1221        )?;
1222
1223        // sim-dylib.rs for the base crate and dylib-examples crate
1224        let sim_dylib_contents = prettyplease::unparse(&syn::parse_quote! {
1225            #![allow(unused_imports, unused_crate_dependencies, missing_docs, non_snake_case, unexpected_cfgs, unfulfilled_lint_expectations)]
1226            include!(std::concat!(env!("TRYBUILD_LIB_NAME"), ".rs"));
1227        });
1228        write_atomic(
1229            sim_dylib_contents.as_bytes(),
1230            &path!(project.dir / "examples" / "sim-dylib.rs"),
1231        )?;
1232        write_atomic(
1233            sim_dylib_contents.as_bytes(),
1234            &path!(dylib_examples_dir / "examples" / "sim-dylib.rs"),
1235        )?;
1236
1237        let workspace_manifest = format!(
1238            r#"{}
1239[[example]]
1240name = "sim-dylib"
1241crate-type = ["cdylib"]
1242
1243[workspace]
1244members = ["dylib", "dylib-examples"]
1245"#,
1246            base_manifest,
1247        );
1248
1249        write_atomic(
1250            workspace_manifest.as_ref(),
1251            &path!(project.dir / "Cargo.toml"),
1252        )?;
1253
1254        // Compute hash for cache invalidation, covering all generated manifests (the dylib and
1255        // dylib-examples manifests affect Cargo.lock, so they must participate in the hash).
1256        // The dylib's target name does not affect Cargo.lock (only package names do), so it is
1257        // excluded: otherwise every toolchain/rustflags switch would pay for a lockfile
1258        // regeneration.
1259        let manifest_hash = {
1260            let mut hasher = Sha256::new();
1261            hasher.update(&workspace_manifest);
1262            hasher.update(dylib_manifest.replace(&dylib_lib_name, ""));
1263            hasher.update(&dylib_examples_manifest);
1264            format!("{:X}", hasher.finalize())
1265                .chars()
1266                .take(8)
1267                .collect::<String>()
1268        };
1269
1270        let workspace_cargo_lock = path!(project.workspace / "Cargo.lock");
1271        let workspace_cargo_lock_contents_and_hash = if workspace_cargo_lock.exists() {
1272            let cargo_lock_contents = fs::read_to_string(&workspace_cargo_lock)?;
1273
1274            let hash = format!("{:X}", Sha256::digest(&cargo_lock_contents))
1275                .chars()
1276                .take(8)
1277                .collect::<String>();
1278
1279            Some((cargo_lock_contents, hash))
1280        } else {
1281            None
1282        };
1283
1284        let trybuild_hash = format!(
1285            "{}-{}",
1286            manifest_hash,
1287            workspace_cargo_lock_contents_and_hash
1288                .as_ref()
1289                .map(|(_contents, hash)| &**hash)
1290                .unwrap_or_default()
1291        );
1292
1293        if !check_contents(
1294            trybuild_hash.as_bytes(),
1295            &path!(project.dir / ".hydro-trybuild-manifest"),
1296        )
1297        .is_ok_and(|b| b)
1298        {
1299            let _span = tracing::debug_span!(target: "hydro_build", "update_lockfile").entered();
1300            // this is expensive, so we only do it if the manifest changed
1301            if let Some((cargo_lock_contents, _)) = workspace_cargo_lock_contents_and_hash {
1302                // only overwrite when the hash changed, because writing Cargo.lock must be
1303                // immediately followed by a local `cargo update -w`
1304                write_atomic(
1305                    cargo_lock_contents.as_ref(),
1306                    &path!(project.dir / "Cargo.lock"),
1307                )?;
1308            } else {
1309                let _ = cargo::cargo(&project).arg("generate-lockfile").status();
1310            }
1311
1312            // not `--offline` because some new runtime features may be enabled
1313            std::process::Command::new("cargo")
1314                .current_dir(&project.dir)
1315                .args(["update", "-w"]) // -w to not actually update any versions
1316                .stdout(std::process::Stdio::null())
1317                .stderr(std::process::Stdio::null())
1318                .status()
1319                .unwrap();
1320
1321            write_atomic(
1322                trybuild_hash.as_bytes(),
1323                &path!(project.dir / ".hydro-trybuild-manifest"),
1324            )?;
1325        }
1326
1327        // Create examples folder for base crate (static linking)
1328        let examples_folder = path!(project.dir / "examples");
1329        fs::create_dir_all(&examples_folder)?;
1330
1331        let workspace_dot_cargo_config_toml = path!(project.workspace / ".cargo" / "config.toml");
1332        if workspace_dot_cargo_config_toml.exists() {
1333            let dot_cargo_folder = path!(project.dir / ".cargo");
1334            fs::create_dir_all(&dot_cargo_folder)?;
1335
1336            write_atomic(
1337                fs::read_to_string(&workspace_dot_cargo_config_toml)?.as_ref(),
1338                &path!(dot_cargo_folder / "config.toml"),
1339            )?;
1340        }
1341
1342        let vscode_folder = path!(project.dir / ".vscode");
1343        fs::create_dir_all(&vscode_folder)?;
1344        write_atomic(
1345            include_bytes!("./vscode-trybuild.json"),
1346            &path!(vscode_folder / "settings.json"),
1347        )?;
1348    }
1349
1350    Ok((
1351        project.dir.as_ref().into(),
1352        project.target_dir.as_ref().into(),
1353        dylib_package_name,
1354        project.features,
1355    ))
1356}
1357
1358fn check_contents(contents: &[u8], path: &Path) -> Result<bool, std::io::Error> {
1359    let mut file = File::options()
1360        .read(true)
1361        .write(false)
1362        .create(false)
1363        .truncate(false)
1364        .open(path)?;
1365    file.lock()?;
1366
1367    let mut existing_contents = Vec::new();
1368    file.read_to_end(&mut existing_contents)?;
1369    Ok(existing_contents == contents)
1370}
1371
1372pub(crate) fn write_atomic(contents: &[u8], path: &Path) -> Result<(), std::io::Error> {
1373    let mut file = File::options()
1374        .read(true)
1375        .write(true)
1376        .create(true)
1377        .truncate(false)
1378        .open(path)?;
1379
1380    let mut existing_contents = Vec::new();
1381    file.read_to_end(&mut existing_contents)?;
1382    if existing_contents != contents {
1383        file.lock()?;
1384        file.seek(SeekFrom::Start(0))?;
1385        file.set_len(0)?;
1386        file.write_all(contents)?;
1387    }
1388
1389    Ok(())
1390}