use crate::strategies::{
get_all_workers, get_worker_by_name, normalize_config_paths_for_persistence,
resolve_config_paths_for_runtime, WorkerConfig, XbpConfig,
};
use crate::utils::{find_xbp_config_upwards, parse_config_with_auto_heal};
use regex::Regex;
use serde_json::{json, Value};
use std::collections::HashMap;
use std::env;
use std::fs;
use std::path::{Path, PathBuf};
use walkdir::WalkDir;
const DEFAULT_WORKER_NAME: &str = "xbp";
const KNOWN_WORKER_APP_DIRS: &[&str] = &["apps/web", "apps/api", "apps/pkg"];
#[derive(Debug, Clone)]
pub struct DiscoveredWorkerApp {
pub label: String,
pub root: PathBuf,
pub base_script_name: String,
pub config: Option<WorkerConfig>,
}
#[derive(Debug, Clone)]
pub struct WorkerTargetResolution {
pub project_root: PathBuf,
pub config_path: PathBuf,
pub config: XbpConfig,
pub selected: Vec<DiscoveredWorkerApp>,
}
pub fn pick_first_non_empty<I>(values: I) -> Option<String>
where
I: IntoIterator<Item = Option<String>>,
{
values
.into_iter()
.flatten()
.map(|value| value.trim().to_string())
.find(|value| !value.is_empty() && !value.starts_with('<'))
}
pub fn discover_worker_apps(start: &Path) -> Vec<DiscoveredWorkerApp> {
let (project_root, config) = match load_project_config_from_start(start) {
Ok((project_root, _, config)) => (project_root, Some(config)),
Err(_) => (
find_xbp_config_upwards(start)
.map(|found| found.project_root)
.unwrap_or_else(|| start.to_path_buf()),
None,
),
};
discover_worker_apps_for_project(&project_root, config.as_ref())
}
pub fn discover_worker_apps_for_project(
project_root: &Path,
config: Option<&XbpConfig>,
) -> Vec<DiscoveredWorkerApp> {
let mut apps = Vec::new();
if let Some(config) = config {
for worker in get_all_workers(config) {
let root = PathBuf::from(&worker.root);
if !looks_like_worker_root(&root) {
continue;
}
push_worker_app(&mut apps, worker_app_from_config(&root, worker));
}
}
for relative in KNOWN_WORKER_APP_DIRS {
let candidate = project_root.join(relative);
if !looks_like_worker_root(&candidate)
|| apps.iter().any(|app| path_eq(&app.root, &candidate))
{
continue;
}
let label = relative
.split('/')
.next_back()
.unwrap_or(relative)
.to_string();
push_worker_app(&mut apps, worker_app_from_root(&candidate, label));
}
for candidate in scan_worker_roots(project_root) {
if apps.iter().any(|app| path_eq(&app.root, &candidate)) {
continue;
}
push_worker_app(
&mut apps,
worker_app_from_root(&candidate, infer_worker_label(&candidate, project_root)),
);
}
if looks_like_worker_root(project_root)
&& !apps.iter().any(|app| path_eq(&app.root, project_root))
{
push_worker_app(
&mut apps,
worker_app_from_root(project_root, "worker".to_string()),
);
}
apps.sort_by(|left, right| left.label.cmp(&right.label));
apps
}
pub fn resolve_worker_targets(
start: &Path,
root_override: Option<&Path>,
app_override: Option<&str>,
all: bool,
) -> Result<WorkerTargetResolution, String> {
let start = root_override
.map(Path::to_path_buf)
.unwrap_or_else(|| start.to_path_buf());
let (project_root, config_path, config) = load_project_config_from_start(&start)?;
let apps = discover_worker_apps_for_project(&project_root, Some(&config));
if apps.is_empty() {
return Err(format!(
"No Worker apps were found for {}. Add a `workers:` section to {} or run `xbp workers deploy configure --write-config`.",
project_root.display(),
config_path.display()
));
}
let selected = if all {
apps.clone()
} else if let Some(app_name) = app_override
.map(str::trim)
.filter(|value| !value.is_empty())
{
let worker = get_worker_by_name(&config, app_name)?;
let selected = apps
.iter()
.find(|app| app.label == worker.name || app.base_script_name == worker.name)
.cloned()
.or_else(|| {
let root = PathBuf::from(&worker.root);
if looks_like_worker_root(&root) {
Some(worker_app_from_config(&root, worker.clone()))
} else {
None
}
})
.ok_or_else(|| {
format!(
"Worker app `{app_name}` is configured in {} but its root `{}` is not a valid Worker project.",
config_path.display(),
worker.root
)
})?;
vec![selected]
} else {
match select_worker_for_current_dir(&start, &project_root, &apps) {
Ok(app) => vec![app],
Err(_) if apps.len() == 1 => vec![apps[0].clone()],
Err(message) => return Err(message),
}
};
Ok(WorkerTargetResolution {
project_root,
config_path,
config,
selected,
})
}
pub fn scan_worker_roots(project_root: &Path) -> Vec<PathBuf> {
const SKIP_DIRS: &[&str] = &[
".git",
".xbp",
"node_modules",
"target",
"dist",
"build",
".next",
".open-next",
".wrangler",
".turbo",
"coverage",
];
let mut discovered = Vec::new();
for entry in WalkDir::new(project_root)
.follow_links(false)
.into_iter()
.filter_map(Result::ok)
.filter(|entry| entry.file_type().is_dir())
{
let depth = entry.depth();
if depth == 0 || depth > 6 {
continue;
}
let relative_path = entry
.path()
.strip_prefix(project_root)
.unwrap_or(entry.path());
if relative_path.components().any(|component| {
let name = component.as_os_str().to_string_lossy();
SKIP_DIRS.iter().any(|skip| name == *skip)
}) {
continue;
}
let candidate = entry.into_path();
if looks_like_worker_root(&candidate) {
discovered.push(candidate);
}
}
discovered.sort();
discovered
}
pub fn discover_worker_configs_on_disk(project_root: &Path) -> Vec<WorkerConfig> {
discover_worker_apps_for_project(project_root, None)
.into_iter()
.map(|app| WorkerConfig {
name: app.label.clone(),
root: collapse_worker_root(project_root, &app.root),
script_name: Some(app.base_script_name),
service: None,
deploy: None,
container: None,
})
.collect()
}
pub fn upsert_worker_configs(config: &mut XbpConfig, discovered: Vec<WorkerConfig>) -> usize {
let workers = config.workers.get_or_insert_with(Vec::new);
let mut inserted = 0usize;
for candidate in discovered {
let existing_index = workers.iter().position(|existing| {
existing.name == candidate.name
|| existing.root == candidate.root
|| existing
.script_name
.as_deref()
.is_some_and(|name| candidate.script_name.as_deref() == Some(name))
});
if let Some(index) = existing_index {
let existing = workers.get_mut(index).expect("worker index");
if existing.script_name.is_none() {
existing.script_name = candidate.script_name;
}
if existing.service.is_none() {
existing.service = candidate.service;
}
if existing.deploy.is_none() {
existing.deploy = candidate.deploy;
}
} else {
workers.push(candidate);
inserted += 1;
}
}
workers.sort_by(|left, right| left.name.cmp(&right.name));
inserted
}
pub fn write_worker_configs_to_project(
project_root: &Path,
config_path: &Path,
config: &mut XbpConfig,
) -> Result<(), String> {
normalize_config_paths_for_persistence(config, project_root);
let yaml = serde_yaml::to_string(config)
.map_err(|error| format!("Failed to encode {}: {}", config_path.display(), error))?;
fs::write(config_path, yaml).map_err(|error| {
format!(
"Failed to write worker config to {}: {}",
config_path.display(),
error
)
})
}
fn load_project_config_from_start(start: &Path) -> Result<(PathBuf, PathBuf, XbpConfig), String> {
let found = find_xbp_config_upwards(start).ok_or_else(|| {
format!(
"Could not find .xbp/xbp.yaml from {}. Run `xbp setup` or pass `--root`.",
start.display()
)
})?;
let content = fs::read_to_string(&found.config_path)
.map_err(|error| format!("Failed to read {}: {}", found.config_path.display(), error))?;
let (mut config, healed_content) = parse_config_with_auto_heal(&content, found.kind)
.map_err(|error| format!("Failed to parse {}: {}", found.config_path.display(), error))?;
if let Some(healed_content) = healed_content {
let _ = fs::write(&found.config_path, healed_content);
}
resolve_config_paths_for_runtime(&mut config, &found.project_root);
Ok((found.project_root, found.config_path, config))
}
fn select_worker_for_current_dir(
start: &Path,
project_root: &Path,
apps: &[DiscoveredWorkerApp],
) -> Result<DiscoveredWorkerApp, String> {
let start = fs::canonicalize(start).unwrap_or_else(|_| start.to_path_buf());
let _project_root =
fs::canonicalize(project_root).unwrap_or_else(|_| project_root.to_path_buf());
let mut matches = apps
.iter()
.filter(|app| {
let root = fs::canonicalize(&app.root).unwrap_or_else(|_| app.root.clone());
start == root || start.starts_with(&root)
})
.cloned()
.collect::<Vec<_>>();
matches.sort_by_key(|app| std::cmp::Reverse(path_depth(&app.root)));
matches.dedup_by(|left, right| path_eq(&left.root, &right.root));
match matches.len() {
0 => Err(format!(
"Could not infer a Worker app from {}. Pass `--app <name>` or `--all`. Known workers: {}",
start.display(),
format_worker_names(apps)
)),
1 => Ok(matches.remove(0)),
_ => Err(format!(
"Multiple Worker apps match {}: {}. Pass `--app <name>` to choose one.",
start.display(),
format_worker_names(&matches)
)),
}
}
fn worker_app_from_config(root: &Path, worker: WorkerConfig) -> DiscoveredWorkerApp {
let local_env = parse_multiline_env_file(&root.join(".env.local")).unwrap_or_default();
let base_script_name = pick_first_non_empty([
worker.script_name.clone(),
Some(resolve_dashboard_worker_name(root, &local_env)),
])
.unwrap_or_else(|| worker.name.clone());
DiscoveredWorkerApp {
label: worker.name.clone(),
root: root.to_path_buf(),
base_script_name,
config: Some(worker),
}
}
fn worker_app_from_root(root: &Path, label: String) -> DiscoveredWorkerApp {
let local_env = parse_multiline_env_file(&root.join(".env.local")).unwrap_or_default();
DiscoveredWorkerApp {
label: label.clone(),
base_script_name: resolve_dashboard_worker_name(root, &local_env),
root: root.to_path_buf(),
config: None,
}
}
fn push_worker_app(apps: &mut Vec<DiscoveredWorkerApp>, app: DiscoveredWorkerApp) {
if apps
.iter()
.any(|existing| path_eq(&existing.root, &app.root))
{
return;
}
apps.push(app);
}
fn infer_worker_label(root: &Path, project_root: &Path) -> String {
read_worker_name_from_config(&root.join("wrangler.jsonc"))
.or_else(|| read_worker_name_from_config(&root.join("wrangler.jsonc.example")))
.or_else(|| {
root.strip_prefix(project_root)
.ok()
.and_then(|relative| relative.to_str())
.map(|relative| relative.replace('\\', "/"))
})
.and_then(|value| {
value
.split('/')
.next_back()
.map(str::to_string)
.filter(|value| !value.is_empty())
})
.unwrap_or_else(|| "worker".to_string())
}
fn collapse_worker_root(project_root: &Path, worker_root: &Path) -> String {
crate::utils::collapse_project_path(project_root, &worker_root.to_string_lossy())
}
fn format_worker_names(apps: &[DiscoveredWorkerApp]) -> String {
apps.iter()
.map(|app| format!("{} ({})", app.label, app.base_script_name))
.collect::<Vec<_>>()
.join(", ")
}
fn path_depth(path: &Path) -> usize {
path.components().count()
}
pub fn resolve_project_script_names(apps: &[DiscoveredWorkerApp]) -> Vec<String> {
let mut names = Vec::new();
for app in apps {
names.push(app.base_script_name.clone());
for environment in ["production", "preview"] {
names.push(format!("{}-{}", app.base_script_name, environment));
}
}
names.sort();
names.dedup();
names
}
pub fn script_belongs_to_project(script_id: &str, project_scripts: &[String]) -> bool {
project_scripts.iter().any(|expected| {
script_id == expected
|| script_id.starts_with(&format!("{expected}-"))
|| expected.starts_with(&format!("{script_id}-"))
})
}
pub fn label_for_script(script_id: &str, apps: &[DiscoveredWorkerApp]) -> Option<String> {
apps.iter()
.find(|app| {
script_id == app.base_script_name
|| script_id.starts_with(&format!("{}-", app.base_script_name))
})
.map(|app| app.label.clone())
}
pub fn worker_root_for_script<'a>(
script_id: &str,
apps: &'a [DiscoveredWorkerApp],
) -> Option<&'a Path> {
apps.iter()
.find(|app| {
script_id == app.base_script_name
|| script_id.starts_with(&format!("{}-", app.base_script_name))
})
.map(|app| app.root.as_path())
}
pub fn resolve_workers_project_root(
root_override: Option<&Path>,
app_override: Option<&str>,
) -> Result<PathBuf, String> {
let current_dir =
env::current_dir().map_err(|error| format!("Failed to read current dir: {}", error))?;
let resolution = resolve_worker_targets(¤t_dir, root_override, app_override, false)?;
resolution
.selected
.first()
.map(|app| app.root.clone())
.ok_or_else(|| "No Worker app was selected.".to_string())
}
fn looks_like_worker_root(path: &Path) -> bool {
path.join("package.json").exists()
&& (path
.join("scripts")
.join("generate-wrangler-dashboard-config.mjs")
.exists()
|| path.join("wrangler.jsonc").exists()
|| path.join("wrangler.json").exists()
|| path.join("wrangler.toml").exists()
|| path.join("wrangler.jsonc.example").exists())
}
pub fn resolve_dashboard_worker_name(
worker_root: &Path,
local_env: &HashMap<String, String>,
) -> String {
pick_first_non_empty([
local_env.get("DASHBOARD_PROD_WORKER_NAME").cloned(),
env::var("DASHBOARD_PROD_WORKER_NAME").ok(),
local_env.get("CLOUDFLARE_WORKER_NAME").cloned(),
env::var("CLOUDFLARE_WORKER_NAME").ok(),
local_env.get("WORKER_NAME").cloned(),
env::var("WORKER_NAME").ok(),
read_worker_name_from_config(&worker_root.join("wrangler.jsonc")),
read_worker_name_from_config(&worker_root.join("wrangler.json")),
read_worker_name_from_config(&worker_root.join("wrangler.toml")),
read_worker_name_from_config(&worker_root.join("wrangler.jsonc.example")),
Some(DEFAULT_WORKER_NAME.to_string()),
])
.unwrap_or_else(|| DEFAULT_WORKER_NAME.to_string())
}
pub fn resolve_remote_script_name(
worker_root: &Path,
script_override: Option<&str>,
worker_override: Option<&str>,
environment: Option<&str>,
local_env: &HashMap<String, String>,
) -> String {
if let Some(script) = script_override
.map(str::trim)
.filter(|value| !value.is_empty())
{
return script.to_string();
}
let base_name = pick_first_non_empty([
worker_override.map(str::trim).map(ToOwned::to_owned),
Some(resolve_dashboard_worker_name(worker_root, local_env)),
])
.unwrap_or_else(|| DEFAULT_WORKER_NAME.to_string());
match environment.map(str::trim).filter(|value| !value.is_empty()) {
Some(environment) => format!("{base_name}-{environment}"),
None => base_name,
}
}
pub fn resolve_wrangler_config_path(
worker_root: &Path,
command_name: &str,
mode: &str,
) -> Option<String> {
let is_dev_command =
command_name.eq_ignore_ascii_case("serve") && !mode.eq_ignore_ascii_case("production");
if !is_dev_command {
return None;
}
for name in [
"wrangler.dev.jsonc",
"wrangler.dev.json",
"wrangler.dev.toml",
"wrangler.jsonc",
"wrangler.json",
"wrangler.toml",
] {
if worker_root.join(name).exists() {
return Some(name.to_string());
}
}
None
}
pub fn parse_multiline_env_file(path: &Path) -> Result<HashMap<String, String>, String> {
let content = fs::read_to_string(path)
.map_err(|error| format!("Failed to read {}: {}", path.display(), error))?;
Ok(parse_multiline_env_content(&content))
}
pub fn parse_multiline_env_content(content: &str) -> HashMap<String, String> {
let mut vars = HashMap::new();
let lines = content.lines().collect::<Vec<_>>();
let mut key: Option<String> = None;
let mut value_lines = Vec::new();
let flush = |vars: &mut HashMap<String, String>,
key: &mut Option<String>,
value_lines: &mut Vec<String>| {
let Some(current_key) = key.take() else {
return;
};
let mut value = value_lines.join("\n").trim().to_string();
if (value.starts_with('"') && value.ends_with('"'))
|| (value.starts_with('\'') && value.ends_with('\''))
{
value = value[1..value.len().saturating_sub(1)].to_string();
}
vars.insert(current_key, value);
value_lines.clear();
};
for line in lines {
let trimmed = line.trim();
if key.is_none() && (trimmed.is_empty() || trimmed.starts_with('#')) {
continue;
}
if key.is_none() {
let Some((raw_key, raw_value)) = line.split_once('=') else {
continue;
};
let parsed_key = raw_key.trim();
if parsed_key.is_empty() {
continue;
}
key = Some(parsed_key.to_string());
value_lines.push(raw_value.to_string());
let joined = value_lines.join("\n");
let unquoted = joined.trim();
let is_complete_quoted = (unquoted.starts_with('"') && unquoted.ends_with('"'))
|| (unquoted.starts_with('\'') && unquoted.ends_with('\''));
if is_complete_quoted || !unquoted.starts_with('"') {
flush(&mut vars, &mut key, &mut value_lines);
}
continue;
}
value_lines.push(line.to_string());
let joined = value_lines.join("\n").trim().to_string();
if joined.ends_with('"') || joined.ends_with('\'') {
flush(&mut vars, &mut key, &mut value_lines);
}
}
flush(&mut vars, &mut key, &mut value_lines);
vars
}
pub fn read_configured_custom_domain_routes(worker_root: &Path) -> Vec<Value> {
let site_config_path = worker_root.join("src").join("lib").join("site-config.ts");
let Ok(content) = fs::read_to_string(site_config_path) else {
return Vec::new();
};
let domain_pattern = Regex::new(r#"domain:\s*"([^"]+)""#).expect("domain regex");
let Some(captures) = domain_pattern.captures(&content) else {
return Vec::new();
};
let Some(domain) = captures.get(1).map(|value| value.as_str().trim()) else {
return Vec::new();
};
if domain.is_empty() {
return Vec::new();
}
vec![json!({
"pattern": domain,
"zone_name": domain,
"custom_domain": true
})]
}
fn read_worker_name_from_config(path: &Path) -> Option<String> {
if !path.exists() {
return None;
}
read_top_level_string_property(path, "name")
}
fn read_top_level_string_property(path: &Path, property_name: &str) -> Option<String> {
let content = fs::read_to_string(path).ok()?;
if path.extension().and_then(|value| value.to_str()) == Some("toml") {
let property_pattern = Regex::new(&format!(
r#"(?m)^\s*{}\s*=\s*["']([^"']+)["']"#,
regex::escape(property_name)
))
.ok()?;
return property_pattern
.captures(&content)
.and_then(|captures| captures.get(1))
.map(|value| value.as_str().trim().to_string())
.filter(|value| !value.is_empty());
}
let property_pattern = Regex::new(&format!(
r#"^\s*"{}"\s*:\s*"([^"]+)""#,
regex::escape(property_name)
))
.ok()?;
let mut brace_depth = 0usize;
let mut in_string = false;
let mut escaped = false;
let mut line_start = 0usize;
let maybe_read_line = |line_end: usize, brace_depth: usize, line_start: usize| {
if brace_depth != 1 {
return None;
}
let line = &content[line_start..line_end];
property_pattern
.captures(line)
.and_then(|captures| captures.get(1))
.map(|value| value.as_str().trim().to_string())
.filter(|value| !value.is_empty())
};
for (index, ch) in content.char_indices() {
if ch == '\n' {
if let Some(value) = maybe_read_line(index, brace_depth, line_start) {
return Some(value);
}
line_start = index + 1;
continue;
}
if in_string {
if escaped {
escaped = false;
continue;
}
if ch == '\\' {
escaped = true;
continue;
}
if ch == '"' {
in_string = false;
}
continue;
}
if ch == '"' {
in_string = true;
continue;
}
if ch == '{' {
brace_depth += 1;
} else if ch == '}' {
brace_depth = brace_depth.saturating_sub(1);
}
}
maybe_read_line(content.len(), brace_depth, line_start)
}
fn path_eq(left: &Path, right: &Path) -> bool {
normalize_path(left) == normalize_path(right)
}
fn normalize_path(path: &Path) -> String {
path.to_string_lossy()
.replace('\\', "/")
.to_ascii_lowercase()
}
#[cfg(test)]
mod tests {
use super::{
discover_worker_apps, parse_multiline_env_content, read_configured_custom_domain_routes,
resolve_project_script_names, resolve_remote_script_name, resolve_wrangler_config_path,
script_belongs_to_project,
};
use std::collections::HashMap;
use std::fs;
fn temp_dir(label: &str) -> std::path::PathBuf {
let dir = std::env::temp_dir().join(format!(
"xbp-workers-project-{label}-{}",
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.expect("system time")
.as_nanos()
));
fs::create_dir_all(&dir).expect("create temp dir");
dir
}
#[test]
fn parse_multiline_env_content_handles_quoted_blocks() {
let parsed =
parse_multiline_env_content("A=plain\nB=\"line 1\nline 2\"\nC='single quoted'\n");
assert_eq!(parsed.get("A").map(String::as_str), Some("plain"));
assert_eq!(parsed.get("B").map(String::as_str), Some("line 1\nline 2"));
assert_eq!(parsed.get("C").map(String::as_str), Some("single quoted"));
}
#[test]
fn resolve_remote_script_name_appends_environment() {
let root = temp_dir("script-name");
fs::write(root.join("wrangler.jsonc"), "{\n \"name\": \"xbp\"\n}\n").expect("write");
let name =
resolve_remote_script_name(&root, None, None, Some("production"), &HashMap::new());
assert_eq!(name, "xbp-production");
let _ = fs::remove_dir_all(root);
}
#[test]
fn resolve_wrangler_config_prefers_dev_for_local_serve() {
let root = temp_dir("config-path");
fs::write(root.join("wrangler.dev.jsonc"), "{}\n").expect("write dev config");
fs::write(root.join("wrangler.jsonc"), "{}\n").expect("write default config");
assert_eq!(
resolve_wrangler_config_path(&root, "serve", "development").as_deref(),
Some("wrangler.dev.jsonc")
);
assert_eq!(
resolve_wrangler_config_path(&root, "serve", "production"),
None
);
let _ = fs::remove_dir_all(root);
}
#[test]
fn recognizes_toml_wrangler_projects_and_reads_local_config() {
let root = temp_dir("toml-config");
fs::write(root.join("package.json"), "{}\n").expect("package json");
fs::write(
root.join("wrangler.toml"),
"name = \"toml-worker\"\nmain = \"src/index.js\"\n",
)
.expect("wrangler toml");
assert!(super::looks_like_worker_root(&root));
assert_eq!(
super::read_worker_name_from_config(&root.join("wrangler.toml")).as_deref(),
Some("toml-worker")
);
assert_eq!(
resolve_wrangler_config_path(&root, "serve", "development").as_deref(),
Some("wrangler.toml")
);
let _ = fs::remove_dir_all(root);
}
#[test]
fn discover_worker_apps_finds_known_apps_under_repo_root() {
let repo_root = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("..")
.join("..");
let apps = discover_worker_apps(&repo_root);
let labels = apps
.iter()
.map(|app| app.label.as_str())
.collect::<Vec<_>>();
assert!(labels.contains(&"web"));
assert!(labels.contains(&"api"));
assert!(labels.contains(&"pkg"));
}
#[test]
fn resolve_project_script_names_include_environment_suffixes() {
let root = temp_dir("project-script-names");
fs::write(
root.join("wrangler.jsonc"),
"{\n \"name\": \"xbp-api\"\n}\n",
)
.expect("write");
let apps = vec![super::DiscoveredWorkerApp {
label: "api".to_string(),
root: root.clone(),
base_script_name: "xbp-api".to_string(),
config: None,
}];
let names = resolve_project_script_names(&apps);
assert!(names.iter().any(|name| name == "xbp-api"));
assert!(names.iter().any(|name| name == "xbp-api-production"));
assert!(script_belongs_to_project("xbp-api-production", &names));
let _ = fs::remove_dir_all(root);
}
#[test]
fn scan_worker_roots_finds_nested_wrangler_projects() {
let project_root = temp_dir("scan-workers");
let nested = project_root.join("apps").join("studio");
fs::create_dir_all(&nested).expect("nested worker dir");
fs::write(nested.join("package.json"), "{}\n").expect("package json");
fs::write(
nested.join("wrangler.jsonc"),
"{\n \"name\": \"athena-studio\"\n}\n",
)
.expect("wrangler config");
let discovered = super::scan_worker_roots(&project_root);
assert_eq!(discovered.len(), 1);
assert!(discovered[0].ends_with("apps/studio") || discovered[0].ends_with("apps\\studio"));
let _ = fs::remove_dir_all(project_root);
}
#[test]
fn discover_worker_apps_prefers_configured_workers() {
let project_root = temp_dir("configured-workers");
let worker_root = project_root.join("apps").join("gateway");
fs::create_dir_all(&worker_root).expect("worker dir");
fs::write(worker_root.join("package.json"), "{}\n").expect("package json");
fs::write(
worker_root.join("wrangler.jsonc"),
"{\n \"name\": \"athena\"\n}\n",
)
.expect("wrangler config");
let config = crate::strategies::XbpConfig {
project_name: "athena".to_string(),
version: "1.0.0".to_string(),
port: 3000,
build_dir: "./".to_string(),
workers: Some(vec![crate::strategies::WorkerConfig {
name: "athena".to_string(),
root: worker_root.to_string_lossy().to_string(),
script_name: Some("athena".to_string()),
service: None,
deploy: None,
container: None,
}]),
app_type: None,
build_command: None,
start_command: None,
install_command: None,
environment: None,
services: None,
openapi: None,
systemd_service_name: None,
systemd: None,
kafka_brokers: None,
kafka_topic: None,
kafka_public_url: None,
log_files: None,
monitor_url: None,
monitor_method: None,
monitor_expected_code: None,
monitor_interval: None,
database: None,
target: None,
branch: None,
crate_name: None,
npm_script: None,
port_storybook: None,
url: None,
url_storybook: None,
linear: None,
github: None,
release: None,
publish: None,
version_targets: Vec::new(),
version_domains: Vec::new(),
};
let apps = super::discover_worker_apps_for_project(&project_root, Some(&config));
assert_eq!(apps.len(), 1);
assert_eq!(apps[0].label, "athena");
assert_eq!(apps[0].base_script_name, "athena");
let _ = fs::remove_dir_all(project_root);
}
#[test]
fn read_configured_custom_domain_routes_extracts_site_domain() {
let root = temp_dir("routes");
let site_config_dir = root.join("src").join("lib");
fs::create_dir_all(&site_config_dir).expect("site config dir");
fs::write(
site_config_dir.join("site-config.ts"),
"export const siteConfig = {\n domain: \"xbp.app\",\n};\n",
)
.expect("write site config");
let routes = read_configured_custom_domain_routes(&root);
assert_eq!(routes.len(), 1);
assert_eq!(routes[0]["pattern"], "xbp.app");
let _ = fs::remove_dir_all(root);
}
}