442 lines
13 KiB
Rust
442 lines
13 KiB
Rust
//! Console output helpers: colored logging, plain text tables, JSON output.
|
|
|
|
use colored::Colorize;
|
|
use serde::Serialize;
|
|
use std::io::{IsTerminal, Write};
|
|
use std::path::PathBuf;
|
|
|
|
/// Name of the global log file inside the log directory.
|
|
pub const LOG_FILE: &str = "agent-manager.log";
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Logger
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Structured logger: writes every message to the timestamped log file and
|
|
/// echoes the important ones to stderr (stdout stays reserved for data).
|
|
#[derive(Debug)]
|
|
pub struct Logger {
|
|
log_dir: PathBuf,
|
|
pub verbose: bool,
|
|
pub quiet: bool,
|
|
pub no_color: bool,
|
|
}
|
|
|
|
impl Logger {
|
|
pub fn new(log_dir: PathBuf, verbose: bool, quiet: bool, no_color: bool) -> Self {
|
|
Self {
|
|
log_dir,
|
|
verbose,
|
|
quiet,
|
|
no_color,
|
|
}
|
|
}
|
|
|
|
pub fn log_dir(&self) -> &PathBuf {
|
|
&self.log_dir
|
|
}
|
|
|
|
pub fn color_enabled(&self) -> bool {
|
|
!self.no_color && std::io::stderr().is_terminal()
|
|
}
|
|
|
|
fn write_log(&self, line: &str) {
|
|
if std::fs::create_dir_all(&self.log_dir).is_err() {
|
|
return;
|
|
}
|
|
let path = self.log_dir.join(LOG_FILE);
|
|
if let Ok(mut f) = std::fs::OpenOptions::new()
|
|
.create(true)
|
|
.append(true)
|
|
.open(&path)
|
|
{
|
|
let ts = chrono::Local::now().format("%Y-%m-%d %H:%M:%S");
|
|
let _ = writeln!(f, "[{ts}] {line}");
|
|
}
|
|
}
|
|
|
|
/// Detailed message, shown only in verbose mode (always written to the log).
|
|
pub fn verbose<S: AsRef<str>>(&self, msg: S) {
|
|
let msg = msg.as_ref();
|
|
self.write_log(msg);
|
|
if self.verbose && !self.quiet {
|
|
if self.color_enabled() {
|
|
eprintln!("{}", msg.dimmed());
|
|
} else {
|
|
eprintln!("{msg}");
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Normal informational message.
|
|
pub fn info<S: AsRef<str>>(&self, msg: S) {
|
|
let msg = msg.as_ref();
|
|
self.write_log(msg);
|
|
if !self.quiet {
|
|
eprintln!("{msg}");
|
|
}
|
|
}
|
|
|
|
/// Success message (green).
|
|
pub fn success<S: AsRef<str>>(&self, msg: S) {
|
|
let msg = msg.as_ref();
|
|
self.write_log(msg);
|
|
if !self.quiet {
|
|
if self.color_enabled() {
|
|
eprintln!("{}", msg.green());
|
|
} else {
|
|
eprintln!("{msg}");
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Warning message (yellow).
|
|
pub fn warn<S: AsRef<str>>(&self, msg: S) {
|
|
let msg = msg.as_ref();
|
|
self.write_log(msg);
|
|
if !self.quiet {
|
|
if self.color_enabled() {
|
|
eprintln!("{}", msg.yellow());
|
|
} else {
|
|
eprintln!("{msg}");
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Error message (red); always shown, even in quiet mode.
|
|
pub fn error<S: AsRef<str>>(&self, msg: S) {
|
|
let msg = msg.as_ref();
|
|
self.write_log(msg);
|
|
if self.color_enabled() {
|
|
eprintln!("{}", msg.red());
|
|
} else {
|
|
eprintln!("{msg}");
|
|
}
|
|
}
|
|
|
|
/// Dry-run note (yellow prefix).
|
|
pub fn dry<S: AsRef<str>>(&self, msg: S) {
|
|
let msg = msg.as_ref();
|
|
self.write_log(msg);
|
|
if !self.quiet {
|
|
if self.color_enabled() {
|
|
eprintln!("{} {msg}", "[dry-run]".yellow().bold());
|
|
} else {
|
|
eprintln!("[dry-run] {msg}");
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Record an executed command (log file; echoed when verbose).
|
|
pub fn cmd(&self, display: &str) {
|
|
self.write_log(&format!("exec: {display}"));
|
|
if self.verbose && !self.quiet {
|
|
eprintln!(" {}", display.dimmed());
|
|
}
|
|
}
|
|
|
|
/// Record a command result.
|
|
pub fn result(&self, code: i32, stderr: &str) {
|
|
if code != 0 {
|
|
self.write_log(&format!("exit code {code}; stderr: {}", stderr.trim()));
|
|
}
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Shared boxed outputs (the Nushell-style "white frame")
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Truncate a string to max visible chars with an ellipsis.
|
|
pub fn truncate(s: &str, max: usize) -> String {
|
|
if s.chars().count() <= max {
|
|
s.to_string()
|
|
} else {
|
|
let mut cut: String = s.chars().take(max.saturating_sub(1)).collect();
|
|
cut.push('…');
|
|
cut
|
|
}
|
|
}
|
|
|
|
/// Build a boxed Nushell-style table (white frame, like ls) from plain rows
|
|
/// and render it, fitted to the terminal width and the active theme.
|
|
pub fn boxed_table(
|
|
headers: Vec<&str>,
|
|
rows: Vec<Vec<String>>,
|
|
theme: &crate::theme::Theme,
|
|
color: bool,
|
|
) -> String {
|
|
use crate::tables::{Cell, DataTable};
|
|
let table = DataTable {
|
|
columns: headers.into_iter().map(|h| h.to_string()).collect(),
|
|
rows: rows
|
|
.into_iter()
|
|
.map(|r| r.into_iter().map(Cell::str).collect())
|
|
.collect(),
|
|
};
|
|
table.render_themed(theme, color, terminal_width().unwrap_or(120))
|
|
}
|
|
|
|
/// Render a two-column key/value box (no header row), like the version
|
|
/// table. Keys use the theme key style, values the value style.
|
|
pub fn kv_table(
|
|
rows: &[(String, String)],
|
|
theme: &crate::theme::Theme,
|
|
color: bool,
|
|
) -> String {
|
|
let key_w = rows
|
|
.iter()
|
|
.map(|(k, _)| visible_len(k))
|
|
.max()
|
|
.unwrap_or(0);
|
|
let val_w = rows
|
|
.iter()
|
|
.map(|(_, v)| visible_len(v))
|
|
.max()
|
|
.unwrap_or(0);
|
|
let border = |left: char, mid: char, right: char| -> String {
|
|
let line = format!(
|
|
"{left}{}{mid}{}{right}",
|
|
"─".repeat(key_w + 2),
|
|
"─".repeat(val_w + 2)
|
|
);
|
|
if color {
|
|
theme.frame(&line)
|
|
} else {
|
|
line
|
|
}
|
|
};
|
|
let paint = |s: &str, color: bool, style: fn(&crate::theme::Theme, &str) -> String| {
|
|
if color && !s.contains('\u{1b}') {
|
|
style(theme, s)
|
|
} else {
|
|
s.to_string()
|
|
}
|
|
};
|
|
let bar = if color {
|
|
theme.frame("│")
|
|
} else {
|
|
"│".to_string()
|
|
};
|
|
let mut out = String::new();
|
|
out.push_str(&border('╭', '┬', '╮'));
|
|
out.push('\n');
|
|
for (k, v) in rows {
|
|
let key = paint(k, color, crate::theme::Theme::key);
|
|
let val = paint(v, color, crate::theme::Theme::val);
|
|
out.push_str(&format!(
|
|
"{bar} {} {bar} {} {bar}\n",
|
|
pad(key.as_str(), key_w),
|
|
pad(val.as_str(), val_w)
|
|
));
|
|
}
|
|
out.push_str(&border('╰', '┴', '╯'));
|
|
out.push('\n');
|
|
out
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Tables
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// A minimal plain-text table. Cells must be pre-colored (widths are computed
|
|
/// on ANSI-stripped text).
|
|
pub struct Table {
|
|
headers: Vec<String>,
|
|
rows: Vec<Vec<String>>,
|
|
}
|
|
|
|
const MAX_CELL: usize = 48;
|
|
|
|
impl Table {
|
|
pub fn new(headers: Vec<&str>) -> Self {
|
|
Self {
|
|
headers: headers.into_iter().map(|h| h.to_string()).collect(),
|
|
rows: Vec::new(),
|
|
}
|
|
}
|
|
|
|
pub fn row(&mut self, cells: Vec<String>) -> &mut Self {
|
|
self.rows.push(cells);
|
|
self
|
|
}
|
|
|
|
pub fn render(&self) -> String {
|
|
if self.headers.is_empty() {
|
|
return String::new();
|
|
}
|
|
let cols = self.headers.len();
|
|
let mut widths: Vec<usize> = self
|
|
.headers
|
|
.iter()
|
|
.map(|h| visible_len(h).min(MAX_CELL))
|
|
.collect();
|
|
for row in &self.rows {
|
|
for (i, cell) in row.iter().enumerate().take(cols) {
|
|
widths[i] = widths[i].max(visible_len(cell).min(MAX_CELL));
|
|
}
|
|
}
|
|
let mut out = String::new();
|
|
let mut header_line = String::new();
|
|
for (i, h) in self.headers.iter().enumerate() {
|
|
if i > 0 {
|
|
header_line.push_str(" ");
|
|
}
|
|
header_line.push_str(&pad(h, widths[i]));
|
|
}
|
|
let header_line = header_line.trim_end().to_string();
|
|
out.push_str(&format!("{}
|
|
", header_line.bold()));
|
|
out.push_str(&"-".repeat(visible_len(&header_line)));
|
|
out.push('\n');
|
|
for row in &self.rows {
|
|
let mut l = String::new();
|
|
for i in 0..cols {
|
|
if i > 0 {
|
|
l.push_str(" ");
|
|
}
|
|
let cell = row.get(i).cloned().unwrap_or_default();
|
|
l.push_str(&pad(&cell, widths[i]));
|
|
}
|
|
out.push_str(l.trim_end());
|
|
out.push('\n');
|
|
}
|
|
out
|
|
}
|
|
}
|
|
|
|
/// Color a status cell for display in tables (theme-aware).
|
|
pub fn status_style(status: &str, theme: &crate::theme::Theme, color: bool) -> String {
|
|
if !color {
|
|
return status.to_string();
|
|
}
|
|
match status {
|
|
"running" => theme.acc(status),
|
|
"installed" => theme.inf(status),
|
|
"external" => theme.warn(status),
|
|
"not-installable" => theme.err(status),
|
|
"not-installed" => theme.dimmed(status),
|
|
"ok" => theme.ok(status),
|
|
"warn" => theme.warn(status),
|
|
"error" => theme.err(status),
|
|
_ => status.to_string(),
|
|
}
|
|
}
|
|
|
|
/// Print a value as pretty JSON on stdout (for scripts).
|
|
pub fn print_json<T: Serialize>(value: &T) {
|
|
if serde_json::to_writer_pretty(std::io::stdout(), value).is_ok() {
|
|
println!();
|
|
}
|
|
}
|
|
|
|
/// Width of the terminal attached to stdout, in columns. Returns None when
|
|
/// stdout is not a terminal (e.g. piped). AM_TABLE_WIDTH overrides it, mostly
|
|
/// for tests and scripts.
|
|
pub fn terminal_width() -> Option<usize> {
|
|
if let Ok(v) = std::env::var("AM_TABLE_WIDTH") {
|
|
if let Ok(w) = v.trim().parse::<usize>() {
|
|
return Some(w);
|
|
}
|
|
}
|
|
#[cfg(windows)]
|
|
{
|
|
use std::os::windows::io::AsRawHandle;
|
|
use windows_sys::Win32::System::Console::{
|
|
GetConsoleScreenBufferInfo, CONSOLE_SCREEN_BUFFER_INFO,
|
|
};
|
|
let handle = std::io::stdout().as_raw_handle() as isize;
|
|
let mut info: CONSOLE_SCREEN_BUFFER_INFO = unsafe { std::mem::zeroed() };
|
|
if unsafe { GetConsoleScreenBufferInfo(handle, &mut info) } != 0 {
|
|
let width = (info.srWindow.Right - info.srWindow.Left + 1) as usize;
|
|
if width > 0 {
|
|
return Some(width);
|
|
}
|
|
}
|
|
None
|
|
}
|
|
#[cfg(not(windows))]
|
|
{
|
|
if let Ok(v) = std::env::var("COLUMNS") {
|
|
if let Ok(w) = v.trim().parse::<usize>() {
|
|
return Some(w);
|
|
}
|
|
}
|
|
None
|
|
}
|
|
}
|
|
|
|
fn strip_ansi(s: &str) -> String {
|
|
let mut out = String::new();
|
|
let mut chars = s.chars();
|
|
while let Some(c) = chars.next() {
|
|
if c == '' {
|
|
for n in chars.by_ref() {
|
|
if n == 'm' {
|
|
break;
|
|
}
|
|
}
|
|
} else {
|
|
out.push(c);
|
|
}
|
|
}
|
|
out
|
|
}
|
|
|
|
fn visible_len(s: &str) -> usize {
|
|
strip_ansi(s).chars().count()
|
|
}
|
|
|
|
fn pad(s: &str, width: usize) -> String {
|
|
let n = visible_len(s);
|
|
if n > width {
|
|
let plain = strip_ansi(s);
|
|
let mut cut: String = plain.chars().take(width.saturating_sub(1)).collect();
|
|
cut.push('…');
|
|
cut
|
|
} else {
|
|
format!("{s}{}", " ".repeat(width - n))
|
|
}
|
|
}
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn kv_table_is_aligned_with_and_without_colors() {
|
|
let rows = vec![
|
|
("version".to_string(), "0.2.13".to_string()),
|
|
("build_os".to_string(), "windows-x86_64".to_string()),
|
|
];
|
|
for color in [false, true] {
|
|
let text = kv_table(&rows, crate::theme::default_theme(), color);
|
|
// Compare visible widths (ANSI codes must not shift columns).
|
|
let widths: Vec<usize> = text
|
|
.lines()
|
|
.map(|l| strip_ansi(l).chars().count())
|
|
.collect();
|
|
assert!(
|
|
widths.iter().all(|w| *w == widths[0]),
|
|
"unaligned ({color}):\n{text}"
|
|
);
|
|
}
|
|
// Color output really contains ANSI sequences.
|
|
let colored = kv_table(&rows, crate::theme::default_theme(), true);
|
|
assert!(colored.contains("\x1b["));
|
|
}
|
|
|
|
#[test]
|
|
fn boxed_table_has_a_frame() {
|
|
let text = boxed_table(
|
|
vec!["NAME", "STATUS"],
|
|
vec![vec!["claude-code".to_string(), "external".to_string()]],
|
|
crate::theme::default_theme(),
|
|
false,
|
|
);
|
|
assert!(text.contains("╭") && text.contains("╰"));
|
|
assert!(text.contains("claude-code"));
|
|
assert!(text.contains("NAME"));
|
|
}
|
|
}
|
|
|