Files
doctate/clients/desktop/src/upload.rs
T
Brummel 0c66fc6010 refactor: rename to common/, clients/desktop, clients/wearos
Move three directories into their target topology:
- doctate-common/ -> common/
- client-desktop/ -> clients/desktop/
- watch/wearos/   -> clients/wearos/

Update doctate-common path-dependency in 3 Cargo.toml files
(server, clients/desktop, experiments) and the workspace
members list in the root Cargo.toml. Crate names unchanged
(doctate-server, doctate-common, doctate-desktop).

Workspace still in single-Cargo.lock form; isolation into
standalone workspaces follows in stage 3. All 508 tests pass,
experiments standalone-workspace also resolves the new path.
2026-05-02 11:55:38 +02:00

182 lines
5.9 KiB
Rust

//! 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);
}
}