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:
2026-05-02 11:50:37 +02:00
parent c5535c0848
commit 39cc666ca6
19 changed files with 250 additions and 308 deletions
+5 -1
View File
@@ -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"
+9 -8
View File
@@ -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
+220
View File
@@ -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
View File
@@ -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));
}
}
+48
View File
@@ -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")
);
}
}
+227
View File
@@ -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);
}
}
+21
View File
@@ -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;
+2 -9
View File
@@ -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
+219
View File
@@ -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
+176
View File
@@ -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());
}
}
+150
View File
@@ -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;
}
}
+181
View File
@@ -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);
}
}