use serde::{Deserialize, Serialize};
use std::fs;
use std::path::PathBuf;
#[derive(Debug, Serialize, Deserialize, Default, Clone)]
pub struct Config {
pub theme: Option<String>,
pub show_logo: Option<bool>,
pub ascii_only: Option<bool>,
pub logo: Option<String>,
pub fields: Option<Vec<String>>,
pub custom_theme: Option<CustomTheme>,
}
#[derive(Debug, Serialize, Deserialize, Default, Clone)]
pub struct CustomTheme {
pub label_color: Option<String>,
pub value_color: Option<String>,
pub accent_color: Option<String>,
pub title_color: Option<String>,
pub separator_color: Option<String>,
}
impl Config {
pub fn load(custom_path: Option<&str>) -> anyhow::Result<Self> {
let path = if let Some(p) = custom_path {
Some(PathBuf::from(p))
} else {
Self::config_path()
};
if let Some(path) = path {
if path.exists() {
let contents = fs::read_to_string(&path)?;
let config: Config = toml::from_str(&contents)?;
return Ok(config);
}
}
Ok(Self::default())
}
pub fn config_path() -> Option<PathBuf> {
dirs::config_dir().map(|mut p| {
p.push("retch");
p.push("config.toml");
p
})
}
pub fn merge_with_cli(&self, cli: &crate::cli::Cli) -> Self {
let mut merged = self.clone();
if let Some(theme) = &cli.theme {
merged.theme = Some(theme.clone());
}
if cli.no_logo {
merged.show_logo = Some(false);
}
if cli.ascii_only {
merged.ascii_only = Some(true);
}
if let Some(logo) = &cli.logo {
merged.logo = Some(logo.clone());
}
if let Some(fields_str) = &cli.fields {
let fields = fields_str
.split(',')
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect::<Vec<String>>();
merged.fields = Some(fields);
}
merged
}
pub fn merge_defaults(existing: &str) -> (String, Vec<&'static str>) {
let mut new_content = existing.trim_end().to_string();
let mut additions = Vec::new();
let checks = [
("theme", DEFAULT_THEME_BLOCK),
("show_logo", DEFAULT_SHOW_LOGO_BLOCK),
("ascii_only", DEFAULT_ASCII_ONLY_BLOCK),
("logo", DEFAULT_LOGO_BLOCK),
("fields", DEFAULT_FIELDS_BLOCK),
];
for &(key, block) in &checks {
if !contains_key_line(existing, key) {
if !new_content.is_empty() {
new_content.push_str("\n\n");
}
new_content.push_str(block);
additions.push(key);
}
}
if !contains_custom_theme(existing) {
if !new_content.is_empty() {
new_content.push_str("\n\n");
}
new_content.push_str(DEFAULT_CUSTOM_THEME_BLOCK);
additions.push("custom_theme");
}
if !new_content.is_empty() && !new_content.ends_with('\n') {
new_content.push('\n');
}
(new_content, additions)
}
}
const DEFAULT_THEME_BLOCK: &str = r##"# Theme to use. Defaults to "auto" (follows system dark/light preference).
# Other options: "neutral", "dark", "light", "custom",
# or popular themes: "catppuccin-mocha", "solarized-dark", etc.
# theme = "auto""##;
const DEFAULT_CUSTOM_THEME_BLOCK: &str = r##"# Custom theme color overrides (used when theme = "custom" or when partial overrides are provided)
# Colors can be named (e.g. "bright_cyan") or hex (e.g. "#89b4fa")
# [custom_theme]
# label_color = "bright_cyan"
# value_color = "white"
# accent_color = "bright_green"
# title_color = "bright_yellow"
# separator_color = "bright_black""##;
const DEFAULT_SHOW_LOGO_BLOCK: &str = r##"# Whether to show the ASCII logo
# show_logo = true"##;
const DEFAULT_ASCII_ONLY_BLOCK: &str = r##"# Force ASCII-only output (even if graphical logos are supported)
# ascii_only = false"##;
const DEFAULT_LOGO_BLOCK: &str = r##"# Force a specific distribution logo by name/ID
# logo = "arch""##;
const DEFAULT_FIELDS_BLOCK: &str = r##"# List of fields to display (leave empty or omit to show all)
# fields = [
# "os", "kernel", "host", "arch", "cpu", "cpu-freq", "gpu",
# "memory", "swap", "uptime", "procs", "load",
# "disk", "temp", "net", "battery",
# "shell", "terminal", "desktop", "users", "packages"
# ]"##;
fn contains_key_line(content: &str, key: &str) -> bool {
for line in content.lines() {
let trimmed = line.trim();
let without_comment = trimmed
.strip_prefix('#')
.map(|s| s.trim())
.unwrap_or(trimmed);
if let Some(rest) = without_comment.strip_prefix(key) {
let rest = rest.trim();
if rest.starts_with('=') {
return true;
}
}
}
false
}
fn contains_custom_theme(content: &str) -> bool {
for line in content.lines() {
let trimmed = line.trim();
let without_comment = trimmed
.strip_prefix('#')
.map(|s| s.trim())
.unwrap_or(trimmed);
let cleaned = without_comment.replace(' ', "");
if cleaned.contains("[custom_theme]") {
return true;
}
}
false
}
#[cfg(test)]
mod tests {
use super::*;
use crate::cli::Cli;
use clap::Parser;
#[test]
fn test_config_merge_with_cli() {
let config = Config {
theme: Some("dark".to_string()),
show_logo: Some(true),
ascii_only: Some(false),
fields: Some(vec!["os".to_string(), "kernel".to_string()]),
..Default::default()
};
let cli = Cli::try_parse_from(["retch", "--theme", "light"]).unwrap();
let merged = config.merge_with_cli(&cli);
assert_eq!(merged.theme, Some("light".to_string()));
let cli = Cli::try_parse_from(["retch", "--no-logo"]).unwrap();
let merged = config.merge_with_cli(&cli);
assert_eq!(merged.show_logo, Some(false));
let cli = Cli::try_parse_from(["retch", "--ascii-only"]).unwrap();
let merged = config.merge_with_cli(&cli);
assert_eq!(merged.ascii_only, Some(true));
let cli = Cli::try_parse_from(["retch", "--logo", "manjaro"]).unwrap();
let merged = config.merge_with_cli(&cli);
assert_eq!(merged.logo, Some("manjaro".to_string()));
let cli = Cli::try_parse_from(["retch", "--fields", "cpu,gpu,memory"]).unwrap();
let merged = config.merge_with_cli(&cli);
assert_eq!(
merged.fields,
Some(vec![
"cpu".to_string(),
"gpu".to_string(),
"memory".to_string()
])
);
let cli = Cli::try_parse_from(["retch", "--fields", " cpu , , gpu "]).unwrap();
let merged = config.merge_with_cli(&cli);
assert_eq!(
merged.fields,
Some(vec!["cpu".to_string(), "gpu".to_string()])
);
}
#[test]
fn test_config_load_valid() {
let temp_dir = std::env::temp_dir();
let file_path = temp_dir.join("valid_config.toml");
std::fs::write(&file_path, "theme = \"dark\"\nshow_logo = true\n").unwrap();
let config = Config::load(Some(file_path.to_str().unwrap())).unwrap();
assert_eq!(config.theme, Some("dark".to_string()));
assert_eq!(config.show_logo, Some(true));
let _ = std::fs::remove_file(file_path);
}
#[test]
fn test_config_load_invalid() {
let temp_dir = std::env::temp_dir();
let file_path = temp_dir.join("invalid_config.toml");
std::fs::write(&file_path, "theme = dark\n").unwrap();
let config = Config::load(Some(file_path.to_str().unwrap()));
assert!(config.is_err());
let _ = std::fs::remove_file(file_path);
}
#[test]
fn test_config_load_missing() {
let config = Config::load(Some("non_existent_file.toml")).unwrap();
assert_eq!(config.theme, None);
assert_eq!(config.show_logo, None);
}
#[test]
fn test_merge_defaults_all_present() {
let existing = "theme = \"dark\"\nshow_logo = true\nascii_only = false\nlogo = \"fedora\"\nfields = [\"os\"]\n[custom_theme]\nlabel_color = \"red\"\n";
let (merged, additions) = Config::merge_defaults(existing);
assert!(additions.is_empty());
assert_eq!(merged.trim(), existing.trim());
}
#[test]
fn test_merge_defaults_commented_ignored() {
let existing = "# theme = \"auto\"\n# show_logo = true\n# ascii_only = false\n# logo = \"arch\"\n# fields = []\n# [custom_theme]\n";
let (merged, additions) = Config::merge_defaults(existing);
assert!(additions.is_empty());
assert_eq!(merged.trim(), existing.trim());
}
#[test]
fn test_merge_defaults_missing_some() {
let existing = "theme = \"light\"\n";
let (merged, additions) = Config::merge_defaults(existing);
assert_eq!(
additions,
vec!["show_logo", "ascii_only", "logo", "fields", "custom_theme"]
);
assert!(merged.contains("theme = \"light\""));
assert!(merged.contains("show_logo = true"));
assert!(merged.contains("ascii_only = false"));
assert!(merged.contains("logo = \"arch\""));
assert!(merged.contains("fields = ["));
assert!(merged.contains("[custom_theme]"));
}
#[test]
fn test_default_fields_include_battery() {
assert!(DEFAULT_FIELDS_BLOCK.contains("battery"));
}
}