xbp 10.57.0

XBP is a zero-config build pack that can also interact with proxies, kafka, sockets, synthetic monitors.
Documentation
//! Remediate known Cloudflare Workers Builds failures (empty pnpm packages,
//! package-manager mismatch) from build logs or a local log file.

use crate::commands::workers::builds_config::{
    print_build_command_sync_report, sync_local_wrangler_build_command,
    sync_workers_builds_build_command_with_root, BuildCommandSyncReport,
};
use crate::utils::{
    classify_builds_failure, detect_js_install_package_manager, ensure_pnpm_workspace_packages,
    flatten_build_logs_json, suggested_build_command, BuildsFailureClassification,
};
use colored::Colorize;
use serde_json::Value;
use std::fs;
use std::path::{Path, PathBuf};
use std::process::Command;
use xbp_providers::CloudflareClient;

#[derive(Debug, Clone)]
pub struct BuildsFixReport {
    pub script_name: String,
    pub classification: BuildsFailureClassification,
    pub log_source: String,
    pub healed_workspace_files: Vec<PathBuf>,
    pub desired_build_command: Option<String>,
    pub package_manager: &'static str,
    pub sync: Option<BuildCommandSyncReport>,
    pub notes: Vec<String>,
    pub committed: bool,
}

/// Options for [`fix_workers_builds_failure`].
#[derive(Debug, Clone, Default)]
pub struct BuildsFixOptions {
    pub dry_run: bool,
    /// Skip Cloudflare API (local heals only).
    pub no_remote: bool,
    /// Optional path to a log file instead of fetching latest failed build.
    pub log_path: Option<PathBuf>,
    /// Stage healed workspace files with `git add` (no commit message / push).
    pub commit: bool,
    /// When true, always attempt workspace heal + builds sync even if classification
    /// is empty (proactive doctor mode).
    pub always_heal: bool,
}

/// Load failure log text: file path, or latest failed Workers Build.
pub async fn load_builds_failure_log(
    client: Option<&CloudflareClient>,
    script_name: &str,
    log_path: Option<&Path>,
) -> Result<(String, String), String> {
    if let Some(path) = log_path {
        let text = fs::read_to_string(path)
            .map_err(|e| format!("read log {}: {e}", path.display()))?;
        return Ok((text, format!("file:{}", path.display())));
    }

    let client = client.ok_or_else(|| {
        "no Cloudflare client and no --log path — cannot fetch build logs".to_string()
    })?;

    let scripts = client.list_worker_scripts().await?;
    let script = scripts
        .iter()
        .find(|s| s.id == script_name || s.id.eq_ignore_ascii_case(script_name))
        .ok_or_else(|| format!("Worker script `{script_name}` not found"))?;
    let tag = script
        .tag
        .as_deref()
        .filter(|s| !s.is_empty())
        .ok_or_else(|| {
            format!("Worker `{script_name}` has no Builds tag — connect Workers Builds first")
        })?;

    let builds = client.list_worker_builds(tag).await?;
    let failed = builds.iter().find(|b| {
        b.status
            .as_deref()
            .map(|s| {
                let s = s.to_ascii_lowercase();
                s.contains("fail") || s.contains("error") || s == "canceled" || s == "cancelled"
            })
            .unwrap_or(false)
    });

    let Some(build) = failed else {
        return Err(
            "no failed Workers Builds found (latest builds are green or empty)".into(),
        );
    };

    let logs = client
        .get_worker_build_logs(&build.build_uuid)
        .await
        .map_err(|e| format!("fetch logs for build {}: {e}", build.build_uuid))?;
    let text = flatten_build_logs_json(&logs);
    if text.trim().is_empty() {
        // Still allow classification from empty — treat as unknown
        return Ok((
            text,
            format!("build:{} (empty log)", build.build_uuid),
        ));
    }
    Ok((text, format!("build:{}", build.build_uuid)))
}

/// Classify + heal local workspace + sync remote build_command.
pub async fn fix_workers_builds_failure(
    client: Option<&CloudflareClient>,
    script_name: &str,
    worker_root: &Path,
    root_directory: Option<&str>,
    opts: BuildsFixOptions,
) -> Result<BuildsFixReport, String> {
    let pm = detect_js_install_package_manager(worker_root);
    let mut notes = Vec::new();

    let (log_text, log_source) = if opts.always_heal && opts.log_path.is_none() && opts.no_remote {
        (
            String::new(),
            "proactive (no log)".to_string(),
        )
    } else if opts.always_heal && opts.log_path.is_none() {
        match load_builds_failure_log(client, script_name, None).await {
            Ok(pair) => pair,
            Err(e) => {
                notes.push(format!("no failed build log: {e}"));
                (String::new(), "proactive (no failed build)".into())
            }
        }
    } else {
        load_builds_failure_log(client, script_name, opts.log_path.as_deref()).await?
    };

    let mut classification = if log_text.is_empty() {
        BuildsFailureClassification::default()
    } else {
        classify_builds_failure(&log_text)
    };

    if opts.always_heal {
        // Proactive: always heal empty packages + sync build command.
        classification.packages_field_missing = true;
        classification.package_manager_mismatch = true;
        if classification.notes.is_empty() {
            classification
                .notes
                .push("proactive heal: workspace packages + install-aware build_command".into());
        }
    }

    if !classification.is_known() && !opts.always_heal {
        return Ok(BuildsFixReport {
            script_name: script_name.to_string(),
            classification,
            log_source,
            healed_workspace_files: vec![],
            desired_build_command: preferred_desired_command(worker_root),
            package_manager: pm.name(),
            sync: None,
            notes: vec![
                "unknown failure pattern — no automatic remediation applied".into(),
                "known patterns: packages field missing/empty; bun/npm install + pnpm/yarn build"
                    .into(),
            ],
            committed: false,
        });
    }

    let mut healed = Vec::new();
    if classification.needs_workspace_heal() {
        if opts.dry_run {
            notes.push("would heal pnpm-workspace.yaml packages field (if empty)".into());
            // Report which files would be candidates without writing
            for name in ["pnpm-workspace.yaml", "pnpm-workspace.yml"] {
                let mut cur = worker_root.to_path_buf();
                loop {
                    let path = cur.join(name);
                    if path.is_file() {
                        notes.push(format!("candidate: {}", path.display()));
                    }
                    if !cur.pop() {
                        break;
                    }
                }
            }
        } else {
            healed = ensure_pnpm_workspace_packages(worker_root)?;
            for p in &healed {
                notes.push(format!("healed {}", p.display()));
            }
            if healed.is_empty() {
                notes.push("pnpm-workspace packages already valid (or no workspace yaml)".into());
            } else {
                notes.push(
                    "commit healed pnpm-workspace.yaml if CI still uses pnpm (push required)"
                        .into(),
                );
            }
        }
    }

    if let Ok(Some(msg)) = sync_local_wrangler_build_command(worker_root) {
        notes.push(msg);
    }

    let desired = preferred_desired_command(worker_root);
    let mut sync_report = None;

    if classification.needs_build_command_sync() && !opts.no_remote {
        if let Some(client) = client {
            let report = sync_workers_builds_build_command_with_root(
                client,
                script_name,
                worker_root,
                root_directory,
                opts.dry_run,
            )
            .await?;
            sync_report = Some(report);
        } else {
            notes.push("skipped remote Builds sync (no client)".into());
        }
    } else if opts.no_remote {
        notes.push("skipped remote Builds sync (--no-remote)".into());
        if let Some(d) = &desired {
            notes.push(format!("desired build_command would be `{d}`"));
        }
    }

    let mut committed = false;
    if opts.commit && !opts.dry_run && !healed.is_empty() {
        match git_add_paths(worker_root, &healed) {
            Ok(()) => {
                committed = true;
                notes.push("git add: staged healed workspace files (no commit)".into());
            }
            Err(e) => notes.push(format!("git add failed: {e}")),
        }
    }

    Ok(BuildsFixReport {
        script_name: script_name.to_string(),
        classification,
        log_source,
        healed_workspace_files: healed,
        desired_build_command: desired,
        package_manager: pm.name(),
        sync: sync_report,
        notes,
        committed,
    })
}

fn preferred_desired_command(worker_root: &Path) -> Option<String> {
    let package_json = worker_root.join("package.json");
    let content = fs::read_to_string(package_json).ok()?;
    let value: Value = serde_json::from_str(&content).ok()?;
    let scripts = value.get("scripts")?.as_object()?;
    let script = if scripts.contains_key("build:worker") {
        "build:worker"
    } else if scripts.contains_key("build") {
        "build"
    } else {
        return None;
    };
    Some(suggested_build_command(worker_root, script))
}

fn git_add_paths(cwd: &Path, paths: &[PathBuf]) -> Result<(), String> {
    for path in paths {
        let status = Command::new("git")
            .args(["add", "--"])
            .arg(path)
            .current_dir(cwd)
            .status()
            .map_err(|e| format!("git add: {e}"))?;
        if !status.success() {
            return Err(format!("git add failed for {}", path.display()));
        }
    }
    Ok(())
}

pub fn print_builds_fix_report(report: &BuildsFixReport) {
    println!(
        "{} worker={} pm={} log={}",
        "Workers Builds fix".bright_cyan().bold(),
        report.script_name.bright_white(),
        report.package_manager.bright_yellow(),
        report.log_source.bright_black()
    );
    for n in &report.classification.notes {
        println!("  {} {n}", "cause".yellow());
    }
    if let Some(cmd) = &report.desired_build_command {
        println!("  {} desired=`{cmd}`", "build".bright_white());
    }
    for p in &report.healed_workspace_files {
        println!("  {} {}", "healed".green(), p.display());
    }
    if let Some(sync) = &report.sync {
        print_build_command_sync_report(sync);
    }
    for note in &report.notes {
        println!("  {} {note}", "note".yellow());
    }
    if report.committed {
        println!("  {} staged workspace heals", "git".green());
    }
}

/// True when error/log text looks like a known Builds failure we can auto-fix.
pub fn looks_like_fixable_builds_error(text: &str) -> bool {
    classify_builds_failure(text).is_known()
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn looks_like_user_error() {
        let log = "bun install --frozen-lockfile\npnpm run build\npackages field missing or empty";
        assert!(looks_like_fixable_builds_error(log));
    }
}