//! 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>(&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>(&self, msg: S) { let msg = msg.as_ref(); self.write_log(msg); if !self.quiet { eprintln!("{msg}"); } } /// Success message (green). pub fn success>(&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>(&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>(&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>(&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, rows: Vec>, } 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) -> &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 = 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(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 { if let Ok(v) = std::env::var("AM_TABLE_WIDTH") { if let Ok(w) = v.trim().parse::() { 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::() { 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)) } }