use std::path::PathBuf;
#[derive(Clone, Copy)]
pub struct PanelVisibility {
pub context: bool,
pub quota: bool,
pub tokens: bool,
pub projects: bool,
pub ports: bool,
pub sessions: bool,
pub mcp: bool,
}
impl Default for PanelVisibility {
fn default() -> Self {
Self {
context: true,
quota: true,
tokens: true,
projects: true,
ports: true,
sessions: true,
mcp: true,
}
}
}
pub struct AppConfig {
pub theme: String,
pub hidden_agents: Vec<String>,
pub panels: PanelVisibility,
}
impl Default for AppConfig {
fn default() -> Self {
Self {
theme: "btop".to_string(),
hidden_agents: Vec::new(),
panels: PanelVisibility::default(),
}
}
}
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('\'');
match key {
"theme" => config.theme = val.to_string(),
"show_context" => config.panels.context = parse_bool(val).unwrap_or(true),
"show_quota" => config.panels.quota = parse_bool(val).unwrap_or(true),
"show_tokens" => config.panels.tokens = parse_bool(val).unwrap_or(true),
"show_projects" => config.panels.projects = parse_bool(val).unwrap_or(true),
"show_ports" => config.panels.ports = parse_bool(val).unwrap_or(true),
"show_sessions" => config.panels.sessions = parse_bool(val).unwrap_or(true),
"show_mcp" => config.panels.mcp = parse_bool(val).unwrap_or(true),
_ => {}
}
}
}
config
}
fn parse_bool(raw: &str) -> Option<bool> {
match raw.trim().to_ascii_lowercase().as_str() {
"true" => Some(true),
"false" => Some(false),
_ => None,
}
}
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> {
write_with_updates(&[("theme", format!("\"{}\"", name))])
}
pub fn save_panel_visibility(panels: &PanelVisibility) -> Result<(), String> {
write_with_updates(&[
("show_context", panels.context.to_string()),
("show_quota", panels.quota.to_string()),
("show_tokens", panels.tokens.to_string()),
("show_projects", panels.projects.to_string()),
("show_ports", panels.ports.to_string()),
("show_sessions", panels.sessions.to_string()),
("show_mcp", panels.mcp.to_string()),
])
}
fn write_with_updates(updates: &[(&str, String)]) -> 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_kv_lines(&content, updates);
std::fs::write(&path, new_content).map_err(|e| e.to_string())
}
fn rewrite_kv_lines(content: &str, updates: &[(&str, String)]) -> String {
let mut found = vec![false; updates.len()];
let mut out: Vec<String> = Vec::new();
for line in content.lines() {
let line_key = line.split_once('=').map(|(k, _)| k.trim().to_string());
let mut replaced = false;
if let Some(key) = line_key {
if let Some(idx) = updates.iter().position(|(k, _)| *k == key) {
out.push(format!("{} = {}", updates[idx].0, updates[idx].1));
found[idx] = true;
replaced = true;
}
}
if !replaced {
out.push(line.to_string());
}
}
for (idx, (k, v)) in updates.iter().enumerate() {
if !found[idx] {
out.push(format!("{} = {}", k, v));
}
}
out.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()));
}
fn theme_update(name: &str) -> Vec<(&'static str, String)> {
vec![("theme", format!("\"{}\"", name))]
}
#[test]
fn rewrite_theme_preserves_hidden_agents_line() {
let before = "theme = \"btop\"\nhidden_agents = [\"codex\"]\n";
let after = rewrite_kv_lines(before, &theme_update("dracula"));
assert!(after.contains("theme = \"dracula\""));
assert!(
after.contains("hidden_agents = [\"codex\"]"),
"hidden_agents line dropped:\n{after}"
);
}
#[test]
fn rewrite_theme_preserves_arbitrary_unknown_keys() {
let before = "# user comment\nfuture_key = 42\ntheme = \"btop\"\n";
let after = rewrite_kv_lines(before, &theme_update("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_kv_lines(before, &theme_update("gruvbox"));
assert!(after.contains("hidden_agents = [\"codex\"]"));
assert!(after.contains("theme = \"gruvbox\""));
}
#[test]
fn rewrite_panels_replaces_existing_and_appends_missing() {
let before = "theme = \"btop\"\nshow_quota = true\n";
let updates: Vec<(&str, String)> = vec![
("show_quota", "false".to_string()),
("show_projects", "false".to_string()),
];
let after = rewrite_kv_lines(before, &updates);
assert!(after.contains("show_quota = false"));
assert!(!after.contains("show_quota = true"));
assert!(after.contains("show_projects = false"));
assert!(after.contains("theme = \"btop\""));
}
#[test]
fn parse_bool_round_trips_visibility_keys() {
assert_eq!(parse_bool("true"), Some(true));
assert_eq!(parse_bool("False"), Some(false));
assert_eq!(parse_bool("nope"), None);
}
}