use std::path::{Path, PathBuf}; use doctate_common::oneliners::{ONELINER_FILENAME, OnelinerState}; use serde::{Deserialize, Serialize}; use time::OffsetDateTime; use uuid::Uuid; /// 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 soft-delete marker inside a case directory. pub const DELETE_MARKER: &str = ".deleted"; /// Soft-delete marker payload. One file per deleted case; all cases removed /// in the same user click share the same `batch` UUID. `deleted_at` is an /// RFC3339 timestamp — used to find the most recent batch for "Undo last /// delete". #[derive(Debug, Clone, Serialize, Deserialize)] pub struct DeleteMarker { pub batch: Uuid, pub deleted_at: String, } /// True iff the case directory contains a `.deleted` marker file. pub async fn is_deleted(case_dir: &Path) -> bool { tokio::fs::try_exists(case_dir.join(DELETE_MARKER)) .await .unwrap_or(false) } /// Read and parse the `.deleted` marker. Returns `None` if absent or malformed /// (treated identically — a malformed marker still means "deleted" via /// `is_deleted`, but cannot participate in batch-undo). pub async fn read_delete_marker(case_dir: &Path) -> Option { let bytes = tokio::fs::read(case_dir.join(DELETE_MARKER)).await.ok()?; serde_json::from_slice(&bytes).ok() } /// Write the `.deleted` marker as JSON. Overwrites any existing marker. pub async fn write_delete_marker(case_dir: &Path, marker: &DeleteMarker) -> std::io::Result<()> { let bytes = serde_json::to_vec(marker) .map_err(|e| std::io::Error::other(format!("serialize delete marker: {e}")))?; tokio::fs::write(case_dir.join(DELETE_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) }