//! Configuration loader for `wh`. //! //! All settings come from the TOML file `config.toml` inside the data directory //! (`/wh`, i.e. `%APPDATA%\wh` on Windows), falling back to built-in //! defaults for missing or blank keys (except `system_prompt`, which has no built-in //! default). No environment variable is ever read. //! Supported file keys: `openai_api_key`, `openai_base_url`, `openai_model`, //! `openai_vision_model`, `system_prompt`, `ai_log` (boolean, default `false`: when `true` //! every request sent to the AI model is appended to `ai_requests.log` in the data //! directory), `cooldown_seconds` (non-negative integer, default 30: minimum seconds between //! autoreplies in a private chat or to a group message addressed to the user), //! `group_cooldown_seconds` (non-negative integer, default 300: same, for group messages not //! addressed to the user); 0 disables the respective cooldown, `catch_up_seconds` //! (non-negative integer, default 7200: after a restart, the last unanswered message of each //! private chat is answered once if it is at most this old; 0 disables the catch-up), //! `ai_retries` (non-negative integer, default 2: how many times a failed AI request is //! retried, waiting 2s, 4s, ... between attempts; 0 disables retrying; HTTP 4xx errors other //! than 429 are never retried), `web_port` (optional integer 1-65535: when set, a web control //! panel listens on `0.0.0.0:` without authentication; unset disables it). //! Unknown keys are ignored. A missing file means //! all defaults; an unreadable or invalid file is an error. The data directory is fixed and //! cannot be changed from the file. //! //! The prompt defining the responder's identity lives only in `config.toml` (`system_prompt`); //! the binary embeds no prompt text. Without it the AI is not configured. use std::fmt; use std::io::ErrorKind; use std::path::{Path, PathBuf}; use anyhow::{Context, Result, anyhow, bail}; use serde::Deserialize; /// Default base URL of the OpenAI-compatible endpoint. const DEFAULT_BASE_URL: &str = "https://ollama.com/v1"; /// Default model used for autoreplies. pub const DEFAULT_MODEL: &str = "deepseek-v4.1-flash"; /// Default cooldown, in seconds, for private chats and group messages addressed to the user. pub const DEFAULT_COOLDOWN_SECONDS: i64 = 30; /// Default cooldown, in seconds, for group messages not addressed to the user. pub const DEFAULT_GROUP_COOLDOWN_SECONDS: i64 = 300; /// Default maximum age, in seconds, of a message answered by the catch-up after a restart. pub const DEFAULT_CATCH_UP_SECONDS: i64 = 7200; /// Default number of retries of a failed AI request (0 disables retrying). pub const DEFAULT_AI_RETRIES: u32 = 2; /// Name of the application sub-directory inside the platform config directory. const APP_DIR_NAME: &str = "wh"; /// File name of the WhatsApp session database. const SESSION_DB_FILE: &str = "session.db"; /// File name of the chat history database. const HISTORY_DB_FILE: &str = "history.db"; /// File name of the optional AI request log inside the data directory. const AI_LOG_FILE: &str = "ai_requests.log"; /// File name of the optional TOML configuration file inside the data directory. const CONFIG_FILE: &str = "config.toml"; /// Name of the file key holding the base URL, used in validation messages. const KEY_BASE_URL: &str = "openai_base_url"; /// Name of the file key holding the web control panel port, used in validation messages. const KEY_WEB_PORT: &str = "web_port"; /// Byte order mark that Windows editors may prepend to UTF-8 files. const UTF8_BOM: char = '\u{feff}'; /// Text shown in `Debug` output in place of a set API key. const DEBUG_KEY_MASK: &str = "***"; /// Text shown in `Debug` output when no API key is set. const DEBUG_KEY_UNSET: &str = ""; /// Validated application configuration, built from `config.toml` and built-in defaults. #[derive(Clone)] pub struct Config { /// API key from `openai_api_key` (file), trimmed; `None` if unset or blank. pub openai_api_key: Option, /// Endpoint base URL from `openai_base_url` (file), without trailing '/'. pub openai_base_url: String, /// Model name from `openai_model` (file). pub openai_model: String, /// Vision model name from `openai_vision_model` (file), trimmed; `None` if unset or /// blank, which disables image understanding. pub openai_vision_model: Option, /// System prompt from `system_prompt` (file), as written (not trimmed); `None` if unset /// or blank, meaning the AI is not configured. There is no built-in default. pub system_prompt: Option, /// Whether to log every request sent to the AI model, from `ai_log` (file); default `false`. pub ai_log: bool, /// Autoreply cooldown in seconds for private chats and addressed group messages, from /// `cooldown_seconds` (file); default 30, 0 means no cooldown. pub cooldown_seconds: i64, /// Autoreply cooldown in seconds for group messages not addressed to the user, from /// `group_cooldown_seconds` (file); default 300, 0 means no cooldown. pub group_cooldown_seconds: i64, /// Maximum age in seconds of a message answered by the catch-up after a restart, from /// `catch_up_seconds` (file); default 7200, 0 disables the catch-up. pub catch_up_seconds: i64, /// Number of retries of a failed AI request, from `ai_retries` (file); default 2, 0 /// disables retrying. pub ai_retries: u32, /// Port of the web control panel from `web_port` (file); `None` = web panel disabled. /// It listens on all interfaces, with no authentication. pub web_port: Option, /// Directory holding `config.toml` and the databases: `/wh`. pub data_dir: PathBuf, } /// Optional settings read from `config.toml`; unknown keys are ignored. /// Deliberately has no `Debug` impl so the API key can never be formatted by accident. #[derive(Default, Deserialize)] struct FileConfig { /// API key for the OpenAI-compatible endpoint. openai_api_key: Option, /// Base URL of the OpenAI-compatible endpoint. openai_base_url: Option, /// Model name used for autoreplies. openai_model: Option, /// Vision model name (unset or blank disables image understanding). openai_vision_model: Option, /// System prompt for autoreplies. system_prompt: Option, /// Enables the AI request log (unset means disabled). ai_log: Option, /// Cooldown for private chats and addressed group messages (unsigned: negatives are rejected). cooldown_seconds: Option, /// Cooldown for group messages not addressed to the user (unsigned: negatives are rejected). group_cooldown_seconds: Option, /// Catch-up window after a restart (unsigned: negatives are rejected). catch_up_seconds: Option, /// Retries of a failed AI request (unsigned: negatives are rejected). ai_retries: Option, /// Port of the optional web control panel (unsigned 16-bit: negatives and values above /// 65535 are rejected by TOML parsing). web_port: Option, } impl fmt::Debug for Config { /// Purpose: format the config for debugging without leaking the API key. /// Inputs: `f`, the target formatter. /// Output: `fmt::Result` of the formatting operation. /// Side effects: none (writes only to `f`). fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { let key = if self.openai_api_key.is_some() { DEBUG_KEY_MASK } else { DEBUG_KEY_UNSET }; f.debug_struct("Config") .field("openai_api_key", &key) .field("openai_base_url", &self.openai_base_url) .field("openai_model", &self.openai_model) .field("openai_vision_model", &self.openai_vision_model) .field("system_prompt", &self.system_prompt) .field("ai_log", &self.ai_log) .field("cooldown_seconds", &self.cooldown_seconds) .field("group_cooldown_seconds", &self.group_cooldown_seconds) .field("catch_up_seconds", &self.catch_up_seconds) .field("ai_retries", &self.ai_retries) .field("web_port", &self.web_port) .field("data_dir", &self.data_dir) .finish() } } /// Purpose: resolve the fixed data directory. /// Inputs: none. /// Output: the platform config dir joined with `wh`; error if the platform has no config dir. /// Side effects: queries the platform for the user config directory. fn default_data_dir() -> Result { dirs::config_dir() .map(|d| d.join(APP_DIR_NAME)) .context("cannot determine the platform config directory") } /// Purpose: convert a byte offset into a 1-based line and column. /// Inputs: `content`, the text; `offset`, a byte offset into it. /// Output: `(line, column)`; the column counts bytes since the last newline. /// Side effects: none. fn line_col(content: &str, offset: usize) -> (usize, usize) { let before = content.as_bytes().get(..offset).unwrap_or(content.as_bytes()); let line = before.iter().filter(|&&b| b == b'\n').count() + 1; let line_start = before.iter().rposition(|&b| b == b'\n').map_or(0, |i| i + 1); (line, before.len() - line_start + 1) } /// Purpose: parse the content of a `config.toml` file. /// Inputs: `content`, the file text (a leading UTF-8 BOM is tolerated). /// Output: the parsed `FileConfig`, or an error giving only line/column, never file content. /// Side effects: none. fn parse_config_file(content: &str) -> Result { let content = content.trim_start_matches(UTF8_BOM); toml::from_str(content).map_err(|e| match e.span() { Some(span) => { let (line, col) = line_col(content, span.start); anyhow!("invalid TOML syntax or value type at line {line}, column {col}") } None => anyhow!("invalid TOML syntax or value type"), }) } /// Purpose: load the configuration file at `path`. /// Inputs: `path`, the location of the file. /// Output: parsed settings; defaults if the file does not exist; error naming the path if unreadable or invalid. /// Side effects: reads the file from disk. fn read_config_file(path: &Path) -> Result { let content = match std::fs::read_to_string(path) { Ok(content) => content, Err(e) if e.kind() == ErrorKind::NotFound => return Ok(FileConfig::default()), Err(e) => { return Err(e).with_context(|| format!("cannot read config file {}", path.display())); } }; parse_config_file(&content) .with_context(|| format!("invalid config file {}", path.display())) } /// Purpose: discard blank (empty or whitespace-only) values. /// Inputs: `value`, an optional raw file value. /// Output: `value` unchanged if it contains non-whitespace characters, else `None`. /// Side effects: none. fn non_blank(value: Option) -> Option { value.filter(|v| !v.trim().is_empty()) } /// Purpose: discard blank values and trim the remaining ones. /// Inputs: `value`, an optional raw file value. /// Output: the trimmed value, or `None` if absent or blank. /// Side effects: none. fn trimmed_non_blank(value: Option) -> Option { non_blank(value).map(|v| v.trim().to_owned()) } /// Purpose: validate and normalise the endpoint base URL. /// Inputs: `raw`, the optional `openai_base_url` file value. /// Output: the trimmed URL without trailing '/' (default if absent/blank); error if only slashes remain. /// Side effects: none. fn resolve_base_url(raw: Option) -> Result { let raw = non_blank(raw).unwrap_or_else(|| DEFAULT_BASE_URL.to_owned()); let url = raw.trim().trim_end_matches('/').to_owned(); if url.is_empty() { bail!("{KEY_BASE_URL} in {CONFIG_FILE} must not be empty or only slashes"); } Ok(url) } /// Purpose: validate the optional web control panel port. /// Inputs: `raw`, the optional `web_port` file value (already range-checked to 0-65535 by parsing). /// Output: `raw` unchanged; error naming the key and the file if it is 0. /// Side effects: none. fn resolve_web_port(raw: Option) -> Result> { if raw == Some(0) { bail!("{KEY_WEB_PORT} in {CONFIG_FILE} must be between 1 and 65535"); } Ok(raw) } impl Config { /// Purpose: load the configuration from `/config.toml` and built-in defaults. /// Inputs: none (the data dir is `/wh`). /// Output: validated `Config`, or an error if the config dir is unavailable, the file is unreadable/invalid, or validation fails. /// Side effects: reads the config file from disk; never reads environment variables. pub fn load() -> Result { let data_dir = default_data_dir()?; let file = read_config_file(&data_dir.join(CONFIG_FILE))?; Self::from_file(file, data_dir) } /// Purpose: build a validated configuration from parsed file settings. /// Inputs: `file`, the parsed settings; `data_dir`, the directory to record in the config. /// Output: validated `Config` with defaults for missing/blank keys, or an error for an invalid base URL. /// Side effects: none. fn from_file(file: FileConfig, data_dir: PathBuf) -> Result { Ok(Config { openai_api_key: trimmed_non_blank(file.openai_api_key), openai_base_url: resolve_base_url(file.openai_base_url)?, openai_model: trimmed_non_blank(file.openai_model) .unwrap_or_else(|| DEFAULT_MODEL.to_owned()), openai_vision_model: trimmed_non_blank(file.openai_vision_model), system_prompt: non_blank(file.system_prompt), ai_log: file.ai_log.unwrap_or_default(), cooldown_seconds: file.cooldown_seconds.map_or(DEFAULT_COOLDOWN_SECONDS, i64::from), group_cooldown_seconds: file .group_cooldown_seconds .map_or(DEFAULT_GROUP_COOLDOWN_SECONDS, i64::from), catch_up_seconds: file.catch_up_seconds.map_or(DEFAULT_CATCH_UP_SECONDS, i64::from), ai_retries: file.ai_retries.unwrap_or(DEFAULT_AI_RETRIES), web_port: resolve_web_port(file.web_port)?, data_dir, }) } /// Purpose: make sure the data directory exists. /// Inputs: none (uses `self.data_dir`). /// Output: `Ok(())` if the directory exists after the call, otherwise an error. /// Side effects: creates `data_dir` and any missing parents on disk. pub fn ensure_data_dir(&self) -> Result<()> { std::fs::create_dir_all(&self.data_dir) .with_context(|| format!("cannot create data directory {}", self.data_dir.display())) } /// Purpose: locate the WhatsApp session database. /// Inputs: none. /// Output: `data_dir/session.db`. /// Side effects: none. pub fn session_db_path(&self) -> PathBuf { self.data_dir.join(SESSION_DB_FILE) } /// Purpose: locate the chat history database. /// Inputs: none. /// Output: `data_dir/history.db`. /// Side effects: none. pub fn history_db_path(&self) -> PathBuf { self.data_dir.join(HISTORY_DB_FILE) } /// Purpose: locate the AI request log (used only when `ai_log` is enabled). /// Inputs: none. /// Output: `data_dir/ai_requests.log`, next to `config.toml`. /// Side effects: none. pub fn ai_log_path(&self) -> PathBuf { self.data_dir.join(AI_LOG_FILE) } } #[cfg(test)] mod tests { use super::*; /// Fake data directory recorded in test configs (never touched on disk). const TEST_DATA_DIR: &str = "wh-test-data"; /// Directory (relative to the package root) where file tests create scratch files. const TEST_SCRATCH_DIR: &str = "target"; /// Purpose: build a `Config` from TOML text through the production parse path. /// Inputs: `toml_text`, the content of a hypothetical `config.toml`. /// Output: the resulting `Config`, or the parse/validation error. /// Side effects: none. fn config_from(toml_text: &str) -> Result { Config::from_file(parse_config_file(toml_text)?, PathBuf::from(TEST_DATA_DIR)) } /// Purpose: build a `Config` from TOML text, failing the test on error. /// Inputs: `toml_text`, the content of a hypothetical `config.toml`. /// Output: the resulting `Config`. /// Side effects: none. fn ok_config(toml_text: &str) -> Config { config_from(toml_text).expect("config must load") } /// Purpose: build a unique scratch file path for a file-based test. /// Inputs: `tag`, a short test-specific suffix. /// Output: path under the scratch dir, unique per process and tag. /// Side effects: creates the scratch directory if missing. fn scratch_path(tag: &str) -> PathBuf { let dir = PathBuf::from(TEST_SCRATCH_DIR); std::fs::create_dir_all(&dir).expect("scratch dir must be creatable"); dir.join(format!("wh-config-test-{}-{tag}.toml", std::process::id())) } #[test] fn defaults_on_empty_file() { let cfg = ok_config(""); assert_eq!(cfg.openai_api_key, None); assert_eq!(cfg.openai_base_url, DEFAULT_BASE_URL); assert_eq!(cfg.openai_model, DEFAULT_MODEL); assert_eq!(cfg.openai_vision_model, None); assert_eq!(cfg.system_prompt, None); assert_eq!(cfg.data_dir, PathBuf::from(TEST_DATA_DIR)); } #[test] fn full_file_values_are_used() { let cfg = ok_config( "openai_api_key = \"sk-file\"\nopenai_base_url = \"http://file/v1\"\n\ openai_model = \"file-model\"\nopenai_vision_model = \"file-vision\"\n\ system_prompt = \"file prompt\"\n", ); assert_eq!(cfg.openai_api_key.as_deref(), Some("sk-file")); assert_eq!(cfg.openai_base_url, "http://file/v1"); assert_eq!(cfg.openai_model, "file-model"); assert_eq!(cfg.openai_vision_model.as_deref(), Some("file-vision")); assert_eq!(cfg.system_prompt.as_deref(), Some("file prompt")); } #[test] fn blank_values_fall_back_to_defaults_or_none() { let cfg = ok_config( "openai_api_key = \" \"\nopenai_base_url = \" \"\nopenai_model = \"\"\n\ openai_vision_model = \" \\t \"\nsystem_prompt = \" \"\n", ); assert_eq!(cfg.openai_api_key, None); assert_eq!(cfg.openai_base_url, DEFAULT_BASE_URL); assert_eq!(cfg.openai_model, DEFAULT_MODEL); assert_eq!(cfg.openai_vision_model, None); assert_eq!(cfg.system_prompt, None); } #[test] fn multiline_system_prompt_is_preserved() { const PROMPT: &str = "Line one.\n Line two, indented.\nLine three.\n"; let cfg = ok_config(&format!("system_prompt = \"\"\"\n{PROMPT}\"\"\"\n")); assert_eq!(cfg.system_prompt.as_deref(), Some(PROMPT)); } #[test] fn values_are_trimmed() { let cfg = ok_config( "openai_api_key = \" sk-x \"\nopenai_model = \" m \"\n\ openai_vision_model = \" vision-model \\t\"\n", ); assert_eq!(cfg.openai_api_key.as_deref(), Some("sk-x")); assert_eq!(cfg.openai_model, "m"); assert_eq!(cfg.openai_vision_model.as_deref(), Some("vision-model")); } #[test] fn trailing_slash_is_trimmed() { let cfg = ok_config("openai_base_url = \" http://localhost:8080/v1// \"\n"); assert_eq!(cfg.openai_base_url, "http://localhost:8080/v1"); } #[test] fn only_slashes_base_url_is_error_naming_key() { let Err(err) = config_from("openai_base_url = \"///\"\n") else { panic!("only-slashes base URL must fail"); }; let msg = format!("{err:#}"); assert!(msg.contains(KEY_BASE_URL), "unexpected message: {msg}"); assert!(msg.contains(CONFIG_FILE), "unexpected message: {msg}"); } #[test] fn debug_masks_api_key() { const SECRET: &str = "sk-super-secret-value"; let cfg = ok_config(&format!("openai_api_key = \"{SECRET}\"\n")); assert_eq!(cfg.openai_api_key.as_deref(), Some(SECRET)); let out = format!("{cfg:?}"); assert!(!out.contains(SECRET)); assert!(out.contains(DEBUG_KEY_MASK)); assert!(format!("{:?}", ok_config("")).contains(DEBUG_KEY_UNSET)); } #[test] fn vision_model_set_and_unset() { let set = ok_config("openai_vision_model = \" v \"\n"); assert_eq!(set.openai_vision_model.as_deref(), Some("v")); let unset = ok_config("openai_model = \"m\"\n"); assert_eq!(unset.openai_vision_model, None); assert!(format!("{unset:?}").contains("openai_vision_model")); } #[test] fn web_port_unset_is_none_and_set_is_kept() { assert_eq!(ok_config("").web_port, None); assert_eq!(ok_config("web_port = 8080\n").web_port, Some(8080)); assert!(format!("{:?}", ok_config("web_port = 8080\n")).contains("web_port")); } #[test] fn zero_web_port_is_error_naming_key() { let Err(err) = config_from("web_port = 0\n") else { panic!("web_port = 0 must fail"); }; let msg = format!("{err:#}"); assert!(msg.contains(KEY_WEB_PORT), "unexpected message: {msg}"); assert!(msg.contains(CONFIG_FILE), "unexpected message: {msg}"); } #[test] fn out_of_range_web_port_is_error() { assert!(config_from("web_port = 70000\n").is_err()); assert!(config_from("web_port = -1\n").is_err()); } #[test] fn db_paths_are_under_data_dir() { let cfg = ok_config(""); assert_eq!(cfg.session_db_path(), PathBuf::from(TEST_DATA_DIR).join(SESSION_DB_FILE)); assert_eq!(cfg.history_db_path(), PathBuf::from(TEST_DATA_DIR).join(HISTORY_DB_FILE)); } #[test] fn ai_log_defaults_to_false_and_can_be_enabled() { assert!(!ok_config("").ai_log); assert!(ok_config("ai_log = true\n").ai_log); assert!(!ok_config("ai_log = false\n").ai_log); assert!(format!("{:?}", ok_config("")).contains("ai_log")); } #[test] fn cooldowns_default_when_unset() { let cfg = ok_config(""); assert_eq!(cfg.cooldown_seconds, DEFAULT_COOLDOWN_SECONDS); assert_eq!(cfg.group_cooldown_seconds, DEFAULT_GROUP_COOLDOWN_SECONDS); let out = format!("{cfg:?}"); assert!(out.contains("cooldown_seconds") && out.contains("group_cooldown_seconds")); } #[test] fn cooldowns_use_custom_values_including_zero() { let cfg = ok_config("cooldown_seconds = 5\ngroup_cooldown_seconds = 0\n"); assert_eq!(cfg.cooldown_seconds, 5); assert_eq!(cfg.group_cooldown_seconds, 0); } #[test] fn negative_cooldown_is_rejected() { assert!(config_from("cooldown_seconds = -1\n").is_err()); assert!(config_from("group_cooldown_seconds = -1\n").is_err()); } #[test] fn catch_up_defaults_when_unset() { let cfg = ok_config(""); assert_eq!(cfg.catch_up_seconds, DEFAULT_CATCH_UP_SECONDS); assert!(format!("{cfg:?}").contains("catch_up_seconds")); } #[test] fn catch_up_uses_custom_value_including_zero() { assert_eq!(ok_config("catch_up_seconds = 600\n").catch_up_seconds, 600); assert_eq!(ok_config("catch_up_seconds = 0\n").catch_up_seconds, 0); } #[test] fn negative_catch_up_is_rejected() { assert!(config_from("catch_up_seconds = -1\n").is_err()); } #[test] fn ai_retries_defaults_when_unset() { let cfg = ok_config(""); assert_eq!(cfg.ai_retries, DEFAULT_AI_RETRIES); assert!(format!("{cfg:?}").contains("ai_retries")); } #[test] fn ai_retries_uses_custom_value_including_zero() { assert_eq!(ok_config("ai_retries = 5\n").ai_retries, 5); assert_eq!(ok_config("ai_retries = 0\n").ai_retries, 0); } #[test] fn negative_ai_retries_is_rejected() { assert!(config_from("ai_retries = -1\n").is_err()); } #[test] fn ai_log_path_is_under_data_dir() { let cfg = ok_config(""); assert_eq!(cfg.ai_log_path(), PathBuf::from(TEST_DATA_DIR).join(AI_LOG_FILE)); } #[test] fn unknown_keys_are_ignored() { let cfg = ok_config("future_option = 42\nwh_data_dir = \"/ignored\"\nopenai_model = \"m\"\n[extra]\nx = 1\n"); assert_eq!(cfg.openai_model, "m"); assert_eq!(cfg.data_dir, PathBuf::from(TEST_DATA_DIR)); } #[test] fn bom_and_crlf_are_tolerated() { let cfg = ok_config("\u{feff}openai_model = \"m\"\r\nsystem_prompt = \"p\"\r\n"); assert_eq!(cfg.openai_model, "m"); assert_eq!(cfg.system_prompt.as_deref(), Some("p")); } #[test] fn invalid_toml_is_error_without_leaking_content() { const SECRET: &str = "sk-secret-in-bad-file"; let bad = format!("openai_model = \"ok\"\nopenai_api_key = \"{SECRET}\nnext = 1\n"); let Err(err) = parse_config_file(&bad) else { panic!("invalid TOML must fail"); }; let msg = format!("{err:#}"); assert!(msg.contains("line 2"), "unexpected message: {msg}"); assert!(!msg.contains(SECRET)); } #[test] fn wrong_value_type_error_does_not_echo_value() { const SECRET: &str = "987654321"; let Err(err) = parse_config_file(&format!("openai_api_key = {SECRET}\n")) else { panic!("wrong type must fail"); }; assert!(!format!("{err:#}").contains(SECRET)); } #[test] fn missing_file_yields_defaults() { let path = scratch_path("missing"); let file = read_config_file(&path).expect("missing file is not an error"); let cfg = Config::from_file(file, PathBuf::from(TEST_DATA_DIR)).expect("defaults must load"); assert_eq!(cfg.openai_model, DEFAULT_MODEL); assert_eq!(cfg.openai_api_key, None); } #[test] fn existing_file_is_read_from_disk() { let path = scratch_path("existing"); std::fs::write(&path, "openai_model = \"disk-model\"\n").expect("temp file must be writable"); let result = read_config_file(&path); std::fs::remove_file(&path).expect("temp file must be removable"); let cfg = Config::from_file(result.expect("file must parse"), PathBuf::from(TEST_DATA_DIR)) .expect("config must load"); assert_eq!(cfg.openai_model, "disk-model"); } #[test] fn invalid_file_error_names_path_without_values() { const SECRET: &str = "sk-secret-on-disk"; let path = scratch_path("invalid"); std::fs::write(&path, format!("openai_api_key = \"{SECRET}\nx = 1\n")) .expect("temp file must be writable"); let result = read_config_file(&path); std::fs::remove_file(&path).expect("temp file must be removable"); let Err(err) = result else { panic!("invalid TOML must fail"); }; let msg = format!("{err:#}"); assert!(msg.contains(&path.display().to_string())); assert!(msg.contains("line 1"), "unexpected message: {msg}"); assert!(!msg.contains(SECRET)); } #[test] fn unreadable_file_error_names_path() { const INVALID_UTF8: [u8; 3] = [0xff, 0xfe, 0xfd]; let path = scratch_path("utf8"); std::fs::write(&path, INVALID_UTF8).expect("temp file must be writable"); let result = read_config_file(&path); std::fs::remove_file(&path).expect("temp file must be removable"); let Err(err) = result else { panic!("non-UTF-8 content must be an error"); }; assert!(format!("{err:#}").contains(&path.display().to_string())); } }