xbp 10.30.1

XBP is a zero-config build pack that can also interact with proxies, kafka, sockets, synthetic monitors.
Documentation
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);
    }
}