rm-lisa 0.5.2

A logging library for rem-verse, with support for inputs, tasks, and more.
Documentation
//! A color "renderer" that renders using fancy ASCII codes.

use crate::{
	display::{
		renderers::{
			ConsoleOutputFeatures, ConsoleRenderer,
			color::{
				fields::{create_combined_message, create_field_tailers},
				helpers::{
					EMPTY_HEADER, calculate_message_width, calculate_tailer_width,
					chunk_string_into_width, create_header, erase_line, format_task_duration,
					move_cursor, pad_to_width,
				},
				terminal::TerminalState,
			},
		},
		tracing::{FlattenedTracingField, SuperConsoleLogMessage},
	},
	errors::LisaError,
	input::{InputProvider, TerminalInputEvent},
	tasks::{GloballyUniqueTaskId, LisaTaskStatus, TaskEvent},
};
use chrono::{DateTime, Utc};
use fnv::FnvHashMap;
use owo_colors::{OwoColorize, Style};
use parking_lot::Mutex;
use std::{
	env::var as env_var,
	fmt::Write,
	hash::BuildHasherDefault,
	sync::atomic::{AtomicBool, Ordering},
};
use tracing::Level;

mod fields;
mod helpers;
mod terminal;

/// A structure that renders using ANSI color codes, and is optimized for
/// humans who are not visually impaired.
#[derive(Debug)]
pub struct ColorConsoleRenderer {
	/// If a user has manually requested that log output be paused.
	force_pause: AtomicBool,
	/// A new PS1 that needs to be loaded (isn't immediate).
	new_ps1: Mutex<Option<String>>,
	/// The current terminal state.
	state: Mutex<TerminalState>,
	/// The amount of task lines that were last rendered.
	task_lines_rendered: Mutex<u16>,
}

impl ColorConsoleRenderer {
	/// Create a new color'd console renderer.
	#[must_use]
	pub fn new() -> Self {
		Self {
			force_pause: AtomicBool::new(false),
			new_ps1: Mutex::new(None),
			state: Mutex::new(TerminalState::new(Self::default_ps1_impl())),
			task_lines_rendered: Mutex::new(0),
		}
	}

	/// The actual default PS1 implementation.
	///
	/// Used by various pieces within the color renderer.
	#[must_use]
	fn default_ps1_impl() -> String {
		"$ ".to_owned()
	}
}

impl Default for ColorConsoleRenderer {
	fn default() -> Self {
		Self::new()
	}
}

impl ConsoleRenderer for ColorConsoleRenderer {
	fn should_use_renderer(
		&self,
		stream_features: &dyn ConsoleOutputFeatures,
		environment_prefix: &str,
	) -> bool {
		// If someone has explicitly specificed a log format, ignore all else.
		if let Ok(explicit_renderer) = env_var(format!("{environment_prefix}_LOG_FORMAT")) {
			return explicit_renderer.trim().eq_ignore_ascii_case("color");
		}

		// Now check for `NO_COLOR`/`NOCOLOR`/`CLICOLOR`
		for no_color_var in ["NO_COLOR", "NOCOLOR"] {
			if env_var(no_color_var).as_deref() == Ok("1") {
				return false;
			}
		}
		for color_var in ["CLICOLOR", "CLI_COLOR", "CLICOLOR_FORCE"] {
			let env = env_var(color_var);
			if env.as_deref() == Ok("0") {
				return false;
			}
			if env.as_deref() == Ok("1") {
				return true;
			}
		}

		if !stream_features.is_atty() {
			return false;
		}
		if !stream_features.enable_ansi() {
			return false;
		}

		true
	}

	fn render_message(
		&self,
		app_name: &'static str,
		log: SuperConsoleLogMessage,
		term_width: u16,
	) -> Result<String, LisaError> {
		let mut data = String::new();

		let header = create_header(app_name, &log)?;
		let tailer_width = calculate_tailer_width(term_width);
		let msg_width = calculate_message_width(term_width);

		let (real_msg, skip_cause) =
			if *log.level() == Level::ERROR && log.metadata().contains_key("cause") {
				let mut new_message = log
					.message()
					.map_or("<no message>".to_owned(), ToOwned::to_owned);
				new_message.push('\n');
				write!(
					&mut new_message,
					"{}",
					log.metadata()
						.get("cause")
						.unwrap_or_else(|| unreachable!())
				)?;
				(Some(new_message), true)
			} else {
				(log.message().map(ToOwned::to_owned).clone(), false)
			};

		// If we have too many fields it will be very awkward to render them in the
		// gutter... instead render them as part of the main message body. Similar
		// to how tracing works.
		let excessive_field_count = if skip_cause { 4 } else { 3 };
		let actual_field_count: usize = log
			.metadata()
			.values()
			.map(FlattenedTracingField::field_count)
			.sum();
		if actual_field_count > excessive_field_count || log.force_combine() {
			let messages = create_combined_message(
				msg_width + tailer_width,
				log.metadata(),
				real_msg.unwrap_or("<no message>".to_owned()),
				log.should_hide_fields_for_humans(),
				skip_cause,
			);

			for (idx, msg) in messages.into_iter().enumerate() {
				write!(
					&mut data,
					"{}{}",
					if idx == 0 {
						header.as_str()
					} else {
						EMPTY_HEADER
					},
					msg,
				)?;
				writeln!(&mut data)?;
			}
		} else {
			let empty_map = FnvHashMap::with_capacity_and_hasher(0, BuildHasherDefault::default());
			let mut fields = create_field_tailers(
				tailer_width,
				if log.should_hide_fields_for_humans() {
					&empty_map
				} else {
					log.metadata()
				},
				skip_cause,
				false,
			);
			let mut messages =
				chunk_string_into_width(msg_width, &real_msg.unwrap_or("<no message>".to_owned()));

			while fields.len() < messages.len() {
				fields.push(pad_to_width("|".to_owned(), tailer_width));
			}
			while messages.len() < fields.len() {
				messages.push(pad_to_width(String::new(), msg_width));
			}

			for idx in 0..fields.len() {
				write!(
					&mut data,
					"{}{}{}",
					if idx == 0 {
						header.as_str()
					} else {
						EMPTY_HEADER
					},
					messages[idx],
					fields[idx],
				)?;
				writeln!(&mut data)?;
			}
		}

		Ok(data)
	}

	fn default_ps1(&self) -> String {
		Self::default_ps1_impl()
	}

	fn supports_ansi(&self) -> bool {
		true
	}

	fn should_pause_log_events(&self, _provider: &dyn InputProvider) -> bool {
		self.force_pause.load(Ordering::Acquire)
	}

	fn render_input(
		&self,
		_app_name: &'static str,
		provider: &dyn InputProvider,
		term_width: u16,
	) -> Result<String, LisaError> {
		let msg_width = calculate_message_width(term_width);
		let tailer_width = calculate_tailer_width(term_width);

		let mut state_lock = self.state.lock();
		let new_ps1_lock = self.new_ps1.lock();
		Ok(state_lock.render_current_standalone(
			new_ps1_lock.as_deref(),
			msg_width,
			tailer_width,
			&provider.current_input(),
		))
	}

	fn clear_input(&self, _term_width: u16) -> String {
		let state_lock = self.state.lock();
		state_lock.clear_current_render()
	}

	fn clear_task_list(&self, _task_list_size: usize) -> String {
		let mut data = String::new();

		let task_lines_lock = self.task_lines_rendered.lock();
		for _ in 0..*task_lines_lock {
			if data.is_empty() {
				data = move_cursor(helpers::CursorDirection::Left, 9999);
			} else {
				data.push_str(&move_cursor(helpers::CursorDirection::Up, 1));
			}
			data.push_str(&erase_line(helpers::ClearLine::EntireLine));
		}
		std::mem::drop(task_lines_lock);

		data
	}

	fn rerender_tasks(
		&self,
		_new_task_events: &[TaskEvent],
		current_task_states: &FnvHashMap<
			GloballyUniqueTaskId,
			(DateTime<Utc>, String, LisaTaskStatus),
		>,
		tasks_running_since: Option<DateTime<Utc>>,
		term_height: u16,
	) -> Result<String, LisaError> {
		let Some(running_since) = tasks_running_since else {
			*self.task_lines_rendered.lock() = 0;
			return Ok(String::new());
		};
		if current_task_states.is_empty() {
			*self.task_lines_rendered.lock() = 0;
			return Ok(String::new());
		}

		let mut task_lines_lock = self.task_lines_rendered.lock();
		let max_tasks_rendered = usize::from(term_height / 3).max(1);
		*task_lines_lock = 2;
		let my_time = Utc::now();
		let mut data = String::new();
		data.push('[');
		write!(&mut data, "{}", '+'.bright_green())?;
		data.push_str("] Running... ");

		writeln!(
			&mut data,
			"{}",
			format_task_duration(my_time.signed_duration_since(running_since)),
		)?;

		let mut keys_to_sort = current_task_states.keys().collect::<Vec<_>>();
		keys_to_sort.sort_by(|one, two| {
			let first_comp = one.0.cmp(&two.0);
			let second_comp = one.1.cmp(&two.1);

			if second_comp == std::cmp::Ordering::Equal {
				first_comp
			} else {
				second_comp
			}
		});

		let mut tasks_rendered = 0_usize;
		for key in keys_to_sort {
			if tasks_rendered >= max_tasks_rendered {
				break;
			}
			tasks_rendered += 1;
			*task_lines_lock += 1;
			let (task_start_time, task_name, status) =
				current_task_states.get(key).expect("Guaranteed to exist!");

			let style = match status {
				LisaTaskStatus::Inactive => Style::new().white().bold(),
				LisaTaskStatus::Running(_) => Style::new().cyan().italic(),
				LisaTaskStatus::Waiting(_) => Style::new().yellow().italic(),
			};
			writeln!(
				&mut data,
				"{}",
				format!(
					"  | => {}/{}|{task_name}: {status} [{}...]",
					key.0,
					key.1,
					format_task_duration(my_time.signed_duration_since(task_start_time)),
				)
				.style(style)
			)?;
		}

		let remaining_tasks = current_task_states.len().saturating_sub(tasks_rendered);
		if remaining_tasks > 0 {
			writeln!(&mut data, "  | => {remaining_tasks} tasks also running...")?;
			*task_lines_lock += 1;
		}
		std::mem::drop(task_lines_lock);

		Ok(data)
	}

	fn on_input(
		&self,
		event: TerminalInputEvent,
		provider: &dyn InputProvider,
	) -> Result<String, LisaError> {
		let mut state_lock = self.state.lock();
		Ok(state_lock.on_input_event(provider, event, &self.force_pause))
	}

	fn update_ps1(&self, new_ps1: String) {
		let mut new_ps1_lock = self.new_ps1.lock();
		_ = new_ps1_lock.insert(new_ps1);
	}
}