use std::collections::HashMap;
use std::io::Write;
#[cfg(unix)]
use std::os::unix::fs::OpenOptionsExt;
use std::path::PathBuf;
use serde::{Deserialize, Serialize};
use crate::error::{BosshoggError, Result};
#[derive(Serialize, Deserialize, Debug, Default)]
pub struct Config {
pub current_context: Option<String>,
#[serde(default)]
pub contexts: HashMap<String, Context>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub analytics_enabled: Option<bool>,
}
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct Context {
pub host: String,
#[serde(default)]
pub region: Option<String>,
#[serde(default)]
pub api_key: Option<String>,
#[serde(default)]
pub project_token: Option<String>,
#[serde(default)]
pub project_id: Option<String>,
#[serde(default)]
pub env_id: Option<String>,
#[serde(default)]
pub org_id: Option<String>,
#[serde(default, skip_serializing_if = "is_false")]
pub allow_http: bool,
}
fn is_false(b: &bool) -> bool {
!*b
}
pub fn config_path() -> PathBuf {
if let Ok(p) = std::env::var("BOSSHOGG_CONFIG") {
return PathBuf::from(p);
}
match dirs::config_dir() {
Some(base) => base.join("bosshogg").join("config.toml"),
None => PathBuf::from("bosshogg-config.toml"),
}
}
pub fn load() -> Result<Config> {
let path = config_path();
if !path.exists() {
return Ok(Config::default());
}
let raw = std::fs::read_to_string(&path)?;
if let Ok(cfg) = toml::from_str::<Config>(&raw) {
if !cfg.contexts.is_empty() || cfg.current_context.is_some() {
return Ok(cfg);
}
}
#[derive(Deserialize)]
struct Legacy {
default_profile: Option<String>,
#[serde(default)]
profiles: HashMap<String, Context>,
}
let legacy: Legacy = toml::from_str(&raw)?;
Ok(Config {
current_context: legacy.default_profile,
contexts: legacy.profiles,
analytics_enabled: None,
})
}
pub fn save(config: &Config) -> Result<()> {
let path = config_path();
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
let body = toml::to_string_pretty(config).map_err(|e| BosshoggError::Config(e.to_string()))?;
let tmp = path.with_extension("toml.tmp");
{
let mut opts = std::fs::OpenOptions::new();
opts.create(true).write(true).truncate(true);
#[cfg(unix)]
opts.mode(0o600);
let mut f = opts.open(&tmp)?;
f.write_all(body.as_bytes())?;
f.flush()?;
}
std::fs::rename(&tmp, &path)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mut perms = std::fs::metadata(&path)?.permissions();
perms.set_mode(0o600);
std::fs::set_permissions(&path, perms)?;
}
Ok(())
}
pub fn data_dir() -> Option<PathBuf> {
if let Ok(p) = std::env::var("BOSSHOGG_CONFIG") {
return PathBuf::from(p).parent().map(PathBuf::from);
}
dirs::config_dir().map(|d| d.join("bosshogg"))
}
pub fn is_analytics_enabled() -> bool {
if std::env::var("DO_NOT_TRACK").ok().as_deref() == Some("1") {
return false;
}
!matches!(load().ok().and_then(|c| c.analytics_enabled), Some(false))
}
pub fn set_analytics_enabled(value: Option<bool>) -> Result<()> {
let mut cfg = load().unwrap_or_default();
cfg.analytics_enabled = value;
save(&cfg)
}
pub fn active_region(context_override: Option<&str>) -> Option<String> {
let cfg = load().ok()?;
let name = context_override
.map(String::from)
.or(cfg.current_context.clone())?;
cfg.contexts.get(&name)?.region.clone()
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
fn with_fake_home<F: FnOnce(&std::path::Path)>(f: F) {
let tmp = TempDir::new().unwrap();
let home = tmp.path().to_path_buf();
temp_env::with_vars(
[
("HOME", Some(home.to_str().unwrap().to_string())),
("XDG_CONFIG_HOME", None),
],
|| f(&home),
);
}
#[test]
fn config_path_under_home_xdg() {
with_fake_home(|home| {
let p = config_path();
assert!(p.starts_with(home), "{p:?} should be under {home:?}");
assert!(p.ends_with("bosshogg/config.toml"));
});
}
#[test]
fn load_missing_returns_default() {
with_fake_home(|_| {
let cfg = load().unwrap();
assert!(cfg.contexts.is_empty());
assert_eq!(cfg.current_context, None);
});
}
#[test]
fn save_then_load_roundtrip() {
with_fake_home(|_| {
let mut cfg = Config::default();
cfg.contexts.insert(
"prod-us".into(),
Context {
host: "https://us.posthog.com".into(),
region: Some("us".into()),
api_key: Some("phx_secret".into()),
project_token: Some("phc_public_token".into()),
project_id: Some("999999".into()),
env_id: None,
org_id: None,
allow_http: false,
},
);
cfg.current_context = Some("prod-us".into());
save(&cfg).unwrap();
let loaded = load().unwrap();
assert_eq!(loaded.current_context.as_deref(), Some("prod-us"));
let ctx = loaded.contexts.get("prod-us").expect("ctx present");
assert_eq!(ctx.project_id.as_deref(), Some("999999"));
assert_eq!(ctx.api_key.as_deref(), Some("phx_secret"));
assert_eq!(ctx.project_token.as_deref(), Some("phc_public_token"));
});
}
#[test]
fn save_sets_0600_perms_on_unix() {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
with_fake_home(|_| {
save(&Config::default()).unwrap();
let perms = std::fs::metadata(config_path()).unwrap().permissions();
assert_eq!(perms.mode() & 0o777, 0o600);
});
}
}
#[test]
fn legacy_profiles_migrate_to_contexts_on_load() {
with_fake_home(|_| {
let path = config_path();
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(
&path,
r#"
default_profile = "prod"
[profiles.prod]
host = "https://us.posthog.com"
api_key = "phx_old"
project_id = "999999"
"#,
)
.unwrap();
let cfg = load().unwrap();
assert_eq!(cfg.current_context.as_deref(), Some("prod"));
let ctx = cfg.contexts.get("prod").expect("migrated");
assert_eq!(ctx.api_key.as_deref(), Some("phx_old"));
assert_eq!(ctx.project_id.as_deref(), Some("999999"));
});
}
}