Skip to main content

ch_rds/dev/
mod.rs

1use std::collections::{BTreeSet, HashMap, VecDeque};
2use std::env;
3use std::ffi::{OsStr, OsString};
4use std::fs;
5use std::io::{Read, Write};
6use std::net::{SocketAddr, TcpStream};
7use std::path::{Path, PathBuf};
8use std::process::{self, Command as ProcessCommand};
9use std::thread;
10use std::time::{Duration, Instant, SystemTime};
11
12#[cfg(feature = "btcpay")]
13mod btcpay;
14
15#[cfg(feature = "btcpay")]
16pub use self::btcpay::BtcpayDevProfile;
17#[cfg(feature = "btcpay")]
18use self::btcpay::{LocalBtcpayBootstrapOptions, bootstrap_local_btcpay};
19use clap::{Args, Parser, Subcommand};
20use color_eyre::eyre::{Context, bail, ensure};
21use serde::Deserialize;
22use serde_json::Value;
23
24/// Result type returned by development workflow operations.
25pub type DevResult<T = ()> = color_eyre::Result<T>;
26
27#[derive(Parser, Debug)]
28#[command(about = "Build and run the current application's backend and web frontend.")]
29struct AppCli {
30    #[command(subcommand)]
31    command: AppCommand,
32}
33
34#[derive(Subcommand, Debug)]
35enum AppCommand {
36    /// Run the backend and web development server with automatic reloads.
37    Watch(AppWatchCommand),
38    /// Build the web assets, then run the backend.
39    Serve(AppRunCommand),
40    /// Build the web assets without starting the backend.
41    Build(AppBuildCommand),
42    /// Rebuild one generated browser asset from the active project profile.
43    #[command(hide = true)]
44    BuildPublicAsset(AppPublicAssetBuildCommand),
45}
46
47#[derive(Args, Debug)]
48struct AppRunCommand {
49    #[command(flatten)]
50    optional_assets: OptionalAssetsArgs,
51    #[arg(long)]
52    release: bool,
53    #[arg(short, long, action = clap::ArgAction::Count)]
54    verbose: u8,
55}
56
57#[derive(Args, Debug, Default)]
58struct OptionalAssetsArgs {
59    /// Include assets registered as optional by the project profile.
60    #[arg(long = "with-optional-assets", alias = "editor")]
61    include_optional_assets: bool,
62}
63
64#[derive(Args, Debug)]
65struct AppWatchCommand {
66    #[command(flatten)]
67    run: AppRunCommand,
68    /// Start the backend once instead of rebuilding it for every watched Rust change.
69    #[arg(long)]
70    no_backend_reload: bool,
71}
72
73#[derive(Args, Debug)]
74struct AppBuildCommand {
75    #[command(flatten)]
76    optional_assets: OptionalAssetsArgs,
77    #[arg(short, long, action = clap::ArgAction::Count)]
78    verbose: u8,
79}
80
81#[derive(Args, Debug)]
82struct AppPublicAssetBuildCommand {
83    #[arg(long)]
84    id: String,
85    #[arg(long)]
86    release: bool,
87    /// Override this asset's Cargo output directory with an absolute path.
88    #[arg(long)]
89    target_dir: Option<PathBuf>,
90}
91
92#[derive(Clone, Debug)]
93/// Describes the backend, web client, and generated assets of one project.
94pub struct ProjectProfile {
95    app_id: String,
96    web_dir: String,
97    bin_target: Option<String>,
98    api_addr: String,
99    backend_ready_timeout: Duration,
100    backend_ready_path: Option<String>,
101    backend_target_dir: Option<String>,
102    web_backend_origin_env: Option<String>,
103    #[cfg(feature = "btcpay")]
104    btcpay: Option<BtcpayDevProfile>,
105    public_assets: Vec<ProjectPublicAsset>,
106    optional_public_assets: Vec<ProjectPublicAsset>,
107}
108
109#[derive(Clone, Debug, Deserialize)]
110#[serde(tag = "kind", rename_all = "kebab-case")]
111/// A browser asset that must be built before the web client is served.
112pub enum ProjectPublicAsset {
113    /// A Rust package compiled to WebAssembly and processed by `wasm-bindgen`.
114    WasmBindgen(WasmBindgenPublicAsset),
115}
116
117#[derive(Clone, Debug, Deserialize)]
118/// Build settings for a public WebAssembly asset produced by `wasm-bindgen`.
119pub struct WasmBindgenPublicAsset {
120    id: String,
121    label: String,
122    package: String,
123    wasm_file: String,
124    #[serde(default)]
125    no_default_features: bool,
126    #[serde(default)]
127    features: String,
128    #[serde(default)]
129    target_dir: Option<String>,
130    out_dir: Vec<String>,
131    out_name: String,
132    wasm_bindgen_version: String,
133}
134
135#[derive(Clone, Copy, Debug)]
136/// Selects how the backend and web client are run after preparation.
137pub enum RunMode {
138    /// Run both processes in development mode and rebuild on source changes.
139    Watch,
140    /// Build the web client once, then serve it from the backend.
141    Serve,
142}
143
144#[derive(Debug)]
145/// Resolved workspace, project, and web-client directories for a project.
146pub struct WorkspacePaths {
147    workspace_root: PathBuf,
148    project_root: PathBuf,
149    web_root: PathBuf,
150}
151
152#[derive(Debug, Deserialize)]
153struct CargoMetadata {
154    packages: Vec<CargoPackage>,
155    resolve: Option<CargoResolve>,
156}
157
158#[derive(Debug, Deserialize)]
159struct CargoPackage {
160    id: String,
161    name: String,
162    source: Option<String>,
163    manifest_path: PathBuf,
164    targets: Vec<CargoTarget>,
165}
166
167#[derive(Debug, Deserialize)]
168struct CargoTarget {
169    kind: Vec<String>,
170    name: String,
171    src_path: PathBuf,
172}
173
174#[derive(Debug, Deserialize)]
175struct CargoResolve {
176    nodes: Vec<CargoResolveNode>,
177}
178
179#[derive(Debug, Deserialize)]
180struct CargoResolveNode {
181    id: String,
182    deps: Vec<CargoResolveDependency>,
183}
184
185#[derive(Debug, Deserialize)]
186struct CargoResolveDependency {
187    pkg: String,
188}
189
190#[derive(Debug, Default, Eq, PartialEq)]
191struct BackendWatchPaths {
192    paths: Vec<PathBuf>,
193}
194
195#[derive(Clone, Copy, Debug, Eq, PartialEq)]
196struct BackendArtifactStamp {
197    modified: SystemTime,
198    length: u64,
199}
200
201/// Owns a child process so every return path terminates and reaps it.
202#[derive(Debug)]
203struct ManagedChild {
204    child: process::Child,
205}
206
207impl std::ops::Deref for ManagedChild {
208    type Target = process::Child;
209    fn deref(&self) -> &Self::Target {
210        &self.child
211    }
212}
213
214impl std::ops::DerefMut for ManagedChild {
215    fn deref_mut(&mut self) -> &mut Self::Target {
216        &mut self.child
217    }
218}
219
220impl ManagedChild {
221    fn terminate(&mut self) {
222        let _ = self.child.kill();
223        let _ = self.child.wait();
224    }
225}
226
227impl Drop for ManagedChild {
228    fn drop(&mut self) {
229        self.terminate();
230    }
231}
232
233#[derive(Debug)]
234struct BackendWatchRuntime {
235    process: ManagedChild,
236    builder: Option<ManagedChild>,
237    artifact: Option<PathBuf>,
238    active_image: Option<PathBuf>,
239    active_stamp: Option<BackendArtifactStamp>,
240    generation: u64,
241}
242
243const DEFAULT_BACKEND_READY_TIMEOUT: Duration = Duration::from_secs(300);
244const BACKEND_READY_POLL_INTERVAL: Duration = Duration::from_millis(250);
245const BACKEND_READY_CONNECT_TIMEOUT: Duration = Duration::from_millis(200);
246const WATCH_DEBOUNCE: &str = "750ms";
247
248impl ProjectProfile {
249    /// Creates a project profile from its explicit project id and backend bind address.
250    ///
251    /// The backend binary target defaults to the same project id. Callers can
252    /// override it when the Cargo binary intentionally uses a different name.
253    pub fn new(app_id: impl Into<String>, api_addr: impl Into<String>) -> Self {
254        Self {
255            app_id: app_id.into(),
256            web_dir: "web".to_string(),
257            bin_target: None,
258            api_addr: api_addr.into(),
259            backend_ready_timeout: DEFAULT_BACKEND_READY_TIMEOUT,
260            backend_ready_path: None,
261            backend_target_dir: None,
262            web_backend_origin_env: None,
263            #[cfg(feature = "btcpay")]
264            btcpay: None,
265            public_assets: Vec::new(),
266            optional_public_assets: Vec::new(),
267        }
268    }
269
270    /// Overrides the web-client directory relative to the project root.
271    pub fn with_web_dir(mut self, web_dir: impl Into<String>) -> Self {
272        self.web_dir = web_dir.into();
273        self
274    }
275
276    /// Overrides the backend binary when it does not follow the app repository prefix.
277    pub fn with_bin_target(mut self, bin_target: impl Into<String>) -> Self {
278        self.bin_target = Some(bin_target.into());
279        self
280    }
281
282    /// Overrides how long `watch` waits for a cold backend build to open its listener.
283    pub fn with_backend_ready_timeout(mut self, timeout: Duration) -> Self {
284        self.backend_ready_timeout = timeout;
285        self
286    }
287
288    /// Waits for an HTTP response from this path before activating a backend build.
289    pub fn with_backend_ready_path(mut self, path: impl Into<String>) -> Self {
290        self.backend_ready_path = Some(path.into());
291        self
292    }
293
294    /// Isolates backend builds from other Cargo processes in the same workspace.
295    pub fn with_backend_target_dir(mut self, target_dir: impl Into<String>) -> Self {
296        self.backend_target_dir = Some(target_dir.into());
297        self
298    }
299
300    /// Sets the environment variable used to expose the backend origin to the web client.
301    pub fn with_web_backend_origin_env(mut self, key: impl Into<String>) -> Self {
302        self.web_backend_origin_env = Some(key.into());
303        self
304    }
305
306    #[cfg(feature = "btcpay")]
307    /// Attaches typed settings for the optional local BTCPay development stack.
308    pub fn with_btcpay_profile(mut self, profile: BtcpayDevProfile) -> Self {
309        self.btcpay = Some(profile);
310        self
311    }
312
313    /// Registers an additional browser asset to build before the web client.
314    pub fn with_public_asset(mut self, asset: ProjectPublicAsset) -> Self {
315        self.public_assets.push(asset);
316        self
317    }
318
319    /// Registers a browser asset that is built only when optional assets are requested.
320    pub fn with_optional_public_asset(mut self, asset: ProjectPublicAsset) -> Self {
321        self.optional_public_assets.push(asset);
322        self
323    }
324
325    /// Returns the explicit project identifier.
326    pub fn app_id(&self) -> &str {
327        &self.app_id
328    }
329
330    /// Returns the configured backend binary target or the project identifier.
331    pub fn bin_target(&self) -> &str {
332        self.bin_target.as_deref().unwrap_or(&self.app_id)
333    }
334
335    /// Validates the profile and annotates failures with the supplied source label.
336    pub fn validate(&self, source: &str) -> DevResult {
337        ensure!(
338            !self.app_id.trim().is_empty(),
339            "{source} contains an empty app id"
340        );
341        ensure!(
342            !self.web_dir.trim().is_empty(),
343            "{source} project `{}` has an empty web_dir",
344            self.app_id
345        );
346        ensure!(
347            !self.bin_target().trim().is_empty(),
348            "{source} project `{}` has an empty bin_target",
349            self.app_id
350        );
351        ensure!(
352            !self.backend_ready_timeout.is_zero(),
353            "{source} project `{}` has a zero backend readiness timeout",
354            self.app_id
355        );
356        if let Some(path) = &self.backend_ready_path {
357            ensure!(
358                path.starts_with('/') && !path.chars().any(char::is_control),
359                "{source} project `{}` has an invalid backend readiness path",
360                self.app_id
361            );
362        }
363        if let Some(target_dir) = &self.backend_target_dir {
364            ensure!(
365                !target_dir.trim().is_empty(),
366                "{source} project `{}` has an empty backend target directory",
367                self.app_id
368            );
369        }
370        api_addr(self)?;
371
372        for asset in &self.public_assets {
373            asset.validate(source, &self.app_id)?;
374        }
375        for asset in &self.optional_public_assets {
376            asset.validate(source, &self.app_id)?;
377        }
378        #[cfg(feature = "btcpay")]
379        {
380            if let Some(profile) = &self.btcpay {
381                profile.validate().map_err(|error| {
382                    color_eyre::eyre::eyre!(
383                        "{source} project `{}` has an invalid BTCPay profile: {error:#}",
384                        self.app_id
385                    )
386                })?;
387            }
388        }
389
390        Ok(())
391    }
392}
393
394impl ProjectPublicAsset {
395    /// Creates a `wasm-bindgen` asset build from explicit package and output settings.
396    pub fn wasm_bindgen(
397        id: impl Into<String>,
398        label: impl Into<String>,
399        package: impl Into<String>,
400        wasm_file: impl Into<String>,
401        out_dir: impl Into<Vec<String>>,
402        out_name: impl Into<String>,
403        wasm_bindgen_version: impl Into<String>,
404    ) -> Self {
405        Self::WasmBindgen(WasmBindgenPublicAsset {
406            id: id.into(),
407            label: label.into(),
408            package: package.into(),
409            wasm_file: wasm_file.into(),
410            no_default_features: false,
411            features: String::new(),
412            target_dir: None,
413            out_dir: out_dir.into(),
414            out_name: out_name.into(),
415            wasm_bindgen_version: wasm_bindgen_version.into(),
416        })
417    }
418
419    /// Validates this asset for the named project and configuration source.
420    pub fn validate(&self, source: &str, project_id: &str) -> DevResult {
421        match self {
422            Self::WasmBindgen(asset) => asset.validate(source, project_id),
423        }
424    }
425
426    /// Controls whether Cargo default features are disabled for this asset build.
427    pub fn with_no_default_features(mut self, no_default_features: bool) -> Self {
428        match &mut self {
429            Self::WasmBindgen(asset) => {
430                asset.no_default_features = no_default_features;
431            }
432        }
433        self
434    }
435
436    /// Sets the comma-separated Cargo feature list used for this asset build.
437    pub fn with_features(mut self, features: impl Into<String>) -> Self {
438        match &mut self {
439            Self::WasmBindgen(asset) => {
440                asset.features = features.into();
441            }
442        }
443        self
444    }
445
446    /// Sets the Cargo target directory relative to the project root.
447    pub fn with_target_dir(mut self, target_dir: impl Into<String>) -> Self {
448        match &mut self {
449            Self::WasmBindgen(asset) => {
450                asset.target_dir = Some(target_dir.into());
451            }
452        }
453        self
454    }
455
456    fn id(&self) -> &str {
457        match self {
458            Self::WasmBindgen(asset) => &asset.id,
459        }
460    }
461
462    fn package(&self) -> &str {
463        match self {
464            Self::WasmBindgen(asset) => &asset.package,
465        }
466    }
467}
468
469impl WasmBindgenPublicAsset {
470    fn validate(&self, source: &str, project_id: &str) -> DevResult {
471        ensure!(
472            !self.id.trim().is_empty(),
473            "{source} project `{project_id}` has a wasm-bindgen asset with an empty id"
474        );
475        ensure!(
476            !self.label.trim().is_empty(),
477            "{source} project `{project_id}` asset `{}` has an empty label",
478            self.id
479        );
480        ensure!(
481            !self.package.trim().is_empty(),
482            "{source} project `{project_id}` asset `{}` has an empty package",
483            self.id
484        );
485        ensure!(
486            !self.wasm_file.trim().is_empty(),
487            "{source} project `{project_id}` asset `{}` has an empty wasm_file",
488            self.id
489        );
490        ensure!(
491            !self.out_dir.is_empty(),
492            "{source} project `{project_id}` asset `{}` must declare out_dir",
493            self.id
494        );
495        ensure!(
496            !self.out_name.trim().is_empty(),
497            "{source} project `{project_id}` asset `{}` has an empty out_name",
498            self.id
499        );
500        ensure!(
501            !self.wasm_bindgen_version.trim().is_empty(),
502            "{source} project `{project_id}` asset `{}` has an empty wasm_bindgen_version",
503            self.id
504        );
505        if let Some(target_dir) = &self.target_dir {
506            let target_dir = Path::new(target_dir);
507            ensure!(
508                target_dir.is_relative()
509                    && target_dir
510                        .components()
511                        .all(|component| matches!(component, std::path::Component::Normal(_))),
512                "{source} project `{project_id}` asset `{}` has an invalid target_dir",
513                self.id
514            );
515        }
516        Ok(())
517    }
518}
519
520impl WorkspacePaths {
521    /// Returns the Cargo workspace root.
522    pub fn workspace_root(&self) -> &Path {
523        &self.workspace_root
524    }
525
526    /// Returns the project repository root.
527    pub fn project_root(&self) -> &Path {
528        &self.project_root
529    }
530
531    /// Returns the web-client root.
532    pub fn web_root(&self) -> &Path {
533        &self.web_root
534    }
535}
536
537/// Runs the selected development task for `profile`.
538pub fn run_project(profile: ProjectProfile, manifest_dir: impl AsRef<Path>) -> DevResult {
539    color_eyre::install()?;
540    match AppCli::parse().command {
541        AppCommand::Watch(command) => {
542            let profile = activate_optional_public_assets(
543                profile,
544                command.run.optional_assets.include_optional_assets,
545            );
546            let paths = resolve_paths_from_manifest_dir(&profile, manifest_dir.as_ref())?;
547            run_profile_with_backend_reload(
548                &profile,
549                &paths,
550                command.run.release,
551                RunMode::Watch,
552                !command.no_backend_reload,
553            )
554        }
555        AppCommand::Serve(command) => {
556            let profile = activate_optional_public_assets(
557                profile,
558                command.optional_assets.include_optional_assets,
559            );
560            let paths = resolve_paths_from_manifest_dir(&profile, manifest_dir.as_ref())?;
561            run_profile(&profile, &paths, command.release, RunMode::Serve)
562        }
563        AppCommand::Build(command) => {
564            let profile = activate_optional_public_assets(
565                profile,
566                command.optional_assets.include_optional_assets,
567            );
568            let paths = resolve_paths_from_manifest_dir(&profile, manifest_dir.as_ref())?;
569            build_profile(&profile, &paths, true)
570        }
571        AppCommand::BuildPublicAsset(command) => {
572            let paths = resolve_paths_from_manifest_dir(&profile, manifest_dir.as_ref())?;
573            let asset = profile
574                .public_assets
575                .iter()
576                .chain(&profile.optional_public_assets)
577                .find(|asset| asset.id() == command.id)
578                .ok_or_else(|| {
579                    color_eyre::eyre::eyre!(
580                        "project `{}` has no public asset named `{}`",
581                        profile.app_id,
582                        command.id
583                    )
584                })?;
585            match asset {
586                ProjectPublicAsset::WasmBindgen(asset) => build_wasm_bindgen_public_asset(
587                    &profile,
588                    &paths,
589                    asset,
590                    command.release,
591                    command.target_dir.as_deref(),
592                ),
593            }
594        }
595    }
596}
597
598fn activate_optional_public_assets(
599    mut profile: ProjectProfile,
600    include_optional_assets: bool,
601) -> ProjectProfile {
602    if include_optional_assets {
603        profile
604            .public_assets
605            .append(&mut profile.optional_public_assets);
606    }
607    profile
608}
609
610/// Resolves project paths by walking upward from a Cargo manifest directory.
611pub fn resolve_paths_from_manifest_dir(
612    profile: &ProjectProfile,
613    manifest_dir: &Path,
614) -> DevResult<WorkspacePaths> {
615    let project_root = find_project_root_from_manifest_dir(manifest_dir, &profile.web_dir)?;
616    let workspace_root = find_workspace_root(&project_root)?;
617    let web_root = project_root.join(&profile.web_dir);
618    validate_paths(&project_root, &web_root)?;
619    Ok(WorkspacePaths {
620        workspace_root,
621        project_root,
622        web_root,
623    })
624}
625
626/// Resolves project paths from a repository directory beneath the current workspace.
627pub fn resolve_paths_from_workspace_project(
628    profile: &ProjectProfile,
629    repo_dir: &str,
630) -> DevResult<WorkspacePaths> {
631    let cwd = env::current_dir().wrap_err("failed to resolve current directory")?;
632    let workspace_root = find_workspace_root(&cwd)?;
633    let project_root = workspace_root.join(repo_dir);
634    let web_root = project_root.join(&profile.web_dir);
635    validate_paths(&project_root, &web_root)?;
636    Ok(WorkspacePaths {
637        workspace_root,
638        project_root,
639        web_root,
640    })
641}
642
643/// Prepares a project and runs it in the requested mode.
644pub fn run_profile(
645    profile: &ProjectProfile,
646    paths: &WorkspacePaths,
647    release: bool,
648    mode: RunMode,
649) -> DevResult {
650    run_profile_with_backend_reload(profile, paths, release, mode, true)
651}
652
653fn run_profile_with_backend_reload(
654    profile: &ProjectProfile,
655    paths: &WorkspacePaths,
656    release: bool,
657    mode: RunMode,
658    backend_reload: bool,
659) -> DevResult {
660    install_node_dependencies(&paths.web_root)?;
661    bootstrap_project_dev_services(profile, paths, release)?;
662
663    match mode {
664        RunMode::Watch => {
665            build_project_public_assets(profile, paths, release)?;
666            watch_project(profile, paths, release, backend_reload)
667        }
668        RunMode::Serve => {
669            build_project_public_assets(profile, paths, release)?;
670            build_react(&paths.web_root)?;
671            start_backend(profile, paths, release)
672        }
673    }
674}
675
676/// Builds registered public assets and the React client without starting the backend.
677pub fn build_profile(profile: &ProjectProfile, paths: &WorkspacePaths, release: bool) -> DevResult {
678    install_node_dependencies(&paths.web_root)?;
679    build_project_public_assets(profile, paths, release)?;
680    build_react(&paths.web_root)
681}
682
683fn find_project_root_from_manifest_dir(manifest_dir: &Path, web_dir: &str) -> DevResult<PathBuf> {
684    let start = if manifest_dir.is_absolute() {
685        manifest_dir.to_path_buf()
686    } else {
687        env::current_dir()
688            .wrap_err("failed to resolve current directory")?
689            .join(manifest_dir)
690    };
691
692    let mut current = Some(start.as_path());
693    while let Some(candidate) = current {
694        if candidate.join("Cargo.toml").is_file()
695            && candidate.join(web_dir).join("package.json").is_file()
696        {
697            return Ok(candidate.to_path_buf());
698        }
699        current = candidate.parent();
700    }
701
702    bail!(
703        "failed to resolve app project root from {}",
704        manifest_dir.display()
705    );
706}
707
708fn validate_paths(project_root: &Path, web_root: &Path) -> DevResult {
709    if !project_root.join("Cargo.toml").is_file() {
710        bail!(
711            "project manifest is missing at {}",
712            project_root.join("Cargo.toml").display()
713        );
714    }
715    if !web_root.join("package.json").is_file() {
716        bail!(
717            "web package manifest is missing at {}",
718            web_root.join("package.json").display()
719        );
720    }
721    Ok(())
722}
723
724#[cfg(feature = "btcpay")]
725fn bootstrap_project_dev_services(
726    profile: &ProjectProfile,
727    paths: &WorkspacePaths,
728    release: bool,
729) -> DevResult {
730    let Some(btcpay_profile) = profile.btcpay.clone() else {
731        return Ok(());
732    };
733    if release {
734        return Ok(());
735    }
736
737    let options = LocalBtcpayBootstrapOptions::new(btcpay_profile, &paths.project_root);
738    let summary = bootstrap_local_btcpay(&options)
739        .map_err(|error| color_eyre::eyre::eyre!("failed to bootstrap local BTCPay: {error:#}"))?;
740    eprintln!(
741        "BTCPay local dev bootstrap ready for {}: {} (store {}, api {}, webhook {}, env {})",
742        summary.app_display_name,
743        summary.url,
744        summary.store_id,
745        summary.api_key_masked,
746        summary.webhook_secret_masked,
747        summary.env_file.display()
748    );
749    Ok(())
750}
751
752#[cfg(not(feature = "btcpay"))]
753fn bootstrap_project_dev_services(
754    _profile: &ProjectProfile,
755    _paths: &WorkspacePaths,
756    _release: bool,
757) -> DevResult {
758    Ok(())
759}
760
761fn find_workspace_root(start: &Path) -> DevResult<PathBuf> {
762    let mut nearest_package = None;
763
764    for candidate in start.ancestors() {
765        let manifest_path = candidate.join("Cargo.toml");
766        if !manifest_path.is_file() {
767            continue;
768        }
769
770        let source = fs::read_to_string(&manifest_path).wrap_err_with(|| {
771            format!(
772                "failed to read Cargo manifest at {}",
773                manifest_path.display()
774            )
775        })?;
776        let manifest = toml::from_str::<toml::Table>(&source).wrap_err_with(|| {
777            format!(
778                "failed to parse Cargo manifest at {}",
779                manifest_path.display()
780            )
781        })?;
782
783        if manifest.contains_key("workspace") {
784            return Ok(candidate.to_path_buf());
785        }
786        if nearest_package.is_none() && manifest.contains_key("package") {
787            nearest_package = Some(candidate.to_path_buf());
788        }
789    }
790
791    nearest_package.ok_or_else(|| {
792        color_eyre::eyre::eyre!(
793            "failed to find a Cargo workspace or package manifest above {}",
794            start.display()
795        )
796    })
797}
798
799fn install_node_dependencies(web_root: &Path) -> DevResult {
800    if web_root.join("node_modules").is_dir() {
801        return Ok(());
802    }
803
804    run_command("Node dependency install", npm(), &["ci"], web_root, &[])
805}
806
807fn build_react(web_root: &Path) -> DevResult {
808    let script = resolve_build_script(web_root)?;
809    run_command("Web build", npm(), &["run", script], web_root, &[])
810}
811
812fn build_project_public_assets(
813    profile: &ProjectProfile,
814    paths: &WorkspacePaths,
815    release: bool,
816) -> DevResult {
817    for asset in &profile.public_assets {
818        match asset {
819            ProjectPublicAsset::WasmBindgen(asset) => {
820                build_wasm_bindgen_public_asset(profile, paths, asset, release, None)?;
821            }
822        }
823    }
824
825    Ok(())
826}
827
828fn resolve_public_asset_target_dir(
829    profile: &ProjectProfile,
830    paths: &WorkspacePaths,
831    asset: &WasmBindgenPublicAsset,
832    target_dir_override: Option<&Path>,
833) -> DevResult<PathBuf> {
834    if let Some(target_dir) = target_dir_override {
835        ensure!(
836            target_dir.is_absolute()
837                && target_dir.components().all(|component| matches!(
838                    component,
839                    std::path::Component::Prefix(_)
840                        | std::path::Component::RootDir
841                        | std::path::Component::Normal(_)
842                ))
843                && target_dir
844                    .components()
845                    .any(|component| matches!(component, std::path::Component::Normal(_))),
846            "public asset --target-dir must be a nonempty absolute normal path"
847        );
848        return Ok(target_dir.to_path_buf());
849    }
850    Ok(asset.target_dir.as_ref().map_or_else(
851        || {
852            paths
853                .project_root
854                .join("target")
855                .join(format!("{}-dev-{}-build", profile.app_id, asset.id))
856        },
857        |target_dir| paths.project_root.join(target_dir),
858    ))
859}
860
861fn build_wasm_bindgen_public_asset(
862    profile: &ProjectProfile,
863    paths: &WorkspacePaths,
864    asset: &WasmBindgenPublicAsset,
865    release: bool,
866    target_dir_override: Option<&Path>,
867) -> DevResult {
868    let target_dir = resolve_public_asset_target_dir(profile, paths, asset, target_dir_override)?;
869    let target_dir_arg = path_arg(&target_dir)?;
870    let mut args = vec![
871        "build",
872        "--lib",
873        "--package",
874        asset.package.as_str(),
875        "--locked",
876        "--target",
877        "wasm32-unknown-unknown",
878    ];
879    if release {
880        args.push("--release");
881    }
882    if asset.no_default_features {
883        args.push("--no-default-features");
884    }
885    if !asset.features.trim().is_empty() {
886        args.push("--features");
887        args.push(asset.features.as_str());
888    }
889    args.push("--target-dir");
890    args.push(target_dir_arg);
891
892    run_command(
893        format!("{} WASM build", asset.label).as_str(),
894        cargo(),
895        &args,
896        &paths.project_root,
897        &[],
898    )?;
899
900    let wasm_input = target_dir
901        .join("wasm32-unknown-unknown")
902        .join(if release { "release" } else { "debug" })
903        .join(&asset.wasm_file);
904    if !wasm_input.is_file() {
905        bail!(
906            "missing expected {} WASM at {}",
907            asset.label,
908            wasm_input.display()
909        );
910    }
911
912    let mut out_dir = paths.web_root.clone();
913    for segment in &asset.out_dir {
914        out_dir.push(segment);
915    }
916    let out_parent = out_dir.parent().ok_or_else(|| {
917        color_eyre::eyre::eyre!(
918            "{} WASM output has no parent directory: {}",
919            asset.label,
920            out_dir.display()
921        )
922    })?;
923    fs::create_dir_all(out_parent).wrap_err_with(|| {
924        format!(
925            "failed to create {} WASM output parent at {}",
926            asset.label,
927            out_parent.display()
928        )
929    })?;
930    let out_name = out_dir.file_name().ok_or_else(|| {
931        color_eyre::eyre::eyre!(
932            "{} WASM output has no directory name: {}",
933            asset.label,
934            out_dir.display()
935        )
936    })?;
937    let staging_dir = out_parent.join(format!(".{}.building", out_name.to_string_lossy()));
938    if staging_dir.exists() {
939        fs::remove_dir_all(&staging_dir).wrap_err_with(|| {
940            format!(
941                "failed to remove stale {} WASM staging output at {}",
942                asset.label,
943                staging_dir.display()
944            )
945        })?;
946    }
947    fs::create_dir_all(&staging_dir).wrap_err_with(|| {
948        format!(
949            "failed to create {} WASM staging output at {}",
950            asset.label,
951            staging_dir.display()
952        )
953    })?;
954
955    let wasm_input_arg = path_arg(&wasm_input)?;
956    let staging_dir_arg = path_arg(&staging_dir)?;
957    if let Err(error) = run_command(
958        format!("{} wasm-bindgen", asset.label).as_str(),
959        resolve_wasm_bindgen(&asset.wasm_bindgen_version)?,
960        &[
961            wasm_input_arg,
962            "--target",
963            "web",
964            "--out-dir",
965            staging_dir_arg,
966            "--out-name",
967            asset.out_name.as_str(),
968        ],
969        &paths.project_root,
970        &[],
971    ) {
972        let _ = fs::remove_dir_all(&staging_dir);
973        return Err(error);
974    }
975
976    if out_dir.exists() {
977        fs::remove_dir_all(&out_dir).wrap_err_with(|| {
978            format!(
979                "failed to replace previous {} WASM output at {}",
980                asset.label,
981                out_dir.display()
982            )
983        })?;
984    }
985    fs::rename(&staging_dir, &out_dir).wrap_err_with(|| {
986        format!(
987            "failed to activate {} WASM output from {} to {}",
988            asset.label,
989            staging_dir.display(),
990            out_dir.display()
991        )
992    })
993}
994
995fn resolve_build_script(web_root: &Path) -> DevResult<&'static str> {
996    let package_json_path = web_root.join("package.json");
997    let package_json = std::fs::read_to_string(&package_json_path)
998        .wrap_err_with(|| format!("failed to read {}", package_json_path.display()))?;
999    let package_json: Value = serde_json::from_str(&package_json)
1000        .wrap_err_with(|| format!("failed to parse {}", package_json_path.display()))?;
1001    let scripts = package_json
1002        .get("scripts")
1003        .and_then(Value::as_object)
1004        .ok_or_else(|| color_eyre::eyre::eyre!("package.json has no scripts object"))?;
1005
1006    if scripts.contains_key("build") {
1007        return Ok("build");
1008    }
1009    if scripts.contains_key("build:web") {
1010        return Ok("build:web");
1011    }
1012
1013    bail!(
1014        "{} must define either `build` or `build:web`",
1015        package_json_path.display()
1016    );
1017}
1018
1019fn start_backend(profile: &ProjectProfile, paths: &WorkspacePaths, release: bool) -> DevResult {
1020    let args = backend_args(profile, release);
1021    let envs = backend_envs(profile);
1022    run_command("Rust backend", cargo(), &args, &paths.project_root, &envs)
1023}
1024
1025fn watch_project(
1026    profile: &ProjectProfile,
1027    paths: &WorkspacePaths,
1028    release: bool,
1029    backend_reload: bool,
1030) -> DevResult {
1031    let api_addr = api_addr(profile)?;
1032    let backend_envs = backend_envs(profile);
1033    let mut backend = if backend_reload {
1034        let artifact = backend_executable_path(profile, paths, release);
1035        // The last artifact can predate source changes made while the watcher
1036        // was stopped. Build once before launching it so the first readiness
1037        // probe always exercises the current owner pipeline.
1038        let args = backend_build_args(profile, release);
1039        run_command(
1040            "initial Rust backend build",
1041            cargo(),
1042            &args,
1043            &paths.project_root,
1044            &backend_envs,
1045        )?;
1046        let active_stamp = backend_artifact_stamp(&artifact)?;
1047        let active_image = backend_runtime_image_path(profile, &artifact, 0);
1048        copy_backend_runtime_image(&artifact, &active_image)?;
1049        let mut process = spawn_command(
1050            "Rust backend",
1051            active_image.as_os_str().to_os_string(),
1052            &[] as &[&str],
1053            &paths.project_root,
1054            &[],
1055        )?;
1056        wait_for_backend_ready(
1057            &mut process,
1058            api_addr,
1059            profile.backend_ready_timeout,
1060            profile.backend_ready_path.as_deref(),
1061        )?;
1062
1063        let watchexec = resolve_watchexec()?;
1064        let watch_paths = resolve_backend_watch_paths(profile, paths)?;
1065        let backend_args = backend_watchexec_args(profile, paths, &watch_paths, release);
1066        let builder = spawn_command(
1067            "Rust backend build watcher",
1068            watchexec,
1069            &backend_args,
1070            &paths.project_root,
1071            &backend_envs,
1072        )?;
1073        BackendWatchRuntime {
1074            process,
1075            builder: Some(builder),
1076            artifact: Some(artifact),
1077            active_image: Some(active_image),
1078            active_stamp: Some(active_stamp),
1079            generation: 0,
1080        }
1081    } else {
1082        let backend_args = backend_args(profile, release);
1083        let mut process = spawn_command(
1084            "Rust backend",
1085            cargo(),
1086            &backend_args,
1087            &paths.project_root,
1088            &backend_envs,
1089        )?;
1090        wait_for_backend_ready(
1091            &mut process,
1092            api_addr,
1093            profile.backend_ready_timeout,
1094            profile.backend_ready_path.as_deref(),
1095        )?;
1096        BackendWatchRuntime {
1097            process,
1098            builder: None,
1099            artifact: None,
1100            active_image: None,
1101            active_stamp: None,
1102            generation: 0,
1103        }
1104    };
1105
1106    let mut public_asset_watchers = spawn_public_asset_watchers(profile, paths, release)?;
1107    let backend_origin = format!("http://127.0.0.1:{}", api_addr.port());
1108    let web_envs = web_dev_envs(profile, &backend_origin);
1109    let mut web = spawn_command(
1110        "web dev server",
1111        npm(),
1112        &["run", "dev"],
1113        &paths.web_root,
1114        &web_envs,
1115    )?;
1116
1117    wait_for_watch_processes(
1118        profile,
1119        paths,
1120        api_addr,
1121        &mut backend,
1122        &mut web,
1123        &mut public_asset_watchers,
1124    )
1125}
1126
1127fn wait_for_backend_ready(
1128    backend: &mut ManagedChild,
1129    api_addr: SocketAddr,
1130    ready_timeout: Duration,
1131    ready_path: Option<&str>,
1132) -> DevResult {
1133    let ready_addr = SocketAddr::from(([127, 0, 0, 1], api_addr.port()));
1134    let started_at = Instant::now();
1135
1136    loop {
1137        if let Some(status) = backend.try_wait().wrap_err("failed to poll Rust backend")? {
1138            if status.success() {
1139                bail!(
1140                    "Rust backend exited before it became ready at {}",
1141                    ready_addr
1142                );
1143            }
1144            bail!(
1145                "Rust backend exited with status {:?} before it became ready at {}",
1146                status.code(),
1147                ready_addr
1148            );
1149        }
1150
1151        let readiness = match ready_path {
1152            Some(path) => probe_backend_http(ready_addr, path),
1153            None => {
1154                TcpStream::connect_timeout(&ready_addr, BACKEND_READY_CONNECT_TIMEOUT).map(|_| ())
1155            }
1156        };
1157        let last_error = match readiness {
1158            Ok(()) => return Ok(()),
1159            Err(error) => error,
1160        };
1161
1162        if started_at.elapsed() >= ready_timeout {
1163            backend.terminate();
1164            bail!(
1165                "Rust backend did not become ready at {} within {:?}: {}",
1166                ready_addr,
1167                ready_timeout,
1168                last_error
1169            );
1170        }
1171
1172        thread::sleep(BACKEND_READY_POLL_INTERVAL);
1173    }
1174}
1175
1176fn probe_backend_http(ready_addr: SocketAddr, path: &str) -> std::io::Result<()> {
1177    let mut stream = TcpStream::connect_timeout(&ready_addr, BACKEND_READY_CONNECT_TIMEOUT)?;
1178    stream.set_read_timeout(Some(BACKEND_READY_CONNECT_TIMEOUT))?;
1179    stream.set_write_timeout(Some(BACKEND_READY_CONNECT_TIMEOUT))?;
1180    write!(
1181        stream,
1182        "GET {path} HTTP/1.1\r\nHost: {ready_addr}\r\nConnection: close\r\n\r\n"
1183    )?;
1184
1185    let mut prefix = [0_u8; 5];
1186    stream.read_exact(&mut prefix)?;
1187    if prefix == *b"HTTP/" {
1188        Ok(())
1189    } else {
1190        Err(std::io::Error::new(
1191            std::io::ErrorKind::InvalidData,
1192            "backend readiness response is not HTTP",
1193        ))
1194    }
1195}
1196
1197fn backend_args(profile: &ProjectProfile, release: bool) -> Vec<&str> {
1198    let mut args = vec!["run", "--bin", profile.bin_target(), "--locked"];
1199    if release {
1200        args.push("--release");
1201    }
1202    args
1203}
1204
1205fn backend_build_args(profile: &ProjectProfile, release: bool) -> Vec<&str> {
1206    let mut args = vec!["build", "--bin", profile.bin_target(), "--locked"];
1207    if release {
1208        args.push("--release");
1209    }
1210    args
1211}
1212
1213fn backend_executable_path(
1214    profile: &ProjectProfile,
1215    paths: &WorkspacePaths,
1216    release: bool,
1217) -> PathBuf {
1218    let target_root = profile.backend_target_dir.as_ref().map_or_else(
1219        || paths.workspace_root.join("target"),
1220        |target_dir| {
1221            let target_dir = Path::new(target_dir);
1222            if target_dir.is_absolute() {
1223                target_dir.to_path_buf()
1224            } else {
1225                paths.project_root.join(target_dir)
1226            }
1227        },
1228    );
1229    target_root
1230        .join(if release { "release" } else { "debug" })
1231        .join(format!(
1232            "{}{}",
1233            profile.bin_target(),
1234            env::consts::EXE_SUFFIX
1235        ))
1236}
1237
1238fn backend_artifact_stamp(path: &Path) -> DevResult<BackendArtifactStamp> {
1239    backend_artifact_stamp_if_present(path)?
1240        .ok_or_else(|| color_eyre::eyre::eyre!("Rust backend is missing at {}", path.display()))
1241}
1242
1243fn backend_artifact_stamp_if_present(path: &Path) -> DevResult<Option<BackendArtifactStamp>> {
1244    let metadata = match fs::metadata(path) {
1245        Ok(metadata) => metadata,
1246        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
1247        Err(error) => {
1248            return Err(error)
1249                .wrap_err_with(|| format!("failed to inspect Rust backend at {}", path.display()));
1250        }
1251    };
1252    Ok(Some(BackendArtifactStamp {
1253        modified: metadata.modified().wrap_err_with(|| {
1254            format!(
1255                "failed to read Rust backend timestamp at {}",
1256                path.display()
1257            )
1258        })?,
1259        length: metadata.len(),
1260    }))
1261}
1262
1263fn backend_runtime_image_path(
1264    profile: &ProjectProfile,
1265    artifact: &Path,
1266    generation: u64,
1267) -> PathBuf {
1268    artifact
1269        .parent()
1270        .unwrap_or_else(|| Path::new("."))
1271        .join(format!(
1272            ".{}-ch-rds-{}-{generation}{}",
1273            profile.bin_target(),
1274            std::process::id(),
1275            env::consts::EXE_SUFFIX
1276        ))
1277}
1278
1279fn copy_backend_runtime_image(artifact: &Path, image: &Path) -> DevResult {
1280    fs::copy(artifact, image).wrap_err_with(|| {
1281        format!(
1282            "failed to prepare Rust backend runtime image from {} to {}",
1283            artifact.display(),
1284            image.display()
1285        )
1286    })?;
1287    Ok(())
1288}
1289
1290fn backend_watchexec_args(
1291    profile: &ProjectProfile,
1292    paths: &WorkspacePaths,
1293    watch_paths: &BackendWatchPaths,
1294    release: bool,
1295) -> Vec<OsString> {
1296    let mut args = vec![
1297        OsString::from("--on-busy-update=queue"),
1298        OsString::from(format!("--debounce={WATCH_DEBOUNCE}")),
1299        OsString::from("--shell=none"),
1300        OsString::from("--emit-events-to=none"),
1301        OsString::from("--project-origin"),
1302        paths.project_root.as_os_str().to_os_string(),
1303        OsString::from("--exts"),
1304        OsString::from("rs,toml,lock,sql"),
1305    ];
1306
1307    for path in &watch_paths.paths {
1308        args.push(OsString::from("--watch"));
1309        args.push(path.as_os_str().to_os_string());
1310    }
1311
1312    args.push(OsString::from("--"));
1313    args.push(cargo());
1314    args.extend(
1315        backend_build_args(profile, release)
1316            .into_iter()
1317            .map(OsString::from),
1318    );
1319    args
1320}
1321
1322fn public_asset_watchexec_args(
1323    paths: &WorkspacePaths,
1324    asset: &ProjectPublicAsset,
1325    watch_paths: &BackendWatchPaths,
1326    release: bool,
1327) -> DevResult<Vec<OsString>> {
1328    let mut args = vec![
1329        OsString::from("--on-busy-update=queue"),
1330        OsString::from(format!("--debounce={WATCH_DEBOUNCE}")),
1331        OsString::from("--shell=none"),
1332        OsString::from("--emit-events-to=none"),
1333        OsString::from("--project-origin"),
1334        paths.project_root.as_os_str().to_os_string(),
1335        OsString::from("--exts"),
1336        OsString::from("rs,toml,lock"),
1337    ];
1338
1339    for path in &watch_paths.paths {
1340        args.push(OsString::from("--watch"));
1341        args.push(path.as_os_str().to_os_string());
1342    }
1343
1344    args.push(OsString::from("--"));
1345    args.push(
1346        env::current_exe()
1347            .wrap_err("failed to resolve the project task executable")?
1348            .into_os_string(),
1349    );
1350    args.push(OsString::from("build-public-asset"));
1351    args.push(OsString::from("--id"));
1352    args.push(OsString::from(asset.id()));
1353    if release {
1354        args.push(OsString::from("--release"));
1355    }
1356    Ok(args)
1357}
1358
1359fn spawn_public_asset_watchers(
1360    profile: &ProjectProfile,
1361    paths: &WorkspacePaths,
1362    release: bool,
1363) -> DevResult<Vec<ManagedChild>> {
1364    if profile.public_assets.is_empty() {
1365        return Ok(Vec::new());
1366    }
1367
1368    let watchexec = resolve_watchexec()?;
1369    let metadata = load_cargo_metadata(paths, "public asset reloads")?;
1370    let mut watchers = Vec::with_capacity(profile.public_assets.len());
1371    for asset in &profile.public_assets {
1372        let watch_paths = package_watch_paths_from_metadata(&metadata, asset.package())?;
1373        let args = public_asset_watchexec_args(paths, asset, &watch_paths, release)?;
1374        watchers.push(spawn_command(
1375            format!("{} public asset watcher", asset.id()).as_str(),
1376            watchexec.clone(),
1377            &args,
1378            &paths.project_root,
1379            &[],
1380        )?);
1381    }
1382    Ok(watchers)
1383}
1384
1385fn resolve_watchexec() -> DevResult<OsString> {
1386    let executable = find_on_path("watchexec").ok_or_else(|| {
1387        color_eyre::eyre::eyre!(
1388            "Watchexec is required for backend reloads; install it with `cargo install watchexec-cli --version 2.5.1 --locked`"
1389        )
1390    })?;
1391    let output = ProcessCommand::new(&executable)
1392        .arg("--version")
1393        .output()
1394        .wrap_err("failed to check the installed Watchexec version")?;
1395    if output.status.success() {
1396        return Ok(executable.into_os_string());
1397    }
1398
1399    let stderr = String::from_utf8_lossy(&output.stderr);
1400    let details = stderr.trim();
1401    if details.is_empty() {
1402        bail!(
1403            "Watchexec is required for backend reloads; install it with `cargo install watchexec-cli --version 2.5.1 --locked`"
1404        );
1405    }
1406
1407    bail!(
1408        "Watchexec is required for backend reloads; install it with `cargo install watchexec-cli --version 2.5.1 --locked`\n\n{details}"
1409    );
1410}
1411
1412fn resolve_backend_watch_paths(
1413    profile: &ProjectProfile,
1414    paths: &WorkspacePaths,
1415) -> DevResult<BackendWatchPaths> {
1416    let metadata = load_cargo_metadata(paths, "backend reloads")?;
1417    backend_watch_paths_from_metadata(&metadata, profile.bin_target())
1418}
1419
1420fn load_cargo_metadata(paths: &WorkspacePaths, purpose: &str) -> DevResult<CargoMetadata> {
1421    let output = ProcessCommand::new(cargo())
1422        .args(["metadata", "--format-version=1", "--locked"])
1423        .current_dir(&paths.project_root)
1424        .output()
1425        .wrap_err_with(|| format!("failed to read Cargo metadata for {purpose}"))?;
1426    if !output.status.success() {
1427        let details = String::from_utf8_lossy(&output.stderr);
1428        bail!("Cargo metadata failed for {purpose}: {}", details.trim());
1429    }
1430
1431    serde_json::from_slice::<CargoMetadata>(&output.stdout)
1432        .wrap_err_with(|| format!("failed to parse Cargo metadata for {purpose}"))
1433}
1434
1435fn backend_watch_paths_from_metadata(
1436    metadata: &CargoMetadata,
1437    bin_target: &str,
1438) -> DevResult<BackendWatchPaths> {
1439    let root_ids = metadata
1440        .packages
1441        .iter()
1442        .filter(|package| {
1443            package.targets.iter().any(|target| {
1444                target.name == bin_target && target.kind.iter().any(|kind| kind == "bin")
1445            })
1446        })
1447        .map(|package| package.id.as_str())
1448        .collect::<Vec<_>>();
1449    ensure!(
1450        !root_ids.is_empty(),
1451        "Cargo metadata contains no binary target named `{bin_target}`"
1452    );
1453
1454    watch_paths_from_metadata_roots(metadata, root_ids)
1455}
1456
1457fn package_watch_paths_from_metadata(
1458    metadata: &CargoMetadata,
1459    package_name: &str,
1460) -> DevResult<BackendWatchPaths> {
1461    let root_ids = metadata
1462        .packages
1463        .iter()
1464        .filter(|package| package.name == package_name)
1465        .map(|package| package.id.as_str())
1466        .collect::<Vec<_>>();
1467    ensure!(
1468        !root_ids.is_empty(),
1469        "Cargo metadata contains no package named `{package_name}`"
1470    );
1471
1472    watch_paths_from_metadata_roots(metadata, root_ids)
1473}
1474
1475fn watch_paths_from_metadata_roots<'a>(
1476    metadata: &'a CargoMetadata,
1477    root_ids: Vec<&'a str>,
1478) -> DevResult<BackendWatchPaths> {
1479    let resolve = metadata
1480        .resolve
1481        .as_ref()
1482        .ok_or_else(|| color_eyre::eyre::eyre!("Cargo metadata did not include a resolve graph"))?;
1483
1484    let nodes = resolve
1485        .nodes
1486        .iter()
1487        .map(|node| (node.id.as_str(), node))
1488        .collect::<HashMap<_, _>>();
1489    let mut reachable = BTreeSet::new();
1490    let mut pending = root_ids.into_iter().collect::<VecDeque<_>>();
1491    while let Some(package_id) = pending.pop_front() {
1492        if !reachable.insert(package_id) {
1493            continue;
1494        }
1495        if let Some(node) = nodes.get(package_id) {
1496            pending.extend(node.deps.iter().map(|dependency| dependency.pkg.as_str()));
1497        }
1498    }
1499
1500    let mut watch_paths = BTreeSet::new();
1501    for package in metadata
1502        .packages
1503        .iter()
1504        .filter(|package| package.source.is_none() && reachable.contains(package.id.as_str()))
1505    {
1506        let package_root = package.manifest_path.parent().ok_or_else(|| {
1507            color_eyre::eyre::eyre!(
1508                "Cargo package manifest has no parent: {}",
1509                package.manifest_path.display()
1510            )
1511        })?;
1512        insert_existing_watch_path(&mut watch_paths, &package.manifest_path)?;
1513
1514        let workspace_root = find_workspace_root(package_root)?;
1515        insert_existing_watch_path(&mut watch_paths, &workspace_root.join("Cargo.toml"))?;
1516        for workspace_file in ["Cargo.lock", "rust-toolchain.toml", ".rust-version"] {
1517            let workspace_file = workspace_root.join(workspace_file);
1518            if workspace_file.is_file() {
1519                insert_existing_watch_path(&mut watch_paths, &workspace_file)?;
1520            }
1521        }
1522        let cargo_config_dir = workspace_root.join(".cargo");
1523        if cargo_config_dir.is_dir() {
1524            insert_existing_watch_path(&mut watch_paths, &cargo_config_dir)?;
1525        }
1526
1527        let conventional_source_root = package_root.join("src");
1528        if conventional_source_root.is_dir() {
1529            insert_existing_watch_path(&mut watch_paths, &conventional_source_root)?;
1530        }
1531        for target in &package.targets {
1532            if !target.src_path.starts_with(&conventional_source_root) && target.src_path.is_file()
1533            {
1534                insert_existing_watch_path(&mut watch_paths, &target.src_path)?;
1535            }
1536        }
1537
1538        let migrations = package_root.join("migrations");
1539        if migrations.is_dir() {
1540            insert_existing_watch_path(&mut watch_paths, &migrations)?;
1541        }
1542    }
1543
1544    Ok(BackendWatchPaths {
1545        paths: watch_paths.into_iter().collect(),
1546    })
1547}
1548
1549fn insert_existing_watch_path(paths: &mut BTreeSet<PathBuf>, path: &Path) -> DevResult {
1550    let canonical = path
1551        .canonicalize()
1552        .wrap_err_with(|| format!("failed to resolve backend watch path {}", path.display()))?;
1553    paths.insert(canonical);
1554    Ok(())
1555}
1556
1557fn api_addr(profile: &ProjectProfile) -> DevResult<SocketAddr> {
1558    profile.api_addr.parse::<SocketAddr>().wrap_err_with(|| {
1559        format!(
1560            "invalid site address `{}` for project `{}`",
1561            profile.api_addr, profile.app_id
1562        )
1563    })
1564}
1565
1566fn web_dev_envs(profile: &ProjectProfile, backend_origin: &str) -> Vec<(String, String)> {
1567    let mut envs = Vec::new();
1568    if let Some(origin_env) = &profile.web_backend_origin_env {
1569        envs.push((origin_env.clone(), backend_origin.to_string()));
1570    }
1571    envs
1572}
1573
1574fn backend_envs(profile: &ProjectProfile) -> Vec<(String, String)> {
1575    profile
1576        .backend_target_dir
1577        .as_ref()
1578        .map(|target_dir| vec![("CARGO_TARGET_DIR".to_string(), target_dir.clone())])
1579        .unwrap_or_default()
1580}
1581
1582fn wait_for_watch_processes(
1583    profile: &ProjectProfile,
1584    paths: &WorkspacePaths,
1585    api_addr: SocketAddr,
1586    backend: &mut BackendWatchRuntime,
1587    web: &mut ManagedChild,
1588    public_asset_watchers: &mut [ManagedChild],
1589) -> DevResult {
1590    loop {
1591        if let Some(status) = backend
1592            .process
1593            .try_wait()
1594            .wrap_err("failed to poll Rust backend")?
1595        {
1596            if status.success() {
1597                bail!("Rust backend exited before the web dev server");
1598            }
1599            bail!("Rust backend exited with status {:?}", status.code());
1600        }
1601
1602        if let Some(builder) = backend.builder.as_mut()
1603            && let Some(status) = builder
1604                .try_wait()
1605                .wrap_err("failed to poll Rust backend build watcher")?
1606        {
1607            bail!(
1608                "Rust backend build watcher exited unexpectedly with status {:?}",
1609                status.code()
1610            );
1611        }
1612
1613        refresh_backend_after_successful_build(profile, paths, api_addr, backend)?;
1614
1615        if let Some(status) = web.try_wait().wrap_err("failed to poll web dev server")? {
1616            if status.success() {
1617                return Ok(());
1618            }
1619            bail!("web dev server exited with status {:?}", status.code());
1620        }
1621
1622        let mut watcher_exit = None;
1623        for watcher in public_asset_watchers.iter_mut() {
1624            if let Some(status) = watcher
1625                .try_wait()
1626                .wrap_err("failed to poll a public asset watcher")?
1627            {
1628                watcher_exit = Some(status);
1629                break;
1630            }
1631        }
1632        if let Some(status) = watcher_exit {
1633            bail!(
1634                "public asset watcher exited unexpectedly with status {:?}",
1635                status.code()
1636            );
1637        }
1638
1639        thread::sleep(Duration::from_millis(250));
1640    }
1641}
1642
1643fn refresh_backend_after_successful_build(
1644    profile: &ProjectProfile,
1645    paths: &WorkspacePaths,
1646    api_addr: SocketAddr,
1647    backend: &mut BackendWatchRuntime,
1648) -> DevResult {
1649    let Some(artifact) = backend.artifact.as_ref() else {
1650        return Ok(());
1651    };
1652    let Some(candidate_stamp) = backend_artifact_stamp_if_present(artifact)? else {
1653        // Cargo may temporarily remove the final executable while rustc links a
1654        // replacement. The build watcher still owns that transition, so keep
1655        // serving the last successful runtime image until the artifact appears.
1656        return Ok(());
1657    };
1658    if backend.active_stamp == Some(candidate_stamp) {
1659        return Ok(());
1660    }
1661
1662    let candidate_generation = backend.generation.saturating_add(1);
1663    let candidate_image = backend_runtime_image_path(profile, artifact, candidate_generation);
1664    copy_backend_runtime_image(artifact, &candidate_image)?;
1665    backend.process.terminate();
1666    let candidate = spawn_command(
1667        "rebuilt Rust backend",
1668        candidate_image.as_os_str().to_os_string(),
1669        &[] as &[&str],
1670        &paths.project_root,
1671        &[],
1672    );
1673    let candidate = candidate.and_then(|mut candidate| {
1674        wait_for_backend_ready(
1675            &mut candidate,
1676            api_addr,
1677            profile.backend_ready_timeout,
1678            profile.backend_ready_path.as_deref(),
1679        )
1680        .map(|()| candidate)
1681    });
1682
1683    match candidate {
1684        Ok(candidate) => {
1685            remove_backend_runtime_image(backend.active_image.replace(candidate_image));
1686            backend.process = candidate;
1687            backend.active_stamp = Some(candidate_stamp);
1688            backend.generation = candidate_generation;
1689            eprintln!(
1690                "Activated rebuilt Rust backend generation {} from {}",
1691                backend.generation,
1692                artifact.display()
1693            );
1694            Ok(())
1695        }
1696        Err(error) => {
1697            remove_backend_runtime_image(Some(candidate_image));
1698            let fallback_image = backend.active_image.as_ref().ok_or_else(|| {
1699                color_eyre::eyre::eyre!(
1700                    "rebuilt Rust backend failed and no last successful runtime image exists"
1701                )
1702            })?;
1703            let mut fallback = spawn_command(
1704                "last successful Rust backend",
1705                fallback_image.as_os_str().to_os_string(),
1706                &[] as &[&str],
1707                &paths.project_root,
1708                &[],
1709            )?;
1710            wait_for_backend_ready(
1711                &mut fallback,
1712                api_addr,
1713                profile.backend_ready_timeout,
1714                profile.backend_ready_path.as_deref(),
1715            )?;
1716            backend.process = fallback;
1717            backend.active_stamp = Some(candidate_stamp);
1718            eprintln!(
1719                "Rebuilt Rust backend did not become ready: {error:#}. Continuing with the last successful backend until another build succeeds."
1720            );
1721            Ok(())
1722        }
1723    }
1724}
1725
1726impl Drop for BackendWatchRuntime {
1727    fn drop(&mut self) {
1728        self.process.terminate();
1729        if let Some(builder) = self.builder.as_mut() {
1730            builder.terminate();
1731        }
1732        remove_backend_runtime_image(self.active_image.take());
1733    }
1734}
1735
1736fn remove_backend_runtime_image(path: Option<PathBuf>) {
1737    if let Some(path) = path {
1738        let _ = fs::remove_file(path);
1739    }
1740}
1741
1742fn run_command<S: AsRef<OsStr>>(
1743    label: &str,
1744    executable: OsString,
1745    args: &[S],
1746    cwd: &Path,
1747    envs: &[(String, String)],
1748) -> DevResult {
1749    let mut child = spawn_command(label, executable, args, cwd, envs)?;
1750    let status = child
1751        .wait()
1752        .wrap_err_with(|| format!("failed to wait for {label}"))?;
1753    if status.success() {
1754        return Ok(());
1755    }
1756
1757    bail!("{label} exited with status {:?}", status.code());
1758}
1759
1760fn spawn_command<S: AsRef<OsStr>>(
1761    label: &str,
1762    executable: OsString,
1763    args: &[S],
1764    cwd: &Path,
1765    envs: &[(String, String)],
1766) -> DevResult<ManagedChild> {
1767    let mut command = ProcessCommand::new(executable);
1768    command.current_dir(cwd).args(args);
1769    for (key, value) in envs {
1770        command.env(key, value);
1771    }
1772
1773    command
1774        .spawn()
1775        .map(|child| ManagedChild { child })
1776        .wrap_err_with(|| format!("failed to start {label}"))
1777}
1778
1779fn path_arg(path: &Path) -> DevResult<&str> {
1780    path.to_str()
1781        .ok_or_else(|| color_eyre::eyre::eyre!("path is not valid UTF-8: {}", path.display()))
1782}
1783
1784fn cargo() -> OsString {
1785    env::var_os("CARGO").unwrap_or_else(|| OsString::from("cargo"))
1786}
1787
1788fn npm() -> OsString {
1789    match cfg!(windows) {
1790        true => OsString::from("npm.cmd"),
1791        false => OsString::from("npm"),
1792    }
1793}
1794
1795fn resolve_wasm_bindgen(required_version: &str) -> DevResult<OsString> {
1796    if let Some(configured) = env::var_os("WASM_BINDGEN") {
1797        if wasm_bindgen_matches(&configured, required_version) {
1798            return Ok(configured);
1799        }
1800        bail!(
1801            "WASM_BINDGEN does not point to wasm-bindgen {}",
1802            required_version
1803        );
1804    }
1805
1806    if let Some(executable) = find_on_path("wasm-bindgen") {
1807        let executable = executable.into_os_string();
1808        if wasm_bindgen_matches(&executable, required_version) {
1809            return Ok(executable);
1810        }
1811    }
1812
1813    bail!(
1814        "wasm-bindgen {} is required for configured WASM public assets; install it with `cargo install wasm-bindgen-cli --version {} --locked` or set WASM_BINDGEN",
1815        required_version,
1816        required_version
1817    );
1818}
1819
1820fn wasm_bindgen_matches(executable: &OsString, required_version: &str) -> bool {
1821    let output = ProcessCommand::new(executable).arg("--version").output();
1822    let Ok(output) = output else {
1823        return false;
1824    };
1825    let text = String::from_utf8_lossy(&output.stdout);
1826    output.status.success() && text.contains(required_version)
1827}
1828
1829fn find_on_path(name: &str) -> Option<PathBuf> {
1830    let path = env::var_os("PATH")?;
1831    for directory in env::split_paths(&path) {
1832        let candidate = directory.join(name);
1833        if candidate.is_file() {
1834            return Some(candidate);
1835        }
1836        #[cfg(windows)]
1837        {
1838            let candidate = directory.join(format!("{name}.exe"));
1839            if candidate.is_file() {
1840                return Some(candidate);
1841            }
1842        }
1843    }
1844    None
1845}
1846
1847#[cfg(test)]
1848mod tests {
1849    use super::*;
1850    use std::net::TcpListener;
1851    use std::sync::atomic::{AtomicU64, Ordering};
1852
1853    static TEMP_SEQUENCE: AtomicU64 = AtomicU64::new(0);
1854
1855    struct TestDirectory(PathBuf);
1856
1857    impl TestDirectory {
1858        fn new(label: &str) -> Self {
1859            let sequence = TEMP_SEQUENCE.fetch_add(1, Ordering::Relaxed);
1860            let path = env::temp_dir().join(format!(
1861                "cargo-workspace-{label}-{}-{sequence}",
1862                std::process::id()
1863            ));
1864            fs::create_dir_all(&path).expect("test directory should be created");
1865            Self(path)
1866        }
1867
1868        fn path(&self) -> &Path {
1869            &self.0
1870        }
1871    }
1872
1873    impl Drop for TestDirectory {
1874        fn drop(&mut self) {
1875            let _ = fs::remove_dir_all(&self.0);
1876        }
1877    }
1878
1879    #[test]
1880    fn project_profile_reuses_the_explicit_identity_by_default() {
1881        let profile = ProjectProfile::new("example-service", "127.0.0.1:4100");
1882
1883        assert_eq!(profile.app_id(), "example-service");
1884        assert_eq!(profile.bin_target(), "example-service");
1885        profile
1886            .validate("test profile")
1887            .expect("single-identity profile should be valid");
1888    }
1889
1890    #[test]
1891    fn project_profile_can_isolate_backend_builds() {
1892        let profile = ProjectProfile::new("example-service", "127.0.0.1:4100")
1893            .with_backend_target_dir("target/example-backend");
1894
1895        assert_eq!(
1896            backend_envs(&profile),
1897            vec![(
1898                "CARGO_TARGET_DIR".to_string(),
1899                "target/example-backend".to_string()
1900            )]
1901        );
1902        profile
1903            .validate("test profile")
1904            .expect("isolated backend profile should be valid");
1905    }
1906
1907    #[test]
1908    fn wasm_asset_can_use_a_project_relative_target_directory() {
1909        let asset = ProjectPublicAsset::wasm_bindgen(
1910            "editor",
1911            "Example editor",
1912            "example-editor",
1913            "example.wasm",
1914            vec!["public".to_string()],
1915            "example",
1916            "0.2.126",
1917        )
1918        .with_target_dir("build/web/wasm");
1919
1920        asset
1921            .validate("test profile", "example-service")
1922            .expect("project-relative target directory should be valid");
1923    }
1924
1925    #[test]
1926    fn public_asset_command_parses_optional_absolute_target_directory() {
1927        let default = AppCli::try_parse_from(["app", "build-public-asset", "--id", "editor"])
1928            .expect("existing public asset invocation should parse");
1929        let AppCommand::BuildPublicAsset(default) = default.command else {
1930            panic!("expected public asset command");
1931        };
1932        assert_eq!(default.id, "editor");
1933        assert!(!default.release);
1934        assert!(default.target_dir.is_none());
1935
1936        let directory = TestDirectory::new("public-asset-cli-target");
1937        let target = directory.path().join("wasm-cache");
1938        let explicit = AppCli::try_parse_from(vec![
1939            OsString::from("app"),
1940            OsString::from("build-public-asset"),
1941            OsString::from("--id"),
1942            OsString::from("editor"),
1943            OsString::from("--release"),
1944            OsString::from("--target-dir"),
1945            target.as_os_str().to_os_string(),
1946        ])
1947        .expect("explicit public asset target directory should parse");
1948        let AppCommand::BuildPublicAsset(explicit) = explicit.command else {
1949            panic!("expected public asset command");
1950        };
1951        assert!(explicit.release);
1952        assert_eq!(explicit.target_dir, Some(target));
1953    }
1954
1955    #[test]
1956    fn public_asset_target_directory_preserves_defaults_and_absolute_override() {
1957        let directory = TestDirectory::new("public-asset-target-resolution");
1958        let paths = WorkspacePaths {
1959            workspace_root: directory.path().to_path_buf(),
1960            project_root: directory.path().to_path_buf(),
1961            web_root: directory.path().join("web"),
1962        };
1963        let profile = ProjectProfile::new("example-service", "127.0.0.1:4100");
1964        let ProjectPublicAsset::WasmBindgen(mut asset) = ProjectPublicAsset::wasm_bindgen(
1965            "editor",
1966            "Example editor",
1967            "example-editor",
1968            "example.wasm",
1969            vec!["public".to_owned()],
1970            "example",
1971            "0.2.126",
1972        );
1973        assert_eq!(
1974            resolve_public_asset_target_dir(&profile, &paths, &asset, None).unwrap(),
1975            paths
1976                .project_root
1977                .join("target/example-service-dev-editor-build")
1978        );
1979        asset.target_dir = Some("build/web/wasm".to_owned());
1980        assert_eq!(
1981            resolve_public_asset_target_dir(&profile, &paths, &asset, None).unwrap(),
1982            paths.project_root.join("build/web/wasm")
1983        );
1984        let explicit = directory.path().join("separate-ssd-cache");
1985        assert_eq!(
1986            resolve_public_asset_target_dir(&profile, &paths, &asset, Some(&explicit)).unwrap(),
1987            explicit
1988        );
1989        assert_eq!(asset.target_dir.as_deref(), Some("build/web/wasm"));
1990        for invalid in [
1991            PathBuf::new(),
1992            PathBuf::from("relative/cache"),
1993            directory.path().join("../cache"),
1994        ] {
1995            assert!(
1996                resolve_public_asset_target_dir(&profile, &paths, &asset, Some(&invalid)).is_err()
1997            );
1998        }
1999    }
2000
2001    #[test]
2002    fn missing_backend_artifact_is_a_transient_watch_state() {
2003        let directory = TestDirectory::new("backend-artifact-transition");
2004        let artifact = directory.path().join("example-server");
2005
2006        assert_eq!(
2007            backend_artifact_stamp_if_present(&artifact)
2008                .expect("missing artifact should remain a valid watch state"),
2009            None
2010        );
2011
2012        fs::write(&artifact, b"new backend").expect("backend artifact should be written");
2013        let stamp = backend_artifact_stamp_if_present(&artifact)
2014            .expect("finished artifact should be readable")
2015            .expect("finished artifact should be present");
2016        assert_eq!(stamp.length, 11);
2017    }
2018
2019    #[test]
2020    fn builds_backend_artifacts_before_restarting_the_running_generation() {
2021        let directory = TestDirectory::new("watchexec-args");
2022        let profile = ProjectProfile::new("example-service", "127.0.0.1:4100")
2023            .with_bin_target("example-server");
2024        let paths = WorkspacePaths {
2025            workspace_root: directory.path().to_path_buf(),
2026            project_root: directory.path().to_path_buf(),
2027            web_root: directory.path().join("web"),
2028        };
2029        let watch_paths = BackendWatchPaths {
2030            paths: vec![directory.path().join("src")],
2031        };
2032
2033        assert_eq!(
2034            backend_watchexec_args(&profile, &paths, &watch_paths, false),
2035            [
2036                OsString::from("--on-busy-update=queue"),
2037                OsString::from("--debounce=750ms"),
2038                OsString::from("--shell=none"),
2039                OsString::from("--emit-events-to=none"),
2040                OsString::from("--project-origin"),
2041                directory.path().as_os_str().to_os_string(),
2042                OsString::from("--exts"),
2043                OsString::from("rs,toml,lock,sql"),
2044                OsString::from("--watch"),
2045                directory.path().join("src").into_os_string(),
2046                OsString::from("--"),
2047                cargo(),
2048                OsString::from("build"),
2049                OsString::from("--bin"),
2050                OsString::from("example-server"),
2051                OsString::from("--locked"),
2052            ]
2053        );
2054
2055        let release_args = backend_watchexec_args(&profile, &paths, &watch_paths, true);
2056        assert_eq!(release_args.last(), Some(&OsString::from("--release")));
2057    }
2058
2059    #[test]
2060    fn public_asset_watcher_rebuilds_after_the_initial_public_asset_gate() {
2061        let directory = TestDirectory::new("public-asset-watcher");
2062        let paths = WorkspacePaths {
2063            workspace_root: directory.path().to_path_buf(),
2064            project_root: directory.path().to_path_buf(),
2065            web_root: directory.path().join("web"),
2066        };
2067        let watch_paths = BackendWatchPaths {
2068            paths: vec![directory.path().join("src")],
2069        };
2070        let asset = ProjectPublicAsset::wasm_bindgen(
2071            "editor",
2072            "Example editor",
2073            "example-editor",
2074            "example.wasm",
2075            vec!["public".to_string()],
2076            "example",
2077            "0.2.126",
2078        );
2079
2080        let args = public_asset_watchexec_args(&paths, &asset, &watch_paths, false)
2081            .expect("public asset watcher command should resolve");
2082
2083        // The blocking initial build runs before watch_project. The watcher
2084        // then performs every later rebuild through the same asset command.
2085        assert!(!args.contains(&OsString::from("--postpone")));
2086        assert!(args.contains(&OsString::from("--on-busy-update=queue")));
2087        assert!(args.contains(&OsString::from("--debounce=750ms")));
2088        assert!(args.contains(&OsString::from("build-public-asset")));
2089    }
2090
2091    #[test]
2092    fn watch_cli_can_start_the_backend_once_without_a_reload_supervisor() {
2093        let cli = AppCli::try_parse_from(["project-xtask", "watch", "--no-backend-reload"])
2094            .expect("watch command should accept the backend reload opt-out");
2095
2096        let AppCommand::Watch(command) = cli.command else {
2097            panic!("watch command should preserve its typed owner");
2098        };
2099        assert!(command.no_backend_reload);
2100        assert!(!command.run.release);
2101    }
2102
2103    #[test]
2104    fn optional_asset_switch_is_available_for_watch_serve_and_build() {
2105        for command_args in [
2106            ["project-xtask", "watch", "--editor"].as_slice(),
2107            ["project-xtask", "serve", "--with-optional-assets"].as_slice(),
2108            ["project-xtask", "build", "--editor"].as_slice(),
2109        ] {
2110            let cli = AppCli::try_parse_from(command_args)
2111                .expect("optional asset switch should be accepted by every run mode");
2112
2113            let include_optional_assets = match cli.command {
2114                AppCommand::Watch(command) => command.run.optional_assets.include_optional_assets,
2115                AppCommand::Serve(command) => command.optional_assets.include_optional_assets,
2116                AppCommand::Build(command) => command.optional_assets.include_optional_assets,
2117                AppCommand::BuildPublicAsset(_) => {
2118                    panic!("internal public asset command is not part of this matrix")
2119                }
2120            };
2121            assert!(include_optional_assets);
2122        }
2123    }
2124
2125    #[test]
2126    fn optional_public_assets_are_not_active_without_the_explicit_switch() {
2127        let asset = ProjectPublicAsset::wasm_bindgen(
2128            "editor",
2129            "Example editor",
2130            "example-editor",
2131            "example.wasm",
2132            vec!["public".to_string()],
2133            "example",
2134            "0.2.126",
2135        );
2136        let profile = ProjectProfile::new("example-service", "127.0.0.1:4100")
2137            .with_optional_public_asset(asset);
2138
2139        let default_profile = activate_optional_public_assets(profile.clone(), false);
2140        assert!(default_profile.public_assets.is_empty());
2141        assert_eq!(default_profile.optional_public_assets.len(), 1);
2142
2143        let enabled_profile = activate_optional_public_assets(profile, true);
2144        assert_eq!(enabled_profile.public_assets.len(), 1);
2145        assert!(enabled_profile.optional_public_assets.is_empty());
2146    }
2147
2148    #[test]
2149    fn watches_only_local_packages_reachable_from_the_backend() {
2150        let directory = TestDirectory::new("cargo-metadata");
2151        let app_workspace = directory.path().join("app-workspace");
2152        let app_root = app_workspace.join("app");
2153        let unused_root = app_workspace.join("unused");
2154        let shared_workspace = directory.path().join("shared-workspace");
2155        let shared_root = shared_workspace.join("crates").join("shared");
2156
2157        for source_root in [
2158            app_root.join("src"),
2159            unused_root.join("src"),
2160            shared_root.join("src"),
2161        ] {
2162            fs::create_dir_all(source_root).expect("source directory should be created");
2163        }
2164        fs::write(
2165            app_workspace.join("Cargo.toml"),
2166            "[workspace]\nmembers = [\"app\", \"unused\"]\nresolver = \"3\"\n",
2167        )
2168        .expect("app workspace manifest should be written");
2169        fs::write(
2170            shared_workspace.join("Cargo.toml"),
2171            "[workspace]\nmembers = [\"crates/shared\"]\nresolver = \"3\"\n",
2172        )
2173        .expect("shared workspace manifest should be written");
2174        for (root, name) in [
2175            (&app_root, "app"),
2176            (&unused_root, "unused"),
2177            (&shared_root, "shared"),
2178        ] {
2179            fs::write(
2180                root.join("Cargo.toml"),
2181                format!("[package]\nname = \"{name}\"\nversion = \"0.1.0\"\n"),
2182            )
2183            .expect("package manifest should be written");
2184        }
2185
2186        let package = |id: &str, root: &Path, target_name: &str, kind: &str| CargoPackage {
2187            id: id.to_string(),
2188            name: id.to_string(),
2189            source: None,
2190            manifest_path: root.join("Cargo.toml"),
2191            targets: vec![CargoTarget {
2192                kind: vec![kind.to_string()],
2193                name: target_name.to_string(),
2194                src_path: root
2195                    .join("src")
2196                    .join(if kind == "bin" { "main.rs" } else { "lib.rs" }),
2197            }],
2198        };
2199        let metadata = CargoMetadata {
2200            packages: vec![
2201                package("app", &app_root, "example-server", "bin"),
2202                package("shared", &shared_root, "shared", "lib"),
2203                package("unused", &unused_root, "unused", "lib"),
2204                CargoPackage {
2205                    id: "registry".to_string(),
2206                    name: "registry".to_string(),
2207                    source: Some("registry+https://example.invalid".to_string()),
2208                    manifest_path: directory.path().join("registry").join("Cargo.toml"),
2209                    targets: Vec::new(),
2210                },
2211            ],
2212            resolve: Some(CargoResolve {
2213                nodes: vec![
2214                    CargoResolveNode {
2215                        id: "app".to_string(),
2216                        deps: vec![
2217                            CargoResolveDependency {
2218                                pkg: "shared".to_string(),
2219                            },
2220                            CargoResolveDependency {
2221                                pkg: "registry".to_string(),
2222                            },
2223                        ],
2224                    },
2225                    CargoResolveNode {
2226                        id: "shared".to_string(),
2227                        deps: Vec::new(),
2228                    },
2229                    CargoResolveNode {
2230                        id: "unused".to_string(),
2231                        deps: Vec::new(),
2232                    },
2233                    CargoResolveNode {
2234                        id: "registry".to_string(),
2235                        deps: Vec::new(),
2236                    },
2237                ],
2238            }),
2239        };
2240
2241        let watch_paths = backend_watch_paths_from_metadata(&metadata, "example-server")
2242            .expect("backend watch paths should resolve");
2243        let public_asset_watch_paths = package_watch_paths_from_metadata(&metadata, "app")
2244            .expect("public asset watch paths should resolve");
2245
2246        assert_eq!(watch_paths, public_asset_watch_paths);
2247
2248        assert!(
2249            watch_paths
2250                .paths
2251                .contains(&app_root.join("src").canonicalize().unwrap())
2252        );
2253        assert!(
2254            watch_paths
2255                .paths
2256                .contains(&shared_root.join("src").canonicalize().unwrap())
2257        );
2258        assert!(
2259            !watch_paths
2260                .paths
2261                .contains(&unused_root.join("src").canonicalize().unwrap())
2262        );
2263        assert!(
2264            watch_paths
2265                .paths
2266                .contains(&app_workspace.join("Cargo.toml").canonicalize().unwrap())
2267        );
2268        assert!(
2269            watch_paths
2270                .paths
2271                .contains(&shared_workspace.join("Cargo.toml").canonicalize().unwrap())
2272        );
2273        assert!(
2274            !watch_paths
2275                .paths
2276                .contains(&app_root.canonicalize().unwrap())
2277        );
2278    }
2279
2280    #[test]
2281    fn project_profile_keeps_an_explicit_binary_override() {
2282        let profile = ProjectProfile::new("example-service", "127.0.0.1:4100")
2283            .with_bin_target("custom-server");
2284
2285        assert_eq!(profile.app_id(), "example-service");
2286        assert_eq!(profile.bin_target(), "custom-server");
2287    }
2288
2289    #[test]
2290    fn project_profile_accepts_a_cold_backend_build_timeout() {
2291        let profile = ProjectProfile::new("example-service", "127.0.0.1:4100")
2292            .with_backend_ready_timeout(Duration::from_secs(900));
2293
2294        assert_eq!(profile.backend_ready_timeout, Duration::from_secs(900));
2295        profile
2296            .validate("test profile")
2297            .expect("positive readiness timeout should be valid");
2298    }
2299
2300    #[test]
2301    fn project_profile_rejects_a_zero_backend_build_timeout() {
2302        let profile = ProjectProfile::new("example-service", "127.0.0.1:4100")
2303            .with_backend_ready_timeout(Duration::ZERO);
2304
2305        let error = profile
2306            .validate("test profile")
2307            .expect_err("zero readiness timeout should be rejected");
2308        assert!(error.to_string().contains("zero backend readiness timeout"));
2309    }
2310
2311    #[test]
2312    fn project_profile_accepts_an_http_backend_readiness_path() {
2313        let profile = ProjectProfile::new("example-service", "127.0.0.1:4100")
2314            .with_backend_ready_path("/ready");
2315
2316        assert_eq!(profile.backend_ready_path.as_deref(), Some("/ready"));
2317        profile
2318            .validate("test profile")
2319            .expect("absolute HTTP readiness path should be valid");
2320    }
2321
2322    #[test]
2323    fn project_profile_rejects_an_invalid_http_backend_readiness_path() {
2324        let profile = ProjectProfile::new("example-service", "127.0.0.1:4100")
2325            .with_backend_ready_path("ready\r\ninvalid");
2326
2327        let error = profile
2328            .validate("test profile")
2329            .expect_err("invalid HTTP readiness path should be rejected");
2330        assert!(error.to_string().contains("invalid backend readiness path"));
2331    }
2332
2333    #[test]
2334    fn http_readiness_requires_an_http_response() {
2335        let listener = TcpListener::bind("127.0.0.1:0").expect("listener should bind");
2336        let addr = listener
2337            .local_addr()
2338            .expect("listener address should resolve");
2339
2340        probe_backend_http(addr, "/ready")
2341            .expect_err("a bound socket without a serving backend is not ready");
2342    }
2343
2344    #[test]
2345    fn http_readiness_accepts_a_serving_backend() {
2346        let listener = TcpListener::bind("127.0.0.1:0").expect("listener should bind");
2347        let addr = listener
2348            .local_addr()
2349            .expect("listener address should resolve");
2350        let server = thread::spawn(move || {
2351            let (mut stream, _) = listener.accept().expect("readiness request should connect");
2352            let mut request = [0_u8; 256];
2353            let read = stream
2354                .read(&mut request)
2355                .expect("readiness request should be readable");
2356            assert!(read > 0);
2357            stream
2358                .write_all(b"HTTP/1.1 204 No Content\r\nContent-Length: 0\r\n\r\n")
2359                .expect("readiness response should be written");
2360        });
2361
2362        probe_backend_http(addr, "/ready").expect("serving backend should be ready");
2363        server.join().expect("readiness server should finish");
2364    }
2365
2366    #[test]
2367    fn finds_the_owning_cargo_workspace_above_a_member_package() {
2368        let directory = TestDirectory::new("member");
2369        let workspace_root = directory.path().join("workspace");
2370        let package_root = workspace_root.join("crates").join("service");
2371        let source_root = package_root.join("src");
2372        fs::create_dir_all(&source_root).expect("member source directory should be created");
2373        fs::write(
2374            workspace_root.join("Cargo.toml"),
2375            "[workspace]\nmembers = [\"crates/service\"]\n",
2376        )
2377        .expect("workspace manifest should be written");
2378        fs::write(
2379            package_root.join("Cargo.toml"),
2380            "[package]\nname = \"service\"\nversion = \"0.1.0\"\n",
2381        )
2382        .expect("package manifest should be written");
2383
2384        assert_eq!(
2385            find_workspace_root(&source_root).expect("workspace should resolve"),
2386            workspace_root
2387        );
2388    }
2389
2390    #[test]
2391    fn uses_the_nearest_standalone_package_when_no_workspace_exists() {
2392        let directory = TestDirectory::new("standalone");
2393        let package_root = directory.path().join("service");
2394        let source_root = package_root.join("src");
2395        fs::create_dir_all(&source_root).expect("package source directory should be created");
2396        fs::write(
2397            package_root.join("Cargo.toml"),
2398            "[package]\nname = \"service\"\nversion = \"0.1.0\"\n",
2399        )
2400        .expect("package manifest should be written");
2401
2402        assert_eq!(
2403            find_workspace_root(&source_root).expect("package root should resolve"),
2404            package_root
2405        );
2406    }
2407
2408    #[test]
2409    fn reports_an_invalid_cargo_manifest() {
2410        let directory = TestDirectory::new("invalid");
2411        let package_root = directory.path().join("service");
2412        fs::create_dir_all(&package_root).expect("package directory should be created");
2413        fs::write(package_root.join("Cargo.toml"), "[workspace\n")
2414            .expect("invalid manifest should be written");
2415
2416        let error = find_workspace_root(&package_root)
2417            .expect_err("an invalid Cargo manifest must not be ignored");
2418        assert!(error.to_string().contains("failed to parse Cargo manifest"));
2419    }
2420    #[cfg(target_os = "linux")]
2421    #[test]
2422    fn managed_child_is_terminated_and_reaped_on_an_error_return() {
2423        let directory = tempfile::tempdir().unwrap();
2424        let pid_file = directory.path().join("process.pid");
2425        let result = (|| -> DevResult<u32> {
2426            let child = spawn_command(
2427                "owned test child",
2428                std::env::current_exe().unwrap().into_os_string(),
2429                &[
2430                    "--ignored",
2431                    "--exact",
2432                    "docker::tests::process_fixture",
2433                    "--nocapture",
2434                ],
2435                directory.path(),
2436                &[
2437                    ("RUNTIME_TEST_PROCESS_CASE".into(), "wait".into()),
2438                    (
2439                        "RUNTIME_TEST_PROCESS_PID".into(),
2440                        pid_file.to_string_lossy().into_owned(),
2441                    ),
2442                ],
2443            )?;
2444            let pid = child.id();
2445            let start = Instant::now();
2446            while !pid_file.exists() {
2447                assert!(start.elapsed() < Duration::from_secs(10));
2448                thread::sleep(Duration::from_millis(10));
2449            }
2450            Err(color_eyre::eyre::eyre!(
2451                "setup failed after starting process {pid}"
2452            ))
2453        })();
2454        assert!(result.is_err());
2455        let pid = fs::read_to_string(pid_file).unwrap();
2456        assert!(
2457            !Path::new(&format!("/proc/{}", pid.trim())).exists(),
2458            "an error return leaked a child process"
2459        );
2460    }
2461}