use std::collections::{BTreeSet, HashMap, VecDeque};
use std::env;
use std::ffi::{OsStr, OsString};
use std::fs;
use std::net::{SocketAddr, TcpStream};
use std::path::{Path, PathBuf};
use std::process::{Child, Command as ProcessCommand};
use std::thread;
use std::time::{Duration, Instant};
#[cfg(feature = "btcpay")]
mod btcpay;
#[cfg(feature = "btcpay")]
pub use self::btcpay::BtcpayDevProfile;
#[cfg(feature = "btcpay")]
use self::btcpay::{LocalBtcpayBootstrapOptions, bootstrap_local_btcpay};
use clap::{Args, Parser, Subcommand};
use color_eyre::eyre::{Context, bail, ensure};
use serde::Deserialize;
use serde_json::Value;
pub type DevResult<T = ()> = color_eyre::Result<T>;
#[derive(Parser, Debug)]
#[command(name = "app-dev")]
#[command(about = "Build and run the current application's backend and web frontend.")]
struct AppCli {
#[command(subcommand)]
command: AppCommand,
}
#[derive(Subcommand, Debug)]
enum AppCommand {
Watch(AppRunCommand),
Serve(AppRunCommand),
Build(AppBuildCommand),
}
#[derive(Args, Debug)]
struct AppRunCommand {
#[arg(long)]
release: bool,
#[arg(short, long, action = clap::ArgAction::Count)]
verbose: u8,
}
#[derive(Args, Debug)]
struct AppBuildCommand {
#[arg(short, long, action = clap::ArgAction::Count)]
verbose: u8,
}
#[derive(Clone, Debug)]
pub struct ProjectProfile {
app_id: String,
web_dir: String,
bin_target: Option<String>,
api_addr: String,
web_backend_origin_env: Option<String>,
#[cfg(feature = "btcpay")]
btcpay: Option<BtcpayDevProfile>,
public_assets: Vec<ProjectPublicAsset>,
}
#[derive(Clone, Debug, Deserialize)]
#[serde(tag = "kind", rename_all = "kebab-case")]
pub enum ProjectPublicAsset {
WasmBindgen(WasmBindgenPublicAsset),
}
#[derive(Clone, Debug, Deserialize)]
pub struct WasmBindgenPublicAsset {
id: String,
label: String,
package: String,
wasm_file: String,
#[serde(default)]
no_default_features: bool,
#[serde(default)]
features: String,
out_dir: Vec<String>,
out_name: String,
wasm_bindgen_version: String,
}
#[derive(Clone, Copy, Debug)]
pub enum RunMode {
Watch,
Serve,
}
#[derive(Debug)]
pub struct WorkspacePaths {
workspace_root: PathBuf,
project_root: PathBuf,
web_root: PathBuf,
}
#[derive(Debug, Deserialize)]
struct CargoMetadata {
packages: Vec<CargoPackage>,
resolve: Option<CargoResolve>,
}
#[derive(Debug, Deserialize)]
struct CargoPackage {
id: String,
source: Option<String>,
manifest_path: PathBuf,
targets: Vec<CargoTarget>,
}
#[derive(Debug, Deserialize)]
struct CargoTarget {
kind: Vec<String>,
name: String,
src_path: PathBuf,
}
#[derive(Debug, Deserialize)]
struct CargoResolve {
nodes: Vec<CargoResolveNode>,
}
#[derive(Debug, Deserialize)]
struct CargoResolveNode {
id: String,
deps: Vec<CargoResolveDependency>,
}
#[derive(Debug, Deserialize)]
struct CargoResolveDependency {
pkg: String,
}
#[derive(Debug, Default, Eq, PartialEq)]
struct BackendWatchPaths {
paths: Vec<PathBuf>,
}
const BACKEND_READY_TIMEOUT: Duration = Duration::from_secs(300);
const BACKEND_READY_POLL_INTERVAL: Duration = Duration::from_millis(250);
const BACKEND_READY_CONNECT_TIMEOUT: Duration = Duration::from_millis(200);
fn default_web_dir() -> String {
"web".to_string()
}
impl ProjectProfile {
pub fn new(app_id: impl Into<String>, api_addr: impl Into<String>) -> Self {
Self {
app_id: app_id.into(),
web_dir: default_web_dir(),
bin_target: None,
api_addr: api_addr.into(),
web_backend_origin_env: None,
#[cfg(feature = "btcpay")]
btcpay: None,
public_assets: Vec::new(),
}
}
pub fn with_web_dir(mut self, web_dir: impl Into<String>) -> Self {
self.web_dir = web_dir.into();
self
}
pub fn with_bin_target(mut self, bin_target: impl Into<String>) -> Self {
self.bin_target = Some(bin_target.into());
self
}
pub fn with_web_backend_origin_env(mut self, key: impl Into<String>) -> Self {
self.web_backend_origin_env = Some(key.into());
self
}
#[cfg(feature = "btcpay")]
pub fn with_btcpay_profile(mut self, profile: BtcpayDevProfile) -> Self {
self.btcpay = Some(profile);
self
}
pub fn with_public_asset(mut self, asset: ProjectPublicAsset) -> Self {
self.public_assets.push(asset);
self
}
pub fn app_id(&self) -> &str {
&self.app_id
}
pub fn bin_target(&self) -> &str {
self.bin_target.as_deref().unwrap_or(&self.app_id)
}
pub fn validate(&self, source: &str) -> DevResult {
ensure!(
!self.app_id.trim().is_empty(),
"{source} contains an empty app id"
);
ensure!(
!self.web_dir.trim().is_empty(),
"{source} project `{}` has an empty web_dir",
self.app_id
);
ensure!(
!self.bin_target().trim().is_empty(),
"{source} project `{}` has an empty bin_target",
self.app_id
);
api_addr(self)?;
for asset in &self.public_assets {
asset.validate(source, &self.app_id)?;
}
#[cfg(feature = "btcpay")]
{
if let Some(profile) = &self.btcpay {
profile.validate().map_err(|error| {
color_eyre::eyre::eyre!(
"{source} project `{}` has an invalid BTCPay profile: {error:#}",
self.app_id
)
})?;
}
}
Ok(())
}
}
impl ProjectPublicAsset {
pub fn wasm_bindgen(
id: impl Into<String>,
label: impl Into<String>,
package: impl Into<String>,
wasm_file: impl Into<String>,
out_dir: impl Into<Vec<String>>,
out_name: impl Into<String>,
wasm_bindgen_version: impl Into<String>,
) -> Self {
Self::WasmBindgen(WasmBindgenPublicAsset {
id: id.into(),
label: label.into(),
package: package.into(),
wasm_file: wasm_file.into(),
no_default_features: false,
features: String::new(),
out_dir: out_dir.into(),
out_name: out_name.into(),
wasm_bindgen_version: wasm_bindgen_version.into(),
})
}
pub fn validate(&self, source: &str, project_id: &str) -> DevResult {
match self {
Self::WasmBindgen(asset) => asset.validate(source, project_id),
}
}
pub fn with_no_default_features(mut self, no_default_features: bool) -> Self {
match &mut self {
Self::WasmBindgen(asset) => {
asset.no_default_features = no_default_features;
}
}
self
}
pub fn with_features(mut self, features: impl Into<String>) -> Self {
match &mut self {
Self::WasmBindgen(asset) => {
asset.features = features.into();
}
}
self
}
}
impl WasmBindgenPublicAsset {
fn validate(&self, source: &str, project_id: &str) -> DevResult {
ensure!(
!self.id.trim().is_empty(),
"{source} project `{project_id}` has a wasm-bindgen asset with an empty id"
);
ensure!(
!self.label.trim().is_empty(),
"{source} project `{project_id}` asset `{}` has an empty label",
self.id
);
ensure!(
!self.package.trim().is_empty(),
"{source} project `{project_id}` asset `{}` has an empty package",
self.id
);
ensure!(
!self.wasm_file.trim().is_empty(),
"{source} project `{project_id}` asset `{}` has an empty wasm_file",
self.id
);
ensure!(
!self.out_dir.is_empty(),
"{source} project `{project_id}` asset `{}` must declare out_dir",
self.id
);
ensure!(
!self.out_name.trim().is_empty(),
"{source} project `{project_id}` asset `{}` has an empty out_name",
self.id
);
ensure!(
!self.wasm_bindgen_version.trim().is_empty(),
"{source} project `{project_id}` asset `{}` has an empty wasm_bindgen_version",
self.id
);
Ok(())
}
}
impl WorkspacePaths {
pub fn workspace_root(&self) -> &Path {
&self.workspace_root
}
pub fn project_root(&self) -> &Path {
&self.project_root
}
pub fn web_root(&self) -> &Path {
&self.web_root
}
}
pub fn run_project(profile: ProjectProfile, manifest_dir: impl AsRef<Path>) -> DevResult {
color_eyre::install()?;
match AppCli::parse().command {
AppCommand::Watch(command) => {
let paths = resolve_paths_from_manifest_dir(&profile, manifest_dir.as_ref())?;
run_profile(&profile, &paths, command.release, RunMode::Watch)
}
AppCommand::Serve(command) => {
let paths = resolve_paths_from_manifest_dir(&profile, manifest_dir.as_ref())?;
run_profile(&profile, &paths, command.release, RunMode::Serve)
}
AppCommand::Build(_) => {
let paths = resolve_paths_from_manifest_dir(&profile, manifest_dir.as_ref())?;
build_profile(&profile, &paths, true)
}
}
}
pub fn resolve_paths_from_manifest_dir(
profile: &ProjectProfile,
manifest_dir: &Path,
) -> DevResult<WorkspacePaths> {
let project_root = find_project_root_from_manifest_dir(manifest_dir, &profile.web_dir)?;
let workspace_root = find_workspace_root(&project_root)?;
let web_root = project_root.join(&profile.web_dir);
validate_paths(&project_root, &web_root)?;
Ok(WorkspacePaths {
workspace_root,
project_root,
web_root,
})
}
pub fn resolve_paths_from_workspace_project(
profile: &ProjectProfile,
repo_dir: &str,
) -> DevResult<WorkspacePaths> {
let cwd = env::current_dir().wrap_err("failed to resolve current directory")?;
let workspace_root = find_workspace_root(&cwd)?;
let project_root = workspace_root.join(repo_dir);
let web_root = project_root.join(&profile.web_dir);
validate_paths(&project_root, &web_root)?;
Ok(WorkspacePaths {
workspace_root,
project_root,
web_root,
})
}
pub fn run_profile(
profile: &ProjectProfile,
paths: &WorkspacePaths,
release: bool,
mode: RunMode,
) -> DevResult {
install_node_dependencies(&paths.web_root)?;
build_project_public_assets(profile, paths, release)?;
bootstrap_project_dev_services(profile, paths, release)?;
match mode {
RunMode::Watch => watch_project(profile, paths, release),
RunMode::Serve => {
build_react(&paths.web_root)?;
start_backend(profile, paths, release)
}
}
}
pub fn build_profile(profile: &ProjectProfile, paths: &WorkspacePaths, release: bool) -> DevResult {
install_node_dependencies(&paths.web_root)?;
build_project_public_assets(profile, paths, release)?;
build_react(&paths.web_root)
}
fn find_project_root_from_manifest_dir(manifest_dir: &Path, web_dir: &str) -> DevResult<PathBuf> {
let start = if manifest_dir.is_absolute() {
manifest_dir.to_path_buf()
} else {
env::current_dir()
.wrap_err("failed to resolve current directory")?
.join(manifest_dir)
};
let mut current = Some(start.as_path());
while let Some(candidate) = current {
if candidate.join("Cargo.toml").is_file()
&& candidate.join(web_dir).join("package.json").is_file()
{
return Ok(candidate.to_path_buf());
}
current = candidate.parent();
}
bail!(
"failed to resolve app project root from {}",
manifest_dir.display()
)
}
fn validate_paths(project_root: &Path, web_root: &Path) -> DevResult {
if !project_root.join("Cargo.toml").is_file() {
bail!(
"project manifest is missing at {}",
project_root.join("Cargo.toml").display()
);
}
if !web_root.join("package.json").is_file() {
bail!(
"web package manifest is missing at {}",
web_root.join("package.json").display()
);
}
Ok(())
}
#[cfg(feature = "btcpay")]
fn bootstrap_project_dev_services(
profile: &ProjectProfile,
paths: &WorkspacePaths,
release: bool,
) -> DevResult {
let Some(btcpay_profile) = profile.btcpay.clone() else {
return Ok(());
};
if release {
return Ok(());
}
if env::var(btcpay_profile.skip_btcpay_bootstrap_env_key()).as_deref() == Ok("1") {
return Ok(());
}
let options = LocalBtcpayBootstrapOptions::new(
btcpay_profile,
&paths.workspace_root,
&paths.project_root,
);
let summary = bootstrap_local_btcpay(&options)
.map_err(|error| color_eyre::eyre::eyre!("failed to bootstrap local BTCPay: {error:#}"))?;
eprintln!(
"BTCPay local dev bootstrap ready for {}: {} (store {}, api {}, webhook {}, env {})",
summary.app_display_name,
summary.url,
summary.store_id,
summary.api_key_masked,
summary.webhook_secret_masked,
summary.env_file.display()
);
Ok(())
}
#[cfg(not(feature = "btcpay"))]
fn bootstrap_project_dev_services(
_profile: &ProjectProfile,
_paths: &WorkspacePaths,
_release: bool,
) -> DevResult {
Ok(())
}
fn find_workspace_root(start: &Path) -> DevResult<PathBuf> {
let mut nearest_package = None;
for candidate in start.ancestors() {
let manifest_path = candidate.join("Cargo.toml");
if !manifest_path.is_file() {
continue;
}
let source = fs::read_to_string(&manifest_path).wrap_err_with(|| {
format!(
"failed to read Cargo manifest at {}",
manifest_path.display()
)
})?;
let manifest = toml::from_str::<toml::Table>(&source).wrap_err_with(|| {
format!(
"failed to parse Cargo manifest at {}",
manifest_path.display()
)
})?;
if manifest.contains_key("workspace") {
return Ok(candidate.to_path_buf());
}
if nearest_package.is_none() && manifest.contains_key("package") {
nearest_package = Some(candidate.to_path_buf());
}
}
nearest_package.ok_or_else(|| {
color_eyre::eyre::eyre!(
"failed to find a Cargo workspace or package manifest above {}",
start.display()
)
})
}
fn install_node_dependencies(web_root: &Path) -> DevResult {
if web_root.join("node_modules").is_dir() {
return Ok(());
}
run_command("Node dependency install", npm(), &["ci"], web_root, &[])
}
fn build_react(web_root: &Path) -> DevResult {
let script = resolve_build_script(web_root)?;
run_command("Web build", npm(), &["run", script], web_root, &[])
}
fn build_project_public_assets(
profile: &ProjectProfile,
paths: &WorkspacePaths,
release: bool,
) -> DevResult {
for asset in &profile.public_assets {
match asset {
ProjectPublicAsset::WasmBindgen(asset) => {
build_wasm_bindgen_public_asset(profile, paths, asset, release)?;
}
}
}
Ok(())
}
fn build_wasm_bindgen_public_asset(
profile: &ProjectProfile,
paths: &WorkspacePaths,
asset: &WasmBindgenPublicAsset,
release: bool,
) -> DevResult {
let target_dir = paths
.project_root
.join("target")
.join(format!("{}-dev-{}-build", profile.app_id, asset.id));
let target_dir_arg = path_arg(&target_dir)?;
let mut args = vec![
"build",
"--lib",
"--package",
asset.package.as_str(),
"--locked",
"--target",
"wasm32-unknown-unknown",
];
if release {
args.push("--release");
}
if asset.no_default_features {
args.push("--no-default-features");
}
if !asset.features.trim().is_empty() {
args.push("--features");
args.push(asset.features.as_str());
}
args.push("--target-dir");
args.push(target_dir_arg);
run_command(
format!("{} WASM build", asset.label).as_str(),
cargo(),
&args,
&paths.project_root,
&[],
)?;
let wasm_input = target_dir
.join("wasm32-unknown-unknown")
.join(if release { "release" } else { "debug" })
.join(&asset.wasm_file);
if !wasm_input.is_file() {
bail!(
"missing expected {} WASM at {}",
asset.label,
wasm_input.display()
);
}
let mut out_dir = paths.web_root.clone();
for segment in &asset.out_dir {
out_dir.push(segment);
}
if out_dir.exists() {
fs::remove_dir_all(&out_dir).wrap_err_with(|| {
format!(
"failed to remove previous {} WASM output at {}",
asset.label,
out_dir.display()
)
})?;
}
fs::create_dir_all(&out_dir).wrap_err_with(|| {
format!(
"failed to create {} WASM output directory at {}",
asset.label,
out_dir.display()
)
})?;
let wasm_input_arg = path_arg(&wasm_input)?;
let out_dir_arg = path_arg(&out_dir)?;
run_command(
format!("{} wasm-bindgen", asset.label).as_str(),
resolve_wasm_bindgen(&asset.wasm_bindgen_version)?,
&[
wasm_input_arg,
"--target",
"web",
"--out-dir",
out_dir_arg,
"--out-name",
asset.out_name.as_str(),
],
&paths.project_root,
&[],
)
}
fn resolve_build_script(web_root: &Path) -> DevResult<&'static str> {
let package_json_path = web_root.join("package.json");
let package_json = std::fs::read_to_string(&package_json_path)
.wrap_err_with(|| format!("failed to read {}", package_json_path.display()))?;
let package_json: Value = serde_json::from_str(&package_json)
.wrap_err_with(|| format!("failed to parse {}", package_json_path.display()))?;
let scripts = package_json
.get("scripts")
.and_then(Value::as_object)
.ok_or_else(|| color_eyre::eyre::eyre!("package.json has no scripts object"))?;
if scripts.contains_key("build") {
return Ok("build");
}
if scripts.contains_key("build:web") {
return Ok("build:web");
}
bail!(
"{} must define either `build` or `build:web`",
package_json_path.display()
)
}
fn start_backend(profile: &ProjectProfile, paths: &WorkspacePaths, release: bool) -> DevResult {
let args = backend_args(profile, release);
run_command("Rust backend", cargo(), &args, &paths.project_root, &[])
}
fn watch_project(profile: &ProjectProfile, paths: &WorkspacePaths, release: bool) -> DevResult {
let api_addr = api_addr(profile)?;
let watchexec = resolve_watchexec()?;
let watch_paths = resolve_backend_watch_paths(profile, paths)?;
let backend_args = backend_watchexec_args(profile, paths, &watch_paths, release);
let mut backend = spawn_command(
"Rust backend watcher",
watchexec,
&backend_args,
&paths.project_root,
&[],
)?;
wait_for_backend_listener(&mut backend, api_addr)?;
let backend_origin = format!("http://127.0.0.1:{}", api_addr.port());
let web_envs = web_dev_envs(profile, &backend_origin);
let mut web = spawn_command(
"web dev server",
npm(),
&["run", "dev"],
&paths.web_root,
&web_envs,
)?;
wait_for_watch_processes(&mut backend, &mut web)
}
fn wait_for_backend_listener(backend: &mut Child, api_addr: SocketAddr) -> DevResult {
let ready_addr = SocketAddr::from(([127, 0, 0, 1], api_addr.port()));
let started_at = Instant::now();
loop {
if let Some(status) = backend.try_wait().wrap_err("failed to poll Rust backend")? {
if status.success() {
bail!("Rust backend exited before it opened {}", ready_addr);
}
bail!(
"Rust backend exited with status {:?} before it opened {}",
status.code(),
ready_addr
);
}
let last_error =
match TcpStream::connect_timeout(&ready_addr, BACKEND_READY_CONNECT_TIMEOUT) {
Ok(_) => return Ok(()),
Err(error) => error,
};
if started_at.elapsed() >= BACKEND_READY_TIMEOUT {
terminate_child(backend);
bail!(
"Rust backend did not open {} within {:?}: {}",
ready_addr,
BACKEND_READY_TIMEOUT,
last_error
);
}
thread::sleep(BACKEND_READY_POLL_INTERVAL);
}
}
fn backend_args(profile: &ProjectProfile, release: bool) -> Vec<&str> {
let mut args = vec!["run", "--bin", profile.bin_target(), "--locked"];
if release {
args.push("--release");
}
args
}
fn backend_watchexec_args(
profile: &ProjectProfile,
paths: &WorkspacePaths,
watch_paths: &BackendWatchPaths,
release: bool,
) -> Vec<OsString> {
let mut args = vec![
OsString::from("--restart"),
OsString::from("--shell=none"),
OsString::from("--emit-events-to=none"),
OsString::from("--project-origin"),
paths.project_root.as_os_str().to_os_string(),
OsString::from("--exts"),
OsString::from("rs,toml,lock,sql"),
];
for path in &watch_paths.paths {
args.push(OsString::from("--watch"));
args.push(path.as_os_str().to_os_string());
}
args.push(OsString::from("--"));
args.push(cargo());
args.extend(
backend_args(profile, release)
.into_iter()
.map(OsString::from),
);
args
}
fn resolve_watchexec() -> DevResult<OsString> {
let executable = find_on_path("watchexec").ok_or_else(|| {
color_eyre::eyre::eyre!(
"Watchexec is required for backend reloads; install it with `cargo install watchexec-cli --version 2.5.1 --locked`"
)
})?;
let output = ProcessCommand::new(&executable)
.arg("--version")
.output()
.wrap_err("failed to check the installed Watchexec version")?;
if output.status.success() {
return Ok(executable.into_os_string());
}
let stderr = String::from_utf8_lossy(&output.stderr);
let details = stderr.trim();
if details.is_empty() {
bail!(
"Watchexec is required for backend reloads; install it with `cargo install watchexec-cli --version 2.5.1 --locked`"
);
}
bail!(
"Watchexec is required for backend reloads; install it with `cargo install watchexec-cli --version 2.5.1 --locked`\n\n{details}"
)
}
fn resolve_backend_watch_paths(
profile: &ProjectProfile,
paths: &WorkspacePaths,
) -> DevResult<BackendWatchPaths> {
let output = ProcessCommand::new(cargo())
.args(["metadata", "--format-version=1", "--locked"])
.current_dir(&paths.project_root)
.output()
.wrap_err("failed to read Cargo metadata for backend reloads")?;
if !output.status.success() {
let details = String::from_utf8_lossy(&output.stderr);
bail!(
"Cargo metadata failed for backend reloads: {}",
details.trim()
);
}
let metadata = serde_json::from_slice::<CargoMetadata>(&output.stdout)
.wrap_err("failed to parse Cargo metadata for backend reloads")?;
backend_watch_paths_from_metadata(&metadata, profile.bin_target())
}
fn backend_watch_paths_from_metadata(
metadata: &CargoMetadata,
bin_target: &str,
) -> DevResult<BackendWatchPaths> {
let resolve = metadata
.resolve
.as_ref()
.ok_or_else(|| color_eyre::eyre::eyre!("Cargo metadata did not include a resolve graph"))?;
let root_ids = metadata
.packages
.iter()
.filter(|package| {
package.targets.iter().any(|target| {
target.name == bin_target && target.kind.iter().any(|kind| kind == "bin")
})
})
.map(|package| package.id.as_str())
.collect::<Vec<_>>();
ensure!(
!root_ids.is_empty(),
"Cargo metadata contains no binary target named `{bin_target}`"
);
let nodes = resolve
.nodes
.iter()
.map(|node| (node.id.as_str(), node))
.collect::<HashMap<_, _>>();
let mut reachable = BTreeSet::new();
let mut pending = root_ids.into_iter().collect::<VecDeque<_>>();
while let Some(package_id) = pending.pop_front() {
if !reachable.insert(package_id) {
continue;
}
if let Some(node) = nodes.get(package_id) {
pending.extend(node.deps.iter().map(|dependency| dependency.pkg.as_str()));
}
}
let mut watch_paths = BTreeSet::new();
for package in metadata
.packages
.iter()
.filter(|package| package.source.is_none() && reachable.contains(package.id.as_str()))
{
let package_root = package.manifest_path.parent().ok_or_else(|| {
color_eyre::eyre::eyre!(
"Cargo package manifest has no parent: {}",
package.manifest_path.display()
)
})?;
insert_existing_watch_path(&mut watch_paths, &package.manifest_path)?;
let workspace_root = find_workspace_root(package_root)?;
insert_existing_watch_path(&mut watch_paths, &workspace_root.join("Cargo.toml"))?;
for workspace_file in ["Cargo.lock", "rust-toolchain.toml", ".rust-version"] {
let workspace_file = workspace_root.join(workspace_file);
if workspace_file.is_file() {
insert_existing_watch_path(&mut watch_paths, &workspace_file)?;
}
}
let cargo_config_dir = workspace_root.join(".cargo");
if cargo_config_dir.is_dir() {
insert_existing_watch_path(&mut watch_paths, &cargo_config_dir)?;
}
let conventional_source_root = package_root.join("src");
if conventional_source_root.is_dir() {
insert_existing_watch_path(&mut watch_paths, &conventional_source_root)?;
}
for target in &package.targets {
if !target.src_path.starts_with(&conventional_source_root) && target.src_path.is_file()
{
insert_existing_watch_path(&mut watch_paths, &target.src_path)?;
}
}
let migrations = package_root.join("migrations");
if migrations.is_dir() {
insert_existing_watch_path(&mut watch_paths, &migrations)?;
}
}
Ok(BackendWatchPaths {
paths: watch_paths.into_iter().collect(),
})
}
fn insert_existing_watch_path(paths: &mut BTreeSet<PathBuf>, path: &Path) -> DevResult {
let canonical = path
.canonicalize()
.wrap_err_with(|| format!("failed to resolve backend watch path {}", path.display()))?;
paths.insert(canonical);
Ok(())
}
fn api_addr(profile: &ProjectProfile) -> DevResult<SocketAddr> {
profile.api_addr.parse::<SocketAddr>().wrap_err_with(|| {
format!(
"invalid site address `{}` for project `{}`",
profile.api_addr, profile.app_id
)
})
}
fn web_dev_envs(profile: &ProjectProfile, backend_origin: &str) -> Vec<(String, String)> {
let mut envs = Vec::new();
if let Some(origin_env) = &profile.web_backend_origin_env {
envs.push((origin_env.clone(), backend_origin.to_string()));
}
envs
}
fn wait_for_watch_processes(backend: &mut Child, web: &mut Child) -> DevResult {
loop {
if let Some(status) = backend.try_wait().wrap_err("failed to poll Rust backend")? {
terminate_child(web);
if status.success() {
bail!("Rust backend exited before the web dev server");
}
bail!("Rust backend exited with status {:?}", status.code());
}
if let Some(status) = web.try_wait().wrap_err("failed to poll web dev server")? {
terminate_child(backend);
if status.success() {
return Ok(());
}
bail!("web dev server exited with status {:?}", status.code());
}
thread::sleep(Duration::from_millis(250));
}
}
fn terminate_child(child: &mut Child) {
let _ = child.kill();
let _ = child.wait();
}
fn run_command<S: AsRef<OsStr>>(
label: &str,
executable: OsString,
args: &[S],
cwd: &Path,
envs: &[(String, String)],
) -> DevResult {
let mut child = spawn_command(label, executable, args, cwd, envs)?;
let status = child
.wait()
.wrap_err_with(|| format!("failed to wait for {label}"))?;
if status.success() {
return Ok(());
}
bail!("{label} exited with status {:?}", status.code())
}
fn spawn_command<S: AsRef<OsStr>>(
label: &str,
executable: OsString,
args: &[S],
cwd: &Path,
envs: &[(String, String)],
) -> DevResult<Child> {
let mut command = ProcessCommand::new(executable);
command.current_dir(cwd).args(args);
for (key, value) in envs {
command.env(key, value);
}
command
.spawn()
.wrap_err_with(|| format!("failed to start {label}"))
}
fn path_arg(path: &Path) -> DevResult<&str> {
path.to_str()
.ok_or_else(|| color_eyre::eyre::eyre!("path is not valid UTF-8: {}", path.display()))
}
fn cargo() -> OsString {
env::var_os("CARGO").unwrap_or_else(|| OsString::from("cargo"))
}
fn npm() -> OsString {
match cfg!(windows) {
true => OsString::from("npm.cmd"),
false => OsString::from("npm"),
}
}
fn resolve_wasm_bindgen(required_version: &str) -> DevResult<OsString> {
if let Some(configured) = env::var_os("WASM_BINDGEN") {
if wasm_bindgen_matches(&configured, required_version) {
return Ok(configured);
}
bail!(
"WASM_BINDGEN does not point to wasm-bindgen {}",
required_version
);
}
if let Some(executable) = find_on_path("wasm-bindgen") {
let executable = executable.into_os_string();
if wasm_bindgen_matches(&executable, required_version) {
return Ok(executable);
}
}
bail!(
"wasm-bindgen {} is required for configured WASM public assets; install it with `cargo install wasm-bindgen-cli --version {} --locked` or set WASM_BINDGEN",
required_version,
required_version
)
}
fn wasm_bindgen_matches(executable: &OsString, required_version: &str) -> bool {
let output = ProcessCommand::new(executable).arg("--version").output();
let Ok(output) = output else {
return false;
};
let text = String::from_utf8_lossy(&output.stdout);
output.status.success() && text.contains(required_version)
}
fn find_on_path(name: &str) -> Option<PathBuf> {
let path = env::var_os("PATH")?;
for directory in env::split_paths(&path) {
let candidate = directory.join(name);
if candidate.is_file() {
return Some(candidate);
}
#[cfg(windows)]
{
let candidate = directory.join(format!("{name}.exe"));
if candidate.is_file() {
return Some(candidate);
}
}
}
None
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::atomic::{AtomicU64, Ordering};
static TEMP_SEQUENCE: AtomicU64 = AtomicU64::new(0);
struct TestDirectory(PathBuf);
impl TestDirectory {
fn new(label: &str) -> Self {
let sequence = TEMP_SEQUENCE.fetch_add(1, Ordering::Relaxed);
let path = env::temp_dir().join(format!(
"cargo-workspace-{label}-{}-{sequence}",
std::process::id()
));
fs::create_dir_all(&path).expect("test directory should be created");
Self(path)
}
fn path(&self) -> &Path {
&self.0
}
}
impl Drop for TestDirectory {
fn drop(&mut self) {
let _ = fs::remove_dir_all(&self.0);
}
}
#[test]
fn project_profile_reuses_the_explicit_identity_by_default() {
let profile = ProjectProfile::new("example-service", "127.0.0.1:4100");
assert_eq!(profile.app_id(), "example-service");
assert_eq!(profile.bin_target(), "example-service");
profile
.validate("test profile")
.expect("single-identity profile should be valid");
}
#[test]
fn builds_direct_watchexec_commands_for_debug_and_release_backends() {
let directory = TestDirectory::new("watchexec-args");
let profile = ProjectProfile::new("example-service", "127.0.0.1:4100")
.with_bin_target("example-server");
let paths = WorkspacePaths {
workspace_root: directory.path().to_path_buf(),
project_root: directory.path().to_path_buf(),
web_root: directory.path().join("web"),
};
let watch_paths = BackendWatchPaths {
paths: vec![directory.path().join("src")],
};
assert_eq!(
backend_watchexec_args(&profile, &paths, &watch_paths, false),
[
OsString::from("--restart"),
OsString::from("--shell=none"),
OsString::from("--emit-events-to=none"),
OsString::from("--project-origin"),
directory.path().as_os_str().to_os_string(),
OsString::from("--exts"),
OsString::from("rs,toml,lock,sql"),
OsString::from("--watch"),
directory.path().join("src").into_os_string(),
OsString::from("--"),
cargo(),
OsString::from("run"),
OsString::from("--bin"),
OsString::from("example-server"),
OsString::from("--locked"),
]
);
let release_args = backend_watchexec_args(&profile, &paths, &watch_paths, true);
assert_eq!(release_args.last(), Some(&OsString::from("--release")));
}
#[test]
fn watches_only_local_packages_reachable_from_the_backend() {
let directory = TestDirectory::new("cargo-metadata");
let app_workspace = directory.path().join("app-workspace");
let app_root = app_workspace.join("app");
let unused_root = app_workspace.join("unused");
let shared_workspace = directory.path().join("shared-workspace");
let shared_root = shared_workspace.join("crates").join("shared");
for source_root in [
app_root.join("src"),
unused_root.join("src"),
shared_root.join("src"),
] {
fs::create_dir_all(source_root).expect("source directory should be created");
}
fs::write(
app_workspace.join("Cargo.toml"),
"[workspace]\nmembers = [\"app\", \"unused\"]\nresolver = \"3\"\n",
)
.expect("app workspace manifest should be written");
fs::write(
shared_workspace.join("Cargo.toml"),
"[workspace]\nmembers = [\"crates/shared\"]\nresolver = \"3\"\n",
)
.expect("shared workspace manifest should be written");
for (root, name) in [
(&app_root, "app"),
(&unused_root, "unused"),
(&shared_root, "shared"),
] {
fs::write(
root.join("Cargo.toml"),
format!("[package]\nname = \"{name}\"\nversion = \"0.1.0\"\n"),
)
.expect("package manifest should be written");
}
let package = |id: &str, root: &Path, target_name: &str, kind: &str| CargoPackage {
id: id.to_string(),
source: None,
manifest_path: root.join("Cargo.toml"),
targets: vec![CargoTarget {
kind: vec![kind.to_string()],
name: target_name.to_string(),
src_path: root
.join("src")
.join(if kind == "bin" { "main.rs" } else { "lib.rs" }),
}],
};
let metadata = CargoMetadata {
packages: vec![
package("app", &app_root, "example-server", "bin"),
package("shared", &shared_root, "shared", "lib"),
package("unused", &unused_root, "unused", "lib"),
CargoPackage {
id: "registry".to_string(),
source: Some("registry+https://example.invalid".to_string()),
manifest_path: directory.path().join("registry").join("Cargo.toml"),
targets: Vec::new(),
},
],
resolve: Some(CargoResolve {
nodes: vec![
CargoResolveNode {
id: "app".to_string(),
deps: vec![
CargoResolveDependency {
pkg: "shared".to_string(),
},
CargoResolveDependency {
pkg: "registry".to_string(),
},
],
},
CargoResolveNode {
id: "shared".to_string(),
deps: Vec::new(),
},
CargoResolveNode {
id: "unused".to_string(),
deps: Vec::new(),
},
CargoResolveNode {
id: "registry".to_string(),
deps: Vec::new(),
},
],
}),
};
let watch_paths = backend_watch_paths_from_metadata(&metadata, "example-server")
.expect("backend watch paths should resolve");
assert!(
watch_paths
.paths
.contains(&app_root.join("src").canonicalize().unwrap())
);
assert!(
watch_paths
.paths
.contains(&shared_root.join("src").canonicalize().unwrap())
);
assert!(
!watch_paths
.paths
.contains(&unused_root.join("src").canonicalize().unwrap())
);
assert!(
watch_paths
.paths
.contains(&app_workspace.join("Cargo.toml").canonicalize().unwrap())
);
assert!(
watch_paths
.paths
.contains(&shared_workspace.join("Cargo.toml").canonicalize().unwrap())
);
assert!(
!watch_paths
.paths
.contains(&app_root.canonicalize().unwrap())
);
}
#[test]
fn project_profile_keeps_an_explicit_binary_override() {
let profile = ProjectProfile::new("example-service", "127.0.0.1:4100")
.with_bin_target("custom-server");
assert_eq!(profile.app_id(), "example-service");
assert_eq!(profile.bin_target(), "custom-server");
}
#[test]
fn finds_the_owning_cargo_workspace_above_a_member_package() {
let directory = TestDirectory::new("member");
let workspace_root = directory.path().join("workspace");
let package_root = workspace_root.join("crates").join("service");
let source_root = package_root.join("src");
fs::create_dir_all(&source_root).expect("member source directory should be created");
fs::write(
workspace_root.join("Cargo.toml"),
"[workspace]\nmembers = [\"crates/service\"]\n",
)
.expect("workspace manifest should be written");
fs::write(
package_root.join("Cargo.toml"),
"[package]\nname = \"service\"\nversion = \"0.1.0\"\n",
)
.expect("package manifest should be written");
assert_eq!(
find_workspace_root(&source_root).expect("workspace should resolve"),
workspace_root
);
}
#[test]
fn uses_the_nearest_standalone_package_when_no_workspace_exists() {
let directory = TestDirectory::new("standalone");
let package_root = directory.path().join("service");
let source_root = package_root.join("src");
fs::create_dir_all(&source_root).expect("package source directory should be created");
fs::write(
package_root.join("Cargo.toml"),
"[package]\nname = \"service\"\nversion = \"0.1.0\"\n",
)
.expect("package manifest should be written");
assert_eq!(
find_workspace_root(&source_root).expect("package root should resolve"),
package_root
);
}
#[test]
fn reports_an_invalid_cargo_manifest() {
let directory = TestDirectory::new("invalid");
let package_root = directory.path().join("service");
fs::create_dir_all(&package_root).expect("package directory should be created");
fs::write(package_root.join("Cargo.toml"), "[workspace\n")
.expect("invalid manifest should be written");
let error = find_workspace_root(&package_root)
.expect_err("an invalid Cargo manifest must not be ignored");
assert!(error.to_string().contains("failed to parse Cargo manifest"));
}
}