Skip to main content

blue_build_process_management/
logging.rs

1use std::{
2    borrow::Cow,
3    fs::OpenOptions,
4    io::{BufRead, BufReader, Result, Write as IoWrite},
5    path::{Path, PathBuf},
6    process::{Command, ExitStatus, Stdio},
7    sync::{
8        LazyLock, Mutex,
9        atomic::{AtomicUsize, Ordering},
10    },
11    thread,
12    time::Duration,
13};
14
15use blue_build_utils::get_env_var;
16use bon::Builder;
17use chrono::Local;
18use colored::{ColoredString, Colorize, control::ShouldColorize};
19use indicatif::{MultiProgress, ProgressBar};
20use indicatif_log_bridge::LogWrapper;
21use log::{Level, LevelFilter, Record, warn};
22use log4rs::{
23    Config, Logger as L4RSLogger,
24    append::{
25        console::ConsoleAppender,
26        rolling_file::{
27            RollingFileAppender,
28            policy::compound::{
29                CompoundPolicy, roll::fixed_window::FixedWindowRoller, trigger::size::SizeTrigger,
30            },
31        },
32    },
33    config::{Appender, Root},
34    encode::{Encode, Write, pattern::PatternEncoder},
35};
36use nu_ansi_term::Color;
37use private::Private;
38use rand::seq::SliceRandom;
39
40use crate::signal_handler::{add_pid, remove_pid};
41
42mod private {
43    pub trait Private {}
44}
45
46impl Private for Command {}
47
48static MULTI_PROGRESS: std::sync::LazyLock<MultiProgress> =
49    std::sync::LazyLock::new(MultiProgress::new);
50static LOG_DIR: std::sync::LazyLock<Mutex<PathBuf>> =
51    std::sync::LazyLock::new(|| Mutex::new(PathBuf::new()));
52
53#[derive(Debug, Clone)]
54pub struct Logger {
55    modules: Vec<(String, LevelFilter)>,
56    level: LevelFilter,
57    log_dir: Option<PathBuf>,
58}
59
60impl Logger {
61    const TRIGGER_FILE_SIZE: u64 = 10 * 1024;
62    const ARCHIVE_FILENAME_PATTERN: &'static str = "bluebuild.{}.log";
63    const LOG_FILENAME: &'static str = "bluebuild.log";
64    const LOG_FILE_COUNT: u32 = 4;
65
66    #[must_use]
67    pub fn new() -> Self {
68        Self::default()
69    }
70
71    pub fn filter_modules<I, S>(&mut self, filter_modules: I) -> &mut Self
72    where
73        I: IntoIterator<Item = (S, LevelFilter)>,
74        S: AsRef<str>,
75    {
76        self.modules = filter_modules
77            .into_iter()
78            .map(|(module, level)| (module.as_ref().to_string(), level))
79            .collect::<Vec<_>>();
80        self
81    }
82
83    pub const fn filter_level(&mut self, filter_level: LevelFilter) -> &mut Self {
84        self.level = filter_level;
85        self
86    }
87
88    pub fn log_out_dir<P>(&mut self, path: Option<P>) -> &mut Self
89    where
90        P: AsRef<Path>,
91    {
92        self.log_dir = path.map(|p| p.as_ref().to_path_buf());
93        self
94    }
95
96    /// Initializes logging for the application.
97    ///
98    /// # Panics
99    /// Will panic if logging is unable to be initialized.
100    pub fn init(&self) {
101        let home = get_env_var("HOME").expect("$HOME should be defined");
102        let log_dir = self.log_dir.as_ref().map_or_else(
103            || Path::new(home.as_str()).join(".cache/bluebuild"),
104            Clone::clone,
105        );
106
107        let mut lock = LOG_DIR.lock().expect("Should lock LOG_DIR");
108        lock.clone_from(&log_dir);
109        drop(lock);
110
111        let log_out_path = log_dir.join(Self::LOG_FILENAME);
112        let log_archive_pattern =
113            format!("{}/{}", log_dir.display(), Self::ARCHIVE_FILENAME_PATTERN);
114
115        let stderr = ConsoleAppender::builder()
116            .encoder(Box::new(
117                CustomPatternEncoder::builder()
118                    .filter_modules(self.modules.clone())
119                    .build(),
120            ))
121            .target(log4rs::append::console::Target::Stderr)
122            .tty_only(true)
123            .build();
124
125        let config =
126            Config::builder().appender(Appender::builder().build("stderr", Box::new(stderr)));
127        let mut root = Root::builder().appender("stderr");
128
129        let config = {
130            let file_appender = FixedWindowRoller::builder()
131                .build(&log_archive_pattern, Self::LOG_FILE_COUNT)
132                .and_then(|window_roller| {
133                    Ok(RollingFileAppender::builder()
134                        .encoder(Box::new(PatternEncoder::new("{d} - {l} - {m}{n}")))
135                        .build(
136                            log_out_path,
137                            Box::new(CompoundPolicy::new(
138                                Box::new(SizeTrigger::new(Self::TRIGGER_FILE_SIZE)),
139                                Box::new(window_roller),
140                            )),
141                        )?)
142                });
143            match file_appender {
144                Err(e) => {
145                    eprintln!("Cannot create logs directory:\n{e}");
146                    config
147                }
148                Ok(file_appender) => {
149                    root = root.appender("file");
150                    config.appender(Appender::builder().build("file", Box::new(file_appender)))
151                }
152            }
153            .build(root.build(self.level))
154            .expect("Logger config should build")
155        };
156
157        let logger = L4RSLogger::new(config);
158
159        LogWrapper::new(MULTI_PROGRESS.clone(), logger)
160            .try_init()
161            .expect("LogWrapper should initialize");
162    }
163
164    pub fn multi_progress() -> MultiProgress {
165        MULTI_PROGRESS.clone()
166    }
167}
168
169impl Default for Logger {
170    fn default() -> Self {
171        Self {
172            modules: vec![],
173            level: LevelFilter::Info,
174            log_dir: None,
175        }
176    }
177}
178
179trait ColoredLevel {
180    fn colored(&self) -> ColoredString;
181}
182
183impl ColoredLevel for Level {
184    fn colored(&self) -> ColoredString {
185        match self {
186            Self::Error => Self::Error.as_str().red(),
187            Self::Warn => Self::Warn.as_str().yellow(),
188            Self::Info => Self::Info.as_str().green(),
189            Self::Debug => Self::Debug.as_str().blue(),
190            Self::Trace => Self::Trace.as_str().cyan(),
191        }
192    }
193}
194
195pub trait CommandLogging: Private {
196    /// Prints each line of stdout/stderr with an image ref string
197    /// and a progress spinner while also logging the build output.
198    /// This helps to keep track of every build running in parallel.
199    ///
200    /// # Errors
201    /// Will error if there was an issue executing the process.
202    fn build_status<T, U>(self, image_ref: T, message: U) -> Result<ExitStatus>
203    where
204        T: AsRef<str>,
205        U: AsRef<str>;
206
207    /// Prints each line of stdout/stderr with a log header
208    /// and a progress spinner. This helps to keep track of every
209    /// command running in parallel.
210    ///
211    /// # Errors
212    /// Will error if there was an issue executing the process.
213    fn message_status<S, D>(self, header: S, message: D) -> Result<ExitStatus>
214    where
215        S: AsRef<str>,
216        D: Into<Cow<'static, str>>;
217}
218
219impl CommandLogging for Command {
220    fn build_status<T, U>(self, image_ref: T, message: U) -> Result<ExitStatus>
221    where
222        T: AsRef<str>,
223        U: AsRef<str>,
224    {
225        fn inner(mut command: Command, image_ref: &str, message: &str) -> Result<ExitStatus> {
226            let ansi_color = gen_random_ansi_color();
227            let name = color_str(image_ref, ansi_color);
228            let short_name = color_str(shorten_name(image_ref), ansi_color);
229            let (reader, writer) = os_pipe::pipe()?;
230
231            command
232                .stdout(writer.try_clone()?)
233                .stderr(writer)
234                .stdin(Stdio::piped());
235
236            let progress = Logger::multi_progress()
237                .add(ProgressBar::new_spinner().with_message(format!("{message} {name}")));
238            progress.enable_steady_tick(Duration::from_millis(100));
239
240            let mut child = command.spawn()?;
241
242            let child_pid = child.id();
243            add_pid(child_pid);
244
245            // We drop the `Command` to prevent blocking on writer
246            // https://docs.rs/os_pipe/latest/os_pipe/#examples
247            drop(command);
248
249            let reader = BufReader::new(reader);
250            let log_file_path = {
251                let lock = LOG_DIR.lock().expect("Should lock LOG_DIR");
252                lock.join(format!("{}.log", image_ref.replace(['/', ':', '.'], "_")))
253            };
254            let log_file = OpenOptions::new()
255                .create(true)
256                .append(true)
257                .open(log_file_path.as_path())?;
258
259            thread::spawn(move || {
260                let mp = Logger::multi_progress();
261                reader.lines().for_each(|line| {
262                    if let Ok(l) = line {
263                        let text =
264                            format!("{log_prefix} {l}", log_prefix = log_header(&short_name));
265                        if mp.is_hidden() {
266                            eprintln!("{text}");
267                        } else {
268                            mp.println(text).unwrap();
269                        }
270                        if let Err(e) = writeln!(&log_file, "{l}") {
271                            warn!(
272                                "Failed to write to log for build {}: {e:?}",
273                                log_file_path.display()
274                            );
275                        }
276                    }
277                });
278            });
279
280            let status = child.wait()?;
281            remove_pid(child_pid);
282
283            progress.finish();
284            Logger::multi_progress().remove(&progress);
285
286            Ok(status)
287        }
288        inner(self, image_ref.as_ref(), message.as_ref())
289    }
290
291    fn message_status<S, D>(self, header: S, message: D) -> Result<ExitStatus>
292    where
293        S: AsRef<str>,
294        D: Into<Cow<'static, str>>,
295    {
296        fn inner(
297            mut command: Command,
298            header: &str,
299            message: Cow<'static, str>,
300        ) -> Result<ExitStatus> {
301            let ansi_color = gen_random_ansi_color();
302            let header = color_str(header, ansi_color);
303            let (reader, writer) = os_pipe::pipe()?;
304
305            command
306                .stdout(writer.try_clone()?)
307                .stderr(writer)
308                .stdin(Stdio::piped());
309
310            let progress =
311                Logger::multi_progress().add(ProgressBar::new_spinner().with_message(message));
312            progress.enable_steady_tick(Duration::from_millis(100));
313
314            let mut child = command.spawn()?;
315
316            let child_pid = child.id();
317            add_pid(child_pid);
318
319            // We drop the `Command` to prevent blocking on writer
320            // https://docs.rs/os_pipe/latest/os_pipe/#examples
321            drop(command);
322
323            let reader = BufReader::new(reader);
324
325            thread::spawn(move || {
326                let mp = Logger::multi_progress();
327                reader.lines().for_each(|line| {
328                    if let Ok(l) = line {
329                        let text = format!("{log_prefix} {l}", log_prefix = log_header(&header));
330                        if mp.is_hidden() {
331                            eprintln!("{text}");
332                        } else {
333                            mp.println(text).unwrap();
334                        }
335                    }
336                });
337            });
338
339            let status = child.wait()?;
340            remove_pid(child_pid);
341
342            progress.finish();
343            Logger::multi_progress().remove(&progress);
344
345            Ok(status)
346        }
347        inner(self, header.as_ref(), message.into())
348    }
349}
350
351#[derive(Debug, Builder)]
352struct CustomPatternEncoder {
353    #[builder(default, into)]
354    filter_modules: Vec<(String, LevelFilter)>,
355}
356
357impl Encode for CustomPatternEncoder {
358    fn encode(&self, w: &mut dyn Write, record: &Record) -> anyhow::Result<()> {
359        if record.module_path().is_some_and(|mp| {
360            self.filter_modules
361                .iter()
362                .any(|(module, level)| mp.contains(module) && *level <= record.level())
363        }) {
364            Ok(())
365        } else {
366            match log::max_level() {
367                LevelFilter::Error | LevelFilter::Warn | LevelFilter::Info => Ok(writeln!(
368                    w,
369                    "{prefix} {args}",
370                    prefix = log_header(format!(
371                        "{level:width$}",
372                        level = record.level().colored(),
373                        width = 5,
374                    )),
375                    args = record.args(),
376                )?),
377                LevelFilter::Debug => Ok(writeln!(
378                    w,
379                    "{prefix} {args}",
380                    prefix = log_header(format!(
381                        "{level:>width$}",
382                        level = record.level().colored(),
383                        width = 5,
384                    )),
385                    args = record.args(),
386                )?),
387                LevelFilter::Trace => Ok(writeln!(
388                    w,
389                    "{prefix} {args}",
390                    prefix = log_header(format!(
391                        "{level:width$} {module}:{line}",
392                        level = record.level().colored(),
393                        width = 5,
394                        module = record.module_path().unwrap_or("").bright_yellow(),
395                        line = record
396                            .line()
397                            .map_or_else(String::new, |l| l.to_string())
398                            .bright_green(),
399                    )),
400                    args = record.args(),
401                )?),
402                LevelFilter::Off => Ok(()),
403            }
404        }
405    }
406}
407
408/// Used to keep the style of logs consistent between
409/// normal log use and command output.
410fn log_header<T>(text: T) -> String
411where
412    T: AsRef<str>,
413{
414    fn inner(text: &str) -> String {
415        match log::max_level() {
416            LevelFilter::Error | LevelFilter::Warn | LevelFilter::Info => {
417                format!("{text} {sep}", sep = "=>".bold())
418            }
419            LevelFilter::Debug | LevelFilter::Trace => format!(
420                "[{time} {text}] {sep}",
421                time = Local::now().format("%H:%M:%S"),
422                sep = "=>".bold(),
423            ),
424            LevelFilter::Off => String::new(),
425        }
426    }
427    inner(text.as_ref())
428}
429
430/// Shortens the image name so that it won't take up the
431/// entire width of the terminal. This is a similar format
432/// to what Earthly does in their terminal output for long
433/// images on their log prefix output.
434///
435/// # Examples
436/// `ghcr.io/blue-build/cli:latest` -> `g.i/b/cli:latest`
437/// `registry.gitlab.com/some/namespace/image:latest` -> `r.g.c/s/n/image:latest`
438#[must_use]
439fn shorten_name<T>(text: T) -> String
440where
441    T: AsRef<str>,
442{
443    let text = text.as_ref();
444
445    // Split the reference by colon to separate the tag or digest
446    let mut parts = text.split(':');
447
448    let Some(path) = parts.next() else {
449        return text.to_string();
450    };
451    let tag = parts.next();
452
453    // Split the path by slash to work on each part
454    let path_parts: Vec<&str> = path.split('/').collect();
455
456    // Shorten each part except the last one to their initial letters
457    let shortened_parts: Vec<String> = path_parts
458        .iter()
459        .enumerate()
460        .map(|(i, part)| {
461            if i < path_parts.len() - 1 {
462                // Split on '.' and shorten each section
463                part.split('.')
464                    .filter_map(|p| p.chars().next())
465                    .map(|c| c.to_string())
466                    .collect::<Vec<String>>()
467                    .join(".")
468            } else {
469                (*part).into() // Keep the last part as it is
470            }
471        })
472        .collect();
473
474    // Rejoin the parts with '/'
475    let joined_path = shortened_parts.join("/");
476
477    // If there was a tag, append it back with ':', otherwise just return the path
478    match tag {
479        Some(t) => format!("{joined_path}:{t}"),
480        None => joined_path,
481    }
482}
483
484// ANSI extended color range:
485// https://www.ditig.com/publications/256-colors-cheat-sheet
486//
487// The following ANSI color codes are exactly the color codes that have a contrast ratio of
488// at least 4.0 on both white and black backgrounds, as defined by WCAG 2.2:
489// https://www.w3.org/TR/WCAG22/#dfn-contrast-ratio
490// This ensures that the colors are legible in both light and dark mode.
491// (WCAG 2.2 requires a contrast ratio of 4.5 for accessibility, but there are too few colors
492// that meet that requirement on both white and black backgrounds.)
493const MID_COLORS: [u8; 22] = [
494    27, 28, 29, 30, 31, 62, 63, 64, 65, 96, 97, 98, 99, 129, 130, 131, 132, 133, 161, 162, 163, 164,
495];
496
497/// Generate random ANSI colors that are legible on both light and dark backgrounds.
498///
499/// More precisely, all generated colors have a contrast ratio of at least 4.0 (as defined by
500/// WCAG 2.2) on both white and black backgrounds.
501///
502/// This function internally keeps track of state and will cycle through all such colors in a
503/// random order before repeating colors.
504#[must_use]
505pub fn gen_random_ansi_color() -> u8 {
506    static SHUFFLED_COLORS: LazyLock<[u8; MID_COLORS.len()]> = LazyLock::new(|| {
507        let mut colors = MID_COLORS;
508        colors.shuffle(&mut rand::rng());
509        colors
510    });
511    static COUNTER: AtomicUsize = AtomicUsize::new(0);
512    let index = COUNTER.fetch_add(1, Ordering::Relaxed) % MID_COLORS.len();
513    SHUFFLED_COLORS[index]
514}
515
516pub fn color_str<T>(text: T, ansi_color: u8) -> String
517where
518    T: AsRef<str>,
519{
520    if ShouldColorize::from_env().should_colorize() {
521        Color::Fixed(ansi_color)
522            .paint(text.as_ref().to_string())
523            .to_string()
524    } else {
525        text.as_ref().to_string()
526    }
527}