use super::project::{
discover_worker_apps, resolve_project_script_names, script_belongs_to_project,
worker_root_for_script, DiscoveredWorkerApp,
};
use super::render::{
color_enabled, color_status_text, emit_log_lines, extract_build_log_lines, format_timestamp,
is_failed_status, pad, print_unicode_table, truncate_middle, LogRenderOptions, OutputSink,
};
use super::wrangler::{run_wrangler_capture, run_wrangler_stream};
use super::{build_cloudflare_client, load_local_env, resolve_target_script_name};
use crate::cli::commands::WorkersLogsCmd;
use crate::provider_support::{CloudflareClient, CloudflareWorkerBuild, CloudflareWorkerScript};
use colored::Colorize;
use dialoguer::{theme::ColorfulTheme, FuzzySelect};
use serde_json::Value;
use std::env;
use std::path::Path;
pub async fn run_worker_logs(
root_override: Option<&Path>,
token_override: Option<&str>,
account_id_override: Option<&str>,
cmd: WorkersLogsCmd,
) -> Result<(), String> {
let start = root_override
.map(Path::to_path_buf)
.or_else(|| env::current_dir().ok())
.ok_or_else(|| "Failed to resolve current directory.".to_string())?;
let apps = discover_worker_apps(&start);
let project_scripts = resolve_project_script_names(&apps);
let local_env = apps
.first()
.map(|app| load_local_env(&app.root))
.transpose()?
.unwrap_or_default();
let client = build_cloudflare_client(token_override, account_id_override, &local_env)?;
let scripts = client.list_worker_scripts().await?;
let scoped_scripts = filter_scripts(&scripts, &project_scripts);
let script_name = resolve_script_name(
&start,
&apps,
&cmd,
&scoped_scripts,
&scripts,
&project_scripts,
)?;
if cmd.build {
return show_build_logs(&client, &scripts, &script_name, &cmd).await;
}
let worker_root = worker_root_for_script(&script_name, &apps)
.or_else(|| apps.first().map(|app| app.root.as_path()))
.ok_or_else(|| {
format!(
"Could not resolve a Workers project root for `{}`. Pass `xbp workers --root <path> logs`.",
script_name
)
})?;
if cmd.follow {
tail_runtime_logs(worker_root, &script_name, &cmd)?;
return Ok(());
}
show_deployment_status(worker_root, &script_name, &cmd)
}
fn filter_scripts<'a>(
scripts: &'a [CloudflareWorkerScript],
project_scripts: &[String],
) -> Vec<&'a CloudflareWorkerScript> {
if project_scripts.is_empty() {
return scripts.iter().collect();
}
scripts
.iter()
.filter(|script| script_belongs_to_project(&script.id, project_scripts))
.collect()
}
fn resolve_script_name(
start: &Path,
apps: &[DiscoveredWorkerApp],
cmd: &WorkersLogsCmd,
scoped_scripts: &[&CloudflareWorkerScript],
all_scripts: &[CloudflareWorkerScript],
project_scripts: &[String],
) -> Result<String, String> {
if let Some(script) = cmd
.target
.script
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
{
return Ok(script.to_string());
}
if let Some(script) = cmd
.script_name
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
{
return Ok(script.to_string());
}
if let Some(worker_root) = apps.first().map(|app| app.root.as_path()) {
let local_env = load_local_env(worker_root).unwrap_or_default();
let resolved = resolve_target_script_name(worker_root, &cmd.target, &local_env);
if all_scripts.iter().any(|script| script.id == resolved) {
return Ok(resolved);
}
} else if let Ok(worker_root) = super::project::resolve_workers_project_root(Some(start)) {
let local_env = load_local_env(&worker_root).unwrap_or_default();
let resolved = resolve_target_script_name(&worker_root, &cmd.target, &local_env);
if all_scripts.iter().any(|script| script.id == resolved) {
return Ok(resolved);
}
}
let candidates: Vec<&CloudflareWorkerScript> = if scoped_scripts.is_empty() {
all_scripts.iter().collect()
} else {
scoped_scripts.to_vec()
};
if candidates.is_empty() {
return Err("No Workers found in this Cloudflare account.".to_string());
}
if candidates.len() == 1 {
return Ok(candidates[0].id.clone());
}
let labels: Vec<String> = candidates
.iter()
.map(|script| format_worker_choice(script, project_scripts))
.collect();
let selection = FuzzySelect::with_theme(&ColorfulTheme::default())
.with_prompt("Select a Worker to inspect")
.items(&labels)
.default(0)
.interact()
.map_err(|error| format!("Failed to run Worker selection prompt: {}", error))?;
Ok(candidates[selection].id.clone())
}
fn format_worker_choice(script: &CloudflareWorkerScript, project_scripts: &[String]) -> String {
let scope =
if project_scripts.is_empty() || script_belongs_to_project(&script.id, project_scripts) {
"project"
} else {
"account"
};
format!("{} ({scope})", script.id)
}
fn log_render_options(cmd: &WorkersLogsCmd) -> LogRenderOptions {
LogRenderOptions::from_flags(cmd.lines, cmd.grep.clone(), cmd.errors_only, cmd.no_color)
}
async fn show_build_logs(
client: &CloudflareClient,
scripts: &[CloudflareWorkerScript],
script_name: &str,
cmd: &WorkersLogsCmd,
) -> Result<(), String> {
let script = scripts
.iter()
.find(|script| script.id == script_name)
.ok_or_else(|| format!("Worker script `{}` was not found.", script_name))?;
let tag = script
.tag
.as_deref()
.filter(|value| !value.is_empty())
.ok_or_else(|| {
format!(
"Worker `{}` has no build tag. Workers Builds may not be configured for it.",
script_name
)
})?;
let builds = client.list_worker_builds(tag).await?;
if builds.is_empty() {
println!(
"{}",
format!("No Workers Builds history found for `{}`.", script_name).dimmed()
);
return Ok(());
}
let color = color_enabled(cmd.no_color);
if cmd.list_builds {
print_builds_table(&builds, color);
}
let build = select_build(&builds, cmd)?;
let status = build
.status
.clone()
.unwrap_or_else(|| "unknown".to_string());
print_build_header(&build, &status, color);
let logs = client.get_worker_build_logs(&build.build_uuid).await?;
if cmd.json {
let payload = if cmd.output.is_some() {
serde_json::to_string_pretty(&logs)
.map_err(|error| format!("Failed to encode build logs: {}", error))?
} else {
return print_json_stdout(&logs);
};
write_raw_output(cmd, &payload)?;
return Ok(());
}
let lines = extract_build_log_lines(&logs);
let mut sink = OutputSink::new(cmd.output.as_deref(), cmd.no_color)?;
emit_log_lines(&lines, &mut sink, &log_render_options(cmd))?;
if let Some(path) = sink.path() {
println!(
"{} {}",
"Wrote build logs to".dimmed(),
path.display().to_string().cyan()
);
}
if is_failed_status(&status) {
println!(
"\n{}",
"Latest matching build failed. Use `xbp workers logs --build --failed` after a new failure to jump to it.".yellow()
);
}
Ok(())
}
fn select_build<'a>(
builds: &'a [CloudflareWorkerBuild],
cmd: &WorkersLogsCmd,
) -> Result<&'a CloudflareWorkerBuild, String> {
if let Some(index) = cmd.build_index {
return builds.get(index).ok_or_else(|| {
format!(
"Build index {} is out of range. {} build(s) available (0 = latest).",
index,
builds.len()
)
});
}
if cmd.failed {
return builds
.iter()
.find(|build| is_failed_status(build.status.as_deref().unwrap_or_default()))
.or_else(|| builds.first())
.ok_or_else(|| "No build records were found.".to_string());
}
builds
.first()
.ok_or_else(|| "No build records were found.".to_string())
}
fn print_build_header(build: &CloudflareWorkerBuild, status: &str, color: bool) {
let status_text = color_status_text(status, status.len(), color);
let branch = build
.branch
.as_deref()
.map(|value| format!("branch={value}"))
.unwrap_or_default();
println!(
"{} {} {} {}",
"Build".bold(),
build.build_uuid.cyan(),
status_text,
branch.dimmed()
);
}
fn print_builds_table(builds: &[CloudflareWorkerBuild], color: bool) {
let headers = ["#", "STATUS", "BRANCH", "CREATED", "BUILD UUID"];
let rows: Vec<Vec<String>> = builds
.iter()
.enumerate()
.map(|(index, build)| {
vec![
index.to_string(),
build
.status
.clone()
.unwrap_or_else(|| "unknown".to_string()),
build
.branch
.clone()
.map(|value| truncate_middle(&value, 24))
.unwrap_or_else(|| "—".to_string()),
format_timestamp(build.created_at.as_deref()),
truncate_middle(&build.build_uuid, 28),
]
})
.collect();
print_unicode_table(&headers, &rows, color, |row, column, color| match column {
1 => color_status_text(&row[1], row[1].len(), color),
0 => {
let padded = pad(&row[0], row[0].len());
if color {
padded.bold().to_string()
} else {
padded
}
}
_ => pad(&row[column], row[column].len()),
});
println!();
}
fn tail_runtime_logs(
worker_root: &Path,
script_name: &str,
cmd: &WorkersLogsCmd,
) -> Result<(), String> {
let mut args = vec!["tail".to_string(), script_name.to_string()];
if let Some(environment) = cmd
.target
.environment
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
{
args.push("--env".to_string());
args.push(environment.to_string());
}
if let Some(pattern) = cmd
.grep
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
{
args.push("--search".to_string());
args.push(pattern.to_string());
}
if cmd.errors_only {
args.push("--status".to_string());
args.push("error".to_string());
}
args.push("--format".to_string());
args.push(if cmd.json {
"json".to_string()
} else {
"pretty".to_string()
});
println!(
"{} {} {}",
"Tailing runtime logs for".dimmed(),
script_name.cyan().bold(),
"(Ctrl+C to stop)".dimmed()
);
let options = log_render_options(cmd);
let mut sink = OutputSink::new(cmd.output.as_deref(), cmd.no_color)?;
run_wrangler_stream(worker_root, &args, |line| {
if cmd.json {
sink.write_plain(line)?;
return Ok(());
}
sink.write_line(line, &options)
})?;
if let Some(path) = sink.path() {
println!(
"{} {}",
"Wrote runtime logs to".dimmed(),
path.display().to_string().cyan()
);
}
Ok(())
}
fn show_deployment_status(
worker_root: &Path,
script_name: &str,
cmd: &WorkersLogsCmd,
) -> Result<(), String> {
println!(
"{} {}",
"Recent deployments for".dimmed(),
script_name.cyan().bold()
);
let args = vec![
"deployments".to_string(),
"list".to_string(),
"--name".to_string(),
script_name.to_string(),
"--json".to_string(),
];
let output = run_wrangler_capture(worker_root, &args)?;
if output.exit_code != 0 {
return Err(super::wrangler::format_wrangler_failure(&args, &output));
}
if cmd.json {
let payload = output.stdout.trim();
if cmd.output.is_some() {
write_raw_output(cmd, payload)?;
} else {
println!("{payload}");
}
return Ok(());
}
let mut deployments = parse_deployments_json(&output.stdout);
deployments = filter_deployments(deployments, cmd);
if deployments.is_empty() {
let fallback_lines: Vec<String> = output
.stdout
.lines()
.chain(output.stderr.lines())
.map(str::to_string)
.filter(|line| !line.trim().is_empty())
.collect();
let mut sink = OutputSink::new(cmd.output.as_deref(), cmd.no_color)?;
emit_log_lines(&fallback_lines, &mut sink, &log_render_options(cmd))?;
print_logs_hint();
return Ok(());
}
print_deployments_table(&deployments, cmd.no_color);
let log_lines = deployments_to_log_lines(&deployments);
let mut sink = OutputSink::new(cmd.output.as_deref(), cmd.no_color)?;
if cmd.output.is_some() {
emit_log_lines(&log_lines, &mut sink, &log_render_options(cmd))?;
println!(
"{} {}",
"Wrote deployment log lines to".dimmed(),
sink.path()
.map(|path| path.display().to_string())
.unwrap_or_default()
.cyan()
);
}
print_logs_hint();
Ok(())
}
#[derive(Debug, Clone)]
struct DeploymentRow {
id: String,
created_on: String,
source: String,
message: String,
}
fn filter_deployments(mut deployments: Vec<DeploymentRow>, cmd: &WorkersLogsCmd) -> Vec<DeploymentRow> {
if let Some(pattern) = cmd
.grep
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
{
let pattern = pattern.to_ascii_lowercase();
deployments.retain(|deployment| {
deployment.id.to_ascii_lowercase().contains(&pattern)
|| deployment.source.to_ascii_lowercase().contains(&pattern)
|| deployment.message.to_ascii_lowercase().contains(&pattern)
});
}
if cmd.errors_only {
deployments.retain(|deployment| {
super::render::looks_like_error_line(&deployment.message)
|| super::render::looks_like_error_line(&deployment.source)
});
}
if let Some(limit) = cmd.lines {
if deployments.len() > limit {
deployments.truncate(limit);
}
}
deployments
}
fn parse_deployments_json(stdout: &str) -> Vec<DeploymentRow> {
let trimmed = stdout.trim();
if trimmed.is_empty() {
return Vec::new();
}
let value = match serde_json::from_str::<Value>(trimmed) {
Ok(value) => value,
Err(_) => return Vec::new(),
};
let entries = match value {
Value::Array(items) => items,
Value::Object(mut map) => map
.remove("deployments")
.or_else(|| map.remove("result"))
.and_then(|value| match value {
Value::Array(items) => Some(items),
_ => None,
})
.unwrap_or_default(),
_ => Vec::new(),
};
entries
.into_iter()
.filter_map(|entry| {
let Value::Object(map) = entry else {
return None;
};
Some(DeploymentRow {
id: map
.get("id")
.and_then(Value::as_str)
.map(str::to_string)
.unwrap_or_else(|| "—".to_string()),
created_on: map
.get("created_on")
.or_else(|| map.get("created"))
.and_then(Value::as_str)
.map(|value| format_timestamp(Some(value)))
.unwrap_or_else(|| "—".to_string()),
source: map
.get("source")
.and_then(Value::as_str)
.map(str::to_string)
.unwrap_or_else(|| "—".to_string()),
message: map
.get("message")
.or_else(|| map.get("comment"))
.and_then(Value::as_str)
.map(str::to_string)
.unwrap_or_else(|| "—".to_string()),
})
})
.collect()
}
fn print_deployments_table(deployments: &[DeploymentRow], no_color: bool) {
let color = color_enabled(no_color);
let headers = ["ID", "CREATED", "SOURCE", "MESSAGE"];
let rows: Vec<Vec<String>> = deployments
.iter()
.map(|deployment| {
vec![
truncate_middle(&deployment.id, 18),
deployment.created_on.clone(),
deployment.source.clone(),
truncate_middle(&deployment.message, 40),
]
})
.collect();
print_unicode_table(&headers, &rows, color, |row, column, _| {
pad(&row[column], row[column].len())
});
println!();
}
fn deployments_to_log_lines(deployments: &[DeploymentRow]) -> Vec<String> {
deployments
.iter()
.map(|deployment| {
format!(
"{} {} source={} {}",
deployment.created_on, deployment.id, deployment.source, deployment.message
)
})
.collect()
}
fn print_logs_hint() {
println!(
"{}",
"Use `xbp workers logs -f` to stream runtime logs, or `xbp workers logs --build` for Workers Builds CI output.".dimmed()
);
}
fn print_json_stdout(value: &impl serde::Serialize) -> Result<(), String> {
println!(
"{}",
serde_json::to_string_pretty(value)
.map_err(|error| format!("Failed to encode JSON output: {}", error))?
);
Ok(())
}
fn write_raw_output(cmd: &WorkersLogsCmd, payload: &str) -> Result<(), String> {
let path = cmd
.output
.as_deref()
.ok_or_else(|| "No output path was provided.".to_string())?;
std::fs::write(path, payload)
.map_err(|error| format!("Failed to write {}: {}", path.display(), error))?;
println!(
"{} {}",
"Wrote output to".dimmed(),
path.display().to_string().cyan()
);
Ok(())
}