use std::collections::HashMap;
use std::path::PathBuf;
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct Config {
#[serde(default = "default_server_name")]
pub default_server: String,
pub servers: HashMap<String, ServerConfig>,
}
fn default_server_name() -> String {
"default".to_string()
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct ServerConfig {
pub addr: String,
pub api_key: String,
}
#[derive(Debug, Deserialize)]
struct OldConfig {
server: OldServerConfig,
}
#[derive(Debug, Deserialize)]
struct OldServerConfig {
addr: String,
api_key: String,
}
pub fn gout_dir() -> PathBuf {
let home = if let Ok(h) = std::env::var("HOME") {
PathBuf::from(h)
} else {
dirs::home_dir().expect("cannot find home directory")
};
home.join(".gout")
}
fn config_path() -> PathBuf {
let new = gout_dir().join("config.toml");
if new.exists() {
return new;
}
let legacy = gout_dir().with_file_name(".goutrc");
if legacy.exists() {
return legacy;
}
new
}
pub fn read() -> Result<Config> {
let path = config_path();
if !path.exists() {
anyhow::bail!(
"config not found. Run `gout login <server> <key>` first.\n looked in: {}",
path.display()
);
}
let content = std::fs::read_to_string(&path).context("read config")?;
let cfg = match toml::from_str::<Config>(&content) {
Ok(c) => c,
Err(_) => {
let old: OldConfig =
toml::from_str(&content).context("config format unrecognized")?;
let mut servers = HashMap::new();
servers.insert(
"default".to_string(),
ServerConfig {
addr: old.server.addr,
api_key: old.server.api_key,
},
);
let cfg = Config {
default_server: "default".to_string(),
servers,
};
let new_content = toml::to_string_pretty(&cfg)?;
let new_path = gout_dir().join("config.toml");
std::fs::create_dir_all(gout_dir()).ok();
std::fs::write(&new_path, &new_content).ok();
if path != new_path {
std::fs::remove_file(&path).ok();
}
cfg
}
};
Ok(cfg)
}
pub fn write(name: &str, addr: &str, api_key: &str) -> Result<()> {
let mut cfg = if config_path().exists() {
read().unwrap_or_else(|_| Config {
default_server: default_server_name(),
servers: HashMap::new(),
})
} else {
Config {
default_server: default_server_name(),
servers: HashMap::new(),
}
};
cfg.servers.insert(
name.to_string(),
ServerConfig {
addr: addr.to_string(),
api_key: api_key.to_string(),
},
);
if cfg.servers.len() == 1 {
cfg.default_server = name.to_string();
}
let content = toml::to_string_pretty(&cfg).context("serialize config")?;
let new_path = gout_dir().join("config.toml");
std::fs::create_dir_all(gout_dir()).context("create ~/.gout")?;
std::fs::write(&new_path, &content).context("write config")?;
let legacy = gout_dir().with_file_name(".goutrc");
if legacy.exists() {
std::fs::remove_file(&legacy).ok();
}
Ok(())
}
pub fn resolve(name: Option<&str>) -> Result<ServerConfig> {
let cfg = read()?;
let key = name.unwrap_or(&cfg.default_server);
let key = if key.is_empty() { &cfg.default_server } else { key };
cfg.servers
.get(key)
.cloned()
.or_else(|| {
cfg.servers.values().find(|s| s.addr == key).cloned()
})
.context(format!("server {key:?} not found"))
}
pub fn remove(name: &str) -> Result<()> {
let mut cfg = read()?;
if !cfg.servers.contains_key(name) {
anyhow::bail!("server {name:?} not found");
}
cfg.servers.remove(name);
if cfg.default_server == name {
cfg.default_server = cfg.servers.keys().next().cloned().unwrap_or_default();
}
let content = toml::to_string_pretty(&cfg)?;
let new_path = gout_dir().join("config.toml");
std::fs::write(&new_path, &content).context("write config")?;
Ok(())
}
pub fn set_default(name: &str) -> Result<()> {
let mut cfg = read().unwrap_or_else(|_| Config {
default_server: default_server_name(),
servers: HashMap::new(),
});
if !cfg.servers.contains_key(name) {
anyhow::bail!("server {name:?} not found");
}
cfg.default_server = name.to_string();
let content = toml::to_string_pretty(&cfg)?;
let new_path = gout_dir().join("config.toml");
std::fs::write(&new_path, &content).context("write config")?;
Ok(())
}
pub fn list_servers() -> Result<Vec<(String, ServerConfig, bool)>> {
let cfg = read()?;
let mut list: Vec<_> = cfg
.servers
.into_iter()
.map(|(name, sc)| {
let is_default = name == cfg.default_server;
(name, sc, is_default)
})
.collect();
list.sort_by(|a, b| b.2.cmp(&a.2).then(a.0.cmp(&b.0))); Ok(list)
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::Mutex;
static HOME_LOCK: Mutex<()> = Mutex::new(());
fn with_temp_home(f: impl FnOnce(&std::path::Path)) {
let _guard = HOME_LOCK.lock().unwrap();
let tmp = tempfile::tempdir().expect("temp dir");
let home = tmp.path().join("home");
std::fs::create_dir_all(&home).unwrap();
let old = std::env::var_os("HOME");
std::env::set_var("HOME", &home);
f(&home);
match old {
Some(v) => std::env::set_var("HOME", v),
None => std::env::remove_var("HOME"),
}
}
#[test]
fn test_write_then_read() {
with_temp_home(|_home| {
write("default", "example.com:8080", "sk-test123").unwrap();
let cfg = read().unwrap();
let s = cfg.servers.get("default").unwrap();
assert_eq!(s.addr, "example.com:8080");
assert_eq!(s.api_key, "sk-test123");
assert_eq!(cfg.default_server, "default");
});
}
#[test]
fn test_multi_server() {
with_temp_home(|_home| {
write("prod", "prod.com:8080", "sk-prod").unwrap();
write("dev", "dev.com:8080", "sk-dev").unwrap();
let cfg = read().unwrap();
assert_eq!(cfg.servers.len(), 2);
assert_eq!(cfg.default_server, "prod"); });
}
#[test]
fn test_resolve_default() {
with_temp_home(|_home| {
write("main", "m:8080", "sk-m").unwrap();
let s = resolve(None).unwrap();
assert_eq!(s.addr, "m:8080");
});
}
#[test]
fn test_resolve_by_addr() {
with_temp_home(|_home| {
write("x", "x.com:8080", "sk-x").unwrap();
let s = resolve(Some("x.com:8080")).unwrap();
assert_eq!(s.api_key, "sk-x");
});
}
#[test]
fn test_read_not_found() {
with_temp_home(|_home| {
let err = read().unwrap_err();
assert!(err.to_string().contains("not found"), "got: {err}");
});
}
#[test]
fn test_migrates_old_format() {
with_temp_home(|home| {
let old_content = r#"[server]
addr = "old:8080"
api_key = "sk-old"
"#;
std::fs::write(home.join(".goutrc"), old_content).unwrap();
let cfg = read().unwrap();
let s = cfg.servers.get("default").unwrap();
assert_eq!(s.addr, "old:8080");
assert_eq!(s.api_key, "sk-old");
assert_eq!(cfg.default_server, "default");
assert!(!home.join(".goutrc").exists());
assert!(config_path().exists());
});
}
#[test]
fn test_list_servers() {
with_temp_home(|_home| {
write("b", "b:1", "k").unwrap();
write("a", "a:2", "k").unwrap();
let list = list_servers().unwrap();
assert!(list.len() >= 2);
assert_eq!(list[0].0, "b");
assert!(list[0].2); });
}
#[test]
fn test_set_default_switches() {
with_temp_home(|_home| {
write("a", "a:1", "k").unwrap();
write("b", "b:2", "k").unwrap();
set_default("b").unwrap();
let list = list_servers().unwrap();
assert_eq!(list[0].0, "b", "should now be default");
assert!(list[0].2);
});
}
#[test]
fn test_set_default_nonexistent() {
with_temp_home(|_home| {
write("x", "x:1", "k").unwrap();
let err = set_default("y").unwrap_err();
assert!(err.to_string().contains("not found"));
});
}
}