use anstyle::{AnsiColor, Style};
use indicatif::{MultiProgress, ProgressBar, ProgressStyle};
use serde::Serialize;
use std::fmt::Display;
use std::io::{self, IsTerminal, Write};
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
use waterui_cli::build::{BuildProgress, CompileEvent};
use waterui_cli::utils::set_std_output;
mod styles {
use super::{AnsiColor, Style};
pub const HEADER: Style = Style::new()
.bold()
.fg_color(Some(anstyle::Color::Ansi(AnsiColor::Green)));
pub const ERROR: Style = Style::new()
.bold()
.fg_color(Some(anstyle::Color::Ansi(AnsiColor::Red)));
pub const WARN: Style = Style::new()
.bold()
.fg_color(Some(anstyle::Color::Ansi(AnsiColor::Yellow)));
pub const NOTE: Style = Style::new()
.bold()
.fg_color(Some(anstyle::Color::Ansi(AnsiColor::Cyan)));
pub const DEBUG: Style = Style::new().fg_color(Some(anstyle::Color::Ansi(AnsiColor::Magenta)));
pub const TRACE: Style =
Style::new().fg_color(Some(anstyle::Color::Ansi(AnsiColor::BrightBlack)));
pub const TAG: Style = Style::new().bold();
}
pub struct Shell {
output: ShellOut,
multi_progress: MultiProgress,
}
enum ShellOut {
Human,
Json,
}
impl Shell {
#[must_use]
pub fn new(json: bool) -> Self {
Self {
output: if json {
ShellOut::Json
} else {
ShellOut::Human
},
multi_progress: MultiProgress::new(),
}
}
#[must_use]
pub const fn is_json(&self) -> bool {
matches!(self.output, ShellOut::Json)
}
#[must_use]
pub fn is_terminal(&self) -> bool {
match &self.output {
ShellOut::Human => io::stderr().is_terminal(),
ShellOut::Json => false,
}
}
pub fn status(&self, status: impl Display, message: impl Display) -> io::Result<()> {
match &self.output {
ShellOut::Human => {
let mut stderr = anstream::stderr().lock();
writeln!(
stderr,
"{}{}{} {message}",
styles::HEADER,
status,
styles::HEADER.render_reset()
)?;
stderr.flush()
}
ShellOut::Json => {
#[derive(Serialize)]
struct Status<'a> {
status: &'a str,
message: &'a str,
}
let json = serde_json::to_string(&Status {
status: &status.to_string(),
message: &message.to_string(),
})?;
writeln!(io::stdout(), "{json}")?;
io::stdout().flush()
}
}
}
pub fn error(&self, message: impl Display) -> io::Result<()> {
match &self.output {
ShellOut::Human => {
let mut stderr = anstream::stderr().lock();
write!(
stderr,
"{}error{}: ",
styles::ERROR,
styles::ERROR.render_reset()
)?;
writeln!(stderr, "{message}")?;
stderr.flush()
}
ShellOut::Json => {
#[derive(Serialize)]
struct Error<'a> {
level: &'static str,
message: &'a str,
}
let json = serde_json::to_string(&Error {
level: "error",
message: &message.to_string(),
})?;
writeln!(io::stdout(), "{json}")?;
io::stdout().flush()
}
}
}
pub fn warn(&self, message: impl Display) -> io::Result<()> {
match &self.output {
ShellOut::Human => {
let mut stderr = anstream::stderr().lock();
write!(
stderr,
"{}warning{}: ",
styles::WARN,
styles::WARN.render_reset()
)?;
writeln!(stderr, "{message}")?;
stderr.flush()
}
ShellOut::Json => {
#[derive(Serialize)]
struct Warning<'a> {
level: &'static str,
message: &'a str,
}
let json = serde_json::to_string(&Warning {
level: "warning",
message: &message.to_string(),
})?;
writeln!(io::stdout(), "{json}")?;
io::stdout().flush()
}
}
}
pub fn note(&self, message: impl Display) -> io::Result<()> {
match &self.output {
ShellOut::Human => {
let mut stderr = anstream::stderr().lock();
write!(
stderr,
"{}note{}: ",
styles::NOTE,
styles::NOTE.render_reset()
)?;
writeln!(stderr, "{message}")?;
stderr.flush()
}
ShellOut::Json => Ok(()),
}
}
pub fn println(&self, message: impl Display) -> io::Result<()> {
match &self.output {
ShellOut::Human => {
writeln!(anstream::stderr().lock(), "{message}")?;
Ok(())
}
ShellOut::Json => Ok(()),
}
}
pub fn json_raw(&self, json: &str) -> io::Result<()> {
match &self.output {
ShellOut::Human => Ok(()),
ShellOut::Json => {
let mut stdout = io::stdout().lock();
writeln!(stdout, "{json}")?;
stdout.flush()
}
}
}
pub fn device_log(
&self,
platform: &str,
level: tracing::Level,
message: impl Display,
) -> io::Result<()> {
match &self.output {
ShellOut::Human => {
let mut stderr = anstream::stderr().lock();
let msg = message.to_string();
let (level_style, level_char) = match level {
tracing::Level::ERROR => (styles::ERROR, 'E'),
tracing::Level::WARN => (styles::WARN, 'W'),
tracing::Level::INFO => (styles::NOTE, 'I'),
tracing::Level::DEBUG => (styles::DEBUG, 'D'),
tracing::Level::TRACE => (styles::TRACE, 'V'),
};
let reset = Style::new().render_reset();
if let Some((tag, rest)) = parse_log_tag(&msg) {
writeln!(
stderr,
"{level_style}{platform}/{level_char}{reset} {tag_style}[{tag}]{reset} {rest}",
tag_style = styles::TAG,
)?;
} else {
writeln!(stderr, "{level_style}{platform}/{level_char}{reset} {msg}")?;
}
stderr.flush()
}
ShellOut::Json => {
#[derive(Serialize)]
struct Log<'a> {
#[serde(rename = "type")]
ty: &'static str,
platform: &'a str,
level: &'a str,
message: &'a str,
}
let level_str = match level {
tracing::Level::ERROR => "error",
tracing::Level::WARN => "warn",
tracing::Level::INFO => "info",
tracing::Level::DEBUG => "debug",
tracing::Level::TRACE => "trace",
};
let json = serde_json::to_string(&Log {
ty: "log",
platform,
level: level_str,
message: &message.to_string(),
})?;
writeln!(io::stdout(), "{json}")?;
io::stdout().flush()
}
}
}
pub fn header(&self, message: impl Display) -> io::Result<()> {
match &self.output {
ShellOut::Human => {
writeln!(
anstream::stderr().lock(),
"{}▶ {}{}",
styles::HEADER,
message,
styles::HEADER.render_reset()
)?;
Ok(())
}
ShellOut::Json => Ok(()),
}
}
#[must_use]
pub fn spinner(&self, message: impl Into<String>) -> Option<ProgressBar> {
if !self.is_terminal() || self.is_json() {
return None;
}
let pb = self.multi_progress.add(ProgressBar::new_spinner());
pb.set_style(
ProgressStyle::default_spinner()
.template("{spinner:.cyan} {msg}")
.expect("valid template"),
);
pb.set_message(message.into());
pb.enable_steady_tick(std::time::Duration::from_millis(80));
Some(pb)
}
#[must_use]
pub fn build_progress(&self) -> BuildProgress {
let mode = if self.is_json() {
CompileRender::Json
} else if self.is_terminal() {
CompileRender::Interactive
} else {
CompileRender::Piped
};
let bars = self.multi_progress.clone();
let units = Arc::new(AtomicUsize::new(0));
BuildProgress::new(move |event| {
render_compile_event(mode, &bars, &units, &event, &mut anstream::stderr().lock());
})
.showing_all_lines()
}
pub fn panic_message(&self, crash_msg: &str) {
let report = PanicReport::parse(crash_msg);
let _ = self.panic_report(&report);
}
pub async fn display_output<Fut: Future>(&self, fut: Fut) -> Fut::Output {
if self.is_interactive() {
set_std_output(true);
let result = fut.await;
set_std_output(false);
result
} else {
fut.await
}
}
pub fn clear(&self) {
self.multi_progress.clear().ok();
}
#[must_use]
pub fn is_interactive(&self) -> bool {
self.is_terminal() && !self.is_json()
}
}
fn parse_log_tag(msg: &str) -> Option<(&str, &str)> {
let msg = msg.trim();
if !msg.starts_with('[') {
return None;
}
let end = msg.find(']')?;
let tag = &msg[1..end];
let rest = msg[end + 1..].trim_start();
Some((tag, rest))
}
fn find_file_in_workspace(relative_path: &std::path::Path) -> Option<std::path::PathBuf> {
let cwd = std::env::current_dir().ok()?;
let direct = cwd.join(relative_path);
if direct.exists() {
return Some(direct);
}
let mut current = cwd.as_path();
while let Some(parent) = current.parent() {
let candidate = parent.join(relative_path);
if candidate.exists() {
return Some(candidate);
}
if parent.join("Cargo.toml").exists() || parent.components().count() <= 2 {
}
current = parent;
}
None
}
pub struct PanicReport<'a> {
pub message: &'a str,
pub file: Option<&'a str>,
pub line: Option<usize>,
pub column: Option<usize>,
pub extra: Option<&'a str>,
pub crash_report_path: Option<&'a str>,
}
impl<'a> PanicReport<'a> {
pub fn parse(crash_msg: &'a str) -> Self {
let mut message = crash_msg;
let mut file = None;
let mut line = None;
let mut column = None;
let mut extra = None;
let mut crash_report_path = None;
let lines: Vec<&str> = crash_msg.lines().collect();
for (i, ln) in lines.iter().enumerate() {
let ln = ln.trim();
if ln.starts_with("Panic:") {
message = ln.strip_prefix("Panic:").unwrap_or(ln).trim();
}
else if ln.starts_with("at ") {
if let Some(loc) = ln.strip_prefix("at ") {
let parts: Vec<&str> = loc.rsplitn(3, ':').collect();
match parts.as_slice() {
[col, ln_num, path] => {
file = Some(*path);
line = ln_num.parse().ok();
column = col.parse().ok();
}
[ln_num, path] => {
file = Some(*path);
line = ln_num.parse().ok();
}
_ => {}
}
}
}
else if ln.starts_with("Crash report:") {
crash_report_path = ln.strip_prefix("Crash report:").map(str::trim);
}
else if ln.starts_with("Exception:") || ln.starts_with("Signal:") {
let extra_end = lines[i..]
.iter()
.position(|l| l.starts_with("Crash report:"))
.map_or(lines.len(), |pos| i + pos);
if extra_end > i
&& lines[i..extra_end]
.iter()
.map(|line| line.trim())
.any(|line| !line.is_empty())
{
extra = Some(lines[i].trim());
}
}
}
Self {
message,
file,
line,
column,
extra,
crash_report_path,
}
}
}
impl Shell {
pub fn panic_report(&self, report: &PanicReport<'_>) -> io::Result<()> {
match &self.output {
ShellOut::Human => Self::panic_report_human(report),
ShellOut::Json => Self::panic_report_json(report),
}
}
fn panic_report_human(report: &PanicReport<'_>) -> io::Result<()> {
use std::fs::File;
use std::io::BufRead;
use std::path::Path;
let mut stderr = anstream::stderr().lock();
let reset = Style::new().render_reset();
let error_style = styles::ERROR;
let note_style = styles::NOTE;
let line_num_style = Style::new().fg_color(Some(anstyle::Color::Ansi(AnsiColor::Blue)));
let highlight_style = Style::new()
.bold()
.fg_color(Some(anstyle::Color::Ansi(AnsiColor::Red)));
writeln!(
stderr,
"{error_style}error{reset}: {error_style}Panic{reset}: {}",
report.message
)?;
if let (Some(file), Some(line)) = (report.file, report.line) {
let col = report.column.unwrap_or(1);
writeln!(stderr, " {note_style}-->{reset} {file}:{line}:{col}")?;
let file_path = Path::new(file);
let resolved_path = if file_path.is_absolute() {
Some(file_path.to_path_buf())
} else {
find_file_in_workspace(file_path)
};
if let Some(ref resolved) = resolved_path
&& let Ok(source_file) = File::open(resolved)
{
let reader = io::BufReader::new(source_file);
let lines: Vec<String> = reader.lines().map_while(Result::ok).collect();
let line_idx = line.saturating_sub(1);
let start = line_idx.saturating_sub(1);
let end = (line_idx + 2).min(lines.len());
let max_line_num = end;
let line_num_width = max_line_num.to_string().len();
writeln!(stderr, " {line_num_style}|{reset}")?;
for (idx, source_line) in lines[start..end].iter().enumerate() {
let current_line = start + idx + 1;
let is_panic_line = current_line == line;
if is_panic_line {
writeln!(
stderr,
"{error_style}{current_line:>line_num_width$}{reset} {line_num_style}|{reset} {highlight_style}{source_line}{reset}"
)?;
let col_offset = col.saturating_sub(1);
let spaces = " ".repeat(col_offset);
let carets =
"^".repeat(source_line.len().saturating_sub(col_offset).clamp(1, 20));
writeln!(
stderr,
"{:>line_num_width$} {line_num_style}|{reset} {spaces}{error_style}{carets}{reset}",
""
)?;
} else {
writeln!(
stderr,
"{line_num_style}{current_line:>line_num_width$}{reset} {line_num_style}|{reset} {source_line}"
)?;
}
}
writeln!(stderr, " {line_num_style}|{reset}")?;
}
}
if let Some(extra) = report.extra {
writeln!(stderr)?;
writeln!(stderr, "{note_style}note{reset}: {extra}")?;
}
if let Some(path) = report.crash_report_path {
writeln!(stderr)?;
writeln!(stderr, "{note_style}crash report{reset}: {path}")?;
}
stderr.flush()
}
fn panic_report_json(report: &PanicReport<'_>) -> io::Result<()> {
#[derive(Serialize)]
struct JsonPanic<'a> {
#[serde(rename = "type")]
ty: &'static str,
message: &'a str,
#[serde(skip_serializing_if = "Option::is_none")]
file: Option<&'a str>,
#[serde(skip_serializing_if = "Option::is_none")]
line: Option<usize>,
#[serde(skip_serializing_if = "Option::is_none")]
column: Option<usize>,
#[serde(skip_serializing_if = "Option::is_none")]
extra: Option<&'a str>,
#[serde(skip_serializing_if = "Option::is_none")]
crash_report: Option<&'a str>,
}
let json = serde_json::to_string(&JsonPanic {
ty: "panic",
message: report.message,
file: report.file,
line: report.line,
column: report.column,
extra: report.extra,
crash_report: report.crash_report_path,
})?;
writeln!(io::stdout(), "{json}")?;
io::stdout().flush()
}
}
#[derive(Clone, Copy)]
enum CompileRender {
Interactive,
Piped,
Json,
}
fn render_compile_event(
mode: CompileRender,
bars: &MultiProgress,
units: &AtomicUsize,
event: &CompileEvent,
piped_out: &mut dyn io::Write,
) {
match mode {
CompileRender::Interactive => {
let _ = bars.println(compile_event_text(units, event));
}
CompileRender::Piped => {
let _ = writeln!(piped_out, "{}", piped_event_line(units, event));
let _ = piped_out.flush();
}
CompileRender::Json => {
let record = compile_event_record(units, event);
if let Ok(json) = serde_json::to_string(&record) {
let mut stdout = io::stdout().lock();
let _ = writeln!(stdout, "{json}");
let _ = stdout.flush();
}
}
}
}
fn piped_event_line(units: &AtomicUsize, event: &CompileEvent) -> String {
console::strip_ansi_codes(&compile_event_text(units, event)).into_owned()
}
fn compile_event_text(units: &AtomicUsize, event: &CompileEvent) -> String {
match event {
CompileEvent::Unit {
phase,
name,
version,
} => {
let count = units.fetch_add(1, Ordering::Relaxed) + 1;
version.as_ref().map_or_else(
|| format!("{phase:>12} {name} ({count})"),
|version| format!("{phase:>12} {name} v{version} ({count})"),
)
}
CompileEvent::Finished(text) | CompileEvent::Line(text) => text.clone(),
}
}
#[derive(Serialize)]
struct BuildProgressRecord<'a> {
#[serde(rename = "type")]
ty: &'static str,
phase: String,
#[serde(rename = "crate", skip_serializing_if = "Option::is_none")]
name: Option<&'a str>,
#[serde(skip_serializing_if = "Option::is_none")]
version: Option<&'a str>,
#[serde(skip_serializing_if = "Option::is_none")]
count: Option<usize>,
#[serde(skip_serializing_if = "Option::is_none")]
message: Option<String>,
}
fn compile_event_record<'a>(
units: &AtomicUsize,
event: &'a CompileEvent,
) -> BuildProgressRecord<'a> {
match event {
CompileEvent::Unit {
phase,
name,
version,
} => BuildProgressRecord {
ty: "build-progress",
phase: phase.to_ascii_lowercase(),
name: Some(name),
version: version.as_deref(),
count: Some(units.fetch_add(1, Ordering::Relaxed) + 1),
message: None,
},
CompileEvent::Finished(text) => BuildProgressRecord {
ty: "build-progress",
phase: "finished".to_string(),
name: None,
version: None,
count: None,
message: Some(console::strip_ansi_codes(text).into_owned()),
},
CompileEvent::Line(text) => BuildProgressRecord {
ty: "build-progress",
phase: "output".to_string(),
name: None,
version: None,
count: None,
message: Some(console::strip_ansi_codes(text).into_owned()),
},
}
}
#[macro_export]
macro_rules! success {
($shell:expr, $($arg:tt)*) => {{
let _ = $shell.status("✓", format!($($arg)*));
}};
}
#[macro_export]
macro_rules! line {
($shell:expr) => {{
let _ = $shell.println("");
}};
($shell:expr, $($arg:tt)*) => {{
let _ = $shell.println(format!($($arg)*));
}};
}
#[macro_export]
macro_rules! warn {
($shell:expr, $($arg:tt)*) => {{
let _ = $shell.warn(format!($($arg)*));
}};
}
#[macro_export]
macro_rules! error {
($shell:expr, $($arg:tt)*) => {{
let _ = $shell.error(format!($($arg)*));
}};
}
#[macro_export]
macro_rules! note {
($shell:expr, $($arg:tt)*) => {{
let _ = $shell.note(format!($($arg)*));
}};
}
#[macro_export]
macro_rules! header {
($shell:expr, $($arg:tt)*) => {{
let _ = $shell.header(format!($($arg)*));
}};
}
#[cfg(test)]
mod tests {
use std::sync::atomic::AtomicUsize;
use waterui_cli::build::CompileEvent;
use indicatif::{MultiProgress, ProgressDrawTarget};
use super::{CompileRender, piped_event_line, render_compile_event};
#[test]
fn piped_render_keeps_cargo_status_lines() {
let bars = MultiProgress::with_draw_target(ProgressDrawTarget::hidden());
let units = AtomicUsize::new(0);
let mut piped = Vec::new();
render_compile_event(
CompileRender::Piped,
&bars,
&units,
&CompileEvent::Line("Blocking waiting for file lock on package cache".to_string()),
&mut piped,
);
let rendered = String::from_utf8(piped).expect("piped output is UTF-8");
assert_eq!(
rendered.trim_end(),
"Blocking waiting for file lock on package cache"
);
}
#[test]
fn piped_render_strips_ansi_and_counts_units() {
let units = AtomicUsize::new(0);
let line = piped_event_line(
&units,
&CompileEvent::Line("\u{1b}[32mUpdating\u{1b}[0m index".to_string()),
);
assert_eq!(line, "Updating index");
let line = piped_event_line(
&units,
&CompileEvent::Unit {
phase: "Compiling",
name: "waterui".to_string(),
version: Some("0.1.0".to_string()),
},
);
assert!(line.contains("Compiling waterui v0.1.0"), "{line}");
assert!(line.ends_with("(1)"), "{line}");
}
}