use std::collections::HashMap; use std::path::PathBuf; use serde::Deserialize; /// Per-user retention settings from users.toml. /// /// `auto_close_days = 0` disables the auto-close sweep (a case is never /// closed for age alone). `auto_delete_days = 0` disables the auto-purge /// sweep (closed cases stay forever until manual purge) — important for /// admin / dev setups where data must survive across restarts. #[derive(Debug, Default, Clone, Deserialize)] pub struct RetentionUserSettings { #[serde(default)] pub auto_close_days: u32, #[serde(default)] pub auto_delete_days: u32, } /// Default time window (hours) of visible cases when the user entry /// omits `window_hours`. Chosen so Monday morning still shows Friday's /// unfinished work. Server-authoritative: clients never override this. fn default_window_hours() -> u32 { 72 } /// Default number of analysis-preview lines rendered under each case on /// the list view when the user entry omits `preview_lines`. Two lines /// show the first sentence of the LLM output in most cases without /// dominating the list row. fn default_preview_lines() -> u32 { 2 } /// 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, /// Optional human-readable display name shown in the web UI in /// place of the slug (e.g. "Dr. Müller"). Falls back to `slug` /// when omitted — see [`User::display_name`]. #[serde(default)] pub display_name: Option, /// ASR input language code (e.g. "de", "en"). Forwarded as the /// `language` form/query field to the active ASR backend /// (Whisper or Canary). Missing field → backend default ("de"). #[serde(default)] pub language: Option, #[serde(default)] pub retention: RetentionUserSettings, /// Time window (hours) that defines which cases are visible to this /// user's clients. Server-authoritative: the `/api/oneliners` scan /// filters recordings older than `now - window_hours` before they /// reach any client. Clients render what the server returns and /// apply no additional time filter. #[serde(default = "default_window_hours")] pub window_hours: u32, /// Number of analysis-preview lines rendered under each case row on /// the list view. The preview itself is always the first paragraph of /// `document.md`; this value only controls how many *visual* lines /// the browser will show before truncating with `…`. Applied via a /// CSS `line-clamp` custom property, so a single server value adapts /// to any viewport width. #[serde(default = "default_preview_lines")] pub preview_lines: u32, } impl User { /// Borrow the human-readable display name for this user. Returns /// `display_name` when set, otherwise falls back to `slug`. Used /// by web handlers to render the page header without the caller /// having to repeat the fallback logic. pub fn display_name(&self) -> &str { self.display_name.as_deref().unwrap_or(&self.slug) } } /// Wrapper for deserializing the [[user]] array from TOML. #[derive(Deserialize)] struct UsersFile { user: Vec, } /// Active ASR backend, picked once at server startup from the /// `ASR_BACKEND` env var. `Whisper` (default) routes the worker to the /// `whisper-asr-webservice` at `[whisper].url`; `Canary` routes it to /// the NeMo Canary container at `[canary].url`. Switching backends /// requires a server restart — both cannot run on the same GPU at /// once (12 GB VRAM budget; see docs/canary.md §2.6). #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] pub enum AsrBackend { #[default] Whisper, Canary, } impl std::str::FromStr for AsrBackend { type Err = String; fn from_str(s: &str) -> Result { match s.trim().to_ascii_lowercase().as_str() { "whisper" => Ok(Self::Whisper), "canary" => Ok(Self::Canary), other => Err(format!("expected 'whisper' or 'canary', got '{other}'")), } } } /// Bootstrap config loaded once from `.env` at startup. Runtime-tunable /// values (retention, whisper/ollama/llm endpoints, prompts) live in /// `Settings` (loaded from `settings.toml`). 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 /// Directory containing gazetteer `*.txt` files (anatomy, substances, /// medications). Loaded once at startup; a missing directory is a /// non-fatal WARN — analysis runs without proper-name correction. pub vocab_dir: PathBuf, /// Path prefix (without extension) to a hunspell dictionary pair /// `.aff` + `.dic`. Used by the gazetteer as a dict /// veto against false-positive rewrites (e.g. `Kaktus` → `Lantus`). /// Missing / unreadable files are a non-fatal WARN — the gazetteer /// falls back to dict-less behaviour. pub hunspell_dict_path: PathBuf, pub session_timeout_hours: u32, /// Whether to set the `Secure` flag on the session cookie. Default `true`. /// Set `COOKIE_SECURE=false` only for plain-HTTP local development — /// browsers refuse `Secure` cookies on non-HTTPS origins. pub cookie_secure: bool, /// Selected ASR backend (Whisper or Canary). Read from `ASR_BACKEND` /// env var at startup; default `Whisper`. Worker uses this to dispatch /// the transcription call to the matching endpoint. pub asr_backend: AsrBackend, } 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, vocab_dir: PathBuf::from(optional_env("VOCAB_DIR", "./vocab")), hunspell_dict_path: PathBuf::from(optional_env( "HUNSPELL_DICT", "/usr/share/hunspell/de_DE", )), session_timeout_hours: optional_env_parsed("SESSION_TIMEOUT_HOURS", 8), cookie_secure: optional_env_parsed("COOKIE_SECURE", true), asr_backend: optional_env("ASR_BACKEND", "whisper") .parse::() .unwrap_or_else(|e| panic!("Invalid value for ASR_BACKEND: {e}")), } } /// Sane defaults for integration tests. Not a `Default` impl on purpose: /// production code must go through `from_env()`, and `Default::default()` /// carries an implicit "safe fallback" connotation this value does not /// satisfy (empty users, `/tmp` paths). Override fields via /// struct-update syntax: /// /// ```ignore /// let cfg = Config { /// data_path: tmp_dir, /// users: vec![my_user], /// ..Config::test_default() /// }; /// ``` #[doc(hidden)] pub fn test_default() -> Self { Self { server_port: 3000, data_path: PathBuf::from("/tmp"), log_level: "info".into(), log_path: PathBuf::from("/tmp"), log_max_days: 90, users: Vec::new(), api_keys: HashMap::new(), vocab_dir: PathBuf::new(), hunspell_dict_path: PathBuf::new(), session_timeout_hours: 8, cookie_secure: false, asr_backend: AsrBackend::Whisper, } } } /// 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 malformed slugs (would /// silently fail at request time when no matching data directory exists), /// 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 let Err(why) = crate::validate::validate_slug(&u.slug) { return Err(format!("invalid slug `{}`: {why}", u.slug)); } 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(), display_name: None, language: None, retention: RetentionUserSettings::default(), window_hours: default_window_hours(), preview_lines: default_preview_lines(), } } #[test] fn parses_user_without_language() { let toml_str = r#" [[user]] slug = "a" api_key = "k1" web_password = "" role = "doctor" "#; let parsed: UsersFile = toml::from_str(toml_str).unwrap(); let u = &parsed.user[0]; assert!(u.language.is_none()); } #[test] fn parses_user_without_retention_block_defaults_to_zero() { let toml_str = r#" [[user]] slug = "a" api_key = "k1" web_password = "" role = "doctor" "#; let parsed: UsersFile = toml::from_str(toml_str).unwrap(); let r = &parsed.user[0].retention; // Both zero means "feature disabled", matching the pre-migration // status quo — no case is ever auto-closed or auto-purged. assert_eq!(r.auto_close_days, 0); assert_eq!(r.auto_delete_days, 0); } #[test] fn parses_user_with_retention_block() { let toml_str = r#" [[user]] slug = "a" api_key = "k1" web_password = "" role = "doctor" [user.retention] auto_close_days = 7 auto_delete_days = 30 "#; let parsed: UsersFile = toml::from_str(toml_str).unwrap(); let r = &parsed.user[0].retention; assert_eq!(r.auto_close_days, 7); assert_eq!(r.auto_delete_days, 30); } #[test] fn parses_user_without_preview_lines_defaults_to_2() { let toml_str = r#" [[user]] slug = "a" api_key = "k1" web_password = "" role = "doctor" "#; let parsed: UsersFile = toml::from_str(toml_str).unwrap(); assert_eq!(parsed.user[0].preview_lines, 2); } #[test] fn parses_user_with_explicit_preview_lines() { let toml_str = r#" [[user]] slug = "a" api_key = "k1" web_password = "" role = "doctor" preview_lines = 5 "#; let parsed: UsersFile = toml::from_str(toml_str).unwrap(); assert_eq!(parsed.user[0].preview_lines, 5); } #[test] fn parses_user_with_partial_retention_block() { let toml_str = r#" [[user]] slug = "a" api_key = "k1" web_password = "" role = "doctor" [user.retention] auto_close_days = 5 "#; let parsed: UsersFile = toml::from_str(toml_str).unwrap(); let r = &parsed.user[0].retention; assert_eq!(r.auto_close_days, 5); // Missing field defaults to 0 = disabled — lets admins test auto-close // in isolation without losing data to auto-purge. assert_eq!(r.auto_delete_days, 0); } #[test] fn parses_user_with_explicit_language() { let toml_str = r#" [[user]] slug = "a" api_key = "k1" web_password = "" role = "doctor" language = "de" "#; let parsed: UsersFile = toml::from_str(toml_str).unwrap(); assert_eq!(parsed.user[0].language.as_deref(), Some("de")); } #[test] fn accepts_admin_role() { let toml_str = r#" [[user]] slug = "root" api_key = "k1" web_password = "" role = "admin" "#; let parsed: UsersFile = toml::from_str(toml_str).unwrap(); assert_eq!(parsed.user[0].role, "admin"); } #[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}"); } #[test] fn rejects_malformed_slug_at_boot() { // "Brummel" with uppercase B used to slip past the loader and // only surfaced as a runtime "no data directory" oddity. Boot // must now fail loudly so the operator fixes users.toml before // requests start arriving. let users = vec![u("Brummel", "k1")]; let err = validate_and_index_users(&users).unwrap_err(); assert!(err.contains("invalid slug"), "got: {err}"); assert!(err.contains("Brummel"), "got: {err}"); } #[test] fn parses_user_without_display_name_falls_back_to_slug() { let toml_str = r#" [[user]] slug = "dr_a" api_key = "k1" web_password = "" role = "doctor" "#; let parsed: UsersFile = toml::from_str(toml_str).unwrap(); let u = &parsed.user[0]; assert!(u.display_name.is_none()); assert_eq!(u.display_name(), "dr_a"); } #[test] fn parses_user_with_display_name() { let toml_str = r#" [[user]] slug = "dr_a" api_key = "k1" web_password = "" role = "doctor" display_name = "Dr. Müller" "#; let parsed: UsersFile = toml::from_str(toml_str).unwrap(); let u = &parsed.user[0]; assert_eq!(u.display_name.as_deref(), Some("Dr. Müller")); assert_eq!(u.display_name(), "Dr. Müller"); } #[test] fn parses_user_without_window_hours_defaults_to_72() { let toml_str = r#" [[user]] slug = "a" api_key = "k1" web_password = "" role = "doctor" "#; let parsed: UsersFile = toml::from_str(toml_str).unwrap(); // Missing field falls back to the serde default so existing // users.toml files continue to work unchanged. assert_eq!(parsed.user[0].window_hours, 72); } #[test] fn parses_user_with_explicit_window_hours() { let toml_str = r#" [[user]] slug = "a" api_key = "k1" web_password = "" role = "doctor" window_hours = 168 "#; let parsed: UsersFile = toml::from_str(toml_str).unwrap(); assert_eq!(parsed.user[0].window_hours, 168); } // ----- AsrBackend ----- #[test] fn asr_backend_default_is_whisper() { assert_eq!(AsrBackend::default(), AsrBackend::Whisper); } #[test] fn asr_backend_parses_whisper() { assert_eq!( "whisper".parse::().unwrap(), AsrBackend::Whisper ); } #[test] fn asr_backend_parses_canary() { assert_eq!("canary".parse::().unwrap(), AsrBackend::Canary); } #[test] fn asr_backend_parses_case_insensitive_with_whitespace() { assert_eq!( " Whisper ".parse::().unwrap(), AsrBackend::Whisper ); assert_eq!("CANARY".parse::().unwrap(), AsrBackend::Canary); } #[test] fn asr_backend_rejects_invalid() { let err = "wav2vec".parse::().unwrap_err(); assert!(err.contains("wav2vec"), "got: {err}"); } }