use std::path::{Path, PathBuf}; use doctate_common::oneliners::{ONELINER_FILENAME, OnelinerState}; use doctate_common::{RECORDING_META_SUFFIX, RecordingMeta, TranscriptState}; use serde::{Deserialize, Serialize}; use time::OffsetDateTime; use tracing::info; use crate::oneliner_locks::OnelinerLocks; /// Atomically write `value` as JSON to `final_path`. /// /// Writes to `.tmp` first, then `rename`s into place. /// `rename` within the same filesystem is atomic on every POSIX target /// we run on, so concurrent readers either see the previous full file /// or the new full file — never a half-written one. Caller is /// responsible for the destination's parent directory existing. /// /// Used by every metadata sidecar (oneliner state, per-recording meta, /// …) so the on-disk format never has partial documents the parser /// would choke on. pub async fn atomic_write_json(final_path: &Path, value: &T) -> std::io::Result<()> { let tmp_path = match final_path.file_name() { Some(name) => { let mut tmp_name = name.to_owned(); tmp_name.push(".tmp"); final_path.with_file_name(tmp_name) } // No file_name on a path means we cannot derive a sibling tmp // file — bail out with InvalidInput rather than silently // writing to a wrong place. None => { return Err(std::io::Error::new( std::io::ErrorKind::InvalidInput, "atomic_write_json: target path has no file name", )); } }; let payload = serde_json::to_vec(value) .map_err(|e| std::io::Error::other(format!("serialize json: {e}")))?; tokio::fs::write(&tmp_path, &payload).await?; tokio::fs::rename(&tmp_path, final_path).await } /// Absolute on-disk location of a case directory. /// /// Single source of truth for the layout `///`. /// Use everywhere instead of inlining `.join(slug).join(case_id)` so the /// layout can evolve in one place. pub fn case_dir(data_path: &Path, slug: &str, case_id: &str) -> PathBuf { data_path.join(slug).join(case_id) } /// Filename of the close marker inside a case directory. A case with /// this marker is "closed": hidden from the default listing, reversible /// via a new upload or the explicit reopen action, and eligible for /// lazy auto-purge after `auto_delete_days`. pub const CLOSE_MARKER: &str = ".closed"; /// Close-marker payload. `closed_at` is an RFC3339 timestamp and the /// authoritative input for purge eligibility (`now - closed_at > auto_delete_days`). #[derive(Debug, Clone, Serialize, Deserialize)] pub struct CloseMarker { pub closed_at: String, } /// True iff the case directory contains a `.closed` marker file. pub async fn is_closed(case_dir: &Path) -> bool { tokio::fs::try_exists(case_dir.join(CLOSE_MARKER)) .await .unwrap_or(false) } /// Read and parse the `.closed` marker. Returns `None` if absent or /// malformed (treated identically — a malformed marker still means /// "closed" via `is_closed`, but cannot participate in timestamp-based /// grouping or purge checks). pub async fn read_close_marker(case_dir: &Path) -> Option { let bytes = tokio::fs::read(case_dir.join(CLOSE_MARKER)).await.ok()?; serde_json::from_slice(&bytes).ok() } /// Write the `.closed` marker as JSON. Overwrites any existing marker. pub async fn write_close_marker(case_dir: &Path, marker: &CloseMarker) -> std::io::Result<()> { let bytes = serde_json::to_vec(marker) .map_err(|e| std::io::Error::other(format!("serialize close marker: {e}")))?; tokio::fs::write(case_dir.join(CLOSE_MARKER), bytes).await } /// Read the persisted oneliner state for a case. /// /// Returns `(Some(state), Some(mtime))` when `oneliner.json` exists and /// parses. Missing file → `(None, None)`. A file that exists but fails /// to parse is treated as missing and logged — the next transcription /// job will overwrite it with a well-formed state. pub async fn read_oneliner_state( case_dir: &Path, ) -> (Option, Option) { let path = case_dir.join(ONELINER_FILENAME); let bytes = match tokio::fs::read(&path).await { Ok(b) => b, Err(_) => return (None, None), }; let state: OnelinerState = match serde_json::from_slice(&bytes) { Ok(s) => s, Err(e) => { tracing::warn!( path = %path.display(), error = %e, "malformed oneliner.json; treating as missing" ); return (None, None); } }; let mtime = tokio::fs::metadata(&path) .await .ok() .and_then(|m| m.modified().ok()) .map(OffsetDateTime::from); (Some(state), mtime) } /// Write `state` atomically to `case_dir/oneliner.json`. Thin wrapper /// over [`atomic_write_json`] that fixes the filename — kept as its own /// function so call-sites read as "write the oneliner state" rather /// than threading the filename constant in by hand. pub async fn write_oneliner_state(case_dir: &Path, state: &OnelinerState) -> std::io::Result<()> { atomic_write_json(&case_dir.join(ONELINER_FILENAME), state).await } /// Delete `case_dir/oneliner.json` unless it persists a manual override. /// /// Acquires the per-case oneliner lock, reads the current state, and /// removes the file only when the on-disk state is missing, malformed, /// or one of the LLM-derived variants (`Ready`, `Empty`, `Error`). /// `OnelinerState::Manual` is preserved untouched — once a clinician /// has set the oneliner by hand, it must survive every server-driven /// artefact wipe (recording delete, admin reset). The only legitimate /// path that may remove a manual oneliner is the user-initiated /// `purge-closed`, which removes the entire case directory and is /// therefore not routed through this helper. /// /// Returns `Ok(true)` if the file was deleted (or was already absent), /// `Ok(false)` if a manual override was preserved. pub async fn delete_oneliner_unless_manual( case_dir: &Path, locks: &OnelinerLocks, ) -> std::io::Result { // Hold the per-case lock across read+decide+delete so a concurrent // worker cannot flip Manual <-> Ready between our check and our // remove. Worker write paths take the same lock around their own // re-check window, so the two paths serialize cleanly. let _guard = locks.lock_for(case_dir).await; let (state, _) = read_oneliner_state(case_dir).await; if matches!(state, Some(OnelinerState::Manual { .. })) { info!( case_dir = %case_dir.display(), "manual oneliner preserved across artefact wipe" ); return Ok(false); } match tokio::fs::remove_file(case_dir.join(ONELINER_FILENAME)).await { Ok(_) => Ok(true), Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(true), Err(e) => Err(e), } } /// Load the `.json` metadata sidecar for a recording as a /// [`TranscriptState`]. /// /// `audio_stem_path` is the non-`.failed` stem path of the recording /// (e.g. `case_dir/2026-04-16T16-23-38Z.m4a`) — the sidecar sits next /// to it with the `.json` extension. /// /// This is the single reader of that sidecar's transcript field. /// Every "is this recording transcribed?" check should go through /// here so the three-state semantics (Pending / Silent / Content) /// stay consistent across the codebase. pub async fn read_transcript_state(audio_stem_path: &Path) -> TranscriptState { TranscriptState::from_meta(read_recording_meta(audio_stem_path).await.as_ref()) } /// Path of the per-recording metadata sidecar next to `audio_stem_path`. /// /// `audio_stem_path` is the non-`.failed` stem path of the recording /// (e.g. `case_dir/2026-04-16T16-23-38Z.m4a`); the sidecar lives at /// `case_dir/2026-04-16T16-23-38Z.json`. pub fn recording_meta_path(audio_stem_path: &Path) -> PathBuf { audio_stem_path.with_extension(RECORDING_META_SUFFIX) } /// Read and parse the `.json` sidecar for a recording. /// /// Returns `None` for missing files (the typical "transcriber hasn't /// finished yet" case) and for malformed JSON (logged at warn — a /// rewritten sidecar will overwrite the bad one on the next worker /// pass). Callers translate `None` to [`TranscriptState::Pending`]. pub async fn read_recording_meta(audio_stem_path: &Path) -> Option { let path = recording_meta_path(audio_stem_path); let bytes = tokio::fs::read(&path).await.ok()?; match serde_json::from_slice(&bytes) { Ok(meta) => Some(meta), Err(e) => { tracing::warn!( path = %path.display(), error = %e, "malformed recording meta sidecar; treating as missing" ); None } } } /// Atomically write `.json` for a recording. Thin wrapper around /// [`atomic_write_json`] that fixes the filename so call-sites read as /// "persist this recording's metadata" rather than threading the /// suffix in by hand. pub async fn write_recording_meta_atomic( audio_stem_path: &Path, meta: &RecordingMeta, ) -> std::io::Result<()> { atomic_write_json(&recording_meta_path(audio_stem_path), meta).await } /// Synchronous test fixture: write a `.json` directly with the /// given transcript and duration. Mirrors what the worker would have /// persisted at the end of its pipeline. Async helpers exist for /// production paths; this one keeps unit-test setups terse and /// callable from non-async contexts. #[cfg(test)] pub(crate) fn write_recording_meta_sync( case_dir: &Path, stem: &str, transcript: doctate_common::Transcript, duration_seconds: Option, ) { let meta = doctate_common::RecordingMeta { transcript, duration_seconds, loudness: None, }; let bytes = serde_json::to_vec(&meta).expect("RecordingMeta serializes infallibly"); std::fs::write( case_dir.join(format!("{stem}.{RECORDING_META_SUFFIX}")), bytes, ) .expect("write recording meta sidecar in test fixture"); } #[cfg(test)] mod tests { use super::*; use tempfile::TempDir; fn manual_state() -> OnelinerState { OnelinerState::Manual { text: "manuell-arzt".to_owned(), set_at: "2026-04-26T10:00:00Z".to_owned(), } } fn ready_state() -> OnelinerState { OnelinerState::Ready { text: "knee, left, pain".to_owned(), generated_at: "2026-04-26T10:00:00Z".to_owned(), } } #[tokio::test] async fn delete_oneliner_unless_manual_keeps_manual() { let dir = TempDir::new().unwrap(); write_oneliner_state(dir.path(), &manual_state()) .await .unwrap(); let locks = OnelinerLocks::new(); let removed = delete_oneliner_unless_manual(dir.path(), &locks) .await .unwrap(); assert!(!removed, "Manual must be preserved"); let (state, _) = read_oneliner_state(dir.path()).await; assert!( matches!(state, Some(OnelinerState::Manual { ref text, .. }) if text == "manuell-arzt"), "Manual state must remain identical on disk, got {state:?}" ); } #[tokio::test] async fn delete_oneliner_unless_manual_removes_ready() { let dir = TempDir::new().unwrap(); write_oneliner_state(dir.path(), &ready_state()) .await .unwrap(); let locks = OnelinerLocks::new(); let removed = delete_oneliner_unless_manual(dir.path(), &locks) .await .unwrap(); assert!(removed, "Ready must be deleted"); assert!(!dir.path().join(ONELINER_FILENAME).exists()); } #[tokio::test] async fn delete_oneliner_unless_manual_handles_missing_file() { let dir = TempDir::new().unwrap(); let locks = OnelinerLocks::new(); let removed = delete_oneliner_unless_manual(dir.path(), &locks) .await .expect("missing file must not be an error"); assert!(removed, "absent file is reported as deleted"); } #[tokio::test] async fn delete_oneliner_unless_manual_handles_malformed_json() { // Malformed JSON must not lock the case in place forever — the // file is treated like a non-Manual state and removed so the // pipeline can re-derive a fresh oneliner. let dir = TempDir::new().unwrap(); tokio::fs::write(dir.path().join(ONELINER_FILENAME), b"{ not json") .await .unwrap(); let locks = OnelinerLocks::new(); let removed = delete_oneliner_unless_manual(dir.path(), &locks) .await .unwrap(); assert!(removed, "malformed file must be cleared"); assert!(!dir.path().join(ONELINER_FILENAME).exists()); } }