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
24pub 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 Watch(AppWatchCommand),
38 Serve(AppRunCommand),
40 Build(AppBuildCommand),
42 #[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 #[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 #[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 #[arg(long)]
89 target_dir: Option<PathBuf>,
90}
91
92#[derive(Clone, Debug)]
93pub 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")]
111pub enum ProjectPublicAsset {
113 WasmBindgen(WasmBindgenPublicAsset),
115}
116
117#[derive(Clone, Debug, Deserialize)]
118pub 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)]
136pub enum RunMode {
138 Watch,
140 Serve,
142}
143
144#[derive(Debug)]
145pub 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#[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 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 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 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 pub fn with_backend_ready_timeout(mut self, timeout: Duration) -> Self {
284 self.backend_ready_timeout = timeout;
285 self
286 }
287
288 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 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 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 pub fn with_btcpay_profile(mut self, profile: BtcpayDevProfile) -> Self {
309 self.btcpay = Some(profile);
310 self
311 }
312
313 pub fn with_public_asset(mut self, asset: ProjectPublicAsset) -> Self {
315 self.public_assets.push(asset);
316 self
317 }
318
319 pub fn with_optional_public_asset(mut self, asset: ProjectPublicAsset) -> Self {
321 self.optional_public_assets.push(asset);
322 self
323 }
324
325 pub fn app_id(&self) -> &str {
327 &self.app_id
328 }
329
330 pub fn bin_target(&self) -> &str {
332 self.bin_target.as_deref().unwrap_or(&self.app_id)
333 }
334
335 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 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 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 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 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 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 pub fn workspace_root(&self) -> &Path {
523 &self.workspace_root
524 }
525
526 pub fn project_root(&self) -> &Path {
528 &self.project_root
529 }
530
531 pub fn web_root(&self) -> &Path {
533 &self.web_root
534 }
535}
536
537pub 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
610pub 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
626pub 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
643pub 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
676pub 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 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 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 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}