diff --git a/Cargo.lock b/Cargo.lock index 3fdfb9d..28d4124 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -523,31 +523,6 @@ dependencies = [ "regex", ] -[[package]] -name = "filecaster" -version = "0.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc97921cbd5451445637b91a0237b3c9316fa550ea7ff166a40be7ce5afad335" -dependencies = [ - "filecaster-derive", - "merge", - "serde", -] - -[[package]] -name = "filecaster-derive" -version = "0.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f5c7bc432f529eac3ec2d60812e05c1eae39fde9d7434fe59e64c03c27da882" -dependencies = [ - "merge", - "proc-macro-error2", - "proc-macro2", - "quote", - "serde", - "syn 2.0.104", -] - [[package]] name = "filedescriptor" version = "0.8.3" @@ -1156,28 +1131,6 @@ dependencies = [ "autocfg", ] -[[package]] -name = "merge" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56e520ba58faea3487f75df198b1d079644ec226ea3b0507d002c6fa4b8cf93a" -dependencies = [ - "merge_derive", - "num-traits", -] - -[[package]] -name = "merge_derive" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c8f8ce6efff81cbc83caf4af0905c46e58cb46892f63ad3835e81b47eaf7968" -dependencies = [ - "proc-macro-error2", - "proc-macro2", - "quote", - "syn 2.0.104", -] - [[package]] name = "minimal-lexical" version = "0.2.1" @@ -1481,28 +1434,6 @@ dependencies = [ "zerocopy", ] -[[package]] -name = "proc-macro-error-attr2" -version = "2.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96de42df36bb9bba5542fe9f1a054b8cc87e172759a1868aa05c1f3acc89dfc5" -dependencies = [ - "proc-macro2", - "quote", -] - -[[package]] -name = "proc-macro-error2" -version = "2.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11ec05c52be0a07b08061f7dd003e7d7092e0472bc731b4af7bb1ef876109802" -dependencies = [ - "proc-macro-error-attr2", - "proc-macro2", - "quote", - "syn 2.0.104", -] - [[package]] name = "proc-macro2" version = "1.0.95" @@ -2582,8 +2513,6 @@ dependencies = [ "crossterm", "derive_more", "dirs", - "filecaster", - "merge", "ratatui", "serde", "thiserror 2.0.12", diff --git a/Cargo.toml b/Cargo.toml index ca59521..2ac73f9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,13 +6,11 @@ license = "GPLv3" edition = "2024" [dependencies] -filecaster = { version = "0.2", features = ["derive", "merge"] } color-eyre = "0.6" crossterm = "0.29" derive_more = { version = "2.1", features = ["display"] } dirs = "6.0" -merge = "0.2" -ratatui = { version = "0.30" } +ratatui = "0.30" serde = { version = "1.0", features = ["derive"] } thiserror = "2.0" tokio = { version = "1", features = ["macros", "rt-multi-thread", "fs"] } diff --git a/config/default.toml b/config/default.toml index cc7286d..b7cba82 100644 --- a/config/default.toml +++ b/config/default.toml @@ -1,49 +1,93 @@ +# ============================================================================ +# TRAXOR CONFIGURATION +# ============================================================================ +# This file defines all default settings. User config (~/.config/traxor/config.toml) +# overrides these values. Only specify what you want to change in your user config. + +# ============================================================================ +# KEYBINDS +# ============================================================================ [keybinds] -quit = "q" -next_tab = "l" -prev_tab = "h" -next_torrent = "j" +# Navigation prev_torrent = "k" +next_torrent = "j" +prev_tab = "h" +next_tab = "l" + +# Tab switching switch_tab_1 = "1" switch_tab_2 = "2" switch_tab_3 = "3" + +# Torrent actions toggle_torrent = "enter" toggle_all = "a" +select = " " +move_torrent = "m" +rename_torrent = "r" delete = "d" delete_force = "D" -select = " " -toggle_help = "?" -move = "m" +# General +toggle_help = "?" +quit = "q" + +# ============================================================================ +# COLORS +# ============================================================================ [colors] +# UI colors highlight_background = "magenta" highlight_foreground = "black" header_foreground = "yellow" info_foreground = "blue" -error_foreground = "red" +# Status colors (torrent state) +status_downloading = "cyan" +status_seeding = "white" +status_stopped = "dark_gray" +status_verifying = "yellow" +status_queued = "light_blue" + +# ============================================================================ +# LOGGING +# ============================================================================ [log] traxor = "info" ratatui = "warn" transmission_rpc = "warn" -# Custom tabs configuration -# Each [[tabs]] entry defines a tab with a name and list of columns. +# ============================================================================ +# TABS +# ============================================================================ +# Define custom tabs with specific columns. Each tab needs a name and columns list. +# # Available columns: # name, status, size, downloaded, uploaded, ratio, progress, eta, # peers, seeds, leeches, downspeed, upspeed, path, added, done, # left, queue, error, labels, tracker, hash, private, stalled, # finished, files, activity -# -# Example: -# [[tabs]] -# name = "All" -# columns = ["status", "ratio", "size", "uploaded", "path", "name"] -# -# [[tabs]] -# name = "Active" -# columns = ["progress", "downspeed", "upspeed", "eta", "name"] -# -# [[tabs]] -# name = "Downloading" -# columns = ["size", "left", "progress", "downspeed", "eta", "name"] + +[[tabs]] +name = "All" +columns = ["status", "leeches", "ratio", "size", "uploaded", "path", "name"] + +[[tabs]] +name = "Active" +columns = [ + "size", + "uploaded", + "ratio", + "leeches", + "seeds", + "status", + "eta", + "progress", + "downspeed", + "upspeed", + "name", +] + +[[tabs]] +name = "Downloading" +columns = ["size", "left", "progress", "downspeed", "eta", "path", "name"] diff --git a/src/config/color.rs b/src/config/color.rs index 48abeff..3c9e6d1 100644 --- a/src/config/color.rs +++ b/src/config/color.rs @@ -1,25 +1,14 @@ -use filecaster::FromFile; +use serde::{Deserialize, Serialize}; -#[derive(Debug, Clone, FromFile)] +#[derive(Debug, Clone, Deserialize, Serialize)] pub struct ColorConfig { - #[from_file(default = "magenta")] pub highlight_background: String, - #[from_file(default = "black")] pub highlight_foreground: String, - #[from_file(default = "yellow")] pub header_foreground: String, - #[from_file(default = "blue")] pub info_foreground: String, - - // Status colors - #[from_file(default = "cyan")] pub status_downloading: String, - #[from_file(default = "white")] pub status_seeding: String, - #[from_file(default = "dark_gray")] pub status_stopped: String, - #[from_file(default = "yellow")] pub status_verifying: String, - #[from_file(default = "light_blue")] pub status_queued: String, } diff --git a/src/config/keybinds.rs b/src/config/keybinds.rs index cbde39b..ac67c65 100644 --- a/src/config/keybinds.rs +++ b/src/config/keybinds.rs @@ -1,37 +1,21 @@ -use filecaster::FromFile; +use serde::{Deserialize, Serialize}; -#[derive(Debug, Clone, FromFile)] +#[derive(Debug, Clone, Deserialize, Serialize)] pub struct KeybindsConfig { - #[from_file(default = "q")] pub quit: String, - #[from_file(default = "l")] pub next_tab: String, - #[from_file(default = "h")] pub prev_tab: String, - #[from_file(default = "j")] pub next_torrent: String, - #[from_file(default = "k")] pub prev_torrent: String, - #[from_file(default = "1")] pub switch_tab_1: String, - #[from_file(default = "2")] pub switch_tab_2: String, - #[from_file(default = "3")] pub switch_tab_3: String, - #[from_file(default = "enter")] pub toggle_torrent: String, - #[from_file(default = "a")] pub toggle_all: String, - #[from_file(default = "d")] pub delete: String, - #[from_file(default = "D")] pub delete_force: String, - #[from_file(default = " ")] pub select: String, - #[from_file(default = "?")] pub toggle_help: String, - #[from_file(default = "m")] pub move_torrent: String, - #[from_file(default = "r")] pub rename_torrent: String, } diff --git a/src/config/log.rs b/src/config/log.rs index a7d8b60..ed3a3a5 100644 --- a/src/config/log.rs +++ b/src/config/log.rs @@ -1,11 +1,8 @@ -use filecaster::FromFile; +use serde::{Deserialize, Serialize}; -#[derive(Debug, Clone, FromFile)] +#[derive(Debug, Clone, Deserialize, Serialize)] pub struct LogConfig { - #[from_file(default = "warn")] pub traxor: String, - #[from_file(default = "warn")] pub ratatui: String, - #[from_file(default = "warn")] pub transmission_rpc: String, } diff --git a/src/config/mod.rs b/src/config/mod.rs index 9653520..1400e32 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -6,133 +6,104 @@ pub mod tabs; use color::ColorConfig; use color_eyre::{ Result, - eyre::{Context, ContextCompat, Ok}, + eyre::{Context, ContextCompat}, }; -use filecaster::FromFile; use keybinds::KeybindsConfig; use log::LogConfig; -use merge::Merge; -use serde::Deserialize; +use serde::{Deserialize, Serialize}; use std::{ fs::read_to_string, path::{Path, PathBuf}, }; use tabs::TabConfig; -use tracing::{debug, info, warn}; +use toml::Value; +use tracing::{debug, info}; -const DEFAULT_CONFIG_STR: &str = include_str!("../../config/default.toml"); +/// Embedded default configuration - single source of truth for all defaults. +const DEFAULT_CONFIG: &str = include_str!("../../config/default.toml"); -#[derive(Debug, Clone, FromFile)] +#[derive(Debug, Clone, Deserialize, Serialize)] pub struct Config { pub keybinds: KeybindsConfig, pub colors: ColorConfig, pub log: LogConfig, - #[from_file(skip)] - pub tabs: Vec, -} - -/// Helper struct for parsing tabs from TOML. -#[derive(Debug, Deserialize, Default)] -struct TabsFile { #[serde(default)] - tabs: Vec, + pub tabs: Vec, } impl Config { /// Load configuration with fallback to embedded defaults. /// /// Merge order: - /// 1. Embedded defaults + /// 1. Embedded defaults (config/default.toml - compiled in) /// 2. System-wide config (`/etc/xdg/traxor/config.toml`) /// 3. User config (`~/.config/traxor/config.toml`) /// /// # Errors /// - /// Returns an error if: - /// - The embedded default config cannot be parsed (should never happen unless corrupted at build time). - /// - The system-wide or user config file cannot be read due to I/O errors. - /// - The TOML in any config file is invalid and cannot be parsed. + /// Returns an error if the TOML in any config file is invalid. #[tracing::instrument(name = "Loading configuration")] pub fn load() -> Result { - let mut cfg_file = toml::from_str::(DEFAULT_CONFIG_STR) - .context("Failed to parse embedded default config")?; + let mut config: Value = + toml::from_str(DEFAULT_CONFIG).context("Failed to parse embedded default config")?; let candidates = [ ("system-wide", PathBuf::from("/etc/xdg/traxor/config.toml")), ("user-specific", get_config_path()?), ]; - let mut tabs: Option> = None; - for (label, path) in &candidates { - merge_config(&mut cfg_file, label, path)?; - // Load tabs separately (last one wins) - if let Some(t) = load_tabs(path) { - tabs = Some(t); + if let Some(user_config) = load_toml_file(label, path)? { + deep_merge(&mut config, user_config); } } - let mut config: Self = cfg_file.into(); - config.tabs = tabs.unwrap_or_else(tabs::default_tabs); + let config: Self = config + .try_into() + .context("Failed to deserialize merged config")?; debug!("Configuration loaded successfully."); Ok(config) } } -fn load_tabs(path: &Path) -> Option> { - if !path.exists() { - return None; - } - let content = read_to_string(path).ok()?; - let tabs_file: TabsFile = toml::from_str(&content).ok()?; - if tabs_file.tabs.is_empty() { - None - } else { - Some(tabs_file.tabs) - } -} - -#[tracing::instrument(name = "Getting config path")] fn get_config_path() -> Result { let config_dir = dirs::config_dir().context("Could not determine user configuration directory")?; Ok(config_dir.join("traxor").join("config.toml")) } -#[tracing::instrument(name = "Merging config", skip(cfg_file, path))] -fn merge_config(cfg_file: &mut ConfigFile, label: &str, path: &Path) -> Result<()> { - if !exists_and_log(label, path) { - return Ok(()); +fn load_toml_file(label: &str, path: &Path) -> Result> { + if !path.exists() { + debug!("{} config not found at: {:?}", label, path); + return Ok(None); } info!("Loading {} config from: {:?}", label, path); - let s = read_config_str(label, path)?; - let other = parse_config_toml(label, &s)?; + let content = read_to_string(path) + .with_context(|| format!("Failed to read {label} config: {}", path.display()))?; + + let value: Value = + toml::from_str(&content).with_context(|| format!("Failed to parse {label} config TOML"))?; - cfg_file.merge(other); info!("Successfully loaded {} config.", label); - Ok(()) + Ok(Some(value)) } -fn exists_and_log(label: &str, path: &Path) -> bool { - if !path.exists() { - warn!("{} config not found at: {:?}", label, path); - return false; +/// Deep merge two TOML values. User values override defaults. +/// For tables, merge recursively. For arrays (like tabs), replace entirely. +fn deep_merge(base: &mut Value, other: Value) { + match (base, other) { + (Value::Table(base_table), Value::Table(other_table)) => { + for (key, other_value) in other_table { + match base_table.get_mut(&key) { + Some(base_value) => deep_merge(base_value, other_value), + None => { + base_table.insert(key, other_value); + } + } + } + } + (base, other) => *base = other, } - true -} - -fn read_config_str(label: &str, path: &Path) -> Result { - read_to_string(path).with_context(|| { - format!( - "Failed to read {label} config file at {}", - path.to_string_lossy() - ) - }) -} - -fn parse_config_toml(label: &str, s: &str) -> Result { - toml::from_str::(s) - .with_context(|| format!("Failed to parse TOML in {label} config")) } diff --git a/src/config/tabs.rs b/src/config/tabs.rs index 83fc256..0323947 100644 --- a/src/config/tabs.rs +++ b/src/config/tabs.rs @@ -16,7 +16,6 @@ impl TabConfig { } fn parse_field(s: &str) -> Option { - // Match against known field names (case-insensitive) Some(match s.to_lowercase().as_str() { "name" => TorrentGetField::Name, "status" => TorrentGetField::Status, @@ -49,50 +48,3 @@ fn parse_field(s: &str) -> Option { _ => return None, }) } - -/// Default tabs matching original hardcoded behavior. -#[must_use] -pub fn default_tabs() -> Vec { - vec![ - TabConfig { - name: "All".into(), - columns: vec![ - "status".into(), - "peers_getting".into(), - "ratio".into(), - "size".into(), - "uploaded".into(), - "path".into(), - "name".into(), - ], - }, - TabConfig { - name: "Active".into(), - columns: vec![ - "size".into(), - "uploaded".into(), - "ratio".into(), - "peers_getting".into(), - "seeds".into(), - "status".into(), - "eta".into(), - "progress".into(), - "downspeed".into(), - "upspeed".into(), - "name".into(), - ], - }, - TabConfig { - name: "Downloading".into(), - columns: vec![ - "size".into(), - "left".into(), - "progress".into(), - "downspeed".into(), - "eta".into(), - "path".into(), - "name".into(), - ], - }, - ] -}