use std::ffi::OsString;
use std::path::{Path, PathBuf};
pub fn config_home() -> Option<PathBuf> {
config_home_from(
std::env::var_os("XDG_CONFIG_HOME"),
std::env::var_os("HOME"),
)
}
fn config_home_from(xdg: Option<OsString>, home: Option<OsString>) -> Option<PathBuf> {
xdg.filter(|value| !value.is_empty())
.map(PathBuf::from)
.or_else(|| home.map(|home| PathBuf::from(home).join(".config")))
.map(|base| base.join("ikigai"))
}
pub fn config_path() -> PathBuf {
config_home()
.unwrap_or_else(|| Path::new(".").join(".config").join("ikigai"))
.join("config.toml")
}
pub fn get(key: &str) -> Option<String> {
value_for(&std::fs::read_to_string(config_path()).ok()?, key)
}
pub fn all(key: &str) -> Vec<String> {
std::fs::read_to_string(config_path())
.map(|text| values_for(&text, key))
.unwrap_or_default()
}
pub fn scoping_instances(key: &str) -> Vec<String> {
std::fs::read_to_string(config_path())
.map(|text| scoping_instances_in(&text, key))
.unwrap_or_default()
}
fn scoping_instances_in(text: &str, key: &str) -> Vec<String> {
let suffix = format!(".{key}");
let mut instances: Vec<String> = Vec::new();
for line in text.lines() {
let line = line.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
if let Some((name, _)) = line.split_once('=') {
if let Some(instance) = name.trim().strip_suffix(&suffix) {
if !instance.is_empty() && !instances.iter().any(|i| i == instance) {
instances.push(instance.to_string());
}
}
}
}
instances
}
fn values_for(text: &str, key: &str) -> Vec<String> {
text.lines()
.map(str::trim)
.filter(|line| !line.is_empty() && !line.starts_with('#'))
.filter_map(|line| line.split_once('='))
.filter(|(name, _)| name.trim() == key)
.map(|(_, value)| value.trim().trim_matches(['"', '\'']).trim().to_string())
.collect()
}
fn value_for(text: &str, key: &str) -> Option<String> {
for line in text.lines() {
let line = line.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
if let Some((name, value)) = line.split_once('=') {
if name.trim() == key {
return Some(value.trim().trim_matches(['"', '\'']).trim().to_string());
}
}
}
None
}
#[cfg(test)]
mod tests {
use super::{
config_home, config_home_from, config_path, scoping_instances_in, value_for, values_for,
};
use std::ffi::OsString;
use std::path::{Path, PathBuf};
#[test]
fn xdg_config_home_decides_the_config_home_when_set() {
assert_eq!(
config_home_from(Some("/xdg".into()), Some("/home/b".into())),
Some(PathBuf::from("/xdg/ikigai"))
);
assert_eq!(
config_home_from(Some("/xdg".into()), None),
Some(PathBuf::from("/xdg/ikigai"))
);
}
#[test]
fn an_unset_or_empty_xdg_falls_back_to_home_config() {
assert_eq!(
config_home_from(None, Some("/home/b".into())),
Some(PathBuf::from("/home/b/.config/ikigai"))
);
assert_eq!(
config_home_from(Some(OsString::new()), Some("/home/b".into())),
Some(PathBuf::from("/home/b/.config/ikigai"))
);
}
#[test]
fn no_base_directory_at_all_is_none() {
assert_eq!(config_home_from(None, None), None);
assert_eq!(
config_path(),
config_home()
.unwrap_or_else(|| Path::new(".").join(".config").join("ikigai"))
.join("config.toml")
);
}
#[test]
fn the_config_home_files_are_siblings() {
if std::env::var_os("IKIGAI_GRANTS").is_some()
|| std::env::var_os("IKIGAI_CLIENTS").is_some()
{
return;
}
let Some(home) = config_home() else {
return; };
let grants = crate::grants_path();
let clients = crate::clients::clients_path();
assert_eq!(config_path().parent(), Some(home.as_path()));
assert_eq!(
grants.as_deref().and_then(Path::parent),
Some(home.as_path())
);
assert_eq!(
clients.as_deref().and_then(Path::parent),
Some(home.as_path())
);
}
#[test]
fn scoped_spellings_surface_their_instances() {
let text = "browse.root = \"~/a\"\n\
serve.browse.root = \"~/b\"\n\
serve.browse.root = \"~/c\"\n\
daemon.browse.root = \"~/d\"\n\
# repl.browse.root = \"~/e\"\n\
mail.from = \"x@y.example\"\n";
assert_eq!(
scoping_instances_in(text, "browse.root"),
vec!["serve".to_string(), "daemon".to_string()]
);
assert!(scoping_instances_in(text, "browse.store").is_empty());
}
#[test]
fn repeated_keys_all_come_back_in_order() {
let text = "# topology\n\
mount = \"prefer urn:llm:=peer:plasma\"\n\
other = 1\n\
mount = \"alias urn:cal:=quic://bug.local:4433\"\n";
assert_eq!(
values_for(text, "mount"),
vec![
"prefer urn:llm:=peer:plasma".to_string(),
"alias urn:cal:=quic://bug.local:4433".to_string()
]
);
assert!(values_for(text, "absent").is_empty());
}
#[test]
fn reads_a_dotted_key_unquoted() {
let text = "# host config\n\nmail.from = \"brian@bosatsu.net\"\nmail.port = 587\n";
assert_eq!(
value_for(text, "mail.from").as_deref(),
Some("brian@bosatsu.net")
);
assert_eq!(value_for(text, "mail.port").as_deref(), Some("587"));
}
#[test]
fn an_absent_or_commented_key_is_none() {
assert_eq!(value_for("mail.host = localhost", "mail.from"), None);
assert_eq!(value_for("# mail.from = x@y.example", "mail.from"), None);
}
}