From 5d308df2b57e23e77002145246a82a1547b73f98 Mon Sep 17 00:00:00 2001 From: Brummel Date: Sat, 18 Apr 2026 14:09:26 +0200 Subject: [PATCH] Add TOML dependency and new config/startup modules The TOML dependency is added to `Cargo.lock` to support configuration file parsing. New modules `config` and `startup` are introduced in `doctate-client-core`: - `config`: Handles client configuration loading and saving, including defaults for polling intervals and window hours. - `startup`: Contains routines for initial cleanup tasks, such as removing stale markers and orphan audio files based on defined retention periods. --- Cargo.lock | 1 + client-desktop/src/app.rs | 52 ++--- client-desktop/src/config.rs | 210 ++------------------ client-desktop/src/state.rs | 234 +---------------------- doctate-client-core/Cargo.toml | 3 +- doctate-client-core/src/config.rs | 199 +++++++++++++++++++ doctate-client-core/src/footer_status.rs | 215 +++++++++++++++++++++ doctate-client-core/src/lib.rs | 6 + doctate-client-core/src/startup.rs | 150 +++++++++++++++ doctate-common/src/timestamp.rs | 54 +++++- 10 files changed, 666 insertions(+), 458 deletions(-) create mode 100644 doctate-client-core/src/config.rs create mode 100644 doctate-client-core/src/footer_status.rs create mode 100644 doctate-client-core/src/startup.rs diff --git a/Cargo.lock b/Cargo.lock index d64b3c1..3d99020 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1158,6 +1158,7 @@ dependencies = [ "thiserror 1.0.69", "time", "tokio", + "toml", "tracing", "uuid", "wiremock", diff --git a/client-desktop/src/app.rs b/client-desktop/src/app.rs index e42594b..5835b40 100644 --- a/client-desktop/src/app.rs +++ b/client-desktop/src/app.rs @@ -14,10 +14,13 @@ use std::sync::Arc; use std::time::{Duration, Instant}; use doctate_client_core::{ - cleanup_orphan_audio, write_sidecar, CaseMarker, CaseStore, PendingUpload, ServerSync, - SnapshotCache, SyncConfig, UploadEvent, WorkerSnapshot, WorkerState, + pick_footer_status, run_startup_cleanup, write_sidecar, CaseMarker, CaseStore, FooterStatus, + PendingUpload, ServerSync, SnapshotCache, SyncConfig, UploadEvent, WorkerSnapshot, WorkerState, + DEFAULT_MARKER_RETENTION, DEFAULT_ORPHAN_AUDIO_RETENTION, +}; +use doctate_common::timestamp::{ + extract_hhmm, now_rfc3339, parse_rfc3339, recorded_at_to_filename_stem, }; -use doctate_common::timestamp::{now_rfc3339, recorded_at_to_filename_stem}; use eframe::egui; use tokio::runtime::Runtime; use tokio::sync::{mpsc, watch}; @@ -26,7 +29,7 @@ use uuid::Uuid; use crate::config::Config; use crate::recorder::{Recorder, RecorderEvent}; -use crate::state::{pick_footer_status, AppState, FooterStatus}; +use crate::state::AppState; pub struct DoctateApp { runtime: Arc, @@ -72,7 +75,7 @@ impl DoctateApp { cases_dir: PathBuf, snapshot_cache_path: PathBuf, ) -> Self { - let config = match Config::load() { + let config = match crate::config::load() { Ok(Some(c)) => Some(c), Ok(None) => None, Err(e) => { @@ -102,18 +105,13 @@ impl DoctateApp { let store = case_store.clone(); let pending = pending_dir.clone(); runtime.block_on(async move { - let marker_cutoff = time::OffsetDateTime::now_utc() - time::Duration::hours(72); - match store.cleanup_stale(marker_cutoff).await { - Ok(n) if n > 0 => info!(count = n, "startup cleanup: stale markers removed"), - Ok(_) => {} - Err(e) => warn!(error = %e, "startup cleanup: marker sweep failed"), - } - let audio_cutoff = - std::time::SystemTime::now() - std::time::Duration::from_secs(24 * 3600); - let n = cleanup_orphan_audio(&pending, audio_cutoff).await; - if n > 0 { - info!(count = n, "startup cleanup: orphan audio removed"); - } + run_startup_cleanup( + &store, + &pending, + DEFAULT_MARKER_RETENTION, + DEFAULT_ORPHAN_AUDIO_RETENTION, + ) + .await; }); } @@ -164,7 +162,7 @@ impl DoctateApp { .as_ref() .and_then(|c| c.oneliner_window_hours), }; - if let Err(e) = cfg.save() { + if let Err(e) = crate::config::save(&cfg) { error!(error = %e, "config save failed"); self.state = AppState::Error { message: format!("Speichern: {e}"), @@ -641,24 +639,6 @@ fn spawn_worker( (sync, events_rx, snapshot_rx) } -fn parse_rfc3339(s: &str) -> Option { - time::OffsetDateTime::parse(s, &time::format_description::well_known::Rfc3339).ok() -} - -/// Extract `HH:MM` from an RFC3339 UTC timestamp. Returns the raw string -/// truncated to the first 16 chars as a fallback on parse failure — the -/// user still gets something readable. -fn extract_hhmm(ts: &str) -> String { - match parse_rfc3339(ts) { - Some(t) => { - let local = t - .to_offset(time::UtcOffset::current_local_offset().unwrap_or(time::UtcOffset::UTC)); - format!("{:02}:{:02}", local.hour(), local.minute()) - } - None => ts.chars().take(16).collect(), - } -} - impl eframe::App for DoctateApp { fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { self.drain_recorder_events(); diff --git a/client-desktop/src/config.rs b/client-desktop/src/config.rs index 670e853..144fcf4 100644 --- a/client-desktop/src/config.rs +++ b/client-desktop/src/config.rs @@ -1,53 +1,15 @@ -use std::path::{Path, PathBuf}; -use std::time::Duration; +//! Desktop-side config helpers. The `Config` struct itself lives in +//! `doctate-client-core` — 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; -use serde::{Deserialize, Serialize}; -use thiserror::Error; +pub use doctate_client_core::config::{Config, ConfigError}; const CONFIG_FILENAME: &str = "client.toml"; -/// Fallback values used when the TOML file omits the field. -const DEFAULT_POLL_INTERVAL_SECONDS: u64 = 5; -const DEFAULT_ONELINER_WINDOW_HOURS: u32 = 72; - -/// 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, - /// Polling cadence for `/api/oneliners`. Omit in TOML to use the - /// built-in default (5 s). - #[serde(default, skip_serializing_if = "Option::is_none")] - pub oneliner_poll_interval_seconds: Option, - /// Time window handed to the server as `?hours=N`. Omit in TOML to - /// use the built-in default (72 h = 3 days). - #[serde(default, skip_serializing_if = "Option::is_none")] - pub oneliner_window_hours: Option, -} - -#[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` @@ -58,159 +20,25 @@ pub fn default_config_path() -> Result { .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)) - } +/// Convenience: load from `default_config_path()`. +pub fn load() -> Result, ConfigError> { + Config::load_from(&default_config_path()?) +} - /// 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()?) - } - - /// 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), - ) - } - - /// Window-hours parameter for `/api/oneliners`, with default. - pub fn window_hours(&self) -> u32 { - self.oneliner_window_hours - .unwrap_or(DEFAULT_ONELINER_WINDOW_HOURS) - } +/// 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::*; - 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, - oneliner_window_hours: 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)); - assert_eq!(cfg.window_hours(), 72); - } - - #[test] - fn explicit_values_override_defaults() { - let cfg = Config { - server_url: "http://x".into(), - api_key: "y".into(), - oneliner_poll_interval_seconds: Some(10), - oneliner_window_hours: Some(24), - }; - assert_eq!(cfg.poll_interval(), Duration::from_secs(10)); - assert_eq!(cfg.window_hours(), 24); - } - - #[test] - fn roundtrip_preserves_new_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), - oneliner_window_hours: Some(168), - }; - original.save_to(&path).unwrap(); - let loaded = Config::load_from(&path).unwrap().unwrap(); - assert_eq!(loaded.oneliner_poll_interval_seconds, Some(3)); - assert_eq!(loaded.oneliner_window_hours, Some(168)); + 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")); } } diff --git a/client-desktop/src/state.rs b/client-desktop/src/state.rs index ec0e087..528bd36 100644 --- a/client-desktop/src/state.rs +++ b/client-desktop/src/state.rs @@ -1,14 +1,13 @@ -//! UI-visible application state and the footer status derivation. +//! UI-visible application state. //! //! The state machine here only reflects **user intent** — "ready to //! record", "recording in progress", "config error, please fix". //! Background activity (upload queue, poll cycle, finalize flush) lives -//! in a separate observable layer (`FooterStatus`) derived via a pure -//! function so it can be table-tested without egui. +//! in a separate observable layer — see +//! `doctate_client_core::footer_status`. -use std::time::Duration; +use std::time::Instant; -use doctate_client_core::server_sync::WorkerState; use uuid::Uuid; #[derive(Debug)] @@ -21,7 +20,7 @@ pub enum AppState { Recording { case_id: Uuid, recorded_at: String, - started_at: std::time::Instant, + started_at: Instant, }, /// Terminal error; user must dismiss or fix config. Error { @@ -39,226 +38,3 @@ impl AppState { } } } - -/// Derived observable rendered in the footer. Priority order encoded -/// in `pick_footer_status` — active work beats connectivity indicators. -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum FooterStatus { - /// ffmpeg is flushing the MOOV atom. Spinner. - Finalizing, - /// Worker is uploading. Spinner. `count` = pending-dir depth. - Uploading { count: usize }, - /// Worker is polling `/api/oneliners`. Spinner. - Synchronizing, - /// Never got a successful request yet — cold start or bad config. - NeverConnected, - /// Last success older than threshold. Red text. - Offline, - /// Last success fresh. Green text. The default quiet state. - Online, -} - -/// Decide which footer status to show. Pure function — no side effects, -/// fully table-testable. Inputs chosen to match what `DoctateApp` can -/// observe cheaply per egui frame. -/// -/// Decision rules for the `Idle` branch: -/// - A failure younger than the last success (or a failure with no -/// prior success at all) wins → `Offline`. -/// - Otherwise fall back to success-age vs. threshold (classic staleness). -/// -/// `last_*_age` arguments are `Option` = "seconds since that -/// event, or `None` if it never happened". Smaller Duration = more recent. -pub fn pick_footer_status( - finalizing: bool, - worker_state: WorkerState, - queue_len: usize, - last_success_age: Option, - last_failure_age: Option, - offline_threshold: Duration, -) -> FooterStatus { - if finalizing { - return FooterStatus::Finalizing; - } - match worker_state { - WorkerState::Uploading => FooterStatus::Uploading { - count: queue_len.max(1), - }, - WorkerState::Polling => FooterStatus::Synchronizing, - WorkerState::Idle => { - let failure_more_recent = match (last_success_age, last_failure_age) { - (Some(s), Some(f)) => f < s, // smaller age = younger - (None, Some(_)) => true, // only a failure on record - _ => false, - }; - if failure_more_recent { - return FooterStatus::Offline; - } - match last_success_age { - None => FooterStatus::NeverConnected, - Some(age) if age > offline_threshold => FooterStatus::Offline, - Some(_) => FooterStatus::Online, - } - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - - const THRESHOLD: Duration = Duration::from_secs(30); - - /// Convenience: most tests don't care about the failure field. - fn status( - finalizing: bool, - worker_state: WorkerState, - queue_len: usize, - last_success_age: Option, - ) -> FooterStatus { - pick_footer_status(finalizing, worker_state, queue_len, last_success_age, None, THRESHOLD) - } - - #[test] - fn finalizing_wins_over_everything() { - let s = pick_footer_status( - true, - WorkerState::Uploading, - 5, - Some(Duration::from_millis(1)), - Some(Duration::from_millis(1)), - THRESHOLD, - ); - assert_eq!(s, FooterStatus::Finalizing); - } - - #[test] - fn uploading_when_queue_non_empty() { - let s = status( - false, - WorkerState::Uploading, - 3, - Some(Duration::from_millis(1)), - ); - assert_eq!(s, FooterStatus::Uploading { count: 3 }); - } - - #[test] - fn uploading_reports_at_least_one_even_if_scan_raced() { - let s = status( - false, - WorkerState::Uploading, - 0, - Some(Duration::from_millis(1)), - ); - assert_eq!(s, FooterStatus::Uploading { count: 1 }); - } - - #[test] - fn synchronizing_when_polling_and_no_queue() { - let s = status(false, WorkerState::Polling, 0, Some(Duration::from_millis(1))); - assert_eq!(s, FooterStatus::Synchronizing); - } - - #[test] - fn never_connected_without_last_success() { - let s = status(false, WorkerState::Idle, 0, None); - assert_eq!(s, FooterStatus::NeverConnected); - } - - #[test] - fn offline_when_last_success_is_stale() { - let s = status( - false, - WorkerState::Idle, - 0, - Some(Duration::from_secs(120)), - ); - assert_eq!(s, FooterStatus::Offline); - } - - #[test] - fn online_when_last_success_is_fresh() { - let s = status(false, WorkerState::Idle, 0, Some(Duration::from_secs(5))); - assert_eq!(s, FooterStatus::Online); - } - - #[test] - fn offline_threshold_is_inclusive_of_equal_age_as_online() { - let s = status(false, WorkerState::Idle, 0, Some(THRESHOLD)); - assert_eq!(s, FooterStatus::Online); - } - - #[test] - fn offline_while_queue_has_items_but_worker_backing_off() { - let s = status( - false, - WorkerState::Idle, - 3, - Some(Duration::from_secs(120)), - ); - assert_eq!(s, FooterStatus::Offline); - } - - #[test] - fn online_while_queue_has_items_but_worker_idle_and_recent_success() { - let s = status(false, WorkerState::Idle, 3, Some(Duration::from_secs(2))); - assert_eq!(s, FooterStatus::Online); - } - - #[test] - fn never_connected_while_queue_has_items_and_no_success_ever() { - let s = status(false, WorkerState::Idle, 3, None); - assert_eq!(s, FooterStatus::NeverConnected); - } - - // --- last_failure-driven scenarios --- - - /// Core regression this fix addresses: server was reachable (fresh - /// last_success), then went offline mid-session; a single failed - /// upload must flip the footer to Offline immediately, without - /// waiting for the success-age threshold. - #[test] - fn failure_younger_than_success_forces_offline() { - let s = pick_footer_status( - false, - WorkerState::Idle, - 1, - Some(Duration::from_secs(10)), // was online 10s ago - Some(Duration::from_secs(1)), // but failed 1s ago - THRESHOLD, - ); - assert_eq!(s, FooterStatus::Offline); - } - - /// Complement: success younger than the last failure → the server - /// recovered. Show Online. - #[test] - fn success_younger_than_failure_wins_back_online() { - let s = pick_footer_status( - false, - WorkerState::Idle, - 0, - Some(Duration::from_secs(1)), // succeeded 1s ago - Some(Duration::from_secs(10)), // previous failure 10s ago - THRESHOLD, - ); - assert_eq!(s, FooterStatus::Online); - } - - /// Only a failure ever, no success: treat as Offline, not - /// NeverConnected — we tried and got rejected, which is a stronger - /// signal than "silent cold start". - #[test] - fn only_failure_no_success_is_offline() { - let s = pick_footer_status( - false, - WorkerState::Idle, - 0, - None, - Some(Duration::from_secs(5)), - THRESHOLD, - ); - assert_eq!(s, FooterStatus::Offline); - } -} diff --git a/doctate-client-core/Cargo.toml b/doctate-client-core/Cargo.toml index 09057d8..5c20244 100644 --- a/doctate-client-core/Cargo.toml +++ b/doctate-client-core/Cargo.toml @@ -2,7 +2,7 @@ name = "doctate-client-core" version.workspace = true edition.workspace = true -description = "Shared client logic for the doctate medical dictation system: case store, oneliner poller, snapshot cache" +description = "Shared client logic for the doctate medical dictation system: case store, server sync worker, config, snapshot cache" [dependencies] doctate-common = { path = "../doctate-common" } @@ -10,6 +10,7 @@ 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 } diff --git a/doctate-client-core/src/config.rs b/doctate-client-core/src/config.rs new file mode 100644 index 0000000..5ba32e0 --- /dev/null +++ b/doctate-client-core/src/config.rs @@ -0,0 +1,199 @@ +//! 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; +/// Fallback window passed as `?hours=N` to `/api/oneliners`. 72 h so +/// Monday-morning shows Friday's unfinished work. +pub const DEFAULT_ONELINER_WINDOW_HOURS: u32 = 72; + +/// 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, + /// Time window handed to the server as `?hours=N`. Omit in TOML to + /// use the built-in default. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub oneliner_window_hours: Option, +} + +#[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, 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), + ) + } + + /// Window-hours parameter for `/api/oneliners`, with default. + pub fn window_hours(&self) -> u32 { + self.oneliner_window_hours + .unwrap_or(DEFAULT_ONELINER_WINDOW_HOURS) + } +} + +#[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, + oneliner_window_hours: 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)); + assert_eq!(cfg.window_hours(), 72); + } + + #[test] + fn explicit_values_override_defaults() { + let cfg = Config { + server_url: "http://x".into(), + api_key: "y".into(), + oneliner_poll_interval_seconds: Some(10), + oneliner_window_hours: Some(24), + }; + assert_eq!(cfg.poll_interval(), Duration::from_secs(10)); + assert_eq!(cfg.window_hours(), 24); + } + + #[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), + oneliner_window_hours: Some(168), + }; + original.save_to(&path).unwrap(); + let loaded = Config::load_from(&path).unwrap().unwrap(); + assert_eq!(loaded.oneliner_poll_interval_seconds, Some(3)); + assert_eq!(loaded.oneliner_window_hours, Some(168)); + } +} diff --git a/doctate-client-core/src/footer_status.rs b/doctate-client-core/src/footer_status.rs new file mode 100644 index 0000000..0b200ad --- /dev/null +++ b/doctate-client-core/src/footer_status.rs @@ -0,0 +1,215 @@ +//! Footer status derivation — a pure function that every client (desktop, +//! watch, phone) uses to decide which one-line indicator to show in its +//! status bar. No UI framework dependency. +//! +//! The truth-table is deliberately tied to the **worker state**, not the +//! queue depth: if the worker is sitting in `sleep(backoff)` after a +//! transient failure, the queue still has items but the worker is `Idle` +//! → we show `Offline` / `Online` based on `last_success_age` vs. +//! `last_failure_age`, not a misleading "Uploading…". + +use std::time::Duration; + +use crate::server_sync::WorkerState; + +/// Derived observable for the client's status line. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum FooterStatus { + /// ffmpeg (or platform-equivalent) is flushing the last recording. + Finalizing, + /// Worker is uploading. `count` = pending-dir depth. + Uploading { count: usize }, + /// Worker is polling `/api/oneliners`. + Synchronizing, + /// Never got a successful request yet, and never saw a failure either — + /// typical cold start with no network attempt so far. + NeverConnected, + /// Last event on record was a failure, or `last_success` is older + /// than the offline threshold. + Offline, + /// `last_success` is fresh enough, and no failure has overtaken it. + Online, +} + +/// Decide which footer status to show. Pure function — no side effects, +/// fully table-testable. +/// +/// Decision rules for the `Idle` branch: +/// - A failure younger than the last success (or a failure with no +/// prior success at all) wins → `Offline`. +/// - Otherwise fall back to success-age vs. threshold (classic staleness). +/// +/// `last_*_age` arguments are `Option` = "seconds since that +/// event, or `None` if it never happened". Smaller Duration = more recent. +pub fn pick_footer_status( + finalizing: bool, + worker_state: WorkerState, + queue_len: usize, + last_success_age: Option, + last_failure_age: Option, + offline_threshold: Duration, +) -> FooterStatus { + if finalizing { + return FooterStatus::Finalizing; + } + match worker_state { + WorkerState::Uploading => FooterStatus::Uploading { + count: queue_len.max(1), + }, + WorkerState::Polling => FooterStatus::Synchronizing, + WorkerState::Idle => { + let failure_more_recent = match (last_success_age, last_failure_age) { + (Some(s), Some(f)) => f < s, // smaller age = younger + (None, Some(_)) => true, // only a failure on record + _ => false, + }; + if failure_more_recent { + return FooterStatus::Offline; + } + match last_success_age { + None => FooterStatus::NeverConnected, + Some(age) if age > offline_threshold => FooterStatus::Offline, + Some(_) => FooterStatus::Online, + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const THRESHOLD: Duration = Duration::from_secs(30); + + /// Convenience: most tests don't care about the failure field. + fn status( + finalizing: bool, + worker_state: WorkerState, + queue_len: usize, + last_success_age: Option, + ) -> FooterStatus { + pick_footer_status(finalizing, worker_state, queue_len, last_success_age, None, THRESHOLD) + } + + #[test] + fn finalizing_wins_over_everything() { + let s = pick_footer_status( + true, + WorkerState::Uploading, + 5, + Some(Duration::from_millis(1)), + Some(Duration::from_millis(1)), + THRESHOLD, + ); + assert_eq!(s, FooterStatus::Finalizing); + } + + #[test] + fn uploading_when_queue_non_empty() { + let s = status( + false, + WorkerState::Uploading, + 3, + Some(Duration::from_millis(1)), + ); + assert_eq!(s, FooterStatus::Uploading { count: 3 }); + } + + #[test] + fn uploading_reports_at_least_one_even_if_scan_raced() { + let s = status( + false, + WorkerState::Uploading, + 0, + Some(Duration::from_millis(1)), + ); + assert_eq!(s, FooterStatus::Uploading { count: 1 }); + } + + #[test] + fn synchronizing_when_polling_and_no_queue() { + let s = status(false, WorkerState::Polling, 0, Some(Duration::from_millis(1))); + assert_eq!(s, FooterStatus::Synchronizing); + } + + #[test] + fn never_connected_without_last_success() { + let s = status(false, WorkerState::Idle, 0, None); + assert_eq!(s, FooterStatus::NeverConnected); + } + + #[test] + fn offline_when_last_success_is_stale() { + let s = status(false, WorkerState::Idle, 0, Some(Duration::from_secs(120))); + assert_eq!(s, FooterStatus::Offline); + } + + #[test] + fn online_when_last_success_is_fresh() { + let s = status(false, WorkerState::Idle, 0, Some(Duration::from_secs(5))); + assert_eq!(s, FooterStatus::Online); + } + + #[test] + fn offline_threshold_is_inclusive_of_equal_age_as_online() { + let s = status(false, WorkerState::Idle, 0, Some(THRESHOLD)); + assert_eq!(s, FooterStatus::Online); + } + + #[test] + fn offline_while_queue_has_items_but_worker_backing_off() { + let s = status(false, WorkerState::Idle, 3, Some(Duration::from_secs(120))); + assert_eq!(s, FooterStatus::Offline); + } + + #[test] + fn online_while_queue_has_items_but_worker_idle_and_recent_success() { + let s = status(false, WorkerState::Idle, 3, Some(Duration::from_secs(2))); + assert_eq!(s, FooterStatus::Online); + } + + #[test] + fn never_connected_while_queue_has_items_and_no_success_ever() { + let s = status(false, WorkerState::Idle, 3, None); + assert_eq!(s, FooterStatus::NeverConnected); + } + + #[test] + fn failure_younger_than_success_forces_offline() { + let s = pick_footer_status( + false, + WorkerState::Idle, + 1, + Some(Duration::from_secs(10)), + Some(Duration::from_secs(1)), + THRESHOLD, + ); + assert_eq!(s, FooterStatus::Offline); + } + + #[test] + fn success_younger_than_failure_wins_back_online() { + let s = pick_footer_status( + false, + WorkerState::Idle, + 0, + Some(Duration::from_secs(1)), + Some(Duration::from_secs(10)), + THRESHOLD, + ); + assert_eq!(s, FooterStatus::Online); + } + + #[test] + fn only_failure_no_success_is_offline() { + let s = pick_footer_status( + false, + WorkerState::Idle, + 0, + None, + Some(Duration::from_secs(5)), + THRESHOLD, + ); + assert_eq!(s, FooterStatus::Offline); + } +} diff --git a/doctate-client-core/src/lib.rs b/doctate-client-core/src/lib.rs index 5ec8944..220cecc 100644 --- a/doctate-client-core/src/lib.rs +++ b/doctate-client-core/src/lib.rs @@ -14,13 +14,19 @@ //! - [`pending_cleanup`]: orphan reaper for the pending-upload directory pub mod case_store; +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 config::{Config, ConfigError, DEFAULT_ONELINER_WINDOW_HOURS, DEFAULT_POLL_INTERVAL_SECONDS}; +pub use footer_status::{pick_footer_status, FooterStatus}; pub use pending_cleanup::cleanup_orphan_audio; pub use server_sync::{ServerSync, SyncConfig, WorkerSnapshot, WorkerState}; pub use snapshot_cache::{CachedSnapshot, SnapshotCache}; +pub use startup::{run_startup_cleanup, DEFAULT_MARKER_RETENTION, DEFAULT_ORPHAN_AUDIO_RETENTION}; pub use upload::{scan_pending_dir, sidecar_path_for, write_sidecar, PendingUpload, UploadEvent, UploadIoError}; diff --git a/doctate-client-core/src/startup.rs b/doctate-client-core/src/startup.rs new file mode 100644 index 0000000..af186af --- /dev/null +++ b/doctate-client-core/src/startup.rs @@ -0,0 +1,150 @@ +//! One-shot client startup routines shared across all native clients. +//! +//! Today this is just the data-minimization sweep: ephemeral devices +//! (desktop, watch, phone) are "sophisticated microphones" — the client +//! must not hoard patient-sensitive audio or metadata past the +//! dictation-day horizon. +//! +//! Two retention horizons exist: +//! - **Audio** (sensitive): 24 h by default. Orphan m4a/sidecar files +//! that never reached the server get deleted. +//! - **Markers** (non-sensitive: UUIDs + oneliners): 72 h by default. +//! Long enough to span a weekend; short enough that a stolen device +//! can't reconstruct a long case history. + +use std::path::Path; +use std::time::{Duration, SystemTime}; + +use time::OffsetDateTime; +use tracing::{info, warn}; + +use crate::case_store::CaseStore; +use crate::pending_cleanup::cleanup_orphan_audio; + +/// Default retention for case-markers on disk. Confirmed-by-server +/// markers older than this are pruned; unsynced markers are kept +/// forever (they guard pending uploads). +pub const DEFAULT_MARKER_RETENTION: Duration = Duration::from_secs(72 * 3600); + +/// Default retention for orphan audio files in the pending directory. +/// Orphan = an `.m4a` without its matching sidecar, or vice versa, +/// older than this horizon. +pub const DEFAULT_ORPHAN_AUDIO_RETENTION: Duration = Duration::from_secs(24 * 3600); + +/// Run the one-shot startup cleanup. Intended to be called once at app +/// launch, before the server-sync worker spawns. +/// +/// Logs each outcome via `tracing`; no error bubbles up because both +/// sweeps are best-effort (a failing sweep shouldn't prevent the app +/// from starting). +pub async fn run_startup_cleanup( + store: &CaseStore, + pending_dir: &Path, + marker_retention: Duration, + orphan_audio_retention: Duration, +) { + let marker_cutoff = OffsetDateTime::now_utc() + - time::Duration::seconds(marker_retention.as_secs() as i64); + match store.cleanup_stale(marker_cutoff).await { + Ok(n) if n > 0 => info!(count = n, "startup cleanup: stale markers removed"), + Ok(_) => {} + Err(e) => warn!(error = %e, "startup cleanup: marker sweep failed"), + } + + let audio_cutoff = SystemTime::now() - orphan_audio_retention; + let n = cleanup_orphan_audio(pending_dir, audio_cutoff).await; + if n > 0 { + info!(count = n, "startup cleanup: orphan audio removed"); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::upload::{write_sidecar, PendingUpload}; + use filetime::{set_file_mtime, FileTime}; + use tempfile::TempDir; + use uuid::Uuid; + + /// Integration-style test: seed a stale synced marker + an orphan + /// audio file, call the helper with tight retention horizons, watch + /// both go away. + #[tokio::test] + async fn sweeps_both_stale_markers_and_orphan_audio() { + let tmp = TempDir::new().unwrap(); + let cases_dir = tmp.path().join("cases"); + let pending_dir = tmp.path().join("pending"); + std::fs::create_dir_all(&pending_dir).unwrap(); + + // Seed a stale synced marker by opening the store, creating it, + // flipping the synced flag via merge, then reopening to re-scan. + { + let (store, _rx) = CaseStore::open(cases_dir.clone()).await.unwrap(); + let old_id = Uuid::new_v4(); + let then = OffsetDateTime::parse( + "2020-01-01T00:00:00Z", + &time::format_description::well_known::Rfc3339, + ) + .unwrap(); + store.create_local(old_id, then).await.unwrap(); + store.mark_synced(old_id).await.unwrap(); + } + + // Seed an orphan audio file, pre-dated to be older than 24h. + let orphan = pending_dir.join("orphan.m4a"); + std::fs::write(&orphan, b"dummy").unwrap(); + let two_days_ago = SystemTime::now() - Duration::from_secs(48 * 3600); + set_file_mtime(&orphan, FileTime::from_system_time(two_days_ago)).unwrap(); + + // Also seed a fresh, *paired* upload — must survive both sweeps. + let fresh_id = Uuid::new_v4(); + let fresh_stem = format!("{fresh_id}_2026-04-18T10-00-00Z"); + let fresh_m4a = pending_dir.join(format!("{fresh_stem}.m4a")); + let upload = PendingUpload { + case_id: fresh_id, + recorded_at: "2026-04-18T10:00:00Z".into(), + file: fresh_m4a.clone(), + }; + std::fs::write(&fresh_m4a, b"fresh audio").unwrap(); + write_sidecar(&upload).unwrap(); + + // Reopen store to load all on-disk markers. + let (store, _rx) = CaseStore::open(cases_dir).await.unwrap(); + + run_startup_cleanup( + &store, + &pending_dir, + Duration::from_secs(3600), // markers older than 1h: nuke + Duration::from_secs(3600), // orphans older than 1h: nuke + ) + .await; + + // Stale marker gone. + assert!( + store.list().await.is_empty(), + "old synced marker must be swept" + ); + // Orphan gone. + assert!(!orphan.exists(), "old orphan audio must be swept"); + // Fresh paired upload survived. + assert!(fresh_m4a.exists(), "fresh paired m4a must remain"); + } + + #[tokio::test] + async fn no_op_on_clean_directories() { + let tmp = TempDir::new().unwrap(); + let cases_dir = tmp.path().join("cases"); + let pending_dir = tmp.path().join("pending"); + std::fs::create_dir_all(&pending_dir).unwrap(); + let (store, _rx) = CaseStore::open(cases_dir).await.unwrap(); + + // Should simply return without error and without logs at >= warn. + run_startup_cleanup( + &store, + &pending_dir, + DEFAULT_MARKER_RETENTION, + DEFAULT_ORPHAN_AUDIO_RETENTION, + ) + .await; + } +} diff --git a/doctate-common/src/timestamp.rs b/doctate-common/src/timestamp.rs index 1f5b385..b1e33ef 100644 --- a/doctate-common/src/timestamp.rs +++ b/doctate-common/src/timestamp.rs @@ -1,5 +1,5 @@ -use time::OffsetDateTime; use time::format_description::well_known::Rfc3339; +use time::{OffsetDateTime, UtcOffset}; /// Current UTC time as an RFC3339 string with second precision, e.g. /// `2026-04-15T10:32:00Z`. Subsecond fractions are deliberately stripped @@ -36,6 +36,27 @@ pub fn filename_stem_to_recorded_at(stem: &str) -> String { } } +/// Parse an RFC3339 timestamp. Returns `None` on any error so callers +/// can fall back to a lenient display path without having to classify +/// failures. +pub fn parse_rfc3339(s: &str) -> Option { + OffsetDateTime::parse(s, &Rfc3339).ok() +} + +/// Extract `HH:MM` in the local timezone from an RFC3339 UTC timestamp. +/// On parse failure, returns the raw string truncated to its first 16 +/// characters — the user still sees something readable even if the +/// value is shaped unexpectedly. +pub fn extract_hhmm(ts: &str) -> String { + match parse_rfc3339(ts) { + Some(t) => { + let local = t.to_offset(UtcOffset::current_local_offset().unwrap_or(UtcOffset::UTC)); + format!("{:02}:{:02}", local.hour(), local.minute()) + } + None => ts.chars().take(16).collect(), + } +} + #[cfg(test)] mod tests { use super::*; @@ -88,4 +109,35 @@ mod tests { ); assert!(now.ends_with('Z'), "expected UTC 'Z' suffix, got {now}"); } + + #[test] + fn parse_rfc3339_accepts_utc_z() { + let t = parse_rfc3339("2026-04-18T10:32:00Z").expect("parse UTC"); + assert_eq!(t.hour(), 10); + assert_eq!(t.minute(), 32); + } + + #[test] + fn parse_rfc3339_returns_none_on_garbage() { + assert!(parse_rfc3339("not a timestamp").is_none()); + } + + /// `extract_hhmm` depends on the host timezone for formatting, so we + /// can't assert an exact value — but we can assert the shape and + /// that the fallback kicks in on unparseable input. + #[test] + fn extract_hhmm_shape_is_two_colon_two() { + let s = extract_hhmm("2026-04-18T10:32:00Z"); + assert_eq!(s.len(), 5, "expected HH:MM, got {s}"); + assert!(s.chars().nth(2) == Some(':'), "expected colon at index 2: {s}"); + } + + #[test] + fn extract_hhmm_falls_back_to_truncated_raw_on_parse_failure() { + assert_eq!(extract_hhmm("not-a-timestamp"), "not-a-timestamp"); + assert_eq!( + extract_hhmm("way-longer-than-sixteen-chars"), + "way-longer-than-" + ); + } }