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::net::{SocketAddr, TcpStream};
6use std::path::{Path, PathBuf};
7use std::process::{Child, Command as ProcessCommand};
8use std::thread;
9use std::time::{Duration, Instant};
10
11#[cfg(feature = "btcpay")]
12mod btcpay;
13
14#[cfg(feature = "btcpay")]
15pub use self::btcpay::BtcpayDevProfile;
16#[cfg(feature = "btcpay")]
17use self::btcpay::{LocalBtcpayBootstrapOptions, bootstrap_local_btcpay};
18use clap::{Args, Parser, Subcommand};
19use color_eyre::eyre::{Context, bail, ensure};
20use serde::Deserialize;
21use serde_json::Value;
22
23/// Result type returned by development workflow operations.
24pub type DevResult<T = ()> = color_eyre::Result<T>;
25
26#[derive(Parser, Debug)]
27#[command(name = "app-dev")]
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(AppRunCommand),
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}
43
44#[derive(Args, Debug)]
45struct AppRunCommand {
46    #[arg(long)]
47    release: bool,
48    #[arg(short, long, action = clap::ArgAction::Count)]
49    verbose: u8,
50}
51
52#[derive(Args, Debug)]
53struct AppBuildCommand {
54    #[arg(short, long, action = clap::ArgAction::Count)]
55    verbose: u8,
56}
57
58#[derive(Clone, Debug)]
59/// Describes the backend, web client, and generated assets of one project.
60pub struct ProjectProfile {
61    app_id: String,
62    web_dir: String,
63    bin_target: Option<String>,
64    api_addr: String,
65    web_backend_origin_env: Option<String>,
66    #[cfg(feature = "btcpay")]
67    btcpay: Option<BtcpayDevProfile>,
68    public_assets: Vec<ProjectPublicAsset>,
69}
70
71#[derive(Clone, Debug, Deserialize)]
72#[serde(tag = "kind", rename_all = "kebab-case")]
73/// A browser asset that must be built before the web client is served.
74pub enum ProjectPublicAsset {
75    /// A Rust package compiled to WebAssembly and processed by `wasm-bindgen`.
76    WasmBindgen(WasmBindgenPublicAsset),
77}
78
79#[derive(Clone, Debug, Deserialize)]
80/// Build settings for a public WebAssembly asset produced by `wasm-bindgen`.
81pub struct WasmBindgenPublicAsset {
82    id: String,
83    label: String,
84    package: String,
85    wasm_file: String,
86    #[serde(default)]
87    no_default_features: bool,
88    #[serde(default)]
89    features: String,
90    out_dir: Vec<String>,
91    out_name: String,
92    wasm_bindgen_version: String,
93}
94
95#[derive(Clone, Copy, Debug)]
96/// Selects how the backend and web client are run after preparation.
97pub enum RunMode {
98    /// Run both processes in development mode and rebuild on source changes.
99    Watch,
100    /// Build the web client once, then serve it from the backend.
101    Serve,
102}
103
104#[derive(Debug)]
105/// Resolved workspace, project, and web-client directories for a project.
106pub struct WorkspacePaths {
107    workspace_root: PathBuf,
108    project_root: PathBuf,
109    web_root: PathBuf,
110}
111
112#[derive(Debug, Deserialize)]
113struct CargoMetadata {
114    packages: Vec<CargoPackage>,
115    resolve: Option<CargoResolve>,
116}
117
118#[derive(Debug, Deserialize)]
119struct CargoPackage {
120    id: String,
121    source: Option<String>,
122    manifest_path: PathBuf,
123    targets: Vec<CargoTarget>,
124}
125
126#[derive(Debug, Deserialize)]
127struct CargoTarget {
128    kind: Vec<String>,
129    name: String,
130    src_path: PathBuf,
131}
132
133#[derive(Debug, Deserialize)]
134struct CargoResolve {
135    nodes: Vec<CargoResolveNode>,
136}
137
138#[derive(Debug, Deserialize)]
139struct CargoResolveNode {
140    id: String,
141    deps: Vec<CargoResolveDependency>,
142}
143
144#[derive(Debug, Deserialize)]
145struct CargoResolveDependency {
146    pkg: String,
147}
148
149#[derive(Debug, Default, Eq, PartialEq)]
150struct BackendWatchPaths {
151    paths: Vec<PathBuf>,
152}
153
154const BACKEND_READY_TIMEOUT: Duration = Duration::from_secs(300);
155const BACKEND_READY_POLL_INTERVAL: Duration = Duration::from_millis(250);
156const BACKEND_READY_CONNECT_TIMEOUT: Duration = Duration::from_millis(200);
157
158fn default_web_dir() -> String {
159    "web".to_string()
160}
161
162impl ProjectProfile {
163    /// Creates a project profile from its explicit project id and backend bind address.
164    ///
165    /// The backend binary target defaults to the same project id. Callers can
166    /// override it when the Cargo binary intentionally uses a different name.
167    pub fn new(app_id: impl Into<String>, api_addr: impl Into<String>) -> Self {
168        Self {
169            app_id: app_id.into(),
170            web_dir: default_web_dir(),
171            bin_target: None,
172            api_addr: api_addr.into(),
173            web_backend_origin_env: None,
174            #[cfg(feature = "btcpay")]
175            btcpay: None,
176            public_assets: Vec::new(),
177        }
178    }
179
180    /// Overrides the web-client directory relative to the project root.
181    pub fn with_web_dir(mut self, web_dir: impl Into<String>) -> Self {
182        self.web_dir = web_dir.into();
183        self
184    }
185
186    /// Overrides the backend binary when it does not follow the app repository prefix.
187    pub fn with_bin_target(mut self, bin_target: impl Into<String>) -> Self {
188        self.bin_target = Some(bin_target.into());
189        self
190    }
191
192    /// Sets the environment variable used to expose the backend origin to the web client.
193    pub fn with_web_backend_origin_env(mut self, key: impl Into<String>) -> Self {
194        self.web_backend_origin_env = Some(key.into());
195        self
196    }
197
198    #[cfg(feature = "btcpay")]
199    /// Attaches typed settings for the optional local BTCPay development stack.
200    pub fn with_btcpay_profile(mut self, profile: BtcpayDevProfile) -> Self {
201        self.btcpay = Some(profile);
202        self
203    }
204
205    /// Registers an additional browser asset to build before the web client.
206    pub fn with_public_asset(mut self, asset: ProjectPublicAsset) -> Self {
207        self.public_assets.push(asset);
208        self
209    }
210
211    /// Returns the explicit project identifier.
212    pub fn app_id(&self) -> &str {
213        &self.app_id
214    }
215
216    /// Returns the configured backend binary target or the project identifier.
217    pub fn bin_target(&self) -> &str {
218        self.bin_target.as_deref().unwrap_or(&self.app_id)
219    }
220
221    /// Validates the profile and annotates failures with the supplied source label.
222    pub fn validate(&self, source: &str) -> DevResult {
223        ensure!(
224            !self.app_id.trim().is_empty(),
225            "{source} contains an empty app id"
226        );
227        ensure!(
228            !self.web_dir.trim().is_empty(),
229            "{source} project `{}` has an empty web_dir",
230            self.app_id
231        );
232        ensure!(
233            !self.bin_target().trim().is_empty(),
234            "{source} project `{}` has an empty bin_target",
235            self.app_id
236        );
237        api_addr(self)?;
238
239        for asset in &self.public_assets {
240            asset.validate(source, &self.app_id)?;
241        }
242        #[cfg(feature = "btcpay")]
243        {
244            if let Some(profile) = &self.btcpay {
245                profile.validate().map_err(|error| {
246                    color_eyre::eyre::eyre!(
247                        "{source} project `{}` has an invalid BTCPay profile: {error:#}",
248                        self.app_id
249                    )
250                })?;
251            }
252        }
253
254        Ok(())
255    }
256}
257
258impl ProjectPublicAsset {
259    /// Creates a `wasm-bindgen` asset build from explicit package and output settings.
260    pub fn wasm_bindgen(
261        id: impl Into<String>,
262        label: impl Into<String>,
263        package: impl Into<String>,
264        wasm_file: impl Into<String>,
265        out_dir: impl Into<Vec<String>>,
266        out_name: impl Into<String>,
267        wasm_bindgen_version: impl Into<String>,
268    ) -> Self {
269        Self::WasmBindgen(WasmBindgenPublicAsset {
270            id: id.into(),
271            label: label.into(),
272            package: package.into(),
273            wasm_file: wasm_file.into(),
274            no_default_features: false,
275            features: String::new(),
276            out_dir: out_dir.into(),
277            out_name: out_name.into(),
278            wasm_bindgen_version: wasm_bindgen_version.into(),
279        })
280    }
281
282    /// Validates this asset for the named project and configuration source.
283    pub fn validate(&self, source: &str, project_id: &str) -> DevResult {
284        match self {
285            Self::WasmBindgen(asset) => asset.validate(source, project_id),
286        }
287    }
288
289    /// Controls whether Cargo default features are disabled for this asset build.
290    pub fn with_no_default_features(mut self, no_default_features: bool) -> Self {
291        match &mut self {
292            Self::WasmBindgen(asset) => {
293                asset.no_default_features = no_default_features;
294            }
295        }
296        self
297    }
298
299    /// Sets the comma-separated Cargo feature list used for this asset build.
300    pub fn with_features(mut self, features: impl Into<String>) -> Self {
301        match &mut self {
302            Self::WasmBindgen(asset) => {
303                asset.features = features.into();
304            }
305        }
306        self
307    }
308}
309
310impl WasmBindgenPublicAsset {
311    fn validate(&self, source: &str, project_id: &str) -> DevResult {
312        ensure!(
313            !self.id.trim().is_empty(),
314            "{source} project `{project_id}` has a wasm-bindgen asset with an empty id"
315        );
316        ensure!(
317            !self.label.trim().is_empty(),
318            "{source} project `{project_id}` asset `{}` has an empty label",
319            self.id
320        );
321        ensure!(
322            !self.package.trim().is_empty(),
323            "{source} project `{project_id}` asset `{}` has an empty package",
324            self.id
325        );
326        ensure!(
327            !self.wasm_file.trim().is_empty(),
328            "{source} project `{project_id}` asset `{}` has an empty wasm_file",
329            self.id
330        );
331        ensure!(
332            !self.out_dir.is_empty(),
333            "{source} project `{project_id}` asset `{}` must declare out_dir",
334            self.id
335        );
336        ensure!(
337            !self.out_name.trim().is_empty(),
338            "{source} project `{project_id}` asset `{}` has an empty out_name",
339            self.id
340        );
341        ensure!(
342            !self.wasm_bindgen_version.trim().is_empty(),
343            "{source} project `{project_id}` asset `{}` has an empty wasm_bindgen_version",
344            self.id
345        );
346        Ok(())
347    }
348}
349
350impl WorkspacePaths {
351    /// Returns the Cargo workspace root.
352    pub fn workspace_root(&self) -> &Path {
353        &self.workspace_root
354    }
355
356    /// Returns the project repository root.
357    pub fn project_root(&self) -> &Path {
358        &self.project_root
359    }
360
361    /// Returns the web-client root.
362    pub fn web_root(&self) -> &Path {
363        &self.web_root
364    }
365}
366
367/// Runs the selected development task for `profile`.
368pub fn run_project(profile: ProjectProfile, manifest_dir: impl AsRef<Path>) -> DevResult {
369    color_eyre::install()?;
370    match AppCli::parse().command {
371        AppCommand::Watch(command) => {
372            let paths = resolve_paths_from_manifest_dir(&profile, manifest_dir.as_ref())?;
373            run_profile(&profile, &paths, command.release, RunMode::Watch)
374        }
375        AppCommand::Serve(command) => {
376            let paths = resolve_paths_from_manifest_dir(&profile, manifest_dir.as_ref())?;
377            run_profile(&profile, &paths, command.release, RunMode::Serve)
378        }
379        AppCommand::Build(_) => {
380            let paths = resolve_paths_from_manifest_dir(&profile, manifest_dir.as_ref())?;
381            build_profile(&profile, &paths, true)
382        }
383    }
384}
385
386/// Resolves project paths by walking upward from a Cargo manifest directory.
387pub fn resolve_paths_from_manifest_dir(
388    profile: &ProjectProfile,
389    manifest_dir: &Path,
390) -> DevResult<WorkspacePaths> {
391    let project_root = find_project_root_from_manifest_dir(manifest_dir, &profile.web_dir)?;
392    let workspace_root = find_workspace_root(&project_root)?;
393    let web_root = project_root.join(&profile.web_dir);
394    validate_paths(&project_root, &web_root)?;
395    Ok(WorkspacePaths {
396        workspace_root,
397        project_root,
398        web_root,
399    })
400}
401
402/// Resolves project paths from a repository directory beneath the current workspace.
403pub fn resolve_paths_from_workspace_project(
404    profile: &ProjectProfile,
405    repo_dir: &str,
406) -> DevResult<WorkspacePaths> {
407    let cwd = env::current_dir().wrap_err("failed to resolve current directory")?;
408    let workspace_root = find_workspace_root(&cwd)?;
409    let project_root = workspace_root.join(repo_dir);
410    let web_root = project_root.join(&profile.web_dir);
411    validate_paths(&project_root, &web_root)?;
412    Ok(WorkspacePaths {
413        workspace_root,
414        project_root,
415        web_root,
416    })
417}
418
419/// Prepares a project and runs it in the requested mode.
420pub fn run_profile(
421    profile: &ProjectProfile,
422    paths: &WorkspacePaths,
423    release: bool,
424    mode: RunMode,
425) -> DevResult {
426    install_node_dependencies(&paths.web_root)?;
427    build_project_public_assets(profile, paths, release)?;
428    bootstrap_project_dev_services(profile, paths, release)?;
429
430    match mode {
431        RunMode::Watch => watch_project(profile, paths, release),
432        RunMode::Serve => {
433            build_react(&paths.web_root)?;
434            start_backend(profile, paths, release)
435        }
436    }
437}
438
439/// Builds registered public assets and the React client without starting the backend.
440pub fn build_profile(profile: &ProjectProfile, paths: &WorkspacePaths, release: bool) -> DevResult {
441    install_node_dependencies(&paths.web_root)?;
442    build_project_public_assets(profile, paths, release)?;
443    build_react(&paths.web_root)
444}
445
446fn find_project_root_from_manifest_dir(manifest_dir: &Path, web_dir: &str) -> DevResult<PathBuf> {
447    let start = if manifest_dir.is_absolute() {
448        manifest_dir.to_path_buf()
449    } else {
450        env::current_dir()
451            .wrap_err("failed to resolve current directory")?
452            .join(manifest_dir)
453    };
454
455    let mut current = Some(start.as_path());
456    while let Some(candidate) = current {
457        if candidate.join("Cargo.toml").is_file()
458            && candidate.join(web_dir).join("package.json").is_file()
459        {
460            return Ok(candidate.to_path_buf());
461        }
462        current = candidate.parent();
463    }
464
465    bail!(
466        "failed to resolve app project root from {}",
467        manifest_dir.display()
468    )
469}
470
471fn validate_paths(project_root: &Path, web_root: &Path) -> DevResult {
472    if !project_root.join("Cargo.toml").is_file() {
473        bail!(
474            "project manifest is missing at {}",
475            project_root.join("Cargo.toml").display()
476        );
477    }
478    if !web_root.join("package.json").is_file() {
479        bail!(
480            "web package manifest is missing at {}",
481            web_root.join("package.json").display()
482        );
483    }
484    Ok(())
485}
486
487#[cfg(feature = "btcpay")]
488fn bootstrap_project_dev_services(
489    profile: &ProjectProfile,
490    paths: &WorkspacePaths,
491    release: bool,
492) -> DevResult {
493    let Some(btcpay_profile) = profile.btcpay.clone() else {
494        return Ok(());
495    };
496    if release {
497        return Ok(());
498    }
499
500    if env::var(btcpay_profile.skip_btcpay_bootstrap_env_key()).as_deref() == Ok("1") {
501        return Ok(());
502    }
503
504    let options = LocalBtcpayBootstrapOptions::new(
505        btcpay_profile,
506        &paths.workspace_root,
507        &paths.project_root,
508    );
509    let summary = bootstrap_local_btcpay(&options)
510        .map_err(|error| color_eyre::eyre::eyre!("failed to bootstrap local BTCPay: {error:#}"))?;
511    eprintln!(
512        "BTCPay local dev bootstrap ready for {}: {} (store {}, api {}, webhook {}, env {})",
513        summary.app_display_name,
514        summary.url,
515        summary.store_id,
516        summary.api_key_masked,
517        summary.webhook_secret_masked,
518        summary.env_file.display()
519    );
520    Ok(())
521}
522
523#[cfg(not(feature = "btcpay"))]
524fn bootstrap_project_dev_services(
525    _profile: &ProjectProfile,
526    _paths: &WorkspacePaths,
527    _release: bool,
528) -> DevResult {
529    Ok(())
530}
531
532fn find_workspace_root(start: &Path) -> DevResult<PathBuf> {
533    let mut nearest_package = None;
534
535    for candidate in start.ancestors() {
536        let manifest_path = candidate.join("Cargo.toml");
537        if !manifest_path.is_file() {
538            continue;
539        }
540
541        let source = fs::read_to_string(&manifest_path).wrap_err_with(|| {
542            format!(
543                "failed to read Cargo manifest at {}",
544                manifest_path.display()
545            )
546        })?;
547        let manifest = toml::from_str::<toml::Table>(&source).wrap_err_with(|| {
548            format!(
549                "failed to parse Cargo manifest at {}",
550                manifest_path.display()
551            )
552        })?;
553
554        if manifest.contains_key("workspace") {
555            return Ok(candidate.to_path_buf());
556        }
557        if nearest_package.is_none() && manifest.contains_key("package") {
558            nearest_package = Some(candidate.to_path_buf());
559        }
560    }
561
562    nearest_package.ok_or_else(|| {
563        color_eyre::eyre::eyre!(
564            "failed to find a Cargo workspace or package manifest above {}",
565            start.display()
566        )
567    })
568}
569
570fn install_node_dependencies(web_root: &Path) -> DevResult {
571    if web_root.join("node_modules").is_dir() {
572        return Ok(());
573    }
574
575    run_command("Node dependency install", npm(), &["ci"], web_root, &[])
576}
577
578fn build_react(web_root: &Path) -> DevResult {
579    let script = resolve_build_script(web_root)?;
580    run_command("Web build", npm(), &["run", script], web_root, &[])
581}
582
583fn build_project_public_assets(
584    profile: &ProjectProfile,
585    paths: &WorkspacePaths,
586    release: bool,
587) -> DevResult {
588    for asset in &profile.public_assets {
589        match asset {
590            ProjectPublicAsset::WasmBindgen(asset) => {
591                build_wasm_bindgen_public_asset(profile, paths, asset, release)?;
592            }
593        }
594    }
595
596    Ok(())
597}
598
599fn build_wasm_bindgen_public_asset(
600    profile: &ProjectProfile,
601    paths: &WorkspacePaths,
602    asset: &WasmBindgenPublicAsset,
603    release: bool,
604) -> DevResult {
605    let target_dir = paths
606        .project_root
607        .join("target")
608        .join(format!("{}-dev-{}-build", profile.app_id, asset.id));
609    let target_dir_arg = path_arg(&target_dir)?;
610    let mut args = vec![
611        "build",
612        "--lib",
613        "--package",
614        asset.package.as_str(),
615        "--locked",
616        "--target",
617        "wasm32-unknown-unknown",
618    ];
619    if release {
620        args.push("--release");
621    }
622    if asset.no_default_features {
623        args.push("--no-default-features");
624    }
625    if !asset.features.trim().is_empty() {
626        args.push("--features");
627        args.push(asset.features.as_str());
628    }
629    args.push("--target-dir");
630    args.push(target_dir_arg);
631
632    run_command(
633        format!("{} WASM build", asset.label).as_str(),
634        cargo(),
635        &args,
636        &paths.project_root,
637        &[],
638    )?;
639
640    let wasm_input = target_dir
641        .join("wasm32-unknown-unknown")
642        .join(if release { "release" } else { "debug" })
643        .join(&asset.wasm_file);
644    if !wasm_input.is_file() {
645        bail!(
646            "missing expected {} WASM at {}",
647            asset.label,
648            wasm_input.display()
649        );
650    }
651
652    let mut out_dir = paths.web_root.clone();
653    for segment in &asset.out_dir {
654        out_dir.push(segment);
655    }
656    if out_dir.exists() {
657        fs::remove_dir_all(&out_dir).wrap_err_with(|| {
658            format!(
659                "failed to remove previous {} WASM output at {}",
660                asset.label,
661                out_dir.display()
662            )
663        })?;
664    }
665    fs::create_dir_all(&out_dir).wrap_err_with(|| {
666        format!(
667            "failed to create {} WASM output directory at {}",
668            asset.label,
669            out_dir.display()
670        )
671    })?;
672
673    let wasm_input_arg = path_arg(&wasm_input)?;
674    let out_dir_arg = path_arg(&out_dir)?;
675    run_command(
676        format!("{} wasm-bindgen", asset.label).as_str(),
677        resolve_wasm_bindgen(&asset.wasm_bindgen_version)?,
678        &[
679            wasm_input_arg,
680            "--target",
681            "web",
682            "--out-dir",
683            out_dir_arg,
684            "--out-name",
685            asset.out_name.as_str(),
686        ],
687        &paths.project_root,
688        &[],
689    )
690}
691
692fn resolve_build_script(web_root: &Path) -> DevResult<&'static str> {
693    let package_json_path = web_root.join("package.json");
694    let package_json = std::fs::read_to_string(&package_json_path)
695        .wrap_err_with(|| format!("failed to read {}", package_json_path.display()))?;
696    let package_json: Value = serde_json::from_str(&package_json)
697        .wrap_err_with(|| format!("failed to parse {}", package_json_path.display()))?;
698    let scripts = package_json
699        .get("scripts")
700        .and_then(Value::as_object)
701        .ok_or_else(|| color_eyre::eyre::eyre!("package.json has no scripts object"))?;
702
703    if scripts.contains_key("build") {
704        return Ok("build");
705    }
706    if scripts.contains_key("build:web") {
707        return Ok("build:web");
708    }
709
710    bail!(
711        "{} must define either `build` or `build:web`",
712        package_json_path.display()
713    )
714}
715
716fn start_backend(profile: &ProjectProfile, paths: &WorkspacePaths, release: bool) -> DevResult {
717    let args = backend_args(profile, release);
718    run_command("Rust backend", cargo(), &args, &paths.project_root, &[])
719}
720
721fn watch_project(profile: &ProjectProfile, paths: &WorkspacePaths, release: bool) -> DevResult {
722    let api_addr = api_addr(profile)?;
723    let watchexec = resolve_watchexec()?;
724    let watch_paths = resolve_backend_watch_paths(profile, paths)?;
725    let backend_args = backend_watchexec_args(profile, paths, &watch_paths, release);
726    let mut backend = spawn_command(
727        "Rust backend watcher",
728        watchexec,
729        &backend_args,
730        &paths.project_root,
731        &[],
732    )?;
733    wait_for_backend_listener(&mut backend, api_addr)?;
734
735    let backend_origin = format!("http://127.0.0.1:{}", api_addr.port());
736    let web_envs = web_dev_envs(profile, &backend_origin);
737    let mut web = spawn_command(
738        "web dev server",
739        npm(),
740        &["run", "dev"],
741        &paths.web_root,
742        &web_envs,
743    )?;
744
745    wait_for_watch_processes(&mut backend, &mut web)
746}
747
748fn wait_for_backend_listener(backend: &mut Child, api_addr: SocketAddr) -> DevResult {
749    let ready_addr = SocketAddr::from(([127, 0, 0, 1], api_addr.port()));
750    let started_at = Instant::now();
751
752    loop {
753        if let Some(status) = backend.try_wait().wrap_err("failed to poll Rust backend")? {
754            if status.success() {
755                bail!("Rust backend exited before it opened {}", ready_addr);
756            }
757            bail!(
758                "Rust backend exited with status {:?} before it opened {}",
759                status.code(),
760                ready_addr
761            );
762        }
763
764        let last_error =
765            match TcpStream::connect_timeout(&ready_addr, BACKEND_READY_CONNECT_TIMEOUT) {
766                Ok(_) => return Ok(()),
767                Err(error) => error,
768            };
769
770        if started_at.elapsed() >= BACKEND_READY_TIMEOUT {
771            terminate_child(backend);
772            bail!(
773                "Rust backend did not open {} within {:?}: {}",
774                ready_addr,
775                BACKEND_READY_TIMEOUT,
776                last_error
777            );
778        }
779
780        thread::sleep(BACKEND_READY_POLL_INTERVAL);
781    }
782}
783
784fn backend_args(profile: &ProjectProfile, release: bool) -> Vec<&str> {
785    let mut args = vec!["run", "--bin", profile.bin_target(), "--locked"];
786    if release {
787        args.push("--release");
788    }
789    args
790}
791
792fn backend_watchexec_args(
793    profile: &ProjectProfile,
794    paths: &WorkspacePaths,
795    watch_paths: &BackendWatchPaths,
796    release: bool,
797) -> Vec<OsString> {
798    let mut args = vec![
799        OsString::from("--restart"),
800        OsString::from("--shell=none"),
801        OsString::from("--emit-events-to=none"),
802        OsString::from("--project-origin"),
803        paths.project_root.as_os_str().to_os_string(),
804        OsString::from("--exts"),
805        OsString::from("rs,toml,lock,sql"),
806    ];
807
808    for path in &watch_paths.paths {
809        args.push(OsString::from("--watch"));
810        args.push(path.as_os_str().to_os_string());
811    }
812
813    args.push(OsString::from("--"));
814    args.push(cargo());
815    args.extend(
816        backend_args(profile, release)
817            .into_iter()
818            .map(OsString::from),
819    );
820    args
821}
822
823fn resolve_watchexec() -> DevResult<OsString> {
824    let executable = find_on_path("watchexec").ok_or_else(|| {
825        color_eyre::eyre::eyre!(
826            "Watchexec is required for backend reloads; install it with `cargo install watchexec-cli --version 2.5.1 --locked`"
827        )
828    })?;
829    let output = ProcessCommand::new(&executable)
830        .arg("--version")
831        .output()
832        .wrap_err("failed to check the installed Watchexec version")?;
833    if output.status.success() {
834        return Ok(executable.into_os_string());
835    }
836
837    let stderr = String::from_utf8_lossy(&output.stderr);
838    let details = stderr.trim();
839    if details.is_empty() {
840        bail!(
841            "Watchexec is required for backend reloads; install it with `cargo install watchexec-cli --version 2.5.1 --locked`"
842        );
843    }
844
845    bail!(
846        "Watchexec is required for backend reloads; install it with `cargo install watchexec-cli --version 2.5.1 --locked`\n\n{details}"
847    )
848}
849
850fn resolve_backend_watch_paths(
851    profile: &ProjectProfile,
852    paths: &WorkspacePaths,
853) -> DevResult<BackendWatchPaths> {
854    let output = ProcessCommand::new(cargo())
855        .args(["metadata", "--format-version=1", "--locked"])
856        .current_dir(&paths.project_root)
857        .output()
858        .wrap_err("failed to read Cargo metadata for backend reloads")?;
859    if !output.status.success() {
860        let details = String::from_utf8_lossy(&output.stderr);
861        bail!(
862            "Cargo metadata failed for backend reloads: {}",
863            details.trim()
864        );
865    }
866
867    let metadata = serde_json::from_slice::<CargoMetadata>(&output.stdout)
868        .wrap_err("failed to parse Cargo metadata for backend reloads")?;
869    backend_watch_paths_from_metadata(&metadata, profile.bin_target())
870}
871
872fn backend_watch_paths_from_metadata(
873    metadata: &CargoMetadata,
874    bin_target: &str,
875) -> DevResult<BackendWatchPaths> {
876    let resolve = metadata
877        .resolve
878        .as_ref()
879        .ok_or_else(|| color_eyre::eyre::eyre!("Cargo metadata did not include a resolve graph"))?;
880    let root_ids = metadata
881        .packages
882        .iter()
883        .filter(|package| {
884            package.targets.iter().any(|target| {
885                target.name == bin_target && target.kind.iter().any(|kind| kind == "bin")
886            })
887        })
888        .map(|package| package.id.as_str())
889        .collect::<Vec<_>>();
890    ensure!(
891        !root_ids.is_empty(),
892        "Cargo metadata contains no binary target named `{bin_target}`"
893    );
894
895    let nodes = resolve
896        .nodes
897        .iter()
898        .map(|node| (node.id.as_str(), node))
899        .collect::<HashMap<_, _>>();
900    let mut reachable = BTreeSet::new();
901    let mut pending = root_ids.into_iter().collect::<VecDeque<_>>();
902    while let Some(package_id) = pending.pop_front() {
903        if !reachable.insert(package_id) {
904            continue;
905        }
906        if let Some(node) = nodes.get(package_id) {
907            pending.extend(node.deps.iter().map(|dependency| dependency.pkg.as_str()));
908        }
909    }
910
911    let mut watch_paths = BTreeSet::new();
912    for package in metadata
913        .packages
914        .iter()
915        .filter(|package| package.source.is_none() && reachable.contains(package.id.as_str()))
916    {
917        let package_root = package.manifest_path.parent().ok_or_else(|| {
918            color_eyre::eyre::eyre!(
919                "Cargo package manifest has no parent: {}",
920                package.manifest_path.display()
921            )
922        })?;
923        insert_existing_watch_path(&mut watch_paths, &package.manifest_path)?;
924
925        let workspace_root = find_workspace_root(package_root)?;
926        insert_existing_watch_path(&mut watch_paths, &workspace_root.join("Cargo.toml"))?;
927        for workspace_file in ["Cargo.lock", "rust-toolchain.toml", ".rust-version"] {
928            let workspace_file = workspace_root.join(workspace_file);
929            if workspace_file.is_file() {
930                insert_existing_watch_path(&mut watch_paths, &workspace_file)?;
931            }
932        }
933        let cargo_config_dir = workspace_root.join(".cargo");
934        if cargo_config_dir.is_dir() {
935            insert_existing_watch_path(&mut watch_paths, &cargo_config_dir)?;
936        }
937
938        let conventional_source_root = package_root.join("src");
939        if conventional_source_root.is_dir() {
940            insert_existing_watch_path(&mut watch_paths, &conventional_source_root)?;
941        }
942        for target in &package.targets {
943            if !target.src_path.starts_with(&conventional_source_root) && target.src_path.is_file()
944            {
945                insert_existing_watch_path(&mut watch_paths, &target.src_path)?;
946            }
947        }
948
949        let migrations = package_root.join("migrations");
950        if migrations.is_dir() {
951            insert_existing_watch_path(&mut watch_paths, &migrations)?;
952        }
953    }
954
955    Ok(BackendWatchPaths {
956        paths: watch_paths.into_iter().collect(),
957    })
958}
959
960fn insert_existing_watch_path(paths: &mut BTreeSet<PathBuf>, path: &Path) -> DevResult {
961    let canonical = path
962        .canonicalize()
963        .wrap_err_with(|| format!("failed to resolve backend watch path {}", path.display()))?;
964    paths.insert(canonical);
965    Ok(())
966}
967
968fn api_addr(profile: &ProjectProfile) -> DevResult<SocketAddr> {
969    profile.api_addr.parse::<SocketAddr>().wrap_err_with(|| {
970        format!(
971            "invalid site address `{}` for project `{}`",
972            profile.api_addr, profile.app_id
973        )
974    })
975}
976
977fn web_dev_envs(profile: &ProjectProfile, backend_origin: &str) -> Vec<(String, String)> {
978    let mut envs = Vec::new();
979    if let Some(origin_env) = &profile.web_backend_origin_env {
980        envs.push((origin_env.clone(), backend_origin.to_string()));
981    }
982    envs
983}
984
985fn wait_for_watch_processes(backend: &mut Child, web: &mut Child) -> DevResult {
986    loop {
987        if let Some(status) = backend.try_wait().wrap_err("failed to poll Rust backend")? {
988            terminate_child(web);
989            if status.success() {
990                bail!("Rust backend exited before the web dev server");
991            }
992            bail!("Rust backend exited with status {:?}", status.code());
993        }
994
995        if let Some(status) = web.try_wait().wrap_err("failed to poll web dev server")? {
996            terminate_child(backend);
997            if status.success() {
998                return Ok(());
999            }
1000            bail!("web dev server exited with status {:?}", status.code());
1001        }
1002
1003        thread::sleep(Duration::from_millis(250));
1004    }
1005}
1006
1007fn terminate_child(child: &mut Child) {
1008    let _ = child.kill();
1009    let _ = child.wait();
1010}
1011
1012fn run_command<S: AsRef<OsStr>>(
1013    label: &str,
1014    executable: OsString,
1015    args: &[S],
1016    cwd: &Path,
1017    envs: &[(String, String)],
1018) -> DevResult {
1019    let mut child = spawn_command(label, executable, args, cwd, envs)?;
1020    let status = child
1021        .wait()
1022        .wrap_err_with(|| format!("failed to wait for {label}"))?;
1023    if status.success() {
1024        return Ok(());
1025    }
1026
1027    bail!("{label} exited with status {:?}", status.code())
1028}
1029
1030fn spawn_command<S: AsRef<OsStr>>(
1031    label: &str,
1032    executable: OsString,
1033    args: &[S],
1034    cwd: &Path,
1035    envs: &[(String, String)],
1036) -> DevResult<Child> {
1037    let mut command = ProcessCommand::new(executable);
1038    command.current_dir(cwd).args(args);
1039    for (key, value) in envs {
1040        command.env(key, value);
1041    }
1042
1043    command
1044        .spawn()
1045        .wrap_err_with(|| format!("failed to start {label}"))
1046}
1047
1048fn path_arg(path: &Path) -> DevResult<&str> {
1049    path.to_str()
1050        .ok_or_else(|| color_eyre::eyre::eyre!("path is not valid UTF-8: {}", path.display()))
1051}
1052
1053fn cargo() -> OsString {
1054    env::var_os("CARGO").unwrap_or_else(|| OsString::from("cargo"))
1055}
1056
1057fn npm() -> OsString {
1058    match cfg!(windows) {
1059        true => OsString::from("npm.cmd"),
1060        false => OsString::from("npm"),
1061    }
1062}
1063
1064fn resolve_wasm_bindgen(required_version: &str) -> DevResult<OsString> {
1065    if let Some(configured) = env::var_os("WASM_BINDGEN") {
1066        if wasm_bindgen_matches(&configured, required_version) {
1067            return Ok(configured);
1068        }
1069        bail!(
1070            "WASM_BINDGEN does not point to wasm-bindgen {}",
1071            required_version
1072        );
1073    }
1074
1075    if let Some(executable) = find_on_path("wasm-bindgen") {
1076        let executable = executable.into_os_string();
1077        if wasm_bindgen_matches(&executable, required_version) {
1078            return Ok(executable);
1079        }
1080    }
1081
1082    bail!(
1083        "wasm-bindgen {} is required for configured WASM public assets; install it with `cargo install wasm-bindgen-cli --version {} --locked` or set WASM_BINDGEN",
1084        required_version,
1085        required_version
1086    )
1087}
1088
1089fn wasm_bindgen_matches(executable: &OsString, required_version: &str) -> bool {
1090    let output = ProcessCommand::new(executable).arg("--version").output();
1091    let Ok(output) = output else {
1092        return false;
1093    };
1094    let text = String::from_utf8_lossy(&output.stdout);
1095    output.status.success() && text.contains(required_version)
1096}
1097
1098fn find_on_path(name: &str) -> Option<PathBuf> {
1099    let path = env::var_os("PATH")?;
1100    for directory in env::split_paths(&path) {
1101        let candidate = directory.join(name);
1102        if candidate.is_file() {
1103            return Some(candidate);
1104        }
1105        #[cfg(windows)]
1106        {
1107            let candidate = directory.join(format!("{name}.exe"));
1108            if candidate.is_file() {
1109                return Some(candidate);
1110            }
1111        }
1112    }
1113    None
1114}
1115
1116#[cfg(test)]
1117mod tests {
1118    use super::*;
1119    use std::sync::atomic::{AtomicU64, Ordering};
1120
1121    static TEMP_SEQUENCE: AtomicU64 = AtomicU64::new(0);
1122
1123    struct TestDirectory(PathBuf);
1124
1125    impl TestDirectory {
1126        fn new(label: &str) -> Self {
1127            let sequence = TEMP_SEQUENCE.fetch_add(1, Ordering::Relaxed);
1128            let path = env::temp_dir().join(format!(
1129                "cargo-workspace-{label}-{}-{sequence}",
1130                std::process::id()
1131            ));
1132            fs::create_dir_all(&path).expect("test directory should be created");
1133            Self(path)
1134        }
1135
1136        fn path(&self) -> &Path {
1137            &self.0
1138        }
1139    }
1140
1141    impl Drop for TestDirectory {
1142        fn drop(&mut self) {
1143            let _ = fs::remove_dir_all(&self.0);
1144        }
1145    }
1146
1147    #[test]
1148    fn project_profile_reuses_the_explicit_identity_by_default() {
1149        let profile = ProjectProfile::new("example-service", "127.0.0.1:4100");
1150
1151        assert_eq!(profile.app_id(), "example-service");
1152        assert_eq!(profile.bin_target(), "example-service");
1153        profile
1154            .validate("test profile")
1155            .expect("single-identity profile should be valid");
1156    }
1157
1158    #[test]
1159    fn builds_direct_watchexec_commands_for_debug_and_release_backends() {
1160        let directory = TestDirectory::new("watchexec-args");
1161        let profile = ProjectProfile::new("example-service", "127.0.0.1:4100")
1162            .with_bin_target("example-server");
1163        let paths = WorkspacePaths {
1164            workspace_root: directory.path().to_path_buf(),
1165            project_root: directory.path().to_path_buf(),
1166            web_root: directory.path().join("web"),
1167        };
1168        let watch_paths = BackendWatchPaths {
1169            paths: vec![directory.path().join("src")],
1170        };
1171
1172        assert_eq!(
1173            backend_watchexec_args(&profile, &paths, &watch_paths, false),
1174            [
1175                OsString::from("--restart"),
1176                OsString::from("--shell=none"),
1177                OsString::from("--emit-events-to=none"),
1178                OsString::from("--project-origin"),
1179                directory.path().as_os_str().to_os_string(),
1180                OsString::from("--exts"),
1181                OsString::from("rs,toml,lock,sql"),
1182                OsString::from("--watch"),
1183                directory.path().join("src").into_os_string(),
1184                OsString::from("--"),
1185                cargo(),
1186                OsString::from("run"),
1187                OsString::from("--bin"),
1188                OsString::from("example-server"),
1189                OsString::from("--locked"),
1190            ]
1191        );
1192
1193        let release_args = backend_watchexec_args(&profile, &paths, &watch_paths, true);
1194        assert_eq!(release_args.last(), Some(&OsString::from("--release")));
1195    }
1196
1197    #[test]
1198    fn watches_only_local_packages_reachable_from_the_backend() {
1199        let directory = TestDirectory::new("cargo-metadata");
1200        let app_workspace = directory.path().join("app-workspace");
1201        let app_root = app_workspace.join("app");
1202        let unused_root = app_workspace.join("unused");
1203        let shared_workspace = directory.path().join("shared-workspace");
1204        let shared_root = shared_workspace.join("crates").join("shared");
1205
1206        for source_root in [
1207            app_root.join("src"),
1208            unused_root.join("src"),
1209            shared_root.join("src"),
1210        ] {
1211            fs::create_dir_all(source_root).expect("source directory should be created");
1212        }
1213        fs::write(
1214            app_workspace.join("Cargo.toml"),
1215            "[workspace]\nmembers = [\"app\", \"unused\"]\nresolver = \"3\"\n",
1216        )
1217        .expect("app workspace manifest should be written");
1218        fs::write(
1219            shared_workspace.join("Cargo.toml"),
1220            "[workspace]\nmembers = [\"crates/shared\"]\nresolver = \"3\"\n",
1221        )
1222        .expect("shared workspace manifest should be written");
1223        for (root, name) in [
1224            (&app_root, "app"),
1225            (&unused_root, "unused"),
1226            (&shared_root, "shared"),
1227        ] {
1228            fs::write(
1229                root.join("Cargo.toml"),
1230                format!("[package]\nname = \"{name}\"\nversion = \"0.1.0\"\n"),
1231            )
1232            .expect("package manifest should be written");
1233        }
1234
1235        let package = |id: &str, root: &Path, target_name: &str, kind: &str| CargoPackage {
1236            id: id.to_string(),
1237            source: None,
1238            manifest_path: root.join("Cargo.toml"),
1239            targets: vec![CargoTarget {
1240                kind: vec![kind.to_string()],
1241                name: target_name.to_string(),
1242                src_path: root
1243                    .join("src")
1244                    .join(if kind == "bin" { "main.rs" } else { "lib.rs" }),
1245            }],
1246        };
1247        let metadata = CargoMetadata {
1248            packages: vec![
1249                package("app", &app_root, "example-server", "bin"),
1250                package("shared", &shared_root, "shared", "lib"),
1251                package("unused", &unused_root, "unused", "lib"),
1252                CargoPackage {
1253                    id: "registry".to_string(),
1254                    source: Some("registry+https://example.invalid".to_string()),
1255                    manifest_path: directory.path().join("registry").join("Cargo.toml"),
1256                    targets: Vec::new(),
1257                },
1258            ],
1259            resolve: Some(CargoResolve {
1260                nodes: vec![
1261                    CargoResolveNode {
1262                        id: "app".to_string(),
1263                        deps: vec![
1264                            CargoResolveDependency {
1265                                pkg: "shared".to_string(),
1266                            },
1267                            CargoResolveDependency {
1268                                pkg: "registry".to_string(),
1269                            },
1270                        ],
1271                    },
1272                    CargoResolveNode {
1273                        id: "shared".to_string(),
1274                        deps: Vec::new(),
1275                    },
1276                    CargoResolveNode {
1277                        id: "unused".to_string(),
1278                        deps: Vec::new(),
1279                    },
1280                    CargoResolveNode {
1281                        id: "registry".to_string(),
1282                        deps: Vec::new(),
1283                    },
1284                ],
1285            }),
1286        };
1287
1288        let watch_paths = backend_watch_paths_from_metadata(&metadata, "example-server")
1289            .expect("backend watch paths should resolve");
1290
1291        assert!(
1292            watch_paths
1293                .paths
1294                .contains(&app_root.join("src").canonicalize().unwrap())
1295        );
1296        assert!(
1297            watch_paths
1298                .paths
1299                .contains(&shared_root.join("src").canonicalize().unwrap())
1300        );
1301        assert!(
1302            !watch_paths
1303                .paths
1304                .contains(&unused_root.join("src").canonicalize().unwrap())
1305        );
1306        assert!(
1307            watch_paths
1308                .paths
1309                .contains(&app_workspace.join("Cargo.toml").canonicalize().unwrap())
1310        );
1311        assert!(
1312            watch_paths
1313                .paths
1314                .contains(&shared_workspace.join("Cargo.toml").canonicalize().unwrap())
1315        );
1316        assert!(
1317            !watch_paths
1318                .paths
1319                .contains(&app_root.canonicalize().unwrap())
1320        );
1321    }
1322
1323    #[test]
1324    fn project_profile_keeps_an_explicit_binary_override() {
1325        let profile = ProjectProfile::new("example-service", "127.0.0.1:4100")
1326            .with_bin_target("custom-server");
1327
1328        assert_eq!(profile.app_id(), "example-service");
1329        assert_eq!(profile.bin_target(), "custom-server");
1330    }
1331
1332    #[test]
1333    fn finds_the_owning_cargo_workspace_above_a_member_package() {
1334        let directory = TestDirectory::new("member");
1335        let workspace_root = directory.path().join("workspace");
1336        let package_root = workspace_root.join("crates").join("service");
1337        let source_root = package_root.join("src");
1338        fs::create_dir_all(&source_root).expect("member source directory should be created");
1339        fs::write(
1340            workspace_root.join("Cargo.toml"),
1341            "[workspace]\nmembers = [\"crates/service\"]\n",
1342        )
1343        .expect("workspace manifest should be written");
1344        fs::write(
1345            package_root.join("Cargo.toml"),
1346            "[package]\nname = \"service\"\nversion = \"0.1.0\"\n",
1347        )
1348        .expect("package manifest should be written");
1349
1350        assert_eq!(
1351            find_workspace_root(&source_root).expect("workspace should resolve"),
1352            workspace_root
1353        );
1354    }
1355
1356    #[test]
1357    fn uses_the_nearest_standalone_package_when_no_workspace_exists() {
1358        let directory = TestDirectory::new("standalone");
1359        let package_root = directory.path().join("service");
1360        let source_root = package_root.join("src");
1361        fs::create_dir_all(&source_root).expect("package source directory should be created");
1362        fs::write(
1363            package_root.join("Cargo.toml"),
1364            "[package]\nname = \"service\"\nversion = \"0.1.0\"\n",
1365        )
1366        .expect("package manifest should be written");
1367
1368        assert_eq!(
1369            find_workspace_root(&source_root).expect("package root should resolve"),
1370            package_root
1371        );
1372    }
1373
1374    #[test]
1375    fn reports_an_invalid_cargo_manifest() {
1376        let directory = TestDirectory::new("invalid");
1377        let package_root = directory.path().join("service");
1378        fs::create_dir_all(&package_root).expect("package directory should be created");
1379        fs::write(package_root.join("Cargo.toml"), "[workspace\n")
1380            .expect("invalid manifest should be written");
1381
1382        let error = find_workspace_root(&package_root)
1383            .expect_err("an invalid Cargo manifest must not be ignored");
1384        assert!(error.to_string().contains("failed to parse Cargo manifest"));
1385    }
1386}