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