refactor: use derive macro

This commit is contained in:
Kristofers Solo 2025-07-07 21:06:38 +03:00
parent 41b3a03e80
commit 7d58d1b74c
Signed by: kristoferssolo
GPG Key ID: 8687F2D3EEE6F0ED
19 changed files with 573 additions and 91 deletions

10
Cargo.lock generated
View File

@ -301,6 +301,15 @@ dependencies = [
"powerfmt",
]
[[package]]
name = "derive_macro"
version = "0.1.0"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "derive_more"
version = "2.0.1"
@ -1951,6 +1960,7 @@ version = "0.1.0"
dependencies = [
"color-eyre",
"crossterm 0.29.0",
"derive_macro",
"dirs",
"ratatui",
"serde",

View File

@ -19,3 +19,4 @@ toml = "0.8"
dirs = "6.0"
transmission-rpc = "0.5"
url = "2.5"
derive_macro = { path = "derive_macro" }

47
derive_macro/Cargo.lock generated Normal file
View File

@ -0,0 +1,47 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "derive_macro"
version = "0.1.0"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "proc-macro2"
version = "1.0.95"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "02b3e5e68a3a1a02aad3ec490a98007cbc13c37cbe84a3cd7b8e406d76e7f778"
dependencies = [
"unicode-ident",
]
[[package]]
name = "quote"
version = "1.0.40"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1885c039570dc00dcb4ff087a89e185fd56bae234ddc7f056a945bf36467248d"
dependencies = [
"proc-macro2",
]
[[package]]
name = "syn"
version = "2.0.104"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "17b6f705963418cdb9927482fa304bc562ece2fdd4f616084c50b7023b435a40"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "unicode-ident"
version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5a5f39404a5da50712a4c1eecf25e90dd62b613502b7e925fd4e4d19b5c96512"

12
derive_macro/Cargo.toml Normal file
View File

@ -0,0 +1,12 @@
[package]
name = "derive_macro"
version = "0.1.0"
edition = "2024"
[lib]
crate-type = ["proc-macro"]
[dependencies]
proc-macro2 = "1.0"
quote = "1.0"
syn = { version = "2.0", features = ["full", "extra-traits"] }

11
derive_macro/src/lib.rs Normal file
View File

@ -0,0 +1,11 @@
mod merge;
use proc_macro::TokenStream;
use syn::{DeriveInput, parse_macro_input};
#[proc_macro_derive(Merge)]
pub fn merge_derive(input: TokenStream) -> TokenStream {
let input = parse_macro_input!(input as DeriveInput);
merge::impl_merge_derive(input)
}

58
derive_macro/src/merge.rs Normal file
View File

@ -0,0 +1,58 @@
use proc_macro::TokenStream;
use quote::quote;
use syn::{Data, DeriveInput, Fields, Type};
pub fn impl_merge_derive(input: DeriveInput) -> TokenStream {
let name = &input.ident;
let (impl_generics, ty_generics, where_clause) = input.generics.split_for_impl();
let fields = match input.data {
Data::Struct(data) => match data.fields {
Fields::Named(fields) => fields.named,
_ => unimplemented!("Only named fields are supported for Merge derive macro"),
},
_ => unimplemented!("Only structs are supported for Merge derive macro"),
};
let merge_logic = fields.iter().map(|field| {
let field_name = &field.ident;
let field_type = &field.ty;
// Check if the field is an Option<T>
if let Type::Path(type_path) = field_type {
if let Some(segment) = type_path.path.segments.last() {
if segment.ident == "Option" {
// This is an Option<T> field
return quote! {
if let Some(o_val) = other.#field_name {
if let Some(s_val) = self.#field_name.as_mut() {
// If both are Some, attempt to merge recursively
s_val.merge(o_val);
} else {
// If self is None, take the other's Some value
self.#field_name = Some(o_val);
}
}
// If other is None, self remains unchanged
};
}
}
}
// For non-Option fields, attempt to merge recursively
quote! {
self.#field_name.merge(other.#field_name);
}
});
let expanded = quote! {
impl #impl_generics Merge for #name #ty_generics #where_clause {
fn merge(&mut self, other: Self) {
#(#merge_logic)*
}
}
};
expanded.into()
}

View File

@ -20,8 +20,9 @@ impl Torrents {
self.client
.torrent_action(action, vec![id])
.await
.map_err(|e| color_eyre::eyre::eyre!("Transmission RPC error: {}", e.to_string()))?;
.map_err(|e| {
color_eyre::eyre::eyre!("Transmission RPC error: {}", e.to_string())
})?;
}
}
Ok(())
@ -48,7 +49,9 @@ impl Torrents {
self.client
.torrent_action(action, vec![id])
.await
.map_err(|e| color_eyre::eyre::eyre!("Transmission RPC error: {}", e.to_string()))?;
.map_err(|e| {
color_eyre::eyre::eyre!("Transmission RPC error: {}", e.to_string())
})?;
}
Ok(())
}
@ -71,12 +74,18 @@ impl Torrents {
self.client
.torrent_set_location(vec![id], location.to_string_lossy().into(), move_from)
.await
.map_err(|e| color_eyre::eyre::eyre!("Transmission RPC error: {}", e.to_string()))?;
.map_err(|e| {
color_eyre::eyre::eyre!("Transmission RPC error: {}", e.to_string())
})?;
}
Ok(())
}
pub async fn delete(&mut self, ids: Selected, delete_local_data: bool) -> color_eyre::eyre::Result<()> {
pub async fn delete(
&mut self,
ids: Selected,
delete_local_data: bool,
) -> color_eyre::eyre::Result<()> {
self.client
.torrent_remove(ids.into(), delete_local_data)
.await
@ -89,7 +98,9 @@ impl Torrents {
self.client
.torrent_rename_path(vec![id], old_name, name.to_string_lossy().into())
.await
.map_err(|e| color_eyre::eyre::eyre!("Transmission RPC error: {}", e.to_string()))?;
.map_err(|e| {
color_eyre::eyre::eyre!("Transmission RPC error: {}", e.to_string())
})?;
}
Ok(())
}

View File

@ -1,8 +1,9 @@
use crate::merge_fields;
use crate::merge::Merge;
use derive_macro::Merge;
use ratatui::prelude::*;
use serde::{Deserialize, Serialize};
#[derive(Debug, Deserialize, Serialize)]
#[derive(Debug, Deserialize, Serialize, Merge)]
pub struct ColorsConfig {
pub highlight_background: Option<String>,
pub highlight_foreground: Option<String>,
@ -32,18 +33,6 @@ impl ColorsConfig {
None => Color::Reset,
}
}
pub fn merge(&mut self, other: Self) {
merge_fields!(
self,
other,
highlight_background,
highlight_foreground,
warning_foreground,
info_foreground,
error_foreground
);
}
}
impl Default for ColorsConfig {

View File

@ -1,7 +1,8 @@
use crate::merge::Merge;
use derive_macro::Merge;
use serde::{Deserialize, Serialize};
use crate::merge_fields;
#[derive(Debug, Deserialize, Serialize)]
#[derive(Debug, Deserialize, Serialize, Merge)]
pub struct KeybindsConfig {
pub quit: Option<String>,
pub next_tab: Option<String>,
@ -19,29 +20,6 @@ pub struct KeybindsConfig {
pub toggle_help: Option<String>,
}
impl KeybindsConfig {
pub fn merge(&mut self, other: Self) {
merge_fields!(
self,
other,
quit,
next_tab,
prev_tab,
next_torrent,
prev_torrent,
switch_tab_1,
switch_tab_2,
switch_tab_3,
toggle_torrent,
toggle_all,
delete,
delete_force,
select,
toggle_help
);
}
}
impl Default for KeybindsConfig {
fn default() -> Self {
Self {

View File

@ -1,24 +1,15 @@
mod colors;
mod keybinds;
use crate::merge::Merge;
use color_eyre::Result;
use colors::ColorsConfig;
use derive_macro::Merge;
use keybinds::KeybindsConfig;
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
#[macro_export]
macro_rules! merge_fields {
($self:ident, $other:ident, $($field:ident),*) => {
$(
if let Some($field) = $other.$field {
$self.$field = Some($field);
}
)*
};
}
#[derive(Debug, Default, Deserialize, Serialize)]
#[derive(Debug, Default, Deserialize, Serialize, Merge)]
pub struct Config {
pub keybinds: KeybindsConfig,
pub colors: ColorsConfig,
@ -64,9 +55,4 @@ impl Config {
});
Ok(config_dir.join("traxor").join("config.toml"))
}
pub fn merge(&mut self, other: Self) {
self.keybinds.merge(other.keybinds);
self.colors.merge(other.colors);
}
}

View File

@ -72,8 +72,12 @@ pub fn get_action(key_event: KeyEvent, app: &App) -> Option<Action> {
_ if matches_keybind(&key_event, &config_keybinds.quit) => Some(Action::Quit),
_ if matches_keybind(&key_event, &config_keybinds.next_tab) => Some(Action::NextTab),
_ if matches_keybind(&key_event, &config_keybinds.prev_tab) => Some(Action::PrevTab),
_ if matches_keybind(&key_event, &config_keybinds.next_torrent) => Some(Action::NextTorrent),
_ if matches_keybind(&key_event, &config_keybinds.prev_torrent) => Some(Action::PrevTorrent),
_ if matches_keybind(&key_event, &config_keybinds.next_torrent) => {
Some(Action::NextTorrent)
}
_ if matches_keybind(&key_event, &config_keybinds.prev_torrent) => {
Some(Action::PrevTorrent)
}
_ if matches_keybind(&key_event, &config_keybinds.switch_tab_1) => {
Some(Action::SwitchTab(0))
}

View File

@ -1,7 +1,8 @@
pub mod config;
pub mod app;
pub mod config;
pub mod event;
pub mod handler;
pub mod log;
pub mod merge;
pub mod tui;
pub mod ui;

View File

@ -53,4 +53,3 @@ async fn main() -> Result<()> {
tui.exit()?;
Ok(())
}

21
src/merge.rs Normal file
View File

@ -0,0 +1,21 @@
pub trait Merge {
fn merge(&mut self, other: Self);
}
impl Merge for String {
fn merge(&mut self, other: Self) {
*self = other;
}
}
impl Merge for u32 {
fn merge(&mut self, other: Self) {
*self = other;
}
}
impl Merge for bool {
fn merge(&mut self, other: Self) {
*self = other;
}
}

View File

@ -1,5 +1,5 @@
use ratatui::{prelude::*, widgets::*};
use crate::app::App;
use ratatui::{prelude::*, widgets::*};
pub fn render_help(frame: &mut Frame, app: &App) {
let block = Block::default()
@ -11,23 +11,62 @@ pub fn render_help(frame: &mut Frame, app: &App) {
let keybinds = &app.config.keybinds;
let rows = vec![
Row::new(vec![Cell::from(keybinds.toggle_help.as_deref().unwrap_or("?")), Cell::from("Show help")]),
Row::new(vec![Cell::from(keybinds.quit.as_deref().unwrap_or("q")), Cell::from("Quit")]),
Row::new(vec![Cell::from(keybinds.prev_tab.as_deref().unwrap_or("h")), Cell::from("Left")]),
Row::new(vec![Cell::from(keybinds.next_tab.as_deref().unwrap_or("l")), Cell::from("Right")]),
Row::new(vec![Cell::from(keybinds.next_torrent.as_deref().unwrap_or("j")), Cell::from("Down")]),
Row::new(vec![Cell::from(keybinds.prev_torrent.as_deref().unwrap_or("k")), Cell::from("Up")]),
Row::new(vec![Cell::from(keybinds.switch_tab_1.as_deref().unwrap_or("1")), Cell::from("Switch to All tab")]),
Row::new(vec![Cell::from(keybinds.switch_tab_2.as_deref().unwrap_or("2")), Cell::from("Switch to Active tab")]),
Row::new(vec![
Cell::from(keybinds.toggle_help.as_deref().unwrap_or("?")),
Cell::from("Show help"),
]),
Row::new(vec![
Cell::from(keybinds.quit.as_deref().unwrap_or("q")),
Cell::from("Quit"),
]),
Row::new(vec![
Cell::from(keybinds.prev_tab.as_deref().unwrap_or("h")),
Cell::from("Left"),
]),
Row::new(vec![
Cell::from(keybinds.next_tab.as_deref().unwrap_or("l")),
Cell::from("Right"),
]),
Row::new(vec![
Cell::from(keybinds.next_torrent.as_deref().unwrap_or("j")),
Cell::from("Down"),
]),
Row::new(vec![
Cell::from(keybinds.prev_torrent.as_deref().unwrap_or("k")),
Cell::from("Up"),
]),
Row::new(vec![
Cell::from(keybinds.switch_tab_1.as_deref().unwrap_or("1")),
Cell::from("Switch to All tab"),
]),
Row::new(vec![
Cell::from(keybinds.switch_tab_2.as_deref().unwrap_or("2")),
Cell::from("Switch to Active tab"),
]),
Row::new(vec![
Cell::from(keybinds.switch_tab_3.as_deref().unwrap_or("3")),
Cell::from("Switch to Downloading tab"),
]),
Row::new(vec![Cell::from(keybinds.toggle_torrent.as_deref().unwrap_or("t")), Cell::from("Toggle torrent")]),
Row::new(vec![Cell::from(keybinds.toggle_all.as_deref().unwrap_or("a")), Cell::from("Toggle all torrents")]),
Row::new(vec![Cell::from(keybinds.delete.as_deref().unwrap_or("d")), Cell::from("Delete torrent")]),
Row::new(vec![Cell::from(keybinds.delete_force.as_deref().unwrap_or("D")), Cell::from("Delete torrent and data")]),
Row::new(vec![Cell::from(keybinds.select.as_deref().unwrap_or(" ")), Cell::from("Select torrent")]),
Row::new(vec![
Cell::from(keybinds.toggle_torrent.as_deref().unwrap_or("t")),
Cell::from("Toggle torrent"),
]),
Row::new(vec![
Cell::from(keybinds.toggle_all.as_deref().unwrap_or("a")),
Cell::from("Toggle all torrents"),
]),
Row::new(vec![
Cell::from(keybinds.delete.as_deref().unwrap_or("d")),
Cell::from("Delete torrent"),
]),
Row::new(vec![
Cell::from(keybinds.delete_force.as_deref().unwrap_or("D")),
Cell::from("Delete torrent and data"),
]),
Row::new(vec![
Cell::from(keybinds.select.as_deref().unwrap_or(" ")),
Cell::from("Select torrent"),
]),
];
let table = Table::new(
@ -35,7 +74,12 @@ pub fn render_help(frame: &mut Frame, app: &App) {
&[Constraint::Percentage(20), Constraint::Percentage(80)],
)
.block(block)
.style(Style::default().fg(app.config.colors.get_color(&app.config.colors.info_foreground)));
.style(
Style::default().fg(app
.config
.colors
.get_color(&app.config.colors.info_foreground)),
);
let area = frame.area();
let height = 15; // Desired height for the help menu
@ -51,4 +95,3 @@ pub fn render_help(frame: &mut Frame, app: &App) {
frame.render_widget(Clear, popup_area);
frame.render_widget(table, popup_area);
}

View File

@ -32,8 +32,18 @@ pub fn render(app: &mut App, frame: &mut Frame) {
.border_type(BorderType::Rounded),
)
.select(app.index())
.style(Style::default().fg(app.config.colors.get_color(&app.config.colors.info_foreground)))
.highlight_style(Style::default().fg(app.config.colors.get_color(&app.config.colors.warning_foreground)))
.style(
Style::default().fg(app
.config
.colors
.get_color(&app.config.colors.info_foreground)),
)
.highlight_style(
Style::default().fg(app
.config
.colors
.get_color(&app.config.colors.warning_foreground)),
)
.divider("|");
frame.render_widget(tabs, chunks[0]); // renders tab

View File

@ -10,8 +10,14 @@ pub fn render_table<'a>(app: &mut App, tab: Tab) -> Table<'a> {
let selected = &app.torrents.selected.clone();
let torrents = &app.torrents.set_fields(None).torrents;
let highlight_bg = app.config.colors.get_color(&app.config.colors.highlight_background);
let highlight_fg = app.config.colors.get_color(&app.config.colors.highlight_foreground);
let highlight_bg = app
.config
.colors
.get_color(&app.config.colors.highlight_background);
let highlight_fg = app
.config
.colors
.get_color(&app.config.colors.highlight_foreground);
let highlight_style = Style::default().bg(highlight_bg).fg(highlight_fg);
let rows: Vec<Row<'_>> = torrents
@ -38,7 +44,10 @@ pub fn render_table<'a>(app: &mut App, tab: Tab) -> Table<'a> {
.map(|&field| Constraint::Length(field.width()))
.collect::<Vec<_>>();
let header_fg = app.config.colors.get_color(&app.config.colors.warning_foreground);
let header_fg = app
.config
.colors
.get_color(&app.config.colors.warning_foreground);
let header = Row::new(
fields
.iter()
@ -47,8 +56,14 @@ pub fn render_table<'a>(app: &mut App, tab: Tab) -> Table<'a> {
)
.style(Style::default().fg(header_fg));
let row_highlight_bg = app.config.colors.get_color(&app.config.colors.info_foreground);
let row_highlight_fg = app.config.colors.get_color(&app.config.colors.highlight_foreground);
let row_highlight_bg = app
.config
.colors
.get_color(&app.config.colors.info_foreground);
let row_highlight_fg = app
.config
.colors
.get_color(&app.config.colors.highlight_foreground);
let row_highlight_style = Style::default().bg(row_highlight_bg).fg(row_highlight_fg);
Table::new(rows, widths)

View File

@ -1,5 +1,5 @@
use crossterm::event::{KeyCode, KeyEvent};
use traxor::{app::action::Action, handler::get_action, app::App, config::Config};
use traxor::{app::action::Action, app::App, config::Config, handler::get_action};
#[test]
fn test_get_action_quit() {

286
tests/merge.rs Normal file
View File

@ -0,0 +1,286 @@
use derive_macro::Merge;
use traxor::merge::Merge;
#[derive(Debug, PartialEq, Merge)]
struct NestedStruct {
value: u32,
name: Option<String>,
}
#[derive(Debug, PartialEq, Merge)]
struct TestStruct {
a: u32,
b: Option<String>,
c: Option<bool>,
nested: NestedStruct,
optional_nested: Option<NestedStruct>,
}
#[test]
fn test_merge_basic_fields() {
let mut s1 = TestStruct {
a: 1,
b: Some("hello".to_string()),
c: None,
nested: NestedStruct {
value: 10,
name: Some("original".to_string()),
},
optional_nested: None,
};
let s2 = TestStruct {
a: 2,
b: Some("world".to_string()),
c: Some(true),
nested: NestedStruct {
value: 20,
name: Some("new".to_string()),
},
optional_nested: Some(NestedStruct {
value: 30,
name: Some("optional".to_string()),
}),
};
s1.merge(s2);
assert_eq!(s1.a, 2);
assert_eq!(s1.b, Some("world".to_string()));
assert_eq!(s1.c, Some(true));
assert_eq!(s1.nested.value, 20);
assert_eq!(s1.nested.name, Some("new".to_string()));
assert_eq!(
s1.optional_nested,
Some(NestedStruct {
value: 30,
name: Some("optional".to_string())
})
);
}
#[test]
fn test_merge_option_none_other() {
let mut s1 = TestStruct {
a: 1,
b: Some("hello".to_string()),
c: Some(false),
nested: NestedStruct {
value: 10,
name: Some("original".to_string()),
},
optional_nested: Some(NestedStruct {
value: 100,
name: Some("existing".to_string()),
}),
};
let s2 = TestStruct {
a: 2,
b: None,
c: None,
nested: NestedStruct {
value: 20,
name: None,
},
optional_nested: None,
};
s1.merge(s2);
assert_eq!(s1.a, 2);
assert_eq!(s1.b, Some("hello".to_string())); // Should remain Some("hello")
assert_eq!(s1.c, Some(false)); // Should remain Some(false)
assert_eq!(s1.nested.value, 20);
assert_eq!(s1.nested.name, Some("original".to_string())); // Should remain original
assert_eq!(
s1.optional_nested,
Some(NestedStruct {
value: 100,
name: Some("existing".to_string())
})
); // Should remain existing
}
#[test]
fn test_merge_option_some_other() {
let mut s1 = TestStruct {
a: 1,
b: None,
c: None,
nested: NestedStruct {
value: 10,
name: None,
},
optional_nested: None,
};
let s2 = TestStruct {
a: 2,
b: Some("world".to_string()),
c: Some(true),
nested: NestedStruct {
value: 20,
name: Some("new".to_string()),
},
optional_nested: Some(NestedStruct {
value: 30,
name: Some("optional".to_string()),
}),
};
s1.merge(s2);
assert_eq!(s1.a, 2);
assert_eq!(s1.b, Some("world".to_string()));
assert_eq!(s1.c, Some(true));
assert_eq!(s1.nested.value, 20);
assert_eq!(s1.nested.name, Some("new".to_string()));
assert_eq!(
s1.optional_nested,
Some(NestedStruct {
value: 30,
name: Some("optional".to_string())
})
);
}
#[test]
fn test_merge_nested_struct_with_none_name() {
let mut s1 = TestStruct {
a: 1,
b: None,
c: None,
nested: NestedStruct {
value: 10,
name: Some("original".to_string()),
},
optional_nested: None,
};
let s2 = TestStruct {
a: 2,
b: None,
c: None,
nested: NestedStruct {
value: 20,
name: None,
},
optional_nested: None,
};
s1.merge(s2);
assert_eq!(s1.nested.value, 20);
assert_eq!(s1.nested.name, Some("original".to_string())); // Should remain original
}
#[test]
fn test_merge_optional_nested_struct_some_to_some() {
let mut s1 = TestStruct {
a: 1,
b: None,
c: None,
nested: NestedStruct {
value: 10,
name: None,
},
optional_nested: Some(NestedStruct {
value: 100,
name: Some("existing".to_string()),
}),
};
let s2 = TestStruct {
a: 2,
b: None,
c: None,
nested: NestedStruct {
value: 20,
name: None,
},
optional_nested: Some(NestedStruct {
value: 200,
name: Some("new_optional".to_string()),
}),
};
s1.merge(s2);
assert_eq!(
s1.optional_nested,
Some(NestedStruct {
value: 200,
name: Some("new_optional".to_string())
})
);
}
#[test]
fn test_merge_optional_nested_struct_none_to_some() {
let mut s1 = TestStruct {
a: 1,
b: None,
c: None,
nested: NestedStruct {
value: 10,
name: None,
},
optional_nested: None,
};
let s2 = TestStruct {
a: 2,
b: None,
c: None,
nested: NestedStruct {
value: 20,
name: None,
},
optional_nested: Some(NestedStruct {
value: 200,
name: Some("new_optional".to_string()),
}),
};
s1.merge(s2);
assert_eq!(
s1.optional_nested,
Some(NestedStruct {
value: 200,
name: Some("new_optional".to_string())
})
);
}
#[test]
fn test_merge_optional_nested_struct_some_to_none() {
let mut s1 = TestStruct {
a: 1,
b: None,
c: None,
nested: NestedStruct {
value: 10,
name: None,
},
optional_nested: Some(NestedStruct {
value: 100,
name: Some("existing".to_string()),
}),
};
let s2 = TestStruct {
a: 2,
b: None,
c: None,
nested: NestedStruct {
value: 20,
name: None,
},
optional_nested: None,
};
s1.merge(s2);
assert_eq!(
s1.optional_nested,
Some(NestedStruct {
value: 100,
name: Some("existing".to_string())
})
); // Should remain existing
}