| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560 |
- //! Configuration loader for `wh`.
- //!
- //! All settings come from the TOML file `config.toml` inside the data directory
- //! (`<platform config dir>/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). 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;
- /// 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";
- /// 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 = "<unset>";
- /// 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<String>,
- /// 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<String>,
- /// 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<String>,
- /// 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,
- /// Directory holding `config.toml` and the databases: `<platform config dir>/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<String>,
- /// Base URL of the OpenAI-compatible endpoint.
- openai_base_url: Option<String>,
- /// Model name used for autoreplies.
- openai_model: Option<String>,
- /// Vision model name (unset or blank disables image understanding).
- openai_vision_model: Option<String>,
- /// System prompt for autoreplies.
- system_prompt: Option<String>,
- /// Enables the AI request log (unset means disabled).
- ai_log: Option<bool>,
- /// Cooldown for private chats and addressed group messages (unsigned: negatives are rejected).
- cooldown_seconds: Option<u32>,
- /// Cooldown for group messages not addressed to the user (unsigned: negatives are rejected).
- group_cooldown_seconds: Option<u32>,
- /// Catch-up window after a restart (unsigned: negatives are rejected).
- catch_up_seconds: Option<u32>,
- }
- 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("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<PathBuf> {
- 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<FileConfig> {
- 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<FileConfig> {
- 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<String>) -> Option<String> {
- 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<String>) -> Option<String> {
- 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<String>) -> Result<String> {
- 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)
- }
- impl Config {
- /// Purpose: load the configuration from `<data_dir>/config.toml` and built-in defaults.
- /// Inputs: none (the data dir is `<platform config dir>/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<Config> {
- 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<Config> {
- 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),
- 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> {
- 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 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_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()));
- }
- }
|