use std::path::{Path, PathBuf}; use doctate_common::TranscriptState; use doctate_common::oneliners::{ONELINER_FILENAME, OnelinerState}; use serde::{Deserialize, Serialize}; use time::OffsetDateTime; use tracing::info; use crate::oneliner_locks::OnelinerLocks; /// 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` via /// `write(tmp) + rename(tmp, final)`. Rename is atomic within a single /// filesystem, so concurrent readers never see a half-serialized JSON /// document. pub async fn write_oneliner_state(case_dir: &Path, state: &OnelinerState) -> std::io::Result<()> { let final_path = case_dir.join(ONELINER_FILENAME); let tmp_path = case_dir.join(format!("{ONELINER_FILENAME}.tmp")); let payload = serde_json::to_vec(state).expect("OnelinerState is infallibly serializable"); tokio::fs::write(&tmp_path, &payload).await?; tokio::fs::rename(&tmp_path, &final_path).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 `.transcript.txt` 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 extension `.transcript.txt`. /// /// This is the single reader for that sidecar. Every call-site that /// used to do `tokio::fs::read_to_string(...).ok()` or /// `with_extension("transcript.txt").exists()` should go through here /// so the three-state semantics stay consistent across the codebase. pub async fn read_transcript_state(audio_stem_path: &Path) -> TranscriptState { let transcript_path = audio_stem_path.with_extension("transcript.txt"); let raw = tokio::fs::read_to_string(&transcript_path).await.ok(); TranscriptState::from_raw(raw.as_deref()) } #[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()); } }