use std::{ borrow::Cow, fs::OpenOptions, io::{BufRead, BufReader, Result, Write as IoWrite}, path::{Path, PathBuf}, process::{Command, ExitStatus, Stdio}, sync::Mutex, thread, time::Duration, }; use blue_build_utils::get_env_var; use bon::Builder; use chrono::Local; use colored::{ColoredString, Colorize, control::ShouldColorize}; use indicatif::{MultiProgress, ProgressBar}; use indicatif_log_bridge::LogWrapper; use log::{Level, LevelFilter, Record, warn}; use log4rs::{ Config, Logger as L4RSLogger, append::{ console::ConsoleAppender, rolling_file::{ RollingFileAppender, policy::compound::{ CompoundPolicy, roll::fixed_window::FixedWindowRoller, trigger::size::SizeTrigger, }, }, }, config::{Appender, Root}, encode::{Encode, Write, pattern::PatternEncoder}, }; use nu_ansi_term::Color; use private::Private; use rand::Rng; use crate::signal_handler::{add_pid, remove_pid}; mod private { pub trait Private {} } impl Private for Command {} static MULTI_PROGRESS: std::sync::LazyLock = std::sync::LazyLock::new(MultiProgress::new); static LOG_DIR: std::sync::LazyLock> = std::sync::LazyLock::new(|| Mutex::new(PathBuf::new())); #[derive(Debug, Clone)] pub struct Logger { modules: Vec<(String, LevelFilter)>, level: LevelFilter, log_dir: Option, } impl Logger { const TRIGGER_FILE_SIZE: u64 = 10 * 1024; const ARCHIVE_FILENAME_PATTERN: &'static str = "bluebuild.{}.log"; const LOG_FILENAME: &'static str = "bluebuild.log"; const LOG_FILE_COUNT: u32 = 4; #[must_use] pub fn new() -> Self { Self::default() } pub fn filter_modules(&mut self, filter_modules: I) -> &mut Self where I: IntoIterator, S: AsRef, { self.modules = filter_modules .into_iter() .map(|(module, level)| (module.as_ref().to_string(), level)) .collect::>(); self } pub const fn filter_level(&mut self, filter_level: LevelFilter) -> &mut Self { self.level = filter_level; self } pub fn log_out_dir

(&mut self, path: Option

) -> &mut Self where P: AsRef, { self.log_dir = path.map(|p| p.as_ref().to_path_buf()); self } /// Initializes logging for the application. /// /// # Panics /// Will panic if logging is unable to be initialized. pub fn init(&self) { let home = get_env_var("HOME").expect("$HOME should be defined"); let log_dir = self.log_dir.as_ref().map_or_else( || Path::new(home.as_str()).join(".cache/bluebuild"), Clone::clone, ); let mut lock = LOG_DIR.lock().expect("Should lock LOG_DIR"); lock.clone_from(&log_dir); drop(lock); let log_out_path = log_dir.join(Self::LOG_FILENAME); let log_archive_pattern = format!("{}/{}", log_dir.display(), Self::ARCHIVE_FILENAME_PATTERN); let stderr = ConsoleAppender::builder() .encoder(Box::new( CustomPatternEncoder::builder() .filter_modules(self.modules.clone()) .build(), )) .target(log4rs::append::console::Target::Stderr) .tty_only(true) .build(); let file = RollingFileAppender::builder() .encoder(Box::new(PatternEncoder::new("{d} - {l} - {m}{n}"))) .build( log_out_path, Box::new(CompoundPolicy::new( Box::new(SizeTrigger::new(Self::TRIGGER_FILE_SIZE)), Box::new( FixedWindowRoller::builder() .build(&log_archive_pattern, Self::LOG_FILE_COUNT) .expect("Roller should be created"), ), )), ) .expect("Must be able to create log FileAppender"); let config = Config::builder() .appender(Appender::builder().build("stderr", Box::new(stderr))) .appender(Appender::builder().build("file", Box::new(file))) .build( Root::builder() .appender("stderr") .appender("file") .build(self.level), ) .expect("Logger config should build"); let logger = L4RSLogger::new(config); LogWrapper::new(MULTI_PROGRESS.clone(), logger) .try_init() .expect("LogWrapper should initialize"); } pub fn multi_progress() -> MultiProgress { MULTI_PROGRESS.clone() } } impl Default for Logger { fn default() -> Self { Self { modules: vec![], level: LevelFilter::Info, log_dir: None, } } } trait ColoredLevel { fn colored(&self) -> ColoredString; } impl ColoredLevel for Level { fn colored(&self) -> ColoredString { match self { Self::Error => Self::Error.as_str().red(), Self::Warn => Self::Warn.as_str().yellow(), Self::Info => Self::Info.as_str().green(), Self::Debug => Self::Debug.as_str().blue(), Self::Trace => Self::Trace.as_str().cyan(), } } } pub trait CommandLogging: Private { /// Prints each line of stdout/stderr with an image ref string /// and a progress spinner while also logging the build output. /// This helps to keep track of every build running in parallel. /// /// # Errors /// Will error if there was an issue executing the process. fn build_status(self, image_ref: T, message: U) -> Result where T: AsRef, U: AsRef; /// Prints each line of stdout/stderr with a log header /// and a progress spinner. This helps to keep track of every /// command running in parallel. /// /// # Errors /// Will error if there was an issue executing the process. fn message_status(self, header: S, message: D) -> Result where S: AsRef, D: Into>; } impl CommandLogging for Command { fn build_status(self, image_ref: T, message: U) -> Result where T: AsRef, U: AsRef, { fn inner(mut command: Command, image_ref: &str, message: &str) -> Result { let ansi_color = gen_random_ansi_color(); let name = color_str(image_ref, ansi_color); let short_name = color_str(shorten_name(image_ref), ansi_color); let (reader, writer) = os_pipe::pipe()?; command .stdout(writer.try_clone()?) .stderr(writer) .stdin(Stdio::piped()); let progress = Logger::multi_progress() .add(ProgressBar::new_spinner().with_message(format!("{message} {name}"))); progress.enable_steady_tick(Duration::from_millis(100)); let mut child = command.spawn()?; let child_pid = child.id(); add_pid(child_pid); // We drop the `Command` to prevent blocking on writer // https://docs.rs/os_pipe/latest/os_pipe/#examples drop(command); let reader = BufReader::new(reader); let log_file_path = { let lock = LOG_DIR.lock().expect("Should lock LOG_DIR"); lock.join(format!("{}.log", image_ref.replace(['/', ':', '.'], "_"))) }; let log_file = OpenOptions::new() .create(true) .append(true) .open(log_file_path.as_path())?; thread::spawn(move || { let mp = Logger::multi_progress(); reader.lines().for_each(|line| { if let Ok(l) = line { let text = format!("{log_prefix} {l}", log_prefix = log_header(&short_name)); if mp.is_hidden() { eprintln!("{text}"); } else { mp.println(text).unwrap(); } if let Err(e) = writeln!(&log_file, "{l}") { warn!( "Failed to write to log for build {}: {e:?}", log_file_path.display() ); } } }); }); let status = child.wait()?; remove_pid(child_pid); progress.finish(); Logger::multi_progress().remove(&progress); Ok(status) } inner(self, image_ref.as_ref(), message.as_ref()) } fn message_status(self, header: S, message: D) -> Result where S: AsRef, D: Into>, { fn inner( mut command: Command, header: &str, message: Cow<'static, str>, ) -> Result { let ansi_color = gen_random_ansi_color(); let header = color_str(header, ansi_color); let (reader, writer) = os_pipe::pipe()?; command .stdout(writer.try_clone()?) .stderr(writer) .stdin(Stdio::piped()); let progress = Logger::multi_progress().add(ProgressBar::new_spinner().with_message(message)); progress.enable_steady_tick(Duration::from_millis(100)); let mut child = command.spawn()?; let child_pid = child.id(); add_pid(child_pid); // We drop the `Command` to prevent blocking on writer // https://docs.rs/os_pipe/latest/os_pipe/#examples drop(command); let reader = BufReader::new(reader); thread::spawn(move || { let mp = Logger::multi_progress(); reader.lines().for_each(|line| { if let Ok(l) = line { let text = format!("{log_prefix} {l}", log_prefix = log_header(&header)); if mp.is_hidden() { eprintln!("{text}"); } else { mp.println(text).unwrap(); } } }); }); let status = child.wait()?; remove_pid(child_pid); progress.finish(); Logger::multi_progress().remove(&progress); Ok(status) } inner(self, header.as_ref(), message.into()) } } #[derive(Debug, Builder)] struct CustomPatternEncoder { #[builder(default, into)] filter_modules: Vec<(String, LevelFilter)>, } impl Encode for CustomPatternEncoder { fn encode(&self, w: &mut dyn Write, record: &Record) -> anyhow::Result<()> { if record.module_path().is_some_and(|mp| { self.filter_modules .iter() .any(|(module, level)| mp.contains(module) && *level <= record.level()) }) { Ok(()) } else { match log::max_level() { LevelFilter::Error | LevelFilter::Warn | LevelFilter::Info => Ok(writeln!( w, "{prefix} {args}", prefix = log_header(format!( "{level:width$}", level = record.level().colored(), width = 5, )), args = record.args(), )?), LevelFilter::Debug => Ok(writeln!( w, "{prefix} {args}", prefix = log_header(format!( "{level:>width$}", level = record.level().colored(), width = 5, )), args = record.args(), )?), LevelFilter::Trace => Ok(writeln!( w, "{prefix} {args}", prefix = log_header(format!( "{level:width$} {module}:{line}", level = record.level().colored(), width = 5, module = record .module_path() .map_or_else(|| "", |p| p) .bright_yellow(), line = record .line() .map_or_else(String::new, |l| l.to_string()) .bright_green(), )), args = record.args(), )?), LevelFilter::Off => Ok(()), } } } } /// Used to keep the style of logs consistent between /// normal log use and command output. fn log_header(text: T) -> String where T: AsRef, { fn inner(text: &str) -> String { match log::max_level() { LevelFilter::Error | LevelFilter::Warn | LevelFilter::Info => { format!("{text} {sep}", sep = "=>".bold()) } LevelFilter::Debug | LevelFilter::Trace => format!( "[{time} {text}] {sep}", time = Local::now().format("%H:%M:%S"), sep = "=>".bold(), ), LevelFilter::Off => String::new(), } } inner(text.as_ref()) } /// Shortens the image name so that it won't take up the /// entire width of the terminal. This is a similar format /// to what Earthly does in their terminal output for long /// images on their log prefix output. /// /// # Examples /// `ghcr.io/blue-build/cli:latest` -> `g.i/b/cli:latest` /// `registry.gitlab.com/some/namespace/image:latest` -> `r.g.c/s/n/image:latest` #[must_use] fn shorten_name(text: T) -> String where T: AsRef, { let text = text.as_ref(); // Split the reference by colon to separate the tag or digest let mut parts = text.split(':'); let Some(path) = parts.next() else { return text.to_string(); }; let tag = parts.next(); // Split the path by slash to work on each part let path_parts: Vec<&str> = path.split('/').collect(); // Shorten each part except the last one to their initial letters let shortened_parts: Vec = path_parts .iter() .enumerate() .map(|(i, part)| { if i < path_parts.len() - 1 { // Split on '.' and shorten each section part.split('.') .filter_map(|p| p.chars().next()) .map(|c| c.to_string()) .collect::>() .join(".") } else { (*part).into() // Keep the last part as it is } }) .collect(); // Rejoin the parts with '/' let joined_path = shortened_parts.join("/"); // If there was a tag, append it back with ':', otherwise just return the path match tag { Some(t) => format!("{joined_path}:{t}"), None => joined_path, } } #[must_use] pub fn gen_random_ansi_color() -> u8 { // ANSI extended color range // https://www.ditig.com/publications/256-colors-cheat-sheet const LOW_END: u8 = 21; // Blue1 #0000ff rgb(0,0,255) hsl(240,100%,50%) const HIGH_END: u8 = 230; // Cornsilk1 #ffffd7 rgb(255,255,215) hsl(60,100%,92%) rand::rng().random_range(LOW_END..=HIGH_END) } pub fn color_str(text: T, ansi_color: u8) -> String where T: AsRef, { if ShouldColorize::from_env().should_colorize() { Color::Fixed(ansi_color) .paint(text.as_ref().to_string()) .to_string() } else { text.as_ref().to_string() } }