use crate::app::config::binds::BindsConfig;
use crate::app::config::colors::ColorsConfig;
use crate::app::config::logging::LoggingConfig;
use crate::app::config::mpd::MpdConfig;
use crate::app::config::pipewire::PipewireConfig;
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
#[derive(Debug, Default, Deserialize, Serialize)]
pub struct Config {
#[serde(default)]
pub mpd: MpdConfig,
#[serde(default)]
pub colors: ColorsConfig,
#[serde(default)]
pub binds: BindsConfig,
#[serde(default)]
pub pipewire: PipewireConfig,
#[serde(default)]
pub logging: LoggingConfig,
}
fn levenshtein_distance(a: &str, b: &str) -> usize {
let a_chars: Vec<char> = a.chars().collect();
let b_chars: Vec<char> = b.chars().collect();
let a_len = a_chars.len();
let b_len = b_chars.len();
if a_len == 0 {
return b_len;
}
if b_len == 0 {
return a_len;
}
let mut prev_row: Vec<usize> = (0..=b_len).collect();
let mut curr_row: Vec<usize> = vec![0; b_len + 1];
for (i, a_char) in a_chars.iter().enumerate() {
curr_row[0] = i + 1;
for (j, b_char) in b_chars.iter().enumerate() {
let cost = if a_char == b_char { 0 } else { 1 };
curr_row[j + 1] = (prev_row[j + 1] + 1)
.min(curr_row[j] + 1)
.min(prev_row[j] + cost);
}
std::mem::swap(&mut prev_row, &mut curr_row);
}
prev_row[b_len]
}
fn find_similar(unknown: &str, candidates: &[&str]) -> Option<String> {
let unknown_lower = unknown.to_lowercase();
let mut best_match: Option<(&str, usize)> = None;
for &candidate in candidates {
let distance = levenshtein_distance(&unknown_lower, &candidate.to_lowercase());
let max_len = unknown.len().max(candidate.len());
let threshold = (max_len / 2).max(3);
if distance <= threshold {
if let Some((_, best_distance)) = best_match {
if distance < best_distance {
best_match = Some((candidate, distance));
}
} else {
best_match = Some((candidate, distance));
}
}
}
best_match.map(|(s, _)| s.to_string())
}
fn format_unknown_warning(section: &str, key: &str, suggestion: Option<&str>) -> String {
if section == "section" {
match suggestion {
Some(s) => format!("Unknown config section: [{}] (did you mean: [{}]?)", key, s),
None => format!("Unknown config section: [{}]", key),
}
} else {
match suggestion {
Some(s) => format!(
"Unknown option in {}: {} (did you mean: {}?)",
section, key, s
),
None => format!("Unknown option in {}: {}", section, key),
}
}
}
impl Config {
fn default_config_path() -> color_eyre::Result<PathBuf> {
let config_dir = dirs::config_dir()
.ok_or_else(|| color_eyre::eyre::eyre!("Could not determine config directory"))?;
Ok(config_dir.join("zarumet").join("config.toml"))
}
pub fn load(config_path: Option<PathBuf>) -> color_eyre::Result<(Self, Vec<String>)> {
let config_path = match config_path {
Some(path) => path,
None => Self::default_config_path()?,
};
if !config_path.exists() {
if let Some(parent) = config_path.parent() {
std::fs::create_dir_all(parent)?;
}
let default_config = Config::default();
let toml_string = toml::to_string_pretty(&default_config)?;
std::fs::write(&config_path, &toml_string)?;
eprintln!("Created default config file at: {}", config_path.display());
return Ok((default_config, Vec::new()));
}
let contents = std::fs::read_to_string(&config_path)?;
let warnings = Self::check_unknown_fields(&contents);
let config: Config = toml::from_str(&contents).unwrap_or_else(|e| {
if cfg!(debug_assertions) {
eprintln!("Warning: Failed to parse config file: {}", e);
}
Config::default()
});
Ok((config, warnings))
}
fn check_unknown_fields(contents: &str) -> Vec<String> {
let mut warnings = Vec::new();
const KNOWN_SECTIONS: &[&str] = &["mpd", "colors", "binds", "pipewire", "logging"];
const KNOWN_MPD_FIELDS: &[&str] = &["address", "volume_increment", "volume_increment_fine"];
const KNOWN_COLORS_FIELDS: &[&str] = &[
"border",
"song_title",
"album",
"artist",
"border_title",
"border_selected",
"progress_filled",
"progress_empty",
"paused",
"playing",
"stopped",
"time_separator",
"time_duration",
"time_elapsed",
"queue_selected_highlight",
"queue_selected_text",
"queue_album",
"queue_song_title",
"queue_song_playing",
"queue_artist",
"queue_position",
"queue_duration",
"top_accent",
"volume",
"volume_empty",
"mode",
"track_duration",
];
const KNOWN_BINDS_FIELDS: &[&str] = &[
"next",
"previous",
"toggle_play_pause",
"volume_up",
"volume_up_fine",
"volume_down",
"volume_down_fine",
"toggle_mute",
"cycle_mode_right",
"cycle_mode_left",
"clear_queue",
"repeat",
"random",
"single",
"consume",
"quit",
"refresh",
"switch_to_queue_menu",
"switch_to_artists",
"switch_to_albums",
"switch_to_years",
"switch_to_genres",
"seek_forward",
"seek_backward",
"scroll_up",
"scroll_down",
"play_selected",
"remove_from_queue",
"move_up_in_queue",
"move_down_in_queue",
"switch_panel_left",
"switch_panel_right",
"toggle_album_expansion",
"add_to_queue",
"scroll_up_big",
"scroll_down_big",
"go_to_top",
"go_to_bottom",
"toggle_bit_perfect",
];
const KNOWN_PIPEWIRE_FIELDS: &[&str] = &["bit_perfect_enabled"];
const KNOWN_LOGGING_FIELDS: &[&str] = &[
"enabled",
"level",
"log_to_console",
"append_to_file",
"rotate_logs",
"rotation_size_mb",
"keep_log_files",
"custom_log_path",
];
let table: Result<toml::Table, _> = toml::from_str(contents);
let table = match table {
Ok(t) => t,
Err(_) => return warnings, };
for key in table.keys() {
if !KNOWN_SECTIONS.contains(&key.as_str()) {
let suggestion = find_similar(key, KNOWN_SECTIONS);
let msg = format_unknown_warning("section", key, suggestion.as_deref());
warnings.push(msg);
}
}
if let Some(toml::Value::Table(mpd)) = table.get("mpd") {
for key in mpd.keys() {
if !KNOWN_MPD_FIELDS.contains(&key.as_str()) {
let suggestion = find_similar(key, KNOWN_MPD_FIELDS);
let msg = format_unknown_warning("[mpd]", key, suggestion.as_deref());
warnings.push(msg);
}
}
}
if let Some(toml::Value::Table(colors)) = table.get("colors") {
for key in colors.keys() {
if !KNOWN_COLORS_FIELDS.contains(&key.as_str()) {
let suggestion = find_similar(key, KNOWN_COLORS_FIELDS);
let msg = format_unknown_warning("[colors]", key, suggestion.as_deref());
warnings.push(msg);
}
}
}
if let Some(toml::Value::Table(binds)) = table.get("binds") {
for key in binds.keys() {
if !KNOWN_BINDS_FIELDS.contains(&key.as_str()) {
let suggestion = find_similar(key, KNOWN_BINDS_FIELDS);
let msg = format_unknown_warning("[binds]", key, suggestion.as_deref());
warnings.push(msg);
}
}
}
if let Some(toml::Value::Table(pipewire)) = table.get("pipewire") {
for key in pipewire.keys() {
if !KNOWN_PIPEWIRE_FIELDS.contains(&key.as_str()) {
let suggestion = find_similar(key, KNOWN_PIPEWIRE_FIELDS);
let msg = format_unknown_warning("[pipewire]", key, suggestion.as_deref());
warnings.push(msg);
}
}
}
if let Some(toml::Value::Table(logging)) = table.get("logging") {
for key in logging.keys() {
if !KNOWN_LOGGING_FIELDS.contains(&key.as_str()) {
let suggestion = find_similar(key, KNOWN_LOGGING_FIELDS);
let msg = format_unknown_warning("[logging]", key, suggestion.as_deref());
warnings.push(msg);
}
}
}
warnings
}
pub fn generate_default(path: PathBuf) -> color_eyre::Result<()> {
if let Some(parent) = path.parent()
&& !parent.as_os_str().is_empty()
{
std::fs::create_dir_all(parent)?;
}
if path.exists() {
return Err(color_eyre::eyre::eyre!(
"Config file already exists at: {}",
path.display()
));
}
let default_config = Config::default();
let toml_string = toml::to_string_pretty(&default_config)?;
std::fs::write(&path, &toml_string)?;
println!("Generated default config at: {}", path.display());
Ok(())
}
}