refactor: rename to common/, clients/desktop, clients/wearos

Move three directories into their target topology:
- doctate-common/ -> common/
- client-desktop/ -> clients/desktop/
- watch/wearos/   -> clients/wearos/

Update doctate-common path-dependency in 3 Cargo.toml files
(server, clients/desktop, experiments) and the workspace
members list in the root Cargo.toml. Crate names unchanged
(doctate-server, doctate-common, doctate-desktop).

Workspace still in single-Cargo.lock form; isolation into
standalone workspaces follows in stage 3. All 508 tests pass,
experiments standalone-workspace also resolves the new path.
This commit is contained in:
2026-05-02 11:55:38 +02:00
parent 39cc666ca6
commit 0c66fc6010
124 changed files with 4 additions and 4 deletions
+13
View File
@@ -0,0 +1,13 @@
[package]
name = "doctate-common"
version.workspace = true
edition.workspace = true
description = "Shared types and helpers between doctate server and clients"
[dependencies]
serde = { workspace = true }
uuid = { workspace = true }
time = { workspace = true }
[dev-dependencies]
serde_json = { workspace = true }
+54
View File
@@ -0,0 +1,54 @@
use serde::{Deserialize, Serialize};
/// Server response to `POST /api/upload`. Shape is stable across all clients.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AckResponse {
pub case_id: String,
pub recorded_at: String,
pub status: AckStatus,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum AckStatus {
/// Recording accepted and persisted.
Received,
/// Case no longer exists server-side; client should delete its local copy.
Gone,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn status_serializes_lowercase() {
assert_eq!(
serde_json::to_string(&AckStatus::Received).unwrap(),
"\"received\""
);
assert_eq!(serde_json::to_string(&AckStatus::Gone).unwrap(), "\"gone\"");
}
#[test]
fn status_deserializes_lowercase() {
let got: AckStatus = serde_json::from_str("\"received\"").unwrap();
assert_eq!(got, AckStatus::Received);
let got: AckStatus = serde_json::from_str("\"gone\"").unwrap();
assert_eq!(got, AckStatus::Gone);
}
#[test]
fn response_roundtrip() {
let original = AckResponse {
case_id: "550e8400-e29b-41d4-a716-446655440000".into(),
recorded_at: "2026-04-15T10:32:00Z".into(),
status: AckStatus::Received,
};
let json = serde_json::to_string(&original).unwrap();
let decoded: AckResponse = serde_json::from_str(&json).unwrap();
assert_eq!(decoded.case_id, original.case_id);
assert_eq!(decoded.recorded_at, original.recorded_at);
assert_eq!(decoded.status, original.status);
}
}
+111
View File
@@ -0,0 +1,111 @@
//! Shared vocabulary for the admin bulk-action endpoint (`POST
//! /web/cases/bulk`).
//!
//! The wire shape is `application/x-www-form-urlencoded` with an
//! `action=<token>` field and one or more `case_id=<uuid>` repetitions.
//! Keeping the set of accepted tokens in a single enum lets the server
//! handler match exhaustively and lets tests build request bodies
//! without string literals that can silently drift from the handler's
//! accepted values.
use std::fmt;
use std::str::FromStr;
use serde::{Deserialize, Serialize};
/// Which operation a bulk POST applies to every selected case.
///
/// Serialized as lowercase `"close" | "analyze" | "reset"`. The server
/// handler [`BulkAction::from_str`]-parses the raw form value so an
/// unknown token produces a domain error ("Unbekannte Aktion") instead
/// of a serde/extractor error — preserving the HTTP contract across
/// refactors.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum BulkAction {
/// Mark each case closed (write `.closed` marker).
Close,
/// Queue each case for LLM analysis.
Analyze,
/// Drop derived artefacts (oneliner, document, analysis input) and
/// revert `.m4a.failed` to `.m4a` so transcription self-heal retries.
Reset,
}
impl BulkAction {
/// Lowercase on-the-wire token. Intended for test request bodies
/// and redirect targets; the server's own deserializer goes through
/// [`FromStr`] to preserve the exact error shape.
pub const fn as_str(self) -> &'static str {
match self {
BulkAction::Close => "close",
BulkAction::Analyze => "analyze",
BulkAction::Reset => "reset",
}
}
}
impl fmt::Display for BulkAction {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.as_str())
}
}
/// Returned by [`BulkAction::from_str`] on an unknown token. Carries
/// the offending input so the caller can log it without re-capturing.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct UnknownBulkAction(pub String);
impl fmt::Display for UnknownBulkAction {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "unknown bulk action: {}", self.0)
}
}
impl std::error::Error for UnknownBulkAction {}
impl FromStr for BulkAction {
type Err = UnknownBulkAction;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s {
"close" => Ok(BulkAction::Close),
"analyze" => Ok(BulkAction::Analyze),
"reset" => Ok(BulkAction::Reset),
other => Err(UnknownBulkAction(other.to_string())),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn round_trip_lowercase_tokens() {
for v in [BulkAction::Close, BulkAction::Analyze, BulkAction::Reset] {
assert_eq!(v, v.as_str().parse().unwrap());
}
}
#[test]
fn wire_tokens_are_stable() {
assert_eq!(BulkAction::Close.as_str(), "close");
assert_eq!(BulkAction::Analyze.as_str(), "analyze");
assert_eq!(BulkAction::Reset.as_str(), "reset");
}
#[test]
fn unknown_token_reports_input() {
let err = "foo".parse::<BulkAction>().unwrap_err();
assert_eq!(err.0, "foo");
}
#[test]
fn serde_matches_wire() {
let json = serde_json::to_string(&BulkAction::Analyze).unwrap();
assert_eq!(json, "\"analyze\"");
let back: BulkAction = serde_json::from_str("\"reset\"").unwrap();
assert_eq!(back, BulkAction::Reset);
}
}
+14
View File
@@ -0,0 +1,14 @@
/// HTTP header that carries the per-user API key on `/api/...` requests.
pub const API_KEY_HEADER: &str = "X-API-Key";
/// Server endpoint path for recording upload (relative to the server base URL).
pub const UPLOAD_PATH: &str = "/api/upload";
// -- Multipart field names for POST /api/upload --
pub const FIELD_CASE_ID: &str = "case_id";
pub const FIELD_RECORDED_AT: &str = "recorded_at";
pub const FIELD_AUDIO: &str = "audio";
/// Content-Type of the audio part in the upload multipart body (m4a/AAC).
pub const CONTENT_TYPE_AUDIO_MP4: &str = "audio/mp4";
+18
View File
@@ -0,0 +1,18 @@
pub mod ack;
pub mod bulk;
pub mod constants;
pub mod oneliners;
pub mod recordings;
pub mod timestamp;
pub mod url;
pub use ack::{AckResponse, AckStatus};
pub use bulk::{BulkAction, UnknownBulkAction};
pub use constants::{
API_KEY_HEADER, CONTENT_TYPE_AUDIO_MP4, FIELD_AUDIO, FIELD_CASE_ID, FIELD_RECORDED_AT,
UPLOAD_PATH,
};
pub use oneliners::{ONELINER_FILENAME, ONELINERS_PATH, OnelinerEntry, OnelinersResponse};
pub use recordings::{Loudness, RECORDING_META_SUFFIX, RecordingMeta, Transcript, TranscriptState};
pub use timestamp::{filename_stem_to_recorded_at, now_rfc3339, recorded_at_to_filename_stem};
pub use url::join_url;
+137
View File
@@ -0,0 +1,137 @@
//! Shared DTOs for the `/api/oneliners` endpoint.
//!
//! Server serializes [`OnelinersResponse`]; clients deserialize the same
//! type. Keeping this in a client-agnostic crate avoids drift between
//! server output and client parsing.
use serde::{Deserialize, Serialize};
/// HTTP path of the oneliners endpoint.
pub const ONELINERS_PATH: &str = "/api/oneliners";
/// Filename used by the server to persist [`OnelinerState`] per case
/// (inside the case directory). Shared so both writer and readers
/// agree on the exact path.
pub const ONELINER_FILENAME: &str = "oneliner.json";
/// Outcome of the LLM oneliner generation for a single case.
///
/// Three disjoint outcomes used to collapse onto the same filesystem
/// representation (missing file). Making them first-class lets the UI
/// distinguish "LLM said nothing medical" (valid empty result) from
/// "LLM call errored" (transient failure worth retrying) from "file
/// simply isn't there yet" (no state persisted).
///
/// Serialized as internally-tagged JSON: `{"kind":"ready","text":...}`.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(tag = "kind", rename_all = "lowercase")]
pub enum OnelinerState {
/// LLM produced a usable one-sentence summary.
Ready {
text: String,
/// RFC3339 UTC timestamp of the LLM call that produced this
/// text. Diagnostic only — the file mtime drives ETag/sort.
generated_at: String,
},
/// LLM deliberately returned an empty answer — the transcript
/// contained no medical content. This is a valid outcome, not a
/// failure, and recovery must not retry it.
Empty { generated_at: String },
/// LLM call failed (timeout, HTTP, parse, …). Details stay in the
/// server logs on purpose; the UI surfaces only "error", and
/// startup recovery retries this variant.
Error { generated_at: String },
/// Manual override set by the doctor via tap-to-edit. Sticky:
/// blocks all auto-regeneration until a new manual edit replaces
/// it. `set_at` is server-side RFC3339 UTC (avoids client clock skew).
Manual { text: String, set_at: String },
}
/// Full response body of `GET /api/oneliners`.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OnelinersResponse {
/// RFC3339 UTC timestamp at which the server built this response.
pub as_of: String,
/// Server-authoritative window the response covers, in hours.
/// Derived from the user's `window_hours` entry in `users.toml`.
/// **Clients must not use this value to apply a display filter** —
/// the oneliners list is already exactly what the server wants shown.
/// The field exists solely so reconciliation logic can anchor its
/// "outside the window" rule (see
/// `doctate-client-core::CaseStore::reconcile_with_server_snapshot`).
pub window_hours: u32,
/// Oneliners of cases whose earliest recording sits inside the window,
/// sorted newest-first by `created_at`.
pub oneliners: Vec<OnelinerEntry>,
}
/// One case summary.
///
/// - `created_at` — earliest `.m4a` mtime in the case (first dictation
/// in this case).
/// - `last_recording_at` — latest `.m4a` mtime (most recent dictation,
/// including addenda). This is the **primary sort key** for clients:
/// "when did the doctor last work on this case?". Absent only if the
/// case has no recordings at all (shouldn't happen in normal flow).
/// - `updated_at` — `oneliner.json` mtime, i.e. when the oneliner state
/// was last written. Diagnostic only — not for sort or display.
/// - `oneliner` — the persisted state; `None` while no state file has
/// been written yet for this case (fresh case, nothing transcribed).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OnelinerEntry {
pub case_id: String,
pub oneliner: Option<OnelinerState>,
pub created_at: String,
pub last_recording_at: Option<String>,
pub updated_at: Option<String>,
}
/// HTTP path template for setting a manual oneliner override.
/// `{case_id}` is replaced by the actual case id by routers and clients;
/// shared so both sides agree on the exact URL shape.
pub const ONELINER_OVERRIDE_PATH_TEMPLATE: &str = "/api/cases/{case_id}/oneliner";
/// Request body for `PUT /api/cases/{case_id}/oneliner`.
///
/// Only `text` travels over the wire — `set_at` is set server-side from
/// the server clock, so clients with skewed/unset clocks (watch on
/// battery save, etc.) cannot poison the timestamp.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OnelinerOverrideRequest {
pub text: String,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn manual_variant_roundtrips() {
let original = OnelinerState::Manual {
text: "55 J., Knieschmerz links".to_owned(),
set_at: "2026-04-26T18:33:00Z".to_owned(),
};
let json = serde_json::to_string(&original).expect("serialize");
let parsed: OnelinerState = serde_json::from_str(&json).expect("deserialize");
assert_eq!(original, parsed);
}
#[test]
fn manual_serializes_with_kind_tag() {
let state = OnelinerState::Manual {
text: "abc".to_owned(),
set_at: "2026-04-26T18:33:00Z".to_owned(),
};
let json = serde_json::to_value(&state).expect("serialize");
assert_eq!(json["kind"], "manual");
assert_eq!(json["text"], "abc");
assert_eq!(json["set_at"], "2026-04-26T18:33:00Z");
}
#[test]
fn override_request_deserializes_minimal_body() {
let body = r#"{"text":"hello"}"#;
let req: OnelinerOverrideRequest = serde_json::from_str(body).expect("deserialize");
assert_eq!(req.text, "hello");
}
}
+288
View File
@@ -0,0 +1,288 @@
//! Recording-side domain types shared between server and clients.
//!
//! Each recording has a single sidecar JSON next to its audio file:
//! `<stem>.json` (suffix [`RECORDING_META_SUFFIX`]). It carries the
//! container duration and the transcript outcome in one structured
//! document.
//!
//! On-disk vs. in-memory representation:
//!
//! - [`Transcript`] is the **on-disk** shape. It only models the two
//! *terminal* outcomes the transcriber can persist (`Silent` or
//! `Content`). A recording that has not been transcribed yet has no
//! sidecar at all — there is no "pending" variant on disk.
//! - [`TranscriptState`] is the **in-memory** shape returned by the
//! server's reader. It adds a [`TranscriptState::Pending`] variant
//! for "no sidecar exists", so callers can `match` on the three
//! logically distinct states without having to inspect the
//! filesystem themselves.
//!
//! Call-sites that collapse this onto a 2-state view (`Option<String>`,
//! `.exists()`, `is_empty()`) drift apart and have caused real bugs:
//! e.g. the UI treating `Silent` like `Content` while the oneliner
//! worker treated it like `Pending`, leaving cases stuck in
//! "Generating" forever.
use serde::{Deserialize, Serialize};
/// Filename suffix of the per-recording metadata JSON sidecar.
///
/// Used as `<audio_stem>.<RECORDING_META_SUFFIX>` next to the `.m4a`,
/// e.g. `2026-04-19T10-00-00Z.json`. Centralised here so the server
/// reader, worker writer, and recovery scan agree on one location.
pub const RECORDING_META_SUFFIX: &str = "json";
/// On-disk transcript outcome.
///
/// Internally tagged: serializes as `{"state": "silent"}` or
/// `{"state": "content", "text": "..."}` — the discriminator lives
/// inside the JSON object next to the data, so the wire format stays
/// flat. Only the two terminal variants the transcriber can produce
/// appear here; "not transcribed yet" is represented by the absence
/// of the sidecar file (see [`TranscriptState::Pending`]).
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(tag = "state", rename_all = "lowercase")]
pub enum Transcript {
/// Transcriber ran and classified the audio as silence — terminal.
/// The next transcribe pass will not re-run unless the user
/// re-uploads.
Silent,
/// Transcriber produced non-empty text.
Content { text: String },
}
/// Replay-gain data for one recording, derived from a single
/// `volumedetect` pass at the end of the transcribe pipeline.
///
/// `gain_db` is applied browser-side via a Web Audio `GainNode` so
/// recordings from clients with very different mic levels (Watch
/// ~-38 dB mean, desktop ~-27 dB) play back at a consistent loudness;
/// the audio file itself is never modified. `mean_db` and `max_db` are
/// persisted alongside so the gain target can be retuned later without
/// re-decoding the audio.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct Loudness {
pub mean_db: f64,
pub max_db: f64,
pub gain_db: f64,
}
/// Per-recording metadata persisted as `<stem>.json`.
///
/// Single atomic write at the end of the transcribe pipeline. The
/// presence of this file is the canonical signal "the transcriber
/// finished for this recording". Absent file = still pending.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct RecordingMeta {
/// Transcriber outcome. See [`Transcript`].
pub transcript: Transcript,
/// Container duration in whole seconds, from `ffprobe` on the
/// remuxed audio. `None` is allowed so test fixtures and recovery
/// flows that mint a metadata file without invoking ffprobe stay
/// valid; production worker writes always populate this.
pub duration_seconds: Option<u32>,
/// Replay-gain data. `None` for sidecars written before this field
/// existed and for production paths where `volumedetect` failed.
/// `#[serde(default)]` keeps older JSON without this field parsable.
#[serde(default)]
pub loudness: Option<Loudness>,
}
/// Three-way state of a recording's transcript, as seen by the server
/// after consulting the on-disk sidecar.
///
/// Introduced to force every call-site to decide explicitly how it
/// treats `Silent` recordings via `match` exhaustiveness. Unlike
/// [`Transcript`], this carries a [`Self::Pending`] variant — the
/// reader synthesises that when the sidecar JSON does not exist.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum TranscriptState {
/// No sidecar JSON yet — transcriber hasn't produced a result.
/// Callers should typically wait or enqueue.
Pending,
/// JSON exists with `state: "silent"`. Terminal: the transcriber
/// already ran and classified this recording as silence.
Silent,
/// JSON exists with `state: "content"`.
Content { text: String },
}
impl From<Transcript> for TranscriptState {
fn from(t: Transcript) -> Self {
match t {
Transcript::Silent => Self::Silent,
Transcript::Content { text } => Self::Content { text },
}
}
}
impl TranscriptState {
/// Build a `TranscriptState` from a parsed [`RecordingMeta`] (or its
/// absence). `None` (file missing or malformed) becomes
/// [`Self::Pending`].
pub fn from_meta(meta: Option<&RecordingMeta>) -> Self {
match meta {
None => Self::Pending,
Some(m) => m.transcript.clone().into(),
}
}
/// True when the transcriber has finished for this recording,
/// regardless of whether it produced content or silence. Use this
/// for UI "is the transcription done?" checks — `Silent` counts.
pub fn has_file(&self) -> bool {
!matches!(self, Self::Pending)
}
/// True when there is no sidecar yet and the transcriber still
/// owes us a result. Use this for "should I enqueue transcription?"
/// or "is a oneliner worth deferring?" gates.
pub fn is_pending(&self) -> bool {
matches!(self, Self::Pending)
}
/// Non-empty transcript text, if any. Returns `None` for both
/// `Pending` and `Silent` — callers that need to distinguish must
/// `match` directly.
pub fn as_content(&self) -> Option<&str> {
match self {
Self::Content { text } => Some(text.as_str()),
_ => None,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn missing_meta_is_pending() {
assert_eq!(TranscriptState::from_meta(None), TranscriptState::Pending);
}
#[test]
fn silent_meta_maps_to_silent_state() {
let meta = RecordingMeta {
transcript: Transcript::Silent,
duration_seconds: Some(7),
loudness: None,
};
assert_eq!(
TranscriptState::from_meta(Some(&meta)),
TranscriptState::Silent
);
}
#[test]
fn content_meta_maps_to_content_state() {
let meta = RecordingMeta {
transcript: Transcript::Content {
text: "Hallo".into(),
},
duration_seconds: None,
loudness: None,
};
assert_eq!(
TranscriptState::from_meta(Some(&meta)),
TranscriptState::Content {
text: "Hallo".into()
}
);
}
#[test]
fn has_file_distinguishes_pending() {
assert!(!TranscriptState::Pending.has_file());
assert!(TranscriptState::Silent.has_file());
assert!(TranscriptState::Content { text: "x".into() }.has_file());
}
#[test]
fn as_content_only_for_content() {
assert_eq!(TranscriptState::Pending.as_content(), None);
assert_eq!(TranscriptState::Silent.as_content(), None);
assert_eq!(
TranscriptState::Content { text: "x".into() }.as_content(),
Some("x")
);
}
#[test]
fn transcript_silent_serializes_with_tag() {
let json = serde_json::to_string(&Transcript::Silent).unwrap();
assert_eq!(json, r#"{"state":"silent"}"#);
}
#[test]
fn transcript_content_serializes_with_tag_and_text() {
let json = serde_json::to_string(&Transcript::Content {
text: "Hallo".into(),
})
.unwrap();
assert_eq!(json, r#"{"state":"content","text":"Hallo"}"#);
}
#[test]
fn recording_meta_roundtrip_with_duration() {
let meta = RecordingMeta {
transcript: Transcript::Content {
text: "Befund.".into(),
},
duration_seconds: Some(42),
loudness: None,
};
let json = serde_json::to_string(&meta).unwrap();
let back: RecordingMeta = serde_json::from_str(&json).unwrap();
assert_eq!(back, meta);
}
#[test]
fn recording_meta_roundtrip_without_duration() {
let meta = RecordingMeta {
transcript: Transcript::Silent,
duration_seconds: None,
loudness: None,
};
let json = serde_json::to_string(&meta).unwrap();
assert!(json.contains(r#""duration_seconds":null"#));
let back: RecordingMeta = serde_json::from_str(&json).unwrap();
assert_eq!(back, meta);
}
#[test]
fn recording_meta_roundtrip_with_loudness() {
let meta = RecordingMeta {
transcript: Transcript::Content {
text: "Befund.".into(),
},
duration_seconds: Some(42),
loudness: Some(Loudness {
mean_db: -27.3,
max_db: -3.1,
gain_db: 2.1,
}),
};
let json = serde_json::to_string(&meta).unwrap();
let back: RecordingMeta = serde_json::from_str(&json).unwrap();
assert_eq!(back, meta);
}
/// Backwards-compat: a sidecar written before `loudness` existed
/// must still parse, with the field defaulting to `None`. This is
/// what `#[serde(default)]` on the field guarantees and is the only
/// reason we don't need a migration step for pre-loudness recordings.
#[test]
fn old_meta_without_loudness_parses_as_none() {
let json = r#"{"transcript":{"state":"silent"},"duration_seconds":7}"#;
let meta: RecordingMeta = serde_json::from_str(json).unwrap();
assert_eq!(
meta,
RecordingMeta {
transcript: Transcript::Silent,
duration_seconds: Some(7),
loudness: None,
}
);
}
}
+146
View File
@@ -0,0 +1,146 @@
use time::format_description::well_known::Rfc3339;
use time::{OffsetDateTime, UtcOffset};
/// Current UTC time as an RFC3339 string with second precision, e.g.
/// `2026-04-15T10:32:00Z`. Subsecond fractions are deliberately stripped
/// so every caller produces the same filename shape — the server's
/// filename parser expects exactly `YYYY-MM-DDTHH-MM-SSZ` after the
/// `:` → `-` substitution, and dictation events don't need finer
/// resolution than one second.
pub fn now_rfc3339() -> String {
OffsetDateTime::now_utc()
.replace_nanosecond(0)
.expect("0 is always a valid nanosecond value")
.format(&Rfc3339)
.expect("RFC3339 formatting of OffsetDateTime is infallible")
}
/// Map an RFC3339 timestamp to a filesystem-safe stem by replacing every
/// `:` with `-`. Intentionally naive — RFC3339 has no colons outside the
/// time/offset portion, so the result is bijective against
/// `filename_stem_to_recorded_at`.
///
/// Example: `"2026-04-15T10:32:00Z"` → `"2026-04-15T10-32-00Z"`.
pub fn recorded_at_to_filename_stem(recorded_at: &str) -> String {
recorded_at.replace(':', "-")
}
/// Reverse of `recorded_at_to_filename_stem`. Only undoes the mapping in
/// the time portion (after `T`) so date-part hyphens stay intact.
///
/// Example: `"2026-04-15T10-32-00Z"` → `"2026-04-15T10:32:00Z"`.
pub fn filename_stem_to_recorded_at(stem: &str) -> String {
match stem.split_once('T') {
Some((date, time)) => format!("{}T{}", date, time.replace('-', ":")),
None => stem.to_string(),
}
}
/// Parse an RFC3339 timestamp. Returns `None` on any error so callers
/// can fall back to a lenient display path without having to classify
/// failures.
pub fn parse_rfc3339(s: &str) -> Option<OffsetDateTime> {
OffsetDateTime::parse(s, &Rfc3339).ok()
}
/// Extract `HH:MM` in the local timezone from an RFC3339 UTC timestamp.
/// On parse failure, returns the raw string truncated to its first 16
/// characters — the user still sees something readable even if the
/// value is shaped unexpectedly.
pub fn extract_hhmm(ts: &str) -> String {
match parse_rfc3339(ts) {
Some(t) => {
let local = t.to_offset(UtcOffset::current_local_offset().unwrap_or(UtcOffset::UTC));
format!("{:02}:{:02}", local.hour(), local.minute())
}
None => ts.chars().take(16).collect(),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn filename_stem_roundtrip() {
assert_eq!(
filename_stem_to_recorded_at("2026-04-15T10-32-00Z"),
"2026-04-15T10:32:00Z"
);
assert_eq!(
filename_stem_to_recorded_at("2026-04-15T10-32-00.123Z"),
"2026-04-15T10:32:00.123Z"
);
}
#[test]
fn recorded_at_stem_roundtrip_utc() {
let original = "2026-04-15T10:32:00Z";
let stem = recorded_at_to_filename_stem(original);
assert_eq!(stem, "2026-04-15T10-32-00Z");
assert_eq!(filename_stem_to_recorded_at(&stem), original);
}
#[test]
fn recorded_at_stem_roundtrip_with_milliseconds() {
let original = "2026-04-15T10:32:00.123Z";
let stem = recorded_at_to_filename_stem(original);
assert_eq!(filename_stem_to_recorded_at(&stem), original);
}
#[test]
fn now_rfc3339_has_expected_shape() {
let now = now_rfc3339();
assert!(now.starts_with("20"), "unexpected year prefix: {now}");
assert!(now.contains('T'), "missing T separator: {now}");
}
/// Regression guard: subsecond precision must be stripped. A single
/// nanosecond leak would feed the server filename parser
/// `YYYY-MM-DDTHH-MM-SS.nnnnnnnnnZ.m4a`, which it treats as
/// malformed — the resulting `<time datetime>` is empty, breaking
/// browser-side TZ rendering.
#[test]
fn now_rfc3339_has_no_subsecond_component() {
let now = now_rfc3339();
assert!(
!now.contains('.'),
"now_rfc3339 must be second-granular, got {now}"
);
assert!(now.ends_with('Z'), "expected UTC 'Z' suffix, got {now}");
}
#[test]
fn parse_rfc3339_accepts_utc_z() {
let t = parse_rfc3339("2026-04-18T10:32:00Z").expect("parse UTC");
assert_eq!(t.hour(), 10);
assert_eq!(t.minute(), 32);
}
#[test]
fn parse_rfc3339_returns_none_on_garbage() {
assert!(parse_rfc3339("not a timestamp").is_none());
}
/// `extract_hhmm` depends on the host timezone for formatting, so we
/// can't assert an exact value — but we can assert the shape and
/// that the fallback kicks in on unparseable input.
#[test]
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}"
);
}
#[test]
fn extract_hhmm_falls_back_to_truncated_raw_on_parse_failure() {
assert_eq!(extract_hhmm("not-a-timestamp"), "not-a-timestamp");
assert_eq!(
extract_hhmm("way-longer-than-sixteen-chars"),
"way-longer-than-"
);
}
}
+55
View File
@@ -0,0 +1,55 @@
//! Small URL-assembly helpers shared by every HTTP caller in the
//! workspace.
//!
//! All clients (desktop, server-internal, server-to-Ollama/Whisper)
//! end up building `{base_url}{path}` where `base_url` comes from
//! config and may or may not carry a trailing slash. The raw
//! `format!("{}{}", base.trim_end_matches('/'), path)` pattern was
//! duplicated in seven call sites; centralizing it here keeps any
//! future rule (enforce a leading slash on the path, tolerate
//! `http://host/` with a path segment, normalize percent-encoding)
//! in one place.
/// Join a base URL with an absolute path, normalizing any trailing
/// slash on the base. The path is expected to start with `/`; no
/// validation is performed — callers already pass constants like
/// [`crate::UPLOAD_PATH`].
///
/// Examples:
/// - `join_url("http://h", "/a")` → `"http://h/a"`
/// - `join_url("http://h/", "/a")` → `"http://h/a"`
/// - `join_url("http://h//", "/a")` → `"http://h/a"`
pub fn join_url(base: &str, path: &str) -> String {
let mut out = String::with_capacity(base.len() + path.len());
out.push_str(base.trim_end_matches('/'));
out.push_str(path);
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn no_trailing_slash_on_base() {
assert_eq!(join_url("http://h", "/a"), "http://h/a");
}
#[test]
fn single_trailing_slash_on_base() {
assert_eq!(join_url("http://h/", "/a"), "http://h/a");
}
#[test]
fn multiple_trailing_slashes_on_base() {
assert_eq!(join_url("http://h///", "/a"), "http://h/a");
}
#[test]
fn nested_path() {
assert_eq!(
join_url("https://example.com/", "/api/upload"),
"https://example.com/api/upload"
);
}
}