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        .envs(ch_config::environment::vars_os())
1393        .arg("--version")
1394        .output()
1395        .wrap_err("failed to check the installed Watchexec version")?;
1396    if output.status.success() {
1397        return Ok(executable.into_os_string());
1398    }
1399
1400    let stderr = String::from_utf8_lossy(&output.stderr);
1401    let details = stderr.trim();
1402    if details.is_empty() {
1403        bail!(
1404            "Watchexec is required for backend reloads; install it with `cargo install watchexec-cli --version 2.5.1 --locked`"
1405        );
1406    }
1407
1408    bail!(
1409        "Watchexec is required for backend reloads; install it with `cargo install watchexec-cli --version 2.5.1 --locked`\n\n{details}"
1410    );
1411}
1412
1413fn resolve_backend_watch_paths(
1414    profile: &ProjectProfile,
1415    paths: &WorkspacePaths,
1416) -> DevResult<BackendWatchPaths> {
1417    let metadata = load_cargo_metadata(paths, "backend reloads")?;
1418    backend_watch_paths_from_metadata(&metadata, profile.bin_target())
1419}
1420
1421fn load_cargo_metadata(paths: &WorkspacePaths, purpose: &str) -> DevResult<CargoMetadata> {
1422    let output = ProcessCommand::new(cargo())
1423        .envs(ch_config::environment::vars_os())
1424        .args(["metadata", "--format-version=1", "--locked"])
1425        .current_dir(&paths.project_root)
1426        .output()
1427        .wrap_err_with(|| format!("failed to read Cargo metadata for {purpose}"))?;
1428    if !output.status.success() {
1429        let details = String::from_utf8_lossy(&output.stderr);
1430        bail!("Cargo metadata failed for {purpose}: {}", details.trim());
1431    }
1432
1433    serde_json::from_slice::<CargoMetadata>(&output.stdout)
1434        .wrap_err_with(|| format!("failed to parse Cargo metadata for {purpose}"))
1435}
1436
1437fn backend_watch_paths_from_metadata(
1438    metadata: &CargoMetadata,
1439    bin_target: &str,
1440) -> DevResult<BackendWatchPaths> {
1441    let root_ids = metadata
1442        .packages
1443        .iter()
1444        .filter(|package| {
1445            package.targets.iter().any(|target| {
1446                target.name == bin_target && target.kind.iter().any(|kind| kind == "bin")
1447            })
1448        })
1449        .map(|package| package.id.as_str())
1450        .collect::<Vec<_>>();
1451    ensure!(
1452        !root_ids.is_empty(),
1453        "Cargo metadata contains no binary target named `{bin_target}`"
1454    );
1455
1456    watch_paths_from_metadata_roots(metadata, root_ids)
1457}
1458
1459fn package_watch_paths_from_metadata(
1460    metadata: &CargoMetadata,
1461    package_name: &str,
1462) -> DevResult<BackendWatchPaths> {
1463    let root_ids = metadata
1464        .packages
1465        .iter()
1466        .filter(|package| package.name == package_name)
1467        .map(|package| package.id.as_str())
1468        .collect::<Vec<_>>();
1469    ensure!(
1470        !root_ids.is_empty(),
1471        "Cargo metadata contains no package named `{package_name}`"
1472    );
1473
1474    watch_paths_from_metadata_roots(metadata, root_ids)
1475}
1476
1477fn watch_paths_from_metadata_roots<'a>(
1478    metadata: &'a CargoMetadata,
1479    root_ids: Vec<&'a str>,
1480) -> DevResult<BackendWatchPaths> {
1481    let resolve = metadata
1482        .resolve
1483        .as_ref()
1484        .ok_or_else(|| color_eyre::eyre::eyre!("Cargo metadata did not include a resolve graph"))?;
1485
1486    let nodes = resolve
1487        .nodes
1488        .iter()
1489        .map(|node| (node.id.as_str(), node))
1490        .collect::<HashMap<_, _>>();
1491    let mut reachable = BTreeSet::new();
1492    let mut pending = root_ids.into_iter().collect::<VecDeque<_>>();
1493    while let Some(package_id) = pending.pop_front() {
1494        if !reachable.insert(package_id) {
1495            continue;
1496        }
1497        if let Some(node) = nodes.get(package_id) {
1498            pending.extend(node.deps.iter().map(|dependency| dependency.pkg.as_str()));
1499        }
1500    }
1501
1502    let mut watch_paths = BTreeSet::new();
1503    for package in metadata
1504        .packages
1505        .iter()
1506        .filter(|package| package.source.is_none() && reachable.contains(package.id.as_str()))
1507    {
1508        let package_root = package.manifest_path.parent().ok_or_else(|| {
1509            color_eyre::eyre::eyre!(
1510                "Cargo package manifest has no parent: {}",
1511                package.manifest_path.display()
1512            )
1513        })?;
1514        insert_existing_watch_path(&mut watch_paths, &package.manifest_path)?;
1515
1516        let workspace_root = find_workspace_root(package_root)?;
1517        insert_existing_watch_path(&mut watch_paths, &workspace_root.join("Cargo.toml"))?;
1518        for workspace_file in ["Cargo.lock", "rust-toolchain.toml", ".rust-version"] {
1519            let workspace_file = workspace_root.join(workspace_file);
1520            if workspace_file.is_file() {
1521                insert_existing_watch_path(&mut watch_paths, &workspace_file)?;
1522            }
1523        }
1524        let cargo_config_dir = workspace_root.join(".cargo");
1525        if cargo_config_dir.is_dir() {
1526            insert_existing_watch_path(&mut watch_paths, &cargo_config_dir)?;
1527        }
1528
1529        let conventional_source_root = package_root.join("src");
1530        if conventional_source_root.is_dir() {
1531            insert_existing_watch_path(&mut watch_paths, &conventional_source_root)?;
1532        }
1533        for target in &package.targets {
1534            if !target.src_path.starts_with(&conventional_source_root) && target.src_path.is_file()
1535            {
1536                insert_existing_watch_path(&mut watch_paths, &target.src_path)?;
1537            }
1538        }
1539
1540        let migrations = package_root.join("migrations");
1541        if migrations.is_dir() {
1542            insert_existing_watch_path(&mut watch_paths, &migrations)?;
1543        }
1544    }
1545
1546    Ok(BackendWatchPaths {
1547        paths: watch_paths.into_iter().collect(),
1548    })
1549}
1550
1551fn insert_existing_watch_path(paths: &mut BTreeSet<PathBuf>, path: &Path) -> DevResult {
1552    let canonical = path
1553        .canonicalize()
1554        .wrap_err_with(|| format!("failed to resolve backend watch path {}", path.display()))?;
1555    paths.insert(canonical);
1556    Ok(())
1557}
1558
1559fn api_addr(profile: &ProjectProfile) -> DevResult<SocketAddr> {
1560    profile.api_addr.parse::<SocketAddr>().wrap_err_with(|| {
1561        format!(
1562            "invalid site address `{}` for project `{}`",
1563            profile.api_addr, profile.app_id
1564        )
1565    })
1566}
1567
1568fn web_dev_envs(profile: &ProjectProfile, backend_origin: &str) -> Vec<(String, String)> {
1569    let mut envs = Vec::new();
1570    if let Some(origin_env) = &profile.web_backend_origin_env {
1571        envs.push((origin_env.clone(), backend_origin.to_string()));
1572    }
1573    envs
1574}
1575
1576fn backend_envs(profile: &ProjectProfile) -> Vec<(String, String)> {
1577    profile
1578        .backend_target_dir
1579        .as_ref()
1580        .map(|target_dir| vec![("CARGO_TARGET_DIR".to_string(), target_dir.clone())])
1581        .unwrap_or_default()
1582}
1583
1584fn wait_for_watch_processes(
1585    profile: &ProjectProfile,
1586    paths: &WorkspacePaths,
1587    api_addr: SocketAddr,
1588    backend: &mut BackendWatchRuntime,
1589    web: &mut ManagedChild,
1590    public_asset_watchers: &mut [ManagedChild],
1591) -> DevResult {
1592    loop {
1593        if let Some(status) = backend
1594            .process
1595            .try_wait()
1596            .wrap_err("failed to poll Rust backend")?
1597        {
1598            if status.success() {
1599                bail!("Rust backend exited before the web dev server");
1600            }
1601            bail!("Rust backend exited with status {:?}", status.code());
1602        }
1603
1604        if let Some(builder) = backend.builder.as_mut()
1605            && let Some(status) = builder
1606                .try_wait()
1607                .wrap_err("failed to poll Rust backend build watcher")?
1608        {
1609            bail!(
1610                "Rust backend build watcher exited unexpectedly with status {:?}",
1611                status.code()
1612            );
1613        }
1614
1615        refresh_backend_after_successful_build(profile, paths, api_addr, backend)?;
1616
1617        if let Some(status) = web.try_wait().wrap_err("failed to poll web dev server")? {
1618            if status.success() {
1619                return Ok(());
1620            }
1621            bail!("web dev server exited with status {:?}", status.code());
1622        }
1623
1624        let mut watcher_exit = None;
1625        for watcher in public_asset_watchers.iter_mut() {
1626            if let Some(status) = watcher
1627                .try_wait()
1628                .wrap_err("failed to poll a public asset watcher")?
1629            {
1630                watcher_exit = Some(status);
1631                break;
1632            }
1633        }
1634        if let Some(status) = watcher_exit {
1635            bail!(
1636                "public asset watcher exited unexpectedly with status {:?}",
1637                status.code()
1638            );
1639        }
1640
1641        thread::sleep(Duration::from_millis(250));
1642    }
1643}
1644
1645fn refresh_backend_after_successful_build(
1646    profile: &ProjectProfile,
1647    paths: &WorkspacePaths,
1648    api_addr: SocketAddr,
1649    backend: &mut BackendWatchRuntime,
1650) -> DevResult {
1651    let Some(artifact) = backend.artifact.as_ref() else {
1652        return Ok(());
1653    };
1654    let Some(candidate_stamp) = backend_artifact_stamp_if_present(artifact)? else {
1655        // Cargo may temporarily remove the final executable while rustc links a
1656        // replacement. The build watcher still owns that transition, so keep
1657        // serving the last successful runtime image until the artifact appears.
1658        return Ok(());
1659    };
1660    if backend.active_stamp == Some(candidate_stamp) {
1661        return Ok(());
1662    }
1663
1664    let candidate_generation = backend.generation.saturating_add(1);
1665    let candidate_image = backend_runtime_image_path(profile, artifact, candidate_generation);
1666    copy_backend_runtime_image(artifact, &candidate_image)?;
1667    backend.process.terminate();
1668    let candidate = spawn_command(
1669        "rebuilt Rust backend",
1670        candidate_image.as_os_str().to_os_string(),
1671        &[] as &[&str],
1672        &paths.project_root,
1673        &[],
1674    );
1675    let candidate = candidate.and_then(|mut candidate| {
1676        wait_for_backend_ready(
1677            &mut candidate,
1678            api_addr,
1679            profile.backend_ready_timeout,
1680            profile.backend_ready_path.as_deref(),
1681        )
1682        .map(|()| candidate)
1683    });
1684
1685    match candidate {
1686        Ok(candidate) => {
1687            remove_backend_runtime_image(backend.active_image.replace(candidate_image));
1688            backend.process = candidate;
1689            backend.active_stamp = Some(candidate_stamp);
1690            backend.generation = candidate_generation;
1691            eprintln!(
1692                "Activated rebuilt Rust backend generation {} from {}",
1693                backend.generation,
1694                artifact.display()
1695            );
1696            Ok(())
1697        }
1698        Err(error) => {
1699            remove_backend_runtime_image(Some(candidate_image));
1700            let fallback_image = backend.active_image.as_ref().ok_or_else(|| {
1701                color_eyre::eyre::eyre!(
1702                    "rebuilt Rust backend failed and no last successful runtime image exists"
1703                )
1704            })?;
1705            let mut fallback = spawn_command(
1706                "last successful Rust backend",
1707                fallback_image.as_os_str().to_os_string(),
1708                &[] as &[&str],
1709                &paths.project_root,
1710                &[],
1711            )?;
1712            wait_for_backend_ready(
1713                &mut fallback,
1714                api_addr,
1715                profile.backend_ready_timeout,
1716                profile.backend_ready_path.as_deref(),
1717            )?;
1718            backend.process = fallback;
1719            backend.active_stamp = Some(candidate_stamp);
1720            eprintln!(
1721                "Rebuilt Rust backend did not become ready: {error:#}. Continuing with the last successful backend until another build succeeds."
1722            );
1723            Ok(())
1724        }
1725    }
1726}
1727
1728impl Drop for BackendWatchRuntime {
1729    fn drop(&mut self) {
1730        self.process.terminate();
1731        if let Some(builder) = self.builder.as_mut() {
1732            builder.terminate();
1733        }
1734        remove_backend_runtime_image(self.active_image.take());
1735    }
1736}
1737
1738fn remove_backend_runtime_image(path: Option<PathBuf>) {
1739    if let Some(path) = path {
1740        let _ = fs::remove_file(path);
1741    }
1742}
1743
1744fn run_command<S: AsRef<OsStr>>(
1745    label: &str,
1746    executable: OsString,
1747    args: &[S],
1748    cwd: &Path,
1749    envs: &[(String, String)],
1750) -> DevResult {
1751    let mut child = spawn_command(label, executable, args, cwd, envs)?;
1752    let status = child
1753        .wait()
1754        .wrap_err_with(|| format!("failed to wait for {label}"))?;
1755    if status.success() {
1756        return Ok(());
1757    }
1758
1759    bail!("{label} exited with status {:?}", status.code());
1760}
1761
1762fn spawn_command<S: AsRef<OsStr>>(
1763    label: &str,
1764    executable: OsString,
1765    args: &[S],
1766    cwd: &Path,
1767    envs: &[(String, String)],
1768) -> DevResult<ManagedChild> {
1769    let mut command = ProcessCommand::new(executable);
1770    command
1771        .current_dir(cwd)
1772        .args(args)
1773        .envs(ch_config::environment::vars_os());
1774    for (key, value) in envs {
1775        command.env(key, value);
1776    }
1777
1778    command
1779        .spawn()
1780        .map(|child| ManagedChild { child })
1781        .wrap_err_with(|| format!("failed to start {label}"))
1782}
1783
1784fn path_arg(path: &Path) -> DevResult<&str> {
1785    path.to_str()
1786        .ok_or_else(|| color_eyre::eyre::eyre!("path is not valid UTF-8: {}", path.display()))
1787}
1788
1789fn cargo() -> OsString {
1790    ch_config::environment::var_os("CARGO").unwrap_or_else(|| OsString::from("cargo"))
1791}
1792
1793fn npm() -> OsString {
1794    match cfg!(windows) {
1795        true => OsString::from("npm.cmd"),
1796        false => OsString::from("npm"),
1797    }
1798}
1799
1800fn resolve_wasm_bindgen(required_version: &str) -> DevResult<OsString> {
1801    if let Some(configured) = ch_config::environment::var_os("WASM_BINDGEN") {
1802        if wasm_bindgen_matches(&configured, required_version) {
1803            return Ok(configured);
1804        }
1805        bail!(
1806            "WASM_BINDGEN does not point to wasm-bindgen {}",
1807            required_version
1808        );
1809    }
1810
1811    if let Some(executable) = find_on_path("wasm-bindgen") {
1812        let executable = executable.into_os_string();
1813        if wasm_bindgen_matches(&executable, required_version) {
1814            return Ok(executable);
1815        }
1816    }
1817
1818    bail!(
1819        "wasm-bindgen {} is required for configured WASM public assets; install it with `cargo install wasm-bindgen-cli --version {} --locked` or set WASM_BINDGEN",
1820        required_version,
1821        required_version
1822    );
1823}
1824
1825fn wasm_bindgen_matches(executable: &OsString, required_version: &str) -> bool {
1826    let output = ProcessCommand::new(executable)
1827        .envs(ch_config::environment::vars_os())
1828        .arg("--version")
1829        .output();
1830    let Ok(output) = output else {
1831        return false;
1832    };
1833    let text = String::from_utf8_lossy(&output.stdout);
1834    output.status.success() && text.contains(required_version)
1835}
1836
1837fn find_on_path(name: &str) -> Option<PathBuf> {
1838    let path = ch_config::environment::var_os("PATH")?;
1839    for directory in env::split_paths(&path) {
1840        let candidate = directory.join(name);
1841        if candidate.is_file() {
1842            return Some(candidate);
1843        }
1844        #[cfg(windows)]
1845        {
1846            let candidate = directory.join(format!("{name}.exe"));
1847            if candidate.is_file() {
1848                return Some(candidate);
1849            }
1850        }
1851    }
1852    None
1853}
1854
1855#[cfg(test)]
1856mod tests {
1857    use super::*;
1858    use std::net::TcpListener;
1859    use std::sync::atomic::{AtomicU64, Ordering};
1860
1861    static TEMP_SEQUENCE: AtomicU64 = AtomicU64::new(0);
1862
1863    struct TestDirectory(PathBuf);
1864
1865    impl TestDirectory {
1866        fn new(label: &str) -> Self {
1867            let sequence = TEMP_SEQUENCE.fetch_add(1, Ordering::Relaxed);
1868            let path = env::temp_dir().join(format!(
1869                "cargo-workspace-{label}-{}-{sequence}",
1870                std::process::id()
1871            ));
1872            fs::create_dir_all(&path).expect("test directory should be created");
1873            Self(path)
1874        }
1875
1876        fn path(&self) -> &Path {
1877            &self.0
1878        }
1879    }
1880
1881    impl Drop for TestDirectory {
1882        fn drop(&mut self) {
1883            let _ = fs::remove_dir_all(&self.0);
1884        }
1885    }
1886
1887    #[test]
1888    fn project_profile_reuses_the_explicit_identity_by_default() {
1889        let profile = ProjectProfile::new("example-service", "127.0.0.1:4100");
1890
1891        assert_eq!(profile.app_id(), "example-service");
1892        assert_eq!(profile.bin_target(), "example-service");
1893        profile
1894            .validate("test profile")
1895            .expect("single-identity profile should be valid");
1896    }
1897
1898    #[test]
1899    fn project_profile_can_isolate_backend_builds() {
1900        let profile = ProjectProfile::new("example-service", "127.0.0.1:4100")
1901            .with_backend_target_dir("target/example-backend");
1902
1903        assert_eq!(
1904            backend_envs(&profile),
1905            vec![(
1906                "CARGO_TARGET_DIR".to_string(),
1907                "target/example-backend".to_string()
1908            )]
1909        );
1910        profile
1911            .validate("test profile")
1912            .expect("isolated backend profile should be valid");
1913    }
1914
1915    #[test]
1916    fn wasm_asset_can_use_a_project_relative_target_directory() {
1917        let asset = ProjectPublicAsset::wasm_bindgen(
1918            "editor",
1919            "Example editor",
1920            "example-editor",
1921            "example.wasm",
1922            vec!["public".to_string()],
1923            "example",
1924            "0.2.126",
1925        )
1926        .with_target_dir("build/web/wasm");
1927
1928        asset
1929            .validate("test profile", "example-service")
1930            .expect("project-relative target directory should be valid");
1931    }
1932
1933    #[test]
1934    fn public_asset_command_parses_optional_absolute_target_directory() {
1935        let default = AppCli::try_parse_from(["app", "build-public-asset", "--id", "editor"])
1936            .expect("existing public asset invocation should parse");
1937        let AppCommand::BuildPublicAsset(default) = default.command else {
1938            panic!("expected public asset command");
1939        };
1940        assert_eq!(default.id, "editor");
1941        assert!(!default.release);
1942        assert!(default.target_dir.is_none());
1943
1944        let directory = TestDirectory::new("public-asset-cli-target");
1945        let target = directory.path().join("wasm-cache");
1946        let explicit = AppCli::try_parse_from(vec![
1947            OsString::from("app"),
1948            OsString::from("build-public-asset"),
1949            OsString::from("--id"),
1950            OsString::from("editor"),
1951            OsString::from("--release"),
1952            OsString::from("--target-dir"),
1953            target.as_os_str().to_os_string(),
1954        ])
1955        .expect("explicit public asset target directory should parse");
1956        let AppCommand::BuildPublicAsset(explicit) = explicit.command else {
1957            panic!("expected public asset command");
1958        };
1959        assert!(explicit.release);
1960        assert_eq!(explicit.target_dir, Some(target));
1961    }
1962
1963    #[test]
1964    fn public_asset_target_directory_preserves_defaults_and_absolute_override() {
1965        let directory = TestDirectory::new("public-asset-target-resolution");
1966        let paths = WorkspacePaths {
1967            workspace_root: directory.path().to_path_buf(),
1968            project_root: directory.path().to_path_buf(),
1969            web_root: directory.path().join("web"),
1970        };
1971        let profile = ProjectProfile::new("example-service", "127.0.0.1:4100");
1972        let ProjectPublicAsset::WasmBindgen(mut asset) = ProjectPublicAsset::wasm_bindgen(
1973            "editor",
1974            "Example editor",
1975            "example-editor",
1976            "example.wasm",
1977            vec!["public".to_owned()],
1978            "example",
1979            "0.2.126",
1980        );
1981        assert_eq!(
1982            resolve_public_asset_target_dir(&profile, &paths, &asset, None).unwrap(),
1983            paths
1984                .project_root
1985                .join("target/example-service-dev-editor-build")
1986        );
1987        asset.target_dir = Some("build/web/wasm".to_owned());
1988        assert_eq!(
1989            resolve_public_asset_target_dir(&profile, &paths, &asset, None).unwrap(),
1990            paths.project_root.join("build/web/wasm")
1991        );
1992        let explicit = directory.path().join("separate-ssd-cache");
1993        assert_eq!(
1994            resolve_public_asset_target_dir(&profile, &paths, &asset, Some(&explicit)).unwrap(),
1995            explicit
1996        );
1997        assert_eq!(asset.target_dir.as_deref(), Some("build/web/wasm"));
1998        for invalid in [
1999            PathBuf::new(),
2000            PathBuf::from("relative/cache"),
2001            directory.path().join("../cache"),
2002        ] {
2003            assert!(
2004                resolve_public_asset_target_dir(&profile, &paths, &asset, Some(&invalid)).is_err()
2005            );
2006        }
2007    }
2008
2009    #[test]
2010    fn missing_backend_artifact_is_a_transient_watch_state() {
2011        let directory = TestDirectory::new("backend-artifact-transition");
2012        let artifact = directory.path().join("example-server");
2013
2014        assert_eq!(
2015            backend_artifact_stamp_if_present(&artifact)
2016                .expect("missing artifact should remain a valid watch state"),
2017            None
2018        );
2019
2020        fs::write(&artifact, b"new backend").expect("backend artifact should be written");
2021        let stamp = backend_artifact_stamp_if_present(&artifact)
2022            .expect("finished artifact should be readable")
2023            .expect("finished artifact should be present");
2024        assert_eq!(stamp.length, 11);
2025    }
2026
2027    #[test]
2028    fn builds_backend_artifacts_before_restarting_the_running_generation() {
2029        let directory = TestDirectory::new("watchexec-args");
2030        let profile = ProjectProfile::new("example-service", "127.0.0.1:4100")
2031            .with_bin_target("example-server");
2032        let paths = WorkspacePaths {
2033            workspace_root: directory.path().to_path_buf(),
2034            project_root: directory.path().to_path_buf(),
2035            web_root: directory.path().join("web"),
2036        };
2037        let watch_paths = BackendWatchPaths {
2038            paths: vec![directory.path().join("src")],
2039        };
2040
2041        assert_eq!(
2042            backend_watchexec_args(&profile, &paths, &watch_paths, false),
2043            [
2044                OsString::from("--on-busy-update=queue"),
2045                OsString::from("--debounce=750ms"),
2046                OsString::from("--shell=none"),
2047                OsString::from("--emit-events-to=none"),
2048                OsString::from("--project-origin"),
2049                directory.path().as_os_str().to_os_string(),
2050                OsString::from("--exts"),
2051                OsString::from("rs,toml,lock,sql"),
2052                OsString::from("--watch"),
2053                directory.path().join("src").into_os_string(),
2054                OsString::from("--"),
2055                cargo(),
2056                OsString::from("build"),
2057                OsString::from("--bin"),
2058                OsString::from("example-server"),
2059                OsString::from("--locked"),
2060            ]
2061        );
2062
2063        let release_args = backend_watchexec_args(&profile, &paths, &watch_paths, true);
2064        assert_eq!(release_args.last(), Some(&OsString::from("--release")));
2065    }
2066
2067    #[test]
2068    fn public_asset_watcher_rebuilds_after_the_initial_public_asset_gate() {
2069        let directory = TestDirectory::new("public-asset-watcher");
2070        let paths = WorkspacePaths {
2071            workspace_root: directory.path().to_path_buf(),
2072            project_root: directory.path().to_path_buf(),
2073            web_root: directory.path().join("web"),
2074        };
2075        let watch_paths = BackendWatchPaths {
2076            paths: vec![directory.path().join("src")],
2077        };
2078        let asset = ProjectPublicAsset::wasm_bindgen(
2079            "editor",
2080            "Example editor",
2081            "example-editor",
2082            "example.wasm",
2083            vec!["public".to_string()],
2084            "example",
2085            "0.2.126",
2086        );
2087
2088        let args = public_asset_watchexec_args(&paths, &asset, &watch_paths, false)
2089            .expect("public asset watcher command should resolve");
2090
2091        // The blocking initial build runs before watch_project. The watcher
2092        // then performs every later rebuild through the same asset command.
2093        assert!(!args.contains(&OsString::from("--postpone")));
2094        assert!(args.contains(&OsString::from("--on-busy-update=queue")));
2095        assert!(args.contains(&OsString::from("--debounce=750ms")));
2096        assert!(args.contains(&OsString::from("build-public-asset")));
2097    }
2098
2099    #[test]
2100    fn watch_cli_can_start_the_backend_once_without_a_reload_supervisor() {
2101        let cli = AppCli::try_parse_from(["project-xtask", "watch", "--no-backend-reload"])
2102            .expect("watch command should accept the backend reload opt-out");
2103
2104        let AppCommand::Watch(command) = cli.command else {
2105            panic!("watch command should preserve its typed owner");
2106        };
2107        assert!(command.no_backend_reload);
2108        assert!(!command.run.release);
2109    }
2110
2111    #[test]
2112    fn optional_asset_switch_is_available_for_watch_serve_and_build() {
2113        for command_args in [
2114            ["project-xtask", "watch", "--editor"].as_slice(),
2115            ["project-xtask", "serve", "--with-optional-assets"].as_slice(),
2116            ["project-xtask", "build", "--editor"].as_slice(),
2117        ] {
2118            let cli = AppCli::try_parse_from(command_args)
2119                .expect("optional asset switch should be accepted by every run mode");
2120
2121            let include_optional_assets = match cli.command {
2122                AppCommand::Watch(command) => command.run.optional_assets.include_optional_assets,
2123                AppCommand::Serve(command) => command.optional_assets.include_optional_assets,
2124                AppCommand::Build(command) => command.optional_assets.include_optional_assets,
2125                AppCommand::BuildPublicAsset(_) => {
2126                    panic!("internal public asset command is not part of this matrix")
2127                }
2128            };
2129            assert!(include_optional_assets);
2130        }
2131    }
2132
2133    #[test]
2134    fn optional_public_assets_are_not_active_without_the_explicit_switch() {
2135        let asset = ProjectPublicAsset::wasm_bindgen(
2136            "editor",
2137            "Example editor",
2138            "example-editor",
2139            "example.wasm",
2140            vec!["public".to_string()],
2141            "example",
2142            "0.2.126",
2143        );
2144        let profile = ProjectProfile::new("example-service", "127.0.0.1:4100")
2145            .with_optional_public_asset(asset);
2146
2147        let default_profile = activate_optional_public_assets(profile.clone(), false);
2148        assert!(default_profile.public_assets.is_empty());
2149        assert_eq!(default_profile.optional_public_assets.len(), 1);
2150
2151        let enabled_profile = activate_optional_public_assets(profile, true);
2152        assert_eq!(enabled_profile.public_assets.len(), 1);
2153        assert!(enabled_profile.optional_public_assets.is_empty());
2154    }
2155
2156    #[test]
2157    fn watches_only_local_packages_reachable_from_the_backend() {
2158        let directory = TestDirectory::new("cargo-metadata");
2159        let app_workspace = directory.path().join("app-workspace");
2160        let app_root = app_workspace.join("app");
2161        let unused_root = app_workspace.join("unused");
2162        let shared_workspace = directory.path().join("shared-workspace");
2163        let shared_root = shared_workspace.join("crates").join("shared");
2164
2165        for source_root in [
2166            app_root.join("src"),
2167            unused_root.join("src"),
2168            shared_root.join("src"),
2169        ] {
2170            fs::create_dir_all(source_root).expect("source directory should be created");
2171        }
2172        fs::write(
2173            app_workspace.join("Cargo.toml"),
2174            "[workspace]\nmembers = [\"app\", \"unused\"]\nresolver = \"3\"\n",
2175        )
2176        .expect("app workspace manifest should be written");
2177        fs::write(
2178            shared_workspace.join("Cargo.toml"),
2179            "[workspace]\nmembers = [\"crates/shared\"]\nresolver = \"3\"\n",
2180        )
2181        .expect("shared workspace manifest should be written");
2182        for (root, name) in [
2183            (&app_root, "app"),
2184            (&unused_root, "unused"),
2185            (&shared_root, "shared"),
2186        ] {
2187            fs::write(
2188                root.join("Cargo.toml"),
2189                format!("[package]\nname = \"{name}\"\nversion = \"0.1.0\"\n"),
2190            )
2191            .expect("package manifest should be written");
2192        }
2193
2194        let package = |id: &str, root: &Path, target_name: &str, kind: &str| CargoPackage {
2195            id: id.to_string(),
2196            name: id.to_string(),
2197            source: None,
2198            manifest_path: root.join("Cargo.toml"),
2199            targets: vec![CargoTarget {
2200                kind: vec![kind.to_string()],
2201                name: target_name.to_string(),
2202                src_path: root
2203                    .join("src")
2204                    .join(if kind == "bin" { "main.rs" } else { "lib.rs" }),
2205            }],
2206        };
2207        let metadata = CargoMetadata {
2208            packages: vec![
2209                package("app", &app_root, "example-server", "bin"),
2210                package("shared", &shared_root, "shared", "lib"),
2211                package("unused", &unused_root, "unused", "lib"),
2212                CargoPackage {
2213                    id: "registry".to_string(),
2214                    name: "registry".to_string(),
2215                    source: Some("registry+https://example.invalid".to_string()),
2216                    manifest_path: directory.path().join("registry").join("Cargo.toml"),
2217                    targets: Vec::new(),
2218                },
2219            ],
2220            resolve: Some(CargoResolve {
2221                nodes: vec![
2222                    CargoResolveNode {
2223                        id: "app".to_string(),
2224                        deps: vec![
2225                            CargoResolveDependency {
2226                                pkg: "shared".to_string(),
2227                            },
2228                            CargoResolveDependency {
2229                                pkg: "registry".to_string(),
2230                            },
2231                        ],
2232                    },
2233                    CargoResolveNode {
2234                        id: "shared".to_string(),
2235                        deps: Vec::new(),
2236                    },
2237                    CargoResolveNode {
2238                        id: "unused".to_string(),
2239                        deps: Vec::new(),
2240                    },
2241                    CargoResolveNode {
2242                        id: "registry".to_string(),
2243                        deps: Vec::new(),
2244                    },
2245                ],
2246            }),
2247        };
2248
2249        let watch_paths = backend_watch_paths_from_metadata(&metadata, "example-server")
2250            .expect("backend watch paths should resolve");
2251        let public_asset_watch_paths = package_watch_paths_from_metadata(&metadata, "app")
2252            .expect("public asset watch paths should resolve");
2253
2254        assert_eq!(watch_paths, public_asset_watch_paths);
2255
2256        assert!(
2257            watch_paths
2258                .paths
2259                .contains(&app_root.join("src").canonicalize().unwrap())
2260        );
2261        assert!(
2262            watch_paths
2263                .paths
2264                .contains(&shared_root.join("src").canonicalize().unwrap())
2265        );
2266        assert!(
2267            !watch_paths
2268                .paths
2269                .contains(&unused_root.join("src").canonicalize().unwrap())
2270        );
2271        assert!(
2272            watch_paths
2273                .paths
2274                .contains(&app_workspace.join("Cargo.toml").canonicalize().unwrap())
2275        );
2276        assert!(
2277            watch_paths
2278                .paths
2279                .contains(&shared_workspace.join("Cargo.toml").canonicalize().unwrap())
2280        );
2281        assert!(
2282            !watch_paths
2283                .paths
2284                .contains(&app_root.canonicalize().unwrap())
2285        );
2286    }
2287
2288    #[test]
2289    fn project_profile_keeps_an_explicit_binary_override() {
2290        let profile = ProjectProfile::new("example-service", "127.0.0.1:4100")
2291            .with_bin_target("custom-server");
2292
2293        assert_eq!(profile.app_id(), "example-service");
2294        assert_eq!(profile.bin_target(), "custom-server");
2295    }
2296
2297    #[test]
2298    fn project_profile_accepts_a_cold_backend_build_timeout() {
2299        let profile = ProjectProfile::new("example-service", "127.0.0.1:4100")
2300            .with_backend_ready_timeout(Duration::from_secs(900));
2301
2302        assert_eq!(profile.backend_ready_timeout, Duration::from_secs(900));
2303        profile
2304            .validate("test profile")
2305            .expect("positive readiness timeout should be valid");
2306    }
2307
2308    #[test]
2309    fn project_profile_rejects_a_zero_backend_build_timeout() {
2310        let profile = ProjectProfile::new("example-service", "127.0.0.1:4100")
2311            .with_backend_ready_timeout(Duration::ZERO);
2312
2313        let error = profile
2314            .validate("test profile")
2315            .expect_err("zero readiness timeout should be rejected");
2316        assert!(error.to_string().contains("zero backend readiness timeout"));
2317    }
2318
2319    #[test]
2320    fn project_profile_accepts_an_http_backend_readiness_path() {
2321        let profile = ProjectProfile::new("example-service", "127.0.0.1:4100")
2322            .with_backend_ready_path("/ready");
2323
2324        assert_eq!(profile.backend_ready_path.as_deref(), Some("/ready"));
2325        profile
2326            .validate("test profile")
2327            .expect("absolute HTTP readiness path should be valid");
2328    }
2329
2330    #[test]
2331    fn project_profile_rejects_an_invalid_http_backend_readiness_path() {
2332        let profile = ProjectProfile::new("example-service", "127.0.0.1:4100")
2333            .with_backend_ready_path("ready\r\ninvalid");
2334
2335        let error = profile
2336            .validate("test profile")
2337            .expect_err("invalid HTTP readiness path should be rejected");
2338        assert!(error.to_string().contains("invalid backend readiness path"));
2339    }
2340
2341    #[test]
2342    fn http_readiness_requires_an_http_response() {
2343        let listener = TcpListener::bind("127.0.0.1:0").expect("listener should bind");
2344        let addr = listener
2345            .local_addr()
2346            .expect("listener address should resolve");
2347
2348        probe_backend_http(addr, "/ready")
2349            .expect_err("a bound socket without a serving backend is not ready");
2350    }
2351
2352    #[test]
2353    fn http_readiness_accepts_a_serving_backend() {
2354        let listener = TcpListener::bind("127.0.0.1:0").expect("listener should bind");
2355        let addr = listener
2356            .local_addr()
2357            .expect("listener address should resolve");
2358        let server = thread::spawn(move || {
2359            let (mut stream, _) = listener.accept().expect("readiness request should connect");
2360            let mut request = [0_u8; 256];
2361            let read = stream
2362                .read(&mut request)
2363                .expect("readiness request should be readable");
2364            assert!(read > 0);
2365            stream
2366                .write_all(b"HTTP/1.1 204 No Content\r\nContent-Length: 0\r\n\r\n")
2367                .expect("readiness response should be written");
2368        });
2369
2370        probe_backend_http(addr, "/ready").expect("serving backend should be ready");
2371        server.join().expect("readiness server should finish");
2372    }
2373
2374    #[test]
2375    fn finds_the_owning_cargo_workspace_above_a_member_package() {
2376        let directory = TestDirectory::new("member");
2377        let workspace_root = directory.path().join("workspace");
2378        let package_root = workspace_root.join("crates").join("service");
2379        let source_root = package_root.join("src");
2380        fs::create_dir_all(&source_root).expect("member source directory should be created");
2381        fs::write(
2382            workspace_root.join("Cargo.toml"),
2383            "[workspace]\nmembers = [\"crates/service\"]\n",
2384        )
2385        .expect("workspace manifest should be written");
2386        fs::write(
2387            package_root.join("Cargo.toml"),
2388            "[package]\nname = \"service\"\nversion = \"0.1.0\"\n",
2389        )
2390        .expect("package manifest should be written");
2391
2392        assert_eq!(
2393            find_workspace_root(&source_root).expect("workspace should resolve"),
2394            workspace_root
2395        );
2396    }
2397
2398    #[test]
2399    fn uses_the_nearest_standalone_package_when_no_workspace_exists() {
2400        let directory = TestDirectory::new("standalone");
2401        let package_root = directory.path().join("service");
2402        let source_root = package_root.join("src");
2403        fs::create_dir_all(&source_root).expect("package source directory should be created");
2404        fs::write(
2405            package_root.join("Cargo.toml"),
2406            "[package]\nname = \"service\"\nversion = \"0.1.0\"\n",
2407        )
2408        .expect("package manifest should be written");
2409
2410        assert_eq!(
2411            find_workspace_root(&source_root).expect("package root should resolve"),
2412            package_root
2413        );
2414    }
2415
2416    #[test]
2417    fn reports_an_invalid_cargo_manifest() {
2418        let directory = TestDirectory::new("invalid");
2419        let package_root = directory.path().join("service");
2420        fs::create_dir_all(&package_root).expect("package directory should be created");
2421        fs::write(package_root.join("Cargo.toml"), "[workspace\n")
2422            .expect("invalid manifest should be written");
2423
2424        let error = find_workspace_root(&package_root)
2425            .expect_err("an invalid Cargo manifest must not be ignored");
2426        assert!(error.to_string().contains("failed to parse Cargo manifest"));
2427    }
2428    #[cfg(target_os = "linux")]
2429    #[test]
2430    fn managed_child_is_terminated_and_reaped_on_an_error_return() {
2431        let directory = tempfile::tempdir().unwrap();
2432        let pid_file = directory.path().join("process.pid");
2433        let result = (|| -> DevResult<u32> {
2434            let child = spawn_command(
2435                "owned test child",
2436                std::env::current_exe().unwrap().into_os_string(),
2437                &[
2438                    "--ignored",
2439                    "--exact",
2440                    "docker::tests::process_fixture",
2441                    "--nocapture",
2442                ],
2443                directory.path(),
2444                &[
2445                    ("RUNTIME_TEST_PROCESS_CASE".into(), "wait".into()),
2446                    (
2447                        "RUNTIME_TEST_PROCESS_PID".into(),
2448                        pid_file.to_string_lossy().into_owned(),
2449                    ),
2450                ],
2451            )?;
2452            let pid = child.id();
2453            let start = Instant::now();
2454            while !pid_file.exists() {
2455                assert!(start.elapsed() < Duration::from_secs(10));
2456                thread::sleep(Duration::from_millis(10));
2457            }
2458            Err(color_eyre::eyre::eyre!(
2459                "setup failed after starting process {pid}"
2460            ))
2461        })();
2462        assert!(result.is_err());
2463        let pid = fs::read_to_string(pid_file).unwrap();
2464        assert!(
2465            !Path::new(&format!("/proc/{}", pid.trim())).exists(),
2466            "an error return leaked a child process"
2467        );
2468    }
2469}