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
23pub 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 Watch(AppRunCommand),
38 Serve(AppRunCommand),
40 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)]
59pub 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")]
73pub enum ProjectPublicAsset {
75 WasmBindgen(WasmBindgenPublicAsset),
77}
78
79#[derive(Clone, Debug, Deserialize)]
80pub 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)]
96pub enum RunMode {
98 Watch,
100 Serve,
102}
103
104#[derive(Debug)]
105pub 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 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 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 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 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 pub fn with_btcpay_profile(mut self, profile: BtcpayDevProfile) -> Self {
201 self.btcpay = Some(profile);
202 self
203 }
204
205 pub fn with_public_asset(mut self, asset: ProjectPublicAsset) -> Self {
207 self.public_assets.push(asset);
208 self
209 }
210
211 pub fn app_id(&self) -> &str {
213 &self.app_id
214 }
215
216 pub fn bin_target(&self) -> &str {
218 self.bin_target.as_deref().unwrap_or(&self.app_id)
219 }
220
221 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 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 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 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 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 pub fn workspace_root(&self) -> &Path {
353 &self.workspace_root
354 }
355
356 pub fn project_root(&self) -> &Path {
358 &self.project_root
359 }
360
361 pub fn web_root(&self) -> &Path {
363 &self.web_root
364 }
365}
366
367pub 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
386pub 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
402pub 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
419pub 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
439pub 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}