0c66fc6010
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.
147 lines
5.1 KiB
Rust
147 lines
5.1 KiB
Rust
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-"
|
|
);
|
|
}
|
|
}
|