310 lines
8.7 KiB
Rust
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))
|
|
}
|
|
}
|