refactor: consolidate bulk actions, URL assembly, and case-artefact filenames
Pulls three pattern groups into shared helpers so a rename or wire-format tweak edits one file instead of 10+: - BulkAction enum in doctate-common replaces the "close"/"analyze"/"reset" string literals that were duplicated between the bulk handler and 3 attack/CSRF test files. FromStr preserves the exact "Unbekannte Aktion" error shape; the handler match is now exhaustive over the enum. - join_url helper in doctate-common absorbs 7 identical trim_end_matches+format! call sites across client-core, client-desktop, server/transcribe (ollama, whisper), and server/analyze (llm). - server/tests/common/artefacts.rs re-exports ONELINER_FILENAME (doctate-common), DOCUMENT_FILE + ANALYSIS_INPUT_FILE (doctate-server::analyze), and CLOSE_MARKER (doctate-server::paths) so test files reference the canonical name instead of inlining literals. Also lifts client-desktop local duplication: - paths.rs: project_path helper collapses 4 identical ProjectDirs chains - main.rs: or_die helper replaces 4 eprintln!+exit(1) blocks - app.rs: named RecordingContext struct replaces (Uuid, String) tuple at 3 sites around the ffmpeg-flush finalization path Verification: 397 tests pass (baseline was 389; +8 for new unit tests on BulkAction and join_url), 0 failed, 4 ignored (unchanged). clippy clean.
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -1,14 +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::{ONELINERS_PATH, OnelinerEntry, OnelinersResponse};
|
||||
pub use oneliners::{ONELINER_FILENAME, ONELINERS_PATH, OnelinerEntry, OnelinersResponse};
|
||||
pub use recordings::TranscriptState;
|
||||
pub use timestamp::{filename_stem_to_recorded_at, now_rfc3339, recorded_at_to_filename_stem};
|
||||
pub use url::join_url;
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user