use std::collections::HashMap;
use std::path::{Path, PathBuf};
use serde::Deserialize;
#[derive(Debug, Default, Deserialize)]
#[serde(default)]
pub struct Config {
pub default_namespace: Option<String>,
pub default_resource: Option<String>,
pub readonly: bool,
pub aliases: HashMap<String, String>,
pub plugins: Vec<Plugin>,
pub views: HashMap<String, ViewConfig>,
pub skin: Skin,
}
#[derive(Debug, Default, Clone, Deserialize)]
#[serde(default)]
pub struct ViewConfig {
pub sort: Option<String>,
pub replace: bool,
pub columns: Vec<ViewColumnConfig>,
}
#[derive(Debug, Default, Clone, Deserialize)]
#[serde(default)]
pub struct ViewColumnConfig {
pub name: String,
pub path: String,
#[serde(rename = "type")]
pub kind: Option<String>,
pub wide: bool,
pub width: Option<u16>,
pub align: Option<String>,
}
#[derive(Debug, Default, Clone, Deserialize)]
#[serde(default)]
pub struct Skin {
pub name: Option<String>,
pub colors: HashMap<String, String>,
pub background: bool,
}
#[derive(Debug, Clone, Deserialize)]
pub struct Plugin {
pub key: char,
pub name: String,
pub command: String,
#[serde(default)]
pub args: Vec<String>,
#[serde(default)]
pub scopes: Vec<String>,
}
pub struct Resolved {
pub config: Config,
pub skin_override: Option<String>,
pub warnings: Vec<String>,
}
#[derive(Default)]
pub struct ConfigLoader {
base: Option<toml::Value>,
dir: Option<PathBuf>,
}
impl ConfigLoader {
pub fn load() -> (Self, Vec<String>) {
let dir = config_dir();
let empty = Self {
base: None,
dir: dir.clone(),
};
match empty.reload() {
Ok(loader) => (loader, Vec::new()),
Err(e) => {
eprintln!("warning: ignoring invalid {e}");
(Self { base: None, dir }, vec![e])
}
}
}
#[cfg(test)]
pub(crate) fn from_dir(dir: Option<PathBuf>) -> Self {
let base = dir.as_ref().and_then(|d| {
let text = std::fs::read_to_string(d.join("config.toml")).ok()?;
parse_doc(&text).ok()
});
Self { base, dir }
}
pub fn reload(&self) -> Result<Self, String> {
let dir = self.dir.clone();
let Some(path) = self.base_path() else {
return Ok(Self { base: None, dir });
};
match std::fs::read_to_string(&path) {
Err(_) => Ok(Self { base: None, dir }),
Ok(text) => match validate(&text) {
Ok(value) => Ok(Self {
base: Some(value),
dir,
}),
Err(e) => Err(format!("{}: {e}", path.display())),
},
}
}
pub fn base_path(&self) -> Option<PathBuf> {
self.dir.as_ref().map(|d| d.join("config.toml"))
}
pub fn has_base(&self) -> bool {
self.base.is_some()
}
pub fn override_paths(&self, context: &str, cluster: &str) -> Vec<PathBuf> {
let mut paths = Vec::new();
if let (Some(dir), false) = (&self.dir, cluster.is_empty()) {
let cluster_dir = dir.join("clusters").join(sanitize(cluster));
paths.push(cluster_dir.join("config.toml"));
if !context.is_empty() {
paths.push(cluster_dir.join(sanitize(context)).join("config.toml"));
}
}
paths
}
pub fn resolve(&self, context: &str, cluster: &str) -> Resolved {
let mut warnings = Vec::new();
let mut merged = self
.base
.clone()
.unwrap_or_else(|| toml::Value::Table(toml::map::Map::new()));
let mut overlay = toml::Value::Table(toml::map::Map::new());
for path in self.override_paths(context, cluster) {
match read_value(&path) {
Ok(Some(v)) => {
merge(&mut merged, v.clone());
merge(&mut overlay, v);
}
Ok(None) => {}
Err(e) => warnings.push(format!("ignoring invalid {}: {e}", path.display())),
}
}
let config = merged.try_into().unwrap_or_else(|e| {
warnings.push(format!("ignoring cluster overrides: {e}"));
self.base
.clone()
.and_then(|b| b.try_into().ok())
.unwrap_or_default()
});
let skin_override = overlay
.get("skin")
.and_then(|s| s.get("name"))
.and_then(|n| n.as_str())
.map(String::from);
Resolved {
config,
skin_override,
warnings,
}
}
}
fn merge(base: &mut toml::Value, overlay: toml::Value) {
match (base, overlay) {
(toml::Value::Table(b), toml::Value::Table(o)) => {
for (k, v) in o {
match b.get_mut(&k) {
Some(slot) if slot.is_table() && v.is_table() => merge(slot, v),
_ => {
b.insert(k, v);
}
}
}
}
(slot, v) => *slot = v,
}
}
fn validate(text: &str) -> Result<toml::Value, toml::de::Error> {
toml::from_str::<Config>(text)?;
parse_doc(text)
}
pub fn file_state(path: &Path) -> &'static str {
match read_value(path) {
Ok(Some(_)) => "loaded",
Ok(None) => "absent",
Err(_) => "invalid — skipped",
}
}
fn read_value(path: &Path) -> Result<Option<toml::Value>, toml::de::Error> {
match std::fs::read_to_string(path) {
Ok(text) => parse_doc(&text).map(Some),
Err(_) => Ok(None),
}
}
fn parse_doc(text: &str) -> Result<toml::Value, toml::de::Error> {
text.parse::<toml::Table>().map(toml::Value::Table)
}
fn sanitize(name: &str) -> String {
let s: String = name
.chars()
.map(|c| {
if c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-') {
c
} else {
'-'
}
})
.collect();
if s.chars().all(|c| c == '.') {
"-".repeat(s.len().max(1))
} else {
s
}
}
fn config_dir() -> Option<PathBuf> {
let base = std::env::var_os("XDG_CONFIG_HOME")
.map(PathBuf::from)
.or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".config")))?;
Some(base.join("sofka"))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_full_config() {
let toml = r#"
default_namespace = "kube-system"
default_resource = "deployments"
[aliases]
dep = "deployments"
[[plugins]]
key = "g"
name = "argocd-sync"
command = "argocd"
args = ["app", "sync", "$NAME"]
scopes = ["deployments"]
"#;
let cfg: Config = toml::from_str(toml).unwrap();
assert_eq!(cfg.default_namespace.as_deref(), Some("kube-system"));
assert_eq!(
cfg.aliases.get("dep").map(String::as_str),
Some("deployments")
);
assert_eq!(cfg.plugins.len(), 1);
let p = &cfg.plugins[0];
assert_eq!(p.key, 'g');
assert_eq!(p.args, vec!["app", "sync", "$NAME"]);
assert_eq!(p.scopes, vec!["deployments"]);
}
#[test]
fn empty_config_is_default() {
let cfg: Config = toml::from_str("").unwrap();
assert!(cfg.aliases.is_empty());
assert!(cfg.plugins.is_empty());
assert!(cfg.default_resource.is_none());
}
fn val(s: &str) -> toml::Value {
parse_doc(s).unwrap()
}
#[test]
fn merge_tables_key_by_key_scalars_and_arrays_replace() {
let mut base = val(r##"
default_namespace = "default"
default_resource = "pods"
[aliases]
dep = "deployments"
[[plugins]]
key = "g"
name = "base-plugin"
command = "true"
[skin]
name = "gruvbox-dark"
[skin.colors]
red = "#ff0000"
"##);
merge(
&mut base,
val(r##"
default_namespace = "kube-system"
[aliases]
ks = "kustomizations"
[[plugins]]
key = "h"
name = "override-plugin"
command = "false"
[skin]
name = "catppuccin-latte"
[skin.colors]
blue = "#0000ff"
"##),
);
let cfg: Config = base.try_into().unwrap();
assert_eq!(cfg.default_namespace.as_deref(), Some("kube-system"));
assert_eq!(cfg.skin.name.as_deref(), Some("catppuccin-latte"));
assert_eq!(cfg.default_resource.as_deref(), Some("pods"));
assert_eq!(cfg.aliases.len(), 2);
assert_eq!(cfg.skin.colors.len(), 2);
assert_eq!(cfg.plugins.len(), 1);
assert_eq!(cfg.plugins[0].name, "override-plugin");
}
#[test]
fn sanitize_maps_unsafe_chars_to_dashes() {
assert_eq!(sanitize("prod-cluster_1.io"), "prod-cluster_1.io");
assert_eq!(
sanitize("arn:aws:eks:eu-west-1:123:cluster/prod"),
"arn-aws-eks-eu-west-1-123-cluster-prod"
);
assert_eq!(sanitize(".."), "--");
assert_eq!(sanitize(""), "-");
}
#[test]
fn resolves_cluster_and_context_overrides() {
let dir = std::env::temp_dir().join(format!("sofka-cfg-test-{}", std::process::id()));
let ctx_dir = dir.join("clusters").join("prod-cluster").join("prod-ctx");
std::fs::create_dir_all(&ctx_dir).unwrap();
std::fs::write(
dir.join("config.toml"),
"default_namespace = \"default\"\n[aliases]\ndep = \"deployments\"\n[skin]\nname = \"gruvbox-dark\"\n",
)
.unwrap();
std::fs::write(
dir.join("clusters")
.join("prod-cluster")
.join("config.toml"),
"readonly = true\n[skin]\nname = \"catppuccin-latte\"\nbackground = true\n",
)
.unwrap();
std::fs::write(
ctx_dir.join("config.toml"),
"default_namespace = \"prod\"\n[aliases]\nks = \"kustomizations\"\n",
)
.unwrap();
let loader = ConfigLoader::from_dir(Some(dir.clone()));
let plain = loader.resolve("dev-ctx", "dev-cluster");
assert!(plain.warnings.is_empty());
assert_eq!(plain.config.default_namespace.as_deref(), Some("default"));
assert_eq!(plain.config.skin.name.as_deref(), Some("gruvbox-dark"));
assert_eq!(plain.skin_override, None);
assert!(!plain.config.readonly);
let prod = loader.resolve("prod-ctx", "prod-cluster");
assert!(prod.warnings.is_empty());
assert_eq!(prod.config.default_namespace.as_deref(), Some("prod"));
assert!(prod.config.skin.background);
assert_eq!(prod.config.aliases.len(), 2);
assert_eq!(prod.skin_override.as_deref(), Some("catppuccin-latte"));
assert!(prod.config.readonly);
let bare = loader.resolve("prod-ctx", "");
assert_eq!(bare.config.default_namespace.as_deref(), Some("default"));
assert_eq!(bare.skin_override, None);
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn reload_swaps_base_and_rejects_invalid_with_precise_errors() {
let dir =
std::env::temp_dir().join(format!("sofka-cfg-reload-test-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("config.toml");
std::fs::write(&path, "default_namespace = \"one\"\n").unwrap();
let loader = ConfigLoader::from_dir(Some(dir.clone()));
assert!(loader.has_base());
assert_eq!(loader.base_path(), Some(path.clone()));
std::fs::write(&path, "default_namespace = \"two\"\n").unwrap();
let loader = loader.reload().unwrap();
let r = loader.resolve("", "");
assert_eq!(r.config.default_namespace.as_deref(), Some("two"));
std::fs::write(&path, "readonly = \"yes\"\n").unwrap();
let err = loader.reload().err().unwrap();
assert!(err.contains("config.toml"), "{err}");
assert!(err.contains("readonly"), "{err}");
assert!(err.contains("expected a boolean"), "{err}");
std::fs::write(&path, "not toml [[[").unwrap();
assert!(loader.reload().err().unwrap().contains("config.toml"));
assert_eq!(file_state(&path), "invalid — skipped");
std::fs::remove_file(&path).unwrap();
let loader = loader.reload().unwrap();
assert!(!loader.has_base());
assert_eq!(file_state(&path), "absent");
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn override_paths_follow_cluster_then_context() {
let dir = PathBuf::from("/tmp/sofka-nowhere");
let loader = ConfigLoader::from_dir(Some(dir.clone()));
assert_eq!(
loader.override_paths("prod-ctx", "prod-cluster"),
vec![
dir.join("clusters")
.join("prod-cluster")
.join("config.toml"),
dir.join("clusters")
.join("prod-cluster")
.join("prod-ctx")
.join("config.toml"),
]
);
assert!(loader.override_paths("ctx", "").is_empty());
assert_eq!(loader.override_paths("", "c1").len(), 1);
}
#[test]
fn malformed_override_warns_and_is_skipped() {
let dir = std::env::temp_dir().join(format!("sofka-cfg-bad-test-{}", std::process::id()));
let cluster_dir = dir.join("clusters").join("c1");
std::fs::create_dir_all(&cluster_dir).unwrap();
std::fs::write(dir.join("config.toml"), "default_namespace = \"base\"\n").unwrap();
std::fs::write(cluster_dir.join("config.toml"), "not valid toml [[[").unwrap();
let loader = ConfigLoader::from_dir(Some(dir.clone()));
let r = loader.resolve("ctx", "c1");
assert_eq!(r.warnings.len(), 1);
assert_eq!(r.config.default_namespace.as_deref(), Some("base"));
std::fs::remove_dir_all(&dir).unwrap();
}
}