0c66fc6010
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.
182 lines
5.9 KiB
Rust
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);
|
|
}
|
|
}
|