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:
@@ -4,9 +4,12 @@ version.workspace = true
|
||||
edition.workspace = true
|
||||
description = "Desktop dictation client for the doctate medical dictation system"
|
||||
|
||||
[[bin]]
|
||||
name = "doctate"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
doctate-common = { path = "../doctate-common" }
|
||||
doctate-client-core = { path = "../doctate-client-core" }
|
||||
eframe = "0.28"
|
||||
reqwest = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
@@ -29,3 +32,4 @@ libc = "0.2"
|
||||
tempfile = "3"
|
||||
tokio = { workspace = true, features = ["test-util"] }
|
||||
wiremock = "0.6"
|
||||
filetime = "0.2"
|
||||
|
||||
@@ -13,12 +13,13 @@ use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use doctate_client_core::{
|
||||
CaseMarker, CaseStore, DEFAULT_MARKER_RETENTION, DEFAULT_ORPHAN_AUDIO_RETENTION, FooterStatus,
|
||||
PendingUpload, PutOnelinerError, ServerSync, SnapshotCache, SyncConfig, UploadEvent,
|
||||
WorkerSnapshot, WorkerState, pick_footer_status, put_oneliner, run_startup_cleanup,
|
||||
write_sidecar,
|
||||
};
|
||||
use crate::case_store::{CaseMarker, CaseStore};
|
||||
use crate::case_update::{PutOnelinerError, put_oneliner};
|
||||
use crate::footer_status::{FooterStatus, pick_footer_status};
|
||||
use crate::server_sync::{ServerSync, SyncConfig, WorkerSnapshot, WorkerState};
|
||||
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::oneliners::OnelinerState;
|
||||
use doctate_common::timestamp::{extract_hhmm, now_rfc3339, recorded_at_to_filename_stem};
|
||||
@@ -118,7 +119,7 @@ impl DoctateApp {
|
||||
cases_dir: PathBuf,
|
||||
snapshot_cache_path: PathBuf,
|
||||
) -> Self {
|
||||
let config = match crate::config::load() {
|
||||
let config = match crate::config_path::load() {
|
||||
Ok(Some(c)) => Some(c),
|
||||
Ok(None) => None,
|
||||
Err(e) => {
|
||||
@@ -207,7 +208,7 @@ impl DoctateApp {
|
||||
.as_ref()
|
||||
.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");
|
||||
self.state = AppState::Error {
|
||||
message: format!("Speichern: {e}"),
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,220 @@
|
||||
//! HTTP client for `PUT /api/cases/{case_id}/oneliner` — send the
|
||||
//! doctor's manual oneliner override to the server.
|
||||
//!
|
||||
//! Returns the persisted [`OnelinerState`] on success (always
|
||||
//! `Manual { text, set_at }` if the server obeyed the contract).
|
||||
//! Errors are split into terminal (operator must fix — wrong API key,
|
||||
//! unknown case, validation refused) and transient (network blip /
|
||||
//! 5xx, retry sensible) so the caller can surface the right message.
|
||||
|
||||
use doctate_common::API_KEY_HEADER;
|
||||
use doctate_common::join_url;
|
||||
use doctate_common::oneliners::{
|
||||
ONELINER_OVERRIDE_PATH_TEMPLATE, OnelinerOverrideRequest, OnelinerState,
|
||||
};
|
||||
use reqwest::StatusCode;
|
||||
use thiserror::Error;
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum PutOnelinerError {
|
||||
/// Server rejected the request for a reason the user must address:
|
||||
/// 401 (bad API key), 403 (no permission), 404 (unknown case),
|
||||
/// 400/422 (validation failure). Surfacing the body lets the UI
|
||||
/// show the server-side message.
|
||||
#[error("HTTP {status}: {body}")]
|
||||
Terminal { status: u16, body: String },
|
||||
/// Network error or 408/429/5xx — the caller may retry after a
|
||||
/// backoff. The string is the most useful diagnostic available.
|
||||
#[error("transient: {0}")]
|
||||
Transient(String),
|
||||
/// Server replied 200 but the body did not parse as
|
||||
/// [`OnelinerState`]. Treated as terminal because there is no
|
||||
/// obvious retry strategy: a body-shape mismatch is not a hiccup.
|
||||
#[error("parse response: {0}")]
|
||||
Parse(String),
|
||||
}
|
||||
|
||||
/// Fire a `PUT /api/cases/{case_id}/oneliner` request and return the
|
||||
/// persisted state on success. The server sets `set_at` on the
|
||||
/// returned variant — clients should not pre-populate it.
|
||||
pub async fn put_oneliner(
|
||||
http: &reqwest::Client,
|
||||
server_url: &str,
|
||||
api_key: &str,
|
||||
case_id: Uuid,
|
||||
text: &str,
|
||||
) -> Result<OnelinerState, PutOnelinerError> {
|
||||
let path = ONELINER_OVERRIDE_PATH_TEMPLATE.replace("{case_id}", &case_id.to_string());
|
||||
let url = join_url(server_url, &path);
|
||||
|
||||
let body = OnelinerOverrideRequest {
|
||||
text: text.to_owned(),
|
||||
};
|
||||
|
||||
let resp = match http
|
||||
.put(&url)
|
||||
.header(API_KEY_HEADER, api_key)
|
||||
.json(&body)
|
||||
.send()
|
||||
.await
|
||||
{
|
||||
Ok(r) => r,
|
||||
Err(e) => return Err(PutOnelinerError::Transient(format!("network: {e}"))),
|
||||
};
|
||||
|
||||
let status = resp.status();
|
||||
if status == StatusCode::OK {
|
||||
match resp.json::<OnelinerState>().await {
|
||||
Ok(s) => Ok(s),
|
||||
Err(e) => Err(PutOnelinerError::Parse(format!("{e}"))),
|
||||
}
|
||||
} else if status.as_u16() == 408 || status.as_u16() == 429 || status.is_server_error() {
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
Err(PutOnelinerError::Transient(format!(
|
||||
"HTTP {status}: {body}"
|
||||
)))
|
||||
} else {
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
Err(PutOnelinerError::Terminal {
|
||||
status: status.as_u16(),
|
||||
body,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
use wiremock::matchers::{header, method, path_regex};
|
||||
use wiremock::{Mock, MockServer, ResponseTemplate};
|
||||
|
||||
const TEST_API_KEY: &str = "test-key";
|
||||
|
||||
async fn mock_server_with(status: u16, body: serde_json::Value) -> MockServer {
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("PUT"))
|
||||
.and(path_regex(r"^/api/cases/[0-9a-fA-F-]+/oneliner$"))
|
||||
.and(header(API_KEY_HEADER, TEST_API_KEY))
|
||||
.respond_with(ResponseTemplate::new(status).set_body_json(body))
|
||||
.mount(&server)
|
||||
.await;
|
||||
server
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn put_oneliner_happy_path_returns_manual_state() {
|
||||
let server = mock_server_with(
|
||||
200,
|
||||
json!({
|
||||
"kind": "manual",
|
||||
"text": "55 J., Knieschmerz",
|
||||
"set_at": "2026-04-26T12:00:00Z"
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
|
||||
let http = reqwest::Client::new();
|
||||
let case_id = Uuid::new_v4();
|
||||
let result = put_oneliner(
|
||||
&http,
|
||||
&server.uri(),
|
||||
TEST_API_KEY,
|
||||
case_id,
|
||||
"55 J., Knieschmerz",
|
||||
)
|
||||
.await
|
||||
.expect("ok");
|
||||
match result {
|
||||
OnelinerState::Manual { text, set_at } => {
|
||||
assert_eq!(text, "55 J., Knieschmerz");
|
||||
assert_eq!(set_at, "2026-04-26T12:00:00Z");
|
||||
}
|
||||
other => panic!("expected Manual, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn put_oneliner_401_is_terminal() {
|
||||
let server = mock_server_with(401, json!({"error": "Unauthorized"})).await;
|
||||
let http = reqwest::Client::new();
|
||||
|
||||
let err = put_oneliner(&http, &server.uri(), TEST_API_KEY, Uuid::new_v4(), "x")
|
||||
.await
|
||||
.expect_err("must err");
|
||||
match err {
|
||||
PutOnelinerError::Terminal { status, .. } => assert_eq!(status, 401),
|
||||
other => panic!("expected Terminal, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn put_oneliner_400_is_terminal() {
|
||||
let server = mock_server_with(400, json!({"error": "empty text"})).await;
|
||||
let http = reqwest::Client::new();
|
||||
|
||||
let err = put_oneliner(&http, &server.uri(), TEST_API_KEY, Uuid::new_v4(), "x")
|
||||
.await
|
||||
.expect_err("must err");
|
||||
assert!(matches!(
|
||||
err,
|
||||
PutOnelinerError::Terminal { status: 400, .. }
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn put_oneliner_404_is_terminal() {
|
||||
let server = mock_server_with(404, json!({"error": "case not found"})).await;
|
||||
let http = reqwest::Client::new();
|
||||
|
||||
let err = put_oneliner(&http, &server.uri(), TEST_API_KEY, Uuid::new_v4(), "x")
|
||||
.await
|
||||
.expect_err("must err");
|
||||
assert!(matches!(
|
||||
err,
|
||||
PutOnelinerError::Terminal { status: 404, .. }
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn put_oneliner_503_is_transient() {
|
||||
let server = mock_server_with(503, json!({"error": "service unavailable"})).await;
|
||||
let http = reqwest::Client::new();
|
||||
|
||||
let err = put_oneliner(&http, &server.uri(), TEST_API_KEY, Uuid::new_v4(), "x")
|
||||
.await
|
||||
.expect_err("must err");
|
||||
assert!(matches!(err, PutOnelinerError::Transient(_)));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn put_oneliner_429_is_transient() {
|
||||
let server = mock_server_with(429, json!({"error": "too many requests"})).await;
|
||||
let http = reqwest::Client::new();
|
||||
|
||||
let err = put_oneliner(&http, &server.uri(), TEST_API_KEY, Uuid::new_v4(), "x")
|
||||
.await
|
||||
.expect_err("must err");
|
||||
assert!(matches!(err, PutOnelinerError::Transient(_)));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn put_oneliner_network_error_is_transient() {
|
||||
// Point at a URL nothing is listening on.
|
||||
let http = reqwest::Client::builder()
|
||||
.timeout(std::time::Duration::from_millis(200))
|
||||
.build()
|
||||
.unwrap();
|
||||
let err = put_oneliner(
|
||||
&http,
|
||||
"http://127.0.0.1:1", // reserved port
|
||||
TEST_API_KEY,
|
||||
Uuid::new_v4(),
|
||||
"x",
|
||||
)
|
||||
.await
|
||||
.expect_err("must err");
|
||||
assert!(matches!(err, PutOnelinerError::Transient(_)));
|
||||
}
|
||||
}
|
||||
+163
-30
@@ -1,47 +1,180 @@
|
||||
//! 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.
|
||||
//! 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::PathBuf;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::Duration;
|
||||
|
||||
use directories::ProjectDirs;
|
||||
pub use doctate_client_core::config::{Config, ConfigError};
|
||||
use serde::{Deserialize, Serialize};
|
||||
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:
|
||||
/// 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)
|
||||
/// 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>,
|
||||
}
|
||||
|
||||
/// Convenience: load from `default_config_path()`.
|
||||
pub fn load() -> Result<Option<Config>, ConfigError> {
|
||||
Config::load_from(&default_config_path()?)
|
||||
#[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),
|
||||
}
|
||||
|
||||
/// Convenience: save to `default_config_path()`.
|
||||
pub fn save(cfg: &Config) -> Result<(), ConfigError> {
|
||||
cfg.save_to(&default_config_path()?)
|
||||
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 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")
|
||||
);
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,227 @@
|
||||
//! 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<Duration>` = "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<Duration>,
|
||||
last_failure_age: Option<Duration>,
|
||||
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<Duration>,
|
||||
) -> 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);
|
||||
}
|
||||
}
|
||||
@@ -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 eframe::egui;
|
||||
@@ -13,8 +6,8 @@ use tracing_subscriber::EnvFilter;
|
||||
|
||||
use std::fs::{File, OpenOptions, TryLockError};
|
||||
|
||||
use crate::app::DoctateApp;
|
||||
use crate::paths::{cases_dir, lock_file_path, pending_dir, snapshot_cache_path};
|
||||
use doctate_desktop::app::DoctateApp;
|
||||
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
|
||||
/// `exit(1)`. Used for unrecoverable bootstrap failures before the
|
||||
|
||||
@@ -0,0 +1,219 @@
|
||||
//! Orphan reaping for the client's pending-upload directory.
|
||||
//!
|
||||
//! The uploader happy-path deletes `.m4a` + `.meta.json` pairs after
|
||||
//! ACK. A crash between recorder-stop and sidecar-write, or a crash
|
||||
//! between ACK and the two `remove_file` calls, can leave **orphans**
|
||||
//! behind:
|
||||
//!
|
||||
//! - `*.m4a` without `*.meta.json` (or vice versa): the uploader's
|
||||
//! `scan_pending_dir` silently skips these on next launch, so they
|
||||
//! would accumulate indefinitely without explicit cleanup.
|
||||
//! - `*.json.tmp` / `*.m4a.tmp` leftovers from interrupted atomic
|
||||
//! writes: pure garbage, always safe to remove.
|
||||
//!
|
||||
//! Orphan audio is sensitive — clients must not hoard patient
|
||||
//! recordings that never made it to the server. This module reaps them
|
||||
//! on a caller-supplied age threshold. The caller passes an `age_cutoff:
|
||||
//! SystemTime`; everything whose mtime is strictly older is deleted.
|
||||
//! `.tmp` leftovers bypass the cutoff (they are never legitimate work
|
||||
//! in progress at app startup — the lock file guarantees exclusivity).
|
||||
|
||||
use std::path::Path;
|
||||
use std::time::SystemTime;
|
||||
|
||||
use tracing::{info, warn};
|
||||
|
||||
/// Reap orphan artefacts in `pending_dir`. Returns the number of files
|
||||
/// deleted. Missing directory is treated as "nothing to do".
|
||||
pub async fn cleanup_orphan_audio(pending_dir: &Path, age_cutoff: SystemTime) -> usize {
|
||||
let Ok(mut entries) = tokio::fs::read_dir(pending_dir).await else {
|
||||
return 0;
|
||||
};
|
||||
|
||||
// First pass: collect filenames so we can answer "does the sibling
|
||||
// exist?" without doing N separate stat calls.
|
||||
let mut names: Vec<String> = Vec::new();
|
||||
while let Ok(Some(e)) = entries.next_entry().await {
|
||||
if let Some(n) = e.file_name().to_str() {
|
||||
names.push(n.to_owned());
|
||||
}
|
||||
}
|
||||
let has_name = |n: &str| names.iter().any(|x| x == n);
|
||||
|
||||
let mut deleted = 0usize;
|
||||
for name in &names {
|
||||
let path = pending_dir.join(name);
|
||||
|
||||
// Atomic-write leftovers: always delete, no age check. A running
|
||||
// process would hold the single-instance lock, so `.tmp` at
|
||||
// startup is by definition stale.
|
||||
if name.ends_with(".m4a.tmp") || name.ends_with(".meta.json.tmp") {
|
||||
if let Err(e) = tokio::fs::remove_file(&path).await {
|
||||
warn!(path = %path.display(), error = %e, "failed to remove tmp leftover");
|
||||
} else {
|
||||
info!(path = %path.display(), "tmp leftover removed");
|
||||
deleted += 1;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Candidate: orphan `.m4a` — only delete if older than cutoff.
|
||||
if let Some(stem) = name.strip_suffix(".m4a") {
|
||||
let sibling = format!("{stem}.meta.json");
|
||||
if has_name(&sibling) {
|
||||
continue; // paired, leave for the uploader
|
||||
}
|
||||
if mtime_older_than(&path, age_cutoff).await {
|
||||
if let Err(e) = tokio::fs::remove_file(&path).await {
|
||||
warn!(path = %path.display(), error = %e, "failed to remove orphan m4a");
|
||||
} else {
|
||||
info!(path = %path.display(), "orphan m4a removed (no sidecar)");
|
||||
deleted += 1;
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Candidate: orphan sidecar — only delete if older than cutoff.
|
||||
if let Some(stem) = name.strip_suffix(".meta.json") {
|
||||
let sibling = format!("{stem}.m4a");
|
||||
if has_name(&sibling) {
|
||||
continue;
|
||||
}
|
||||
if mtime_older_than(&path, age_cutoff).await {
|
||||
if let Err(e) = tokio::fs::remove_file(&path).await {
|
||||
warn!(path = %path.display(), error = %e, "failed to remove orphan sidecar");
|
||||
} else {
|
||||
info!(path = %path.display(), "orphan sidecar removed (no m4a)");
|
||||
deleted += 1;
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
deleted
|
||||
}
|
||||
|
||||
async fn mtime_older_than(path: &Path, cutoff: SystemTime) -> bool {
|
||||
match tokio::fs::metadata(path).await {
|
||||
Ok(meta) => match meta.modified() {
|
||||
Ok(mtime) => mtime < cutoff,
|
||||
Err(_) => false,
|
||||
},
|
||||
Err(_) => false,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::time::Duration;
|
||||
use tempfile::TempDir;
|
||||
|
||||
async fn touch_with_mtime(path: &Path, mtime: SystemTime) {
|
||||
tokio::fs::write(path, b"x").await.unwrap();
|
||||
filetime::set_file_mtime(path, filetime::FileTime::from_system_time(mtime)).unwrap();
|
||||
}
|
||||
|
||||
fn now() -> SystemTime {
|
||||
SystemTime::now()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn missing_directory_returns_zero() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let n = cleanup_orphan_audio(&tmp.path().join("nope"), now()).await;
|
||||
assert_eq!(n, 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn paired_files_are_preserved() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let stem = "uuid_2026-04-18T08-00-00Z";
|
||||
touch_with_mtime(&tmp.path().join(format!("{stem}.m4a")), now()).await;
|
||||
touch_with_mtime(&tmp.path().join(format!("{stem}.meta.json")), now()).await;
|
||||
// Cutoff in the future → any orphan would be deleted — but
|
||||
// these are paired, must survive.
|
||||
let cutoff = now() + Duration::from_secs(3600);
|
||||
let n = cleanup_orphan_audio(tmp.path(), cutoff).await;
|
||||
assert_eq!(n, 0);
|
||||
assert!(tmp.path().join(format!("{stem}.m4a")).exists());
|
||||
assert!(tmp.path().join(format!("{stem}.meta.json")).exists());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn young_orphan_m4a_is_preserved() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let m4a = tmp.path().join("uuid_young.m4a");
|
||||
touch_with_mtime(&m4a, now()).await;
|
||||
// cutoff = 1h ago → file (mtime=now) is younger → keep
|
||||
let cutoff = now() - Duration::from_secs(3600);
|
||||
assert_eq!(cleanup_orphan_audio(tmp.path(), cutoff).await, 0);
|
||||
assert!(m4a.exists());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn old_orphan_m4a_is_deleted() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let m4a = tmp.path().join("uuid_old.m4a");
|
||||
touch_with_mtime(&m4a, now() - Duration::from_secs(48 * 3600)).await;
|
||||
let cutoff = now() - Duration::from_secs(24 * 3600);
|
||||
let n = cleanup_orphan_audio(tmp.path(), cutoff).await;
|
||||
assert_eq!(n, 1);
|
||||
assert!(!m4a.exists(), "old orphan m4a must be gone");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn old_orphan_sidecar_is_deleted() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let sc = tmp.path().join("uuid_orphan.meta.json");
|
||||
touch_with_mtime(&sc, now() - Duration::from_secs(48 * 3600)).await;
|
||||
let cutoff = now() - Duration::from_secs(24 * 3600);
|
||||
let n = cleanup_orphan_audio(tmp.path(), cutoff).await;
|
||||
assert_eq!(n, 1);
|
||||
assert!(!sc.exists());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn tmp_leftovers_always_removed_regardless_of_age() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let young_tmp = tmp.path().join("fresh.meta.json.tmp");
|
||||
let m4a_tmp = tmp.path().join("interrupted.m4a.tmp");
|
||||
touch_with_mtime(&young_tmp, now()).await;
|
||||
touch_with_mtime(&m4a_tmp, now()).await;
|
||||
// Cutoff in the far past → age check would preserve everything.
|
||||
// But tmp leftovers bypass age check.
|
||||
let n = cleanup_orphan_audio(tmp.path(), now() - Duration::from_secs(10 * 86400)).await;
|
||||
assert_eq!(n, 2);
|
||||
assert!(!young_tmp.exists());
|
||||
assert!(!m4a_tmp.exists());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn mix_of_pairs_orphans_and_tmp() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let keep_stem = "uuid_keep_2026-04-18";
|
||||
let orphan_stem = "uuid_orphan_old";
|
||||
|
||||
// Paired (keep)
|
||||
touch_with_mtime(&tmp.path().join(format!("{keep_stem}.m4a")), now()).await;
|
||||
touch_with_mtime(&tmp.path().join(format!("{keep_stem}.meta.json")), now()).await;
|
||||
// Old orphan m4a (delete)
|
||||
touch_with_mtime(
|
||||
&tmp.path().join(format!("{orphan_stem}.m4a")),
|
||||
now() - Duration::from_secs(48 * 3600),
|
||||
)
|
||||
.await;
|
||||
// tmp leftover (delete)
|
||||
touch_with_mtime(&tmp.path().join("crash.m4a.tmp"), now()).await;
|
||||
|
||||
let cutoff = now() - Duration::from_secs(24 * 3600);
|
||||
let n = cleanup_orphan_audio(tmp.path(), cutoff).await;
|
||||
assert_eq!(n, 2, "orphan + tmp must be removed, pair must stay");
|
||||
assert!(tmp.path().join(format!("{keep_stem}.m4a")).exists());
|
||||
assert!(tmp.path().join(format!("{keep_stem}.meta.json")).exists());
|
||||
assert!(!tmp.path().join(format!("{orphan_stem}.m4a")).exists());
|
||||
assert!(!tmp.path().join("crash.m4a.tmp").exists());
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,176 @@
|
||||
//! On-disk cache of the last successful `/api/oneliners` response.
|
||||
//!
|
||||
//! Survives app restarts so the UI has data before the first poll
|
||||
//! completes. A single JSON file under a caller-chosen path. Atomic
|
||||
//! writes via temp-file + rename. Read errors on malformed content are
|
||||
//! treated the same as "no cache" — the next poll will rebuild it.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use doctate_common::oneliners::OnelinersResponse;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tracing::{debug, warn};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct CachedSnapshot {
|
||||
/// Last ETag we handed the server in `If-None-Match` — raw header
|
||||
/// value, including the `W/"..."` wrapper.
|
||||
pub etag: String,
|
||||
/// RFC3339 UTC; when the client received the response. Feeds the
|
||||
/// staleness indicator on cold start.
|
||||
pub fetched_at: String,
|
||||
pub response: OnelinersResponse,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct SnapshotCache {
|
||||
path: PathBuf,
|
||||
}
|
||||
|
||||
impl SnapshotCache {
|
||||
pub fn new(path: PathBuf) -> Self {
|
||||
Self { path }
|
||||
}
|
||||
|
||||
pub fn path(&self) -> &Path {
|
||||
&self.path
|
||||
}
|
||||
|
||||
/// Read and deserialize. `None` on missing file, unreadable file, or
|
||||
/// parse error — the caller treats all three as "cold start".
|
||||
pub async fn load(&self) -> Option<CachedSnapshot> {
|
||||
let bytes = match tokio::fs::read(&self.path).await {
|
||||
Ok(b) => b,
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return None,
|
||||
Err(e) => {
|
||||
warn!(path = %self.path.display(), error = %e, "snapshot cache read failed");
|
||||
return None;
|
||||
}
|
||||
};
|
||||
match serde_json::from_slice::<CachedSnapshot>(&bytes) {
|
||||
Ok(c) => Some(c),
|
||||
Err(e) => {
|
||||
warn!(path = %self.path.display(), error = %e, "snapshot cache malformed — ignoring");
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Persist atomically: write to `{path}.tmp`, then rename.
|
||||
pub async fn store(&self, cached: &CachedSnapshot) -> std::io::Result<()> {
|
||||
if let Some(parent) = self.path.parent() {
|
||||
tokio::fs::create_dir_all(parent).await?;
|
||||
}
|
||||
let tmp = self.path.with_extension(tmp_extension(&self.path));
|
||||
let bytes = serde_json::to_vec(cached)
|
||||
.map_err(|e| std::io::Error::other(format!("serialize cached snapshot: {e}")))?;
|
||||
tokio::fs::write(&tmp, bytes).await?;
|
||||
tokio::fs::rename(&tmp, &self.path).await?;
|
||||
debug!(path = %self.path.display(), "snapshot cache written");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Delete the cache file. Idempotent: missing file is success.
|
||||
pub async fn clear(&self) -> std::io::Result<()> {
|
||||
match tokio::fs::remove_file(&self.path).await {
|
||||
Ok(_) => Ok(()),
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Build a `{original_extension}.tmp`-style extension that the rename
|
||||
/// target never accidentally matches. For `foo.json` → `json.tmp`.
|
||||
fn tmp_extension(path: &Path) -> String {
|
||||
match path.extension().and_then(|s| s.to_str()) {
|
||||
Some(ext) => format!("{ext}.tmp"),
|
||||
None => "tmp".to_owned(),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use doctate_common::oneliners::{OnelinerEntry, OnelinerState};
|
||||
use tempfile::TempDir;
|
||||
|
||||
fn sample() -> CachedSnapshot {
|
||||
CachedSnapshot {
|
||||
etag: "W/\"1729512345-72\"".into(),
|
||||
fetched_at: "2026-04-18T10:45:00Z".into(),
|
||||
response: OnelinersResponse {
|
||||
as_of: "2026-04-18T10:45:00Z".into(),
|
||||
window_hours: 72,
|
||||
oneliners: vec![OnelinerEntry {
|
||||
case_id: "550e8400-e29b-41d4-a716-446655440000".into(),
|
||||
oneliner: Some(OnelinerState::Ready {
|
||||
text: "Kniegelenk".into(),
|
||||
generated_at: "2026-04-18T10:30:00Z".into(),
|
||||
}),
|
||||
created_at: "2026-04-18T10:00:00Z".into(),
|
||||
last_recording_at: Some("2026-04-18T10:30:00Z".into()),
|
||||
updated_at: Some("2026-04-18T10:30:00Z".into()),
|
||||
}],
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn store_and_load_roundtrip() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let cache = SnapshotCache::new(tmp.path().join("cache.json"));
|
||||
let s = sample();
|
||||
cache.store(&s).await.unwrap();
|
||||
|
||||
let loaded = cache.load().await.expect("cache present");
|
||||
assert_eq!(loaded.etag, s.etag);
|
||||
assert_eq!(loaded.response.oneliners.len(), 1);
|
||||
let text = match &loaded.response.oneliners[0].oneliner {
|
||||
Some(OnelinerState::Ready { text, .. }) => Some(text.as_str()),
|
||||
_ => None,
|
||||
};
|
||||
assert_eq!(text, Some("Kniegelenk"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn load_returns_none_when_missing() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let cache = SnapshotCache::new(tmp.path().join("missing.json"));
|
||||
assert!(cache.load().await.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn load_returns_none_on_garbage() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let path = tmp.path().join("garbage.json");
|
||||
tokio::fs::write(&path, b"this is not json").await.unwrap();
|
||||
let cache = SnapshotCache::new(path);
|
||||
assert!(cache.load().await.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn clear_is_idempotent() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let cache = SnapshotCache::new(tmp.path().join("cache.json"));
|
||||
cache.clear().await.unwrap();
|
||||
|
||||
cache.store(&sample()).await.unwrap();
|
||||
assert!(cache.load().await.is_some());
|
||||
|
||||
cache.clear().await.unwrap();
|
||||
assert!(cache.load().await.is_none());
|
||||
|
||||
// Second clear on already-empty is still Ok.
|
||||
cache.clear().await.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn store_creates_parent_dirs() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let deep = tmp.path().join("a/b/c/cache.json");
|
||||
let cache = SnapshotCache::new(deep.clone());
|
||||
cache.store(&sample()).await.unwrap();
|
||||
assert!(deep.exists());
|
||||
}
|
||||
}
|
||||
@@ -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::{PendingUpload, write_sidecar};
|
||||
use filetime::{FileTime, set_file_mtime};
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
//! Upload I/O building blocks: `PendingUpload` DTO, atomic sidecar write,
|
||||
//! pending-dir scan.
|
||||
//!
|
||||
//! No network logic here — the actual `POST /api/upload` lives in the
|
||||
//! `server_sync` worker. This module is only about persistence on the
|
||||
//! client's disk queue:
|
||||
//!
|
||||
//! - `{stem}.m4a` — the recorded audio (written by the recorder)
|
||||
//! - `{stem}.meta.json` — the sidecar with `case_id` + `recorded_at`
|
||||
//!
|
||||
//! A pair of both is a ready-to-upload work item; singletons are orphans
|
||||
//! that `pending_cleanup` reaps.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use doctate_common::ack::AckStatus;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use thiserror::Error;
|
||||
use tracing::warn;
|
||||
use uuid::Uuid;
|
||||
|
||||
/// A recording waiting to be uploaded. The `file` path is derived from the
|
||||
/// sidecar's on-disk location, not from the JSON content itself — so the
|
||||
/// sidecar stays valid if the pending directory is moved/renamed.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PendingUpload {
|
||||
pub case_id: Uuid,
|
||||
pub recorded_at: String,
|
||||
#[serde(skip, default)]
|
||||
pub file: PathBuf,
|
||||
}
|
||||
|
||||
/// Lifecycle events for a single upload attempt. Emitted by the worker,
|
||||
/// consumed by the UI.
|
||||
#[derive(Debug)]
|
||||
pub enum UploadEvent {
|
||||
Started(Uuid),
|
||||
Succeeded {
|
||||
case_id: Uuid,
|
||||
status: AckStatus,
|
||||
},
|
||||
Failed {
|
||||
case_id: Uuid,
|
||||
reason: String,
|
||||
will_retry: bool,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum UploadIoError {
|
||||
#[error("I/O error: {0}")]
|
||||
Io(#[from] std::io::Error),
|
||||
#[error("serialize sidecar: {0}")]
|
||||
Serialize(#[from] serde_json::Error),
|
||||
}
|
||||
|
||||
/// Sidecar path next to a `.m4a` file: same directory, same stem, `.meta.json`.
|
||||
pub fn sidecar_path_for(m4a: &Path) -> PathBuf {
|
||||
let stem = m4a
|
||||
.file_stem()
|
||||
.map(|s| s.to_string_lossy().into_owned())
|
||||
.unwrap_or_default();
|
||||
let dir = m4a.parent().unwrap_or_else(|| Path::new("."));
|
||||
dir.join(format!("{stem}.meta.json"))
|
||||
}
|
||||
|
||||
/// Persist a pending upload's metadata next to its m4a. Atomic: writes a
|
||||
/// `*.tmp` sibling and renames it into place. A crash mid-write cannot
|
||||
/// leave a zero-byte or partial sidecar on disk — `scan_pending_dir`
|
||||
/// would otherwise skip the pair as an unreadable orphan.
|
||||
pub fn write_sidecar(upload: &PendingUpload) -> Result<(), UploadIoError> {
|
||||
let sidecar = sidecar_path_for(&upload.file);
|
||||
if let Some(parent) = sidecar.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
let json = serde_json::to_string_pretty(upload)?;
|
||||
let tmp = sidecar.with_extension("json.tmp");
|
||||
std::fs::write(&tmp, json)?;
|
||||
std::fs::rename(&tmp, &sidecar)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Scan a pending directory for `.m4a` files with matching sidecars.
|
||||
/// Orphans (no sidecar, unreadable sidecar) are logged and skipped — one
|
||||
/// broken pair should not stop recovery of the rest.
|
||||
pub fn scan_pending_dir(pending_dir: &Path) -> Vec<PendingUpload> {
|
||||
let entries = match std::fs::read_dir(pending_dir) {
|
||||
Ok(e) => e,
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Vec::new(),
|
||||
Err(e) => {
|
||||
warn!(dir = ?pending_dir, error = %e, "scan pending dir failed");
|
||||
return Vec::new();
|
||||
}
|
||||
};
|
||||
let mut out = Vec::new();
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
if path.extension().and_then(|s| s.to_str()) != Some("m4a") {
|
||||
continue;
|
||||
}
|
||||
match read_pending(&path) {
|
||||
Ok(upload) => out.push(upload),
|
||||
Err(e) => warn!(path = ?path, error = %e, "skipping pending entry"),
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn read_pending(m4a: &Path) -> Result<PendingUpload, UploadIoError> {
|
||||
let sidecar = sidecar_path_for(m4a);
|
||||
let json = std::fs::read_to_string(&sidecar)?;
|
||||
let mut upload: PendingUpload = serde_json::from_str(&json)?;
|
||||
upload.file = m4a.to_path_buf();
|
||||
Ok(upload)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use tempfile::TempDir;
|
||||
|
||||
fn sample(dir: &Path, stem: &str) -> PendingUpload {
|
||||
let file = dir.join(format!("{stem}.m4a"));
|
||||
std::fs::write(&file, b"dummy audio").unwrap();
|
||||
PendingUpload {
|
||||
case_id: Uuid::parse_str("550e8400-e29b-41d4-a716-446655440000").unwrap(),
|
||||
recorded_at: "2026-04-15T10:32:00Z".into(),
|
||||
file,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sidecar_roundtrip_via_scan() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let upload = sample(tmp.path(), "case_a");
|
||||
write_sidecar(&upload).unwrap();
|
||||
let found = scan_pending_dir(tmp.path());
|
||||
assert_eq!(found.len(), 1);
|
||||
assert_eq!(found[0].case_id, upload.case_id);
|
||||
assert_eq!(found[0].recorded_at, upload.recorded_at);
|
||||
assert_eq!(found[0].file, upload.file);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scan_ignores_m4a_without_sidecar() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
std::fs::write(tmp.path().join("orphan.m4a"), b"x").unwrap();
|
||||
let found = scan_pending_dir(tmp.path());
|
||||
assert!(found.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scan_returns_empty_for_missing_dir() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let missing = tmp.path().join("does-not-exist");
|
||||
assert!(scan_pending_dir(&missing).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn write_sidecar_leaves_no_tmp_artefact() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let upload = sample(tmp.path(), "case_b");
|
||||
write_sidecar(&upload).unwrap();
|
||||
|
||||
let sidecar = sidecar_path_for(&upload.file);
|
||||
assert!(sidecar.exists(), "sidecar must be at final path");
|
||||
let tmp_path = sidecar.with_extension("json.tmp");
|
||||
assert!(!tmp_path.exists(), "tmp artefact must not remain");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scan_returns_pairs_in_arbitrary_order_but_all_present() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
for stem in &["case_1", "case_2", "case_3"] {
|
||||
let upload = sample(tmp.path(), stem);
|
||||
write_sidecar(&upload).unwrap();
|
||||
}
|
||||
let found = scan_pending_dir(tmp.path());
|
||||
assert_eq!(found.len(), 3);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user