//! 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())); } } } // --------------------------------------------------------------------------- // 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>, 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, 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 (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(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)) } } #[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 = 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")); } }