use std::path::PathBuf;
pub struct AppConfig {
pub theme: String,
pub hidden_agents: Vec<String>,
}
impl Default for AppConfig {
fn default() -> Self {
Self {
theme: "btop".to_string(),
hidden_agents: Vec::new(),
}
}
}
fn config_path() -> Option<PathBuf> {
dirs::config_dir().map(|d| d.join("abtop").join("config.toml"))
}
pub fn load_config() -> AppConfig {
let path = match config_path() {
Some(p) => p,
None => return AppConfig::default(),
};
let content = match std::fs::read_to_string(&path) {
Ok(c) => c,
Err(_) => return AppConfig::default(),
};
let mut config = AppConfig::default();
for line in content.lines() {
let line = line.trim();
if line.starts_with('#') || line.is_empty() {
continue;
}
if let Some((key, val)) = line.split_once('=') {
let key = key.trim();
let val = val.trim();
let val = if let Some(comment_pos) = val.find('#') {
val[..comment_pos].trim()
} else {
val
};
if key == "hidden_agents" {
config.hidden_agents = parse_string_array(val);
continue;
}
let val = val.trim_matches('"').trim_matches('\'');
if key == "theme" {
config.theme = val.to_string();
}
}
}
config
}
fn parse_string_array(raw: &str) -> Vec<String> {
let trimmed = raw.trim();
let Some(inner) = trimmed.strip_prefix('[').and_then(|s| s.strip_suffix(']')) else {
return Vec::new();
};
inner
.split(',')
.map(|s| s.trim().trim_matches('"').trim_matches('\'').to_string())
.filter(|s| !s.is_empty())
.collect()
}
pub fn save_theme(name: &str) -> Result<(), String> {
let path = config_path().ok_or("no config directory")?;
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).map_err(|e| e.to_string())?;
}
let content = match std::fs::read_to_string(&path) {
Ok(c) => c,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => String::new(),
Err(e) => return Err(e.to_string()),
};
let new_content = rewrite_theme_line(&content, name);
std::fs::write(&path, new_content).map_err(|e| e.to_string())
}
fn rewrite_theme_line(content: &str, name: &str) -> String {
let mut lines: Vec<String> = Vec::new();
let mut found = false;
for line in content.lines() {
let is_theme_key = line.split_once('=')
.map(|(k, _)| k.trim() == "theme")
.unwrap_or(false);
if is_theme_key {
lines.push(format!("theme = \"{}\"", name));
found = true;
} else {
lines.push(line.to_string());
}
}
if !found {
lines.push(format!("theme = \"{}\"", name));
}
lines.join("\n") + "\n"
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_string_array_basic() {
assert_eq!(parse_string_array(r#"["codex"]"#), vec!["codex"]);
assert_eq!(
parse_string_array(r#"["codex", "claude"]"#),
vec!["codex", "claude"]
);
}
#[test]
fn parse_string_array_quote_styles_and_whitespace() {
assert_eq!(
parse_string_array(r#"[ 'codex' , "claude" ]"#),
vec!["codex", "claude"]
);
}
#[test]
fn parse_string_array_empty_and_malformed() {
assert!(parse_string_array("[]").is_empty());
assert!(parse_string_array("not an array").is_empty());
assert!(parse_string_array(r#"["a",,]"#).iter().all(|s| !s.is_empty()) );
}
#[test]
fn rewrite_theme_preserves_hidden_agents_line() {
let before = "theme = \"btop\"\nhidden_agents = [\"codex\"]\n";
let after = rewrite_theme_line(before, "dracula");
assert!(after.contains("theme = \"dracula\""));
assert!(
after.contains("hidden_agents = [\"codex\"]"),
"hidden_agents line dropped by rewrite_theme_line:\n{after}"
);
}
#[test]
fn rewrite_theme_preserves_arbitrary_unknown_keys() {
let before = "# user comment\nfuture_key = 42\ntheme = \"btop\"\n";
let after = rewrite_theme_line(before, "nord");
assert!(after.contains("# user comment"));
assert!(after.contains("future_key = 42"));
assert!(after.contains("theme = \"nord\""));
}
#[test]
fn rewrite_theme_appends_when_missing() {
let before = "hidden_agents = [\"codex\"]\n";
let after = rewrite_theme_line(before, "gruvbox");
assert!(after.contains("hidden_agents = [\"codex\"]"));
assert!(after.contains("theme = \"gruvbox\""));
}
}