diff --git a/src/app/config.rs b/src/app/config.rs index 6b3dfc01..465cac1b 100644 --- a/src/app/config.rs +++ b/src/app/config.rs @@ -1,6 +1,7 @@ use derive_getters::Getters; use proc_macros::serde_individual_default; use serde::{Deserialize, Serialize}; + use std::{ collections::{HashMap, HashSet}, env, diff --git a/src/app/cover_renderer.rs b/src/app/cover_renderer.rs index fc30f266..9541b8ab 100644 --- a/src/app/cover_renderer.rs +++ b/src/app/cover_renderer.rs @@ -1,12 +1,12 @@ +use serde::{Deserialize, Serialize}; + use std::{ fmt::Display, io::Write, process::{Command, Stdio}, }; -use serde::{Deserialize, Serialize}; - -use super::logging::Logger; +use crate::infrastructure::logging::Logger; #[derive(Debug, Serialize, Deserialize, Clone, Copy, Default)] pub enum CoverRenderer { 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.rs b/src/app/mod.rs similarity index 96% rename from src/app.rs rename to src/app/mod.rs index a5faef22..9ea1783b 100644 --- a/src/app.rs +++ b/src/app/mod.rs @@ -1,19 +1,28 @@ +pub mod config; +mod cover_renderer; +mod patch_renderer; +pub mod screens; + +use ansi_to_tui::IntoText; +use color_eyre::eyre::bail; +use ratatui::text::Text; + +use std::collections::{HashMap, HashSet}; + use crate::{ + infrastructure::{garbage_collector, logging::Logger}, log_on_error, + lore::{ + lore_api_client::BlockingLoreAPIClient, + lore_session, + patch::{Author, Patch}, + }, 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, - patch::{Author, Patch}, -}; use patch_renderer::{render_patch_preview, PatchRenderer}; -use ratatui::text::Text; use screens::{ bookmarked::BookmarkedPatchsets, details_actions::{DetailsActions, PatchsetAction}, @@ -22,15 +31,6 @@ use screens::{ mail_list::MailingListSelection, CurrentScreen, }; -use std::collections::{HashMap, HashSet}; - -use crate::utils; - -pub mod config; -pub mod cover_renderer; -pub mod logging; -pub mod patch_renderer; -pub mod screens; /// Type that represents the overall state of the application. It can be viewed /// as the **Model** component of `patch-hub`. @@ -82,7 +82,7 @@ impl App { // Initialize the logger before the app starts Logger::init_log_file(&config)?; Logger::info("patch-hub started"); - logging::garbage_collector::collect_garbage(&config); + garbage_collector::collect_garbage(&config); Ok(App { current_screen: CurrentScreen::MailingListSelection, @@ -391,30 +391,30 @@ impl App { pub fn check_external_deps(&self) -> bool { let mut app_can_run = true; - if !utils::binary_exists("b4") { + if which::which("b4").is_err() { Logger::error("b4 is not installed, patchsets cannot be downloaded"); app_can_run = false; } - if !utils::binary_exists("git") { + if which::which("git").is_err() { Logger::warn("git is not installed, send-email won't work"); } match self.config.patch_renderer() { PatchRenderer::Bat => { - if !utils::binary_exists("bat") { + if which::which("bat").is_err() { Logger::warn("bat is not installed, patch rendering will fallback to default"); } } PatchRenderer::Delta => { - if !utils::binary_exists("delta") { + if which::which("delta").is_err() { Logger::warn( "delta is not installed, patch rendering will fallback to default", ); } } PatchRenderer::DiffSoFancy => { - if !utils::binary_exists("diff-so-fancy") { + if which::which("diff-so-fancy").is_err() { Logger::warn( "diff-so-fancy is not installed, patch rendering will fallback to default", ); diff --git a/src/app/patch_renderer.rs b/src/app/patch_renderer.rs index 4ff8bf92..a3b69205 100644 --- a/src/app/patch_renderer.rs +++ b/src/app/patch_renderer.rs @@ -1,13 +1,13 @@ +use color_eyre::eyre::eyre; +use serde::{Deserialize, Serialize}; + use std::{ fmt::Display, io::Write, process::{Command, Stdio}, }; -use color_eyre::eyre::eyre; -use serde::{Deserialize, Serialize}; - -use super::logging::Logger; +use crate::infrastructure::logging::Logger; #[derive(Debug, Serialize, Deserialize, Clone, Copy, Default)] pub enum PatchRenderer { diff --git a/src/app/screens/bookmarked.rs b/src/app/screens/bookmarked.rs index bdfdf0d2..ec075efa 100644 --- a/src/app/screens/bookmarked.rs +++ b/src/app/screens/bookmarked.rs @@ -1,4 +1,4 @@ -use patch_hub::lore::patch::Patch; +use crate::lore::patch::Patch; pub struct BookmarkedPatchsets { pub bookmarked_patchsets: Vec, diff --git a/src/app/screens/details_actions.rs b/src/app/screens/details_actions.rs index c5cd40ef..076f167d 100644 --- a/src/app/screens/details_actions.rs +++ b/src/app/screens/details_actions.rs @@ -1,16 +1,23 @@ -use crate::app::config::{Config, KernelTree}; - -use super::CurrentScreen; -use ::patch_hub::lore::{lore_api_client::BlockingLoreAPIClient, lore_session, patch::Patch}; use color_eyre::eyre::{bail, eyre}; -use patch_hub::lore::patch::Author; use ratatui::text::Text; + use std::{ collections::{HashMap, HashSet}, path::Path, process::Command, }; +use crate::{ + app::config::{Config, KernelTree}, + lore::{ + lore_api_client::BlockingLoreAPIClient, + lore_session, + patch::{Author, Patch}, + }, +}; + +use super::CurrentScreen; + pub struct DetailsActions { pub representative_patch: Patch, /// Raw patches as plain text files diff --git a/src/app/screens/edit_config.rs b/src/app/screens/edit_config.rs index e39e0fde..8714f1ca 100644 --- a/src/app/screens/edit_config.rs +++ b/src/app/screens/edit_config.rs @@ -1,8 +1,9 @@ +use color_eyre::eyre::bail; +use derive_getters::Getters; + use std::{collections::HashMap, fmt::Display, path::Path}; use crate::app::config::Config; -use color_eyre::eyre::bail; -use derive_getters::Getters; #[derive(Debug, Getters)] pub struct EditConfig { diff --git a/src/app/screens/latest.rs b/src/app/screens/latest.rs index 61d8c9b4..ce7d448c 100644 --- a/src/app/screens/latest.rs +++ b/src/app/screens/latest.rs @@ -1,6 +1,7 @@ use color_eyre::eyre::bail; use derive_getters::Getters; -use patch_hub::lore::{ + +use crate::lore::{ lore_api_client::{BlockingLoreAPIClient, ClientError}, lore_session::{LoreSession, LoreSessionError}, patch::Patch, diff --git a/src/app/screens/mail_list.rs b/src/app/screens/mail_list.rs index 52d49f7f..e4537b1b 100644 --- a/src/app/screens/mail_list.rs +++ b/src/app/screens/mail_list.rs @@ -1,5 +1,6 @@ use color_eyre::eyre::bail; -use patch_hub::lore::{ + +use crate::lore::{ lore_api_client::BlockingLoreAPIClient, lore_session, mailing_list::MailingList, }; diff --git a/src/cli.rs b/src/cli.rs index 5ed52dad..621ff15f 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -1,10 +1,10 @@ -use std::ops::ControlFlow; - use clap::Parser; use color_eyre::eyre::eyre; use ratatui::{prelude::Backend, Terminal}; -use crate::{app::config::Config, utils}; +use std::ops::ControlFlow; + +use crate::{app::config::Config, infrastructure::terminal::restore}; #[derive(Debug, Parser)] #[command(version, about)] @@ -25,7 +25,7 @@ impl Cli { ) -> ControlFlow, Terminal> { if self.show_configs { drop(terminal); - if let Err(err) = utils::restore() { + if let Err(err) = restore() { return ControlFlow::Break(Err(eyre!(err))); } match serde_json::to_string_pretty(&config) { diff --git a/src/handler/bookmarked.rs b/src/handler/bookmarked.rs index ab3ec4b8..3bca0ccc 100644 --- a/src/handler/bookmarked.rs +++ b/src/handler/bookmarked.rs @@ -1,3 +1,9 @@ +use ratatui::{ + crossterm::event::{KeyCode, KeyEvent}, + prelude::Backend, + Terminal, +}; + use std::ops::ControlFlow; use crate::{ @@ -5,11 +11,6 @@ use crate::{ loading_screen, ui::popup::{help::HelpPopUpBuilder, PopUp}, }; -use ratatui::{ - crossterm::event::{KeyCode, KeyEvent}, - prelude::Backend, - Terminal, -}; pub fn handle_bookmarked_patchsets( app: &mut App, diff --git a/src/handler/details_actions.rs b/src/handler/details_actions.rs index b7ca73a5..26a072b2 100644 --- a/src/handler/details_actions.rs +++ b/src/handler/details_actions.rs @@ -1,14 +1,15 @@ +use ratatui::{ + backend::Backend, + crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers}, + Terminal, +}; + use std::time::Duration; use crate::{ app::{screens::CurrentScreen, App}, + infrastructure::terminal::{setup_user_io, teardown_user_io}, ui::popup::{help::HelpPopUpBuilder, review_trailers::ReviewTrailersPopUp, PopUp}, - utils, -}; -use ratatui::{ - backend::Backend, - crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers}, - Terminal, }; use super::wait_key_press; @@ -107,7 +108,7 @@ pub fn handle_patchset_details( } KeyCode::Enter => { if patchset_details_and_actions.actions_require_user_io() { - utils::setup_user_io(terminal)?; + setup_user_io(terminal)?; app.consolidate_patchset_actions()?; println!("\nPress ENTER continue..."); loop { @@ -117,7 +118,7 @@ pub fn handle_patchset_details( } } } - utils::teardown_user_io(terminal)?; + teardown_user_io(terminal)?; } else { app.consolidate_patchset_actions()?; } diff --git a/src/handler/edit_config.rs b/src/handler/edit_config.rs index 405f6d5d..f0f2f158 100644 --- a/src/handler/edit_config.rs +++ b/src/handler/edit_config.rs @@ -1,8 +1,9 @@ +use ratatui::crossterm::event::{KeyCode, KeyEvent}; + use crate::{ app::{screens::CurrentScreen, App}, ui::popup::{help::HelpPopUpBuilder, PopUp}, }; -use ratatui::crossterm::event::{KeyCode, KeyEvent}; pub fn handle_edit_config(app: &mut App, key: KeyEvent) -> color_eyre::Result<()> { if let Some(edit_config_state) = app.edit_config.as_mut() { diff --git a/src/handler/latest.rs b/src/handler/latest.rs index 6a3b1e66..1d0493e1 100644 --- a/src/handler/latest.rs +++ b/src/handler/latest.rs @@ -1,3 +1,9 @@ +use ratatui::{ + crossterm::event::{KeyCode, KeyEvent}, + prelude::Backend, + Terminal, +}; + use std::ops::ControlFlow; use crate::{ @@ -5,11 +11,6 @@ use crate::{ loading_screen, ui::popup::{help::HelpPopUpBuilder, PopUp}, }; -use ratatui::{ - crossterm::event::{KeyCode, KeyEvent}, - prelude::Backend, - Terminal, -}; pub fn handle_latest_patchsets( app: &mut App, diff --git a/src/handler/mail_list.rs b/src/handler/mail_list.rs index d8797a09..d5a0ffc4 100644 --- a/src/handler/mail_list.rs +++ b/src/handler/mail_list.rs @@ -1,3 +1,9 @@ +use ratatui::{ + crossterm::event::{KeyCode, KeyEvent}, + prelude::Backend, + Terminal, +}; + use std::ops::ControlFlow; use crate::{ @@ -5,11 +11,6 @@ use crate::{ loading_screen, ui::popup::{help::HelpPopUpBuilder, PopUp}, }; -use ratatui::{ - crossterm::event::{KeyCode, KeyEvent}, - prelude::Backend, - Terminal, -}; pub fn handle_mailing_list_selection( app: &mut App, diff --git a/src/handler.rs b/src/handler/mod.rs similarity index 96% rename from src/handler.rs rename to src/handler/mod.rs index ef003201..6dc2a59c 100644 --- a/src/handler.rs +++ b/src/handler/mod.rs @@ -1,8 +1,14 @@ -pub mod bookmarked; -pub mod details_actions; -pub mod edit_config; -pub mod latest; -pub mod mail_list; +mod bookmarked; +mod details_actions; +mod edit_config; +mod latest; +mod mail_list; + +use ratatui::{ + crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind}, + prelude::Backend, + Terminal, +}; use std::{ ops::ControlFlow, @@ -10,7 +16,8 @@ use std::{ }; use crate::{ - app::{logging::Logger, screens::CurrentScreen, App}, + app::{screens::CurrentScreen, App}, + infrastructure::logging::Logger, loading_screen, ui::draw_ui, }; @@ -21,11 +28,6 @@ use details_actions::handle_patchset_details; use edit_config::handle_edit_config; use latest::handle_latest_patchsets; use mail_list::handle_mailing_list_selection; -use ratatui::{ - crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind}, - prelude::Backend, - Terminal, -}; fn key_handling( mut terminal: Terminal, diff --git a/src/infrastructure/errors.rs b/src/infrastructure/errors.rs new file mode 100644 index 00000000..02c75795 --- /dev/null +++ b/src/infrastructure/errors.rs @@ -0,0 +1,74 @@ +use std::panic; + +use super::{logging::Logger, terminal::restore}; + +/// 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<()> { + let (panic_hook, eyre_hook) = color_eyre::config::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(); + panic_hook(panic_info); + })); + + // convert from a color_eyre EyreHook to a eyre ErrorHook + let eyre_hook = eyre_hook.into_eyre_hook(); + color_eyre::eyre::set_hook(Box::new( + move |error: &(dyn std::error::Error + 'static)| { + restore().unwrap(); + eyre_hook(error) + }, + ))?; + + Ok(()) +} + +#[cfg(test)] +mod tests { + use std::sync::Once; + + use super::*; + + static INIT: Once = Once::new(); + + // Tests can be run in parallel, we don't want to override previously installed hooks + fn setup() { + INIT.call_once(|| { + install_hooks().expect("Failed to install hooks"); + }) + } + + #[test] + fn test_install_hooks() { + setup(); + } + + #[test] + fn test_error_hook_works() { + setup(); + + let result: color_eyre::Result<()> = Err(color_eyre::eyre::eyre!("Test error")); + + // We can't directly test the hook's formatting, but we can verify + // that handling an error doesn't cause unexpected panics + match result { + Ok(_) => panic!("Expected an error"), + Err(e) => { + let _ = format!("{:?}", e); + } + } + } + + #[test] + fn test_panic_hook() { + setup(); + + let result = std::panic::catch_unwind(|| std::panic!("Test panic")); + + assert!(result.is_err()); + } +} diff --git a/src/app/logging/garbage_collector.rs b/src/infrastructure/logging/garbage_collector.rs similarity index 100% rename from src/app/logging/garbage_collector.rs rename to src/infrastructure/logging/garbage_collector.rs diff --git a/src/app/logging.rs b/src/infrastructure/logging/mod.rs similarity index 99% rename from src/app/logging.rs rename to src/infrastructure/logging/mod.rs index 1e6ec8fe..c4c60bd3 100644 --- a/src/app/logging.rs +++ b/src/infrastructure/logging/mod.rs @@ -1,16 +1,15 @@ +pub mod garbage_collector; + +use chrono::Local; + use std::{ fmt::Display, fs::{self, File, OpenOptions}, io::Write, }; -use chrono::Local; - use crate::app::config::Config; -pub mod garbage_collector; -pub mod log_on_error; - const LATEST_LOG_FILENAME: &str = "latest.log"; static mut LOG_BUFFER: Logger = Logger { diff --git a/src/infrastructure/mod.rs b/src/infrastructure/mod.rs new file mode 100644 index 00000000..f05edfcb --- /dev/null +++ b/src/infrastructure/mod.rs @@ -0,0 +1,5 @@ +pub mod errors; +pub mod logging; +pub mod terminal; + +pub use logging::garbage_collector; diff --git a/src/infrastructure/terminal.rs b/src/infrastructure/terminal.rs new file mode 100644 index 00000000..c8bf6db9 --- /dev/null +++ b/src/infrastructure/terminal.rs @@ -0,0 +1,42 @@ +use ratatui::{ + crossterm::{ + execute, + terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}, + }, + layout::Position, + prelude::{Backend, CrosstermBackend}, + Terminal, +}; + +use std::io::{self, stdout, Stdout}; + +/// A type alias for the terminal type used in this application +pub type Tui = Terminal>; + +/// Initialize the terminal +pub fn init() -> io::Result { + execute!(stdout(), EnterAlternateScreen)?; + enable_raw_mode()?; + Terminal::new(CrosstermBackend::new(stdout())) +} + +/// Restore the terminal to its original state +pub fn restore() -> io::Result<()> { + execute!(stdout(), LeaveAlternateScreen)?; + disable_raw_mode()?; + Ok(()) +} + +pub fn setup_user_io(terminal: &mut Terminal) -> color_eyre::Result<()> { + terminal.clear()?; + terminal.set_cursor_position(Position::new(0, 0))?; + terminal.show_cursor()?; + disable_raw_mode()?; + Ok(()) +} + +pub fn teardown_user_io(terminal: &mut Terminal) -> color_eyre::Result<()> { + enable_raw_mode()?; + terminal.clear()?; + Ok(()) +} diff --git a/src/lib.rs b/src/lib.rs deleted file mode 100644 index b48300f6..00000000 --- a/src/lib.rs +++ /dev/null @@ -1 +0,0 @@ -pub mod lore; diff --git a/src/lore/lore_api_client.rs b/src/lore/lore_api_client.rs index 5b9336a3..6295bd47 100644 --- a/src/lore/lore_api_client.rs +++ b/src/lore/lore_api_client.rs @@ -1,10 +1,10 @@ -use std::time::Duration; - use mockall::automock; use thiserror::Error; use ureq::tls::TlsConfig; use ureq::Agent; +use std::time::Duration; + #[cfg(test)] mod tests; diff --git a/src/lore/lore_session.rs b/src/lore/lore_session.rs index 57d68a6b..5dd2b0e6 100644 --- a/src/lore/lore_session.rs +++ b/src/lore/lore_session.rs @@ -1,22 +1,23 @@ -use crate::lore::lore_api_client::{ - AvailableListsRequest, ClientError, PatchFeedRequest, PatchHTMLRequest, -}; -use crate::lore::mailing_list::MailingList; -use crate::lore::patch::{Patch, PatchFeed, PatchRegex}; use derive_getters::Getters; use regex::Regex; use serde_xml_rs::from_str; -use std::collections::{HashMap, HashSet}; -use std::io::{BufRead, BufReader}; -use std::mem::swap; -use std::path::Path; -use std::process::{Command, Stdio}; -use std::sync::LazyLock; +use thiserror::Error; + use std::{ + collections::{HashMap, HashSet}, fs::{self, File}, - io, + io::{self, BufRead, BufReader}, + mem::swap, + path::Path, + process::{Command, Stdio}, + sync::LazyLock, +}; + +use crate::lore::{ + lore_api_client::{AvailableListsRequest, ClientError, PatchFeedRequest, PatchHTMLRequest}, + mailing_list::MailingList, + patch::{Patch, PatchFeed, PatchRegex}, }; -use thiserror::Error; #[cfg(test)] mod tests; diff --git a/src/lore/lore_session/tests.rs b/src/lore/lore_session/tests.rs index 456392cb..de777f23 100644 --- a/src/lore/lore_session/tests.rs +++ b/src/lore/lore_session/tests.rs @@ -1,10 +1,11 @@ +use mockall::mock; + use io::Read; +use std::fs; -use super::*; use crate::lore::patch::Author; -use mockall::mock; -use std::fs; +use super::*; mock! { BlockingLoreAPIClient {} diff --git a/src/lore.rs b/src/lore/mod.rs similarity index 100% rename from src/lore.rs rename to src/lore/mod.rs diff --git a/src/lore/patch.rs b/src/lore/patch.rs index cda53497..c8e1e925 100644 --- a/src/lore/patch.rs +++ b/src/lore/patch.rs @@ -1,9 +1,9 @@ -use std::fmt::Display; - use derive_getters::Getters; use regex::Regex; use serde::{Deserialize, Serialize}; +use std::fmt::Display; + #[cfg(test)] mod tests; @@ -62,25 +62,6 @@ fn default_total_in_series() -> usize { } impl Patch { - pub fn new( - title: String, - author: Author, - message_id: MessageID, - in_reply_to: Option, - updated: String, - ) -> Patch { - Patch { - title, - author, - version: 1, - number_in_series: 1, - total_in_series: 1, - message_id, - in_reply_to, - updated, - } - } - pub fn version(&self) -> usize { self.version } diff --git a/src/lore/patch/tests.rs b/src/lore/patch/tests.rs index 7f2fed4b..ed28215f 100644 --- a/src/lore/patch/tests.rs +++ b/src/lore/patch/tests.rs @@ -1,20 +1,30 @@ -use super::*; use serde_xml_rs::from_str; +use super::*; + #[test] fn can_deserialize_patch_without_in_reply_to() { - let expected_patch: Patch = Patch::new( - "[PATCH 0/42] hitchhiker/guide: Complete Collection".to_string(), - Author { + let expected_patch: Patch = { + let title = "[PATCH 0/42] hitchhiker/guide: Complete Collection".to_string(); + let author = Author { name: "Foo Bar".to_string(), email: "foo@bar.foo.bar".to_string(), - }, - MessageID { + }; + let message_id = MessageID { href: "http://lore.kernel.org/some-list/1234-1-foo@bar.foo.bar".to_string(), - }, - None, - "2024-07-06T19:15:48Z".to_string(), - ); + }; + let updated = "2024-07-06T19:15:48Z".to_string(); + Patch { + title, + author, + version: 1, + number_in_series: 1, + total_in_series: 1, + message_id, + in_reply_to: None, + updated, + } + }; let serialized_patch: &str = r#" @@ -40,20 +50,30 @@ fn can_deserialize_patch_without_in_reply_to() { #[test] fn can_deserialize_patch_with_in_reply_to() { - let expected_patch: Patch = Patch::new( - "[PATCH 3/42] hitchhiker/guide: Life, the Universe and Everything".to_string(), - Author { + let expected_patch: Patch = { + let title = "[PATCH 3/42] hitchhiker/guide: Life, the Universe and Everything".to_string(); + let author = Author { name: "Foo Bar".to_string(), email: "foo@bar.foo.bar".to_string(), - }, - MessageID { + }; + let message_id = MessageID { href: "http://lore.kernel.org/some-list/1234-2-foo@bar.foo.bar".to_string(), - }, - Some(MessageID { + }; + let in_reply_to = Some(MessageID { href: "http://lore.kernel.org/some-list/1234-1-foo@bar.foo.bar".to_string(), - }), - "2024-07-06T19:16:53Z".to_string(), - ); + }); + let updated = "2024-07-06T19:16:53Z".to_string(); + Patch { + title, + author, + version: 1, + number_in_series: 1, + total_in_series: 1, + message_id, + in_reply_to, + updated, + } + }; let serialized_patch: &str = r#" @@ -83,20 +103,31 @@ fn can_deserialize_patch_with_in_reply_to() { #[test] fn test_update_patch_metadata() { let patch_regex: PatchRegex = PatchRegex::new(); - let mut patch: Patch = Patch::new( - "[RESEND][v7 PATCH 3/42] hitchhiker/guide: Life, the Universe and Everything".to_string(), - Author { + let mut patch: Patch = { + let title = "[RESEND][v7 PATCH 3/42] hitchhiker/guide: Life, the Universe and Everything" + .to_string(); + let author = Author { name: "Foo Bar".to_string(), email: "foo@bar.foo.bar".to_string(), - }, - MessageID { + }; + let message_id = MessageID { href: "http://lore.kernel.org/some-list/1234-2-foo@bar.foo.bar".to_string(), - }, - Some(MessageID { + }; + let in_reply_to = Some(MessageID { href: "http://lore.kernel.org/some-list/1234-1-foo@bar.foo.bar".to_string(), - }), - "2024-07-06T19:16:53Z".to_string(), - ); + }); + let updated = "2024-07-06T19:16:53Z".to_string(); + Patch { + title, + author, + version: 1, + number_in_series: 1, + total_in_series: 1, + message_id, + in_reply_to, + updated, + } + }; patch.update_patch_metadata(&patch_regex); diff --git a/src/macros.rs b/src/macros.rs new file mode 100644 index 00000000..9664121a --- /dev/null +++ b/src/macros.rs @@ -0,0 +1,77 @@ +#[macro_export] +/// Macro that encapsulates a piece of code that takes long to run and displays a loading screen while it runs. +/// +/// This macro takes two arguments: the terminal and the title of the loading screen (anything that implements `Display`). +/// After a `=>` token, you can pass the code that takes long to run. +/// +/// When the execution finishes, the macro will return the terminal. +/// +/// Important to notice that the code block will run in the same scope as the rest of the macro. +/// Be aware that in Rust, when using `?` or `return` inside a closure, they apply to the outer function, +/// not the closure itself. This can lead to unexpected behavior if you expect the closure to handle +/// errors or return values independently of the enclosing function. +/// +/// # Example +/// ```rust norun +/// terminal = loading_screen! { terminal, "Loading stuff" => { +/// // code that takes long to run +/// }}; +/// ``` +macro_rules! loading_screen { + { $terminal:expr, $title:expr => $inst:expr} => { + { + let loading = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(true)); + let loading_clone = std::sync::Arc::clone(&loading); + let mut terminal = $terminal; + + let handle = std::thread::spawn(move || { + while loading_clone.load(std::sync::atomic::Ordering::Relaxed) { + terminal = $crate::ui::loading_screen::render(terminal, $title); + std::thread::sleep(std::time::Duration::from_millis(200)); + } + + terminal + }); + + // we have to sleep so the loading thread completes at least one render + std::thread::sleep(std::time::Duration::from_millis(200)); + let inst_result = $inst; + + loading.store(false, std::sync::atomic::Ordering::Relaxed); + + let terminal = handle.join().unwrap(); + + inst_result?; + + terminal + } + }; +} + +#[macro_export] +macro_rules! log_on_error { + ($result:expr) => { + log_on_error!($crate::infrastructure::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::infrastructure::logging::LogLevel::Info => { + Logger::info(error_message); + } + $crate::infrastructure::logging::LogLevel::Warning => { + Logger::warn(error_message); + } + $crate::infrastructure::logging::LogLevel::Error => { + Logger::error(error_message); + } + } + $result + } + } + }; +} diff --git a/src/main.rs b/src/main.rs index 34612f6a..5d9b0c8f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,22 +1,27 @@ +mod app; +mod cli; +mod handler; +mod infrastructure; +mod lore; +mod macros; +mod ui; + use std::ops::ControlFlow; -use crate::app::App; -use app::{config::Config, logging::Logger}; +use app::{config::Config, App}; use clap::Parser; use cli::Cli; use handler::run_app; - -mod app; -mod cli; -mod handler; -mod ui; -mod utils; +use infrastructure::{ + logging::Logger, + terminal::{init, restore}, +}; fn main() -> color_eyre::Result<()> { let args = Cli::parse(); - utils::install_hooks()?; - let mut terminal = utils::init()?; + infrastructure::errors::install_hooks()?; + let mut terminal = init()?; let config = Config::build(); config.create_dirs(); @@ -29,7 +34,7 @@ fn main() -> color_eyre::Result<()> { let app = App::new(config)?; run_app(terminal, app)?; - utils::restore()?; + restore()?; Logger::info("patch-hub finished"); Logger::flush(); diff --git a/src/ui/bookmarked.rs b/src/ui/bookmarked.rs index 8a5193c2..a6a9ccfe 100644 --- a/src/ui/bookmarked.rs +++ b/src/ui/bookmarked.rs @@ -1,5 +1,3 @@ -use crate::app; -use app::screens::bookmarked::BookmarkedPatchsets; use ratatui::{ layout::Rect, style::{Color, Modifier, Style}, @@ -8,6 +6,8 @@ use ratatui::{ Frame, }; +use crate::app::screens::bookmarked::BookmarkedPatchsets; + pub fn render_main(f: &mut Frame, bookmarked_patchsets: &BookmarkedPatchsets, chunk: Rect) { let patchset_index = bookmarked_patchsets.patchset_index; let mut list_items = Vec::::new(); diff --git a/src/ui/edit_config.rs b/src/ui/edit_config.rs index 88b5a57d..ef62a8e1 100644 --- a/src/ui/edit_config.rs +++ b/src/ui/edit_config.rs @@ -6,8 +6,7 @@ use ratatui::{ Frame, }; -use crate::app::logging::Logger; -use crate::app::App; +use crate::{app::App, infrastructure::logging::Logger}; pub fn render_main(f: &mut Frame, app: &App, chunk: Rect) { let edit_config = app.edit_config.as_ref().unwrap(); diff --git a/src/ui/latest.rs b/src/ui/latest.rs index 4288543c..e6776366 100644 --- a/src/ui/latest.rs +++ b/src/ui/latest.rs @@ -1,5 +1,3 @@ -use crate::app::App; -use patch_hub::lore::patch::Patch; use ratatui::{ layout::Rect, style::{Color, Modifier, Style}, @@ -8,6 +6,8 @@ use ratatui::{ Frame, }; +use crate::{app::App, lore::patch::Patch}; + pub fn render_main(f: &mut Frame, app: &App, chunk: Rect) { let page_number = app.latest_patchsets.as_ref().unwrap().page_number(); let patchset_index = app.latest_patchsets.as_ref().unwrap().patchset_index(); diff --git a/src/ui/loading_screen.rs b/src/ui/loading_screen.rs index 46e26cf8..99343b4c 100644 --- a/src/ui/loading_screen.rs +++ b/src/ui/loading_screen.rs @@ -1,5 +1,3 @@ -use std::fmt::Display; - use ratatui::{ prelude::Backend, style::{Color, Style}, @@ -8,6 +6,8 @@ use ratatui::{ Frame, Terminal, }; +use std::fmt::Display; + use super::centered_rect; const SPINNER: [char; 8] = [ diff --git a/src/ui.rs b/src/ui/mod.rs similarity index 99% rename from src/ui.rs rename to src/ui/mod.rs index a01d4c68..ed528754 100644 --- a/src/ui.rs +++ b/src/ui/mod.rs @@ -1,12 +1,3 @@ -use crate::app::{screens::CurrentScreen, App}; -use ratatui::{ - layout::{Alignment, Constraint, Direction, Layout, Rect}, - style::{Color, Style, Stylize}, - text::Text, - widgets::{Block, Borders, Clear, Paragraph}, - Frame, -}; - mod bookmarked; mod details_actions; mod edit_config; @@ -16,6 +7,16 @@ mod mail_list; mod navigation_bar; pub mod popup; +use ratatui::{ + layout::{Alignment, Constraint, Direction, Layout, Rect}, + style::{Color, Style, Stylize}, + text::Text, + widgets::{Block, Borders, Clear, Paragraph}, + Frame, +}; + +use crate::app::{screens::CurrentScreen, App}; + pub fn draw_ui(f: &mut Frame, app: &App) { // Clear the whole screen for sanitizing reasons f.render_widget(Clear, f.area()); diff --git a/src/ui/navigation_bar.rs b/src/ui/navigation_bar.rs index b601bf97..47dd6a10 100644 --- a/src/ui/navigation_bar.rs +++ b/src/ui/navigation_bar.rs @@ -1,6 +1,3 @@ -use super::{bookmarked, details_actions, edit_config, latest, mail_list}; -use crate::app::{self, App}; -use app::screens::CurrentScreen; use ratatui::{ layout::{Constraint, Direction, Layout, Rect}, text::Line, @@ -8,6 +5,10 @@ use ratatui::{ Frame, }; +use crate::app::{screens::CurrentScreen, App}; + +use super::{bookmarked, details_actions, edit_config, latest, mail_list}; + pub fn render(f: &mut Frame, app: &App, chunk: Rect) { let mode_footer_text = match app.current_screen { CurrentScreen::MailingListSelection => mail_list::mode_footer_text(app), diff --git a/src/ui/popup.rs b/src/ui/popup.rs index 55928346..f2496123 100644 --- a/src/ui/popup.rs +++ b/src/ui/popup.rs @@ -1,11 +1,11 @@ -use std::fmt::Debug; - -use ratatui::{crossterm::event::KeyEvent, layout::Rect, Frame}; - pub mod help; pub mod info_popup; pub mod review_trailers; +use ratatui::{crossterm::event::KeyEvent, layout::Rect, Frame}; + +use std::fmt::Debug; + /// A trait that represents a popup that can be rendered on top of a screen pub trait PopUp: Debug { /// Returns the dimensions of the popup in percentage of the screen diff --git a/src/ui/popup/help.rs b/src/ui/popup/help.rs index fae6d037..5c70fcfa 100644 --- a/src/ui/popup/help.rs +++ b/src/ui/popup/help.rs @@ -5,6 +5,7 @@ use ratatui::{ text::Line, widgets::{Clear, Paragraph}, }; + use std::fmt::Display; use super::PopUp; diff --git a/src/ui/popup/review_trailers.rs b/src/ui/popup/review_trailers.rs index 9fa48598..f626bcc4 100644 --- a/src/ui/popup/review_trailers.rs +++ b/src/ui/popup/review_trailers.rs @@ -1,6 +1,3 @@ -use std::collections::HashSet; - -use patch_hub::lore::patch::Author; use ratatui::{ crossterm::event::KeyCode, layout::Alignment, @@ -9,7 +6,9 @@ use ratatui::{ widgets::{Clear, Paragraph}, }; -use crate::app::screens::details_actions::DetailsActions; +use std::collections::HashSet; + +use crate::{app::screens::details_actions::DetailsActions, lore::patch::Author}; use super::PopUp; diff --git a/src/utils.rs b/src/utils.rs deleted file mode 100644 index 8dcb5a58..00000000 --- a/src/utils.rs +++ /dev/null @@ -1,189 +0,0 @@ -use color_eyre::{config::HookBuilder, eyre}; -use ratatui::layout::Position; -use std::io::{self, stdout, Stdout}; -use std::panic; - -use ratatui::{ - backend::CrosstermBackend, - crossterm::{ - execute, - terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}, - }, - prelude::Backend, - Terminal, -}; - -use crate::app::logging::Logger; - -/// A type alias for the terminal type used in this application -pub type Tui = Terminal>; - -/// Initialize the terminal -pub fn init() -> io::Result { - execute!(stdout(), EnterAlternateScreen)?; - enable_raw_mode()?; - Terminal::new(CrosstermBackend::new(stdout())) -} - -/// Restore the terminal to its original state -pub fn restore() -> io::Result<()> { - execute!(stdout(), LeaveAlternateScreen)?; - disable_raw_mode()?; - Ok(()) -} - -/// This replaces the standard color_eyre panic and error hooks with hooks that -/// restore the terminal before printing the panic or error. -/// -/// # Tests -/// -/// [tests::test_error_hook] -/// [tests::test_panic_hook] -pub fn install_hooks() -> 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(); - panic_hook(panic_info); - })); - - // convert from a color_eyre EyreHook to a eyre ErrorHook - let eyre_hook = eyre_hook.into_eyre_hook(); - eyre::set_hook(Box::new( - move |error: &(dyn std::error::Error + 'static)| { - restore().unwrap(); - eyre_hook(error) - }, - ))?; - - Ok(()) -} - -pub fn setup_user_io(terminal: &mut Terminal) -> color_eyre::Result<()> { - terminal.clear()?; - terminal.set_cursor_position(Position::new(0, 0))?; - terminal.show_cursor()?; - disable_raw_mode()?; - Ok(()) -} - -pub fn teardown_user_io(terminal: &mut Terminal) -> color_eyre::Result<()> { - enable_raw_mode()?; - terminal.clear()?; - Ok(()) -} - -#[inline] -/// Simply calls `which` to check if a binary exists -/// -/// # Tests -/// -/// [tests::test_binary_exists] -pub fn binary_exists(binary: &str) -> bool { - which::which(binary).is_ok() -} - -#[macro_export] -/// Macro that encapsulates a piece of code that takes long to run and displays a loading screen while it runs. -/// -/// This macro takes two arguments: the terminal and the title of the loading screen (anything that implements `Display`). -/// After a `=>` token, you can pass the code that takes long to run. -/// -/// When the execution finishes, the macro will return the terminal. -/// -/// Important to notice that the code block will run in the same scope as the rest of the macro. -/// Be aware that in Rust, when using `?` or `return` inside a closure, they apply to the outer function, -/// not the closure itself. This can lead to unexpected behavior if you expect the closure to handle -/// errors or return values independently of the enclosing function. -/// -/// # Example -/// ```rust norun -/// terminal = loading_screen! { terminal, "Loading stuff" => { -/// // code that takes long to run -/// }}; -/// ``` -macro_rules! loading_screen { - { $terminal:expr, $title:expr => $inst:expr} => { - { - let loading = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(true)); - let loading_clone = std::sync::Arc::clone(&loading); - let mut terminal = $terminal; - - let handle = std::thread::spawn(move || { - while loading_clone.load(std::sync::atomic::Ordering::Relaxed) { - terminal = $crate::ui::loading_screen::render(terminal, $title); - std::thread::sleep(std::time::Duration::from_millis(200)); - } - - terminal - }); - - // we have to sleep so the loading thread completes at least one render - std::thread::sleep(std::time::Duration::from_millis(200)); - let inst_result = $inst; - - loading.store(false, std::sync::atomic::Ordering::Relaxed); - - let terminal = handle.join().unwrap(); - - inst_result?; - - terminal - } - }; -} - -#[cfg(test)] -mod tests { - use std::sync::Once; - - use super::*; - - static INIT: Once = Once::new(); - - // Tests can be run in parallel, we don't want to override previously installed hooks - fn setup() { - INIT.call_once(|| { - install_hooks().expect("Failed to install hooks"); - }) - } - - #[test] - /// Tests [binary_exists] - fn test_binary_exists() { - // cargo should always exist since we are running the tests with `cargo test` - assert!(super::binary_exists("cargo")); - // there is no way this binary exists - assert!(!super::binary_exists("there_is_no_way_this_binary_exists")); - } - - #[test] - /// Tests [install_hooks] - fn test_error_hook() { - setup(); - - let result: color_eyre::Result<()> = Err(eyre::eyre!("Test error")); - - // We can't directly test the hook's formatting, but we can verify - // that handling an error doesn't cause unexpected panics - match result { - Ok(_) => panic!("Expected an error"), - Err(e) => { - let _ = format!("{:?}", e); - } - } - } - - #[test] - /// Tests [install_hooks] - fn test_panic_hook() { - setup(); - - let result = std::panic::catch_unwind(|| std::panic!("Test panic")); - - assert!(result.is_err()); - } -}