Skip to main content

chipzen_bot/
config.rs

1//! `chipzen.toml` discovery and parsing for the SDK.
2//!
3//! Devs running an external-API bot should be able to drop their
4//! long-lived API token into a config file once and forget about it,
5//! instead of hard-coding `token = "cz_extbot_..."` into source. This
6//! module implements the discovery + parsing half of that convention;
7//! the [`crate::run_external_bot`] entry point (and the
8//! `chipzen-sdk run-external` CLI) consume the result and prefer explicit
9//! arguments over config-file values.
10//!
11//! # Discovery
12//!
13//! Search order, first match wins:
14//!
15//! 1. `./chipzen.toml` (current working directory)
16//! 2. `~/.chipzen/chipzen.toml` (user-home config)
17//! 3. `/etc/chipzen/chipzen.toml` (system config, POSIX only — silently
18//!    skipped on Windows where `/etc` does not exist)
19//!
20//! If no file is found, [`load_chipzen_config`] returns `Ok(None)` and the
21//! caller falls back to whatever explicit arguments were passed. A clear
22//! error is only raised when a file IS found but is malformed or missing
23//! the expected section.
24//!
25//! # File format
26//!
27//! ```toml
28//! [external_api]
29//! token  = "cz_extbot_<32-char-base62-random>"
30//! url    = "wss://chipzen.ai/ws/external/bot/<bot_id>"  # optional
31//! bot_id = "<bot-uuid>"                                 # optional
32//! ```
33//!
34//! All three fields are optional. `url` (when set) overrides the env-aware
35//! lobby URL helper. `bot_id` is the external-API bot UUID; it's consumed
36//! by the `run-external` CLI to build the env-derived URL when no explicit
37//! `url` is configured.
38
39use crate::error::Error;
40use serde::Deserialize;
41use std::path::{Path, PathBuf};
42
43/// The config-file name searched for on the discovery path.
44pub const CONFIG_FILENAME: &str = "chipzen.toml";
45/// The TOML table the SDK reads its settings from.
46pub const SECTION_NAME: &str = "external_api";
47
48/// Parsed contents of a `chipzen.toml` file.
49#[derive(Debug, Clone, PartialEq, Eq, Default)]
50pub struct ChipzenConfig {
51    /// Filesystem path the config was loaded from. `None` for configs
52    /// constructed in-memory (e.g. in tests). Useful for error messages.
53    pub path: Option<PathBuf>,
54    /// Value of `[external_api].token` if present.
55    pub token: Option<String>,
56    /// Value of `[external_api].url` if present.
57    pub url: Option<String>,
58    /// Value of `[external_api].bot_id` if present.
59    pub bot_id: Option<String>,
60}
61
62/// Serde shape of the `[external_api]` table. `deny_unknown_fields` is
63/// deliberately NOT set — forward-compat: a newer file may carry keys this
64/// SDK version doesn't know about, and that shouldn't be a hard error.
65#[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
77/// Return the ordered list of candidate config-file locations.
78fn 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    // POSIX-only system path; `/etc` is not meaningful on Windows.
87    if cfg!(not(windows)) {
88        paths.push(PathBuf::from("/etc/chipzen").join(CONFIG_FILENAME));
89    }
90    paths
91}
92
93/// Best-effort home-directory lookup without pulling in the `dirs` crate.
94/// Honors `$HOME` (POSIX) then `%USERPROFILE%` (Windows).
95fn 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
105/// Return the first existing `chipzen.toml` on the search path, or `None`.
106///
107/// Pass `Some(paths)` to override the default search order (mostly useful
108/// for tests).
109pub 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
121/// Discover and parse a `chipzen.toml` from the search path.
122///
123/// Pass `Some(paths)` to override the default search order. Returns
124/// `Ok(None)` if no file exists (NOT an error — the SDK falls back to
125/// explicit arguments). Returns [`Error::Protocol`] if a file is found but
126/// is malformed or lacks the `[external_api]` section — a "found but
127/// unusable" file is always a hard error so a typo surfaces immediately
128/// rather than being silently masked.
129pub 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
136/// Parse a specific `chipzen.toml`. Public for the CLI / tests that already
137/// know the path.
138pub 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
166/// Return the token to use, honoring precedence:
167///
168/// 1. An explicit `token` (even an empty string — the dev was explicit).
169/// 2. Else, the config-file token.
170/// 3. Else, `None` (the caller decides whether that's a hard error).
171pub 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
181/// Return the URL override to use, honoring precedence:
182///
183/// 1. An explicit `url`.
184/// 2. Else, the config-file url.
185/// 3. Else, `None` (the caller falls back to its own default).
186pub 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        // First entry missing, second present → second is picked.
260        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        // Explicit wins, even an empty string.
279        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        // Else config.
285        assert_eq!(resolve_token(None, Some(&cfg)).as_deref(), Some("cfg_tok"));
286        // Else None.
287        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}