config.rs 24 KB

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