//! Shared client configuration: server URL, API key, polling cadence, //! oneliner window. Every native client (desktop, watch, phone) reads //! the same schema — only the on-disk path differs per platform. //! //! Path resolution is deliberately **not** part of this module. Callers //! provide a concrete `&Path` to `load_from` / `save_to`; the //! per-platform path helper lives in each client crate. use std::path::{Path, PathBuf}; use std::time::Duration; use serde::{Deserialize, Serialize}; use thiserror::Error; /// Fallback polling cadence for `/api/oneliners` in seconds. Chosen to /// feel real-time without hammering the server. pub const DEFAULT_POLL_INTERVAL_SECONDS: u64 = 5; /// Client configuration loaded from a TOML file at a platform-specific /// path. All fields except the two durations are mandatory. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct Config { /// Base URL of the doctate server, e.g. `https://doctate.example.org`. pub server_url: String, /// Per-user API key used in the `X-API-Key` header. pub api_key: String, /// Polling cadence for `/api/oneliners`. Omit in TOML to use the /// built-in default. #[serde(default, skip_serializing_if = "Option::is_none")] pub oneliner_poll_interval_seconds: Option, } #[derive(Debug, Error)] pub enum ConfigError { #[error("cannot determine OS config directory (is $HOME set?)")] NoConfigDir, #[error("I/O error at {path}: {source}")] Io { path: PathBuf, #[source] source: std::io::Error, }, #[error("parse error at {path}: {source}")] Parse { path: PathBuf, #[source] source: toml::de::Error, }, #[error("serialize error: {0}")] Serialize(#[from] toml::ser::Error), } impl Config { /// Load from `path`. Returns `Ok(None)` if the file does not exist /// (first-run case); `Ok(Some(_))` on success; `Err` on I/O or parse /// errors — caller should surface these to the user, not swallow. pub fn load_from(path: &Path) -> Result, ConfigError> { let text = match std::fs::read_to_string(path) { Ok(t) => t, Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None), Err(e) => { return Err(ConfigError::Io { path: path.into(), source: e, }); } }; let cfg: Config = toml::from_str(&text).map_err(|e| ConfigError::Parse { path: path.into(), source: e, })?; Ok(Some(cfg)) } /// Save to `path`, creating parent directories as needed. pub fn save_to(&self, path: &Path) -> Result<(), ConfigError> { if let Some(parent) = path.parent() { std::fs::create_dir_all(parent).map_err(|e| ConfigError::Io { path: parent.into(), source: e, })?; } let text = toml::to_string_pretty(self)?; std::fs::write(path, text).map_err(|e| ConfigError::Io { path: path.into(), source: e, })?; Ok(()) } /// Polling cadence as a [`Duration`], substituting the default if /// the field was omitted. pub fn poll_interval(&self) -> Duration { Duration::from_secs( self.oneliner_poll_interval_seconds .unwrap_or(DEFAULT_POLL_INTERVAL_SECONDS), ) } } #[cfg(test)] mod tests { use super::*; use tempfile::TempDir; fn sample() -> Config { Config { server_url: "https://doctate.example.org".into(), api_key: "sk-test-12345".into(), oneliner_poll_interval_seconds: None, } } #[test] fn roundtrip_through_file() { let tmp = TempDir::new().unwrap(); let path = tmp.path().join("client.toml"); let original = sample(); original.save_to(&path).unwrap(); let loaded = Config::load_from(&path).unwrap(); assert_eq!(loaded, Some(original)); } #[test] fn load_missing_file_returns_none() { let tmp = TempDir::new().unwrap(); let path = tmp.path().join("nonexistent.toml"); assert!(Config::load_from(&path).unwrap().is_none()); } #[test] fn save_creates_parent_dirs() { let tmp = TempDir::new().unwrap(); let path = tmp.path().join("deep/nested/client.toml"); sample().save_to(&path).unwrap(); assert!(path.exists()); } #[test] fn load_invalid_toml_returns_parse_error() { let tmp = TempDir::new().unwrap(); let path = tmp.path().join("bad.toml"); std::fs::write(&path, "this is not [valid toml").unwrap(); let err = Config::load_from(&path).unwrap_err(); assert!(matches!(err, ConfigError::Parse { .. })); } #[test] fn defaults_when_fields_omitted() { let tmp = TempDir::new().unwrap(); let path = tmp.path().join("minimal.toml"); std::fs::write(&path, "server_url = \"http://x\"\napi_key = \"y\"\n").unwrap(); let cfg = Config::load_from(&path).unwrap().unwrap(); assert_eq!(cfg.poll_interval(), Duration::from_secs(5)); } #[test] fn explicit_values_override_defaults() { let cfg = Config { server_url: "http://x".into(), api_key: "y".into(), oneliner_poll_interval_seconds: Some(10), }; assert_eq!(cfg.poll_interval(), Duration::from_secs(10)); } #[test] fn roundtrip_preserves_optional_fields() { let tmp = TempDir::new().unwrap(); let path = tmp.path().join("client.toml"); let original = Config { server_url: "http://x".into(), api_key: "y".into(), oneliner_poll_interval_seconds: Some(3), }; original.save_to(&path).unwrap(); let loaded = Config::load_from(&path).unwrap().unwrap(); assert_eq!(loaded.oneliner_poll_interval_seconds, Some(3)); } }