config.rs 27 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636
  1. //! Configuration loader for `wh`.
  2. //!
  3. //! All settings come from the TOML file `config.toml` inside the data directory
  4. //! (`<platform config dir>/wh`, i.e. `%APPDATA%\wh` on Windows), falling back to built-in
  5. //! defaults for missing or blank keys (except `system_prompt`, which has no built-in
  6. //! default). No environment variable is ever read.
  7. //! Supported file keys: `openai_api_key`, `openai_base_url`, `openai_model`,
  8. //! `openai_vision_model`, `system_prompt`, `ai_log` (boolean, default `false`: when `true`
  9. //! every request sent to the AI model is appended to `ai_requests.log` in the data
  10. //! directory), `cooldown_seconds` (non-negative integer, default 30: minimum seconds between
  11. //! autoreplies in a private chat or to a group message addressed to the user),
  12. //! `group_cooldown_seconds` (non-negative integer, default 300: same, for group messages not
  13. //! addressed to the user); 0 disables the respective cooldown, `catch_up_seconds`
  14. //! (non-negative integer, default 7200: after a restart, the last unanswered message of each
  15. //! private chat is answered once if it is at most this old; 0 disables the catch-up),
  16. //! `ai_retries` (non-negative integer, default 2: how many times a failed AI request is
  17. //! retried, waiting 2s, 4s, ... between attempts; 0 disables retrying; HTTP 4xx errors other
  18. //! than 429 are never retried), `web_port` (optional integer 1-65535: when set, a web control
  19. //! panel listens on `0.0.0.0:<port>` without authentication; unset disables it).
  20. //! Unknown keys are ignored. A missing file means
  21. //! all defaults; an unreadable or invalid file is an error. The data directory is fixed and
  22. //! cannot be changed from the file.
  23. //!
  24. //! The prompt defining the responder's identity lives only in `config.toml` (`system_prompt`);
  25. //! the binary embeds no prompt text. Without it the AI is not configured.
  26. use std::fmt;
  27. use std::io::ErrorKind;
  28. use std::path::{Path, PathBuf};
  29. use anyhow::{Context, Result, anyhow, bail};
  30. use serde::Deserialize;
  31. /// Default base URL of the OpenAI-compatible endpoint.
  32. const DEFAULT_BASE_URL: &str = "https://ollama.com/v1";
  33. /// Default model used for autoreplies.
  34. pub const DEFAULT_MODEL: &str = "deepseek-v4.1-flash";
  35. /// Default cooldown, in seconds, for private chats and group messages addressed to the user.
  36. pub const DEFAULT_COOLDOWN_SECONDS: i64 = 30;
  37. /// Default cooldown, in seconds, for group messages not addressed to the user.
  38. pub const DEFAULT_GROUP_COOLDOWN_SECONDS: i64 = 300;
  39. /// Default maximum age, in seconds, of a message answered by the catch-up after a restart.
  40. pub const DEFAULT_CATCH_UP_SECONDS: i64 = 7200;
  41. /// Default number of retries of a failed AI request (0 disables retrying).
  42. pub const DEFAULT_AI_RETRIES: u32 = 2;
  43. /// Name of the application sub-directory inside the platform config directory.
  44. const APP_DIR_NAME: &str = "wh";
  45. /// File name of the WhatsApp session database.
  46. const SESSION_DB_FILE: &str = "session.db";
  47. /// File name of the chat history database.
  48. const HISTORY_DB_FILE: &str = "history.db";
  49. /// File name of the optional AI request log inside the data directory.
  50. const AI_LOG_FILE: &str = "ai_requests.log";
  51. /// File name of the optional TOML configuration file inside the data directory.
  52. const CONFIG_FILE: &str = "config.toml";
  53. /// Name of the file key holding the base URL, used in validation messages.
  54. const KEY_BASE_URL: &str = "openai_base_url";
  55. /// Name of the file key holding the web control panel port, used in validation messages.
  56. const KEY_WEB_PORT: &str = "web_port";
  57. /// Byte order mark that Windows editors may prepend to UTF-8 files.
  58. const UTF8_BOM: char = '\u{feff}';
  59. /// Text shown in `Debug` output in place of a set API key.
  60. const DEBUG_KEY_MASK: &str = "***";
  61. /// Text shown in `Debug` output when no API key is set.
  62. const DEBUG_KEY_UNSET: &str = "<unset>";
  63. /// Validated application configuration, built from `config.toml` and built-in defaults.
  64. #[derive(Clone)]
  65. pub struct Config {
  66. /// API key from `openai_api_key` (file), trimmed; `None` if unset or blank.
  67. pub openai_api_key: Option<String>,
  68. /// Endpoint base URL from `openai_base_url` (file), without trailing '/'.
  69. pub openai_base_url: String,
  70. /// Model name from `openai_model` (file).
  71. pub openai_model: String,
  72. /// Vision model name from `openai_vision_model` (file), trimmed; `None` if unset or
  73. /// blank, which disables image understanding.
  74. pub openai_vision_model: Option<String>,
  75. /// System prompt from `system_prompt` (file), as written (not trimmed); `None` if unset
  76. /// or blank, meaning the AI is not configured. There is no built-in default.
  77. pub system_prompt: Option<String>,
  78. /// Whether to log every request sent to the AI model, from `ai_log` (file); default `false`.
  79. pub ai_log: bool,
  80. /// Autoreply cooldown in seconds for private chats and addressed group messages, from
  81. /// `cooldown_seconds` (file); default 30, 0 means no cooldown.
  82. pub cooldown_seconds: i64,
  83. /// Autoreply cooldown in seconds for group messages not addressed to the user, from
  84. /// `group_cooldown_seconds` (file); default 300, 0 means no cooldown.
  85. pub group_cooldown_seconds: i64,
  86. /// Maximum age in seconds of a message answered by the catch-up after a restart, from
  87. /// `catch_up_seconds` (file); default 7200, 0 disables the catch-up.
  88. pub catch_up_seconds: i64,
  89. /// Number of retries of a failed AI request, from `ai_retries` (file); default 2, 0
  90. /// disables retrying.
  91. pub ai_retries: u32,
  92. /// Port of the web control panel from `web_port` (file); `None` = web panel disabled.
  93. /// It listens on all interfaces, with no authentication.
  94. pub web_port: Option<u16>,
  95. /// Directory holding `config.toml` and the databases: `<platform config dir>/wh`.
  96. pub data_dir: PathBuf,
  97. }
  98. /// Optional settings read from `config.toml`; unknown keys are ignored.
  99. /// Deliberately has no `Debug` impl so the API key can never be formatted by accident.
  100. #[derive(Default, Deserialize)]
  101. struct FileConfig {
  102. /// API key for the OpenAI-compatible endpoint.
  103. openai_api_key: Option<String>,
  104. /// Base URL of the OpenAI-compatible endpoint.
  105. openai_base_url: Option<String>,
  106. /// Model name used for autoreplies.
  107. openai_model: Option<String>,
  108. /// Vision model name (unset or blank disables image understanding).
  109. openai_vision_model: Option<String>,
  110. /// System prompt for autoreplies.
  111. system_prompt: Option<String>,
  112. /// Enables the AI request log (unset means disabled).
  113. ai_log: Option<bool>,
  114. /// Cooldown for private chats and addressed group messages (unsigned: negatives are rejected).
  115. cooldown_seconds: Option<u32>,
  116. /// Cooldown for group messages not addressed to the user (unsigned: negatives are rejected).
  117. group_cooldown_seconds: Option<u32>,
  118. /// Catch-up window after a restart (unsigned: negatives are rejected).
  119. catch_up_seconds: Option<u32>,
  120. /// Retries of a failed AI request (unsigned: negatives are rejected).
  121. ai_retries: Option<u32>,
  122. /// Port of the optional web control panel (unsigned 16-bit: negatives and values above
  123. /// 65535 are rejected by TOML parsing).
  124. web_port: Option<u16>,
  125. }
  126. impl fmt::Debug for Config {
  127. /// Purpose: format the config for debugging without leaking the API key.
  128. /// Inputs: `f`, the target formatter.
  129. /// Output: `fmt::Result` of the formatting operation.
  130. /// Side effects: none (writes only to `f`).
  131. fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
  132. let key = if self.openai_api_key.is_some() {
  133. DEBUG_KEY_MASK
  134. } else {
  135. DEBUG_KEY_UNSET
  136. };
  137. f.debug_struct("Config")
  138. .field("openai_api_key", &key)
  139. .field("openai_base_url", &self.openai_base_url)
  140. .field("openai_model", &self.openai_model)
  141. .field("openai_vision_model", &self.openai_vision_model)
  142. .field("system_prompt", &self.system_prompt)
  143. .field("ai_log", &self.ai_log)
  144. .field("cooldown_seconds", &self.cooldown_seconds)
  145. .field("group_cooldown_seconds", &self.group_cooldown_seconds)
  146. .field("catch_up_seconds", &self.catch_up_seconds)
  147. .field("ai_retries", &self.ai_retries)
  148. .field("web_port", &self.web_port)
  149. .field("data_dir", &self.data_dir)
  150. .finish()
  151. }
  152. }
  153. /// Purpose: resolve the fixed data directory.
  154. /// Inputs: none.
  155. /// Output: the platform config dir joined with `wh`; error if the platform has no config dir.
  156. /// Side effects: queries the platform for the user config directory.
  157. fn default_data_dir() -> Result<PathBuf> {
  158. dirs::config_dir()
  159. .map(|d| d.join(APP_DIR_NAME))
  160. .context("cannot determine the platform config directory")
  161. }
  162. /// Purpose: convert a byte offset into a 1-based line and column.
  163. /// Inputs: `content`, the text; `offset`, a byte offset into it.
  164. /// Output: `(line, column)`; the column counts bytes since the last newline.
  165. /// Side effects: none.
  166. fn line_col(content: &str, offset: usize) -> (usize, usize) {
  167. let before = content.as_bytes().get(..offset).unwrap_or(content.as_bytes());
  168. let line = before.iter().filter(|&&b| b == b'\n').count() + 1;
  169. let line_start = before.iter().rposition(|&b| b == b'\n').map_or(0, |i| i + 1);
  170. (line, before.len() - line_start + 1)
  171. }
  172. /// Purpose: parse the content of a `config.toml` file.
  173. /// Inputs: `content`, the file text (a leading UTF-8 BOM is tolerated).
  174. /// Output: the parsed `FileConfig`, or an error giving only line/column, never file content.
  175. /// Side effects: none.
  176. fn parse_config_file(content: &str) -> Result<FileConfig> {
  177. let content = content.trim_start_matches(UTF8_BOM);
  178. toml::from_str(content).map_err(|e| match e.span() {
  179. Some(span) => {
  180. let (line, col) = line_col(content, span.start);
  181. anyhow!("invalid TOML syntax or value type at line {line}, column {col}")
  182. }
  183. None => anyhow!("invalid TOML syntax or value type"),
  184. })
  185. }
  186. /// Purpose: load the configuration file at `path`.
  187. /// Inputs: `path`, the location of the file.
  188. /// Output: parsed settings; defaults if the file does not exist; error naming the path if unreadable or invalid.
  189. /// Side effects: reads the file from disk.
  190. fn read_config_file(path: &Path) -> Result<FileConfig> {
  191. let content = match std::fs::read_to_string(path) {
  192. Ok(content) => content,
  193. Err(e) if e.kind() == ErrorKind::NotFound => return Ok(FileConfig::default()),
  194. Err(e) => {
  195. return Err(e).with_context(|| format!("cannot read config file {}", path.display()));
  196. }
  197. };
  198. parse_config_file(&content)
  199. .with_context(|| format!("invalid config file {}", path.display()))
  200. }
  201. /// Purpose: discard blank (empty or whitespace-only) values.
  202. /// Inputs: `value`, an optional raw file value.
  203. /// Output: `value` unchanged if it contains non-whitespace characters, else `None`.
  204. /// Side effects: none.
  205. fn non_blank(value: Option<String>) -> Option<String> {
  206. value.filter(|v| !v.trim().is_empty())
  207. }
  208. /// Purpose: discard blank values and trim the remaining ones.
  209. /// Inputs: `value`, an optional raw file value.
  210. /// Output: the trimmed value, or `None` if absent or blank.
  211. /// Side effects: none.
  212. fn trimmed_non_blank(value: Option<String>) -> Option<String> {
  213. non_blank(value).map(|v| v.trim().to_owned())
  214. }
  215. /// Purpose: validate and normalise the endpoint base URL.
  216. /// Inputs: `raw`, the optional `openai_base_url` file value.
  217. /// Output: the trimmed URL without trailing '/' (default if absent/blank); error if only slashes remain.
  218. /// Side effects: none.
  219. fn resolve_base_url(raw: Option<String>) -> Result<String> {
  220. let raw = non_blank(raw).unwrap_or_else(|| DEFAULT_BASE_URL.to_owned());
  221. let url = raw.trim().trim_end_matches('/').to_owned();
  222. if url.is_empty() {
  223. bail!("{KEY_BASE_URL} in {CONFIG_FILE} must not be empty or only slashes");
  224. }
  225. Ok(url)
  226. }
  227. /// Purpose: validate the optional web control panel port.
  228. /// Inputs: `raw`, the optional `web_port` file value (already range-checked to 0-65535 by parsing).
  229. /// Output: `raw` unchanged; error naming the key and the file if it is 0.
  230. /// Side effects: none.
  231. fn resolve_web_port(raw: Option<u16>) -> Result<Option<u16>> {
  232. if raw == Some(0) {
  233. bail!("{KEY_WEB_PORT} in {CONFIG_FILE} must be between 1 and 65535");
  234. }
  235. Ok(raw)
  236. }
  237. impl Config {
  238. /// Purpose: load the configuration from `<data_dir>/config.toml` and built-in defaults.
  239. /// Inputs: none (the data dir is `<platform config dir>/wh`).
  240. /// Output: validated `Config`, or an error if the config dir is unavailable, the file is unreadable/invalid, or validation fails.
  241. /// Side effects: reads the config file from disk; never reads environment variables.
  242. pub fn load() -> Result<Config> {
  243. let data_dir = default_data_dir()?;
  244. let file = read_config_file(&data_dir.join(CONFIG_FILE))?;
  245. Self::from_file(file, data_dir)
  246. }
  247. /// Purpose: build a validated configuration from parsed file settings.
  248. /// Inputs: `file`, the parsed settings; `data_dir`, the directory to record in the config.
  249. /// Output: validated `Config` with defaults for missing/blank keys, or an error for an invalid base URL.
  250. /// Side effects: none.
  251. fn from_file(file: FileConfig, data_dir: PathBuf) -> Result<Config> {
  252. Ok(Config {
  253. openai_api_key: trimmed_non_blank(file.openai_api_key),
  254. openai_base_url: resolve_base_url(file.openai_base_url)?,
  255. openai_model: trimmed_non_blank(file.openai_model)
  256. .unwrap_or_else(|| DEFAULT_MODEL.to_owned()),
  257. openai_vision_model: trimmed_non_blank(file.openai_vision_model),
  258. system_prompt: non_blank(file.system_prompt),
  259. ai_log: file.ai_log.unwrap_or_default(),
  260. cooldown_seconds: file.cooldown_seconds.map_or(DEFAULT_COOLDOWN_SECONDS, i64::from),
  261. group_cooldown_seconds: file
  262. .group_cooldown_seconds
  263. .map_or(DEFAULT_GROUP_COOLDOWN_SECONDS, i64::from),
  264. catch_up_seconds: file.catch_up_seconds.map_or(DEFAULT_CATCH_UP_SECONDS, i64::from),
  265. ai_retries: file.ai_retries.unwrap_or(DEFAULT_AI_RETRIES),
  266. web_port: resolve_web_port(file.web_port)?,
  267. data_dir,
  268. })
  269. }
  270. /// Purpose: make sure the data directory exists.
  271. /// Inputs: none (uses `self.data_dir`).
  272. /// Output: `Ok(())` if the directory exists after the call, otherwise an error.
  273. /// Side effects: creates `data_dir` and any missing parents on disk.
  274. pub fn ensure_data_dir(&self) -> Result<()> {
  275. std::fs::create_dir_all(&self.data_dir)
  276. .with_context(|| format!("cannot create data directory {}", self.data_dir.display()))
  277. }
  278. /// Purpose: locate the WhatsApp session database.
  279. /// Inputs: none.
  280. /// Output: `data_dir/session.db`.
  281. /// Side effects: none.
  282. pub fn session_db_path(&self) -> PathBuf {
  283. self.data_dir.join(SESSION_DB_FILE)
  284. }
  285. /// Purpose: locate the chat history database.
  286. /// Inputs: none.
  287. /// Output: `data_dir/history.db`.
  288. /// Side effects: none.
  289. pub fn history_db_path(&self) -> PathBuf {
  290. self.data_dir.join(HISTORY_DB_FILE)
  291. }
  292. /// Purpose: locate the AI request log (used only when `ai_log` is enabled).
  293. /// Inputs: none.
  294. /// Output: `data_dir/ai_requests.log`, next to `config.toml`.
  295. /// Side effects: none.
  296. pub fn ai_log_path(&self) -> PathBuf {
  297. self.data_dir.join(AI_LOG_FILE)
  298. }
  299. }
  300. #[cfg(test)]
  301. mod tests {
  302. use super::*;
  303. /// Fake data directory recorded in test configs (never touched on disk).
  304. const TEST_DATA_DIR: &str = "wh-test-data";
  305. /// Directory (relative to the package root) where file tests create scratch files.
  306. const TEST_SCRATCH_DIR: &str = "target";
  307. /// Purpose: build a `Config` from TOML text through the production parse path.
  308. /// Inputs: `toml_text`, the content of a hypothetical `config.toml`.
  309. /// Output: the resulting `Config`, or the parse/validation error.
  310. /// Side effects: none.
  311. fn config_from(toml_text: &str) -> Result<Config> {
  312. Config::from_file(parse_config_file(toml_text)?, PathBuf::from(TEST_DATA_DIR))
  313. }
  314. /// Purpose: build a `Config` from TOML text, failing the test on error.
  315. /// Inputs: `toml_text`, the content of a hypothetical `config.toml`.
  316. /// Output: the resulting `Config`.
  317. /// Side effects: none.
  318. fn ok_config(toml_text: &str) -> Config {
  319. config_from(toml_text).expect("config must load")
  320. }
  321. /// Purpose: build a unique scratch file path for a file-based test.
  322. /// Inputs: `tag`, a short test-specific suffix.
  323. /// Output: path under the scratch dir, unique per process and tag.
  324. /// Side effects: creates the scratch directory if missing.
  325. fn scratch_path(tag: &str) -> PathBuf {
  326. let dir = PathBuf::from(TEST_SCRATCH_DIR);
  327. std::fs::create_dir_all(&dir).expect("scratch dir must be creatable");
  328. dir.join(format!("wh-config-test-{}-{tag}.toml", std::process::id()))
  329. }
  330. #[test]
  331. fn defaults_on_empty_file() {
  332. let cfg = ok_config("");
  333. assert_eq!(cfg.openai_api_key, None);
  334. assert_eq!(cfg.openai_base_url, DEFAULT_BASE_URL);
  335. assert_eq!(cfg.openai_model, DEFAULT_MODEL);
  336. assert_eq!(cfg.openai_vision_model, None);
  337. assert_eq!(cfg.system_prompt, None);
  338. assert_eq!(cfg.data_dir, PathBuf::from(TEST_DATA_DIR));
  339. }
  340. #[test]
  341. fn full_file_values_are_used() {
  342. let cfg = ok_config(
  343. "openai_api_key = \"sk-file\"\nopenai_base_url = \"http://file/v1\"\n\
  344. openai_model = \"file-model\"\nopenai_vision_model = \"file-vision\"\n\
  345. system_prompt = \"file prompt\"\n",
  346. );
  347. assert_eq!(cfg.openai_api_key.as_deref(), Some("sk-file"));
  348. assert_eq!(cfg.openai_base_url, "http://file/v1");
  349. assert_eq!(cfg.openai_model, "file-model");
  350. assert_eq!(cfg.openai_vision_model.as_deref(), Some("file-vision"));
  351. assert_eq!(cfg.system_prompt.as_deref(), Some("file prompt"));
  352. }
  353. #[test]
  354. fn blank_values_fall_back_to_defaults_or_none() {
  355. let cfg = ok_config(
  356. "openai_api_key = \" \"\nopenai_base_url = \" \"\nopenai_model = \"\"\n\
  357. openai_vision_model = \" \\t \"\nsystem_prompt = \" \"\n",
  358. );
  359. assert_eq!(cfg.openai_api_key, None);
  360. assert_eq!(cfg.openai_base_url, DEFAULT_BASE_URL);
  361. assert_eq!(cfg.openai_model, DEFAULT_MODEL);
  362. assert_eq!(cfg.openai_vision_model, None);
  363. assert_eq!(cfg.system_prompt, None);
  364. }
  365. #[test]
  366. fn multiline_system_prompt_is_preserved() {
  367. const PROMPT: &str = "Line one.\n Line two, indented.\nLine three.\n";
  368. let cfg = ok_config(&format!("system_prompt = \"\"\"\n{PROMPT}\"\"\"\n"));
  369. assert_eq!(cfg.system_prompt.as_deref(), Some(PROMPT));
  370. }
  371. #[test]
  372. fn values_are_trimmed() {
  373. let cfg = ok_config(
  374. "openai_api_key = \" sk-x \"\nopenai_model = \" m \"\n\
  375. openai_vision_model = \" vision-model \\t\"\n",
  376. );
  377. assert_eq!(cfg.openai_api_key.as_deref(), Some("sk-x"));
  378. assert_eq!(cfg.openai_model, "m");
  379. assert_eq!(cfg.openai_vision_model.as_deref(), Some("vision-model"));
  380. }
  381. #[test]
  382. fn trailing_slash_is_trimmed() {
  383. let cfg = ok_config("openai_base_url = \" http://localhost:8080/v1// \"\n");
  384. assert_eq!(cfg.openai_base_url, "http://localhost:8080/v1");
  385. }
  386. #[test]
  387. fn only_slashes_base_url_is_error_naming_key() {
  388. let Err(err) = config_from("openai_base_url = \"///\"\n") else {
  389. panic!("only-slashes base URL must fail");
  390. };
  391. let msg = format!("{err:#}");
  392. assert!(msg.contains(KEY_BASE_URL), "unexpected message: {msg}");
  393. assert!(msg.contains(CONFIG_FILE), "unexpected message: {msg}");
  394. }
  395. #[test]
  396. fn debug_masks_api_key() {
  397. const SECRET: &str = "sk-super-secret-value";
  398. let cfg = ok_config(&format!("openai_api_key = \"{SECRET}\"\n"));
  399. assert_eq!(cfg.openai_api_key.as_deref(), Some(SECRET));
  400. let out = format!("{cfg:?}");
  401. assert!(!out.contains(SECRET));
  402. assert!(out.contains(DEBUG_KEY_MASK));
  403. assert!(format!("{:?}", ok_config("")).contains(DEBUG_KEY_UNSET));
  404. }
  405. #[test]
  406. fn vision_model_set_and_unset() {
  407. let set = ok_config("openai_vision_model = \" v \"\n");
  408. assert_eq!(set.openai_vision_model.as_deref(), Some("v"));
  409. let unset = ok_config("openai_model = \"m\"\n");
  410. assert_eq!(unset.openai_vision_model, None);
  411. assert!(format!("{unset:?}").contains("openai_vision_model"));
  412. }
  413. #[test]
  414. fn web_port_unset_is_none_and_set_is_kept() {
  415. assert_eq!(ok_config("").web_port, None);
  416. assert_eq!(ok_config("web_port = 8080\n").web_port, Some(8080));
  417. assert!(format!("{:?}", ok_config("web_port = 8080\n")).contains("web_port"));
  418. }
  419. #[test]
  420. fn zero_web_port_is_error_naming_key() {
  421. let Err(err) = config_from("web_port = 0\n") else {
  422. panic!("web_port = 0 must fail");
  423. };
  424. let msg = format!("{err:#}");
  425. assert!(msg.contains(KEY_WEB_PORT), "unexpected message: {msg}");
  426. assert!(msg.contains(CONFIG_FILE), "unexpected message: {msg}");
  427. }
  428. #[test]
  429. fn out_of_range_web_port_is_error() {
  430. assert!(config_from("web_port = 70000\n").is_err());
  431. assert!(config_from("web_port = -1\n").is_err());
  432. }
  433. #[test]
  434. fn db_paths_are_under_data_dir() {
  435. let cfg = ok_config("");
  436. assert_eq!(cfg.session_db_path(), PathBuf::from(TEST_DATA_DIR).join(SESSION_DB_FILE));
  437. assert_eq!(cfg.history_db_path(), PathBuf::from(TEST_DATA_DIR).join(HISTORY_DB_FILE));
  438. }
  439. #[test]
  440. fn ai_log_defaults_to_false_and_can_be_enabled() {
  441. assert!(!ok_config("").ai_log);
  442. assert!(ok_config("ai_log = true\n").ai_log);
  443. assert!(!ok_config("ai_log = false\n").ai_log);
  444. assert!(format!("{:?}", ok_config("")).contains("ai_log"));
  445. }
  446. #[test]
  447. fn cooldowns_default_when_unset() {
  448. let cfg = ok_config("");
  449. assert_eq!(cfg.cooldown_seconds, DEFAULT_COOLDOWN_SECONDS);
  450. assert_eq!(cfg.group_cooldown_seconds, DEFAULT_GROUP_COOLDOWN_SECONDS);
  451. let out = format!("{cfg:?}");
  452. assert!(out.contains("cooldown_seconds") && out.contains("group_cooldown_seconds"));
  453. }
  454. #[test]
  455. fn cooldowns_use_custom_values_including_zero() {
  456. let cfg = ok_config("cooldown_seconds = 5\ngroup_cooldown_seconds = 0\n");
  457. assert_eq!(cfg.cooldown_seconds, 5);
  458. assert_eq!(cfg.group_cooldown_seconds, 0);
  459. }
  460. #[test]
  461. fn negative_cooldown_is_rejected() {
  462. assert!(config_from("cooldown_seconds = -1\n").is_err());
  463. assert!(config_from("group_cooldown_seconds = -1\n").is_err());
  464. }
  465. #[test]
  466. fn catch_up_defaults_when_unset() {
  467. let cfg = ok_config("");
  468. assert_eq!(cfg.catch_up_seconds, DEFAULT_CATCH_UP_SECONDS);
  469. assert!(format!("{cfg:?}").contains("catch_up_seconds"));
  470. }
  471. #[test]
  472. fn catch_up_uses_custom_value_including_zero() {
  473. assert_eq!(ok_config("catch_up_seconds = 600\n").catch_up_seconds, 600);
  474. assert_eq!(ok_config("catch_up_seconds = 0\n").catch_up_seconds, 0);
  475. }
  476. #[test]
  477. fn negative_catch_up_is_rejected() {
  478. assert!(config_from("catch_up_seconds = -1\n").is_err());
  479. }
  480. #[test]
  481. fn ai_retries_defaults_when_unset() {
  482. let cfg = ok_config("");
  483. assert_eq!(cfg.ai_retries, DEFAULT_AI_RETRIES);
  484. assert!(format!("{cfg:?}").contains("ai_retries"));
  485. }
  486. #[test]
  487. fn ai_retries_uses_custom_value_including_zero() {
  488. assert_eq!(ok_config("ai_retries = 5\n").ai_retries, 5);
  489. assert_eq!(ok_config("ai_retries = 0\n").ai_retries, 0);
  490. }
  491. #[test]
  492. fn negative_ai_retries_is_rejected() {
  493. assert!(config_from("ai_retries = -1\n").is_err());
  494. }
  495. #[test]
  496. fn ai_log_path_is_under_data_dir() {
  497. let cfg = ok_config("");
  498. assert_eq!(cfg.ai_log_path(), PathBuf::from(TEST_DATA_DIR).join(AI_LOG_FILE));
  499. }
  500. #[test]
  501. fn unknown_keys_are_ignored() {
  502. let cfg = ok_config("future_option = 42\nwh_data_dir = \"/ignored\"\nopenai_model = \"m\"\n[extra]\nx = 1\n");
  503. assert_eq!(cfg.openai_model, "m");
  504. assert_eq!(cfg.data_dir, PathBuf::from(TEST_DATA_DIR));
  505. }
  506. #[test]
  507. fn bom_and_crlf_are_tolerated() {
  508. let cfg = ok_config("\u{feff}openai_model = \"m\"\r\nsystem_prompt = \"p\"\r\n");
  509. assert_eq!(cfg.openai_model, "m");
  510. assert_eq!(cfg.system_prompt.as_deref(), Some("p"));
  511. }
  512. #[test]
  513. fn invalid_toml_is_error_without_leaking_content() {
  514. const SECRET: &str = "sk-secret-in-bad-file";
  515. let bad = format!("openai_model = \"ok\"\nopenai_api_key = \"{SECRET}\nnext = 1\n");
  516. let Err(err) = parse_config_file(&bad) else {
  517. panic!("invalid TOML must fail");
  518. };
  519. let msg = format!("{err:#}");
  520. assert!(msg.contains("line 2"), "unexpected message: {msg}");
  521. assert!(!msg.contains(SECRET));
  522. }
  523. #[test]
  524. fn wrong_value_type_error_does_not_echo_value() {
  525. const SECRET: &str = "987654321";
  526. let Err(err) = parse_config_file(&format!("openai_api_key = {SECRET}\n")) else {
  527. panic!("wrong type must fail");
  528. };
  529. assert!(!format!("{err:#}").contains(SECRET));
  530. }
  531. #[test]
  532. fn missing_file_yields_defaults() {
  533. let path = scratch_path("missing");
  534. let file = read_config_file(&path).expect("missing file is not an error");
  535. let cfg = Config::from_file(file, PathBuf::from(TEST_DATA_DIR)).expect("defaults must load");
  536. assert_eq!(cfg.openai_model, DEFAULT_MODEL);
  537. assert_eq!(cfg.openai_api_key, None);
  538. }
  539. #[test]
  540. fn existing_file_is_read_from_disk() {
  541. let path = scratch_path("existing");
  542. std::fs::write(&path, "openai_model = \"disk-model\"\n").expect("temp file must be writable");
  543. let result = read_config_file(&path);
  544. std::fs::remove_file(&path).expect("temp file must be removable");
  545. let cfg = Config::from_file(result.expect("file must parse"), PathBuf::from(TEST_DATA_DIR))
  546. .expect("config must load");
  547. assert_eq!(cfg.openai_model, "disk-model");
  548. }
  549. #[test]
  550. fn invalid_file_error_names_path_without_values() {
  551. const SECRET: &str = "sk-secret-on-disk";
  552. let path = scratch_path("invalid");
  553. std::fs::write(&path, format!("openai_api_key = \"{SECRET}\nx = 1\n"))
  554. .expect("temp file must be writable");
  555. let result = read_config_file(&path);
  556. std::fs::remove_file(&path).expect("temp file must be removable");
  557. let Err(err) = result else {
  558. panic!("invalid TOML must fail");
  559. };
  560. let msg = format!("{err:#}");
  561. assert!(msg.contains(&path.display().to_string()));
  562. assert!(msg.contains("line 1"), "unexpected message: {msg}");
  563. assert!(!msg.contains(SECRET));
  564. }
  565. #[test]
  566. fn unreadable_file_error_names_path() {
  567. const INVALID_UTF8: [u8; 3] = [0xff, 0xfe, 0xfd];
  568. let path = scratch_path("utf8");
  569. std::fs::write(&path, INVALID_UTF8).expect("temp file must be writable");
  570. let result = read_config_file(&path);
  571. std::fs::remove_file(&path).expect("temp file must be removable");
  572. let Err(err) = result else {
  573. panic!("non-UTF-8 content must be an error");
  574. };
  575. assert!(format!("{err:#}").contains(&path.display().to_string()));
  576. }
  577. }