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