From c4413777495b0d2c95381627e8ec1ba3b1b5e7db Mon Sep 17 00:00:00 2001 From: Brummel Date: Fri, 17 Apr 2026 15:35:45 +0200 Subject: [PATCH] feat(client-desktop): Add configuration loading and saving Introduces a `Config` struct to manage client settings, including server URL and API key. This configuration is stored in a TOML file following OS-standard conventions. The `directories` crate is used to determine the correct path for the configuration file across different operating systems. The `thiserror` crate is used for custom error handling related to configuration loading and saving. Added unit tests to verify the configuration loading and saving logic. --- Cargo.lock | 43 +++++++++++ client-desktop/Cargo.toml | 7 ++ client-desktop/src/config.rs | 144 +++++++++++++++++++++++++++++++++++ client-desktop/src/main.rs | 3 + 4 files changed, 197 insertions(+) create mode 100644 client-desktop/src/config.rs diff --git a/Cargo.lock b/Cargo.lock index bbddb65..847a592 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1088,6 +1088,27 @@ dependencies = [ "crypto-common", ] +[[package]] +name = "directories" +version = "5.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a49173b84e034382284f27f1af4dcbbd231ffa358c0fe316541a7337f376a35" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys 0.48.0", +] + [[package]] name = "dispatch" version = "0.2.0" @@ -1138,8 +1159,13 @@ dependencies = [ name = "doctate-desktop" version = "0.1.0" dependencies = [ + "directories", "doctate-common", "eframe", + "serde", + "tempfile", + "thiserror 1.0.69", + "toml", "tracing", "tracing-subscriber", ] @@ -2955,6 +2981,12 @@ version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + [[package]] name = "orbclient" version = "0.3.51" @@ -3378,6 +3410,17 @@ dependencies = [ "bitflags 2.11.1", ] +[[package]] +name = "redox_users" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror 1.0.69", +] + [[package]] name = "regex" version = "1.12.3" diff --git a/client-desktop/Cargo.toml b/client-desktop/Cargo.toml index c79e9ae..3ec84bf 100644 --- a/client-desktop/Cargo.toml +++ b/client-desktop/Cargo.toml @@ -7,5 +7,12 @@ description = "Desktop dictation client for the doctate medical dictation system [dependencies] doctate-common = { path = "../doctate-common" } eframe = "0.28" +serde = { workspace = true } +toml = { workspace = true } tracing = { workspace = true } tracing-subscriber = { version = "0.3", features = ["env-filter"] } +directories = "5" +thiserror = "1" + +[dev-dependencies] +tempfile = "3" diff --git a/client-desktop/src/config.rs b/client-desktop/src/config.rs new file mode 100644 index 0000000..25546d9 --- /dev/null +++ b/client-desktop/src/config.rs @@ -0,0 +1,144 @@ +use std::path::{Path, PathBuf}; + +use directories::ProjectDirs; +use serde::{Deserialize, Serialize}; +use thiserror::Error; + +const CONFIG_FILENAME: &str = "client.toml"; + +/// Client configuration loaded from a TOML file at OS-standard paths. +#[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 on upload. + pub api_key: String, +} + +#[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), +} + +/// Default config path, following OS conventions: +/// Linux: `~/.config/doctate/client.toml` +/// Windows: `%APPDATA%\doctate\client.toml` +/// macOS: `~/Library/Application Support/doctate/client.toml` +pub fn default_config_path() -> Result { + ProjectDirs::from("", "", "doctate") + .map(|dirs| dirs.config_dir().join(CONFIG_FILENAME)) + .ok_or(ConfigError::NoConfigDir) +} + +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(()) + } + + /// Convenience: load from `default_config_path()`. + pub fn load() -> Result, ConfigError> { + Self::load_from(&default_config_path()?) + } + + /// Convenience: save to `default_config_path()`. + pub fn save(&self) -> Result<(), ConfigError> { + self.save_to(&default_config_path()?) + } +} + +#[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(), + } + } + + #[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 { .. })); + } +} diff --git a/client-desktop/src/main.rs b/client-desktop/src/main.rs index 8cee693..cb1dfc3 100644 --- a/client-desktop/src/main.rs +++ b/client-desktop/src/main.rs @@ -1,3 +1,6 @@ +#[allow(dead_code)] +mod config; + use eframe::egui; use tracing::info; use tracing_subscriber::EnvFilter;