diff --git a/Cargo.lock b/Cargo.lock index 7f78fc30..0c689e65 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1,6 +1,6 @@ # This file is automatically @generated by Cargo. # It is not intended for manual editing. -version = 3 +version = 4 [[package]] name = "addr2line" @@ -582,9 +582,9 @@ checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" [[package]] name = "libc" -version = "0.2.161" +version = "0.2.169" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e9489c2807c139ffd9c1794f4af0ebe86a828db53ecdc7fea2111d0fed085d1" +checksum = "b5aba8db14291edd000dfcc4d620c7ebfb122c613afb886ca8803fa4e128a20a" [[package]] name = "linux-raw-sys" @@ -771,6 +771,7 @@ dependencies = [ "serde-xml-rs", "serde_json", "thiserror", + "tokio", "ureq", "which", ] @@ -1084,6 +1085,16 @@ version = "1.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3c5e1a9a646d36c3599cd173a41282daf47c44583ad367b8e6837255952e5c67" +[[package]] +name = "socket2" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c970269d99b64e60ec3bd6ad27270092a5394c4e309314b18ae3fe575695fbe8" +dependencies = [ + "libc", + "windows-sys 0.52.0", +] + [[package]] name = "spin" version = "0.9.8" @@ -1192,6 +1203,35 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" +[[package]] +name = "tokio" +version = "1.43.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d61fa4ffa3de412bfea335c6ecff681de2b609ba3c77ef3e00e521813a9ed9e" +dependencies = [ + "backtrace", + "bytes", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys 0.52.0", +] + +[[package]] +name = "tokio-macros" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e06d43f1345a3bcd39f6a56dbb7dcab2ba47e68e8ac134855e7e2bdbaf8cab8" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "tracing" version = "0.1.40" diff --git a/Cargo.toml b/Cargo.toml index 9711f937..c0a25c49 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -20,6 +20,7 @@ chrono = "0.4.38" ansi-to-tui = "6.0.0" which = "6.0.3" ureq = { version = "=3.0.0-rc2", features = ["rustls"] } +tokio = { version = "1.43.0", features = ["full"] } # The profile that 'cargo dist' will build with [profile.dist] diff --git a/src/app.rs b/src/app.rs index 88251882..2674c71e 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1,12 +1,11 @@ use crate::{ - log_on_error, + logger::Logger, ui::popup::{info_popup::InfoPopUp, PopUp}, }; use ansi_to_tui::IntoText; use color_eyre::eyre::bail; use config::Config; use cover_renderer::render_cover; -use logging::Logger; use patch_hub::lore::{ lore_api_client::BlockingLoreAPIClient, lore_session, @@ -28,7 +27,6 @@ use crate::utils; mod config; pub mod cover_renderer; -pub mod logging; pub mod patch_renderer; pub mod screens; @@ -54,6 +52,8 @@ pub struct App { /// Client to handle Lore API requests and responses pub lore_api_client: BlockingLoreAPIClient, pub popup: Option>, + /// The logger actor instance that the application will use when logging + pub logger: Logger, } impl App { @@ -65,7 +65,7 @@ impl App { /// # Returns /// /// `App` instance with loading configurations and app data. - pub fn new() -> color_eyre::Result { + pub async fn new(logger: Logger) -> color_eyre::Result { let config: Config = Config::build(); config.create_dirs(); @@ -83,9 +83,8 @@ impl App { let lore_api_client = BlockingLoreAPIClient::default(); // Initialize the logger before the app starts - Logger::init_log_file(&config)?; - Logger::info("patch-hub started"); - logging::garbage_collector::collect_garbage(&config); + logger.info("patch-hub started"); + logger.collect_garbage().await; Ok(App { current_screen: CurrentScreen::MailingListSelection, @@ -108,7 +107,8 @@ impl App { config, lore_api_client, popup: None, - }) + logger, + } } /// Initializes field [App::latest_patchsets], from currently selected @@ -163,15 +163,19 @@ impl App { screen => bail!(format!("Invalid screen passed as argument {screen:?}")), }; - let patchset_path: String = match log_on_error!(lore_session::download_patchset( - self.config.patchsets_cache_dir(), - &representative_patch, - )) { - Ok(result) => result, - Err(io_error) => bail!("{io_error}"), - }; + let patchset_path: String = + match self.logger.error_on_error(lore_session::download_patchset( + self.config.patchsets_cache_dir(), + &representative_patch, + )) { + Ok(result) => result, + Err(io_error) => bail!("{io_error}"), + }; - match log_on_error!(lore_session::split_patchset(&patchset_path)) { + match self + .logger + .error_on_error(lore_session::split_patchset(&patchset_path)) + { Ok(raw_patches) => { let mut patches_preview: Vec = Vec::new(); for raw_patch in &raw_patches { @@ -209,8 +213,10 @@ impl App { let rendered_cover = match render_cover(raw_cover, self.config.cover_renderer()) { Ok(render) => render, - Err(_) => { - Logger::error("Failed to render cover preview with external program"); + Err(e) => { + self.logger + .error("Failed to render cover preview with external program"); + self.logger.error(e); raw_cover.to_string() } }; @@ -218,10 +224,10 @@ impl App { let rendered_patch = match render_patch_preview(raw_patch, self.config.patch_renderer()) { Ok(render) => render, - Err(_) => { - Logger::error( - "Failed to render patch preview with external program", - ); + Err(e) => { + self.logger + .error("Failed to render patch preview with external program"); + self.logger.error(e); raw_patch.to_string() } }; @@ -395,30 +401,32 @@ impl App { let mut app_can_run = true; if !utils::binary_exists("b4") { - Logger::error("b4 is not installed, patchsets cannot be downloaded"); + self.logger + .error("b4 is not installed, patchsets cannot be downloaded"); app_can_run = false; } if !utils::binary_exists("git") { - Logger::warn("git is not installed, send-email won't work"); + self.logger + .warn("git is not installed, send-email won't work"); } match self.config.patch_renderer() { PatchRenderer::Bat => { if !utils::binary_exists("bat") { - Logger::warn("bat is not installed, patch rendering will fallback to default"); + self.logger + .warn("bat is not installed, patch rendering will fallback to default"); } } PatchRenderer::Delta => { if !utils::binary_exists("delta") { - Logger::warn( - "delta is not installed, patch rendering will fallback to default", - ); + self.logger + .warn("delta is not installed, patch rendering will fallback to default"); } } PatchRenderer::DiffSoFancy => { if !utils::binary_exists("diff-so-fancy") { - Logger::warn( + self.logger.warn( "diff-so-fancy is not installed, patch rendering will fallback to default", ); } diff --git a/src/app/cover_renderer.rs b/src/app/cover_renderer.rs index fc30f266..6a31f4de 100644 --- a/src/app/cover_renderer.rs +++ b/src/app/cover_renderer.rs @@ -4,10 +4,9 @@ use std::{ process::{Command, Stdio}, }; +use color_eyre::eyre::Context; use serde::{Deserialize, Serialize}; -use super::logging::Logger; - #[derive(Debug, Serialize, Deserialize, Clone, Copy, Default)] pub enum CoverRenderer { #[default] @@ -67,10 +66,7 @@ fn bat_cover_renderer(patch: &str) -> color_eyre::Result { .stdin(Stdio::piped()) .stdout(Stdio::piped()) .spawn() - .map_err(|e| { - Logger::error(format!("Failed to spawn bat for cover preview: {}", e)); - e - })?; + .context("Failed to spawn bat for cover preview")?; bat.stdin.as_mut().unwrap().write_all(patch.as_bytes())?; let output = bat.wait_with_output()?; diff --git a/src/app/logging/garbage_collector.rs b/src/app/logging/garbage_collector.rs deleted file mode 100644 index e06892b4..00000000 --- a/src/app/logging/garbage_collector.rs +++ /dev/null @@ -1,50 +0,0 @@ -//! Log Garbage Collector -//! -//! This module is responsible for cleaning up the log files. - -use crate::app::config::Config; - -use super::Logger; - -/// Collects the garbage from the logs directory. -/// Will check for log files `patch-hub_*.log` and remove them if they are older than the `max_log_age` in the config. -pub fn collect_garbage(config: &Config) { - if config.max_log_age() == 0 { - return; - } - - let now = std::time::SystemTime::now(); - let logs_path = config.logs_path(); - let Ok(logs) = std::fs::read_dir(logs_path) else { - Logger::error("Failed to read the logs directory during garbage collection"); - return; - }; - - for log in logs { - let Ok(log) = log else { - continue; - }; - let filename = log.file_name(); - - if !filename.to_string_lossy().ends_with(".log") - || !filename.to_string_lossy().starts_with("patch-hub_") - { - continue; - } - - let Ok(Ok(created_date)) = log.metadata().map(|meta| meta.created()) else { - continue; - }; - let Ok(age) = now.duration_since(created_date) else { - continue; - }; - let age = age.as_secs() / 60 / 60 / 24; - - if age as usize > config.max_log_age() && std::fs::remove_file(log.path()).is_err() { - Logger::warn(format!( - "Failed to remove the log file: {}", - log.path().to_string_lossy() - )); - } - } -} diff --git a/src/app/logging/log_on_error.rs b/src/app/logging/log_on_error.rs deleted file mode 100644 index b156c33d..00000000 --- a/src/app/logging/log_on_error.rs +++ /dev/null @@ -1,27 +0,0 @@ -#[macro_export] -macro_rules! log_on_error { - ($result:expr) => { - log_on_error!($crate::app::logging::LogLevel::Error, $result) - }; - ($level:expr, $result:expr) => { - match $result { - Ok(_) => $result, - Err(ref error) => { - let error_message = - format!("Error executing {:?}: {}", stringify!($result), &error); - match $level { - $crate::app::logging::LogLevel::Info => { - Logger::info(error_message); - } - $crate::app::logging::LogLevel::Warning => { - Logger::warn(error_message); - } - $crate::app::logging::LogLevel::Error => { - Logger::error(error_message); - } - } - $result - } - } - }; -} diff --git a/src/app/patch_renderer.rs b/src/app/patch_renderer.rs index 5a9efc41..67e5d667 100644 --- a/src/app/patch_renderer.rs +++ b/src/app/patch_renderer.rs @@ -4,11 +4,9 @@ use std::{ process::{Command, Stdio}, }; -use color_eyre::eyre::eyre; +use color_eyre::eyre::{eyre, Context}; use serde::{Deserialize, Serialize}; -use super::logging::Logger; - #[derive(Debug, Serialize, Deserialize, Clone, Copy, Default)] pub enum PatchRenderer { #[default] @@ -95,10 +93,7 @@ fn bat_patch_renderer(patch: &str) -> color_eyre::Result { .stdin(Stdio::piped()) .stdout(Stdio::piped()) .spawn() - .map_err(|e| { - Logger::error(format!("Failed to spawn bat for patch preview: {}", e)); - e - })?; + .context("Failed to spawn bat for patch preview")?; bat.stdin .as_mut() @@ -127,10 +122,7 @@ fn delta_patch_renderer(patch: &str) -> color_eyre::Result { .stdin(Stdio::piped()) .stdout(Stdio::piped()) .spawn() - .map_err(|e| { - Logger::error(format!("Failed to spawn delta for patch preview: {}", e)); - e - })?; + .context("Failed to spawn delta for patch preview")?; delta .stdin @@ -153,13 +145,7 @@ fn diff_so_fancy_renderer(patch: &str) -> color_eyre::Result { .stdin(Stdio::piped()) .stdout(Stdio::piped()) .spawn() - .map_err(|e| { - Logger::error(format!( - "Failed to spawn diff-so-fancy for patch preview: {}", - e - )); - e - })?; + .context("Failed to spawn diff-so-fancy for patch preview")?; dsf.stdin .as_mut() diff --git a/src/cli.rs b/src/cli.rs index 60e2859b..b90a6adf 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -4,10 +4,7 @@ use clap::Parser; use color_eyre::eyre::eyre; use ratatui::{prelude::Backend, Terminal}; -use crate::{ - app::{logging::Logger, App}, - utils, -}; +use crate::{app::App, logger::Logger, utils}; #[derive(Debug, Parser)] #[command(version, about)] @@ -25,9 +22,10 @@ impl Cli { &self, terminal: Terminal, app: &mut App, + logger: Logger, ) -> ControlFlow, Terminal> { if self.show_configs { - Logger::info("Printing current configurations"); + logger.info("Printing current configurations"); drop(terminal); if let Err(err) = utils::restore() { return ControlFlow::Break(Err(eyre!(err))); diff --git a/src/handler.rs b/src/handler.rs index 97597e58..4b161049 100644 --- a/src/handler.rs +++ b/src/handler.rs @@ -10,8 +10,9 @@ use std::{ }; use crate::{ - app::{logging::Logger, screens::CurrentScreen, App}, + app::{screens::CurrentScreen, App}, loading_screen, + logger::Logger, ui::draw_ui, }; @@ -102,12 +103,12 @@ where Ok(terminal) } -pub fn run_app(mut terminal: Terminal, mut app: App) -> color_eyre::Result<()> +pub fn run_app(mut terminal: Terminal, mut app: App, logger: Logger) -> color_eyre::Result<()> where B: Backend + Send + 'static, { if !app.check_external_deps() { - Logger::error("patch-hub cannot be executed because some dependencies are missing"); + logger.error("patch-hub cannot be executed because some dependencies are missing"); bail!("patch-hub cannot be executed because some dependencies are missing, check logs for more information"); } diff --git a/src/logger.rs b/src/logger.rs new file mode 100644 index 00000000..4352fc44 --- /dev/null +++ b/src/logger.rs @@ -0,0 +1,462 @@ +use std::{fmt::Display, path::PathBuf}; + +use color_eyre::eyre::Context; +use tokio::{ + fs::{self, File, OpenOptions}, + io::AsyncWriteExt, + sync::mpsc::Sender, + task::JoinHandle, +}; + +/// Describes the log level of a message +/// +/// This is used to determine the severity of a log message so the logger +/// handles it accordingly to the verbosity level. +/// +/// The levels severity are: `Info` < `Warning` < `Error` +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +pub enum LogLevel { + /// The lowest level, dedicated to regular information that is not really + /// important + Info, + /// Mid level, used to indicate when something went wrong but it's not + /// critical + Warning, + /// The highest level, used to indicate critical errors. But not enought to + /// crash the program + Error, +} + +impl Display for LogLevel { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + LogLevel::Info => write!(f, "INFO"), + LogLevel::Warning => write!(f, "WARN"), + LogLevel::Error => write!(f, "ERROR"), + } + } +} + +/// Describes a message to be logged +/// +/// Contains the message constent itself as a [`String`] and its [`LogLevel`] +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] +pub struct LogMessage { + level: LogLevel, + message: String, +} + +impl Display for LogMessage { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "[{}] {}", self.level, self.message) + } +} + +/// The Logger manages logging to [`stderr`] (log buffer) and a log file. +/// The messages are written to the log file immediatly, +/// but the messages to the `stderr` are written only after the TUI is closed, +/// so they are kept in memory. +/// +/// The logger also has a log level that can be set to filter the messages that +/// are written to the log file. +/// Only messages with a level equal or higher than the log level are written +/// to the log file. +/// +/// You're not supossed to use an instance of this directly, but use +/// [`Logger`] instead by calling [`spawn`] as soon as this struct is built. +/// +/// The expected flow is: +/// - Instantiate the logger with [`build`] +/// - Spawn the actor with [`spawn`] +/// - Log with [`info`], [`warn`] or [`error`] +/// - Flush the log buffer to the stderr and finish the logger with [`flush`] +/// +/// [`Config`]: super::config::Config +/// [`info`]: Logger::info +/// [`warn`]: Logger::warn +/// [`error`]: Logger::error +/// [`flush`]: Logger::flush +/// [`stderr`]: std::io::stderr +/// [`spawn`]: LoggerCore::spawn +/// [`build`]: LoggerCore::build +#[derive(Debug)] +pub struct LoggerCore { + log_dir: PathBuf, + log_file_path: PathBuf, + log_file: File, + latest_log_file: File, + logs_to_print: Vec, + print_level: LogLevel, // TODO: Add a log level configuration + max_age: usize, +} + +impl LoggerCore { + /// Creates a new logger instance. The parameters are the [dir] where the + /// log files will be stored, [level] of log messages, and [max_age] of the + /// log files in days. + /// + /// You're supposed to call [`spawn`] immediately after this method to + /// transform the logger instance into an actor. + /// + /// # Errors + /// + /// If either the latest log file or the log file cannot be created, an + /// error is returned. + /// + /// [`level`]: LogLevel + /// [`flush`]: LoggerTx::flush + /// [`spawn`]: Logger::spawn + pub async fn build(dir: &str, level: LogLevel, max_age: usize) -> color_eyre::Result { + let log_dir = PathBuf::from(dir); + let timestamp = chrono::Local::now().format("%Y%m%d-%H%M%S"); + let log_file_path = log_dir.join(format!("patch-hub-{}.log", timestamp)); + + let log_file = OpenOptions::new() + .append(true) + .create(true) + .open(&log_file_path) + .await + .context("While building the logger")?; + + let latest_log_file = OpenOptions::new() + .write(true) + .create(true) + .truncate(true) + .open(log_dir.join("latest.log")) + .await + .context("While building the logger")?; + + Ok(Self { + log_dir, + log_file_path, + log_file, + latest_log_file, + logs_to_print: Vec::new(), + print_level: level, + max_age, + }) + } + + /// Transforms the logger core instance into an actor. This method returns a + /// [`Logger`] and a [`JoinHandle`] that can be used to send commands to the + /// logger or await for it to finish (when a [`flush`] is performed, for + /// instance). + /// + /// The handling of the commandds received is done sequentially, so a + /// command is only processed once the previous one is finished. + /// + /// [`flush`]: Logger::flush + pub fn spawn(mut self) -> (Logger, JoinHandle<()>) { + let (tx, mut rx) = tokio::sync::mpsc::channel(100); + let handle = tokio::spawn(async move { + while let Some(command) = rx.recv().await { + match command { + Command::Log(msg) => { + self.log(msg).await; + } + Command::Flush => { + self.flush(); + rx.close(); + break; + } + Command::CollectGarbage => { + self.collect_garbage().await; + } + } + } + }); + + (Logger::Default(tx), handle) + } + + /// Given a [`LogMessage`] object, writes it to the current and latest log + /// files. If the message level is high enough, it is also stored in the log + /// buffer to be printed to [`stderr`] when a [`flush`] is performed. + /// + /// [`stderr`]: std::io::stderr + /// [`flush`]: Logger::flush + async fn log(&mut self, message: LogMessage) { + self.log_file + .write_all(format!("{}\n", &message).as_bytes()) + .await + .expect("Failed to write to the current log file"); + + self.log_file + .flush() + .await + .expect("Failed to flush the current log file"); + + self.latest_log_file + .write_all(format!("{}\n", &message).as_bytes()) + .await + .expect("Failed to write to the latest log file"); + + self.latest_log_file + .flush() + .await + .expect("Failed to flush the latest log file"); + + if message.level >= self.print_level { + self.logs_to_print.push(message); + } + } + + /// Writes the log messages to the [`stderr`] if their level is equal or + /// higher than the print level set in the logger. + /// + /// **The logger is destroyed after this method is called.** + /// + /// [`stderr`]: std::io::stderr + fn flush(self) { + for message in &self.logs_to_print { + eprintln!("{}", message); + } + + if !self.logs_to_print.is_empty() { + eprintln!("Check the full log file: {}", self.log_file_path.display()); + } + } + + /// Runs the garbage collector to delete old log files. + /// + /// A log file is a file in the [`log_dir`] and it will be deleted if its + /// older than [`max_age`] days. + /// + /// [`log_dir`]: LoggerCore::log_dir + /// [`max_age`]: LoggerCore::max_age + async fn collect_garbage(&mut self) { + if self.max_age == 0 { + return; + } + + let now = std::time::SystemTime::now(); + + let Ok(mut logs) = fs::read_dir(&self.log_dir).await else { + self.log(LogMessage { + level: LogLevel::Error, + message: "Failed to read the logs directory during garbage collection".into(), + }) + .await; + return; + }; + + loop { + let log = logs.next_entry().await; + let Ok(log) = log else { + continue; + }; + + let Some(log) = log else { + break; + }; + + let filename = log.file_name(); + + if !filename.to_string_lossy().ends_with(".log") + || !filename.to_string_lossy().starts_with("patch-hub_") + { + continue; + } + + let Ok(Ok(created_date)) = log.metadata().await.map(|meta| meta.created()) else { + continue; + }; + let Ok(age) = now.duration_since(created_date) else { + continue; + }; + let age = age.as_secs() / 60 / 60 / 24; + + if age as usize > self.max_age && std::fs::remove_file(log.path()).is_err() { + self.log(LogMessage { + message: format!( + "Failed to remove the log file: {}", + log.path().to_string_lossy() + ), + level: LogLevel::Warning, + }) + .await; + } + } + } +} + +/// The possible commands to be handled by the logger actor. They will be +/// executed synchronously in the same order that they were sent through the +/// channel +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Command { + /// Logs the payload message + Log(LogMessage), + /// Flushes the logger by closing the log file, printing critical errors to + /// the stdout and destroying the logger instance + Flush, + /// Runs the log garbage collector deleting old files according with the + /// configured in the logger + CollectGarbage, +} + +/// The transmitter that sends messages down to a logger actor. This is what +/// you're supossed to use accross the code to log messages, not LoggerCore. +/// Cloning it is cheap so do not feel afraid to pass it around. +/// +/// The transmitter is obtained by calling [`spawn`] on a [`LoggerCore`] +/// instance, consuming it and creating a dedicated task for it. Use the methods +/// of this struct to interact with the logger. +/// +/// The intended usage is: +/// - Instantiate the logger with [`LoggerCore::build`] +/// - Spawn the logger actor with [`LoggerCore::spawn`] +/// - Use the methods of this struct to log messages +/// - Use the method [`flush`] to print the log messages to [`stderr`] +/// and finish the logger +/// +/// [`spawn`]: LoggerCore::spawn +/// [`flush`]: Logger::flush +/// [`stderr`]: std::io::stderr +#[derive(Debug, Clone)] +pub enum Logger { + /// The default version (produced by [`LoggerCore::spawn`]) + Default(Sender), + /// The mock version of this logger which won't do nothing at all + #[allow(dead_code)] + Mock, +} + +impl From for Logger { + fn from(value: LoggerCore) -> Self { + value.spawn().0 + } +} + +impl Logger { + /// Helper to simplify the logging process. This method sends a + /// [`LogMessage`] to the logger. Will send the message in a new task so it + /// won't block the caller + /// + /// # Panics + /// If the logger was flushed + fn log(&self, message: String, level: LogLevel) { + let sender = match self { + Logger::Mock => return, + Logger::Default(sender) => sender.clone(), + }; + + tokio::spawn(async move { + sender + .send(Command::Log(LogMessage { + level, + message: message.to_string(), + })) + .await + .expect("Attemp to use logger after a flush"); + }); + } + + /// Log a message with the `INFO` level + /// + /// # Panics + /// If the logger was flushed + pub fn info(&self, message: M) { + self.log(message.to_string(), LogLevel::Info); + } + + /// Log a message with the `WARNING` level + /// + /// # Panics + /// If the logger was flushed + pub fn warn(&self, message: M) { + self.log(message.to_string(), LogLevel::Warning); + } + + /// Log a message with the `ERROR` level + pub fn error(&self, message: M) { + self.log(message.to_string(), LogLevel::Error); + } + + /// Log an info message if the result is an error + /// and return the result as is + /// + /// # Panics + /// If the logger was flushed + #[allow(dead_code)] + pub fn info_on_error(&self, result: Result) -> Result { + match result { + Ok(value) => Ok(value), + Err(err) => { + self.log(err.to_string(), LogLevel::Info); + Err(err) + } + } + } + + /// Log an warning message if the result is an error + /// and return the result as is + /// + /// # Panics + /// If the logger was flushed + #[allow(dead_code)] + pub fn warn_on_error(&self, result: Result) -> Result { + match result { + Ok(value) => Ok(value), + Err(err) => { + self.log(err.to_string(), LogLevel::Warning); + Err(err) + } + } + } + + /// Log an error message if the result is an error + /// and return the result as is + /// + /// # Panics + /// If the logger was flushed + pub fn error_on_error(&self, result: Result) -> Result { + match result { + Ok(value) => Ok(value), + Err(err) => { + self.log(err.to_string(), LogLevel::Error); + Err(err) + } + } + } + + /// Flushes the logger by printing its messages to [`stderr`] and closing + /// the log file. After this method is called, the logger is destroyed and + /// any attempt to use it will panic. + /// + /// # Panics + /// If called twice + /// + /// [`stderr`]: std::io::stderr + pub fn flush(self) -> JoinHandle<()> { + let Self::Default(sender) = self else { + return tokio::spawn(async {}); + }; + + tokio::spawn(async move { + sender + .send(Command::Flush) + .await + .expect("Flushing a logger twice"); + }) + } + + /// Collects the garbage from the logs directory. Garbage logs are the ones + /// older than the [`max_age`] set during the logger [`build`]. + /// + /// # Panics + /// If called after a flush + /// + /// [`build`]: Logger::build + /// [`max_age`]: Logger::max_age + pub async fn collect_garbage(&self) { + let Self::Default(sender) = self else { + return; + }; + + sender + .send(Command::CollectGarbage) + .await + .expect("Attemp to use logger after a flush") + } +} diff --git a/src/main.rs b/src/main.rs index d738c5bf..0d181916 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,34 +1,38 @@ use std::ops::ControlFlow; use crate::app::App; -use app::logging::Logger; use clap::Parser; use cli::Cli; use handler::run_app; +use logger::{LogLevel, LoggerCore}; mod app; mod cli; mod handler; +mod logger; mod ui; mod utils; -fn main() -> color_eyre::Result<()> { +#[tokio::main] +async fn main() -> color_eyre::Result<()> { let args = Cli::parse(); - utils::install_hooks()?; + let (logger, _) = LoggerCore::build("/tmp", LogLevel::Info, 0).await?.spawn(); + utils::install_hooks(logger.clone())?; + let mut terminal = utils::init()?; - let mut app = App::new()?; + let mut app = App::new(logger.clone())?.await; - match args.resolve(terminal, &mut app) { + match args.resolve(terminal, &mut app, logger.clone()) { ControlFlow::Break(b) => return b, ControlFlow::Continue(t) => terminal = t, } - run_app(terminal, app)?; + run_app(terminal, app, logger.clone())?; utils::restore()?; - Logger::info("patch-hub finished"); - Logger::flush(); + logger.info("patch-hub finished"); + let _ = logger.flush().await; Ok(()) } diff --git a/src/ui/edit_config.rs b/src/ui/edit_config.rs index 88b5a57d..12e6ef90 100644 --- a/src/ui/edit_config.rs +++ b/src/ui/edit_config.rs @@ -6,7 +6,6 @@ use ratatui::{ Frame, }; -use crate::app::logging::Logger; use crate::app::App; pub fn render_main(f: &mut Frame, app: &App, chunk: Rect) { diff --git a/src/utils.rs b/src/utils.rs index 120b4042..25896d41 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -13,7 +13,7 @@ use ratatui::{ Terminal, }; -use crate::app::logging::Logger; +use crate::logger::Logger; /// A type alias for the terminal type used in this application pub type Tui = Terminal>; @@ -34,14 +34,16 @@ pub fn restore() -> io::Result<()> { /// This replaces the standard color_eyre panic and error hooks with hooks that /// restore the terminal before printing the panic or error. -pub fn install_hooks() -> color_eyre::Result<()> { +pub fn install_hooks(logger: Logger) -> color_eyre::Result<()> { let (panic_hook, eyre_hook) = HookBuilder::default().into_hooks(); // convert from a color_eyre PanicHook to a standard panic hook let panic_hook = panic_hook.into_panic_hook(); panic::set_hook(Box::new(move |panic_info| { restore().unwrap(); - Logger::flush(); + // TODO: await for the flush to finish + ::clone(&logger).flush(); + panic_hook(panic_info); })); @@ -122,15 +124,21 @@ macro_rules! loading_screen { mod tests { use std::sync::Once; + use crate::logger::{LogLevel, LoggerCore}; + use super::*; static INIT: Once = Once::new(); // Tests can be run in parallel, we don't want to override previously installed hooks - fn setup() { + async fn setup() -> color_eyre::Result<()> { + let (logger, _) = LoggerCore::build("/tmp", LogLevel::Info, 0).await?.spawn(); + INIT.call_once(|| { - install_hooks().expect("Failed to install hooks"); - }) + install_hooks(logger).expect("Failed to install hooks"); + }); + + Ok(()) } #[test] @@ -141,14 +149,14 @@ mod tests { assert!(!super::binary_exists("there_is_no_way_this_binary_exists")); } - #[test] - fn test_install_hooks() { - setup(); + #[tokio::test] + async fn test_install_hooks() -> color_eyre::Result<()> { + setup().await } - #[test] - fn test_error_hook_works() { - setup(); + #[tokio::test] + async fn test_error_hook_works() -> color_eyre::Result<()> { + setup().await?; let result: color_eyre::Result<()> = Err(eyre::eyre!("Test error")); @@ -160,14 +168,17 @@ mod tests { let _ = format!("{:?}", e); } } + + Ok(()) } - #[test] - fn test_panic_hook() { - setup(); + #[tokio::test] + async fn test_panic_hook() -> color_eyre::Result<()> { + setup().await?; let result = std::panic::catch_unwind(|| std::panic!("Test panic")); assert!(result.is_err()); + Ok(()) } }