1use crate::error::Error;
40use serde::Deserialize;
41use std::path::{Path, PathBuf};
42
43pub const CONFIG_FILENAME: &str = "chipzen.toml";
45pub const SECTION_NAME: &str = "external_api";
47
48#[derive(Debug, Clone, PartialEq, Eq, Default)]
50pub struct ChipzenConfig {
51 pub path: Option<PathBuf>,
54 pub token: Option<String>,
56 pub url: Option<String>,
58 pub bot_id: Option<String>,
60}
61
62#[derive(Debug, Deserialize)]
66struct RawDoc {
67 external_api: Option<RawSection>,
68}
69
70#[derive(Debug, Deserialize)]
71struct RawSection {
72 token: Option<String>,
73 url: Option<String>,
74 bot_id: Option<String>,
75}
76
77fn search_paths() -> Vec<PathBuf> {
79 let mut paths: Vec<PathBuf> = Vec::new();
80 if let Ok(cwd) = std::env::current_dir() {
81 paths.push(cwd.join(CONFIG_FILENAME));
82 }
83 if let Some(home) = home_dir() {
84 paths.push(home.join(".chipzen").join(CONFIG_FILENAME));
85 }
86 if cfg!(not(windows)) {
88 paths.push(PathBuf::from("/etc/chipzen").join(CONFIG_FILENAME));
89 }
90 paths
91}
92
93fn home_dir() -> Option<PathBuf> {
96 if let Some(home) = std::env::var_os("HOME").filter(|s| !s.is_empty()) {
97 return Some(PathBuf::from(home));
98 }
99 if let Some(profile) = std::env::var_os("USERPROFILE").filter(|s| !s.is_empty()) {
100 return Some(PathBuf::from(profile));
101 }
102 None
103}
104
105pub fn discover_config_path(search: Option<&[PathBuf]>) -> Option<PathBuf> {
110 let owned;
111 let candidates: &[PathBuf] = match search {
112 Some(s) => s,
113 None => {
114 owned = search_paths();
115 &owned
116 }
117 };
118 candidates.iter().find(|p| p.is_file()).cloned()
119}
120
121pub fn load_chipzen_config(search: Option<&[PathBuf]>) -> Result<Option<ChipzenConfig>, Error> {
130 let Some(path) = discover_config_path(search) else {
131 return Ok(None);
132 };
133 load_from_path(&path).map(Some)
134}
135
136pub fn load_from_path(path: &Path) -> Result<ChipzenConfig, Error> {
139 let raw = std::fs::read_to_string(path)
140 .map_err(|e| Error::Protocol(format!("failed to read {}: {e}", path.display())))?;
141
142 let doc: RawDoc = toml::from_str(&raw).map_err(|e| {
143 Error::Protocol(format!(
144 "failed to parse {}: {e}. Fix the syntax or delete the file to fall \
145 back to explicit run_external_bot arguments.",
146 path.display()
147 ))
148 })?;
149
150 let Some(section) = doc.external_api else {
151 return Err(Error::Protocol(format!(
152 "{} has no [{SECTION_NAME}] section. Add one with at least:\n\n \
153 [{SECTION_NAME}]\n token = \"cz_extbot_...\"\n",
154 path.display()
155 )));
156 };
157
158 Ok(ChipzenConfig {
159 path: Some(path.to_path_buf()),
160 token: section.token,
161 url: section.url,
162 bot_id: section.bot_id,
163 })
164}
165
166pub fn resolve_token(
172 explicit_token: Option<&str>,
173 config: Option<&ChipzenConfig>,
174) -> Option<String> {
175 if let Some(t) = explicit_token {
176 return Some(t.to_string());
177 }
178 config.and_then(|c| c.token.clone())
179}
180
181pub fn resolve_url(explicit_url: Option<&str>, config: Option<&ChipzenConfig>) -> Option<String> {
187 if let Some(u) = explicit_url {
188 return Some(u.to_string());
189 }
190 config.and_then(|c| c.url.clone())
191}
192
193#[cfg(test)]
194mod tests {
195 use super::*;
196 use std::io::Write;
197
198 fn write_toml(dir: &std::path::Path, body: &str) -> PathBuf {
199 let path = dir.join(CONFIG_FILENAME);
200 let mut f = std::fs::File::create(&path).unwrap();
201 f.write_all(body.as_bytes()).unwrap();
202 path
203 }
204
205 #[test]
206 fn loads_all_external_api_fields() {
207 let dir = tempfile::tempdir().unwrap();
208 let path = write_toml(
209 dir.path(),
210 "[external_api]\ntoken = \"cz_extbot_abc\"\nurl = \"wss://x/y\"\nbot_id = \"uuid-1\"\n",
211 );
212 let cfg = load_from_path(&path).unwrap();
213 assert_eq!(cfg.token.as_deref(), Some("cz_extbot_abc"));
214 assert_eq!(cfg.url.as_deref(), Some("wss://x/y"));
215 assert_eq!(cfg.bot_id.as_deref(), Some("uuid-1"));
216 assert_eq!(cfg.path.as_deref(), Some(path.as_path()));
217 }
218
219 #[test]
220 fn all_fields_optional() {
221 let dir = tempfile::tempdir().unwrap();
222 let path = write_toml(dir.path(), "[external_api]\n");
223 let cfg = load_from_path(&path).unwrap();
224 assert!(cfg.token.is_none() && cfg.url.is_none() && cfg.bot_id.is_none());
225 }
226
227 #[test]
228 fn missing_section_is_an_error() {
229 let dir = tempfile::tempdir().unwrap();
230 let path = write_toml(dir.path(), "[other]\nfoo = 1\n");
231 let err = load_from_path(&path).unwrap_err();
232 assert!(format!("{err}").contains("has no [external_api] section"));
233 }
234
235 #[test]
236 fn malformed_toml_is_an_error() {
237 let dir = tempfile::tempdir().unwrap();
238 let path = write_toml(dir.path(), "[external_api\ntoken = ");
239 let err = load_from_path(&path).unwrap_err();
240 assert!(format!("{err}").contains("failed to parse"));
241 }
242
243 #[test]
244 fn unknown_keys_are_tolerated_forward_compat() {
245 let dir = tempfile::tempdir().unwrap();
246 let path = write_toml(
247 dir.path(),
248 "[external_api]\ntoken = \"t\"\nfuture_field = \"ignored\"\n",
249 );
250 let cfg = load_from_path(&path).unwrap();
251 assert_eq!(cfg.token.as_deref(), Some("t"));
252 }
253
254 #[test]
255 fn discovery_returns_first_existing_in_order() {
256 let dir = tempfile::tempdir().unwrap();
257 let present = write_toml(dir.path(), "[external_api]\ntoken = \"t\"\n");
258 let missing = dir.path().join("nope").join(CONFIG_FILENAME);
259 let found = discover_config_path(Some(&[missing, present.clone()]));
261 assert_eq!(found.as_deref(), Some(present.as_path()));
262 }
263
264 #[test]
265 fn no_file_on_path_is_ok_none() {
266 let dir = tempfile::tempdir().unwrap();
267 let missing = dir.path().join("absent").join(CONFIG_FILENAME);
268 assert!(load_chipzen_config(Some(&[missing])).unwrap().is_none());
269 }
270
271 #[test]
272 fn resolve_precedence() {
273 let cfg = ChipzenConfig {
274 token: Some("cfg_tok".into()),
275 url: Some("cfg_url".into()),
276 ..Default::default()
277 };
278 assert_eq!(
280 resolve_token(Some("explicit"), Some(&cfg)).as_deref(),
281 Some("explicit")
282 );
283 assert_eq!(resolve_token(Some(""), Some(&cfg)).as_deref(), Some(""));
284 assert_eq!(resolve_token(None, Some(&cfg)).as_deref(), Some("cfg_tok"));
286 assert_eq!(resolve_token(None, None), None);
288
289 assert_eq!(
290 resolve_url(Some("ex_url"), Some(&cfg)).as_deref(),
291 Some("ex_url")
292 );
293 assert_eq!(resolve_url(None, Some(&cfg)).as_deref(), Some("cfg_url"));
294 assert_eq!(resolve_url(None, None), None);
295 }
296}