use anstyle::{AnsiColor, Style};
use indicatif::{MultiProgress, ProgressBar, ProgressStyle};
use serde::Serialize;
use std::fmt::Display;
use std::io::{self, IsTerminal, Write};
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)
}
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()
}
}
#[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)*));
}};
}