refactor: use config file as single source of truth for defaults

This commit is contained in:
Kristofers Solo 2026-01-01 04:49:48 +02:00
parent eba2eefc5e
commit dabb434011
Signed by: kristoferssolo
GPG Key ID: 8687F2D3EEE6F0ED
8 changed files with 116 additions and 252 deletions

71
Cargo.lock generated
View File

@ -523,31 +523,6 @@ dependencies = [
"regex", "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]] [[package]]
name = "filedescriptor" name = "filedescriptor"
version = "0.8.3" version = "0.8.3"
@ -1156,28 +1131,6 @@ dependencies = [
"autocfg", "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]] [[package]]
name = "minimal-lexical" name = "minimal-lexical"
version = "0.2.1" version = "0.2.1"
@ -1481,28 +1434,6 @@ dependencies = [
"zerocopy", "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]] [[package]]
name = "proc-macro2" name = "proc-macro2"
version = "1.0.95" version = "1.0.95"
@ -2582,8 +2513,6 @@ dependencies = [
"crossterm", "crossterm",
"derive_more", "derive_more",
"dirs", "dirs",
"filecaster",
"merge",
"ratatui", "ratatui",
"serde", "serde",
"thiserror 2.0.12", "thiserror 2.0.12",

View File

@ -6,13 +6,11 @@ license = "GPLv3"
edition = "2024" edition = "2024"
[dependencies] [dependencies]
filecaster = { version = "0.2", features = ["derive", "merge"] }
color-eyre = "0.6" color-eyre = "0.6"
crossterm = "0.29" crossterm = "0.29"
derive_more = { version = "2.1", features = ["display"] } derive_more = { version = "2.1", features = ["display"] }
dirs = "6.0" dirs = "6.0"
merge = "0.2" ratatui = "0.30"
ratatui = { version = "0.30" }
serde = { version = "1.0", features = ["derive"] } serde = { version = "1.0", features = ["derive"] }
thiserror = "2.0" thiserror = "2.0"
tokio = { version = "1", features = ["macros", "rt-multi-thread", "fs"] } tokio = { version = "1", features = ["macros", "rt-multi-thread", "fs"] }

View File

@ -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] [keybinds]
quit = "q" # Navigation
next_tab = "l"
prev_tab = "h"
next_torrent = "j"
prev_torrent = "k" prev_torrent = "k"
next_torrent = "j"
prev_tab = "h"
next_tab = "l"
# Tab switching
switch_tab_1 = "1" switch_tab_1 = "1"
switch_tab_2 = "2" switch_tab_2 = "2"
switch_tab_3 = "3" switch_tab_3 = "3"
# Torrent actions
toggle_torrent = "enter" toggle_torrent = "enter"
toggle_all = "a" toggle_all = "a"
select = " "
move_torrent = "m"
rename_torrent = "r"
delete = "d" delete = "d"
delete_force = "D" delete_force = "D"
select = " "
toggle_help = "?"
move = "m"
# General
toggle_help = "?"
quit = "q"
# ============================================================================
# COLORS
# ============================================================================
[colors] [colors]
# UI colors
highlight_background = "magenta" highlight_background = "magenta"
highlight_foreground = "black" highlight_foreground = "black"
header_foreground = "yellow" header_foreground = "yellow"
info_foreground = "blue" 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] [log]
traxor = "info" traxor = "info"
ratatui = "warn" ratatui = "warn"
transmission_rpc = "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: # Available columns:
# name, status, size, downloaded, uploaded, ratio, progress, eta, # name, status, size, downloaded, uploaded, ratio, progress, eta,
# peers, seeds, leeches, downspeed, upspeed, path, added, done, # peers, seeds, leeches, downspeed, upspeed, path, added, done,
# left, queue, error, labels, tracker, hash, private, stalled, # left, queue, error, labels, tracker, hash, private, stalled,
# finished, files, activity # finished, files, activity
#
# Example: [[tabs]]
# [[tabs]] name = "All"
# name = "All" columns = ["status", "leeches", "ratio", "size", "uploaded", "path", "name"]
# columns = ["status", "ratio", "size", "uploaded", "path", "name"]
# [[tabs]]
# [[tabs]] name = "Active"
# name = "Active" columns = [
# columns = ["progress", "downspeed", "upspeed", "eta", "name"] "size",
# "uploaded",
# [[tabs]] "ratio",
# name = "Downloading" "leeches",
# columns = ["size", "left", "progress", "downspeed", "eta", "name"] "seeds",
"status",
"eta",
"progress",
"downspeed",
"upspeed",
"name",
]
[[tabs]]
name = "Downloading"
columns = ["size", "left", "progress", "downspeed", "eta", "path", "name"]

View File

@ -1,25 +1,14 @@
use filecaster::FromFile; use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, FromFile)] #[derive(Debug, Clone, Deserialize, Serialize)]
pub struct ColorConfig { pub struct ColorConfig {
#[from_file(default = "magenta")]
pub highlight_background: String, pub highlight_background: String,
#[from_file(default = "black")]
pub highlight_foreground: String, pub highlight_foreground: String,
#[from_file(default = "yellow")]
pub header_foreground: String, pub header_foreground: String,
#[from_file(default = "blue")]
pub info_foreground: String, pub info_foreground: String,
// Status colors
#[from_file(default = "cyan")]
pub status_downloading: String, pub status_downloading: String,
#[from_file(default = "white")]
pub status_seeding: String, pub status_seeding: String,
#[from_file(default = "dark_gray")]
pub status_stopped: String, pub status_stopped: String,
#[from_file(default = "yellow")]
pub status_verifying: String, pub status_verifying: String,
#[from_file(default = "light_blue")]
pub status_queued: String, pub status_queued: String,
} }

View File

@ -1,37 +1,21 @@
use filecaster::FromFile; use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, FromFile)] #[derive(Debug, Clone, Deserialize, Serialize)]
pub struct KeybindsConfig { pub struct KeybindsConfig {
#[from_file(default = "q")]
pub quit: String, pub quit: String,
#[from_file(default = "l")]
pub next_tab: String, pub next_tab: String,
#[from_file(default = "h")]
pub prev_tab: String, pub prev_tab: String,
#[from_file(default = "j")]
pub next_torrent: String, pub next_torrent: String,
#[from_file(default = "k")]
pub prev_torrent: String, pub prev_torrent: String,
#[from_file(default = "1")]
pub switch_tab_1: String, pub switch_tab_1: String,
#[from_file(default = "2")]
pub switch_tab_2: String, pub switch_tab_2: String,
#[from_file(default = "3")]
pub switch_tab_3: String, pub switch_tab_3: String,
#[from_file(default = "enter")]
pub toggle_torrent: String, pub toggle_torrent: String,
#[from_file(default = "a")]
pub toggle_all: String, pub toggle_all: String,
#[from_file(default = "d")]
pub delete: String, pub delete: String,
#[from_file(default = "D")]
pub delete_force: String, pub delete_force: String,
#[from_file(default = " ")]
pub select: String, pub select: String,
#[from_file(default = "?")]
pub toggle_help: String, pub toggle_help: String,
#[from_file(default = "m")]
pub move_torrent: String, pub move_torrent: String,
#[from_file(default = "r")]
pub rename_torrent: String, pub rename_torrent: String,
} }

View File

@ -1,11 +1,8 @@
use filecaster::FromFile; use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, FromFile)] #[derive(Debug, Clone, Deserialize, Serialize)]
pub struct LogConfig { pub struct LogConfig {
#[from_file(default = "warn")]
pub traxor: String, pub traxor: String,
#[from_file(default = "warn")]
pub ratatui: String, pub ratatui: String,
#[from_file(default = "warn")]
pub transmission_rpc: String, pub transmission_rpc: String,
} }

View File

@ -6,133 +6,104 @@ pub mod tabs;
use color::ColorConfig; use color::ColorConfig;
use color_eyre::{ use color_eyre::{
Result, Result,
eyre::{Context, ContextCompat, Ok}, eyre::{Context, ContextCompat},
}; };
use filecaster::FromFile;
use keybinds::KeybindsConfig; use keybinds::KeybindsConfig;
use log::LogConfig; use log::LogConfig;
use merge::Merge; use serde::{Deserialize, Serialize};
use serde::Deserialize;
use std::{ use std::{
fs::read_to_string, fs::read_to_string,
path::{Path, PathBuf}, path::{Path, PathBuf},
}; };
use tabs::TabConfig; 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 struct Config {
pub keybinds: KeybindsConfig, pub keybinds: KeybindsConfig,
pub colors: ColorConfig, pub colors: ColorConfig,
pub log: LogConfig, pub log: LogConfig,
#[from_file(skip)]
pub tabs: Vec<TabConfig>,
}
/// Helper struct for parsing tabs from TOML.
#[derive(Debug, Deserialize, Default)]
struct TabsFile {
#[serde(default)] #[serde(default)]
tabs: Vec<TabConfig>, pub tabs: Vec<TabConfig>,
} }
impl Config { impl Config {
/// Load configuration with fallback to embedded defaults. /// Load configuration with fallback to embedded defaults.
/// ///
/// Merge order: /// Merge order:
/// 1. Embedded defaults /// 1. Embedded defaults (config/default.toml - compiled in)
/// 2. System-wide config (`/etc/xdg/traxor/config.toml`) /// 2. System-wide config (`/etc/xdg/traxor/config.toml`)
/// 3. User config (`~/.config/traxor/config.toml`) /// 3. User config (`~/.config/traxor/config.toml`)
/// ///
/// # Errors /// # Errors
/// ///
/// Returns an error if: /// Returns an error if the TOML in any config file is invalid.
/// - 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.
#[tracing::instrument(name = "Loading configuration")] #[tracing::instrument(name = "Loading configuration")]
pub fn load() -> Result<Self> { pub fn load() -> Result<Self> {
let mut cfg_file = toml::from_str::<ConfigFile>(DEFAULT_CONFIG_STR) let mut config: Value =
.context("Failed to parse embedded default config")?; toml::from_str(DEFAULT_CONFIG).context("Failed to parse embedded default config")?;
let candidates = [ let candidates = [
("system-wide", PathBuf::from("/etc/xdg/traxor/config.toml")), ("system-wide", PathBuf::from("/etc/xdg/traxor/config.toml")),
("user-specific", get_config_path()?), ("user-specific", get_config_path()?),
]; ];
let mut tabs: Option<Vec<TabConfig>> = None;
for (label, path) in &candidates { for (label, path) in &candidates {
merge_config(&mut cfg_file, label, path)?; if let Some(user_config) = load_toml_file(label, path)? {
// Load tabs separately (last one wins) deep_merge(&mut config, user_config);
if let Some(t) = load_tabs(path) {
tabs = Some(t);
} }
} }
let mut config: Self = cfg_file.into(); let config: Self = config
config.tabs = tabs.unwrap_or_else(tabs::default_tabs); .try_into()
.context("Failed to deserialize merged config")?;
debug!("Configuration loaded successfully."); debug!("Configuration loaded successfully.");
Ok(config) Ok(config)
} }
} }
fn load_tabs(path: &Path) -> Option<Vec<TabConfig>> {
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<PathBuf> { fn get_config_path() -> Result<PathBuf> {
let config_dir = let config_dir =
dirs::config_dir().context("Could not determine user configuration directory")?; dirs::config_dir().context("Could not determine user configuration directory")?;
Ok(config_dir.join("traxor").join("config.toml")) Ok(config_dir.join("traxor").join("config.toml"))
} }
#[tracing::instrument(name = "Merging config", skip(cfg_file, path))] fn load_toml_file(label: &str, path: &Path) -> Result<Option<Value>> {
fn merge_config(cfg_file: &mut ConfigFile, label: &str, path: &Path) -> Result<()> { if !path.exists() {
if !exists_and_log(label, path) { debug!("{} config not found at: {:?}", label, path);
return Ok(()); return Ok(None);
} }
info!("Loading {} config from: {:?}", label, path); info!("Loading {} config from: {:?}", label, path);
let s = read_config_str(label, path)?; let content = read_to_string(path)
let other = parse_config_toml(label, &s)?; .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); info!("Successfully loaded {} config.", label);
Ok(()) Ok(Some(value))
} }
fn exists_and_log(label: &str, path: &Path) -> bool { /// Deep merge two TOML values. User values override defaults.
if !path.exists() { /// For tables, merge recursively. For arrays (like tabs), replace entirely.
warn!("{} config not found at: {:?}", label, path); fn deep_merge(base: &mut Value, other: Value) {
return false; 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<String> {
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<ConfigFile> {
toml::from_str::<ConfigFile>(s)
.with_context(|| format!("Failed to parse TOML in {label} config"))
} }

View File

@ -16,7 +16,6 @@ impl TabConfig {
} }
fn parse_field(s: &str) -> Option<TorrentGetField> { fn parse_field(s: &str) -> Option<TorrentGetField> {
// Match against known field names (case-insensitive)
Some(match s.to_lowercase().as_str() { Some(match s.to_lowercase().as_str() {
"name" => TorrentGetField::Name, "name" => TorrentGetField::Name,
"status" => TorrentGetField::Status, "status" => TorrentGetField::Status,
@ -49,50 +48,3 @@ fn parse_field(s: &str) -> Option<TorrentGetField> {
_ => return None, _ => return None,
}) })
} }
/// Default tabs matching original hardcoded behavior.
#[must_use]
pub fn default_tabs() -> Vec<TabConfig> {
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(),
],
},
]
}