use std::{path::PathBuf, time::Duration};
use crate::util::{config_file, parse_bool};
#[derive(Debug, Clone)]
pub struct Config {
pub refresh_interval: Duration,
pub extra_regions: Vec<String>,
pub redact_default: Option<bool>,
pub grouped_default: Option<bool>,
pub theme: String,
pub icons: String,
pub notify_bell: bool,
pub required_tags: Vec<String>,
pub profile_themes: std::collections::HashMap<String, String>,
pub accounts: std::collections::HashMap<String, AccountSpec>,
pub runbooks: std::collections::HashMap<String, String>,
pub safety_envs: std::collections::HashMap<String, bool>,
pub safety_accounts: std::collections::HashMap<String, bool>,
pub notify_webhook: Option<String>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct AccountSpec {
pub role_arn: String,
pub source_profile: Option<String>,
pub external_id: Option<String>,
pub region: Option<String>,
}
impl Default for Config {
fn default() -> Self {
Self {
refresh_interval: Duration::from_secs(15),
extra_regions: Vec::new(),
redact_default: None,
grouped_default: None,
theme: "dark".into(),
icons: "unicode".into(),
notify_bell: false,
required_tags: Vec::new(),
profile_themes: std::collections::HashMap::new(),
accounts: std::collections::HashMap::new(),
runbooks: std::collections::HashMap::new(),
safety_envs: std::collections::HashMap::new(),
safety_accounts: std::collections::HashMap::new(),
notify_webhook: None,
}
}
}
pub fn load() -> Config {
let path = config_path();
let Ok(text) = std::fs::read_to_string(&path) else {
return Config::default();
};
parse(&text)
}
pub fn parse(text: &str) -> Config {
let mut cfg = Config::default();
for line in text.lines() {
let line = line.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
let Some((key, raw_val)) = line.split_once('=') else {
continue;
};
let key = key.trim();
let value = raw_val.trim().trim_matches('"').to_string();
match key {
"refresh_interval_secs" => {
if let Ok(n) = value.parse::<u64>() {
if n > 0 {
cfg.refresh_interval = Duration::from_secs(n);
}
}
}
"extra_regions" => {
cfg.extra_regions = value
.split(',')
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect();
}
"redact_default" => cfg.redact_default = parse_bool(&value),
"grouped_default" => cfg.grouped_default = parse_bool(&value),
"theme" => cfg.theme = value,
"icons" => cfg.icons = value,
"notify_bell" => {
if let Some(b) = parse_bool(&value) {
cfg.notify_bell = b;
}
}
"notify_webhook" => {
cfg.notify_webhook = if value.is_empty() { None } else { Some(value) };
}
"required_tags" => {
cfg.required_tags = value
.split(',')
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect();
}
"profile_themes" => {
cfg.profile_themes = parse_profile_themes(&value);
}
other if other.starts_with("runbooks.") => {
let name = other.trim_start_matches("runbooks.").trim();
if !name.is_empty() && !value.is_empty() {
cfg.runbooks.insert(name.to_string(), value);
}
}
other if other.starts_with("accounts.") => {
let rest = other.trim_start_matches("accounts.");
let Some((name, field)) = rest.split_once('.') else {
continue;
};
let entry = cfg.accounts.entry(name.to_string()).or_default();
match field.trim() {
"role_arn" => entry.role_arn = value,
"source_profile" => entry.source_profile = Some(value),
"external_id" => entry.external_id = Some(value),
"region" => entry.region = Some(value),
_ => {}
}
}
other if other.starts_with("safety.envs.") => {
let rest = other.trim_start_matches("safety.envs.");
let Some((name, field)) = rest.split_once('.') else {
continue;
};
if field.trim() == "read_only" {
if let Some(b) = parse_bool(&value) {
cfg.safety_envs.insert(name.to_string(), b);
}
}
}
other if other.starts_with("safety.accounts.") => {
let rest = other.trim_start_matches("safety.accounts.");
let Some((name, field)) = rest.split_once('.') else {
continue;
};
if field.trim() == "read_only" {
if let Some(b) = parse_bool(&value) {
cfg.safety_accounts.insert(name.to_string(), b);
}
}
}
_ => {}
}
}
cfg
}
pub fn config_path() -> PathBuf {
config_file("config.toml")
}
pub fn save(cfg: &Config) -> std::io::Result<()> {
let path = config_path();
let body = serialize(cfg);
crate::util::write_atomic(&path, &body)
}
pub fn serialize(cfg: &Config) -> String {
let mut out = String::new();
out.push_str("# ebman configuration — written by :settings; hand-edits welcome\n\n");
out.push_str(&format!(
"refresh_interval_secs = {}\n",
cfg.refresh_interval.as_secs()
));
out.push_str(&format!(
"extra_regions = \"{}\"\n",
cfg.extra_regions.join(",")
));
if let Some(b) = cfg.redact_default {
out.push_str(&format!("redact_default = {b}\n"));
}
if let Some(b) = cfg.grouped_default {
out.push_str(&format!("grouped_default = {b}\n"));
}
out.push_str(&format!("theme = \"{}\"\n", cfg.theme));
out.push_str(&format!("icons = \"{}\"\n", cfg.icons));
out.push_str(&format!("notify_bell = {}\n", cfg.notify_bell));
if let Some(url) = &cfg.notify_webhook {
out.push_str(&format!("notify_webhook = \"{url}\"\n"));
}
if !cfg.required_tags.is_empty() {
out.push_str(&format!(
"required_tags = \"{}\"\n",
cfg.required_tags.join(",")
));
}
if !cfg.profile_themes.is_empty() {
let mut pairs: Vec<(&String, &String)> = cfg.profile_themes.iter().collect();
pairs.sort_by(|a, b| a.0.cmp(b.0));
let joined = pairs
.iter()
.map(|(k, v)| format!("{k}:{v}"))
.collect::<Vec<_>>()
.join(",");
out.push_str(&format!("profile_themes = \"{joined}\"\n"));
}
if !cfg.runbooks.is_empty() {
let mut pairs: Vec<(&String, &String)> = cfg.runbooks.iter().collect();
pairs.sort_by(|a, b| a.0.cmp(b.0));
for (env, url) in pairs {
out.push_str(&format!("runbooks.{env} = \"{url}\"\n"));
}
}
if !cfg.safety_envs.is_empty() {
let mut pairs: Vec<(&String, &bool)> = cfg.safety_envs.iter().collect();
pairs.sort_by(|a, b| a.0.cmp(b.0));
for (env, ro) in pairs {
out.push_str(&format!("safety.envs.{env}.read_only = {ro}\n"));
}
}
if !cfg.safety_accounts.is_empty() {
let mut pairs: Vec<(&String, &bool)> = cfg.safety_accounts.iter().collect();
pairs.sort_by(|a, b| a.0.cmp(b.0));
for (acct, ro) in pairs {
out.push_str(&format!("safety.accounts.{acct}.read_only = {ro}\n"));
}
}
out
}
pub fn parse_profile_themes(raw: &str) -> std::collections::HashMap<String, String> {
let mut out = std::collections::HashMap::new();
for token in raw.split(',') {
let Some((k, v)) = token.split_once(':') else {
continue;
};
let key = k.trim();
let val = v.trim();
if key.is_empty() || val.is_empty() {
continue;
}
out.insert(key.to_string(), val.to_string());
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_overrides_defaults() {
let text = r#"
refresh_interval_secs = 30
extra_regions = "us-gov-east-1, cn-north-1"
redact_default = true
grouped_default = false
"#;
let cfg = parse(text);
assert_eq!(cfg.refresh_interval, Duration::from_secs(30));
assert_eq!(
cfg.extra_regions,
vec!["us-gov-east-1".to_string(), "cn-north-1".to_string()]
);
assert_eq!(cfg.redact_default, Some(true));
assert_eq!(cfg.grouped_default, Some(false));
}
#[test]
fn parse_profile_themes_happy_path() {
let map = parse_profile_themes("prod:high-contrast,staging:dark,default:light");
assert_eq!(map.get("prod"), Some(&"high-contrast".to_string()));
assert_eq!(map.get("staging"), Some(&"dark".to_string()));
assert_eq!(map.get("default"), Some(&"light".to_string()));
assert_eq!(map.len(), 3);
}
#[test]
fn parse_profile_themes_trims_whitespace_and_skips_malformed() {
let map = parse_profile_themes(
" prod : high-contrast , noseparator , :empty-key , empty-value: , ",
);
assert_eq!(map.get("prod"), Some(&"high-contrast".to_string()));
assert_eq!(map.len(), 1);
}
#[test]
fn parse_profile_themes_empty_returns_empty_map() {
assert!(parse_profile_themes("").is_empty());
}
#[test]
fn parse_accounts_collects_multiline_specs() {
let text = r#"
accounts.prod.role_arn = "arn:aws:iam::111122223333:role/EbmanReadOnly"
accounts.prod.source_profile = "default"
accounts.prod.region = "eu-west-2"
accounts.staging.role_arn = "arn:aws:iam::555555555555:role/EbmanReadOnly"
accounts.staging.external_id = "abc-xyz"
"#;
let cfg = parse(text);
assert_eq!(cfg.accounts.len(), 2);
let prod = cfg.accounts.get("prod").expect("prod entry");
assert_eq!(
prod.role_arn,
"arn:aws:iam::111122223333:role/EbmanReadOnly"
);
assert_eq!(prod.source_profile.as_deref(), Some("default"));
assert_eq!(prod.region.as_deref(), Some("eu-west-2"));
assert_eq!(prod.external_id, None);
let staging = cfg.accounts.get("staging").expect("staging entry");
assert_eq!(staging.external_id.as_deref(), Some("abc-xyz"));
assert_eq!(staging.source_profile, None);
}
#[test]
fn parse_accounts_ignores_unknown_field() {
let cfg = parse(
"accounts.prod.role_arn = \"arn:…\"\n\
accounts.prod.future_field = \"whatever\"\n",
);
let prod = cfg.accounts.get("prod").expect("prod entry");
assert_eq!(prod.role_arn, "arn:…");
}
#[test]
fn parse_runbooks_maps_env_to_url() {
let cfg = parse(
"runbooks.uflexi-prod = \"https://wiki/runbook/prod\"\n\
runbooks.uflexi-staging = \"https://wiki/runbook/staging\"\n",
);
assert_eq!(cfg.runbooks.len(), 2);
assert_eq!(
cfg.runbooks.get("uflexi-prod").map(String::as_str),
Some("https://wiki/runbook/prod")
);
assert!(parse("runbooks.x = \"\"\n").runbooks.is_empty());
}
#[test]
fn runbooks_round_trip_through_serialize() {
let mut cfg = Config::default();
cfg.runbooks.insert("prod".into(), "https://rb/prod".into());
let reparsed = parse(&serialize(&cfg));
assert_eq!(
reparsed.runbooks.get("prod").map(String::as_str),
Some("https://rb/prod")
);
}
#[test]
fn parse_safety_envs_and_accounts() {
let cfg = parse(
"safety.envs.uflexi-prod.read_only = true\n\
safety.envs.uflexi-staging.read_only = false\n\
safety.accounts.prod.read_only = true\n",
);
assert_eq!(cfg.safety_envs.get("uflexi-prod"), Some(&true));
assert_eq!(cfg.safety_envs.get("uflexi-staging"), Some(&false));
assert_eq!(cfg.safety_accounts.get("prod"), Some(&true));
let cfg = parse("safety.envs.x.future_field = \"whatever\"\n");
assert!(cfg.safety_envs.is_empty());
}
#[test]
fn safety_round_trips_through_serialize() {
let mut cfg = Config::default();
cfg.safety_envs.insert("uflexi-prod".into(), true);
cfg.safety_accounts.insert("prod".into(), true);
let reparsed = parse(&serialize(&cfg));
assert_eq!(reparsed.safety_envs.get("uflexi-prod"), Some(&true));
assert_eq!(reparsed.safety_accounts.get("prod"), Some(&true));
}
#[test]
fn parse_writes_profile_themes_into_config() {
let cfg = parse("profile_themes = \"prod:high-contrast,staging:dark\"\n");
assert_eq!(cfg.profile_themes.len(), 2);
assert_eq!(
cfg.profile_themes.get("prod"),
Some(&"high-contrast".to_string())
);
}
#[test]
fn parse_ignores_zero_interval() {
let cfg = parse("refresh_interval_secs = 0\n");
assert_eq!(cfg.refresh_interval, Duration::from_secs(15));
}
#[test]
fn parse_empty_returns_defaults() {
let cfg = parse("");
assert_eq!(cfg.refresh_interval, Duration::from_secs(15));
assert!(cfg.extra_regions.is_empty());
assert!(cfg.redact_default.is_none());
}
#[test]
fn parse_icons_auto_is_preserved() {
let cfg = parse("icons = \"auto\"\n");
assert_eq!(cfg.icons, "auto");
}
#[test]
fn serialize_round_trips_full_config() {
let mut profile_themes = std::collections::HashMap::new();
profile_themes.insert("prod".into(), "high-contrast".into());
profile_themes.insert("staging".into(), "dark".into());
let cfg = Config {
refresh_interval: Duration::from_secs(45),
extra_regions: vec!["eu-south-2".into(), "ap-southeast-4".into()],
redact_default: Some(true),
grouped_default: Some(false),
theme: "high-contrast".into(),
icons: "powerline".into(),
notify_bell: true,
required_tags: vec!["Owner".into(), "Env".into()],
profile_themes,
accounts: std::collections::HashMap::new(),
runbooks: std::collections::HashMap::new(),
safety_envs: std::collections::HashMap::new(),
safety_accounts: std::collections::HashMap::new(),
notify_webhook: Some("https://hooks.slack.com/services/EXAMPLE".into()),
};
let body = serialize(&cfg);
let reparsed = parse(&body);
assert_eq!(reparsed.refresh_interval, cfg.refresh_interval);
assert_eq!(reparsed.extra_regions, cfg.extra_regions);
assert_eq!(reparsed.redact_default, cfg.redact_default);
assert_eq!(reparsed.grouped_default, cfg.grouped_default);
assert_eq!(reparsed.theme, cfg.theme);
assert_eq!(reparsed.icons, cfg.icons);
assert_eq!(reparsed.notify_bell, cfg.notify_bell);
assert_eq!(reparsed.required_tags, cfg.required_tags);
assert_eq!(reparsed.profile_themes, cfg.profile_themes);
assert_eq!(reparsed.notify_webhook, cfg.notify_webhook);
}
#[test]
fn serialize_round_trips_default_config() {
let cfg = Config::default();
let body = serialize(&cfg);
let reparsed = parse(&body);
assert_eq!(reparsed.refresh_interval, cfg.refresh_interval);
assert_eq!(reparsed.theme, cfg.theme);
assert_eq!(reparsed.icons, cfg.icons);
assert!(reparsed.extra_regions.is_empty());
assert!(reparsed.required_tags.is_empty());
}
}