Files
agent-manager/src/output.rs
T

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"));
}
}