rm-lisa 0.3.2

A logging library for rem-verse, with support for inputs, tasks, and more.
Documentation
//! A JSON Object per line ideal for strucctured logging where you need to
//! parse, and slice/dice logs.

use crate::{
	display::{
		renderers::{ConsoleOutputFeatures, ConsoleRenderer, get_ansi_escape_code_regex},
		tracing::{FlattenedTracingField, SuperConsoleLogMessage},
	},
	errors::LisaError,
	input::{InputProvider, TerminalInputEvent},
	tasks::{GloballyUniqueTaskId, LisaTaskStatus, TaskEvent},
};
use chrono::{DateTime, Utc};
use fnv::FnvHashMap;
use parking_lot::RwLock;
use regex::Regex;
use serde_json::{Map, Number, Value as JSONValue};
use std::{
	borrow::Cow,
	env::var as env_var,
	sync::atomic::{AtomicBool, Ordering},
};
use valuable_serde::Serializable;

/// A simple JSON based console renderer.
#[derive(Debug)]
pub struct JSONConsoleRenderer {
	/// Regex for any ANSI escape codes...
	ansi_escapes: Regex,
	/// If a user has manually force requested a pause.
	force_pause: AtomicBool,
	/// The PS1 or text to render before a command.
	ps1: RwLock<String>,
}

impl JSONConsoleRenderer {
	/// Create a simple JSON console renderer.
	#[must_use]
	pub fn new() -> Self {
		Self {
			ansi_escapes: get_ansi_escape_code_regex(),
			force_pause: AtomicBool::new(false),
			ps1: RwLock::new("> ".to_owned()),
		}
	}
}

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

impl ConsoleRenderer for JSONConsoleRenderer {
	fn should_use_renderer(
		&self,
		_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("json");
		}

		// If we're not explicitly requested, don't enable...
		false
	}

	fn render_message(
		&self,
		_app_name: &'static str,
		log: SuperConsoleLogMessage,
		term_width: u16,
	) -> Result<String, LisaError> {
		let mut map = Map::with_capacity(log.metadata().len() + 1);

		// Insert `lisa` map with all our details.
		{
			let mut lisa_map = Map::with_capacity(8);
			lisa_map.insert(
				"at".to_owned(),
				Number::from_i128(i128::from(log.at().timestamp()))
					.map_or(JSONValue::Null, JSONValue::Number),
			);
			lisa_map.insert(
				"id".to_owned(),
				log.id()
					.map_or(JSONValue::Null, |dat| JSONValue::String(dat.to_owned())),
			);
			lisa_map.insert(
				"level".to_owned(),
				JSONValue::String(format!("{}", log.level())),
			);
			lisa_map.insert(
				"should_decorate".to_owned(),
				JSONValue::Bool(log.should_decorate()),
			);
			lisa_map.insert(
				"subsystem".to_owned(),
				log.subsytem()
					.map_or(JSONValue::Null, |dat| JSONValue::String(dat.to_owned())),
			);
			lisa_map.insert(
				"towards_stdout".to_owned(),
				JSONValue::Bool(log.towards_stdout()),
			);
			lisa_map.insert(
				"term_width".to_owned(),
				Number::from_u128(u128::from(term_width))
					.map_or(JSONValue::Null, JSONValue::Number),
			);
			lisa_map.insert(
				"color".to_owned(),
				log.color()
					.map_or(JSONValue::Null, |dat| JSONValue::String(dat.to_owned())),
			);
			map.insert("lisa".to_owned(), JSONValue::Object(lisa_map));
		}
		map.insert(
			"msg".to_owned(),
			log.message().map_or(JSONValue::Null, |str_value| {
				JSONValue::String(str_value.to_owned())
			}),
		);

		let mut metadata_map = Map::new();
		for (key, val) in log.metadata() {
			metadata_map.insert((*key).to_owned(), field_to_json(val));
		}
		map.insert("metadata".to_owned(), JSONValue::Object(metadata_map));
		let mut data = serde_json::to_string(&JSONValue::Object(map))?;
		data.push('\n');

		Ok(match self.ansi_escapes.replace_all(&data, "") {
			Cow::Borrowed(_) => data,
			Cow::Owned(owned) => owned,
		})
	}

	fn default_ps1(&self) -> String {
		"> ".to_owned()
	}

	fn update_ps1(&self, new_ps1: String) {
		let mut guarded = self.ps1.write();
		*guarded = new_ps1;
	}

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

	/// We actually pause rendering so we don't need to do any 'clear'-ing
	fn clear_input(&self, _term_width: u16) -> String {
		String::with_capacity(0)
	}

	/// We don't need to do any 'clear'-ing
	fn clear_task_list(&self, _task_list_size: usize) -> String {
		String::with_capacity(0)
	}

	/// Render a dynamic input line.
	///
	/// We actually don't render a dynamic input, as that'd require erasing
	/// things, and that can't be done in a TEXT mode, or supported in a braille
	/// display.
	///
	/// We actually only render when [`Self::on_input`] is called. As inputs are paused
	/// anyway clear/render is never called.
	///
	/// ## Errors
	///
	/// This function will never error.
	fn render_input(
		&self,
		_app_name: &'static str,
		_provider: &dyn InputProvider,
		_term_width: u16,
	) -> Result<String, LisaError> {
		Ok(String::with_capacity(0))
	}

	/// Re-render all the tasks as new events come in.
	///
	/// For this will just render any new events that come in between the last
	/// time we called it.
	fn rerender_tasks(
		&self,
		new_task_events: &[TaskEvent],
		_current_task_states: &FnvHashMap<
			GloballyUniqueTaskId,
			(DateTime<Utc>, String, LisaTaskStatus),
		>,
		_running_since: Option<DateTime<Utc>>,
		_term_height: u16,
	) -> Result<String, LisaError> {
		let mut data = String::new();

		for event in new_task_events {
			data.push_str(&serde_json::to_string(&match event {
				TaskEvent::TaskStart(thread_id, task_id, name, status) => {
					serde_json::json!({
						"lisa": {
							"id": "lisa::display::renderers::json::rerender_task::new_event"
						},
						"task": {
							"event": "started",
							"id": format!("{thread_id}/{task_id}"),
							"name": name,
							"status": Serializable::new(status),
						}
					})
				}
				TaskEvent::TaskStatusUpdate(thread_id, task_id, new_status) => {
					serde_json::json!({
						"lisa": {
							"id": "lisa::display::renderers::json::rerender_task::new_event"
						},
						"task": {
							"event": "status_update",
							"id": format!("{thread_id}/{task_id}"),
							"status": Serializable::new(new_status),
						}
					})
				}
				TaskEvent::TaskEnd(thread_id, task_id) => {
					serde_json::json!({
						"lisa": {
							"id": "lisa::display::renderers::json::rerender_task::new_event"
						},
						"task": {
							"event": "end",
							"id": format!("{thread_id}/{task_id}"),
						}
					})
				}
			})?);
			data.push('\n');
		}

		Ok(data)
	}

	/// Handle a user typing into the terminal.
	///
	/// This will simply render the actual input line, and characters as users
	/// type.
	///
	/// ## Errors
	///
	/// Never.
	fn on_input(
		&self,
		event: TerminalInputEvent,
		provider: &dyn InputProvider,
	) -> Result<String, LisaError> {
		match event {
			TerminalInputEvent::InputStarted => {
				let ps1_read = self.ps1.read();
				Ok(ps1_read.clone())
			}
			TerminalInputEvent::InputFinished => Ok("\n".to_owned()),
			TerminalInputEvent::InputAppend(character) => {
				let mut new = String::with_capacity(1);
				new.push(character);
				Ok(new)
			}
			TerminalInputEvent::InputMassAppend(data) => Ok(data),
			TerminalInputEvent::InputChanged(_) => {
				let ps1_read = self.ps1.read();
				let mut data = String::with_capacity(1 + ps1_read.len());
				data.push('\n');
				data.push_str(ps1_read.as_str());
				data.push_str(&provider.current_input());
				Ok(data)
			}
			TerminalInputEvent::InputCancelled => Ok("<CANCELLED>\n".to_owned()),
			TerminalInputEvent::ClearScreen => Ok(String::with_capacity(0)),
			TerminalInputEvent::CursorMoveLeft(_) | TerminalInputEvent::CursorMoveRight(_) => {
				Ok(String::with_capacity(0))
			}
			TerminalInputEvent::ToggleOutputPause => {
				self.force_pause.fetch_not(Ordering::Release);
				Ok(String::with_capacity(0))
			}
		}
	}

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

fn field_to_json(field: &FlattenedTracingField) -> JSONValue {
	match field {
		FlattenedTracingField::Null => JSONValue::Null,
		FlattenedTracingField::Boolean(value) => JSONValue::Bool(*value),
		FlattenedTracingField::Bytes(value) => JSONValue::String(format!("{value:02x?}")),
		FlattenedTracingField::Float(value) => {
			Number::from_f64(*value).map_or(JSONValue::Null, JSONValue::Number)
		}
		FlattenedTracingField::Int(value) => {
			Number::from_i128(i128::from(*value)).map_or(JSONValue::Null, JSONValue::Number)
		}
		FlattenedTracingField::IntLarge(value) => {
			Number::from_i128(*value).map_or(JSONValue::Null, JSONValue::Number)
		}
		FlattenedTracingField::UnsignedInt(value) => {
			Number::from_u128(u128::from(*value)).map_or(JSONValue::Null, JSONValue::Number)
		}
		FlattenedTracingField::UnsignedIntLarge(value) => {
			Number::from_u128(*value).map_or(JSONValue::Null, JSONValue::Number)
		}
		FlattenedTracingField::Str(value) => JSONValue::String(value.clone()),
		FlattenedTracingField::List(value) => {
			let mut items = Vec::with_capacity(value.len());
			for val in value {
				items.push(field_to_json(val));
			}
			JSONValue::Array(items)
		}
		FlattenedTracingField::Object(obj) => {
			let mut map = Map::new();
			for (key, value) in obj {
				map.insert((*key).clone(), field_to_json(value));
			}
			JSONValue::Object(map)
		}
	}
}