Files
agent-manager/src/output.rs
T

310 lines
8.7 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()));
}
}
}
// ---------------------------------------------------------------------------
// 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.
pub fn status_style(status: &str, color: bool) -> String {
let s = status.to_string();
if !color {
return s;
}
match status {
"running" => s.green().bold().to_string(),
"installed" => s.cyan().to_string(),
"external" => s.yellow().to_string(),
"not-installable" => s.red().to_string(),
"not-installed" => s.dimmed().to_string(),
"ok" => s.green().to_string(),
"warn" => s.yellow().to_string(),
"error" => s.red().bold().to_string(),
_ => s,
}
}
/// 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))
}
}