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