Files
doctate/common/src/timestamp.rs
T
Brummel 0c66fc6010 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.
2026-05-02 11:55:38 +02:00

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-"
);
}
}