Files
doctate/server/src/config.rs
T
Brummel a510c20e75 refactor(server): plumb Arc<Settings> through workers and routes
Config sheds its 14 Bucket-B fields (retention, whisper, ollama, llm) and
gains a sibling Arc<Settings> in AppState, loaded from settings.toml at
startup via Settings::load_or_default. Workers (transcribe, analyze,
recovery, auto_trigger) and routes (health, bulk, case_actions,
user_web) take settings as a separate parameter or State<> extractor;
Config::llm_configured moves to Settings::llm_configured.

TestConfig::build_pair() returns (Arc<Config>, Arc<Settings>); the new
create_router_with_settings / create_router_and_session_store_with_settings
helpers wire both into integration tests that exercise LLM/Whisper/Ollama.
The legacy single-Arc create_router falls back to Settings::default()
so the 17 non-LLM tests stay untouched.

Verified: cargo build clean, clippy --all-targets clean, 318 tests pass.
2026-04-27 11:48:41 +02:00

434 lines
14 KiB
Rust

use std::collections::HashMap;
use std::path::PathBuf;
use serde::Deserialize;
/// Per-user Whisper transcription settings from users.toml.
/// All fields optional — missing `[user.whisper]` block yields defaults (None).
#[derive(Debug, Default, Clone, Deserialize)]
pub struct WhisperUserSettings {
pub language: Option<String>,
pub hotwords: Option<String>,
pub initial_prompt: Option<String>,
}
/// 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,
#[serde(default)]
pub whisper: WhisperUserSettings,
#[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,
}
/// Wrapper for deserializing the [[user]] array from TOML.
#[derive(Deserialize)]
struct UsersFile {
user: Vec<User>,
}
/// 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<User>,
pub api_keys: HashMap<String, String>, // 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
/// `<stem>.aff` + `<stem>.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,
}
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),
}
}
/// 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,
}
}
}
/// Load users from a TOML file and build the API key lookup map.
fn load_users(path: &str) -> (Vec<User>, HashMap<String, String>) {
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<HashMap<String, String>, String> {
let mut slugs: std::collections::HashSet<&str> = std::collections::HashSet::new();
let mut api_keys: HashMap<String, String> = 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<T: std::str::FromStr>(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<T: std::str::FromStr>(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(),
whisper: WhisperUserSettings::default(),
retention: RetentionUserSettings::default(),
window_hours: default_window_hours(),
preview_lines: default_preview_lines(),
}
}
#[test]
fn parses_user_without_whisper_block() {
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.whisper.language.is_none());
assert!(u.whisper.hotwords.is_none());
assert!(u.whisper.initial_prompt.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_full_whisper_block() {
let toml_str = r#"
[[user]]
slug = "a"
api_key = "k1"
web_password = ""
role = "doctor"
[user.whisper]
language = "de"
hotwords = "HOCM Valsalva"
initial_prompt = "Kardiologie"
"#;
let parsed: UsersFile = toml::from_str(toml_str).unwrap();
let w = &parsed.user[0].whisper;
assert_eq!(w.language.as_deref(), Some("de"));
assert_eq!(w.hotwords.as_deref(), Some("HOCM Valsalva"));
assert_eq!(w.initial_prompt.as_deref(), Some("Kardiologie"));
}
#[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 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);
}
}