use crate::utils::find_xbp_config_upwards;
use regex::Regex;
use serde_json::{json, Value};
use std::collections::HashMap;
use std::env;
use std::fs;
use std::path::{Path, PathBuf};
const DEFAULT_WORKER_NAME: &str = "xbp";
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 resolve_workers_project_root(root_override: Option<&Path>) -> Result<PathBuf, String> {
let current_dir =
env::current_dir().map_err(|error| format!("Failed to read current dir: {}", error))?;
let start = root_override
.map(Path::to_path_buf)
.unwrap_or_else(|| current_dir.clone());
let mut candidates = vec![start.clone(), start.join("apps").join("web")];
if let Some(found) = find_xbp_config_upwards(&start) {
candidates.push(found.project_root.clone());
candidates.push(found.project_root.join("apps").join("web"));
}
dedupe_paths(&mut candidates);
for candidate in candidates {
if looks_like_worker_root(&candidate) {
return Ok(candidate);
}
}
Err(format!(
"Could not resolve a Cloudflare Workers project root from {}. Pass `xbp workers --root <path>`.",
start.display()
))
}
fn dedupe_paths(paths: &mut Vec<PathBuf>) {
let mut deduped = Vec::with_capacity(paths.len());
for path in paths.drain(..) {
if deduped
.iter()
.any(|existing: &PathBuf| path_eq(existing, &path))
{
continue;
}
deduped.push(path);
}
*paths = deduped;
}
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.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.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;
}
if worker_root.join("wrangler.dev.jsonc").exists() {
return Some("wrangler.dev.jsonc".to_string());
}
if worker_root.join("wrangler.jsonc").exists() {
return Some("wrangler.jsonc".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()?;
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::{
parse_multiline_env_content, read_configured_custom_domain_routes,
resolve_remote_script_name, resolve_wrangler_config_path,
};
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 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);
}
}