From be542551f3130f156e05538beaabbc147d7e6140 Mon Sep 17 00:00:00 2001 From: Kristofers Solo Date: Wed, 10 Dec 2025 03:57:28 +0200 Subject: [PATCH] refactor: add custom error type --- Cargo.lock | 28 ++++++++--- Cargo.toml | 2 +- src/app/command.rs | 30 ++++------- src/app/constants.rs | 7 +++ src/app/input.rs | 102 ++++++++++++++++++++++++++++++++++++++ src/app/mod.rs | 115 ++++++++----------------------------------- src/app/tab.rs | 2 +- src/app/torrent.rs | 10 ++-- src/error.rs | 30 +++++++++++ src/handler.rs | 20 ++++---- src/lib.rs | 1 + src/main.rs | 50 +++++++++---------- src/ui/input.rs | 16 +++--- 13 files changed, 234 insertions(+), 179 deletions(-) create mode 100644 src/app/constants.rs create mode 100644 src/app/input.rs create mode 100644 src/error.rs diff --git a/Cargo.lock b/Cargo.lock index 083b098..5fa81a0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -199,9 +199,9 @@ dependencies = [ [[package]] name = "convert_case" -version = "0.7.1" +version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb402b8d4c85569410425650ce3eddc7d698ed96d39a73f941b08fb63082f1e7" +checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9" dependencies = [ "unicode-segmentation", ] @@ -316,22 +316,23 @@ dependencies = [ [[package]] name = "derive_more" -version = "2.0.1" +version = "2.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "093242cf7570c207c83073cf82f79706fe7b8317e98620a47d5be7c3d8497678" +checksum = "10b768e943bed7bf2cab53df09f4bc34bfd217cdb57d971e769874c9a6710618" dependencies = [ "derive_more-impl", ] [[package]] name = "derive_more-impl" -version = "2.0.1" +version = "2.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bda628edc44c4bb645fbe0f758797143e4e07926f7ebf4e9bdfbd3d2ce621df3" +checksum = "6d286bfdaf75e988b4a78e013ecd79c581e06399ab53fbacd2d916c2f904f30b" dependencies = [ "convert_case", "proc-macro2", "quote", + "rustc_version", "syn", "unicode-xid", ] @@ -1414,6 +1415,15 @@ version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + [[package]] name = "rustix" version = "0.38.44" @@ -1493,6 +1503,12 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "semver" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" + [[package]] name = "serde" version = "1.0.219" diff --git a/Cargo.toml b/Cargo.toml index 176f830..f214095 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,7 +9,7 @@ edition = "2024" filecaster = { version = "0.2", features = ["derive", "merge"] } color-eyre = "0.6" crossterm = "0.29" -derive_more = { version = "2.0", features = ["display"] } +derive_more = { version = "2.1", features = ["display"] } dirs = "6.0" merge = "0.2" ratatui = { version = "0.29" } diff --git a/src/app/command.rs b/src/app/command.rs index 1850041..087b5f3 100644 --- a/src/app/command.rs +++ b/src/app/command.rs @@ -1,5 +1,5 @@ use super::{Torrents, types::Selected}; -use color_eyre::{Result, eyre::eyre}; +use crate::error::Result; use std::{collections::HashSet, path::Path}; use transmission_rpc::types::{Torrent, TorrentAction, TorrentStatus}; @@ -9,11 +9,11 @@ impl Torrents { /// TODO: add error types pub async fn toggle(&mut self, ids: Selected) -> Result<()> { let ids: HashSet<_> = ids.into(); - let torrents_to_toggle: Vec<_> = self + let torrents_to_toggle = self .torrents .iter() .filter(|torrent| torrent.id.is_some_and(|id| ids.contains(&id))) - .collect(); + .collect::>(); for torrent in torrents_to_toggle { let action = match torrent.status { @@ -21,10 +21,7 @@ impl Torrents { _ => TorrentAction::Stop, }; if let Some(id) = torrent.id() { - self.client - .torrent_action(action, vec![id]) - .await - .map_err(|e| eyre!("Transmission RPC error: {}", e.to_string()))?; + self.client.torrent_action(action, vec![id]).await?; } } Ok(()) @@ -51,10 +48,7 @@ impl Torrents { .collect(); for (id, action) in torrents_to_toggle { - self.client - .torrent_action(action, vec![id]) - .await - .map_err(|e| eyre!("Transmission RPC error: {}", e.to_string()))?; + self.client.torrent_action(action, vec![id]).await?; } Ok(()) } @@ -85,8 +79,7 @@ impl Torrents { if let Some(id) = torrent.id() { self.client .torrent_set_location(vec![id], location.to_string_lossy().into(), move_from) - .await - .map_err(|e| eyre!("Transmission RPC error: {}", e.to_string()))?; + .await?; } Ok(()) } @@ -97,8 +90,7 @@ impl Torrents { pub async fn delete(&mut self, ids: Selected, delete_local_data: bool) -> Result<()> { self.client .torrent_remove(ids.into(), delete_local_data) - .await - .map_err(|e| eyre!("Transmission RPC error: {}", e.to_string()))?; + .await?; Ok(()) } @@ -109,8 +101,7 @@ impl Torrents { if let (Some(id), Some(old_name)) = (torrent.id(), torrent.name.clone()) { self.client .torrent_rename_path(vec![id], old_name, name.to_string_lossy().into()) - .await - .map_err(|e| eyre!("Transmission RPC error: {}", e.to_string()))?; + .await?; } Ok(()) } @@ -125,10 +116,7 @@ impl Torrents { .filter_map(Torrent::id) .collect::>(); - self.client - .torrent_action(action, ids) - .await - .map_err(|e| eyre!("Transmission RPC error: {}", e.to_string()))?; + self.client.torrent_action(action, ids).await?; Ok(()) } } diff --git a/src/app/constants.rs b/src/app/constants.rs new file mode 100644 index 0000000..4b00092 --- /dev/null +++ b/src/app/constants.rs @@ -0,0 +1,7 @@ +pub const DEFAULT_TICK_RATE_MS: u64 = 250; +pub const TORRENT_UPDATE_INTERVAL_SECS: u64 = 2; +pub const DEFAULT_RPC_URL: &str = "http://localhost:9091/transmission/rpc"; + +pub const HELP_POPUP_HEIGHT: u16 = 15; +pub const INPUT_WIDTH_DIVISOR: u16 = 4; +pub const TAB_COUNT: usize = 3; diff --git a/src/app/input.rs b/src/app/input.rs new file mode 100644 index 0000000..18b80d5 --- /dev/null +++ b/src/app/input.rs @@ -0,0 +1,102 @@ +use crate::error::Result; +use std::path::{Path, PathBuf}; +use tokio::fs; + +#[derive(Debug, Default)] +pub struct InputHandler { + pub text: String, + pub cursor_position: usize, + pub completions: Vec, + pub completion_idx: usize, +} + +impl InputHandler { + pub fn new() -> Self { + Self::default() + } + + pub fn insert_char(&mut self, ch: char) { + self.text.insert(self.cursor_position, ch); + self.cursor_position += 1; + } + + pub fn delete_char(&mut self) { + if self.cursor_position > 0 { + self.cursor_position -= 1; + self.text.remove(self.cursor_position); + } + } + + pub fn clear(&mut self) { + self.text.clear(); + self.cursor_position = 0; + self.completions.clear(); + self.completion_idx = 0; + } + + pub fn set_text(&mut self, text: String) { + self.cursor_position = text.len(); + self.text = text; + } + + pub async fn complete(&mut self) -> Result<()> { + let path = PathBuf::from(&self.text); + let (base_path, partial_name) = split_path_components(path); + let matches = find_matching_entries(&base_path, &partial_name).await?; + + self.update_completions(matches); + self.update_from_completions(); + Ok(()) + } + + fn update_completions(&mut self, matches: Vec) { + if matches.is_empty() { + self.completions.clear(); + self.completion_idx = 0; + } else if matches != self.completions { + self.completions = matches; + self.completion_idx = 0; + } else { + self.completion_idx = (self.completion_idx + 1) % self.completions.len(); + } + } + + fn update_from_completions(&mut self) { + if let Some(completions) = self.completions.get(self.completion_idx) { + self.set_text(completions.clone()); + } + } +} + +fn split_path_components(path: PathBuf) -> (PathBuf, String) { + if path.is_dir() { + return (path, String::new()); + } + + let partial = path + .file_name() + .unwrap_or_default() + .to_string_lossy() + .to_string(); + let base = path + .parent() + .unwrap_or_else(|| Path::new("/")) + .to_path_buf(); + (base, partial) +} + +async fn find_matching_entries(base_path: &Path, partial_name: &str) -> Result> { + let mut entries = fs::read_dir(&base_path).await?; + let mut matches = Vec::new(); + + while let Some(entry) = entries.next_entry().await? { + let file_name = entry.file_name().to_string_lossy().to_string(); + if file_name + .to_lowercase() + .starts_with(&partial_name.to_lowercase()) + { + matches.push(format!("{}/{}", base_path.to_string_lossy(), file_name)); + } + } + Ok(matches) +} diff --git a/src/app/mod.rs b/src/app/mod.rs index 261f1b0..24d4a57 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -1,36 +1,34 @@ pub mod action; mod command; +pub mod constants; +mod input; mod tab; mod torrent; pub mod types; pub mod utils; -use crate::config::Config; -use color_eyre::Result; +use crate::error::Result; +use crate::{app::input::InputHandler, config::Config}; use ratatui::widgets::TableState; -use std::path::{Path, PathBuf}; -use tokio::fs; +use std::path::PathBuf; use types::Selected; pub use {tab::Tab, torrent::Torrents}; /// Main Application. #[derive(Debug)] -pub struct App<'a> { +pub struct App { pub running: bool, index: usize, - tabs: &'a [Tab], + tabs: Vec, pub state: TableState, pub torrents: Torrents, pub show_help: bool, pub config: Config, - pub input: String, - pub cursor_position: usize, + pub input_handler: InputHandler, pub input_mode: bool, - pub completions: Vec, - pub completion_idx: usize, } -impl App<'_> { +impl App { /// Constructs a new instance of [`App`]. /// Returns instance of `Self`. /// @@ -40,17 +38,14 @@ impl App<'_> { pub fn new(config: Config) -> Result { Ok(Self { running: true, - tabs: &[Tab::All, Tab::Active, Tab::Downloading], + tabs: vec![Tab::All, Tab::Active, Tab::Downloading], index: 0, state: TableState::default(), torrents: Torrents::new()?, // Handle the Result here show_help: false, config, - input: String::new(), - cursor_position: 0, + input_handler: InputHandler::new(), input_mode: false, - completions: Vec::new(), - completion_idx: 0, }) } @@ -58,14 +53,7 @@ impl App<'_> { /// /// TODO: add error types pub async fn complete_input(&mut self) -> Result<()> { - let path = PathBuf::from(&self.input); - let (base_path, partial_name) = split_path_components(path); - let matches = find_matching_entries(&base_path, &partial_name).await?; - - self.update_completions(matches); - self.update_input_with_matches(); - - Ok(()) + self.input_handler.complete().await } /// Handles the tick event of the terminal. @@ -149,8 +137,8 @@ impl App<'_> { /// Returns [`Tab`] slice #[inline] #[must_use] - pub const fn tabs(&self) -> &[Tab] { - self.tabs + pub fn tabs(&self) -> &[Tab] { + &self.tabs } #[inline] @@ -192,16 +180,18 @@ impl App<'_> { /// /// TODO: add error types pub async fn move_torrent(&mut self) -> Result<()> { - self.torrents.move_selection(&self.input).await?; - self.input.clear(); - self.cursor_position = 0; + self.torrents + .move_selection(&self.input_handler.text) + .await?; + self.input_handler.clear(); self.input_mode = false; Ok(()) } pub fn prepare_move_action(&mut self) { if let Some(download_dir) = self.get_current_downlaod_dir() { - self.update_cursor(&download_dir); + self.input_handler + .set_text(download_dir.to_string_lossy().to_string()); } self.input_mode = true; } @@ -235,29 +225,6 @@ impl App<'_> { Selected::List(selected_torrents) } - fn update_completions(&mut self, matches: Vec) { - if matches.is_empty() { - self.completions.clear(); - self.completion_idx = 0; - return; - } - - if matches != self.completions { - self.completions = matches; - self.completion_idx = 0; - return; - } - - self.completion_idx = (self.completion_idx + 1) % self.completions.len(); - } - - fn update_input_with_matches(&mut self) { - if let Some(completion) = self.completions.get(self.completion_idx) { - self.input = completion.clone(); - self.cursor_position = self.input.len(); - } - } - fn get_current_downlaod_dir(&self) -> Option { match self.selected(true) { Selected::Current(current_id) => self @@ -270,46 +237,4 @@ impl App<'_> { Selected::List(_) => None, } } - - fn update_cursor(&mut self, path: &Path) { - self.input = path.to_string_lossy().to_string(); - self.cursor_position = self.input.len(); - } -} - -fn split_path_components(path: PathBuf) -> (PathBuf, String) { - if path.is_dir() { - return (path, String::new()); - } - - let partial = path - .file_name() - .unwrap_or_default() - .to_string_lossy() - .to_string(); - - let base = path - .parent() - .unwrap_or_else(|| Path::new("/")) - .to_path_buf(); - - (base, partial) -} - -async fn find_matching_entries(base_path: &Path, partial_name: &str) -> Result> { - let mut entries = fs::read_dir(&base_path).await?; - let mut matches = Vec::new(); - - while let Some(entry) = entries.next_entry().await? { - let file_name = entry.file_name().to_string_lossy().to_string(); - - if file_name - .to_lowercase() - .starts_with(&partial_name.to_lowercase()) - { - matches.push(format!("{}/{}", base_path.to_string_lossy(), file_name)); - } - } - - Ok(matches) } diff --git a/src/app/tab.rs b/src/app/tab.rs index 526c627..eb523fa 100644 --- a/src/app/tab.rs +++ b/src/app/tab.rs @@ -2,7 +2,7 @@ use std::fmt::Display; use transmission_rpc::types::TorrentGetField; /// Available tabs. -#[derive(Debug, Default)] +#[derive(Debug, Clone, Default)] pub enum Tab { #[default] All, diff --git a/src/app/torrent.rs b/src/app/torrent.rs index 0cff121..5ca906d 100644 --- a/src/app/torrent.rs +++ b/src/app/torrent.rs @@ -1,4 +1,4 @@ -use color_eyre::{Result, eyre::eyre}; +use crate::{app::constants::DEFAULT_RPC_URL, error::Result}; use std::{collections::HashSet, fmt::Debug}; use transmission_rpc::{ TransClient, @@ -22,7 +22,7 @@ impl Torrents { /// /// TODO: add error types pub fn new() -> Result { - let url = Url::parse("http://localhost:9091/transmission/rpc")?; + let url = Url::parse(DEFAULT_RPC_URL)?; Ok(Self { client: TransClient::new(url), torrents: Vec::new(), @@ -70,8 +70,7 @@ impl Torrents { self.torrents = self .client .torrent_get(self.fields.clone(), None) - .await - .map_err(|e| eyre!("Transmission RPC error: {}", e.to_string()))? + .await? .arguments .torrents; Ok(self) @@ -84,8 +83,7 @@ impl Torrents { let ids: Vec = self.selected.iter().map(|id| Id::Id(*id)).collect(); self.client .torrent_set_location(ids, location.to_string(), Some(true)) - .await - .map_err(|e| eyre!("Transmission RPC error: {}", e.to_string()))?; + .await?; Ok(()) } } diff --git a/src/error.rs b/src/error.rs new file mode 100644 index 0000000..059ad43 --- /dev/null +++ b/src/error.rs @@ -0,0 +1,30 @@ +use thiserror::Error; + +#[derive(Debug, Error)] +pub enum TraxorError { + #[error("Transmission RPC error: {0}")] + TransmissionRpc(String), + + #[error("Configuration error: {0}")] + Config(String), + + #[error("IO error: {0}")] + Io(#[from] std::io::Error), + + #[error("URL parse error: {0}")] + UrlParse(#[from] url::ParseError), + + #[error("No torrent selected")] + NoSelection, + + #[error("Invalid torrent ID: {0}")] + InvalidTorrentId(i64), +} + +impl From> for TraxorError { + fn from(e: Box) -> Self { + Self::TransmissionRpc(e.to_string()) + } +} + +pub type Result = std::result::Result; diff --git a/src/handler.rs b/src/handler.rs index 29da634..b06890a 100644 --- a/src/handler.rs +++ b/src/handler.rs @@ -1,25 +1,23 @@ use crate::app::{App, action::Action}; -use color_eyre::Result; +use crate::error::Result; use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; use thiserror::Error; use tracing::{debug, info}; #[tracing::instrument(name = "Handling input", skip(app))] -async fn handle_input(key_event: KeyEvent, app: &mut App<'_>) -> Result> { +async fn handle_input(key_event: KeyEvent, app: &mut App) -> Result> { match key_event.code { KeyCode::Enter => Ok(Some(Action::Submit)), KeyCode::Tab => { app.complete_input().await?; Ok(None) } - KeyCode::Char(c) => { - app.input.push(c); - app.cursor_position = app.input.len(); + KeyCode::Char(ch) => { + app.input_handler.insert_char(ch); Ok(None) } KeyCode::Backspace => { - app.input.pop(); - app.cursor_position = app.input.len(); + app.input_handler.delete_char(); Ok(None) } KeyCode::Esc => Ok(Some(Action::Cancel)), @@ -33,7 +31,7 @@ async fn handle_input(key_event: KeyEvent, app: &mut App<'_>) -> Result) -> Result> { +pub async fn get_action(key_event: KeyEvent, app: &mut App) -> Result> { if app.input_mode { return handle_input(key_event, app).await; } @@ -74,7 +72,7 @@ pub async fn get_action(key_event: KeyEvent, app: &mut App<'_>) -> Result