use anyhow::{bail, Context, Result};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::fmt;
use std::fs;
use std::path::{Path, PathBuf};
#[derive(Clone, Debug, Serialize, Deserialize, Default)]
pub struct Config {
#[serde(default)]
pub general: GeneralConfig,
#[serde(default)]
pub network: NetworkConfig,
#[serde(default)]
pub ui: UiConfig,
#[serde(default)]
pub default_feeds: Vec<DefaultFeed>,
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub keybindings: HashMap<String, toml::Value>,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct GeneralConfig {
#[serde(default = "default_max_dashboard_items")]
pub max_dashboard_items: usize,
#[serde(default)]
pub auto_refresh_interval: u64,
#[serde(default)]
pub refresh_enabled: bool,
#[serde(default = "default_refresh_rate_limit_delay")]
pub refresh_rate_limit_delay: u64,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct NetworkConfig {
#[serde(default = "default_http_timeout")]
pub http_timeout: u64,
#[serde(default = "default_user_agent")]
pub user_agent: String,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct UiConfig {
#[serde(default = "default_tick_rate")]
pub tick_rate: u64,
#[serde(default = "default_error_timeout")]
pub error_display_timeout: u64,
#[serde(default)]
pub theme: Theme,
#[serde(default)]
pub compact_mode: CompactMode,
}
#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum Theme {
Light,
#[default]
Dark,
}
#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum CompactMode {
#[default]
Auto,
Always,
Never,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct DefaultFeed {
pub url: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub category: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub headers: Option<HashMap<String, String>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub refresh_interval: Option<u64>,
}
fn default_max_dashboard_items() -> usize {
100
}
fn default_refresh_rate_limit_delay() -> u64 {
2000 }
fn default_http_timeout() -> u64 {
15
}
fn default_user_agent() -> String {
"Mozilla/5.0 (compatible; Feedr/1.0; +https://github.com/bahdotsh/feedr)".to_string()
}
fn default_tick_rate() -> u64 {
100
}
fn default_error_timeout() -> u64 {
3000
}
impl Default for GeneralConfig {
fn default() -> Self {
Self {
max_dashboard_items: default_max_dashboard_items(),
auto_refresh_interval: 0,
refresh_enabled: false,
refresh_rate_limit_delay: default_refresh_rate_limit_delay(),
}
}
}
impl Default for NetworkConfig {
fn default() -> Self {
Self {
http_timeout: default_http_timeout(),
user_agent: default_user_agent(),
}
}
}
impl Default for UiConfig {
fn default() -> Self {
Self {
tick_rate: default_tick_rate(),
error_display_timeout: default_error_timeout(),
theme: Theme::default(),
compact_mode: CompactMode::default(),
}
}
}
impl fmt::Display for Theme {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Theme::Light => write!(f, "light"),
Theme::Dark => write!(f, "dark"),
}
}
}
impl fmt::Display for CompactMode {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
CompactMode::Auto => write!(f, "auto"),
CompactMode::Always => write!(f, "always"),
CompactMode::Never => write!(f, "never"),
}
}
}
impl Config {
pub fn get_value(&self, key: &str) -> Result<String> {
match key {
"general.max_dashboard_items" => Ok(self.general.max_dashboard_items.to_string()),
"general.auto_refresh_interval" => Ok(self.general.auto_refresh_interval.to_string()),
"general.refresh_enabled" => Ok(self.general.refresh_enabled.to_string()),
"general.refresh_rate_limit_delay" => {
Ok(self.general.refresh_rate_limit_delay.to_string())
}
"network.http_timeout" => Ok(self.network.http_timeout.to_string()),
"network.user_agent" => Ok(self.network.user_agent.clone()),
"ui.tick_rate" => Ok(self.ui.tick_rate.to_string()),
"ui.error_display_timeout" => Ok(self.ui.error_display_timeout.to_string()),
"ui.theme" => Ok(self.ui.theme.to_string()),
"ui.compact_mode" => Ok(self.ui.compact_mode.to_string()),
k if k.starts_with("default_feeds") => {
bail!("Feed management is not supported via CLI. Use 'feedr config --tui' instead.")
}
_ => bail!("Unknown config key: {}", key),
}
}
pub fn validate_and_set(&mut self, key: &str, value: &str) -> Result<()> {
match key {
"general.max_dashboard_items" => {
let v: usize = value.parse().context("Expected a positive integer")?;
if !(1..=10000).contains(&v) {
bail!("Value must be between 1 and 10000");
}
self.general.max_dashboard_items = v;
}
"general.auto_refresh_interval" => {
let v: u64 = value.parse().context("Expected a non-negative integer")?;
if v > 86400 {
bail!("Value must be between 0 and 86400");
}
self.general.auto_refresh_interval = v;
}
"general.refresh_enabled" => {
let v: bool = value.parse().context("Expected 'true' or 'false'")?;
self.general.refresh_enabled = v;
}
"general.refresh_rate_limit_delay" => {
let v: u64 = value.parse().context("Expected a non-negative integer")?;
if v > 60000 {
bail!("Value must be between 0 and 60000");
}
self.general.refresh_rate_limit_delay = v;
}
"network.http_timeout" => {
let v: u64 = value.parse().context("Expected a positive integer")?;
if !(1..=300).contains(&v) {
bail!("Value must be between 1 and 300");
}
self.network.http_timeout = v;
}
"network.user_agent" => {
if value.is_empty() {
bail!("User agent cannot be empty");
}
self.network.user_agent = value.to_string();
}
"ui.tick_rate" => {
let v: u64 = value.parse().context("Expected a positive integer")?;
if !(10..=1000).contains(&v) {
bail!("Value must be between 10 and 1000");
}
self.ui.tick_rate = v;
}
"ui.error_display_timeout" => {
let v: u64 = value.parse().context("Expected a positive integer")?;
if !(500..=30000).contains(&v) {
bail!("Value must be between 500 and 30000");
}
self.ui.error_display_timeout = v;
}
"ui.theme" => match value {
"light" => self.ui.theme = Theme::Light,
"dark" => self.ui.theme = Theme::Dark,
_ => bail!("Invalid theme '{}'. Valid values: light, dark", value),
},
"ui.compact_mode" => match value {
"auto" => self.ui.compact_mode = CompactMode::Auto,
"always" => self.ui.compact_mode = CompactMode::Always,
"never" => self.ui.compact_mode = CompactMode::Never,
_ => bail!(
"Invalid compact_mode '{}'. Valid values: auto, always, never",
value
),
},
k if k.starts_with("default_feeds") => {
bail!("Feed management is not supported via CLI. Use 'feedr config --tui' instead.")
}
_ => bail!("Unknown config key: {}", key),
}
Ok(())
}
pub fn load() -> Result<Self> {
let config_path = Self::config_path();
if config_path.exists() {
let contents =
fs::read_to_string(&config_path).context("Failed to read config file")?;
let config: Config =
toml::from_str(&contents).context("Failed to parse config file")?;
Ok(config)
} else {
let config = Config::default();
if let Err(e) = config.save() {
eprintln!("Warning: Could not create default config file: {}", e);
}
Ok(config)
}
}
pub fn save(&self) -> Result<()> {
let config_path = Self::config_path();
if let Some(parent) = config_path.parent() {
fs::create_dir_all(parent).context("Failed to create config directory")?;
}
let toml_string = toml::to_string_pretty(self).context("Failed to serialize config")?;
let commented_config = Self::add_comments(&toml_string);
fs::write(&config_path, commented_config).context("Failed to write config file")?;
Ok(())
}
pub fn config_path() -> PathBuf {
let mut path = dirs::config_dir().unwrap_or_else(|| Path::new(".").to_path_buf());
path.push("feedr");
path.push("config.toml");
path
}
fn add_comments(toml: &str) -> String {
format!(
"# Feedr Configuration File\n\
# This file is automatically generated with default values.\n\
# You can modify any settings below to customize Feedr's behavior.\n\
#\n\
# For more information, visit: https://github.com/bahdotsh/feedr\n\
\n\
{}\n\
\n\
# Background Refresh Settings:\n\
# - refresh_enabled: Enable automatic background refresh (default: false)\n\
# - auto_refresh_interval: Time in seconds between auto-refreshes (default: 0/disabled)\n\
# - refresh_rate_limit_delay: Delay in milliseconds between requests to same domain (default: 2000ms)\n\
# This prevents \"too many requests\" errors, especially for Reddit feeds\n\
#\n\
# UI Theme Settings:\n\
# - theme: Choose between \"light\" or \"dark\" theme (default: dark)\n\
# You can also toggle the theme in the app by pressing 't'\n\
#\n\
# Example configuration for auto-refresh every 5 minutes:\n\
# [general]\n\
# refresh_enabled = true\n\
# auto_refresh_interval = 300\n\
# refresh_rate_limit_delay = 2000\n\
#\n\
# [ui]\n\
# theme = \"light\"\n\
# compact_mode = \"auto\" # auto (default), always, or never\n\
#\n\
# Example default feeds configuration:\n\
# [[default_feeds]]\n\
# url = \"https://example.com/feed.xml\"\n\
# category = \"News\"\n\
#\n\
# [[default_feeds]]\n\
# url = \"https://another-example.com/rss\"\n\
# category = \"Tech\"\n\
#\n\
# Authenticated feed example (custom HTTP headers):\n\
# [[default_feeds]]\n\
# url = \"https://private.example.com/feed.xml\"\n\
# [default_feeds.headers]\n\
# Authorization = \"Bearer your_token_here\"\n",
toml
)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_default_config() {
let config = Config::default();
assert_eq!(config.general.max_dashboard_items, 100);
assert_eq!(config.network.http_timeout, 15);
assert_eq!(config.ui.tick_rate, 100);
assert_eq!(config.ui.error_display_timeout, 3000);
}
#[test]
fn test_default_feed_with_headers() {
let toml_str = r#"
[[default_feeds]]
url = "https://example.com/feed.xml"
[[default_feeds]]
url = "https://private.example.com/feed.xml"
[default_feeds.headers]
Authorization = "Bearer token123"
X-Custom = "value"
"#;
let config: Config = toml::from_str(toml_str).unwrap();
assert_eq!(config.default_feeds.len(), 2);
assert!(config.default_feeds[0].headers.is_none());
let headers = config.default_feeds[1].headers.as_ref().unwrap();
assert_eq!(headers.get("Authorization").unwrap(), "Bearer token123");
assert_eq!(headers.get("X-Custom").unwrap(), "value");
}
#[test]
fn test_config_serialization() {
let config = Config::default();
let toml_str = toml::to_string(&config).unwrap();
let deserialized: Config = toml::from_str(&toml_str).unwrap();
assert_eq!(
config.general.max_dashboard_items,
deserialized.general.max_dashboard_items
);
assert_eq!(
config.network.http_timeout,
deserialized.network.http_timeout
);
}
}