use std::collections::HashMap; use std::path::PathBuf; use serde::Deserialize; /// A single user entry from users.toml. #[derive(Debug, Deserialize)] pub struct User { pub slug: String, pub api_key: String, pub web_password: String, pub role: String, } /// Wrapper for deserializing the [[user]] array from TOML. #[derive(Deserialize)] struct UsersFile { user: Vec, } pub struct Config { // Phase 1 — required pub server_port: u16, pub data_path: PathBuf, pub log_level: String, pub log_path: PathBuf, pub log_max_days: u32, pub users: Vec, pub api_keys: HashMap, // api_key_value → slug // Phase 2+ — optional with defaults pub retention_audio_days: u32, pub retention_transcript_days: u32, pub retention_document_days: u32, pub whisper_url: String, pub whisper_timeout_seconds: u64, pub ollama_url: String, pub ollama_model: String, pub ollama_keep_alive: u32, pub llm_url: String, pub llm_api_key: String, pub llm_model: String, pub llm_temperature: f32, pub session_timeout_hours: u32, } impl Config { pub fn from_env() -> Self { let users_file = required_env("USERS_FILE"); let (users, api_keys) = load_users(&users_file); Self { server_port: required_env_parsed("SERVER_PORT"), data_path: PathBuf::from(required_env("DATA_PATH")), log_level: optional_env("LOG_LEVEL", "info"), log_path: PathBuf::from(optional_env("LOG_PATH", "/var/log/recorder")), log_max_days: optional_env_parsed("LOG_MAX_DAYS", 90), users, api_keys, retention_audio_days: optional_env_parsed("RETENTION_AUDIO_DAYS", 30), retention_transcript_days: optional_env_parsed("RETENTION_TRANSCRIPT_DAYS", 30), retention_document_days: optional_env_parsed("RETENTION_DOCUMENT_DAYS", 0), whisper_url: optional_env("WHISPER_URL", "http://localhost:10300"), whisper_timeout_seconds: optional_env_parsed("WHISPER_TIMEOUT_SECONDS", 120), ollama_url: optional_env("OLLAMA_URL", "http://localhost:11434"), ollama_model: optional_env("OLLAMA_MODEL", "gemma3:4b"), ollama_keep_alive: optional_env_parsed("OLLAMA_KEEP_ALIVE", 0), llm_url: optional_env("LLM_URL", ""), llm_api_key: optional_env("LLM_API_KEY", ""), llm_model: optional_env("LLM_MODEL", ""), llm_temperature: optional_env_parsed("LLM_TEMPERATURE", 0.0), session_timeout_hours: optional_env_parsed("SESSION_TIMEOUT_HOURS", 8), } } } /// Load users from a TOML file and build the API key lookup map. fn load_users(path: &str) -> (Vec, HashMap) { let content = std::fs::read_to_string(path) .unwrap_or_else(|e| panic!("Failed to read users file {path}: {e}")); let parsed: UsersFile = toml::from_str(&content) .unwrap_or_else(|e| panic!("Failed to parse users file {path}: {e}")); if parsed.user.is_empty() { panic!("No users defined in {path}. At least one is required."); } let api_keys = validate_and_index_users(&parsed.user) .unwrap_or_else(|e| panic!("Invalid users file {path}: {e}")); (parsed.user, api_keys) } /// Build the api_key → slug lookup map. Rejects duplicate slugs (collide on /// data directories) and duplicate api_keys (ambiguous authentication). pub fn validate_and_index_users(users: &[User]) -> Result, String> { let mut slugs: std::collections::HashSet<&str> = std::collections::HashSet::new(); let mut api_keys: HashMap = HashMap::new(); for u in users { if !slugs.insert(u.slug.as_str()) { return Err(format!("duplicate slug: {}", u.slug)); } if let Some(existing) = api_keys.insert(u.api_key.clone(), u.slug.clone()) { return Err(format!( "duplicate api_key shared by users {existing} and {}", u.slug )); } } Ok(api_keys) } /// Read a required environment variable. Panics with a clear message if missing. fn required_env(name: &str) -> String { std::env::var(name) .unwrap_or_else(|_| panic!("Missing required environment variable: {name}")) } /// Read a required environment variable and parse it. Panics if missing or unparseable. fn required_env_parsed(name: &str) -> T where T::Err: std::fmt::Display, { let raw = required_env(name); raw.parse() .unwrap_or_else(|e| panic!("Invalid value for {name}: {e}")) } /// Read an optional environment variable, falling back to a default. fn optional_env(name: &str, default: &str) -> String { std::env::var(name).unwrap_or_else(|_| default.to_owned()) } /// Read an optional environment variable and parse it, falling back to a default. fn optional_env_parsed(name: &str, default: T) -> T where T::Err: std::fmt::Display, { match std::env::var(name) { Ok(raw) => raw .parse() .unwrap_or_else(|e| panic!("Invalid value for {name}: {e}")), Err(_) => default, } } #[cfg(test)] mod tests { use super::*; fn u(slug: &str, api_key: &str) -> User { User { slug: slug.into(), api_key: api_key.into(), web_password: String::new(), role: "doctor".into(), } } #[test] fn accepts_distinct_users() { let users = vec![u("a", "k1"), u("b", "k2")]; let map = validate_and_index_users(&users).unwrap(); assert_eq!(map.len(), 2); assert_eq!(map.get("k1").unwrap(), "a"); } #[test] fn rejects_duplicate_api_keys() { let users = vec![u("a", "same"), u("b", "same")]; let err = validate_and_index_users(&users).unwrap_err(); assert!(err.contains("duplicate api_key"), "got: {err}"); } #[test] fn rejects_duplicate_slugs() { let users = vec![u("same", "k1"), u("same", "k2")]; let err = validate_and_index_users(&users).unwrap_err(); assert!(err.contains("duplicate slug"), "got: {err}"); } }