diff --git a/client-desktop/src/app.rs b/client-desktop/src/app.rs index 5835b40..e6789ab 100644 --- a/client-desktop/src/app.rs +++ b/client-desktop/src/app.rs @@ -14,9 +14,9 @@ use std::sync::Arc; use std::time::{Duration, Instant}; use doctate_client_core::{ - pick_footer_status, run_startup_cleanup, write_sidecar, CaseMarker, CaseStore, FooterStatus, + CaseMarker, CaseStore, DEFAULT_MARKER_RETENTION, DEFAULT_ORPHAN_AUDIO_RETENTION, FooterStatus, PendingUpload, ServerSync, SnapshotCache, SyncConfig, UploadEvent, WorkerSnapshot, WorkerState, - DEFAULT_MARKER_RETENTION, DEFAULT_ORPHAN_AUDIO_RETENTION, + pick_footer_status, run_startup_cleanup, write_sidecar, }; use doctate_common::timestamp::{ extract_hhmm, now_rfc3339, parse_rfc3339, recorded_at_to_filename_stem, @@ -157,10 +157,7 @@ impl DoctateApp { .config .as_ref() .and_then(|c| c.oneliner_poll_interval_seconds), - oneliner_window_hours: self - .config - .as_ref() - .and_then(|c| c.oneliner_window_hours), + oneliner_window_hours: self.config.as_ref().and_then(|c| c.oneliner_window_hours), }; if let Err(e) = crate::config::save(&cfg) { error!(error = %e, "config save failed"); @@ -415,9 +412,7 @@ impl DoctateApp { match &self.state { AppState::NotConfigured => { ui.label("Server URL:"); - ui.add( - egui::TextEdit::singleline(&mut self.server_url_input).desired_width(280.0), - ); + ui.add(egui::TextEdit::singleline(&mut self.server_url_input).desired_width(280.0)); ui.add_space(4.0); ui.label("API Key:"); ui.add( @@ -463,11 +458,7 @@ impl DoctateApp { fn render_case_list(&self, ui: &mut egui::Ui) -> Option { let snapshot = self.snapshot_rx.borrow().clone(); - let window = self - .config - .as_ref() - .map(Config::window_hours) - .unwrap_or(72); + let window = self.config.as_ref().map(Config::window_hours).unwrap_or(72); let cutoff = time::OffsetDateTime::now_utc() - time::Duration::hours(window as i64); let visible: Vec<&CaseMarker> = snapshot diff --git a/client-desktop/src/config.rs b/client-desktop/src/config.rs index 144fcf4..af9762a 100644 --- a/client-desktop/src/config.rs +++ b/client-desktop/src/config.rs @@ -39,6 +39,9 @@ mod tests { // Sanity check on the ProjectDirs wiring — can't assert the // exact path (varies per host), but the filename is deterministic. let path = default_config_path().expect("config path available in test env"); - assert_eq!(path.file_name().and_then(|s| s.to_str()), Some("client.toml")); + assert_eq!( + path.file_name().and_then(|s| s.to_str()), + Some("client.toml") + ); } } diff --git a/client-desktop/src/recorder.rs b/client-desktop/src/recorder.rs index 422d3d5..36cee7f 100644 --- a/client-desktop/src/recorder.rs +++ b/client-desktop/src/recorder.rs @@ -168,7 +168,9 @@ async fn finalize( // non-empty. Real corruption will surface server-side at decode. let event = match timeout(STOP_GRACEFUL_TIMEOUT, child.wait()).await { Ok(Ok(status)) => match tokio::fs::metadata(&output_path).await { - Ok(meta) if meta.len() > 0 => RecorderEvent::Finished { output: output_path }, + Ok(meta) if meta.len() > 0 => RecorderEvent::Finished { + output: output_path, + }, Ok(_) => RecorderEvent::Failed { error: format!("ffmpeg exit {status} and empty output"), }, diff --git a/client-desktop/src/state.rs b/client-desktop/src/state.rs index 528bd36..9640309 100644 --- a/client-desktop/src/state.rs +++ b/client-desktop/src/state.rs @@ -23,9 +23,7 @@ pub enum AppState { started_at: Instant, }, /// Terminal error; user must dismiss or fix config. - Error { - message: String, - }, + Error { message: String }, } impl AppState { diff --git a/doctate-client-core/src/case_store.rs b/doctate-client-core/src/case_store.rs index fdfa946..cdcd2e2 100644 --- a/doctate-client-core/src/case_store.rs +++ b/doctate-client-core/src/case_store.rs @@ -27,7 +27,7 @@ use serde::{Deserialize, Serialize}; use thiserror::Error; use time::format_description::well_known::Rfc3339; use time::{Duration, OffsetDateTime}; -use tokio::sync::{watch, Mutex}; +use tokio::sync::{Mutex, watch}; use tracing::{debug, info, warn}; use uuid::Uuid; @@ -93,8 +93,7 @@ impl CaseStore { })?; let initial = read_all_markers(&dir).await?; - let map: HashMap = - initial.into_iter().map(|m| (m.case_id, m)).collect(); + let map: HashMap = initial.into_iter().map(|m| (m.case_id, m)).collect(); let snapshot = sorted_snapshot(&map); let (tx, rx) = watch::channel(snapshot); @@ -278,7 +277,9 @@ impl CaseStore { for id in &to_remove { let path = self.dir.join(format!("{id}.json")); match tokio::fs::remove_file(&path).await { - Ok(_) => info!(case_id = %id, "marker removed (server reconcile: case missing from snapshot)"), + Ok(_) => { + info!(case_id = %id, "marker removed (server reconcile: case missing from snapshot)") + } Err(e) => warn!(case_id = %id, error = %e, "failed to remove reconciled marker"), } state.remove(id); @@ -298,10 +299,7 @@ impl CaseStore { /// Returns the number of markers removed. Intended as an /// app-startup pass so the data-minimization rule holds: metadata /// ages out after a few days even without an explicit UI action. - pub async fn cleanup_stale( - &self, - cutoff: OffsetDateTime, - ) -> Result { + pub async fn cleanup_stale(&self, cutoff: OffsetDateTime) -> Result { let cutoff_str = format_utc(cutoff)?; let mut state = self.state.lock().await; let to_remove: Vec = state @@ -313,7 +311,9 @@ impl CaseStore { for id in &to_remove { let path = self.dir.join(format!("{id}.json")); match tokio::fs::remove_file(&path).await { - Ok(_) => tracing::info!(case_id = %id, path = %path.display(), "stale marker removed"), + Ok(_) => { + tracing::info!(case_id = %id, path = %path.display(), "stale marker removed") + } Err(e) => warn!(case_id = %id, error = %e, "failed to remove stale marker"), } state.remove(id); @@ -328,13 +328,12 @@ impl CaseStore { /// (new API key = potentially different user). pub async fn clear_all(&self) -> Result<(), CaseStoreError> { let mut state = self.state.lock().await; - let mut entries = - tokio::fs::read_dir(&self.dir) - .await - .map_err(|e| CaseStoreError::Io { - path: self.dir.clone(), - source: e, - })?; + let mut entries = tokio::fs::read_dir(&self.dir) + .await + .map_err(|e| CaseStoreError::Io { + path: self.dir.clone(), + source: e, + })?; while let Ok(Some(entry)) = entries.next_entry().await { let path = entry.path(); if path.extension().and_then(|s| s.to_str()) != Some("json") { @@ -378,10 +377,12 @@ fn sorted_vec(state: &HashMap) -> Vec { async fn read_all_markers(dir: &Path) -> Result, CaseStoreError> { let mut out = Vec::new(); - let mut entries = tokio::fs::read_dir(dir).await.map_err(|e| CaseStoreError::Io { - path: dir.to_owned(), - source: e, - })?; + let mut entries = tokio::fs::read_dir(dir) + .await + .map_err(|e| CaseStoreError::Io { + path: dir.to_owned(), + source: e, + })?; while let Ok(Some(entry)) = entries.next_entry().await { let path = entry.path(); if path.extension().and_then(|s| s.to_str()) != Some("json") { @@ -400,10 +401,12 @@ async fn read_all_markers(dir: &Path) -> Result, CaseStoreError> } async fn read_marker_file(path: &Path) -> Result { - let bytes = tokio::fs::read(path).await.map_err(|e| CaseStoreError::Io { - path: path.to_owned(), - source: e, - })?; + let bytes = tokio::fs::read(path) + .await + .map_err(|e| CaseStoreError::Io { + path: path.to_owned(), + source: e, + })?; Ok(serde_json::from_slice(&bytes)?) } @@ -465,7 +468,10 @@ mod tests { assert!(rx.borrow().is_empty()); let id = Uuid::new_v4(); - let marker = store.create_local(id, t("2026-04-18T10:32:00Z")).await.unwrap(); + let marker = store + .create_local(id, t("2026-04-18T10:32:00Z")) + .await + .unwrap(); assert_eq!(marker.case_id, id); assert!(!marker.synced_to_server); assert!(marker.oneliner.is_none()); @@ -486,8 +492,14 @@ mod tests { let tmp = TempDir::new().unwrap(); let (store, _rx) = CaseStore::open(tmp.path().to_owned()).await.unwrap(); let id = Uuid::new_v4(); - store.create_local(id, t("2026-04-18T10:00:00Z")).await.unwrap(); - store.mark_activity(id, t("2026-04-18T10:45:00Z")).await.unwrap(); + store + .create_local(id, t("2026-04-18T10:00:00Z")) + .await + .unwrap(); + store + .mark_activity(id, t("2026-04-18T10:45:00Z")) + .await + .unwrap(); let list = store.list().await; assert_eq!(list.len(), 1); @@ -501,7 +513,10 @@ mod tests { let tmp = TempDir::new().unwrap(); let (store, _rx) = CaseStore::open(tmp.path().to_owned()).await.unwrap(); let id = Uuid::new_v4(); - store.mark_activity(id, t("2026-04-18T10:00:00Z")).await.unwrap(); + store + .mark_activity(id, t("2026-04-18T10:00:00Z")) + .await + .unwrap(); assert_eq!(store.list().await.len(), 1); } @@ -529,7 +544,10 @@ mod tests { let tmp = TempDir::new().unwrap(); let (store, _rx) = CaseStore::open(tmp.path().to_owned()).await.unwrap(); let id = Uuid::new_v4(); - store.create_local(id, t("2026-04-18T10:00:00Z")).await.unwrap(); + store + .create_local(id, t("2026-04-18T10:00:00Z")) + .await + .unwrap(); let snap = server_snapshot(vec![server_entry( id, @@ -551,7 +569,10 @@ mod tests { let tmp = TempDir::new().unwrap(); let (store, _rx) = CaseStore::open(tmp.path().to_owned()).await.unwrap(); let id = Uuid::new_v4(); - store.create_local(id, t("2026-04-18T10:00:00Z")).await.unwrap(); + store + .create_local(id, t("2026-04-18T10:00:00Z")) + .await + .unwrap(); let snap = server_snapshot(vec![]); store.merge_server_snapshot(&snap).await.unwrap(); @@ -566,16 +587,19 @@ mod tests { let tmp = TempDir::new().unwrap(); let (store, _rx) = CaseStore::open(tmp.path().to_owned()).await.unwrap(); let id = Uuid::new_v4(); - store.create_local(id, t("2026-04-18T10:00:00Z")).await.unwrap(); - let synced_snap = server_snapshot(vec![server_entry( - id, - Some("old"), - "2026-04-18T10:00:00Z", - )]); + store + .create_local(id, t("2026-04-18T10:00:00Z")) + .await + .unwrap(); + let synced_snap = + server_snapshot(vec![server_entry(id, Some("old"), "2026-04-18T10:00:00Z")]); store.merge_server_snapshot(&synced_snap).await.unwrap(); // Next poll: server window moved, case fell out. - store.merge_server_snapshot(&server_snapshot(vec![])).await.unwrap(); + store + .merge_server_snapshot(&server_snapshot(vec![])) + .await + .unwrap(); let list = store.list().await; assert_eq!(list.len(), 1); @@ -588,15 +612,17 @@ mod tests { let tmp = TempDir::new().unwrap(); let (store, _rx) = CaseStore::open(tmp.path().to_owned()).await.unwrap(); let id = Uuid::new_v4(); - store.create_local(id, t("2026-04-18T09:00:00Z")).await.unwrap(); - store.mark_activity(id, t("2026-04-18T11:00:00Z")).await.unwrap(); + store + .create_local(id, t("2026-04-18T09:00:00Z")) + .await + .unwrap(); + store + .mark_activity(id, t("2026-04-18T11:00:00Z")) + .await + .unwrap(); // Server says last activity was 10:00 (stale) — local 11:00 must win. - let snap = server_snapshot(vec![server_entry( - id, - Some("x"), - "2026-04-18T10:00:00Z", - )]); + let snap = server_snapshot(vec![server_entry(id, Some("x"), "2026-04-18T10:00:00Z")]); store.merge_server_snapshot(&snap).await.unwrap(); let list = store.list().await; @@ -607,8 +633,14 @@ mod tests { async fn clear_all_removes_everything() { let tmp = TempDir::new().unwrap(); let (store, _rx) = CaseStore::open(tmp.path().to_owned()).await.unwrap(); - store.create_local(Uuid::new_v4(), t("2026-04-18T10:00:00Z")).await.unwrap(); - store.create_local(Uuid::new_v4(), t("2026-04-18T11:00:00Z")).await.unwrap(); + store + .create_local(Uuid::new_v4(), t("2026-04-18T10:00:00Z")) + .await + .unwrap(); + store + .create_local(Uuid::new_v4(), t("2026-04-18T11:00:00Z")) + .await + .unwrap(); assert_eq!(store.list().await.len(), 2); store.clear_all().await.unwrap(); @@ -685,7 +717,10 @@ mod tests { let old_unsynced = Uuid::new_v4(); // old_synced: created 4 days ago, then merged in → synced=true, old - store.create_local(old_synced, t("2026-04-14T10:00:00Z")).await.unwrap(); + store + .create_local(old_synced, t("2026-04-14T10:00:00Z")) + .await + .unwrap(); store .merge_server_snapshot(&server_snapshot(vec![OnelinerEntry { case_id: old_synced.to_string(), @@ -698,7 +733,10 @@ mod tests { .unwrap(); // young_synced: created today, synced - store.create_local(young_synced, t("2026-04-18T10:00:00Z")).await.unwrap(); + store + .create_local(young_synced, t("2026-04-18T10:00:00Z")) + .await + .unwrap(); store .merge_server_snapshot(&server_snapshot(vec![OnelinerEntry { case_id: young_synced.to_string(), @@ -711,7 +749,10 @@ mod tests { .unwrap(); // old_unsynced: created 4 days ago, NEVER merged in → pending upload - store.create_local(old_unsynced, t("2026-04-14T10:00:00Z")).await.unwrap(); + store + .create_local(old_unsynced, t("2026-04-14T10:00:00Z")) + .await + .unwrap(); // Cutoff: 72h before the "today" markers, i.e. 2026-04-15. let cutoff = t("2026-04-15T10:00:00Z"); @@ -729,15 +770,24 @@ mod tests { // Disk: old_synced's file deleted. let old_path = tmp.path().join(format!("{old_synced}.json")); - assert!(!old_path.exists(), "marker file for old_synced must be deleted"); + assert!( + !old_path.exists(), + "marker file for old_synced must be deleted" + ); } #[tokio::test] async fn cleanup_stale_is_noop_when_nothing_old() { let tmp = TempDir::new().unwrap(); let (store, _rx) = CaseStore::open(tmp.path().to_owned()).await.unwrap(); - store.create_local(Uuid::new_v4(), t("2026-04-18T10:00:00Z")).await.unwrap(); - let removed = store.cleanup_stale(t("2026-01-01T00:00:00Z")).await.unwrap(); + store + .create_local(Uuid::new_v4(), t("2026-04-18T10:00:00Z")) + .await + .unwrap(); + let removed = store + .cleanup_stale(t("2026-01-01T00:00:00Z")) + .await + .unwrap(); assert_eq!(removed, 0); assert_eq!(store.list().await.len(), 1); } @@ -761,7 +811,10 @@ mod tests { let id = Uuid::new_v4(); // Seed a synced marker with recent activity. - store.create_local(id, t("2026-04-18T09:00:00Z")).await.unwrap(); + store + .create_local(id, t("2026-04-18T09:00:00Z")) + .await + .unwrap(); store.mark_synced(id).await.unwrap(); // Snapshot authoritative for [2026-04-15T10Z, 2026-04-18T10Z], @@ -784,7 +837,10 @@ mod tests { let id = Uuid::new_v4(); // Pending: synced_to_server = false (default after create_local). - store.create_local(id, t("2026-04-18T09:00:00Z")).await.unwrap(); + store + .create_local(id, t("2026-04-18T09:00:00Z")) + .await + .unwrap(); let snap = snapshot_as_of("2026-04-18T10:00:00Z", vec![]); let removed = store.reconcile_with_server_snapshot(&snap).await.unwrap(); @@ -804,7 +860,10 @@ mod tests { // last_activity_at = 2026-04-10 (8 days ago); window = 72h anchor 2026-04-18T10Z // → window_start = 2026-04-15T10Z. Marker is outside. - store.create_local(id, t("2026-04-10T10:00:00Z")).await.unwrap(); + store + .create_local(id, t("2026-04-10T10:00:00Z")) + .await + .unwrap(); store.mark_synced(id).await.unwrap(); let snap = snapshot_as_of("2026-04-18T10:00:00Z", vec![]); @@ -823,8 +882,14 @@ mod tests { let (store, _rx) = CaseStore::open(tmp.path().to_owned()).await.unwrap(); let id_a = Uuid::new_v4(); let id_b = Uuid::new_v4(); - store.create_local(id_a, t("2026-04-18T09:00:00Z")).await.unwrap(); - store.create_local(id_b, t("2026-04-18T09:30:00Z")).await.unwrap(); + store + .create_local(id_a, t("2026-04-18T09:00:00Z")) + .await + .unwrap(); + store + .create_local(id_b, t("2026-04-18T09:30:00Z")) + .await + .unwrap(); store.mark_synced(id_a).await.unwrap(); store.mark_synced(id_b).await.unwrap(); @@ -852,7 +917,10 @@ mod tests { let id = Uuid::new_v4(); // Marker last_activity_at 2026-04-18T09:00Z, synced. - store.create_local(id, t("2026-04-18T09:00:00Z")).await.unwrap(); + store + .create_local(id, t("2026-04-18T09:00:00Z")) + .await + .unwrap(); store.mark_synced(id).await.unwrap(); // Server reports as_of 2026-04-18T10:00Z (1h after marker, @@ -866,7 +934,10 @@ mod tests { // Seed a second case for the symmetric assertion. let id2 = Uuid::new_v4(); - store.create_local(id2, t("2026-04-18T09:00:00Z")).await.unwrap(); + store + .create_local(id2, t("2026-04-18T09:00:00Z")) + .await + .unwrap(); store.mark_synced(id2).await.unwrap(); // Now the server reports a much later as_of. The marker's @@ -885,7 +956,10 @@ mod tests { let tmp = TempDir::new().unwrap(); let (store, _rx) = CaseStore::open(tmp.path().to_owned()).await.unwrap(); let id = Uuid::new_v4(); - store.create_local(id, t("2026-04-18T10:00:00Z")).await.unwrap(); + store + .create_local(id, t("2026-04-18T10:00:00Z")) + .await + .unwrap(); assert!(!store.list().await[0].synced_to_server); store.mark_synced(id).await.unwrap(); @@ -908,7 +982,10 @@ mod tests { let tmp = TempDir::new().unwrap(); let (store, _rx) = CaseStore::open(tmp.path().to_owned()).await.unwrap(); let id = Uuid::new_v4(); - store.create_local(id, t("2026-04-18T10:00:00Z")).await.unwrap(); + store + .create_local(id, t("2026-04-18T10:00:00Z")) + .await + .unwrap(); store.mark_synced(id).await.unwrap(); store.mark_synced(id).await.unwrap(); // second call: no disk churn assert!(store.list().await[0].synced_to_server); @@ -920,8 +997,14 @@ mod tests { let (store, _rx) = CaseStore::open(tmp.path().to_owned()).await.unwrap(); let older = Uuid::new_v4(); let newer = Uuid::new_v4(); - store.create_local(older, t("2026-04-18T08:00:00Z")).await.unwrap(); - store.create_local(newer, t("2026-04-18T10:00:00Z")).await.unwrap(); + store + .create_local(older, t("2026-04-18T08:00:00Z")) + .await + .unwrap(); + store + .create_local(newer, t("2026-04-18T10:00:00Z")) + .await + .unwrap(); let list = store.list().await; assert_eq!(list[0].case_id, newer); diff --git a/doctate-client-core/src/footer_status.rs b/doctate-client-core/src/footer_status.rs index 0b200ad..f3ee01a 100644 --- a/doctate-client-core/src/footer_status.rs +++ b/doctate-client-core/src/footer_status.rs @@ -59,8 +59,8 @@ pub fn pick_footer_status( WorkerState::Polling => FooterStatus::Synchronizing, WorkerState::Idle => { let failure_more_recent = match (last_success_age, last_failure_age) { - (Some(s), Some(f)) => f < s, // smaller age = younger - (None, Some(_)) => true, // only a failure on record + (Some(s), Some(f)) => f < s, // smaller age = younger + (None, Some(_)) => true, // only a failure on record _ => false, }; if failure_more_recent { @@ -88,7 +88,14 @@ mod tests { queue_len: usize, last_success_age: Option, ) -> FooterStatus { - pick_footer_status(finalizing, worker_state, queue_len, last_success_age, None, THRESHOLD) + pick_footer_status( + finalizing, + worker_state, + queue_len, + last_success_age, + None, + THRESHOLD, + ) } #[test] @@ -128,7 +135,12 @@ mod tests { #[test] fn synchronizing_when_polling_and_no_queue() { - let s = status(false, WorkerState::Polling, 0, Some(Duration::from_millis(1))); + let s = status( + false, + WorkerState::Polling, + 0, + Some(Duration::from_millis(1)), + ); assert_eq!(s, FooterStatus::Synchronizing); } diff --git a/doctate-client-core/src/lib.rs b/doctate-client-core/src/lib.rs index 220cecc..5dcaaf8 100644 --- a/doctate-client-core/src/lib.rs +++ b/doctate-client-core/src/lib.rs @@ -23,10 +23,14 @@ pub mod startup; pub mod upload; pub use case_store::{CaseMarker, CaseStore, CaseStoreError}; -pub use config::{Config, ConfigError, DEFAULT_ONELINER_WINDOW_HOURS, DEFAULT_POLL_INTERVAL_SECONDS}; -pub use footer_status::{pick_footer_status, FooterStatus}; +pub use config::{ + Config, ConfigError, DEFAULT_ONELINER_WINDOW_HOURS, DEFAULT_POLL_INTERVAL_SECONDS, +}; +pub use footer_status::{FooterStatus, pick_footer_status}; pub use pending_cleanup::cleanup_orphan_audio; pub use server_sync::{ServerSync, SyncConfig, WorkerSnapshot, WorkerState}; pub use snapshot_cache::{CachedSnapshot, SnapshotCache}; -pub use startup::{run_startup_cleanup, DEFAULT_MARKER_RETENTION, DEFAULT_ORPHAN_AUDIO_RETENTION}; -pub use upload::{scan_pending_dir, sidecar_path_for, write_sidecar, PendingUpload, UploadEvent, UploadIoError}; +pub use startup::{DEFAULT_MARKER_RETENTION, DEFAULT_ORPHAN_AUDIO_RETENTION, run_startup_cleanup}; +pub use upload::{ + PendingUpload, UploadEvent, UploadIoError, scan_pending_dir, sidecar_path_for, write_sidecar, +}; diff --git a/doctate-client-core/src/server_sync.rs b/doctate-client-core/src/server_sync.rs index 742bf36..1a1cce5 100644 --- a/doctate-client-core/src/server_sync.rs +++ b/doctate-client-core/src/server_sync.rs @@ -27,7 +27,7 @@ use doctate_common::constants::{ API_KEY_HEADER, CONTENT_TYPE_AUDIO_MP4, FIELD_AUDIO, FIELD_CASE_ID, FIELD_RECORDED_AT, UPLOAD_PATH, }; -use doctate_common::oneliners::{OnelinersResponse, ONELINERS_PATH}; +use doctate_common::oneliners::{ONELINERS_PATH, OnelinersResponse}; use reqwest::StatusCode; use tokio::sync::{mpsc, watch}; use tokio::task::AbortHandle; @@ -36,7 +36,7 @@ use uuid::Uuid; use crate::case_store::CaseStore; use crate::snapshot_cache::{CachedSnapshot, SnapshotCache}; -use crate::upload::{scan_pending_dir, sidecar_path_for, PendingUpload, UploadEvent}; +use crate::upload::{PendingUpload, UploadEvent, scan_pending_dir, sidecar_path_for}; const INITIAL_BACKOFF: Duration = Duration::from_secs(2); const MAX_BACKOFF: Duration = Duration::from_secs(60); @@ -233,8 +233,7 @@ async fn run_loop( let mut files = scan_pending_dir(&pending_dir); files.sort_by(|a, b| a.recorded_at.cmp(&b.recorded_at)); // FIFO let queue_len = files.len(); - let pending_ids: Arc> = - Arc::new(files.iter().map(|u| u.case_id).collect()); + let pending_ids: Arc> = Arc::new(files.iter().map(|u| u.case_id).collect()); let next = files.into_iter().next(); if let Some(upload) = next { @@ -565,7 +564,10 @@ mod tests { let http = reqwest::Client::new(); let outcome = post_upload(&http, &cfg(&server), &upload).await; - assert!(matches!(outcome, UploadOutcome::Succeeded(AckStatus::Received))); + assert!(matches!( + outcome, + UploadOutcome::Succeeded(AckStatus::Received) + )); } #[tokio::test] @@ -710,7 +712,8 @@ mod tests { sample_pending(&pending_dir, case_id, "2026-04-18T10-00-00Z"); let (store, cache) = make_store_and_cache(&tmp).await; - store.create_local(case_id, time::OffsetDateTime::now_utc()) + store + .create_local(case_id, time::OffsetDateTime::now_utc()) .await .unwrap(); @@ -914,10 +917,11 @@ mod tests { async fn fifo_order_by_recorded_at() { let server = MockServer::start().await; // Record which case_ids we saw, in order. - let seen: Arc>> = Arc::new(tokio::sync::Mutex::new(Vec::new())); + let seen: Arc>> = + Arc::new(tokio::sync::Mutex::new(Vec::new())); let seen_for_mock = seen.clone(); - let responder = wiremock::ResponseTemplate::new(200) - .set_body_json(ack_body(AckStatus::Received)); + let responder = + wiremock::ResponseTemplate::new(200).set_body_json(ack_body(AckStatus::Received)); // Simpler: just let both POSTs succeed; infer order from event // stream on our side. let _ = seen_for_mock; diff --git a/doctate-client-core/src/snapshot_cache.rs b/doctate-client-core/src/snapshot_cache.rs index ef0800f..58319f3 100644 --- a/doctate-client-core/src/snapshot_cache.rs +++ b/doctate-client-core/src/snapshot_cache.rs @@ -123,7 +123,10 @@ mod tests { let loaded = cache.load().await.expect("cache present"); assert_eq!(loaded.etag, s.etag); assert_eq!(loaded.response.oneliners.len(), 1); - assert_eq!(loaded.response.oneliners[0].oneliner.as_deref(), Some("Kniegelenk")); + assert_eq!( + loaded.response.oneliners[0].oneliner.as_deref(), + Some("Kniegelenk") + ); } #[tokio::test] diff --git a/doctate-client-core/src/startup.rs b/doctate-client-core/src/startup.rs index af186af..9d76943 100644 --- a/doctate-client-core/src/startup.rs +++ b/doctate-client-core/src/startup.rs @@ -43,8 +43,8 @@ pub async fn run_startup_cleanup( marker_retention: Duration, orphan_audio_retention: Duration, ) { - let marker_cutoff = OffsetDateTime::now_utc() - - time::Duration::seconds(marker_retention.as_secs() as i64); + let marker_cutoff = + OffsetDateTime::now_utc() - time::Duration::seconds(marker_retention.as_secs() as i64); match store.cleanup_stale(marker_cutoff).await { Ok(n) if n > 0 => info!(count = n, "startup cleanup: stale markers removed"), Ok(_) => {} @@ -61,8 +61,8 @@ pub async fn run_startup_cleanup( #[cfg(test)] mod tests { use super::*; - use crate::upload::{write_sidecar, PendingUpload}; - use filetime::{set_file_mtime, FileTime}; + use crate::upload::{PendingUpload, write_sidecar}; + use filetime::{FileTime, set_file_mtime}; use tempfile::TempDir; use uuid::Uuid; @@ -114,8 +114,8 @@ mod tests { run_startup_cleanup( &store, &pending_dir, - Duration::from_secs(3600), // markers older than 1h: nuke - Duration::from_secs(3600), // orphans older than 1h: nuke + Duration::from_secs(3600), // markers older than 1h: nuke + Duration::from_secs(3600), // orphans older than 1h: nuke ) .await; diff --git a/doctate-common/src/lib.rs b/doctate-common/src/lib.rs index 7884b82..d3307fa 100644 --- a/doctate-common/src/lib.rs +++ b/doctate-common/src/lib.rs @@ -8,5 +8,5 @@ pub use constants::{ API_KEY_HEADER, CONTENT_TYPE_AUDIO_MP4, FIELD_AUDIO, FIELD_CASE_ID, FIELD_RECORDED_AT, UPLOAD_PATH, }; -pub use oneliners::{OnelinerEntry, OnelinersResponse, ONELINERS_PATH}; +pub use oneliners::{ONELINERS_PATH, OnelinerEntry, OnelinersResponse}; pub use timestamp::{filename_stem_to_recorded_at, now_rfc3339, recorded_at_to_filename_stem}; diff --git a/doctate-common/src/timestamp.rs b/doctate-common/src/timestamp.rs index b1e33ef..8f87877 100644 --- a/doctate-common/src/timestamp.rs +++ b/doctate-common/src/timestamp.rs @@ -129,7 +129,10 @@ mod tests { fn extract_hhmm_shape_is_two_colon_two() { let s = extract_hhmm("2026-04-18T10:32:00Z"); assert_eq!(s.len(), 5, "expected HH:MM, got {s}"); - assert!(s.chars().nth(2) == Some(':'), "expected colon at index 2: {s}"); + assert!( + s.chars().nth(2) == Some(':'), + "expected colon at index 2: {s}" + ); } #[test] diff --git a/server/src/analyze/llm.rs b/server/src/analyze/llm.rs index f040434..ac9942a 100644 --- a/server/src/analyze/llm.rs +++ b/server/src/analyze/llm.rs @@ -84,7 +84,10 @@ pub async fn chat_once( user_content: &str, timeout: Duration, ) -> Result { - let url = format!("{}/v1/chat/completions", settings.base_url.trim_end_matches('/')); + let url = format!( + "{}/v1/chat/completions", + settings.base_url.trim_end_matches('/') + ); debug!(%url, model = settings.model, "calling llm chat completions"); let body = ChatRequest { @@ -92,8 +95,14 @@ pub async fn chat_once( temperature: settings.temperature, stream: false, messages: vec![ - Message { role: "system", content: system_prompt }, - Message { role: "user", content: user_content }, + Message { + role: "system", + content: system_prompt, + }, + Message { + role: "user", + content: user_content, + }, ], }; @@ -107,7 +116,9 @@ pub async fn chat_once( if !status.is_success() { // Body deliberately dropped — see LlmError docstring. drop(response); - return Err(LlmError::Status { status: status.as_u16() }); + return Err(LlmError::Status { + status: status.as_u16(), + }); } let parsed: ChatResponse = response.json().await.map_err(LlmError::Http)?; diff --git a/server/src/analyze/recovery.rs b/server/src/analyze/recovery.rs index 738c1e9..7ec4675 100644 --- a/server/src/analyze/recovery.rs +++ b/server/src/analyze/recovery.rs @@ -2,7 +2,7 @@ use std::path::Path; use tracing::{info, warn}; -use super::{AnalyzeJob, AnalyzeSender, ANALYSIS_INPUT_FILE, DOCUMENT_FILE}; +use super::{ANALYSIS_INPUT_FILE, AnalyzeJob, AnalyzeSender, DOCUMENT_FILE}; /// Walk `data_path/*/` and enqueue every pending analysis for every user. /// Intended to run once at startup; the same primitive backs the per-user diff --git a/server/src/analyze/render.rs b/server/src/analyze/render.rs index 5dc6e52..96f5877 100644 --- a/server/src/analyze/render.rs +++ b/server/src/analyze/render.rs @@ -10,7 +10,7 @@ //! Pipeline: //! raw markdown → html-escape → ==X== → X → pulldown-cmark → html -use pulldown_cmark::{html, Options, Parser}; +use pulldown_cmark::{Options, Parser, html}; /// Render analysis Markdown to HTML. Safe to inject into an Askama template /// with `{{ var|safe }}` because every `<`/`>` from the LLM is pre-escaped diff --git a/server/src/analyze/worker.rs b/server/src/analyze/worker.rs index b31ce5b..d789475 100644 --- a/server/src/analyze/worker.rs +++ b/server/src/analyze/worker.rs @@ -5,7 +5,7 @@ use std::time::Duration; use tokio::io::AsyncWriteExt; use tracing::{error, info, warn}; -use super::{llm, prompt, AnalysisInput, AnalyzeReceiver, ANALYSIS_INPUT_FILE, DOCUMENT_FILE}; +use super::{ANALYSIS_INPUT_FILE, AnalysisInput, AnalyzeReceiver, DOCUMENT_FILE, llm, prompt}; use crate::config::Config; use crate::gazetteer::Gazetteer; use crate::{BusyGuard, WorkerBusy}; @@ -20,10 +20,7 @@ pub async fn run( worker_busy: WorkerBusy, vocab: Arc, ) { - info!( - vocab_entries = vocab.len(), - "Analyze worker started" - ); + info!(vocab_entries = vocab.len(), "Analyze worker started"); let timeout = Duration::from_secs(config.llm_timeout_seconds); while let Some(job) = rx.recv().await { @@ -144,7 +141,10 @@ async fn process( /// state machine simply sees the job as still pending and retries. async fn write_atomic(path: &Path, bytes: &[u8]) -> std::io::Result<()> { let tmp = path.with_extension({ - let mut ext = path.extension().map(|s| s.to_os_string()).unwrap_or_default(); + let mut ext = path + .extension() + .map(|s| s.to_os_string()) + .unwrap_or_default(); ext.push(".tmp"); ext }); diff --git a/server/src/auth.rs b/server/src/auth.rs index dbd0071..147edb7 100644 --- a/server/src/auth.rs +++ b/server/src/auth.rs @@ -48,10 +48,7 @@ where .and_then(|v| v.to_str().ok()) .ok_or(AppError::Unauthorized)?; - let slug = config - .api_keys - .get(api_key) - .ok_or(AppError::Unauthorized)?; + let slug = config.api_keys.get(api_key).ok_or(AppError::Unauthorized)?; let user = config .users diff --git a/server/src/bin/hash-password.rs b/server/src/bin/hash-password.rs index e0c57fe..ffee5dc 100644 --- a/server/src/bin/hash-password.rs +++ b/server/src/bin/hash-password.rs @@ -35,7 +35,10 @@ fn main() -> ExitCode { Ok(Mode::PatchFile { slug, file }) => match read_password_twice() { Ok(pw) => match patch_users_toml(&file, &slug, &hash(&pw)) { Ok(()) => { - eprintln!("Updated web_password for slug \"{slug}\" in {}", file.display()); + eprintln!( + "Updated web_password for slug \"{slug}\" in {}", + file.display() + ); ExitCode::SUCCESS } Err(e) => { @@ -131,11 +134,8 @@ fn read_password_twice() -> Result { /// Patch `web_password` for the `[[user]]` entry whose `slug` matches. /// Preserves comments and formatting via `toml_edit`. Writes atomically. fn patch_users_toml(path: &Path, slug: &str, new_hash: &str) -> Result<(), String> { - let content = std::fs::read_to_string(path) - .map_err(|e| format!("read: {e}"))?; - let mut doc: toml_edit::DocumentMut = content - .parse() - .map_err(|e| format!("parse: {e}"))?; + let content = std::fs::read_to_string(path).map_err(|e| format!("read: {e}"))?; + let mut doc: toml_edit::DocumentMut = content.parse().map_err(|e| format!("parse: {e}"))?; let users = doc .get_mut("user") @@ -158,8 +158,7 @@ fn patch_users_toml(path: &Path, slug: &str, new_hash: &str) -> Result<(), Strin // Atomic write: tmp file in same dir, then rename. let tmp = path.with_extension("toml.tmp"); { - let mut f = std::fs::File::create(&tmp) - .map_err(|e| format!("create tmp: {e}"))?; + let mut f = std::fs::File::create(&tmp).map_err(|e| format!("create tmp: {e}"))?; f.write_all(doc.to_string().as_bytes()) .map_err(|e| format!("write tmp: {e}"))?; f.sync_all().map_err(|e| format!("sync tmp: {e}"))?; @@ -203,7 +202,10 @@ role = "doctor" patch_users_toml(&p, "dr_a", "$2b$12$NEW").unwrap(); let out = std::fs::read_to_string(&p).unwrap(); assert!(out.contains(r#"web_password = "$2b$12$NEW""#), "got: {out}"); - assert!(out.contains(r#"web_password = "$2b$12$KEEP""#), "dr_b changed: {out}"); + assert!( + out.contains(r#"web_password = "$2b$12$KEEP""#), + "dr_b changed: {out}" + ); assert!(out.contains("# header comment"), "comment lost: {out}"); std::fs::remove_file(&p).ok(); } diff --git a/server/src/case_id.rs b/server/src/case_id.rs index 5db69c5..237c1fb 100644 --- a/server/src/case_id.rs +++ b/server/src/case_id.rs @@ -50,8 +50,8 @@ where let Path(s) = Path::::from_request_parts(parts, state) .await .map_err(|_| AppError::BadRequest("Invalid case_id".into()))?; - let id = CaseId::from_str(&s) - .map_err(|_| AppError::BadRequest("Invalid case_id".into()))?; + let id = + CaseId::from_str(&s).map_err(|_| AppError::BadRequest("Invalid case_id".into()))?; Ok(CaseIdPath(id)) } } diff --git a/server/src/config.rs b/server/src/config.rs index 370a578..1de2bd9 100644 --- a/server/src/config.rs +++ b/server/src/config.rs @@ -216,8 +216,7 @@ pub fn validate_and_index_users(users: &[User]) -> Result String { - std::env::var(name) - .unwrap_or_else(|_| panic!("Missing required environment variable: {name}")) + std::env::var(name).unwrap_or_else(|_| panic!("Missing required environment variable: {name}")) } /// Read a required environment variable and parse it. Panics if missing or unparseable. diff --git a/server/src/gazetteer/mod.rs b/server/src/gazetteer/mod.rs index f0928b1..7257b26 100644 --- a/server/src/gazetteer/mod.rs +++ b/server/src/gazetteer/mod.rs @@ -495,10 +495,7 @@ mod tests { #[test] fn dict_veto_allows_nonword_rewrite() { let g = from_entries(&["Cerebrum"]).with_dict(Box::new(HashSetDict::new(&["Kaktus"]))); - assert_eq!( - g.replace("Blutung im Zerebrum."), - "Blutung im Cerebrum." - ); + assert_eq!(g.replace("Blutung im Zerebrum."), "Blutung im Cerebrum."); } /// The exact-match short-circuit runs *before* any dict lookup. @@ -552,5 +549,4 @@ mod tests { "inflected form must be recognized via affix expansion" ); } - } diff --git a/server/src/lib.rs b/server/src/lib.rs index d7ffa91..bda3b32 100644 --- a/server/src/lib.rs +++ b/server/src/lib.rs @@ -10,11 +10,11 @@ pub mod routes; pub mod transcribe; pub mod web_session; -use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::Arc; +use std::sync::atomic::{AtomicBool, Ordering}; -use axum::extract::FromRef; use axum::Router; +use axum::extract::FromRef; use analyze::AnalyzeSender; use config::Config; diff --git a/server/src/main.rs b/server/src/main.rs index d971318..611e8cd 100644 --- a/server/src/main.rs +++ b/server/src/main.rs @@ -4,15 +4,15 @@ use tokio::net::TcpListener; use tower_http::limit::RequestBodyLimitLayer; use tower_http::trace::TraceLayer; use tracing::{info, warn}; +use tracing_subscriber::EnvFilter; use tracing_subscriber::layer::SubscriberExt; use tracing_subscriber::util::SubscriberInitExt; -use tracing_subscriber::EnvFilter; +use doctate_server::AppState; use doctate_server::analyze; use doctate_server::config::Config; use doctate_server::gazetteer::{Gazetteer, SpellbookDict}; use doctate_server::transcribe; -use doctate_server::AppState; #[tokio::main] async fn main() { @@ -20,8 +20,8 @@ async fn main() { let config = Arc::new(Config::from_env()); // Logging: stdout + daily rotating log files. - let env_filter = EnvFilter::try_new(&config.log_level) - .unwrap_or_else(|_| EnvFilter::new("info")); + let env_filter = + EnvFilter::try_new(&config.log_level).unwrap_or_else(|_| EnvFilter::new("info")); let file_appender = tracing_appender::rolling::daily(&config.log_path, "recorder.log"); let (file_writer, _guard) = tracing_appender::non_blocking(file_appender); @@ -131,10 +131,8 @@ async fn main() { transcribe::recovery::scan_and_enqueue(&data_path, &tx).await; // Then catch up oneliners for cases that crashed between // transcript-write and oneliner-write in a previous run. - transcribe::recovery::regenerate_missing_oneliners( - &data_path, &client, &cfg, &vocab, - ) - .await; + transcribe::recovery::regenerate_missing_oneliners(&data_path, &client, &cfg, &vocab) + .await; }); } diff --git a/server/src/paths.rs b/server/src/paths.rs index a8c76ed..c54e589 100644 --- a/server/src/paths.rs +++ b/server/src/paths.rs @@ -41,10 +41,7 @@ pub async fn read_delete_marker(case_dir: &Path) -> Option { } /// Write the `.deleted` marker as JSON. Overwrites any existing marker. -pub async fn write_delete_marker( - case_dir: &Path, - marker: &DeleteMarker, -) -> std::io::Result<()> { +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 diff --git a/server/src/routes/bulk.rs b/server/src/routes/bulk.rs index f7b552c..a236df1 100644 --- a/server/src/routes/bulk.rs +++ b/server/src/routes/bulk.rs @@ -4,16 +4,18 @@ use axum::extract::State; use axum::response::Redirect; use axum_extra::extract::Form; use serde::Deserialize; -use time::format_description::well_known::Rfc3339; use time::OffsetDateTime; +use time::format_description::well_known::Rfc3339; use tracing::{info, warn}; -use crate::analyze::{AnalyzeJob, AnalyzeSender, ANALYSIS_INPUT_FILE, DOCUMENT_FILE}; +use crate::analyze::{ANALYSIS_INPUT_FILE, AnalyzeJob, AnalyzeSender, DOCUMENT_FILE}; use crate::auth::AuthenticatedWebUser; use crate::config::Config; use crate::error::AppError; -use crate::paths::{write_delete_marker, DeleteMarker}; -use crate::routes::case_actions::{build_analysis_input, reset_case_artefacts, write_input_create_new}; +use crate::paths::{DeleteMarker, write_delete_marker}; +use crate::routes::case_actions::{ + build_analysis_input, reset_case_artefacts, write_input_create_new, +}; use crate::routes::user_web::locate_case; #[derive(Debug, Deserialize)] @@ -37,7 +39,9 @@ pub async fn handle_bulk_action( let user_root = config.data_path.join(&user.slug); match form.action.as_str() { - "analyze" => bulk_analyze(&config, &analyze_tx, &user.slug, &user_root, &form.case_ids).await, + "analyze" => { + bulk_analyze(&config, &analyze_tx, &user.slug, &user_root, &form.case_ids).await + } "delete" => bulk_delete(&user.slug, &user_root, &form.case_ids).await, "reset" => { if !user.is_admin() { diff --git a/server/src/routes/case_actions.rs b/server/src/routes/case_actions.rs index f104a49..7e275d2 100644 --- a/server/src/routes/case_actions.rs +++ b/server/src/routes/case_actions.rs @@ -5,21 +5,21 @@ use std::time::SystemTime; use askama::Template; use axum::extract::State; use axum::response::{Html, Redirect}; -use time::format_description::well_known::Rfc3339; use time::OffsetDateTime; +use time::format_description::well_known::Rfc3339; use tokio::io::AsyncWriteExt; use tracing::{info, warn}; use doctate_common::timestamp::{filename_stem_to_recorded_at, now_rfc3339}; use crate::analyze::{ - AnalysisInput, AnalyzeJob, AnalyzeSender, RecordingInput, ANALYSIS_INPUT_FILE, DOCUMENT_FILE, + ANALYSIS_INPUT_FILE, AnalysisInput, AnalyzeJob, AnalyzeSender, DOCUMENT_FILE, RecordingInput, }; use crate::auth::AuthenticatedWebUser; use crate::case_id::CaseIdPath; use crate::config::Config; use crate::error::AppError; -use crate::paths::{read_delete_marker, write_delete_marker, DeleteMarker, DELETE_MARKER}; +use crate::paths::{DELETE_MARKER, DeleteMarker, read_delete_marker, write_delete_marker}; use crate::routes::user_web::locate_case_or_404; #[derive(Template)] @@ -157,17 +157,13 @@ fn compute_last_mtime(m4as: &[(PathBuf, SystemTime)]) -> SystemTime { /// transkribiert" body. Blank transcripts are silently skipped — they /// represent recordings the transcriber classified as silence and must /// not be pushed to the LLM. -async fn read_recordings( - m4as: &[(PathBuf, SystemTime)], -) -> Result, AppError> { +async fn read_recordings(m4as: &[(PathBuf, SystemTime)]) -> Result, AppError> { let mut recordings: Vec = Vec::new(); for (m4a_path, _) in m4as { let transcript_path = m4a_path.with_extension("transcript.txt"); let text = tokio::fs::read_to_string(&transcript_path) .await - .map_err(|_| { - AppError::BadRequest("Nicht alle Aufnahmen sind transkribiert".into()) - })?; + .map_err(|_| AppError::BadRequest("Nicht alle Aufnahmen sind transkribiert".into()))?; if text.trim().is_empty() { continue; @@ -240,7 +236,9 @@ pub(crate) async fn write_input_create_new( /// Read `case_dir/document.md`. Returns `None` if no document exists. pub(crate) async fn read_document(case_dir: &Path) -> Option { - tokio::fs::read_to_string(case_dir.join(DOCUMENT_FILE)).await.ok() + tokio::fs::read_to_string(case_dir.join(DOCUMENT_FILE)) + .await + .ok() } /// Reset a case to its raw audio: delete all derived artefacts @@ -388,8 +386,7 @@ async fn latest_delete_batch(user_root: &Path) -> Option<(uuid::Uuid, String)> { None => Some(candidate), // Order: deleted_at desc, then batch desc as tie-breaker. Some(ref cur) - if candidate.1 > cur.1 - || (candidate.1 == cur.1 && candidate.0 > cur.0) => + if candidate.1 > cur.1 || (candidate.1 == cur.1 && candidate.0 > cur.0) => { Some(candidate) } diff --git a/server/src/routes/debug.rs b/server/src/routes/debug.rs index f590723..31345b9 100644 --- a/server/src/routes/debug.rs +++ b/server/src/routes/debug.rs @@ -1,5 +1,5 @@ use axum::Json; -use serde_json::{json, Value}; +use serde_json::{Value, json}; use crate::auth::AuthenticatedUser; use crate::error::AppError; diff --git a/server/src/routes/health.rs b/server/src/routes/health.rs index 37d95d8..a27fcf0 100644 --- a/server/src/routes/health.rs +++ b/server/src/routes/health.rs @@ -1,8 +1,8 @@ use std::sync::Arc; -use axum::extract::State; use axum::Json; -use serde_json::{json, Value}; +use axum::extract::State; +use serde_json::{Value, json}; use crate::config::Config; diff --git a/server/src/routes/login.rs b/server/src/routes/login.rs index 0c76b53..3b778cf 100644 --- a/server/src/routes/login.rs +++ b/server/src/routes/login.rs @@ -2,12 +2,12 @@ use std::sync::Arc; use std::time::{Duration, Instant}; use askama::Template; +use axum::Form; use axum::extract::State; use axum::response::{Html, IntoResponse, Redirect, Response}; -use axum::Form; use axum_extra::extract::cookie::{Cookie, CookieJar, SameSite}; -use rand::distributions::Alphanumeric; use rand::Rng; +use rand::distributions::Alphanumeric; use serde::Deserialize; use tracing::{info, warn}; @@ -49,7 +49,10 @@ pub async fn handle_login_submit( if !valid { if user.is_none() { // Dummy verify so timing is similar regardless of slug existence. - let _ = bcrypt::verify(&form.password, "$2b$12$invalidinvalidinvalidinvalidinvalidinvalidinvalidinvalidii"); + let _ = bcrypt::verify( + &form.password, + "$2b$12$invalidinvalidinvalidinvalidinvalidinvalidinvalidinvalidii", + ); } warn!(slug = %form.slug, "login failed"); return render_login(Some("Login fehlgeschlagen")).map(IntoResponse::into_response); diff --git a/server/src/routes/mod.rs b/server/src/routes/mod.rs index 676ac99..d44f83d 100644 --- a/server/src/routes/mod.rs +++ b/server/src/routes/mod.rs @@ -8,8 +8,8 @@ mod upload; pub(crate) mod user_web; pub(crate) mod web; -use axum::routing::{get, post}; use axum::Router; +use axum::routing::{get, post}; use crate::AppState; @@ -19,7 +19,10 @@ pub fn api_router() -> Router { .route("/api/debug/whoami", get(debug::handle_whoami)) .route("/api/upload", post(upload::handle_upload)) .route("/api/oneliners", get(oneliners::handle_oneliners)) - .route("/web/login", get(login::handle_login_page).post(login::handle_login_submit)) + .route( + "/web/login", + get(login::handle_login_page).post(login::handle_login_submit), + ) .route("/web/logout", post(login::handle_logout)) .route("/web/cases", get(user_web::handle_my_cases)) .route("/web/cases/{case_id}", get(user_web::handle_case_detail)) diff --git a/server/src/routes/oneliners.rs b/server/src/routes/oneliners.rs index d278ab5..6a2e587 100644 --- a/server/src/routes/oneliners.rs +++ b/server/src/routes/oneliners.rs @@ -20,10 +20,10 @@ use std::path::{Path, PathBuf}; use std::time::SystemTime; -use axum::extract::Query; -use axum::http::{header, HeaderMap, StatusCode}; -use axum::response::{IntoResponse, Response}; use axum::Json; +use axum::extract::Query; +use axum::http::{HeaderMap, StatusCode, header}; +use axum::response::{IntoResponse, Response}; use doctate_common::oneliners::{OnelinerEntry, OnelinersResponse}; use serde::Deserialize; use time::format_description::well_known::Rfc3339; diff --git a/server/src/routes/upload.rs b/server/src/routes/upload.rs index 45df68f..e8159de 100644 --- a/server/src/routes/upload.rs +++ b/server/src/routes/upload.rs @@ -1,7 +1,7 @@ use std::path::{Path, PathBuf}; -use axum::extract::{Multipart, State}; use axum::Json; +use axum::extract::{Multipart, State}; use doctate_common::timestamp::recorded_at_to_filename_stem; use tokio::sync::mpsc::error::TrySendError; use tracing::{info, warn}; diff --git a/server/src/routes/user_web.rs b/server/src/routes/user_web.rs index 597088a..2f31d53 100644 --- a/server/src/routes/user_web.rs +++ b/server/src/routes/user_web.rs @@ -1,22 +1,22 @@ use std::path::Path; -use std::sync::atomic::Ordering; use std::sync::Arc; +use std::sync::atomic::Ordering; use askama::Template; use axum::extract::State; use axum::response::Html; use tracing::{info, warn}; -use time::{format_description::well_known::Rfc3339, Date, OffsetDateTime}; +use time::{Date, OffsetDateTime, format_description::well_known::Rfc3339}; -use crate::analyze::{recovery as analyze_recovery, ANALYSIS_INPUT_FILE, DOCUMENT_FILE}; +use crate::PipelineState; +use crate::analyze::{ANALYSIS_INPUT_FILE, DOCUMENT_FILE, recovery as analyze_recovery}; use crate::auth::AuthenticatedWebUser; use crate::case_id::{CaseId, CaseIdPath}; use crate::config::Config; use crate::error::AppError; -use crate::routes::web::{scan_recordings, RecordingView}; +use crate::routes::web::{RecordingView, scan_recordings}; use crate::transcribe::recovery as transcribe_recovery; -use crate::PipelineState; struct UserCaseView { case_id: String, @@ -366,9 +366,7 @@ async fn scan_user_cases(config: &Config, slug: &str, worker_busy: bool) -> Vec< /// a valid UUID. Returns the (case_id-string, absolute path) tuple so /// the caller does not need to recompute either. Non-UUID names are /// logged at WARN level; other rejections are silent (common case). -async fn filter_valid_case_dir( - entry: tokio::fs::DirEntry, -) -> Option<(String, std::path::PathBuf)> { +async fn filter_valid_case_dir(entry: tokio::fs::DirEntry) -> Option<(String, std::path::PathBuf)> { let case_id = entry.file_name().into_string().ok()?; if uuid::Uuid::parse_str(&case_id).is_err() { warn!(case_id = %case_id, "Skipping non-UUID directory"); diff --git a/server/src/routes/web.rs b/server/src/routes/web.rs index 5e4bfb5..cacd7f5 100644 --- a/server/src/routes/web.rs +++ b/server/src/routes/web.rs @@ -36,9 +36,7 @@ struct CasesTemplate { cases: Vec, } -pub async fn handle_case_list( - State(config): State>, -) -> Result, AppError> { +pub async fn handle_case_list(State(config): State>) -> Result, AppError> { let cases = scan_cases(&config.data_path).await; let template = CasesTemplate { cases }; template @@ -82,11 +80,7 @@ fn validate_user_slug(user: &str) -> Result<(), AppError> { fn validate_filename(filename: &str) -> Result<(), AppError> { let ok_suffix = filename.ends_with(".m4a") || filename.ends_with(".m4a.failed"); - if !ok_suffix - || filename.contains('/') - || filename.contains('\\') - || filename.contains("..") - { + if !ok_suffix || filename.contains('/') || filename.contains('\\') || filename.contains("..") { return Err(AppError::BadRequest("Invalid filename".into())); } Ok(()) diff --git a/server/src/transcribe/worker.rs b/server/src/transcribe/worker.rs index b768d2f..2c6717b 100644 --- a/server/src/transcribe/worker.rs +++ b/server/src/transcribe/worker.rs @@ -4,7 +4,7 @@ use std::time::Duration; use tracing::{error, info, warn}; -use super::{ffmpeg, ollama, whisper, TranscribeReceiver}; +use super::{TranscribeReceiver, ffmpeg, ollama, whisper}; use crate::config::{Config, WhisperUserSettings}; use crate::gazetteer::Gazetteer; use crate::{BusyGuard, WorkerBusy}; @@ -53,7 +53,15 @@ pub async fn run( } }; - let text = match whisper::transcribe(&client, &config.whisper_url, remuxed.path(), timeout, &settings).await { + let text = match whisper::transcribe( + &client, + &config.whisper_url, + remuxed.path(), + timeout, + &settings, + ) + .await + { Ok(t) => t, Err(e) => { error!(audio = %audio_path.display(), error = %e, "whisper call failed"); @@ -258,12 +266,9 @@ mod tests { #[tokio::test] async fn all_transcripts_joined_skips_empty_recordings() { let dir = tempdir().unwrap(); - tokio::fs::write( - dir.path().join("2026-04-16T10-00-00Z.transcript.txt"), - "", - ) - .await - .unwrap(); + tokio::fs::write(dir.path().join("2026-04-16T10-00-00Z.transcript.txt"), "") + .await + .unwrap(); tokio::fs::write( dir.path().join("2026-04-16T10-05-00Z.transcript.txt"), "non-empty content", diff --git a/server/tests/analyze_test.rs b/server/tests/analyze_test.rs index 82e36b1..7d4d9ae 100644 --- a/server/tests/analyze_test.rs +++ b/server/tests/analyze_test.rs @@ -4,10 +4,10 @@ use std::sync::Arc; use std::time::Duration; use axum::body::Body; -use axum::http::{header, Request, StatusCode}; +use axum::http::{Request, StatusCode, header}; use doctate_server::analyze; use doctate_server::config::{Config, User}; -use serde_json::{json, Value}; +use serde_json::{Value, json}; use tower::util::ServiceExt; use wiremock::matchers::{method, path}; use wiremock::{Mock, MockServer, ResponseTemplate}; @@ -165,7 +165,10 @@ async fn analyze_foreign_case_returns_404() { let app = doctate_server::create_router(config); let cookie = login(app.clone(), "dr_a").await; - let resp = app.oneshot(analyze_request(foreign_case, &cookie)).await.unwrap(); + let resp = app + .oneshot(analyze_request(foreign_case, &cookie)) + .await + .unwrap(); assert_eq!(resp.status(), StatusCode::NOT_FOUND); } @@ -175,7 +178,10 @@ async fn analyze_invalid_uuid_returns_400() { let app = doctate_server::create_router(config); let cookie = login(app.clone(), "dr_a").await; - let resp = app.oneshot(analyze_request("not-a-uuid", &cookie)).await.unwrap(); + let resp = app + .oneshot(analyze_request("not-a-uuid", &cookie)) + .await + .unwrap(); assert_eq!(resp.status(), StatusCode::BAD_REQUEST); } @@ -189,7 +195,10 @@ async fn analyze_case_without_recordings_returns_400() { let app = doctate_server::create_router(config); let cookie = login(app.clone(), "dr_a").await; - let resp = app.oneshot(analyze_request(case_id, &cookie)).await.unwrap(); + let resp = app + .oneshot(analyze_request(case_id, &cookie)) + .await + .unwrap(); assert_eq!(resp.status(), StatusCode::BAD_REQUEST); } @@ -204,7 +213,10 @@ async fn analyze_case_with_missing_transcript_returns_400() { let app = doctate_server::create_router(config); let cookie = login(app.clone(), "dr_a").await; - let resp = app.oneshot(analyze_request(case_id, &cookie)).await.unwrap(); + let resp = app + .oneshot(analyze_request(case_id, &cookie)) + .await + .unwrap(); assert_eq!(resp.status(), StatusCode::BAD_REQUEST); } @@ -218,7 +230,10 @@ async fn analyze_case_without_llm_returns_503() { let app = doctate_server::create_router(config); let cookie = login(app.clone(), "dr_a").await; - let resp = app.oneshot(analyze_request(case_id, &cookie)).await.unwrap(); + let resp = app + .oneshot(analyze_request(case_id, &cookie)) + .await + .unwrap(); assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE); } @@ -232,15 +247,26 @@ async fn analyze_case_happy_path_writes_input_and_redirects() { let case_id = "11111111-1111-1111-1111-111111111111"; let case_dir = seed_case(&config.data_path, "dr_a", case_id); seed_recording(&case_dir, "10-00-00", Some("Patient klagt über Knie.")); - seed_recording(&case_dir, "10-05-00", Some("Korrektur: links, nicht rechts.")); + seed_recording( + &case_dir, + "10-05-00", + Some("Korrektur: links, nicht rechts."), + ); let app = doctate_server::create_router(config); let cookie = login(app.clone(), "dr_a").await; - let resp = app.oneshot(analyze_request(case_id, &cookie)).await.unwrap(); + let resp = app + .oneshot(analyze_request(case_id, &cookie)) + .await + .unwrap(); assert_eq!(resp.status(), StatusCode::SEE_OTHER); assert_eq!( - resp.headers().get(header::LOCATION).unwrap().to_str().unwrap(), + resp.headers() + .get(header::LOCATION) + .unwrap() + .to_str() + .unwrap(), "/web/cases" ); @@ -267,10 +293,17 @@ async fn analyze_case_second_time_returns_409() { let app = doctate_server::create_router(config); let cookie = login(app.clone(), "dr_a").await; - let first = app.clone().oneshot(analyze_request(case_id, &cookie)).await.unwrap(); + let first = app + .clone() + .oneshot(analyze_request(case_id, &cookie)) + .await + .unwrap(); assert_eq!(first.status(), StatusCode::SEE_OTHER); - let second = app.oneshot(analyze_request(case_id, &cookie)).await.unwrap(); + let second = app + .oneshot(analyze_request(case_id, &cookie)) + .await + .unwrap(); assert_eq!(second.status(), StatusCode::CONFLICT); } @@ -286,7 +319,10 @@ async fn analyze_case_skips_blank_transcript_from_recordings() { let app = doctate_server::create_router(config); let cookie = login(app.clone(), "dr_a").await; - let resp = app.oneshot(analyze_request(case_id, &cookie)).await.unwrap(); + let resp = app + .oneshot(analyze_request(case_id, &cookie)) + .await + .unwrap(); assert_eq!(resp.status(), StatusCode::SEE_OTHER); let raw = std::fs::read_to_string(case_dir.join("analysis_input.json")).unwrap(); @@ -515,7 +551,10 @@ async fn recovery_skips_completed_analysis() { let (tx, mut rx) = analyze::channel(); analyze::recovery::scan_and_enqueue(&tmp, &tx).await; - assert!(rx.try_recv().is_err(), "completed analysis must not re-enqueue"); + assert!( + rx.try_recv().is_err(), + "completed analysis must not re-enqueue" + ); } // --------------------------------------------------------------------- @@ -543,7 +582,9 @@ async fn document_view_reads_document() { .await .unwrap(); assert_eq!(resp.status(), StatusCode::OK); - let body = axum::body::to_bytes(resp.into_body(), usize::MAX).await.unwrap(); + let body = axum::body::to_bytes(resp.into_body(), usize::MAX) + .await + .unwrap(); let body_str = String::from_utf8(body.to_vec()).unwrap(); assert!(body_str.contains("der Inhalt"), "expected content in body"); assert!(body_str.contains("Dokument")); @@ -565,7 +606,10 @@ async fn analyze_deletes_old_document_and_writes_fresh_input() { let app = doctate_server::create_router(config); let cookie = login(app.clone(), "dr_a").await; - let resp = app.oneshot(analyze_request(case_id, &cookie)).await.unwrap(); + let resp = app + .oneshot(analyze_request(case_id, &cookie)) + .await + .unwrap(); assert_eq!(resp.status(), StatusCode::SEE_OTHER); assert!( @@ -574,11 +618,11 @@ async fn analyze_deletes_old_document_and_writes_fresh_input() { ); let input_path = case_dir.join("analysis_input.json"); assert!(input_path.exists(), "analysis_input.json missing"); - let parsed: Value = serde_json::from_str(&std::fs::read_to_string(&input_path).unwrap()).unwrap(); + let parsed: Value = + serde_json::from_str(&std::fs::read_to_string(&input_path).unwrap()).unwrap(); assert_eq!(parsed["recordings"].as_array().unwrap().len(), 2); } - // --------------------------------------------------------------------- // Soft-delete + Undo // --------------------------------------------------------------------- @@ -596,7 +640,11 @@ async fn delete_writes_marker_and_redirects() { let resp = app.oneshot(delete_request(case_id, &cookie)).await.unwrap(); assert_eq!(resp.status(), StatusCode::SEE_OTHER); assert_eq!( - resp.headers().get(header::LOCATION).unwrap().to_str().unwrap(), + resp.headers() + .get(header::LOCATION) + .unwrap() + .to_str() + .unwrap(), "/web/cases" ); let marker = case_dir.join(".deleted"); @@ -604,7 +652,10 @@ async fn delete_writes_marker_and_redirects() { let raw = std::fs::read_to_string(&marker).unwrap(); let parsed: Value = serde_json::from_str(&raw).unwrap(); assert!(parsed["batch"].is_string(), "batch must be a UUID string"); - assert!(parsed["deleted_at"].is_string(), "deleted_at must be a string"); + assert!( + parsed["deleted_at"].is_string(), + "deleted_at must be a string" + ); } #[tokio::test] @@ -617,7 +668,11 @@ async fn deleted_case_returns_404_on_detail() { let app = doctate_server::create_router(config); let cookie = login(app.clone(), "dr_a").await; - let resp = app.clone().oneshot(delete_request(case_id, &cookie)).await.unwrap(); + let resp = app + .clone() + .oneshot(delete_request(case_id, &cookie)) + .await + .unwrap(); assert_eq!(resp.status(), StatusCode::SEE_OTHER); let resp = app @@ -647,12 +702,20 @@ async fn undo_delete_restores_latest_batch_only() { let cookie = login(app.clone(), "dr_a").await; // Two separate delete clicks → two distinct batches. - let _ = app.clone().oneshot(delete_request(case_a, &cookie)).await.unwrap(); + let _ = app + .clone() + .oneshot(delete_request(case_a, &cookie)) + .await + .unwrap(); // Batch IDs derive from `now_rfc3339()`, which rounds to whole // seconds — so the sleep must cross a second boundary for the two // deletes to land in distinct batches. tokio::time::sleep(Duration::from_millis(1100)).await; - let _ = app.clone().oneshot(delete_request(case_b, &cookie)).await.unwrap(); + let _ = app + .clone() + .oneshot(delete_request(case_b, &cookie)) + .await + .unwrap(); let undo = app .clone() @@ -669,7 +732,10 @@ async fn undo_delete_restores_latest_batch_only() { assert_eq!(undo.status(), StatusCode::SEE_OTHER); // case_b (latest delete) restored, case_a still deleted. - assert!(!dir_b.join(".deleted").exists(), "case_b marker should be gone"); + assert!( + !dir_b.join(".deleted").exists(), + "case_b marker should be gone" + ); assert!(dir_a.join(".deleted").exists(), "case_a marker must remain"); } @@ -701,9 +767,14 @@ async fn bulk_delete_shares_one_batch_uuid() { .unwrap(); assert_eq!(resp.status(), StatusCode::SEE_OTHER); - let m_a: Value = serde_json::from_str(&std::fs::read_to_string(dir_a.join(".deleted")).unwrap()).unwrap(); - let m_b: Value = serde_json::from_str(&std::fs::read_to_string(dir_b.join(".deleted")).unwrap()).unwrap(); - assert_eq!(m_a["batch"], m_b["batch"], "bulk-delete must share batch UUID"); + let m_a: Value = + serde_json::from_str(&std::fs::read_to_string(dir_a.join(".deleted")).unwrap()).unwrap(); + let m_b: Value = + serde_json::from_str(&std::fs::read_to_string(dir_b.join(".deleted")).unwrap()).unwrap(); + assert_eq!( + m_a["batch"], m_b["batch"], + "bulk-delete must share batch UUID" + ); } #[tokio::test] @@ -814,15 +885,9 @@ async fn reset_case_clears_derived_and_unfails_audio() { let app = doctate_server::create_router(config); let cookie = login(app.clone(), "dr_a").await; - let resp = app - .oneshot(reset_request(case_id, &cookie)) - .await - .unwrap(); + let resp = app.oneshot(reset_request(case_id, &cookie)).await.unwrap(); assert_eq!(resp.status(), StatusCode::SEE_OTHER); - assert_eq!( - resp.headers().get(header::LOCATION).unwrap(), - "/web/cases" - ); + assert_eq!(resp.headers().get(header::LOCATION).unwrap(), "/web/cases"); // Audio preserved, .failed renamed to .m4a. assert!(dir.join("2026-04-15T09-00-00Z.m4a").exists()); @@ -848,10 +913,7 @@ async fn reset_case_requires_admin() { let app = doctate_server::create_router(config); let cookie = login(app.clone(), "dr_a").await; - let resp = app - .oneshot(reset_request(case_id, &cookie)) - .await - .unwrap(); + let resp = app.oneshot(reset_request(case_id, &cookie)).await.unwrap(); assert_eq!(resp.status(), StatusCode::FORBIDDEN); // Nothing touched. diff --git a/server/tests/delete_watermark_test.rs b/server/tests/delete_watermark_test.rs index 55d9088..aa20dd5 100644 --- a/server/tests/delete_watermark_test.rs +++ b/server/tests/delete_watermark_test.rs @@ -10,7 +10,7 @@ use std::collections::HashMap; use std::sync::Arc; use axum::body::Body; -use axum::http::{header, Request, StatusCode}; +use axum::http::{Request, StatusCode, header}; use doctate_common::constants::API_KEY_HEADER; use doctate_common::oneliners::ONELINERS_PATH; use doctate_server::config::{Config, User}; @@ -32,8 +32,10 @@ fn test_config_with_users(users: Vec) -> Arc { std::process::id(), uuid::Uuid::new_v4() )); - let api_keys: HashMap = - users.iter().map(|u| (u.api_key.clone(), u.slug.clone())).collect(); + let api_keys: HashMap = users + .iter() + .map(|u| (u.api_key.clone(), u.slug.clone())) + .collect(); Arc::new(Config { data_path, users, @@ -112,7 +114,11 @@ async fn delete_bumps_watermark() { let app = doctate_server::create_router(config); - let login = app.clone().oneshot(login_request("dr_a", "s")).await.unwrap(); + let login = app + .clone() + .oneshot(login_request("dr_a", "s")) + .await + .unwrap(); let cookie = extract_session_cookie(&login).expect("login should set cookie"); // First GET: capture baseline ETag (watermark empty → seeded on 0). @@ -162,7 +168,11 @@ async fn undo_delete_bumps_watermark() { let app = doctate_server::create_router(config); - let login = app.clone().oneshot(login_request("dr_a", "s")).await.unwrap(); + let login = app + .clone() + .oneshot(login_request("dr_a", "s")) + .await + .unwrap(); let cookie = extract_session_cookie(&login).expect("login should set cookie"); // Delete first so undo has something to restore; grab the post-delete ETag. @@ -217,7 +227,11 @@ async fn bulk_delete_bumps_watermark() { let app = doctate_server::create_router(config); - let login = app.clone().oneshot(login_request("dr_a", "s")).await.unwrap(); + let login = app + .clone() + .oneshot(login_request("dr_a", "s")) + .await + .unwrap(); let cookie = extract_session_cookie(&login).expect("login should set cookie"); let (_, etag_before) = capture_etag(&app, "key-dr_a", None).await; @@ -232,10 +246,7 @@ async fn bulk_delete_bumps_watermark() { .method("POST") .uri("/web/cases/bulk") .header(header::COOKIE, &cookie) - .header( - header::CONTENT_TYPE, - "application/x-www-form-urlencoded", - ) + .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded") .body(Body::from(form)) .unwrap(), ) diff --git a/server/tests/login_test.rs b/server/tests/login_test.rs index 522b20e..635af5a 100644 --- a/server/tests/login_test.rs +++ b/server/tests/login_test.rs @@ -2,7 +2,7 @@ use std::collections::HashMap; use std::sync::Arc; use axum::body::Body; -use axum::http::{header, Request, StatusCode}; +use axum::http::{Request, StatusCode, header}; use tower::util::ServiceExt; use doctate_server::config::{Config, User}; @@ -23,8 +23,10 @@ fn test_config_with_users(users: Vec) -> Arc { std::process::id(), uuid::Uuid::new_v4() )); - let api_keys: HashMap = - users.iter().map(|u| (u.api_key.clone(), u.slug.clone())).collect(); + let api_keys: HashMap = users + .iter() + .map(|u| (u.api_key.clone(), u.slug.clone())) + .collect(); Arc::new(Config { data_path, users, @@ -69,14 +71,27 @@ async fn login_success_sets_session_cookie_and_redirects() { let app = doctate_server::create_router(config); let resp = app.oneshot(login_request("dr_a", "secret")).await.unwrap(); - assert_eq!(resp.status(), StatusCode::SEE_OTHER.as_u16(), "got {:?}", resp.status()); + assert_eq!( + resp.status(), + StatusCode::SEE_OTHER.as_u16(), + "got {:?}", + resp.status() + ); - let loc = resp.headers().get(header::LOCATION).unwrap().to_str().unwrap(); + let loc = resp + .headers() + .get(header::LOCATION) + .unwrap() + .to_str() + .unwrap(); assert_eq!(loc, "/web/cases"); let cookie = extract_session_cookie(&resp).expect("no session cookie"); assert!(cookie.starts_with("session=")); - assert!(cookie.len() > "session=".len() + 20, "token looks too short: {cookie}"); + assert!( + cookie.len() > "session=".len() + 20, + "token looks too short: {cookie}" + ); } #[tokio::test] @@ -96,7 +111,10 @@ async fn login_unknown_user_shows_error() { let config = test_config_with_users(vec![make_user("dr_a", "secret")]); let app = doctate_server::create_router(config); - let resp = app.oneshot(login_request("ghost", "anything")).await.unwrap(); + let resp = app + .oneshot(login_request("ghost", "anything")) + .await + .unwrap(); assert_eq!(resp.status(), StatusCode::OK); assert!(extract_session_cookie(&resp).is_none()); } @@ -107,25 +125,35 @@ async fn cases_without_cookie_redirects_to_login() { let app = doctate_server::create_router(config); let resp = app - .oneshot(Request::builder().uri("/web/cases").body(Body::empty()).unwrap()) + .oneshot( + Request::builder() + .uri("/web/cases") + .body(Body::empty()) + .unwrap(), + ) .await .unwrap(); assert_eq!(resp.status(), StatusCode::FOUND); assert_eq!( - resp.headers().get(header::LOCATION).unwrap().to_str().unwrap(), + resp.headers() + .get(header::LOCATION) + .unwrap() + .to_str() + .unwrap(), "/web/login" ); } #[tokio::test] async fn cases_with_valid_cookie_shows_only_own_cases() { - let config = test_config_with_users(vec![ - make_user("dr_a", "s"), - make_user("dr_b", "s"), - ]); + let config = test_config_with_users(vec![make_user("dr_a", "s"), make_user("dr_b", "s")]); // Seed fixtures: dr_a has an open case, dr_b has one too. - let case_a = config.data_path.join("dr_a/11111111-1111-1111-1111-111111111111"); - let case_b = config.data_path.join("dr_b/22222222-2222-2222-2222-222222222222"); + let case_a = config + .data_path + .join("dr_a/11111111-1111-1111-1111-111111111111"); + let case_b = config + .data_path + .join("dr_b/22222222-2222-2222-2222-222222222222"); std::fs::create_dir_all(&case_a).unwrap(); std::fs::create_dir_all(&case_b).unwrap(); std::fs::write(case_a.join("2026-04-14T10-00-00Z.m4a"), b"x").unwrap(); @@ -134,7 +162,11 @@ async fn cases_with_valid_cookie_shows_only_own_cases() { let app = doctate_server::create_router(config); // Log in as dr_a. - let login = app.clone().oneshot(login_request("dr_a", "s")).await.unwrap(); + let login = app + .clone() + .oneshot(login_request("dr_a", "s")) + .await + .unwrap(); let cookie = extract_session_cookie(&login).unwrap(); let resp = app @@ -155,17 +187,20 @@ async fn cases_with_valid_cookie_shows_only_own_cases() { #[tokio::test] async fn case_detail_of_foreign_case_returns_404() { - let config = test_config_with_users(vec![ - make_user("dr_a", "s"), - make_user("dr_b", "s"), - ]); - let case_b = config.data_path.join("dr_b/22222222-2222-2222-2222-222222222222"); + let config = test_config_with_users(vec![make_user("dr_a", "s"), make_user("dr_b", "s")]); + let case_b = config + .data_path + .join("dr_b/22222222-2222-2222-2222-222222222222"); std::fs::create_dir_all(&case_b).unwrap(); std::fs::write(case_b.join("2026-04-14T11-00-00Z.m4a"), b"x").unwrap(); let app = doctate_server::create_router(config); - let login = app.clone().oneshot(login_request("dr_a", "s")).await.unwrap(); + let login = app + .clone() + .oneshot(login_request("dr_a", "s")) + .await + .unwrap(); let cookie = extract_session_cookie(&login).unwrap(); let resp = app @@ -186,7 +221,11 @@ async fn logout_clears_session() { let config = test_config_with_users(vec![make_user("dr_a", "s")]); let app = doctate_server::create_router(config); - let login = app.clone().oneshot(login_request("dr_a", "s")).await.unwrap(); + let login = app + .clone() + .oneshot(login_request("dr_a", "s")) + .await + .unwrap(); let cookie = extract_session_cookie(&login).unwrap(); let logout = app diff --git a/server/tests/oneliners_api_test.rs b/server/tests/oneliners_api_test.rs index aa73995..88ff2d8 100644 --- a/server/tests/oneliners_api_test.rs +++ b/server/tests/oneliners_api_test.rs @@ -82,9 +82,7 @@ fn get_with_if_none_match(uri: &str, etag: &str) -> Request { .unwrap() } -async fn body_json( - response: axum::http::Response, -) -> serde_json::Value { +async fn body_json(response: axum::http::Response) -> serde_json::Value { let bytes = axum::body::to_bytes(response.into_body(), usize::MAX) .await .unwrap(); @@ -142,7 +140,13 @@ async fn empty_user_dir_returns_200_empty_list() { async fn recent_case_with_oneliner_shows_up() { let (config, dp) = test_config(); let case_id = "550e8400-e29b-41d4-a716-000000000001"; - seed_case(&dp, case_id, Duration::from_secs(60), Some("Kniegelenk re., V.a. Meniskus")).await; + seed_case( + &dp, + case_id, + Duration::from_secs(60), + Some("Kniegelenk re., V.a. Meniskus"), + ) + .await; let app = doctate_server::create_router(config); let response = app.oneshot(get("/api/oneliners")).await.unwrap(); @@ -161,7 +165,13 @@ async fn recent_case_with_oneliner_shows_up() { #[tokio::test] async fn case_without_oneliner_shows_null() { let (config, dp) = test_config(); - seed_case(&dp, "550e8400-e29b-41d4-a716-000000000002", Duration::from_secs(60), None).await; + seed_case( + &dp, + "550e8400-e29b-41d4-a716-000000000002", + Duration::from_secs(60), + None, + ) + .await; let app = doctate_server::create_router(config); let body = body_json(app.oneshot(get("/api/oneliners")).await.unwrap()).await; @@ -202,10 +212,7 @@ async fn case_outside_default_but_inside_custom_window_included() { .await; let app = doctate_server::create_router(config); - let body = body_json( - app.oneshot(get("/api/oneliners?hours=48")).await.unwrap(), - ) - .await; + let body = body_json(app.oneshot(get("/api/oneliners?hours=48")).await.unwrap()).await; assert_eq!(body["window_hours"], 48); assert_eq!(body["oneliners"].as_array().unwrap().len(), 1); @@ -218,7 +225,9 @@ async fn hours_clamped_to_max() { let app = doctate_server::create_router(config); let body = body_json( - app.oneshot(get("/api/oneliners?hours=999999")).await.unwrap(), + app.oneshot(get("/api/oneliners?hours=999999")) + .await + .unwrap(), ) .await; assert_eq!(body["window_hours"], 168); @@ -284,10 +293,7 @@ async fn etag_differs_between_different_hours() { .oneshot(get("/api/oneliners?hours=1")) .await .unwrap(); - let r16 = app - .oneshot(get("/api/oneliners?hours=16")) - .await - .unwrap(); + let r16 = app.oneshot(get("/api/oneliners?hours=16")).await.unwrap(); let etag1 = header_str(&r1, "etag"); let etag16 = header_str(&r16, "etag"); @@ -407,10 +413,7 @@ async fn sorts_by_last_recording_not_created() { .await; let app = doctate_server::create_router(config); - let body = body_json( - app.oneshot(get("/api/oneliners?hours=48")).await.unwrap(), - ) - .await; + let body = body_json(app.oneshot(get("/api/oneliners?hours=48")).await.unwrap()).await; let arr = body["oneliners"].as_array().unwrap(); assert_eq!(arr.len(), 2); assert_eq!(arr[0]["case_id"], case_a, "A must sort first"); diff --git a/server/tests/transcribe_test.rs b/server/tests/transcribe_test.rs index 03273a5..ddb6913 100644 --- a/server/tests/transcribe_test.rs +++ b/server/tests/transcribe_test.rs @@ -6,9 +6,9 @@ use std::sync::Arc; use doctate_server::config::{Config, User, WhisperUserSettings}; use doctate_server::transcribe; use doctate_server::transcribe::ffmpeg::remux_faststart; -use doctate_server::transcribe::ollama::{generate_oneliner, OllamaError}; +use doctate_server::transcribe::ollama::{OllamaError, generate_oneliner}; use doctate_server::transcribe::recovery::scan_and_enqueue; -use doctate_server::transcribe::whisper::{transcribe, WhisperError}; +use doctate_server::transcribe::whisper::{WhisperError, transcribe}; use serde_json::json; use wiremock::matchers::{body_partial_json, method, path, query_param}; use wiremock::{Mock, MockServer, ResponseTemplate}; @@ -24,9 +24,7 @@ async fn ffmpeg_remux_produces_valid_output() { let input = fixture("sample.m4a"); assert!(input.exists(), "fixture missing: {}", input.display()); - let output = remux_faststart(&input) - .await - .expect("remux failed"); + let output = remux_faststart(&input).await.expect("remux failed"); let meta = std::fs::metadata(output.path()).expect("output missing"); assert!(meta.len() > 0, "output file is empty"); @@ -45,7 +43,10 @@ async fn ffmpeg_remux_produces_valid_output() { .expect("ffprobe spawn failed"); assert!(probe.status.success(), "ffprobe failed"); let fmt = String::from_utf8_lossy(&probe.stdout); - assert!(fmt.contains("mp4") || fmt.contains("m4a"), "unexpected format: {fmt}"); + assert!( + fmt.contains("mp4") || fmt.contains("m4a"), + "unexpected format: {fmt}" + ); } #[tokio::test] @@ -134,7 +135,10 @@ async fn whisper_client_times_out() { .await .expect_err("expected timeout"); - assert!(matches!(err, WhisperError::Http(_)), "expected Http error, got {err:?}"); + assert!( + matches!(err, WhisperError::Http(_)), + "expected Http error, got {err:?}" + ); } #[tokio::test] @@ -170,8 +174,14 @@ async fn whisper_client_forwards_hotwords_and_prompt_when_set() { let received = server.received_requests().await.unwrap(); let body = String::from_utf8_lossy(&received[0].body); assert!(body.contains("name=\"hotwords\""), "hotwords part missing"); - assert!(body.contains("HOCM Valsalva"), "hotwords value missing: {body}"); - assert!(body.contains("name=\"initial_prompt\""), "initial_prompt part missing"); + assert!( + body.contains("HOCM Valsalva"), + "hotwords value missing: {body}" + ); + assert!( + body.contains("name=\"initial_prompt\""), + "initial_prompt part missing" + ); assert!(body.contains("Kardiologie"), "initial_prompt value missing"); } @@ -203,8 +213,14 @@ async fn whisper_client_omits_empty_optional_fields() { // Inspect the captured request to confirm absence of the optional parts. let received = server.received_requests().await.unwrap(); let body = String::from_utf8_lossy(&received[0].body); - assert!(!body.contains("name=\"hotwords\""), "hotwords part leaked: {body}"); - assert!(!body.contains("name=\"initial_prompt\""), "initial_prompt part leaked: {body}"); + assert!( + !body.contains("name=\"hotwords\""), + "hotwords part leaked: {body}" + ); + assert!( + !body.contains("name=\"initial_prompt\""), + "initial_prompt part leaked: {body}" + ); } #[tokio::test] @@ -363,15 +379,24 @@ async fn ollama_oneliner_parses_chat_response() { Mock::given(method("POST")) .and(path("/api/chat")) - .respond_with(ResponseTemplate::new(200).set_body_json(ok_chat_body("HOCM mit Septum-Hypertrophie"))) + .respond_with( + ResponseTemplate::new(200).set_body_json(ok_chat_body("HOCM mit Septum-Hypertrophie")), + ) .expect(1) .mount(&server) .await; let client = reqwest::Client::new(); - let out = generate_oneliner(&client, &server.uri(), MODEL, 0, "irrelevant", Duration::from_secs(5)) - .await - .expect("call failed"); + let out = generate_oneliner( + &client, + &server.uri(), + MODEL, + 0, + "irrelevant", + Duration::from_secs(5), + ) + .await + .expect("call failed"); assert_eq!(out, "HOCM mit Septum-Hypertrophie"); } @@ -381,14 +406,23 @@ async fn ollama_oneliner_normalizes_response() { let server = MockServer::start().await; Mock::given(method("POST")) .and(path("/api/chat")) - .respond_with(ResponseTemplate::new(200).set_body_json(ok_chat_body("\"Kniegelenk re., V.a. Meniskus.\"\nSonstiges"))) + .respond_with(ResponseTemplate::new(200).set_body_json(ok_chat_body( + "\"Kniegelenk re., V.a. Meniskus.\"\nSonstiges", + ))) .mount(&server) .await; let client = reqwest::Client::new(); - let out = generate_oneliner(&client, &server.uri(), MODEL, 0, "x", Duration::from_secs(5)) - .await - .unwrap(); + let out = generate_oneliner( + &client, + &server.uri(), + MODEL, + 0, + "x", + Duration::from_secs(5), + ) + .await + .unwrap(); assert_eq!(out, "Kniegelenk re., V.a. Meniskus"); } @@ -402,9 +436,16 @@ async fn ollama_oneliner_returns_status_on_500() { .await; let client = reqwest::Client::new(); - let err = generate_oneliner(&client, &server.uri(), MODEL, 0, "x", Duration::from_secs(5)) - .await - .expect_err("expected error"); + let err = generate_oneliner( + &client, + &server.uri(), + MODEL, + 0, + "x", + Duration::from_secs(5), + ) + .await + .expect_err("expected error"); match err { OllamaError::Status { status, body } => { assert_eq!(status, 500); @@ -430,9 +471,16 @@ async fn ollama_oneliner_sends_keep_alive_and_model() { .await; let client = reqwest::Client::new(); - let _ = generate_oneliner(&client, &server.uri(), MODEL, 0, "irrelevant", Duration::from_secs(5)) - .await - .expect("call failed"); + let _ = generate_oneliner( + &client, + &server.uri(), + MODEL, + 0, + "irrelevant", + Duration::from_secs(5), + ) + .await + .expect("call failed"); // Mock::expect(1) + server drop verifies the matcher hit exactly once. } @@ -446,8 +494,15 @@ async fn ollama_oneliner_parse_error_on_missing_content() { .await; let client = reqwest::Client::new(); - let err = generate_oneliner(&client, &server.uri(), MODEL, 0, "x", Duration::from_secs(5)) - .await - .expect_err("expected parse error"); + let err = generate_oneliner( + &client, + &server.uri(), + MODEL, + 0, + "x", + Duration::from_secs(5), + ) + .await + .expect_err("expected parse error"); assert!(matches!(err, OllamaError::Parse(_)), "got {err:?}"); } diff --git a/server/tests/upload_test.rs b/server/tests/upload_test.rs index a1d42e1..6545269 100644 --- a/server/tests/upload_test.rs +++ b/server/tests/upload_test.rs @@ -6,7 +6,7 @@ use axum::http::{Request, StatusCode}; use tower::util::ServiceExt; use doctate_server::config::{Config, User}; -use doctate_server::paths::{write_delete_marker, DeleteMarker, DELETE_MARKER}; +use doctate_server::paths::{DELETE_MARKER, DeleteMarker, write_delete_marker}; fn test_config() -> Arc { let data_path = std::env::temp_dir().join(format!( @@ -29,11 +29,7 @@ fn test_config() -> Arc { } /// Build a multipart body with the given fields. -fn multipart_body( - case_id: &str, - recorded_at: &str, - audio: &[u8], -) -> (String, Vec) { +fn multipart_body(case_id: &str, recorded_at: &str, audio: &[u8]) -> (String, Vec) { let boundary = "----testboundary"; let mut body = Vec::new(); @@ -417,7 +413,8 @@ async fn upload_resurrects_hard_deleted_case() { assert!(!case_dir.exists()); // Second upload with the same client-generated UUID. - let (boundary, body) = multipart_body(case_id, "2026-04-13T12:00:00Z", b"resurrected recording"); + let (boundary, body) = + multipart_body(case_id, "2026-04-13T12:00:00Z", b"resurrected recording"); let response = app .oneshot( Request::builder() @@ -442,4 +439,3 @@ async fn upload_resurrects_hard_deleted_case() { let _ = std::fs::remove_dir_all(&data_path); } - diff --git a/server/tests/web_test.rs b/server/tests/web_test.rs index 373f2d8..8f8989d 100644 --- a/server/tests/web_test.rs +++ b/server/tests/web_test.rs @@ -40,12 +40,7 @@ async fn web_index_empty() { let app = doctate_server::create_router(config); let response = app - .oneshot( - Request::builder() - .uri("/web/") - .body(Body::empty()) - .unwrap(), - ) + .oneshot(Request::builder().uri("/web/").body(Body::empty()).unwrap()) .await .unwrap(); @@ -65,19 +60,11 @@ async fn web_index_shows_cases() { let open_case = data_path.join("dr_test").join(case1); std::fs::create_dir_all(&open_case).unwrap(); - std::fs::write( - open_case.join("2026-04-13T10-30-00Z.m4a"), - b"fake audio 1", - ) - .unwrap(); + std::fs::write(open_case.join("2026-04-13T10-30-00Z.m4a"), b"fake audio 1").unwrap(); let done_case = data_path.join("dr_test").join(case2); std::fs::create_dir_all(&done_case).unwrap(); - std::fs::write( - done_case.join("2026-04-12T09-00-00Z.m4a"), - b"fake audio 2", - ) - .unwrap(); + std::fs::write(done_case.join("2026-04-12T09-00-00Z.m4a"), b"fake audio 2").unwrap(); std::fs::write( done_case.join("2026-04-12T09-00-00Z.transcript.txt"), "Herzkatheter ohne Befund.", @@ -87,12 +74,7 @@ async fn web_index_shows_cases() { let app = doctate_server::create_router(config); let response = app - .oneshot( - Request::builder() - .uri("/web/") - .body(Body::empty()) - .unwrap(), - ) + .oneshot(Request::builder().uri("/web/").body(Body::empty()).unwrap()) .await .unwrap();