use {
schemars::JsonSchema,
serde::{Deserialize, Serialize},
smart_default::SmartDefault,
};
#[cfg(feature = "cli")]
use {
crate::{
config::validate::{Validate, format_validation_errors},
utils::FileWriter,
},
color_eyre::{
Section, SectionExt,
eyre::{Context, OptionExt, Result, eyre},
},
config::{Config, ConfigBuilder},
std::{
io::Write,
path::{Path, PathBuf},
},
tracing::info,
};
#[derive(Serialize, Deserialize, Clone, Copy, Debug, JsonSchema, SmartDefault)]
#[schemars(bound = "T: JsonSchema + Default")]
pub enum SizeFormat {
Bits,
Bytes,
KiloBytes,
KiloBits,
#[default]
MegaBytes,
MegaBits,
}
impl SizeFormat {
pub fn format_size(&self, bytes: u64) -> String {
let bits = bytes * 8;
match self {
SizeFormat::Bits => format!("{} b", bits),
SizeFormat::Bytes => format!("{} B", bytes),
SizeFormat::KiloBits => format!("{:.1} Kb", bits as f64 / 1000.0),
SizeFormat::KiloBytes => format!("{:.1} KB", bytes as f64 / 1024.0),
SizeFormat::MegaBits => format!("{:.2} Mb", bits as f64 / 1_000_000.0),
SizeFormat::MegaBytes => format!("{:.2} MB", bytes as f64 / (1024.0 * 1024.0)),
}
}
}
#[derive(Serialize, Deserialize, Clone, Debug, JsonSchema, SmartDefault)]
#[schemars(bound = "T: JsonSchema + Default")]
#[serde(rename_all = "kebab-case")]
pub struct HttpConfig {
#[schemars(url)]
#[default(Some("https://e621.net".to_string()))]
pub api: Option<String>,
#[default(Some(32))]
pub pool_max_idle_per_host: Option<usize>,
#[default(Some(90))]
pub pool_idle_timeout: Option<u64>,
#[default(Some(30))]
pub timeout: Option<u64>,
#[default(Some(10))]
pub connect_timeout: Option<u64>,
#[schemars(range(min = 1, max = 15))]
#[default(Some(15))]
pub max_connections: Option<usize>,
#[default(Some(false))]
pub http2: Option<bool>,
#[default(Some(true))]
pub tcp_keepalive: Option<bool>,
#[default(Some(60))]
pub tcp_keepalive_secs: Option<u64>,
#[default(Some(format!(
"{}/v{} (by {} on e621)",
env!("CARGO_PKG_NAME"),
env!("CARGO_PKG_VERSION"),
"bearodactyl"
)))]
pub user_agent: Option<String>,
}
#[derive(Serialize, Deserialize, Clone, Debug, JsonSchema, SmartDefault)]
#[schemars(bound = "T: JsonSchema + Default")]
#[schemars(default)]
#[serde(rename_all = "kebab-case")]
pub struct CacheConfig {
#[default(Some(true))]
pub enabled: Option<bool>,
#[default(Some(".cache".to_owned()))]
pub cache_dir: Option<String>,
#[default(Some(3600))]
pub ttl_secs: Option<u64>,
#[default(Some(1800))]
pub tti_secs: Option<u64>,
#[default(Some(500))]
pub max_size_mb: Option<u64>,
#[default(Some(10000))]
pub max_entries: Option<usize>,
#[default(Some(false))]
pub use_lru_policy: Option<bool>,
#[default(Some(true))]
pub enable_stats: Option<bool>,
#[default(Some(300))]
pub cleanup_interval: Option<u64>,
#[default(Some(false))]
pub enable_compression: Option<bool>,
#[default(Some(6))]
pub compression_level: Option<u8>,
#[default(Some(PostCacheConfig::default()))]
pub posts: Option<PostCacheConfig>,
}
#[derive(Serialize, Deserialize, Clone, Debug, JsonSchema, SmartDefault)]
#[schemars(bound = "T: JsonSchema + Default")]
#[schemars(default)]
#[serde(rename_all = "kebab-case")]
pub struct PostCacheConfig {
#[default(Some(true))]
pub enabled: Option<bool>,
#[default(Some(50000000))]
pub max_posts: Option<usize>,
#[default(Some(true))]
pub wal: Option<bool>,
#[default(Some(4))]
pub page_size_kb: Option<usize>,
#[default(Some(true))]
pub auto_compact: Option<bool>,
#[default(Some(25))]
pub compact_threshold: Option<u8>,
}
#[derive(Serialize, Deserialize, Clone, Debug, JsonSchema, SmartDefault)]
#[schemars(bound = "T: JsonSchema + Default")]
#[schemars(default)]
#[serde(rename_all = "kebab-case")]
pub struct PerformanceConfig {
#[default(Some(true))]
pub prefetch_enabled: Option<bool>,
#[default(Some(10))]
pub prefetch_batch_size: Option<usize>,
#[default(Some(true))]
pub preload_images: Option<bool>,
#[default(Some(100))]
pub max_preload_size_mb: Option<u64>,
}
#[derive(Serialize, Deserialize, Clone, Debug, JsonSchema, SmartDefault)]
#[schemars(bound = "T: JsonSchema + Default")]
#[schemars(default)]
#[serde(rename_all = "kebab-case")]
pub struct UiConfig {
#[default(Some(false))]
pub ctrlc_handler: Option<bool>,
#[default(Some(20))]
pub pagination_size: Option<usize>,
#[default(Some(true))]
pub colored_output: Option<bool>,
#[default(Some(true))]
pub tag_guide: Option<bool>,
#[default(Some(Language::default()))]
pub language: Option<Language>,
#[default(Some(ProgressCfg::default()))]
pub progress: Option<ProgressCfg>,
}
#[derive(Serialize, Deserialize, Clone, Debug, JsonSchema, SmartDefault)]
#[schemars(bound = "T: JsonSchema + Default")]
#[schemars(default)]
#[serde(rename_all = "kebab-case")]
pub struct ProgressCfg {
#[default(Some(20))]
pub refresh_rate: Option<u64>,
#[default(Some(SizeFormat::default()))]
pub format: Option<SizeFormat>,
#[default(Some(true))]
pub detailed: Option<bool>,
#[default(Some("id".to_string()))]
pub message: Option<String>,
}
#[derive(Serialize, Deserialize, Clone, Copy, Debug, JsonSchema, SmartDefault)]
#[schemars(bound = "T: JsonSchema + Default")]
#[serde(rename_all = "kebab-case")]
pub enum LoggingFormat {
Compact,
#[default]
Pretty,
}
#[derive(Serialize, Deserialize, Clone, Debug, JsonSchema, SmartDefault)]
#[schemars(bound = "T: JsonSchema + Default")]
#[schemars(default)]
#[serde(rename_all = "kebab-case")]
pub struct LoggingConfig {
#[default(Some(true))]
pub enable: Option<bool>,
#[default(Some("info".to_string()))]
pub level: Option<String>,
#[default(Some(LoggingFormat::Pretty))]
pub format: Option<LoggingFormat>,
#[default(Some(true))]
pub asni: Option<bool>,
#[default(Some(false))]
pub event_targets: Option<bool>,
#[default(Some(false))]
pub line_numbers: Option<bool>,
}
#[derive(Serialize, Deserialize, Clone, Debug, JsonSchema, SmartDefault)]
#[schemars(bound = "T: JsonSchema + Default")]
#[schemars(default)]
#[serde(rename_all = "kebab-case")]
pub struct ImageDisplay {
#[default(Some(800))]
pub width: Option<u64>,
#[default(Some(600))]
pub height: Option<u64>,
#[default(Some(true))]
pub image_when_info: Option<bool>,
#[default(Some(100))]
pub sixel_quality: Option<u8>,
#[default(Some("lanczos3".to_string()))]
pub resize_method: Option<String>,
}
#[derive(Serialize, Deserialize, Clone, Debug, JsonSchema, SmartDefault)]
#[schemars(bound = "T: JsonSchema + Default")]
#[schemars(default)]
#[serde(rename_all = "kebab-case")]
pub struct SearchCfg {
#[default(Some(320))]
pub results: Option<u64>,
#[default(Some(vec!["young".to_string(), "rape".to_string(), "feral".to_string(), "bestiality".to_string()]))]
pub blacklist: Option<Vec<String>>,
#[default(Some(2))]
pub min_posts_on_tag: Option<u64>,
#[default(Some(2))]
pub min_posts_on_pool: Option<u64>,
#[default(Some(true))]
pub show_inactive_pools: Option<bool>,
#[default(Some(false))]
pub sort_pools_by_post_count: Option<bool>,
#[default(Some(true))]
pub sort_tags_by_post_count: Option<bool>,
#[default(Some(0))]
pub min_post_score: Option<i64>,
#[default(Some(i64::MAX))]
pub max_post_score: Option<i64>,
#[default(Some(false))]
pub reverse_tags_order: Option<bool>,
#[default(Some(8))]
pub fetch_threads: Option<usize>,
#[default(Some(false))]
pub search_history: Option<bool>,
}
#[derive(Serialize, Deserialize, Clone, Debug, JsonSchema, SmartDefault)]
#[schemars(bound = "T: JsonSchema + Default")]
#[schemars(default)]
#[serde(rename_all = "kebab-case")]
pub struct CompletionCfg {
#[default(Some(0.8))]
pub tag_similarity_threshold: Option<f64>,
#[default(Some("data/tags.csv".to_string()))]
pub tags: Option<String>,
#[default(Some("data/tag_aliases.csv".to_string()))]
pub aliases: Option<String>,
#[default(Some("data/tag_implications.csv".to_string()))]
pub implications: Option<String>,
#[default(Some("data/pools.csv".to_string()))]
pub pools: Option<String>,
}
#[derive(Serialize, Deserialize, Clone, Debug, JsonSchema, SmartDefault)]
#[schemars(bound = "T: JsonSchema + Default")]
#[schemars(default)]
#[serde(rename_all = "kebab-case")]
pub struct LoginCfg {
#[default(Some(false))]
pub login: Option<bool>,
#[default(Some(String::new()))]
pub username: Option<String>,
#[default(Some(String::new()))]
pub api_key: Option<String>,
}
#[derive(Serialize, Deserialize, Clone, Debug, JsonSchema, SmartDefault)]
#[schemars(bound = "T: JsonSchema + Default")]
#[schemars(default)]
#[serde(rename_all = "kebab-case")]
pub struct AutoUpdateCfg {
#[default(Some(true))]
pub tags: Option<bool>,
#[default(Some(true))]
pub pools: Option<bool>,
}
#[derive(Serialize, Deserialize, Clone, Debug, JsonSchema, SmartDefault)]
#[schemars(bound = "T: JsonSchema + Default")]
#[schemars(default)]
#[serde(rename_all = "kebab-case")]
pub struct ExplorerCfg {
#[default(Some(true))]
pub recursive: Option<bool>,
#[default(Some(true))]
pub show_progress: Option<bool>,
#[default(Some(100))]
pub progress_threshold: Option<usize>,
#[default(Some("date_newest".to_string()))]
pub default_sort: Option<String>,
#[default(Some(20))]
pub posts_per_page: Option<usize>,
#[default(Some(true))]
pub cache_metadata: Option<bool>,
#[default(Some(true))]
pub auto_display_image: Option<bool>,
#[default(Some(5))]
pub slideshow_delay: Option<u64>,
}
#[derive(Serialize, Deserialize, Clone, Debug, JsonSchema, SmartDefault)]
#[schemars(bound = "T: JsonSchema + Default")]
#[schemars(default)]
#[serde(rename_all = "kebab-case")]
pub struct DownloadCfg {
#[default(Some("downloads".to_string()))]
pub path: Option<String>,
#[default(Some("downloads/pools".to_string()))]
pub pools_path: Option<String>,
#[default(Some(15))]
#[schemars(range(min = 1, max = 15))]
pub threads: Option<usize>,
#[default(Some(true))]
pub save_metadata: Option<bool>,
#[default(Some("$artists[3]/$rating/$tags[3] - $id - $date $time - $score.$ext".to_string()))]
pub format: Option<String>,
}
#[derive(Serialize, Deserialize, Clone, Debug, JsonSchema, SmartDefault)]
#[schemars(bound = "T: JsonSchema + Default")]
#[schemars(default)]
#[serde(rename_all = "kebab-case")]
pub struct GalleryCfg {
#[default(Some(true))]
pub enabled: Option<bool>,
#[default(Some(true))]
pub metadata_filtering: Option<bool>,
#[default(Some(23794))]
pub port: Option<u16>,
#[default(Some(true))]
pub cache_metadata: Option<bool>,
#[default(Some(false))]
pub auto_open: Option<bool>,
#[default(Some(8))]
pub load_threads: Option<usize>,
#[default(Some("catppuccin-frappe".to_string()))]
pub theme: Option<String>,
}
#[derive(Serialize, Deserialize, Clone, Copy, Debug, JsonSchema, Default)]
#[serde(rename_all = "kebab-case")]
pub enum Language {
#[default]
English,
Spanish,
Japanese,
}
#[derive(Serialize, Deserialize, Clone, Debug, JsonSchema, SmartDefault)]
#[schemars(bound = "T: JsonSchema + Default", default)]
#[serde(rename_all = "kebab-case")]
pub struct E62Rs {
#[default(Some(ImageDisplay::default()))]
pub display: Option<ImageDisplay>,
#[default(Some(HttpConfig::default()))]
pub http: Option<HttpConfig>,
#[default(Some(CacheConfig::default()))]
pub cache: Option<CacheConfig>,
#[default(Some(PerformanceConfig::default()))]
pub performance: Option<PerformanceConfig>,
#[default(Some(UiConfig::default()))]
pub ui: Option<UiConfig>,
#[default(Some(SearchCfg::default()))]
pub search: Option<SearchCfg>,
#[default(Some(LoginCfg::default()))]
pub login: Option<LoginCfg>,
#[default(Some(CompletionCfg::default()))]
pub completion: Option<CompletionCfg>,
#[default(Some(AutoUpdateCfg::default()))]
pub autoupdate: Option<AutoUpdateCfg>,
#[default(Some(DownloadCfg::default()))]
pub download: Option<DownloadCfg>,
#[default(Some(ExplorerCfg::default()))]
pub explorer: Option<ExplorerCfg>,
#[default(Some(GalleryCfg::default()))]
pub gallery: Option<GalleryCfg>,
#[default(Some(LoggingConfig::default()))]
pub logging: Option<LoggingConfig>,
}
#[cfg(feature = "cli")]
impl E62Rs {
pub fn load() -> Result<Self> {
let global_path = Self::global_config_path()?;
let defaults = Self::load_defaults()?;
if !global_path.exists() {
info!("No global configuration found. Creating default config...");
Self::make_default_config(&global_path, &defaults)
.wrap_err("Failed to create default configuration file")
.suggestion("Check that you have write permissions to the config directory")
.suggestion("Try creating the config directory manually")?;
if !global_path.exists() {
return Err(eyre!("Config file was not created successfully"))
.suggestion("Check file system permissions")
.suggestion("Try running with elevated privileges if needed");
}
info!(
"✓ Default configuration created at: {}",
global_path.display()
);
info!(" You can edit this file to customize your settings.");
}
let mut builder = Self::create_builder(defaults)?;
builder = builder.add_source(
config::File::with_name(global_path.to_str().unwrap_or("")).required(false),
);
if let Some(local_config) = Self::find_local_config()? {
info!(
"Loading local configuration from: {}",
local_config.display()
);
builder = builder.add_source(
config::File::with_name(local_config.to_str().unwrap_or("")).required(false),
);
}
builder = builder.add_source(config::Environment::with_prefix("E62RS"));
let settings = builder
.build()
.wrap_err("Failed to build configuration from all sources")?;
let cfg: E62Rs = settings
.try_deserialize::<E62Rs>()
.wrap_err("Failed to deserialize configuration")
.suggestion("Check that your config file uses valid TOML syntax")
.suggestion(
"Compare with the default config to ensure all required fields are present",
)?;
Self::run_validation(&cfg)?;
print!("\x1B[2J\x1B[3J\x1B[H");
std::io::stdout()
.flush()
.wrap_err("Failed to clear terminal screen")?;
info!("Configuration loaded successfully!");
Ok(cfg)
}
pub fn init() -> Result<bool> {
let global_path = Self::global_config_path()?;
if global_path.exists() {
info!("Configuration already exists at: {}", global_path.display());
return Ok(false);
}
info!("Initializing e62rs configuration...");
let defaults = Self::load_defaults()?;
Self::make_default_config(&global_path, &defaults)
.wrap_err("Failed to initialize configuration")?;
info!("✓ Configuration initialized at: {}", global_path.display());
info!(" Edit this file to customize your settings.");
Ok(true)
}
pub fn global_config_path() -> Result<PathBuf> {
dirs::config_dir()
.ok_or_eyre("Unable to find system config directory")
.map(|dir| dir.join("e62rs.toml"))
.suggestion("Ensure XDG_CONFIG_HOME or HOME environment vars are set")
.suggestion("On Windows, APPDATA should be set")
}
fn load_defaults() -> Result<Self> {
toml::from_str(include_str!("../../resources/e62rs.default.toml"))
.wrap_err("Failed to parse embedded default configuration")
.note("This is a bug - the embedded defaults are malformed")
}
fn create_builder(defaults: E62Rs) -> Result<ConfigBuilder<config::builder::DefaultState>> {
Ok(Config::builder().add_source(
config::Config::try_from(&defaults)
.wrap_err("Failed to convert default E62Rs struct to config source")?,
))
}
fn run_validation(cfg: &Self) -> Result<()> {
cfg.validate()
.map_err(|errors| {
let formatted = format_validation_errors(&errors);
eyre!(formatted)
})
.wrap_err("Configuration validation failed")
.suggestion("Check your e62rs.toml for invalid values")
.suggestion("Compare with the default config to see valid options")
.suggestion("Try deleting the config file to regenerate defaults")
}
fn find_local_config() -> Result<Option<PathBuf>> {
let curr_dir = std::env::current_dir()
.wrap_err("Failed to get current working directory")
.suggestion("Ensure the current directory exists and is accessible")?;
for ancestor in curr_dir.ancestors() {
let config_path = ancestor.join("e62rs.toml");
if config_path.exists() {
return Ok(Some(config_path));
}
}
Ok(None)
}
fn make_default_config(path: &Path, defaults: &E62Rs) -> Result<()> {
let config_dir = path
.parent()
.ok_or_eyre("Unable to determine parent directory of config path")?;
std::fs::create_dir_all(config_dir)
.wrap_err("Failed to create config directory")
.with_section(|| format!("{}", config_dir.display()).header("Directory:"))?;
let temp_path = path.with_extension("toml.tmp");
let mut temp_writer = FileWriter::toml(&temp_path, true)
.wrap_err("Failed to create temporary config file")?;
temp_writer
.write(defaults)
.wrap_err("Failed to write default config to temp file")?;
temp_writer
.flush()
.wrap_err("Failed to flush temp config file")?;
std::fs::rename(&temp_path, path)
.wrap_err("Failed to rename temp file to final config path")
.with_section(|| {
format!("temp: {}, final: {}", temp_path.display(), path.display()).header("Paths:")
})?;
Ok(())
}
pub fn save_to_file(&self, path: impl AsRef<Path>) -> Result<()> {
let mut toml_writer = FileWriter::toml(path, true)?;
toml_writer
.write(self)
.wrap_err("Failed to write config file")?;
toml_writer.flush()?;
Ok(())
}
pub fn save(&self) -> Result<()> {
let path = Self::global_config_path()?;
self.save_to_file(&path)
}
}