use colored::Colorize;
use regex::Regex;
use serde_json::Value;
use std::fs::{self, OpenOptions};
use std::io::Write;
use std::path::{Path, PathBuf};
use std::sync::LazyLock;
static ERROR_LINE_RE: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(
r"(?i)(error|failed|failure|fatal|panic|exception|✘|✗|×|err!|npm err!|build failed|command failed|non-zero exit|exit code [1-9])",
)
.expect("valid error-line regex")
});
static WARNING_LINE_RE: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"(?i)(warn(?:ing)?|deprecated|notice:)").expect("valid warning-line regex")
});
pub fn color_enabled(no_color: bool) -> bool {
if no_color {
return false;
}
if std::env::var_os("NO_COLOR").is_some() {
return false;
}
if std::env::var("CLICOLOR")
.map(|value| value == "0")
.unwrap_or(false)
{
return false;
}
if std::env::var_os("FORCE_COLOR").is_some() || std::env::var_os("CLICOLOR_FORCE").is_some() {
return true;
}
supports_color::on(supports_color::Stream::Stdout).is_some()
}
pub fn is_failed_status(status: &str) -> bool {
matches!(
status.trim().to_ascii_lowercase().as_str(),
"failed" | "failure" | "error" | "cancelled" | "canceled"
)
}
pub fn is_success_status(status: &str) -> bool {
matches!(
status.trim().to_ascii_lowercase().as_str(),
"success" | "succeeded" | "deployed" | "active" | "healthy"
)
}
pub fn is_running_status(status: &str) -> bool {
matches!(
status.trim().to_ascii_lowercase().as_str(),
"running" | "building" | "queued" | "pending" | "in_progress" | "deploying"
)
}
pub fn status_matches_filter(status: &str, filter: &str) -> bool {
let normalized = status.trim().to_ascii_lowercase();
if normalized.is_empty() || normalized == "—" {
return filter == "unknown" || filter == "none";
}
match filter {
"failed" => is_failed_status(&normalized),
"success" => is_success_status(&normalized),
"running" => is_running_status(&normalized),
"unknown" | "none" => !is_failed_status(&normalized)
&& !is_success_status(&normalized)
&& !is_running_status(&normalized),
other => normalized.contains(other),
}
}
pub fn color_status_text(status: &str, width: usize, color: bool) -> String {
let padded = pad(status, width);
if !color {
return padded;
}
let colored = match status.trim().to_ascii_lowercase().as_str() {
"success" | "succeeded" | "deployed" | "active" | "healthy" => padded.green(),
"failed" | "failure" | "error" | "cancelled" | "canceled" => padded.red(),
"running" | "building" | "queued" | "pending" | "in_progress" | "deploying" => {
padded.yellow()
}
_ => padded.normal(),
};
colored.to_string()
}
pub fn format_timestamp(value: Option<&str>) -> String {
let Some(raw) = value.map(str::trim).filter(|value| !value.is_empty()) else {
return "—".to_string();
};
if raw.len() >= 16 {
raw[..16].replace('T', " ")
} else {
raw.to_string()
}
}
pub fn truncate_middle(value: &str, max: usize) -> String {
if value.chars().count() <= max {
return value.to_string();
}
if max <= 3 {
return value.chars().take(max).collect();
}
let keep = max.saturating_sub(3);
let left = keep / 2;
let right = keep - left;
let chars: Vec<char> = value.chars().collect();
format!(
"{}...{}",
chars.iter().take(left).collect::<String>(),
chars
.iter()
.skip(chars.len().saturating_sub(right))
.collect::<String>()
)
}
#[derive(Debug, Clone, Default)]
pub struct LogRenderOptions {
pub lines: Option<usize>,
pub grep: Option<String>,
pub errors_only: bool,
pub color: bool,
}
impl LogRenderOptions {
pub fn from_flags(
lines: Option<usize>,
grep: Option<String>,
errors_only: bool,
no_color: bool,
) -> Self {
Self {
lines,
grep,
errors_only,
color: color_enabled(no_color),
}
}
}
pub fn looks_like_error_line(line: &str) -> bool {
ERROR_LINE_RE.is_match(line)
}
pub fn looks_like_warning_line(line: &str) -> bool {
WARNING_LINE_RE.is_match(line)
}
pub fn format_log_line(line: &str, color: bool) -> String {
if !color {
return line.to_string();
}
if looks_like_error_line(line) {
return line.red().bold().to_string();
}
if looks_like_warning_line(line) {
return line.yellow().to_string();
}
if line.contains("✓") || line.to_ascii_lowercase().contains("success") {
return line.green().to_string();
}
line.to_string()
}
pub fn line_matches_grep(line: &str, pattern: &str) -> bool {
line.to_ascii_lowercase()
.contains(&pattern.to_ascii_lowercase())
}
pub fn filter_log_lines(lines: Vec<String>, options: &LogRenderOptions) -> Vec<String> {
let mut filtered = lines;
if let Some(pattern) = options
.grep
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
{
filtered.retain(|line| line_matches_grep(line, pattern));
}
if options.errors_only {
filtered.retain(|line| looks_like_error_line(line));
}
if let Some(limit) = options.lines {
if filtered.len() > limit {
filtered = filtered.split_off(filtered.len().saturating_sub(limit));
}
}
filtered
}
pub fn extract_build_log_lines(value: &Value) -> Vec<String> {
match value {
Value::String(text) if !text.trim().is_empty() => text.lines().map(str::to_string).collect(),
Value::Array(lines) => lines
.iter()
.filter_map(extract_single_log_line)
.collect(),
Value::Object(map) => {
if let Some(Value::String(text)) = map.get("logs") {
return text.lines().map(str::to_string).collect();
}
if let Some(Value::Array(lines)) = map.get("logs") {
return lines
.iter()
.filter_map(extract_single_log_line)
.collect();
}
vec![serde_json::to_string_pretty(value).unwrap_or_else(|_| value.to_string())]
}
Value::Null => Vec::new(),
other => vec![other.to_string()],
}
}
fn extract_single_log_line(line: &Value) -> Option<String> {
match line {
Value::String(text) => Some(text.clone()),
Value::Object(entry) => entry
.get("message")
.or_else(|| entry.get("text"))
.or_else(|| entry.get("line"))
.and_then(Value::as_str)
.map(str::to_string)
.filter(|value| !value.is_empty()),
other => Some(other.to_string()),
}
}
pub enum OutputSink {
Stdout,
File {
path: PathBuf,
use_color: bool,
writer: Box<dyn Write>,
},
}
impl OutputSink {
pub fn new(output: Option<&Path>, no_color: bool) -> Result<Self, String> {
let Some(path) = output else {
return Ok(Self::Stdout);
};
if let Some(parent) = path.parent() {
if !parent.as_os_str().is_empty() {
fs::create_dir_all(parent).map_err(|error| {
format!("Failed to create output directory {}: {}", parent.display(), error)
})?;
}
}
let writer: Box<dyn Write> = Box::new(
OpenOptions::new()
.create(true)
.append(false)
.truncate(true)
.write(true)
.open(path)
.map_err(|error| format!("Failed to open output file {}: {}", path.display(), error))?,
);
Ok(Self::File {
path: path.to_path_buf(),
use_color: color_enabled(no_color),
writer,
})
}
pub fn use_color(&self, options: &LogRenderOptions) -> bool {
match self {
Self::Stdout => options.color,
Self::File { use_color, .. } => *use_color,
}
}
pub fn write_line(&mut self, line: &str, options: &LogRenderOptions) -> Result<(), String> {
let color = self.use_color(options);
let rendered = format_log_line(line, color);
match self {
Self::Stdout => {
println!("{rendered}");
Ok(())
}
Self::File { writer, .. } => {
writeln!(writer, "{line}")
.map_err(|error| format!("Failed to write log output: {}", error))?;
writer
.flush()
.map_err(|error| format!("Failed to flush log output: {}", error))?;
Ok(())
}
}
}
pub fn write_plain(&mut self, line: &str) -> Result<(), String> {
match self {
Self::Stdout => {
println!("{line}");
Ok(())
}
Self::File { writer, .. } => {
writeln!(writer, "{line}")
.map_err(|error| format!("Failed to write output: {}", error))?;
writer
.flush()
.map_err(|error| format!("Failed to flush output: {}", error))?;
Ok(())
}
}
}
pub fn path(&self) -> Option<&Path> {
match self {
Self::Stdout => None,
Self::File { path, .. } => Some(path),
}
}
}
pub fn emit_log_lines(
lines: &[String],
sink: &mut OutputSink,
options: &LogRenderOptions,
) -> Result<(), String> {
let filtered = filter_log_lines(lines.to_vec(), options);
if filtered.is_empty() {
let message = if options.errors_only {
"No error lines matched the current filters.".to_string()
} else if options.grep.is_some() {
"No log lines matched the current filters.".to_string()
} else {
"No log lines were returned.".to_string()
};
if options.color && matches!(sink, OutputSink::Stdout) {
sink.write_plain(&message.dimmed().to_string())?;
} else {
sink.write_plain(&message)?;
}
return Ok(());
}
for line in &filtered {
sink.write_line(line, options)?;
}
Ok(())
}
pub fn print_unicode_table<F>(
headers: &[&str],
rows: &[Vec<String>],
color: bool,
color_cell: F,
) where
F: Fn(&[String], usize, bool) -> String,
{
if rows.is_empty() {
return;
}
let column_count = headers.len();
let mut widths = vec![0usize; column_count];
for (index, header) in headers.iter().enumerate() {
widths[index] = header.len();
}
for row in rows {
for (index, value) in row.iter().enumerate().take(column_count) {
widths[index] = widths[index].max(value.len());
}
}
let top = make_line('┌', '┬', '┐', &widths);
let mid = make_line('├', '┼', '┤', &widths);
let bottom = make_line('└', '┴', '┘', &widths);
println!("{top}");
let header_cells: Vec<String> = headers
.iter()
.enumerate()
.map(|(index, header)| {
let padded = pad(header, widths[index]);
if color {
padded.bold().white().to_string()
} else {
padded
}
})
.collect();
println!("{}", format_row(&header_cells));
println!("{mid}");
for row in rows {
let cells: Vec<String> = (0..column_count)
.map(|index| color_cell(row, index, color))
.collect();
println!("{}", format_row(&cells));
}
println!("{bottom}");
}
fn format_row(cells: &[String]) -> String {
let mut parts = Vec::with_capacity(cells.len() * 2 + 1);
for cell in cells {
parts.push(format!(" {cell} "));
parts.push("│".to_string());
}
parts.remove(parts.len() - 1);
format!("│{}", parts.join("│"),)
}
fn make_line(left: char, mid: char, right: char, widths: &[usize]) -> String {
let mut parts = Vec::with_capacity(widths.len() * 2 + 1);
for (index, width) in widths.iter().enumerate() {
let fill = "─".repeat(*width + 2);
if index == 0 {
parts.push(format!("{left}{fill}"));
} else {
parts.push(format!("{mid}{fill}"));
}
}
parts.push(right.to_string());
parts.join("")
}
pub fn pad(value: impl AsRef<str>, width: usize) -> String {
format!("{:width$}", value.as_ref(), width = width)
}
pub fn summarize_status_counts(statuses: &[String]) -> String {
let mut failed = 0usize;
let mut success = 0usize;
let mut running = 0usize;
let mut other = 0usize;
for status in statuses {
if is_failed_status(status) {
failed += 1;
} else if is_success_status(status) {
success += 1;
} else if is_running_status(status) {
running += 1;
} else {
other += 1;
}
}
let total = statuses.len();
let mut parts = vec![format!("{total} total")];
if failed > 0 {
parts.push(format!("{failed} failed"));
}
if running > 0 {
parts.push(format!("{running} running"));
}
if success > 0 {
parts.push(format!("{success} success"));
}
if other > 0 {
parts.push(format!("{other} other"));
}
parts.join(", ")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn filters_error_lines_only() {
let lines = vec![
"Installing dependencies".to_string(),
"npm ERR! build failed".to_string(),
"Done".to_string(),
];
let options = LogRenderOptions {
errors_only: true,
color: false,
..Default::default()
};
let filtered = filter_log_lines(lines, &options);
assert_eq!(filtered.len(), 1);
assert!(filtered[0].contains("ERR"));
}
#[test]
fn grep_filter_is_case_insensitive() {
let lines = vec!["Error: bad".to_string(), "ok".to_string()];
let options = LogRenderOptions {
grep: Some("error".to_string()),
color: false,
..Default::default()
};
let filtered = filter_log_lines(lines, &options);
assert_eq!(filtered, vec!["Error: bad".to_string()]);
}
#[test]
fn lines_limit_keeps_tail() {
let lines = vec!["1".to_string(), "2".to_string(), "3".to_string()];
let options = LogRenderOptions {
lines: Some(2),
color: false,
..Default::default()
};
let filtered = filter_log_lines(lines, &options);
assert_eq!(filtered, vec!["2".to_string(), "3".to_string()]);
}
#[test]
fn status_filter_matches_failed_aliases() {
assert!(status_matches_filter("failure", "failed"));
assert!(status_matches_filter("deployed", "success"));
assert!(status_matches_filter("queued", "running"));
}
}