use crate::error::Error;
use serde::Deserialize;
use std::path::{Path, PathBuf};
pub const CONFIG_FILENAME: &str = "chipzen.toml";
pub const SECTION_NAME: &str = "external_api";
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct ChipzenConfig {
pub path: Option<PathBuf>,
pub token: Option<String>,
pub url: Option<String>,
pub bot_id: Option<String>,
}
#[derive(Debug, Deserialize)]
struct RawDoc {
external_api: Option<RawSection>,
}
#[derive(Debug, Deserialize)]
struct RawSection {
token: Option<String>,
url: Option<String>,
bot_id: Option<String>,
}
fn search_paths() -> Vec<PathBuf> {
let mut paths: Vec<PathBuf> = Vec::new();
if let Ok(cwd) = std::env::current_dir() {
paths.push(cwd.join(CONFIG_FILENAME));
}
if let Some(home) = home_dir() {
paths.push(home.join(".chipzen").join(CONFIG_FILENAME));
}
if cfg!(not(windows)) {
paths.push(PathBuf::from("/etc/chipzen").join(CONFIG_FILENAME));
}
paths
}
fn home_dir() -> Option<PathBuf> {
if let Some(home) = std::env::var_os("HOME").filter(|s| !s.is_empty()) {
return Some(PathBuf::from(home));
}
if let Some(profile) = std::env::var_os("USERPROFILE").filter(|s| !s.is_empty()) {
return Some(PathBuf::from(profile));
}
None
}
pub fn discover_config_path(search: Option<&[PathBuf]>) -> Option<PathBuf> {
let owned;
let candidates: &[PathBuf] = match search {
Some(s) => s,
None => {
owned = search_paths();
&owned
}
};
candidates.iter().find(|p| p.is_file()).cloned()
}
pub fn load_chipzen_config(search: Option<&[PathBuf]>) -> Result<Option<ChipzenConfig>, Error> {
let Some(path) = discover_config_path(search) else {
return Ok(None);
};
load_from_path(&path).map(Some)
}
pub fn load_from_path(path: &Path) -> Result<ChipzenConfig, Error> {
let raw = std::fs::read_to_string(path)
.map_err(|e| Error::Protocol(format!("failed to read {}: {e}", path.display())))?;
let doc: RawDoc = toml::from_str(&raw).map_err(|e| {
Error::Protocol(format!(
"failed to parse {}: {e}. Fix the syntax or delete the file to fall \
back to explicit run_external_bot arguments.",
path.display()
))
})?;
let Some(section) = doc.external_api else {
return Err(Error::Protocol(format!(
"{} has no [{SECTION_NAME}] section. Add one with at least:\n\n \
[{SECTION_NAME}]\n token = \"cz_extbot_...\"\n",
path.display()
)));
};
Ok(ChipzenConfig {
path: Some(path.to_path_buf()),
token: section.token,
url: section.url,
bot_id: section.bot_id,
})
}
pub fn resolve_token(
explicit_token: Option<&str>,
config: Option<&ChipzenConfig>,
) -> Option<String> {
if let Some(t) = explicit_token {
return Some(t.to_string());
}
config.and_then(|c| c.token.clone())
}
pub fn resolve_url(explicit_url: Option<&str>, config: Option<&ChipzenConfig>) -> Option<String> {
if let Some(u) = explicit_url {
return Some(u.to_string());
}
config.and_then(|c| c.url.clone())
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
fn write_toml(dir: &std::path::Path, body: &str) -> PathBuf {
let path = dir.join(CONFIG_FILENAME);
let mut f = std::fs::File::create(&path).unwrap();
f.write_all(body.as_bytes()).unwrap();
path
}
#[test]
fn loads_all_external_api_fields() {
let dir = tempfile::tempdir().unwrap();
let path = write_toml(
dir.path(),
"[external_api]\ntoken = \"cz_extbot_abc\"\nurl = \"wss://x/y\"\nbot_id = \"uuid-1\"\n",
);
let cfg = load_from_path(&path).unwrap();
assert_eq!(cfg.token.as_deref(), Some("cz_extbot_abc"));
assert_eq!(cfg.url.as_deref(), Some("wss://x/y"));
assert_eq!(cfg.bot_id.as_deref(), Some("uuid-1"));
assert_eq!(cfg.path.as_deref(), Some(path.as_path()));
}
#[test]
fn all_fields_optional() {
let dir = tempfile::tempdir().unwrap();
let path = write_toml(dir.path(), "[external_api]\n");
let cfg = load_from_path(&path).unwrap();
assert!(cfg.token.is_none() && cfg.url.is_none() && cfg.bot_id.is_none());
}
#[test]
fn missing_section_is_an_error() {
let dir = tempfile::tempdir().unwrap();
let path = write_toml(dir.path(), "[other]\nfoo = 1\n");
let err = load_from_path(&path).unwrap_err();
assert!(format!("{err}").contains("has no [external_api] section"));
}
#[test]
fn malformed_toml_is_an_error() {
let dir = tempfile::tempdir().unwrap();
let path = write_toml(dir.path(), "[external_api\ntoken = ");
let err = load_from_path(&path).unwrap_err();
assert!(format!("{err}").contains("failed to parse"));
}
#[test]
fn unknown_keys_are_tolerated_forward_compat() {
let dir = tempfile::tempdir().unwrap();
let path = write_toml(
dir.path(),
"[external_api]\ntoken = \"t\"\nfuture_field = \"ignored\"\n",
);
let cfg = load_from_path(&path).unwrap();
assert_eq!(cfg.token.as_deref(), Some("t"));
}
#[test]
fn discovery_returns_first_existing_in_order() {
let dir = tempfile::tempdir().unwrap();
let present = write_toml(dir.path(), "[external_api]\ntoken = \"t\"\n");
let missing = dir.path().join("nope").join(CONFIG_FILENAME);
let found = discover_config_path(Some(&[missing, present.clone()]));
assert_eq!(found.as_deref(), Some(present.as_path()));
}
#[test]
fn no_file_on_path_is_ok_none() {
let dir = tempfile::tempdir().unwrap();
let missing = dir.path().join("absent").join(CONFIG_FILENAME);
assert!(load_chipzen_config(Some(&[missing])).unwrap().is_none());
}
#[test]
fn resolve_precedence() {
let cfg = ChipzenConfig {
token: Some("cfg_tok".into()),
url: Some("cfg_url".into()),
..Default::default()
};
assert_eq!(
resolve_token(Some("explicit"), Some(&cfg)).as_deref(),
Some("explicit")
);
assert_eq!(resolve_token(Some(""), Some(&cfg)).as_deref(), Some(""));
assert_eq!(resolve_token(None, Some(&cfg)).as_deref(), Some("cfg_tok"));
assert_eq!(resolve_token(None, None), None);
assert_eq!(
resolve_url(Some("ex_url"), Some(&cfg)).as_deref(),
Some("ex_url")
);
assert_eq!(resolve_url(None, Some(&cfg)).as_deref(), Some("cfg_url"));
assert_eq!(resolve_url(None, None), None);
}
}