mirror of
https://github.com/kristoferssolo/traxor.git
synced 2026-02-04 06:42:04 +00:00
refactor: use config file as single source of truth for defaults
This commit is contained in:
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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<TabConfig>,
|
||||
}
|
||||
|
||||
/// Helper struct for parsing tabs from TOML.
|
||||
#[derive(Debug, Deserialize, Default)]
|
||||
struct TabsFile {
|
||||
#[serde(default)]
|
||||
tabs: Vec<TabConfig>,
|
||||
pub tabs: Vec<TabConfig>,
|
||||
}
|
||||
|
||||
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<Self> {
|
||||
let mut cfg_file = toml::from_str::<ConfigFile>(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<Vec<TabConfig>> = 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<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> {
|
||||
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<Option<Value>> {
|
||||
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<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"))
|
||||
}
|
||||
|
||||
@@ -16,7 +16,6 @@ impl TabConfig {
|
||||
}
|
||||
|
||||
fn parse_field(s: &str) -> Option<TorrentGetField> {
|
||||
// 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<TorrentGetField> {
|
||||
_ => 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(),
|
||||
],
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user