refactor: dissolve doctate-client-core into doctate-desktop
Move the 9 client-core modules (case_store, case_update, config, footer_status, pending_cleanup, server_sync, snapshot_cache, startup, upload) into client-desktop/src/. Resolve the config.rs name collision by keeping the TOML schema in config.rs (was core) and renaming the platform-path wrapper to config_path.rs. Introduce client-desktop/src/lib.rs so the modules are reachable under the doctate-desktop:: crate path -- needed for Wear-OS doc anchors that mirror Rust symbols. Bin target stays as `doctate` via [[bin]] name in Cargo.toml. This is the first of four stages preparing the server-vs-clients build-world split. Workspace still has 3 members; directory layout unchanged. All 508 workspace tests pass.
This commit is contained in:
Generated
+1
-20
@@ -1145,25 +1145,6 @@ dependencies = [
|
|||||||
"libloading 0.8.9",
|
"libloading 0.8.9",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "doctate-client-core"
|
|
||||||
version = "0.1.0"
|
|
||||||
dependencies = [
|
|
||||||
"doctate-common",
|
|
||||||
"filetime",
|
|
||||||
"reqwest",
|
|
||||||
"serde",
|
|
||||||
"serde_json",
|
|
||||||
"tempfile",
|
|
||||||
"thiserror 1.0.69",
|
|
||||||
"time",
|
|
||||||
"tokio",
|
|
||||||
"toml",
|
|
||||||
"tracing",
|
|
||||||
"uuid",
|
|
||||||
"wiremock",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "doctate-common"
|
name = "doctate-common"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
@@ -1179,9 +1160,9 @@ name = "doctate-desktop"
|
|||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"directories",
|
"directories",
|
||||||
"doctate-client-core",
|
|
||||||
"doctate-common",
|
"doctate-common",
|
||||||
"eframe",
|
"eframe",
|
||||||
|
"filetime",
|
||||||
"libc",
|
"libc",
|
||||||
"reqwest",
|
"reqwest",
|
||||||
"serde",
|
"serde",
|
||||||
|
|||||||
+1
-1
@@ -1,5 +1,5 @@
|
|||||||
[workspace]
|
[workspace]
|
||||||
members = ["server", "doctate-common", "doctate-client-core", "client-desktop"]
|
members = ["server", "doctate-common", "client-desktop"]
|
||||||
resolver = "3"
|
resolver = "3"
|
||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
|
|||||||
@@ -4,9 +4,12 @@ version.workspace = true
|
|||||||
edition.workspace = true
|
edition.workspace = true
|
||||||
description = "Desktop dictation client for the doctate medical dictation system"
|
description = "Desktop dictation client for the doctate medical dictation system"
|
||||||
|
|
||||||
|
[[bin]]
|
||||||
|
name = "doctate"
|
||||||
|
path = "src/main.rs"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
doctate-common = { path = "../doctate-common" }
|
doctate-common = { path = "../doctate-common" }
|
||||||
doctate-client-core = { path = "../doctate-client-core" }
|
|
||||||
eframe = "0.28"
|
eframe = "0.28"
|
||||||
reqwest = { workspace = true }
|
reqwest = { workspace = true }
|
||||||
serde = { workspace = true }
|
serde = { workspace = true }
|
||||||
@@ -29,3 +32,4 @@ libc = "0.2"
|
|||||||
tempfile = "3"
|
tempfile = "3"
|
||||||
tokio = { workspace = true, features = ["test-util"] }
|
tokio = { workspace = true, features = ["test-util"] }
|
||||||
wiremock = "0.6"
|
wiremock = "0.6"
|
||||||
|
filetime = "0.2"
|
||||||
|
|||||||
@@ -13,12 +13,13 @@ use std::path::PathBuf;
|
|||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
use doctate_client_core::{
|
use crate::case_store::{CaseMarker, CaseStore};
|
||||||
CaseMarker, CaseStore, DEFAULT_MARKER_RETENTION, DEFAULT_ORPHAN_AUDIO_RETENTION, FooterStatus,
|
use crate::case_update::{PutOnelinerError, put_oneliner};
|
||||||
PendingUpload, PutOnelinerError, ServerSync, SnapshotCache, SyncConfig, UploadEvent,
|
use crate::footer_status::{FooterStatus, pick_footer_status};
|
||||||
WorkerSnapshot, WorkerState, pick_footer_status, put_oneliner, run_startup_cleanup,
|
use crate::server_sync::{ServerSync, SyncConfig, WorkerSnapshot, WorkerState};
|
||||||
write_sidecar,
|
use crate::snapshot_cache::SnapshotCache;
|
||||||
};
|
use crate::startup::{DEFAULT_MARKER_RETENTION, DEFAULT_ORPHAN_AUDIO_RETENTION, run_startup_cleanup};
|
||||||
|
use crate::upload::{PendingUpload, UploadEvent, write_sidecar};
|
||||||
use doctate_common::join_url;
|
use doctate_common::join_url;
|
||||||
use doctate_common::oneliners::OnelinerState;
|
use doctate_common::oneliners::OnelinerState;
|
||||||
use doctate_common::timestamp::{extract_hhmm, now_rfc3339, recorded_at_to_filename_stem};
|
use doctate_common::timestamp::{extract_hhmm, now_rfc3339, recorded_at_to_filename_stem};
|
||||||
@@ -118,7 +119,7 @@ impl DoctateApp {
|
|||||||
cases_dir: PathBuf,
|
cases_dir: PathBuf,
|
||||||
snapshot_cache_path: PathBuf,
|
snapshot_cache_path: PathBuf,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
let config = match crate::config::load() {
|
let config = match crate::config_path::load() {
|
||||||
Ok(Some(c)) => Some(c),
|
Ok(Some(c)) => Some(c),
|
||||||
Ok(None) => None,
|
Ok(None) => None,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
@@ -207,7 +208,7 @@ impl DoctateApp {
|
|||||||
.as_ref()
|
.as_ref()
|
||||||
.and_then(|c| c.oneliner_poll_interval_seconds),
|
.and_then(|c| c.oneliner_poll_interval_seconds),
|
||||||
};
|
};
|
||||||
if let Err(e) = crate::config::save(&cfg) {
|
if let Err(e) = crate::config_path::save(&cfg) {
|
||||||
error!(error = %e, "config save failed");
|
error!(error = %e, "config save failed");
|
||||||
self.state = AppState::Error {
|
self.state = AppState::Error {
|
||||||
message: format!("Speichern: {e}"),
|
message: format!("Speichern: {e}"),
|
||||||
|
|||||||
+163
-30
@@ -1,47 +1,180 @@
|
|||||||
//! Desktop-side config helpers. The `Config` struct itself lives in
|
//! Shared client configuration: server URL, API key, polling cadence,
|
||||||
//! `doctate-client-core` — this module only provides the per-platform
|
//! oneliner window. Every native client (desktop, watch, phone) reads
|
||||||
//! default path via the `directories` crate, plus two convenience
|
//! the same schema — only the on-disk path differs per platform.
|
||||||
//! functions for load/save against that default path.
|
//!
|
||||||
|
//! 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::PathBuf;
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
use directories::ProjectDirs;
|
use serde::{Deserialize, Serialize};
|
||||||
pub use doctate_client_core::config::{Config, ConfigError};
|
use thiserror::Error;
|
||||||
|
|
||||||
const CONFIG_FILENAME: &str = "client.toml";
|
/// 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;
|
||||||
|
|
||||||
/// Default config path, following OS conventions:
|
/// Client configuration loaded from a TOML file at a platform-specific
|
||||||
/// Linux: `~/.config/doctate/client.toml`
|
/// path. All fields except the two durations are mandatory.
|
||||||
/// Windows: `%APPDATA%\doctate\client.toml`
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
/// macOS: `~/Library/Application Support/doctate/client.toml`
|
pub struct Config {
|
||||||
pub fn default_config_path() -> Result<PathBuf, ConfigError> {
|
/// Base URL of the doctate server, e.g. `https://doctate.example.org`.
|
||||||
ProjectDirs::from("", "", "doctate")
|
pub server_url: String,
|
||||||
.map(|dirs| dirs.config_dir().join(CONFIG_FILENAME))
|
/// Per-user API key used in the `X-API-Key` header.
|
||||||
.ok_or(ConfigError::NoConfigDir)
|
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<u64>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Convenience: load from `default_config_path()`.
|
#[derive(Debug, Error)]
|
||||||
pub fn load() -> Result<Option<Config>, ConfigError> {
|
pub enum ConfigError {
|
||||||
Config::load_from(&default_config_path()?)
|
#[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),
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Convenience: save to `default_config_path()`.
|
impl Config {
|
||||||
pub fn save(cfg: &Config) -> Result<(), ConfigError> {
|
/// Load from `path`. Returns `Ok(None)` if the file does not exist
|
||||||
cfg.save_to(&default_config_path()?)
|
/// (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<Option<Self>, 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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
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]
|
#[test]
|
||||||
fn default_path_ends_with_client_toml() {
|
fn roundtrip_through_file() {
|
||||||
// Sanity check on the ProjectDirs wiring — can't assert the
|
let tmp = TempDir::new().unwrap();
|
||||||
// exact path (varies per host), but the filename is deterministic.
|
let path = tmp.path().join("client.toml");
|
||||||
let path = default_config_path().expect("config path available in test env");
|
let original = sample();
|
||||||
assert_eq!(
|
original.save_to(&path).unwrap();
|
||||||
path.file_name().and_then(|s| s.to_str()),
|
let loaded = Config::load_from(&path).unwrap();
|
||||||
Some("client.toml")
|
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));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
//! Desktop-side config helpers. The `Config` struct itself lives in
|
||||||
|
//! the sibling `config` module (TOML schema, platform-agnostic). This
|
||||||
|
//! module only provides the per-platform default path via the
|
||||||
|
//! `directories` crate, plus two convenience functions for load/save
|
||||||
|
//! against that default path.
|
||||||
|
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
use directories::ProjectDirs;
|
||||||
|
pub use crate::config::{Config, ConfigError};
|
||||||
|
|
||||||
|
const CONFIG_FILENAME: &str = "client.toml";
|
||||||
|
|
||||||
|
/// 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<PathBuf, ConfigError> {
|
||||||
|
ProjectDirs::from("", "", "doctate")
|
||||||
|
.map(|dirs| dirs.config_dir().join(CONFIG_FILENAME))
|
||||||
|
.ok_or(ConfigError::NoConfigDir)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convenience: load from `default_config_path()`.
|
||||||
|
pub fn load() -> Result<Option<Config>, ConfigError> {
|
||||||
|
Config::load_from(&default_config_path()?)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convenience: save to `default_config_path()`.
|
||||||
|
pub fn save(cfg: &Config) -> Result<(), ConfigError> {
|
||||||
|
cfg.save_to(&default_config_path()?)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn default_path_ends_with_client_toml() {
|
||||||
|
// Sanity check on the ProjectDirs wiring — can't assert the
|
||||||
|
// exact path (varies per host), but the filename is deterministic.
|
||||||
|
let path = default_config_path().expect("config path available in test env");
|
||||||
|
assert_eq!(
|
||||||
|
path.file_name().and_then(|s| s.to_str()),
|
||||||
|
Some("client.toml")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
//! Desktop dictation client — Linux + Windows.
|
||||||
|
//!
|
||||||
|
//! Modules listed here are intentionally `pub` so that the Wear-OS
|
||||||
|
//! mirror (Kotlin) can reference canonical Rust paths via doc anchors
|
||||||
|
//! like `doctate-desktop::pending_cleanup::cleanup_orphan_audio`.
|
||||||
|
|
||||||
|
pub mod app;
|
||||||
|
pub mod case_store;
|
||||||
|
pub mod case_update;
|
||||||
|
pub mod config;
|
||||||
|
pub mod config_path;
|
||||||
|
pub mod footer_status;
|
||||||
|
pub mod magic_link;
|
||||||
|
pub mod paths;
|
||||||
|
pub mod pending_cleanup;
|
||||||
|
pub mod recorder;
|
||||||
|
pub mod server_sync;
|
||||||
|
pub mod snapshot_cache;
|
||||||
|
pub mod startup;
|
||||||
|
pub mod state;
|
||||||
|
pub mod upload;
|
||||||
@@ -1,10 +1,3 @@
|
|||||||
mod app;
|
|
||||||
mod config;
|
|
||||||
mod magic_link;
|
|
||||||
mod paths;
|
|
||||||
mod recorder;
|
|
||||||
mod state;
|
|
||||||
|
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use eframe::egui;
|
use eframe::egui;
|
||||||
@@ -13,8 +6,8 @@ use tracing_subscriber::EnvFilter;
|
|||||||
|
|
||||||
use std::fs::{File, OpenOptions, TryLockError};
|
use std::fs::{File, OpenOptions, TryLockError};
|
||||||
|
|
||||||
use crate::app::DoctateApp;
|
use doctate_desktop::app::DoctateApp;
|
||||||
use crate::paths::{cases_dir, lock_file_path, pending_dir, snapshot_cache_path};
|
use doctate_desktop::paths::{cases_dir, lock_file_path, pending_dir, snapshot_cache_path};
|
||||||
|
|
||||||
/// Unwrap a startup `Result` or print `context: {err}` to stderr and
|
/// Unwrap a startup `Result` or print `context: {err}` to stderr and
|
||||||
/// `exit(1)`. Used for unrecoverable bootstrap failures before the
|
/// `exit(1)`. Used for unrecoverable bootstrap failures before the
|
||||||
|
|||||||
@@ -1,23 +0,0 @@
|
|||||||
[package]
|
|
||||||
name = "doctate-client-core"
|
|
||||||
version.workspace = true
|
|
||||||
edition.workspace = true
|
|
||||||
description = "Shared client logic for the doctate medical dictation system: case store, server sync worker, config, snapshot cache"
|
|
||||||
|
|
||||||
[dependencies]
|
|
||||||
doctate-common = { path = "../doctate-common" }
|
|
||||||
tokio = { workspace = true }
|
|
||||||
reqwest = { workspace = true }
|
|
||||||
serde = { workspace = true }
|
|
||||||
serde_json = { workspace = true }
|
|
||||||
toml = { workspace = true }
|
|
||||||
uuid = { workspace = true }
|
|
||||||
time = { workspace = true }
|
|
||||||
tracing = { workspace = true }
|
|
||||||
thiserror = "1"
|
|
||||||
|
|
||||||
[dev-dependencies]
|
|
||||||
tempfile = "3"
|
|
||||||
tokio = { workspace = true, features = ["test-util"] }
|
|
||||||
wiremock = "0.6"
|
|
||||||
filetime = "0.2"
|
|
||||||
@@ -1,180 +0,0 @@
|
|||||||
//! 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<u64>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[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<Option<Self>, 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));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
//! Shared client logic for the doctate dictation system.
|
|
||||||
//!
|
|
||||||
//! This crate is filesystem- and network-aware (unlike `doctate-common`,
|
|
||||||
//! which is DTO-only), but path-agnostic: callers pass concrete paths so
|
|
||||||
//! the same code works on Linux desktop, Windows desktop, Android, iOS.
|
|
||||||
//!
|
|
||||||
//! Modules:
|
|
||||||
//! - [`case_store`]: per-case marker files, merge-with-server-snapshot logic
|
|
||||||
//! - [`snapshot_cache`]: last successful `/api/oneliners` response, cached
|
|
||||||
//! on disk so the UI has data at cold start
|
|
||||||
//! - [`server_sync`]: unified background worker — uploads pending
|
|
||||||
//! recordings and polls `/api/oneliners` from a single HTTP client
|
|
||||||
//! - [`upload`]: pending-queue IO primitives (sidecar write/scan)
|
|
||||||
//! - [`pending_cleanup`]: orphan reaper for the pending-upload directory
|
|
||||||
|
|
||||||
pub mod case_store;
|
|
||||||
pub mod case_update;
|
|
||||||
pub mod config;
|
|
||||||
pub mod footer_status;
|
|
||||||
pub mod pending_cleanup;
|
|
||||||
pub mod server_sync;
|
|
||||||
pub mod snapshot_cache;
|
|
||||||
pub mod startup;
|
|
||||||
pub mod upload;
|
|
||||||
|
|
||||||
pub use case_store::{CaseMarker, CaseStore, CaseStoreError};
|
|
||||||
pub use case_update::{PutOnelinerError, put_oneliner};
|
|
||||||
pub use config::{Config, ConfigError, DEFAULT_POLL_INTERVAL_SECONDS};
|
|
||||||
pub use footer_status::{FooterStatus, pick_footer_status};
|
|
||||||
pub use pending_cleanup::cleanup_orphan_audio;
|
|
||||||
pub use server_sync::{ServerSync, SyncConfig, WorkerSnapshot, WorkerState};
|
|
||||||
pub use snapshot_cache::{CachedSnapshot, SnapshotCache};
|
|
||||||
pub use startup::{DEFAULT_MARKER_RETENTION, DEFAULT_ORPHAN_AUDIO_RETENTION, run_startup_cleanup};
|
|
||||||
pub use upload::{
|
|
||||||
PendingUpload, UploadEvent, UploadIoError, scan_pending_dir, sidecar_path_for, write_sidecar,
|
|
||||||
};
|
|
||||||
Reference in New Issue
Block a user