use crate::cli::interactive::{
pad_label, print_picker_header, searchable_select_required,
};
use crate::commands::cloudflare_config::is_interactive_terminal;
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_repository_xbp_config, find_xbp_config_upwards, list_nested_package_xbp_config_paths,
parse_config_with_auto_heal, FoundXbpConfig,
};
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 raw_root = PathBuf::from(clean_relative_root(&worker.root));
let root = if raw_root.is_absolute() {
clean_path_buf(&raw_root)
} else if raw_root.as_os_str() == "." || worker.root.trim().is_empty() {
project_root.to_path_buf()
} else {
normalize_worker_root_path(project_root, &raw_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 cwd = env::current_dir().unwrap_or_else(|_| start.to_path_buf());
let start = match root_override {
Some(path) => resolve_xbp_start_path(path, &cwd),
None => 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 selected = find_worker_app_by_alias(&apps, app_name)
.or_else(|| {
get_worker_by_name(&config, app_name).ok().and_then(|worker| {
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))
} else {
None
}
})
})
})
.ok_or_else(|| {
format!(
"Worker app `{app_name}` was not found. Pass a configured workers[].name, directory label, wrangler script name, or custom domain. Known workers: {}",
format_worker_names(&apps)
)
})?;
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 ensure_selected_workers_configured(
resolution: &mut WorkerTargetResolution,
) -> Result<usize, String> {
let mut discovered = Vec::new();
for app in &resolution.selected {
if app.config.is_some() {
continue;
}
discovered.push(WorkerConfig {
name: app.label.clone(),
root: collapse_worker_root(&resolution.project_root, &app.root),
script_name: Some(app.base_script_name.clone()),
service: None,
deploy: None,
container: None,
containers: None,
durable_objects: None,
});
}
if discovered.is_empty() {
return Ok(0);
}
let inserted = upsert_worker_configs(&mut resolution.config, discovered);
write_worker_configs_to_project(
&resolution.project_root,
&resolution.config_path,
&mut resolution.config,
)?;
for selected in &mut resolution.selected {
if selected.config.is_some() {
continue;
}
let matched = get_all_workers(&resolution.config).into_iter().find(|worker| {
worker.name == selected.label
|| worker
.script_name
.as_deref()
.is_some_and(|name| name == selected.base_script_name)
|| {
let configured_root = PathBuf::from(&worker.root);
let absolute = if configured_root.is_absolute() {
configured_root.clone()
} else {
resolution.project_root.join(&configured_root)
};
path_eq(&absolute, &selected.root) || path_eq(&configured_root, &selected.root)
}
});
if let Some(worker) = matched {
*selected = worker_app_from_config(&selected.root, worker);
}
}
Ok(inserted)
}
pub(crate) fn find_worker_app_by_alias(
apps: &[DiscoveredWorkerApp],
query: &str,
) -> Option<DiscoveredWorkerApp> {
let query = query.trim();
if query.is_empty() {
return None;
}
let candidates_q = {
let mut q = vec![normalize_worker_alias(query)];
let alt = normalize_worker_alias(query); if !q.contains(&alt) {
q.push(alt);
}
let raw = query.trim().to_ascii_lowercase();
if !q.contains(&raw) {
q.push(raw);
}
q
};
let mut matches = apps
.iter()
.filter(|app| {
let aliases = worker_app_aliases(app);
candidates_q.iter().any(|normalized| {
aliases
.iter()
.any(|alias| normalize_worker_alias(alias) == *normalized)
|| normalize_worker_alias(&app.label) == *normalized
|| normalize_worker_alias(&app.base_script_name) == *normalized
})
})
.cloned()
.collect::<Vec<_>>();
matches.sort_by(|left, right| left.label.cmp(&right.label));
matches.dedup_by(|left, right| path_eq(&left.root, &right.root));
if matches.is_empty() {
return None;
}
if matches.len() == 1 {
return Some(matches.remove(0));
}
for normalized in &candidates_q {
if let Some(exact) = matches
.iter()
.find(|app| normalize_worker_alias(&app.label) == *normalized)
.cloned()
{
return Some(exact);
}
if let Some(by_script) = matches
.iter()
.find(|app| normalize_worker_alias(&app.base_script_name) == *normalized)
.cloned()
{
return Some(by_script);
}
}
if let Some(non_generic) = matches
.iter()
.find(|app| !app.label.eq_ignore_ascii_case("worker"))
.cloned()
{
return Some(non_generic);
}
matches.sort_by_key(|app| std::cmp::Reverse(path_depth(&app.root)));
matches.into_iter().next()
}
fn worker_app_aliases(app: &DiscoveredWorkerApp) -> Vec<String> {
let mut aliases = vec![app.label.clone(), app.base_script_name.clone()];
if !app.label.eq_ignore_ascii_case(&app.base_script_name)
&& !app.base_script_name.is_empty()
{
aliases.push(format!("{} ({})", app.label, app.base_script_name));
aliases.push(format!("({})", app.base_script_name));
}
if let Some(base) = app
.root
.file_name()
.and_then(|s| s.to_str())
.map(str::trim)
.filter(|s| !s.is_empty())
{
aliases.push(base.to_string());
}
if let Some(worker) = app.config.as_ref() {
aliases.push(worker.name.clone());
if let Some(script_name) = worker.script_name.as_ref() {
aliases.push(script_name.clone());
}
let cfg_root = PathBuf::from(clean_relative_root(&worker.root));
if let Some(base) = cfg_root
.file_name()
.and_then(|s| s.to_str())
.map(str::trim)
.filter(|s| !s.is_empty() && *s != ".")
{
aliases.push(base.to_string());
}
}
aliases.extend(read_worker_route_aliases(&app.root));
aliases
.into_iter()
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
.collect()
}
fn read_worker_route_aliases(worker_root: &Path) -> Vec<String> {
let mut aliases = Vec::new();
for route in read_configured_custom_domain_routes(worker_root) {
if let Some(pattern) = route.get("pattern").and_then(Value::as_str) {
aliases.push(pattern.to_string());
}
if let Some(zone) = route.get("zone_name").and_then(Value::as_str) {
aliases.push(zone.to_string());
}
}
for config_name in ["wrangler.jsonc", "wrangler.json", "wrangler.toml"] {
let path = worker_root.join(config_name);
if !path.exists() {
continue;
}
aliases.extend(read_route_aliases_from_wrangler_config(&path));
}
aliases
}
fn read_route_aliases_from_wrangler_config(path: &Path) -> Vec<String> {
let Ok(content) = fs::read_to_string(path) else {
return Vec::new();
};
if path
.extension()
.and_then(|value| value.to_str())
.is_some_and(|ext| ext == "json" || ext == "jsonc")
{
let stripped = strip_jsonc_comments_light(&content);
if let Ok(value) = serde_json::from_str::<Value>(&stripped) {
let mut aliases = Vec::new();
if let Some(name) = value.get("name").and_then(Value::as_str) {
aliases.push(name.to_string());
}
push_route_value_aliases(value.get("route"), &mut aliases);
push_route_value_aliases(value.get("routes"), &mut aliases);
return aliases;
}
}
let mut aliases = Vec::new();
let pattern_re =
Regex::new(r#"(?i)(?:pattern|zone_name|zone)\s*[:=]\s*["']([^"']+)["']"#).expect("regex");
for captures in pattern_re.captures_iter(&content) {
if let Some(value) = captures.get(1) {
aliases.push(value.as_str().to_string());
}
}
aliases
}
fn push_route_value_aliases(value: Option<&Value>, aliases: &mut Vec<String>) {
let Some(value) = value else {
return;
};
match value {
Value::String(pattern) => aliases.push(pattern.clone()),
Value::Object(map) => {
if let Some(pattern) = map.get("pattern").and_then(Value::as_str) {
aliases.push(pattern.to_string());
}
if let Some(zone) = map
.get("zone_name")
.or_else(|| map.get("zone"))
.and_then(Value::as_str)
{
aliases.push(zone.to_string());
}
}
Value::Array(items) => {
for item in items {
push_route_value_aliases(Some(item), aliases);
}
}
_ => {}
}
}
fn normalize_worker_alias(value: &str) -> String {
let value = value.trim();
let value = if let Some(open) = value.rfind('(') {
if value.ends_with(')') {
let inner = value[open + 1..value.len() - 1].trim();
if !inner.is_empty() {
inner
} else {
value
}
} else {
value
}
} else {
value
};
value
.trim()
.trim_start_matches("https://")
.trim_start_matches("http://")
.trim_end_matches('/')
.to_ascii_lowercase()
}
fn strip_jsonc_comments_light(input: &str) -> String {
let mut output = String::with_capacity(input.len());
let bytes = input.as_bytes();
let mut i = 0usize;
let mut in_string = false;
let mut escaped = false;
while i < bytes.len() {
let ch = bytes[i] as char;
if in_string {
output.push(ch);
if escaped {
escaped = false;
} else if ch == '\\' {
escaped = true;
} else if ch == '"' {
in_string = false;
}
i += 1;
continue;
}
if ch == '"' {
in_string = true;
output.push(ch);
i += 1;
continue;
}
if ch == '/' && i + 1 < bytes.len() {
let next = bytes[i + 1] as char;
if next == '/' {
i += 2;
while i < bytes.len() && bytes[i] != b'\n' {
i += 1;
}
continue;
}
if next == '*' {
i += 2;
while i + 1 < bytes.len() && !(bytes[i] == b'*' && bytes[i + 1] == b'/') {
i += 1;
}
i = (i + 2).min(bytes.len());
continue;
}
}
output.push(ch);
i += 1;
}
let mut cleaned = String::with_capacity(output.len());
let chars: Vec<char> = output.chars().collect();
let mut idx = 0usize;
while idx < chars.len() {
if chars[idx] == ',' {
let mut look = idx + 1;
while look < chars.len() && chars[look].is_whitespace() {
look += 1;
}
if look < chars.len() && (chars[look] == '}' || chars[look] == ']') {
idx += 1;
continue;
}
}
cleaned.push(chars[idx]);
idx += 1;
}
cleaned
}
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,
containers: None,
durable_objects: None,
})
.collect()
}
pub fn upsert_worker_configs(config: &mut XbpConfig, discovered: Vec<WorkerConfig>) -> usize {
upsert_worker_configs_merging(config, discovered).0
}
pub fn upsert_worker_configs_merging(
config: &mut XbpConfig,
discovered: Vec<WorkerConfig>,
) -> (usize, usize) {
let workers = config.workers.get_or_insert_with(Vec::new);
let mut inserted = 0usize;
let mut updated = 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");
let mut changed = false;
if existing.script_name.is_none() && candidate.script_name.is_some() {
existing.script_name = candidate.script_name.clone();
changed = true;
}
if existing.service.is_none() && candidate.service.is_some() {
existing.service = candidate.service.clone();
changed = true;
}
if existing.deploy.is_none() && candidate.deploy.is_some() {
existing.deploy = candidate.deploy.clone();
changed = true;
}
if existing.container.is_none() && candidate.container.is_some() {
existing.container = candidate.container.clone();
changed = true;
}
if existing.containers.is_none() && candidate.containers.is_some() {
existing.containers = candidate.containers.clone();
changed = true;
}
if existing.durable_objects.is_none() && candidate.durable_objects.is_some() {
existing.durable_objects = candidate.durable_objects.clone();
changed = true;
}
let er = existing.root.trim();
if (er.is_empty() || er == "." || er == "./")
&& !candidate.root.trim().is_empty()
&& candidate.root.trim() != "."
{
existing.root = candidate.root.clone();
changed = true;
}
if changed {
updated += 1;
}
} else {
workers.push(candidate);
inserted += 1;
}
}
workers.sort_by(|left, right| left.name.cmp(&right.name));
(inserted, updated)
}
pub fn absorb_nested_package_xbp_into_repository(
repo: &FoundXbpConfig,
config: &mut XbpConfig,
) -> Result<(usize, usize, Vec<String>), String> {
let nested_paths = list_nested_package_xbp_config_paths(&repo.project_root)?;
let mut to_upsert: Vec<WorkerConfig> = Vec::new();
let mut notes = Vec::new();
for path in nested_paths {
if path == repo.config_path {
continue;
}
let Some(xbp_dir) = path.parent() else {
continue;
};
let Some(package_root) = xbp_dir.parent() else {
continue;
};
let kind = path
.extension()
.and_then(|e| e.to_str())
.unwrap_or("toml");
let content = match fs::read_to_string(&path) {
Ok(c) => c,
Err(e) => {
notes.push(format!("skip {}: {e}", path.display()));
continue;
}
};
let (nested, _) = match parse_config_with_auto_heal::<XbpConfig>(&content, kind) {
Ok(v) => v,
Err(e) => {
notes.push(format!("skip {}: {e}", path.display()));
continue;
}
};
let package_rel = package_root
.strip_prefix(&repo.project_root)
.map(|p| p.to_string_lossy().replace('\\', "/"))
.unwrap_or_else(|_| package_root.display().to_string());
let package_name = {
let from_cfg = nested.project_name.trim();
if !from_cfg.is_empty() {
from_cfg.to_string()
} else {
package_root
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("worker")
.to_string()
}
};
let workers = nested.workers.unwrap_or_default();
if workers.is_empty() {
if looks_like_worker_root(package_root) {
to_upsert.push(WorkerConfig {
name: package_name.clone(),
root: package_rel.clone(),
script_name: Some(package_name.clone()),
service: Some(package_name.clone()),
deploy: None,
container: None,
containers: None,
durable_objects: None,
});
notes.push(format!("absorbed wrangler package {package_rel}"));
}
continue;
}
let worker_count = workers.len();
for w in workers {
let local = w.root.trim().trim_start_matches("./");
let abs_root = if local.is_empty() || local == "." {
package_rel.clone()
} else {
format!(
"{}/{}",
package_rel.trim_end_matches('/'),
local.replace('\\', "/")
)
};
let name = {
let n = w.name.trim();
if n.is_empty() || n.eq_ignore_ascii_case("worker") || n == "." {
package_name.clone()
} else {
n.to_string()
}
};
let script = w
.script_name
.clone()
.filter(|s| !s.trim().is_empty())
.unwrap_or_else(|| name.clone());
to_upsert.push(WorkerConfig {
name,
root: abs_root,
script_name: Some(script),
service: w.service.or_else(|| Some(package_name.clone())),
deploy: w.deploy,
container: w.container,
containers: w.containers,
durable_objects: w.durable_objects,
});
}
notes.push(format!(
"absorbed {worker_count} worker(s) from {package_rel}"
));
}
if to_upsert.is_empty() {
return Ok((0, 0, notes));
}
let (inserted, updated) = upsert_worker_configs_merging(config, to_upsert);
Ok((inserted, updated, notes))
}
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
)
})
}
pub fn resolve_xbp_start_path(path: &Path, cwd: &Path) -> PathBuf {
let try_canon = |p: PathBuf| -> PathBuf {
fs::canonicalize(&p).unwrap_or(p)
};
if path.as_os_str().is_empty() {
return try_canon(cwd.to_path_buf());
}
if path.exists() {
return try_canon(path.to_path_buf());
}
let joined = cwd.join(path);
if joined.exists() {
return try_canon(joined);
}
let raw = path.to_string_lossy();
let has_leading_sep = raw.starts_with('/') || raw.starts_with('\\');
let stripped = raw
.trim_start_matches(['/', '\\'])
.replace('\\', "/");
if has_leading_sep && !stripped.is_empty() {
let rel = cwd.join(PathBuf::from(stripped.replace('/', std::path::MAIN_SEPARATOR_STR)));
if rel.exists() {
return try_canon(rel);
}
}
path.to_path_buf()
}
fn load_project_config_from_start(start: &Path) -> Result<(PathBuf, PathBuf, XbpConfig), String> {
let found = find_repository_xbp_config(start)
.or_else(|| find_xbp_config_upwards(start))
.ok_or_else(|| {
let cwd = env::current_dir().ok();
let hint = cwd
.as_ref()
.map(|c| {
format!(
" If you meant a path under the monorepo, use a relative root \
(e.g. `--root services/athena-auth` or `--app athena-auth` from {}). \
A leading `/` is only an absolute filesystem path.",
c.display()
)
})
.unwrap_or_default();
format!(
"Could not find .xbp/xbp.yaml from {}. Run `xbp setup`, pass `--root <project>`, or `--app <worker>`.{hint}",
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);
}
match absorb_nested_package_xbp_into_repository(&found, &mut config) {
Ok((inserted, updated, notes)) if inserted > 0 || updated > 0 => {
if let Err(e) =
write_worker_configs_to_project(&found.project_root, &found.config_path, &mut config)
{
eprintln!(
"xbp: nested package absorb wrote workers but failed to persist {}: {e}",
found.config_path.display()
);
} else {
eprintln!(
"xbp: absorbed nested package configs into {} (+{inserted} worker(s), ~{updated} updated)",
found.location
);
for note in notes.iter().take(8) {
eprintln!(" · {note}");
}
}
}
Ok(_) => {}
Err(e) => {
eprintln!("xbp: nested package absorb skipped: {e}");
}
}
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_canon =
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));
let only_project_root_stub = matches.len() == 1
&& path_eq(&matches[0].root, &project_root_canon)
&& apps.len() > 1;
let at_project_root = path_eq(&start, &project_root_canon);
match matches.len() {
1 if !only_project_root_stub && worker_root_looks_deployable(&matches[0].root) => {
Ok(matches.remove(0))
}
1 if only_project_root_stub || at_project_root => {
prompt_worker_app_selection(apps, project_root, &start, true)
}
0 => prompt_worker_app_selection(apps, project_root, &start, true),
1 => {
if is_interactive_worker_selection() && apps.len() > 1 {
prompt_worker_app_selection(apps, project_root, &start, true)
} else {
Ok(matches.remove(0))
}
}
_ => {
if at_project_root || matches.iter().any(|m| path_eq(&m.root, &project_root_canon))
{
prompt_worker_app_selection(apps, project_root, &start, true)
} else {
prompt_worker_app_selection(&matches, project_root, &start, false)
}
}
}
}
fn is_interactive_worker_selection() -> bool {
is_interactive_terminal() && env::var_os("XBP_NON_INTERACTIVE").is_none()
}
fn worker_root_looks_deployable(worker_root: &Path) -> bool {
for name in ["wrangler.toml", "wrangler.jsonc", "wrangler.json"] {
let path = worker_root.join(name);
if !path.is_file() {
continue;
}
let Ok(raw) = fs::read_to_string(&path) else {
continue;
};
let lower = raw.to_ascii_lowercase();
if lower.contains("\nmain ")
|| lower.contains("\nmain=")
|| lower.contains("\"main\"")
|| lower.contains("'main'")
|| lower.contains("assets")
&& (lower.contains("directory") || lower.contains("[assets]"))
{
if lower.contains("main")
|| lower.contains("assets.directory")
|| lower.contains("[assets]")
|| lower.contains("\"directory\"")
{
let has_main_key = lower.lines().any(|l| {
let t = l.trim_start();
t.starts_with("main ")
|| t.starts_with("main=")
|| t.starts_with("\"main\"")
|| t.starts_with("'main'")
});
let has_assets = lower.contains("[assets]")
|| lower.contains("\"assets\"")
|| lower.contains("assets.directory");
if has_main_key || has_assets {
return true;
}
}
}
}
false
}
fn worker_choice_kind(app: &DiscoveredWorkerApp) -> &'static str {
let has_container = app
.config
.as_ref()
.and_then(|c| c.container.as_ref().or(c.containers.as_ref().and_then(|v| v.first())))
.is_some();
if has_container {
"CTR"
} else if worker_root_looks_deployable(&app.root) {
"WKR"
} else {
"STUB"
}
}
fn worker_choice_label(app: &DiscoveredWorkerApp, project_hint: Option<&Path>) -> String {
let root_display = project_hint
.and_then(|project_root| {
let app_root = clean_path_buf(&app.root);
let project_root = clean_path_buf(project_root);
app_root
.strip_prefix(&project_root)
.ok()
.map(|rel| clean_relative_root(&rel.to_string_lossy()))
.or_else(|| {
let a = normalize_path(&app_root);
let p = normalize_path(&project_root);
a.strip_prefix(&p)
.map(|s| clean_relative_root(s.trim_start_matches('/')))
.map(|s| s.to_string())
})
})
.filter(|value| !value.is_empty())
.unwrap_or_else(|| clean_relative_root(&app.root.to_string_lossy()));
let kind = worker_choice_kind(app);
let script = app.base_script_name.as_str();
let entry = if worker_root_looks_deployable(&app.root) {
"entry✓"
} else {
"no-entry"
};
format!(
"{} {} {} {} · {}",
pad_label(kind, 4),
pad_label(&app.label, 22),
pad_label(script, 18),
pad_label(entry, 8),
root_display
)
}
fn prompt_worker_app_selection(
candidates: &[DiscoveredWorkerApp],
project_root: &Path,
start: &Path,
used_all_known: bool,
) -> Result<DiscoveredWorkerApp, String> {
if candidates.is_empty() {
return Err(format!(
"Could not infer a Worker app from {}. Pass `--app <name>` or `--all`.",
start.display()
));
}
if candidates.len() == 1 {
return Ok(candidates[0].clone());
}
if !is_interactive_worker_selection() {
let names = format_worker_names(candidates);
return Err(if used_all_known {
format!(
"Could not infer a Worker app from {}. Pass `--app <name>` or `--all`. Known workers: {}",
start.display(),
names
)
} else {
format!(
"Multiple Worker apps match {}: {}. Pass `--app <name>` to choose one.",
start.display(),
names
)
});
}
let mut ordered: Vec<DiscoveredWorkerApp> = candidates.to_vec();
ordered.sort_by(|a, b| {
let da = worker_root_looks_deployable(&a.root) as u8;
let db = worker_root_looks_deployable(&b.root) as u8;
db.cmp(&da)
.then_with(|| {
let ca = worker_choice_kind(a) == "CTR";
let cb = worker_choice_kind(b) == "CTR";
cb.cmp(&ca)
})
.then_with(|| a.label.cmp(&b.label))
});
print_picker_header(
"xbp cloudflare · pick a Worker app",
"WKR / CTR / STUB · type to fuzzy-filter · Enter selects · Esc cancels",
);
let labels: Vec<String> = ordered
.iter()
.map(|app| worker_choice_label(app, Some(project_root)))
.collect();
let default_idx = ordered
.iter()
.position(|a| worker_root_looks_deployable(&a.root))
.unwrap_or(0);
let selection = searchable_select_required("Worker app", &labels, default_idx)?;
Ok(ordered[selection].clone())
}
fn worker_app_from_config(root: &Path, worker: WorkerConfig) -> DiscoveredWorkerApp {
let root = clean_path_buf(root);
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());
let label = {
let n = worker.name.trim();
if n.is_empty() || n.eq_ignore_ascii_case("worker") {
if !base_script_name.is_empty() && !base_script_name.eq_ignore_ascii_case("worker") {
base_script_name.clone()
} else {
root.file_name()
.and_then(|s| s.to_str())
.unwrap_or("worker")
.to_string()
}
} else {
n.to_string()
}
};
let mut worker = worker;
worker.name = label.clone();
worker.root = clean_relative_root(&worker.root);
if worker.script_name.as_deref().is_none_or(|s| s.trim().is_empty()) {
worker.script_name = Some(base_script_name.clone());
}
DiscoveredWorkerApp {
label,
root,
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 let Some(existing) = apps
.iter_mut()
.find(|existing| path_eq(&existing.root, &app.root))
{
if existing.label.eq_ignore_ascii_case("worker")
&& !app.label.eq_ignore_ascii_case("worker")
{
existing.label = app.label.clone();
}
if existing.base_script_name.eq_ignore_ascii_case("worker")
&& !app.base_script_name.eq_ignore_ascii_case("worker")
{
existing.base_script_name = app.base_script_name.clone();
}
if existing.config.is_none() && app.config.is_some() {
existing.config = app.config;
} else if let (Some(ex), Some(new_cfg)) = (existing.config.as_mut(), app.config.as_ref()) {
if ex.container.is_none() && new_cfg.container.is_some() {
ex.container = new_cfg.container.clone();
}
if ex.script_name.is_none() {
ex.script_name = new_cfg.script_name.clone();
}
if ex.name.eq_ignore_ascii_case("worker") && !new_cfg.name.eq_ignore_ascii_case("worker")
{
ex.name = new_cfg.name.clone();
}
let cleaned = clean_relative_root(&ex.root);
if cleaned == "." || cleaned == "./" {
ex.root = clean_relative_root(&new_cfg.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 {
let raw = crate::utils::collapse_project_path(project_root, &worker_root.to_string_lossy());
clean_relative_root(&raw)
}
fn clean_relative_root(value: &str) -> String {
let value = value.trim().replace('\\', "/");
let mut parts: Vec<&str> = Vec::new();
for part in value.split('/') {
match part {
"" | "." => continue,
".." => {
parts.pop();
}
other => parts.push(other),
}
}
if parts.is_empty() {
".".to_string()
} else {
parts.join("/")
}
}
fn normalize_worker_root_path(project_root: &Path, root: &Path) -> PathBuf {
let cleaned = if root.is_absolute() {
clean_path_buf(root)
} else {
let joined = project_root.join(root);
clean_path_buf(&joined)
};
fs::canonicalize(&cleaned).unwrap_or(cleaned)
}
fn clean_path_buf(path: &Path) -> PathBuf {
use std::path::Component;
let mut out = PathBuf::new();
for component in path.components() {
match component {
Component::CurDir => {}
Component::ParentDir => {
out.pop();
}
other => out.push(other.as_os_str()),
}
}
if out.as_os_str().is_empty() {
PathBuf::from(".")
} else {
out
}
}
fn format_worker_names(apps: &[DiscoveredWorkerApp]) -> String {
let mut seen = std::collections::BTreeSet::new();
let mut labels = Vec::new();
for app in apps {
let key = normalize_path(&app.root);
if !seen.insert(key) {
continue;
}
let primary = if !app.base_script_name.is_empty()
&& !app.base_script_name.eq_ignore_ascii_case("worker")
{
app.base_script_name.as_str()
} else if !app.label.is_empty() {
app.label.as_str()
} else {
"worker"
};
let mut parts = vec![primary.to_string()];
if !app.label.eq_ignore_ascii_case(primary) && !app.label.is_empty() {
parts.push(format!("label={}", app.label));
}
if let Some(base) = app.root.file_name().and_then(|s| s.to_str()) {
if !base.eq_ignore_ascii_case(primary) {
parts.push(format!("dir={base}"));
}
}
labels.push(parts.join(" "));
}
labels.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}-"))
})
}
fn best_app_for_script<'a>(
script_id: &str,
apps: &'a [DiscoveredWorkerApp],
) -> Option<&'a DiscoveredWorkerApp> {
apps.iter()
.filter(|app| {
script_id == app.base_script_name
|| script_id.starts_with(&format!("{}-", app.base_script_name))
})
.max_by_key(|app| app.base_script_name.len())
}
pub fn label_for_script(script_id: &str, apps: &[DiscoveredWorkerApp]) -> Option<String> {
best_app_for_script(script_id, apps).map(|app| app.label.clone())
}
pub fn worker_root_for_script<'a>(
script_id: &str,
apps: &'a [DiscoveredWorkerApp],
) -> Option<&'a Path> {
best_app_for_script(script_id, apps).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 {
clean_path_buf(path)
.to_string_lossy()
.replace('\\', "/")
.trim_end_matches('/')
.to_ascii_lowercase()
}
#[cfg(test)]
mod tests {
use super::{
discover_worker_apps, label_for_script, 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,
worker_root_for_script, DiscoveredWorkerApp,
};
use crate::utils::parse_config_with_auto_heal;
use std::collections::HashMap;
use std::fs;
use std::path::{Path, PathBuf};
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"));
assert!(
labels.iter().any(|l| *l == "docs" || *l == "xbp-docs"),
"expected docs worker, got {labels:?}"
);
}
#[test]
fn label_for_script_prefers_longest_base_name() {
let apps = vec![
DiscoveredWorkerApp {
label: "web".into(),
root: PathBuf::from("apps/web"),
base_script_name: "xbp".into(),
config: None,
},
DiscoveredWorkerApp {
label: "docs".into(),
root: PathBuf::from("apps/docs"),
base_script_name: "xbp-docs".into(),
config: None,
},
];
assert_eq!(label_for_script("xbp", &apps).as_deref(), Some("web"));
assert_eq!(label_for_script("xbp-docs", &apps).as_deref(), Some("docs"));
assert_eq!(
label_for_script("xbp-docs-production", &apps).as_deref(),
Some("docs")
);
assert_eq!(worker_root_for_script("xbp-docs", &apps).map(|p| p.to_string_lossy().into_owned()), Some("apps/docs".into()));
}
#[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,
containers: None,
durable_objects: 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,
oci: None,
kubernetes: None,
deploy: None,
target: None,
branch: None,
crate_name: None,
npm_script: None,
port_storybook: None,
url: None,
url_storybook: None,
linear: None,
github: None,
publish: None,
version_targets: Vec::new(),
version_domains: Vec::new(),
ignore_paths: Vec::new(),
watch_ignore_paths: Vec::new(),
versioning_disabled: Vec::new(),
release_disabled: Vec::new(),
versioning: None,
discord: None,
audit: None,
};
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 clean_relative_root_strips_dot_segments() {
assert_eq!(
super::clean_relative_root(r"services\athena-auth\./"),
"services/athena-auth"
);
assert_eq!(super::clean_relative_root("./"), ".");
assert_eq!(super::clean_relative_root("services//auth/."), "services/auth");
}
#[test]
fn find_alias_prefers_script_name_when_label_is_worker() {
let root = temp_dir("alias-worker");
let auth = root.join("services").join("athena-auth");
fs::create_dir_all(&auth).expect("dir");
fs::write(auth.join("wrangler.toml"), "main = \"src/index.ts\"\n").expect("w");
let apps = vec![
DiscoveredWorkerApp {
label: "worker".into(),
root: auth.clone(),
base_script_name: "athena-auth".into(),
config: None,
},
DiscoveredWorkerApp {
label: "worker".into(),
root: auth.join("."), base_script_name: "athena-auth".into(),
config: None,
},
];
let found = super::find_worker_app_by_alias(&apps, "athena-auth");
assert!(found.is_some(), "should match script_name athena-auth");
assert_eq!(found.unwrap().base_script_name, "athena-auth");
let found_paren = super::find_worker_app_by_alias(&apps, "worker (athena-auth)");
assert!(
found_paren.is_some(),
"should match parenthetical form worker (athena-auth)"
);
let found_bare_paren = super::find_worker_app_by_alias(&apps, "(athena-auth)");
assert!(found_bare_paren.is_some(), "should match (athena-auth)");
let found_dir = super::find_worker_app_by_alias(&apps, "athena-auth");
assert!(found_dir.is_some());
let names = super::format_worker_names(&apps);
assert!(
names.contains("athena-auth"),
"format should lead with script name, got {names}"
);
assert!(
!names.starts_with("worker ("),
"format must not lead with opaque picker label, got {names}"
);
let _ = fs::remove_dir_all(root);
}
#[test]
fn absorbs_nested_package_worker_into_repository_config() {
use crate::strategies::XbpConfig;
use crate::utils::FoundXbpConfig;
let root = temp_dir("absorb-nested");
fs::create_dir_all(root.join(".xbp")).expect(".xbp");
fs::write(
root.join(".xbp/xbp.toml"),
r#"project_name = "monorepo"
port = 1
build_dir = "."
"#,
)
.expect("root config");
let pkg = root.join("services").join("athena-auth");
fs::create_dir_all(pkg.join(".xbp")).expect("pkg .xbp");
fs::write(
pkg.join("wrangler.jsonc"),
r#"{"name":"athena-auth","main":"worker/src/index.ts"}"#,
)
.expect("wrangler");
fs::write(
pkg.join(".xbp/xbp.toml"),
r#"project_name = "athena-auth"
port = 4000
build_dir = "./"
[[workers]]
name = "worker"
root = "./"
script_name = "athena-auth"
[workers.container]
class_name = "AthenaAuthContainer"
binding = "ATHENA_AUTH_CONTAINER"
dockerfile = "Dockerfile"
"#,
)
.expect("nested config");
let repo = FoundXbpConfig {
project_root: root.clone(),
config_path: root.join(".xbp/xbp.toml"),
kind: "toml",
location: ".xbp/xbp.toml".into(),
};
let content = fs::read_to_string(&repo.config_path).expect("read");
let (mut config, _) =
parse_config_with_auto_heal::<XbpConfig>(&content, "toml").expect("parse");
let (inserted, _updated, notes) =
super::absorb_nested_package_xbp_into_repository(&repo, &mut config).expect("absorb");
assert!(inserted >= 1, "notes={notes:?}");
let workers = config.workers.expect("workers");
let auth = workers
.iter()
.find(|w| w.name == "athena-auth" || w.script_name.as_deref() == Some("athena-auth"))
.expect("athena-auth worker");
assert_eq!(auth.root.replace('\\', "/"), "services/athena-auth");
assert!(auth.container.is_some());
let _ = fs::remove_dir_all(root);
}
#[test]
fn resolve_xbp_start_path_treats_leading_slash_as_repo_relative() {
let root = temp_dir("root-slash");
let worker = root.join("services").join("athena-auth");
fs::create_dir_all(&worker).expect("worker");
fs::write(worker.join("wrangler.toml"), "name = \"auth\"\n").expect("wrangler");
let with_slash = PathBuf::from("/services/athena-auth");
let resolved = super::resolve_xbp_start_path(&with_slash, &root);
let resolved_canon = fs::canonicalize(&resolved).unwrap_or(resolved);
let expect = fs::canonicalize(&worker).unwrap_or(worker.clone());
assert_eq!(resolved_canon, expect);
let rel = super::resolve_xbp_start_path(Path::new("services/athena-auth"), &root);
let rel_canon = fs::canonicalize(&rel).unwrap_or(rel);
assert_eq!(rel_canon, expect);
let _ = fs::remove_dir_all(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);
}
#[test]
fn find_worker_app_by_alias_matches_script_name_and_route() {
let root = temp_dir("alias-match");
fs::write(root.join("package.json"), "{}\n").expect("package");
fs::write(
root.join("wrangler.jsonc"),
r#"{
"name": "xbp",
"routes": [{ "pattern": "xbp.app", "custom_domain": true, "zone_name": "xbp.app" }]
}"#,
)
.expect("wrangler");
let app = super::worker_app_from_root(&root, "web".to_string());
let apps = vec![app];
assert_eq!(
super::find_worker_app_by_alias(&apps, "web")
.expect("label")
.label,
"web"
);
assert_eq!(
super::find_worker_app_by_alias(&apps, "xbp")
.expect("script")
.base_script_name,
"xbp"
);
assert_eq!(
super::find_worker_app_by_alias(&apps, "xbp.app")
.expect("domain")
.label,
"web"
);
assert_eq!(
super::find_worker_app_by_alias(&apps, "https://xbp.app/")
.expect("url")
.label,
"web"
);
assert!(super::find_worker_app_by_alias(&apps, "missing").is_none());
let _ = fs::remove_dir_all(root);
}
#[test]
fn ensure_selected_workers_configured_writes_missing_entries() {
let project_root = temp_dir("auto-config");
let worker_root = project_root.join("apps").join("web");
let config_dir = project_root.join(".xbp");
fs::create_dir_all(&worker_root).expect("worker");
fs::create_dir_all(&config_dir).expect("config dir");
fs::write(worker_root.join("package.json"), "{}\n").expect("package");
fs::write(
worker_root.join("wrangler.jsonc"),
"{\n \"name\": \"xbp\"\n}\n",
)
.expect("wrangler");
let config_path = config_dir.join("xbp.yaml");
fs::write(
&config_path,
"project_name: demo\nversion: 1.0.0\nport: 3000\nbuild_dir: ./\nworkers: null\n",
)
.expect("config");
let mut resolution = super::WorkerTargetResolution {
project_root: project_root.clone(),
config_path: config_path.clone(),
config: crate::strategies::XbpConfig {
project_name: "demo".to_string(),
version: "1.0.0".to_string(),
port: 3000,
build_dir: "./".to_string(),
workers: 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,
oci: None,
kubernetes: None,
deploy: None,
target: None,
branch: None,
crate_name: None,
npm_script: None,
port_storybook: None,
url: None,
url_storybook: None,
linear: None,
github: None,
publish: None,
version_targets: Vec::new(),
version_domains: Vec::new(),
ignore_paths: Vec::new(),
watch_ignore_paths: Vec::new(),
versioning_disabled: Vec::new(),
release_disabled: Vec::new(),
versioning: None,
discord: None,
audit: None,
},
selected: vec![super::worker_app_from_root(&worker_root, "web".to_string())],
};
let inserted = super::ensure_selected_workers_configured(&mut resolution).expect("ensure");
assert_eq!(inserted, 1);
assert!(resolution.selected[0].config.is_some());
let written = fs::read_to_string(&config_path).expect("read config");
assert!(written.contains("name: web") || written.contains("name: \"web\""));
assert!(written.contains("apps/web") || written.contains("apps\\web"));
let _ = fs::remove_dir_all(project_root);
}
}