rm-lisa 0.3.2

A logging library for rem-verse, with support for inputs, tasks, and more.
Documentation
//! A 'history' provider, providing history for commands that were previously
//! run.
//!
//! Although lisa comes with it's own built in history provider that acts like
//! bash history (e.g. stored in a file for persistance, but updated in memory
//! til closing time), you can of course specify you're own history provider
//! that reads command history from anything you want.
//!
//! However, your history provider should be fast as if provided it can delay
//! user input from rendering which may cause a suboptimal user experience.

use crate::errors::LisaError;
use parking_lot::RwLock;
use std::{
	collections::VecDeque,
	fs::{File, OpenOptions},
	io::{BufRead, BufReader, Write},
	path::Path,
};

/// An implementation of a provider of a history of commands.
///
/// Not all input providers can accept history providers, and you should look
/// at your input provider to see if it supports history.
pub trait HistoryProvider: Send + Sync {
	/// Get a previous command from this history.
	///
	/// ## Parameters
	///
	/// - `command_back` is a value of `1..N` for how far back/forward a user
	///   wants a command from.
	#[must_use]
	fn get_previous_command(&self, command_back: usize) -> Option<String>;

	/// Complete a command given the input so far.
	fn complete_command(&self, input_so_far: &str) -> Option<String>;

	/// Insert a command into history.
	fn insert_command(&self, new_command: &str);

	/// Attempt to perform a full sync of history.
	///
	/// This is usually called right before shutdown, but can be called at any
	/// time by any input provider.
	fn attempt_to_do_full_sync(&self);
}

/// A simple history provider that just keeps values in memory.
///
/// It can also optionally synchronize to a file, in this way it acts a lot
/// like your standard bash history file.
#[derive(Debug)]
pub struct SimpleHistoryProvider {
	/// The backing file to write the history too.
	backing_file: RwLock<Option<File>>,
	/// A list of commands that have been run in the past.
	history: RwLock<VecDeque<String>>,
	/// The max history size.
	histsize: usize,
}

impl SimpleHistoryProvider {
	/// An in-memory history provider.
	#[must_use]
	pub fn new_in_memory(histsize: usize) -> Self {
		Self {
			backing_file: RwLock::new(None),
			history: RwLock::new(VecDeque::with_capacity(histsize)),
			histsize,
		}
	}

	/// Create a simple history provider that is backed by a file.
	///
	/// This creates a simple history provider similar to a bash history file.
	///
	/// ## Errors
	///
	/// If we cannot open/create the file path you've specified.
	pub fn new_file_backed<PathTy: AsRef<Path>>(
		histsize: usize,
		history_path: PathTy,
	) -> Result<Self, LisaError> {
		let path = history_path.as_ref();

		if path.is_file() {
			let fd = OpenOptions::new().write(true).read(true).open(path)?;
			let mut history = VecDeque::with_capacity(histsize);

			{
				let reader = BufReader::new(&fd);
				for line in reader.lines().collect::<Vec<_>>().into_iter().rev() {
					if history.len() >= histsize {
						break;
					}
					let Ok(full_line) = line else {
						continue;
					};
					if full_line.is_empty() {
						continue;
					}

					history.push_back(full_line);
				}
			}

			Ok(Self {
				backing_file: RwLock::new(Some(fd)),
				history: RwLock::new(history),
				histsize,
			})
		} else {
			let fd = File::create_new(path)?;

			Ok(Self {
				backing_file: RwLock::new(Some(fd)),
				history: RwLock::new(VecDeque::with_capacity(histsize)),
				histsize,
			})
		}
	}
}

impl HistoryProvider for SimpleHistoryProvider {
	fn attempt_to_do_full_sync(&self) {
		let mut backing_file_opt = self.backing_file.write();
		if let Some(fd) = backing_file_opt.as_mut() {
			let to_write = {
				let read_locked = self.history.read();
				read_locked
					.iter()
					.map(|data| data.replace('\n', "    "))
					.collect::<Vec<String>>()
					.join("\n")
			};

			// Truncate, to ensure the file doesn't grow.
			_ = fd.set_len(0);
			_ = fd.write_all(to_write.as_bytes());
			_ = fd.sync_data();
		}
	}

	fn get_previous_command(&self, command_back: usize) -> Option<String> {
		let guard = self.history.read();

		guard
			.iter()
			.rev()
			.nth(command_back - 1)
			.map(ToOwned::to_owned)
	}

	fn complete_command(&self, input_so_far: &str) -> Option<String> {
		let guard = self.history.read();

		for command in guard.iter().rev() {
			if command.len() > input_so_far.len() && command.starts_with(input_so_far) {
				return Some((command[input_so_far.len()..]).to_owned());
			}
		}

		None
	}

	fn insert_command(&self, new_command: &str) {
		let mut guard = self.history.write();
		if guard.len() >= self.histsize {
			guard.pop_front();
		}
		guard.push_back(new_command.to_owned());
	}
}

#[cfg(test)]
mod unit_tests {
	use super::*;
	use std::path::PathBuf;
	use tempfile::tempdir;

	#[test]
	pub fn in_memory_history_provider() {
		let mut memory_provider = SimpleHistoryProvider::new_in_memory(1);
		memory_provider.insert_command("hi :)");
		// Sync does nothing but call it for coverage to ensure it doesn't panic or anything.
		memory_provider.attempt_to_do_full_sync();

		memory_provider = SimpleHistoryProvider::new_in_memory(1);
		// Validate we didn't load anywhere.
		assert!(memory_provider.get_previous_command(1).is_none());
		memory_provider.insert_command("hello :)");
		assert_eq!(
			memory_provider.get_previous_command(1),
			Some("hello :)".to_owned()),
		);
		assert_eq!(memory_provider.get_previous_command(2), None);
		memory_provider.insert_command("new");
		assert_eq!(
			memory_provider.get_previous_command(1),
			Some("new".to_owned()),
		);
		assert_eq!(memory_provider.get_previous_command(2), None);
	}

	#[test]
	pub fn file_backed_history_provider() {
		let tempdir_guard = tempdir().expect("failed to create temporary directory for test!");
		let mut history_path = PathBuf::from(&tempdir_guard.path());
		history_path.push("example-history-file");

		// Start small
		{
			let file_provider_smol =
				SimpleHistoryProvider::new_file_backed(1, history_path.clone())
					.expect("Failed to create file backed provider with empty file!");
			file_provider_smol.insert_command("hello");
			assert_eq!(
				file_provider_smol.get_previous_command(1),
				Some("hello".to_owned()),
			);
			file_provider_smol.insert_command("hi");
			assert_eq!(
				file_provider_smol.get_previous_command(1),
				Some("hi".to_owned()),
			);
			assert_eq!(file_provider_smol.get_previous_command(2), None);
			file_provider_smol.attempt_to_do_full_sync();
		}

		// Load existing.
		{
			let file_provider_lorg =
				SimpleHistoryProvider::new_file_backed(2, history_path.clone())
					.expect("Failed to create file backed provider from existing file!");
			assert_eq!(
				file_provider_lorg.get_previous_command(1),
				Some("hi".to_owned())
			);

			file_provider_lorg.insert_command("other");
			assert_eq!(
				file_provider_lorg.get_previous_command(1),
				Some("other".to_owned()),
			);
			assert_eq!(
				file_provider_lorg.get_previous_command(2),
				Some("hi".to_owned()),
			);

			file_provider_lorg.insert_command("even newer");
			assert_eq!(
				file_provider_lorg.get_previous_command(1),
				Some("even newer".to_owned()),
			);
			assert_eq!(
				file_provider_lorg.get_previous_command(2),
				Some("other".to_owned()),
			);
			file_provider_lorg.attempt_to_do_full_sync();
		}

		// Now load it back again but smaller.
		{
			let file_provider_smol = SimpleHistoryProvider::new_file_backed(1, history_path)
				.expect("Failed to create file backed provider from existing large file!");
			assert_eq!(
				file_provider_smol.get_previous_command(1),
				Some("even newer".to_owned()),
			);
			assert_eq!(file_provider_smol.get_previous_command(2), None);
			file_provider_smol.insert_command("final");
			assert_eq!(
				file_provider_smol.get_previous_command(1),
				Some("final".to_owned()),
			);
			assert_eq!(file_provider_smol.get_previous_command(2), None);
		}
	}

	#[test]
	pub fn complete_command_examples() {
		let memory_provider = SimpleHistoryProvider::new_in_memory(3);
		memory_provider.insert_command("test small");
		memory_provider.insert_command("test large");
		memory_provider.insert_command("other");

		// Complete should always return the first match that starts with.
		assert_eq!(
			memory_provider.complete_command("test"),
			Some(" large".to_owned()),
		);
		assert_eq!(
			memory_provider.complete_command("test s"),
			Some("mall".to_owned()),
		);
		assert_eq!(
			memory_provider.complete_command("oth"),
			Some("er".to_owned()),
		);
		assert_eq!(memory_provider.complete_command("no"), None);
	}
}