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 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 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 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 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 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
408fn 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#[must_use]
439fn shorten_name<T>(text: T) -> String
440where
441 T: AsRef<str>,
442{
443 let text = text.as_ref();
444
445 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 let path_parts: Vec<&str> = path.split('/').collect();
455
456 let shortened_parts: Vec<String> = path_parts
458 .iter()
459 .enumerate()
460 .map(|(i, part)| {
461 if i < path_parts.len() - 1 {
462 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() }
471 })
472 .collect();
473
474 let joined_path = shortened_parts.join("/");
476
477 match tag {
479 Some(t) => format!("{joined_path}:{t}"),
480 None => joined_path,
481 }
482}
483
484const 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#[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}