Skip to main content

khive_runtime/
presentation.rs

1//! Verb response presentation modes and transformation.
2//!
3//! Transforms canonical handler output into caller-appropriate form after dispatch
4//! and before wire serialization. `Agent` mode abbreviates UUIDs/timestamps and drops
5//! empty fields; `Verbose` and `Human` pass through canonical JSON unchanged.
6//!
7//! This module also contains the `OutputFormat` axis (ADR-078) which governs how
8//! the resulting `serde_json::Value` is serialized or rendered to an output string.
9//! `PresentationMode` and `OutputFormat` compose independently.
10
11use std::collections::HashSet;
12
13use serde::{Deserialize, Serialize};
14use serde_json::{Map, Value};
15
16// ── OutputFormat ─────────────────────────────────────────────────────────────
17
18/// Output serialization format for verb results (ADR-078).
19///
20/// Orthogonal to [`PresentationMode`]: `PresentationMode` controls field-level
21/// transforms (UUID shortening, timestamp compaction, empty-field dropping);
22/// `OutputFormat` controls how the resulting `serde_json::Value` is serialized
23/// or rendered to the wire string.
24///
25/// Default is [`OutputFormat::Json`] on every surface: compact, lossless,
26/// shape-stable machine contract.
27///
28/// Note: `Yaml` is a clean follow-up — implemented as a 3-variant enum
29/// (`Json`, `Auto`, `Table`) per ADR-078 §"yaml" which permits omission when
30/// the in-tree emitter would balloon.
31#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
32#[serde(rename_all = "snake_case")]
33pub enum OutputFormat {
34    /// Compact JSON (`serde_json::to_string`). Lossless machine contract. Default.
35    #[default]
36    Json,
37    /// Shape-aware: markdown table for homogeneous record arrays,
38    /// flat key-value block for single records, compact-JSON fallback.
39    Auto,
40    /// Force the markdown-table renderer regardless of detected shape.
41    Table,
42}
43
44/// Cell truncation limit for markdown-table rendering (ADR-078 §3a).
45const CELL_TRUNCATE: usize = 120;
46
47// ── Public render entry point ────────────────────────────────────────────────
48
49/// Render a successful verb result value to a wire string using the given format.
50///
51/// Called at the single serialization seam (ADR-078 §9) AFTER all `$prev` chain
52/// resolution and AFTER the [`PresentationMode`] transform.
53///
54/// Error envelopes (`ok=false`) are never passed here — the caller must handle
55/// them as compact JSON directly (ADR-078 §8.2).
56///
57/// When `format` is [`OutputFormat::Json`], returns compact JSON (`serde_json::to_string`).
58/// When `format` is [`OutputFormat::Auto`] or [`OutputFormat::Table`], applies the
59/// redundancy-reduction pre-pass (§7) — unless `presentation` is [`PresentationMode::Verbose`]
60/// — then dispatches to the shape-aware renderer.
61pub fn render_format(value: Value, format: OutputFormat, presentation: PresentationMode) -> String {
62    match format {
63        OutputFormat::Json => serde_json::to_string(&value).unwrap_or_else(|_| "null".to_string()),
64        OutputFormat::Auto | OutputFormat::Table => {
65            // Skip the redundancy-reduction pre-pass in Verbose mode: full
66            // canonical shape must pass through unchanged.
67            let reduced = if presentation == PresentationMode::Verbose {
68                value
69            } else {
70                apply_redundancy_drop(value)
71            };
72            match format {
73                OutputFormat::Auto => render_auto(reduced),
74                OutputFormat::Table => render_table_forced(reduced),
75                OutputFormat::Json => unreachable!(),
76            }
77        }
78    }
79}
80
81// ── Redundancy-reduction pre-pass (ADR-078 §7) ──────────────────────────────
82
83/// Apply the view-only redundancy-reduction pre-pass (ADR-078 §7) to a value.
84///
85/// Applies at most ONE pass over the value. This function is the canonical
86/// entry for the pre-pass; the per-record logic lives in `drop_record`.
87///
88/// Applied only when `format` ∈ {`auto`, `table`} AND `presentation` ≠ `Verbose`.
89/// Callers are responsible for checking those conditions; this function applies
90/// unconditionally.
91pub fn apply_redundancy_drop(value: Value) -> Value {
92    match value {
93        Value::Object(_) => drop_record(value),
94        Value::Array(arr) => Value::Array(
95            arr.into_iter()
96                .map(|v| if v.is_object() { drop_record(v) } else { v })
97                .collect(),
98        ),
99        other => other,
100    }
101}
102
103/// Apply per-record redundancy rules (§7.1, §7.2, §7.3) to a single record object.
104fn drop_record(value: Value) -> Value {
105    let Value::Object(mut map) = value else {
106        return value;
107    };
108
109    // `full_id` is a chaining handle, not needed in view-only output.
110    map.remove("full_id");
111
112    // `"local"` is the common case, so only surface `namespace` when it isn't.
113    if map.get("namespace").and_then(Value::as_str) == Some("local") {
114        map.remove("namespace");
115    }
116
117    // Properties dedup: drop key-value pairs from `properties` that
118    // duplicate an identical top-level sibling.
119    let props_val = map.remove("properties");
120    if let Some(Value::Object(props)) = props_val {
121        let mut new_props = Map::new();
122        for (k, v) in props {
123            if map.get(&k) != Some(&v) {
124                new_props.insert(k, v);
125            }
126        }
127        if !new_props.is_empty() {
128            map.insert("properties".to_string(), Value::Object(new_props));
129        }
130    } else if let Some(other) = props_val {
131        map.insert("properties".to_string(), other);
132    }
133
134    // Recurse into array values so nested record arrays are also reduced.
135    let out: Map<String, Value> = map
136        .into_iter()
137        .map(|(k, v)| {
138            let v = match v {
139                Value::Array(arr) => Value::Array(
140                    arr.into_iter()
141                        .map(|item| {
142                            if item.is_object() {
143                                drop_record(item)
144                            } else {
145                                item
146                            }
147                        })
148                        .collect(),
149                ),
150                other => other,
151            };
152            (k, v)
153        })
154        .collect();
155    Value::Object(out)
156}
157
158// ── Shape-aware rendering (`auto`) ──────────────────────────────────────────
159
160/// Render a value using shape-aware dispatch (ADR-078 §3).
161fn render_auto(value: Value) -> String {
162    // Shape (a): homogeneous record array.
163    if let Some((records, keys)) = find_record_array(&value) {
164        return render_table(&records, &keys);
165    }
166    // Shape (b): single record / heterogeneous object.
167    if value.is_object() {
168        return render_kv_block(&value, 0);
169    }
170    // Shape (c): compact-JSON fallback.
171    serde_json::to_string(&value).unwrap_or_else(|_| "null".to_string())
172}
173
174/// Force the markdown-table renderer regardless of detected shape (ADR-078 §6).
175fn render_table_forced(value: Value) -> String {
176    if let Some((records, keys)) = find_record_array(&value) {
177        return render_table(&records, &keys);
178    }
179    // No record array detected: fall back to compact JSON.
180    serde_json::to_string(&value).unwrap_or_else(|_| "null".to_string())
181}
182
183/// Find the first homogeneous record array in `value`.
184///
185/// Checks:
186/// 1. `value` itself is an array of 2+ objects.
187/// 2. `value` is an object with a key whose value is an array of 2+ objects.
188///
189/// Returns `(records_cloned, ordered_column_keys)` when found.
190fn find_record_array(value: &Value) -> Option<(Vec<Value>, Vec<String>)> {
191    match value {
192        Value::Array(arr) if arr.len() >= 2 && arr.iter().all(Value::is_object) => {
193            let keys = collect_keys(arr);
194            Some((arr.clone(), keys))
195        }
196        Value::Object(map) => {
197            for v in map.values() {
198                if let Value::Array(arr) = v {
199                    if arr.len() >= 2 && arr.iter().all(Value::is_object) {
200                        let keys = collect_keys(arr);
201                        return Some((arr.clone(), keys));
202                    }
203                }
204            }
205            None
206        }
207        _ => None,
208    }
209}
210
211/// Collect column names in first-seen order across all records.
212fn collect_keys(records: &[Value]) -> Vec<String> {
213    let mut seen = HashSet::new();
214    let mut keys = Vec::new();
215    for record in records {
216        if let Value::Object(map) = record {
217            for k in map.keys() {
218                if seen.insert(k.clone()) {
219                    keys.push(k.clone());
220                }
221            }
222        }
223    }
224    keys
225}
226
227// ── Markdown table renderer (ADR-078 §3a) ───────────────────────────────────
228
229/// Render a record array as a GitHub-Flavored Markdown table.
230fn render_table(records: &[Value], keys: &[String]) -> String {
231    let mut out = String::new();
232
233    out.push('|');
234    for k in keys {
235        out.push(' ');
236        out.push_str(k);
237        out.push_str(" |");
238    }
239    out.push('\n');
240
241    out.push('|');
242    for _ in keys {
243        out.push_str("---|");
244    }
245    out.push('\n');
246
247    for record in records {
248        out.push('|');
249        for k in keys {
250            let cell = record.get(k).unwrap_or(&Value::Null);
251            let text = cell_text(cell);
252            out.push(' ');
253            out.push_str(&text);
254            out.push_str(" |");
255        }
256        out.push('\n');
257    }
258
259    out
260}
261
262/// Format a cell value: escape `|`, collapse newlines, truncate to ~120 chars.
263fn cell_text(value: &Value) -> String {
264    let raw = match value {
265        Value::Null => String::new(),
266        Value::Bool(b) => b.to_string(),
267        Value::Number(n) => n.to_string(),
268        Value::String(s) => s.clone(),
269        // Arrays and nested objects use compact JSON in the cell.
270        other => serde_json::to_string(other).unwrap_or_default(),
271    };
272
273    // Escape literal `|` and collapse embedded newlines to a space.
274    let escaped = raw.replace('|', "\\|").replace(['\n', '\r'], " ");
275
276    // Truncate to approximately CELL_TRUNCATE *characters* (char boundary,
277    // not byte index — slicing on a byte offset can panic on multi-byte chars).
278    let char_count = escaped.chars().count();
279    if char_count > CELL_TRUNCATE {
280        let truncated: String = escaped.chars().take(CELL_TRUNCATE).collect();
281        format!("{truncated}...")
282    } else {
283        escaped
284    }
285}
286
287// ── Flat key-value block renderer (ADR-078 §3b) ─────────────────────────────
288
289/// Render a single record or heterogeneous object as a flat key-value block.
290fn render_kv_block(value: &Value, depth: usize) -> String {
291    let indent = "  ".repeat(depth);
292    match value {
293        Value::Object(map) => {
294            let mut out = String::new();
295            for (k, v) in map {
296                match v {
297                    Value::Object(_) => {
298                        out.push_str(&format!("{}{}:\n", indent, k));
299                        out.push_str(&render_kv_block(v, depth + 1));
300                    }
301                    Value::Array(arr) if arr.iter().any(Value::is_object) => {
302                        out.push_str(&format!("{}{}:\n", indent, k));
303                        for item in arr {
304                            if item.is_object() {
305                                out.push_str(&render_kv_block(item, depth + 1));
306                            } else {
307                                out.push_str(&format!("{}  - {}\n", indent, cell_text(item)));
308                            }
309                        }
310                    }
311                    _ => {
312                        out.push_str(&format!("{}{}: {}\n", indent, k, cell_text(v)));
313                    }
314                }
315            }
316            out
317        }
318        other => format!("{}{}\n", indent, cell_text(other)),
319    }
320}
321
322/// Convert a microsecond epoch `i64` to an RFC 3339 / ISO-8601 string.
323///
324/// Entity and Note storage uses `i64` microseconds internally; this is the
325/// single conversion point before any field reaches the MCP boundary.
326///
327/// Format: `YYYY-MM-DDTHH:MM:SS.ffffffZ` (SecondsFormat::Micros, UTC `Z`).
328pub fn micros_to_iso(micros: i64) -> String {
329    chrono::DateTime::<chrono::Utc>::from_timestamp_micros(micros)
330        .unwrap_or_else(chrono::Utc::now)
331        .to_rfc3339_opts(chrono::SecondsFormat::Micros, true)
332}
333
334/// How the response envelope is presented to the caller.
335#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
336#[serde(rename_all = "snake_case")]
337pub enum PresentationMode {
338    /// Token-efficient. Default for MCP callers (agents).
339    ///
340    /// Short UUIDs (8-char), compact timestamps (minute granularity or
341    /// relative), empty fields dropped, lifecycle nulls preserved, score
342    /// fields truncated to 3 significant figures.
343    #[default]
344    Agent,
345    /// Full canonical shape. Default for `kkernel exec` and CI/scripted callers.
346    ///
347    /// No transformation — handler output passes through as-is.
348    Verbose,
349    /// Pretty-printed terminal output. Default for `khive` CLI.
350    ///
351    /// **At the MCP runtime level this is identical to `Verbose`** — the
352    /// canonical JSON is returned unchanged. Terminal formatting (relative
353    /// timestamps, glyph substitution, table layout) is applied by the CLI
354    /// layer (`khive-cli::format::pretty`), not the MCP response pipeline.
355    Human,
356}
357
358/// Lifecycle `null` fields that are PRESERVED in Agent mode even when null.
359///
360/// These fields carry lifecycle meaning (absent ≠ null) and must not be dropped.
361const LIFECYCLE_NULL_PRESERVE: &[&str] = &[
362    "completed_at",
363    "deleted_at",
364    "due_at",
365    "read_at",
366    "started_at",
367    "superseded_at",
368    "applied_at",
369    "withdrawn_at",
370    "reviewed_at",
371    "parent_id",
372    "superseded_by",
373    "replaced_by",
374];
375
376/// Field names carrying caller-supplied payload timestamps that must never be
377/// compacted (relative-time or minute-truncated), regardless of nesting.
378///
379/// These encode domain semantics the caller needs to round-trip verbatim —
380/// e.g. `trigger_at` on a `schedule.remind`/`schedule.schedule` create
381/// response, returned as a top-level convenience field alongside `id` and
382/// `full_id`, not nested under `"properties"`. The `inside_properties` guard
383/// alone only protects fields nested under a literal `"properties"` key
384/// (as returned by `agenda`/`get`); it does not cover this top-level case,
385/// which `compact_timestamp` rewrote into either a relative string or a
386/// minute-truncated absolute form — either way discarding the seconds and
387/// offset the caller needs to round-trip the exact submitted value (#871).
388///
389/// `due` on `gtd.assign`/`gtd.tasks`/`gtd.next` responses is the same shape:
390/// a top-level convenience field mirroring `properties.due`, which
391/// `parse_due` already normalizes to full RFC 3339.
392const PAYLOAD_TIMESTAMP_FIELDS: &[&str] = &["trigger_at", "due"];
393
394/// Score field names that are truncated to 3 significant figures in Agent mode.
395const SCORE_FIELDS: &[&str] = &[
396    "score",
397    "salience",
398    "decay_factor",
399    "rrf_score",
400    "similarity",
401    "cross_encoder_score",
402    "graph_proximity_score",
403];
404
405/// UUID v4 canonical string length (8-4-4-4-12 = 32 hex + 4 dashes = 36).
406const UUID_CANONICAL_LEN: usize = 36;
407
408/// Return true for fields whose whole-string UUID values may be shortened in
409/// Agent mode. Content-like fields are intentionally excluded even when their
410/// value happens to be UUID-shaped.
411///
412/// `full_id` is explicitly excluded: its purpose is to give callers a
413/// stable chaining handle, so shortening it makes it identical to `id` and
414/// defeats the field entirely.
415fn should_shorten_uuid_field(key: &str) -> bool {
416    if key == "full_id" {
417        return false;
418    }
419    key == "id" || key.ends_with("_id") || matches!(key, "superseded_by" | "replaced_by")
420}
421
422/// Transform a successful verb result value according to the given
423/// [`PresentationMode`].
424///
425/// - `Verbose` / `Human`: returns `value` unchanged.
426/// - `Agent`: applies UUID shortening, timestamp compaction, empty-field
427///   dropping, lifecycle-null preservation, and score truncation.
428///
429/// `now_unix_seconds` is sampled once per response and passed through so all
430/// relative datetime renderings within a response use the same instant.
431pub fn present(value: Value, mode: PresentationMode, now_unix_seconds: i64) -> Value {
432    match mode {
433        PresentationMode::Verbose | PresentationMode::Human => value,
434        PresentationMode::Agent => {
435            let lifecycle_preserve: HashSet<&str> =
436                LIFECYCLE_NULL_PRESERVE.iter().copied().collect();
437            let score_fields: HashSet<&str> = SCORE_FIELDS.iter().copied().collect();
438            let payload_timestamps: HashSet<&str> =
439                PAYLOAD_TIMESTAMP_FIELDS.iter().copied().collect();
440            transform_agent(
441                value,
442                &lifecycle_preserve,
443                &score_fields,
444                &payload_timestamps,
445                now_unix_seconds,
446                false,
447            )
448        }
449    }
450}
451
452/// Apply the Agent-mode transform to an arbitrary JSON value.
453///
454/// `inside_properties` is `true` when recursing inside a `"properties"` object.
455/// Caller-supplied payload timestamps (e.g. `trigger_at`) must not be compacted
456/// because they encode domain semantics the agent may need to round-trip.
457fn transform_agent(
458    value: Value,
459    lifecycle: &HashSet<&str>,
460    scores: &HashSet<&str>,
461    payload_timestamps: &HashSet<&str>,
462    now: i64,
463    inside_properties: bool,
464) -> Value {
465    match value {
466        Value::Object(map) => {
467            let mut out = Map::new();
468            for (k, v) in map {
469                let child_inside_properties = inside_properties || k == "properties";
470                let transformed = transform_field_agent(
471                    &k,
472                    v,
473                    lifecycle,
474                    scores,
475                    payload_timestamps,
476                    now,
477                    child_inside_properties,
478                );
479                match transformed {
480                    None => {} // drop
481                    Some(tv) => {
482                        out.insert(k, tv);
483                    }
484                }
485            }
486            Value::Object(out)
487        }
488        Value::Array(arr) => {
489            let items: Vec<Value> = arr
490                .into_iter()
491                .map(|v| {
492                    transform_agent(
493                        v,
494                        lifecycle,
495                        scores,
496                        payload_timestamps,
497                        now,
498                        inside_properties,
499                    )
500                })
501                .collect();
502            Value::Array(items)
503        }
504        other => other,
505    }
506}
507
508/// Transform a single named field value under Agent mode.
509///
510/// Returns `None` if the field should be dropped.
511///
512/// `inside_properties` suppresses timestamp compaction for caller-submitted
513/// payload values nested under a literal `"properties"` key (e.g. `trigger_at`
514/// as returned by `agenda`/`get`). `payload_timestamps` suppresses compaction
515/// by field name regardless of nesting, covering top-level convenience fields
516/// such as the `trigger_at` returned directly in a `schedule.remind`/
517/// `schedule.schedule` create response (#871). Metadata timestamps at the top
518/// level (`created_at`, `updated_at`) are still compacted.
519fn transform_field_agent(
520    key: &str,
521    value: Value,
522    lifecycle: &HashSet<&str>,
523    scores: &HashSet<&str>,
524    payload_timestamps: &HashSet<&str>,
525    now: i64,
526    inside_properties: bool,
527) -> Option<Value> {
528    match &value {
529        // Preserve lifecycle nulls; drop other nulls.
530        Value::Null => {
531            if lifecycle.contains(key) {
532                Some(value)
533            } else {
534                None
535            }
536        }
537        // Drop empty strings, arrays, objects.
538        Value::String(s) if s.is_empty() => None,
539        Value::Array(a) if a.is_empty() => None,
540        Value::Object(o) if o.is_empty() => None,
541        // Truncate score fields.
542        Value::Number(_) if scores.contains(key) => {
543            if let Some(f) = value.as_f64() {
544                Some(truncate_to_3_sig_figs(f))
545            } else {
546                Some(value)
547            }
548        }
549        // Shorten UUIDs only in fields whose names carry ID semantics.
550        Value::String(s) if is_canonical_uuid(s) && should_shorten_uuid_field(key) => {
551            Some(Value::String(s[..8].to_string()))
552        }
553        // Compact ISO-8601 timestamps unless inside a caller-supplied payload
554        // object, or the field is a named payload timestamp at any nesting.
555        Value::String(s)
556            if !inside_properties && !payload_timestamps.contains(key) && looks_like_iso8601(s) =>
557        {
558            Some(Value::String(compact_timestamp(s, now)))
559        }
560        // Recurse into objects and arrays.
561        Value::Object(_) | Value::Array(_) => Some(transform_agent(
562            value,
563            lifecycle,
564            scores,
565            payload_timestamps,
566            now,
567            inside_properties,
568        )),
569        // Everything else passes through.
570        _ => Some(value),
571    }
572}
573
574/// Returns `true` if `s` looks like a canonical UUID (36 chars, standard form).
575fn is_canonical_uuid(s: &str) -> bool {
576    if s.len() != UUID_CANONICAL_LEN {
577        return false;
578    }
579    let b = s.as_bytes();
580    // Pattern: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
581    b[8] == b'-'
582        && b[13] == b'-'
583        && b[18] == b'-'
584        && b[23] == b'-'
585        && b[..8].iter().all(|c| c.is_ascii_hexdigit())
586        && b[9..13].iter().all(|c| c.is_ascii_hexdigit())
587        && b[14..18].iter().all(|c| c.is_ascii_hexdigit())
588        && b[19..23].iter().all(|c| c.is_ascii_hexdigit())
589        && b[24..].iter().all(|c| c.is_ascii_hexdigit())
590}
591
592/// Returns `true` if `s` looks like an ISO-8601 datetime string.
593///
594/// Heuristic: starts with `YYYY-MM-DDTHH:` (16 chars, proper digit positions).
595fn looks_like_iso8601(s: &str) -> bool {
596    if s.len() < 16 {
597        return false;
598    }
599    let b = s.as_bytes();
600    b[4] == b'-'
601        && b[7] == b'-'
602        && b[10] == b'T'
603        && b[13] == b':'
604        && b[..4].iter().all(|c| c.is_ascii_digit())
605        && b[5..7].iter().all(|c| c.is_ascii_digit())
606        && b[8..10].iter().all(|c| c.is_ascii_digit())
607        && b[11..13].iter().all(|c| c.is_ascii_digit())
608}
609
610/// Compact an ISO-8601 timestamp for Agent mode.
611///
612/// - Within the last 24 hours: relative form (e.g. `"3m ago"`, `"2h ago"`).
613/// - Older: minute-granularity absolute form `"YYYY-MM-DDTHH:MM"`.
614fn compact_timestamp(s: &str, now: i64) -> String {
615    // Parse Unix seconds from the timestamp if possible; fall back to truncation.
616    if let Some(unix) = parse_iso8601_unix(s) {
617        let diff = now - unix;
618        if (0..86400).contains(&diff) {
619            return relative_time(diff);
620        }
621    }
622    // Minute granularity: take the first 16 chars.
623    s.chars().take(16).collect()
624}
625
626/// Attempt to parse an ISO-8601 datetime string to Unix seconds.
627///
628/// Only handles the subset produced by khive handlers:
629/// `YYYY-MM-DDTHH:MM:SS[.frac][Z|±HH:MM|±HHMM]`. Returns `None` for anything
630/// we can't parse (graceful degradation — the timestamp is still compacted
631/// by truncation).
632fn parse_iso8601_unix(s: &str) -> Option<i64> {
633    // Minimum parseable: "YYYY-MM-DDTHH:MM:SS"
634    if s.len() < 19 {
635        return None;
636    }
637    let b = s.as_bytes();
638    let year: i64 = parse_digits(&b[0..4])?;
639    let month: i64 = parse_digits(&b[5..7])?;
640    let day: i64 = parse_digits(&b[8..10])?;
641    let hour: i64 = parse_digits(&b[11..13])?;
642    let minute: i64 = parse_digits(&b[14..16])?;
643    let second: i64 = parse_digits(&b[17..19])?;
644
645    // Simple Gregorian → local-wall-clock Unix seconds, then adjust for any
646    // trailing timezone offset (see `parse_tz_offset_secs`) to get the
647    // actual UTC instant.
648    let days_since_epoch = days_from_civil(year, month, day);
649    let local = days_since_epoch * 86400 + hour * 3600 + minute * 60 + second;
650    let offset_secs = parse_tz_offset_secs(&s[19..])?;
651    Some(local - offset_secs)
652}
653
654/// Parse the tail of an ISO-8601 timestamp (everything from byte index 19
655/// onward, i.e. after the whole-seconds field) into a UTC offset in seconds.
656///
657/// Handles, in order: optional fractional seconds (`.nnn`, skipped — this
658/// parser only has whole-second precision), then one of:
659/// - empty string or `"Z"` → offset 0
660/// - `±HH:MM` or the compact `±HHMM` form → `sign * (hh*3600 + mm*60)`
661///
662/// Returns `None` for anything else (malformed tail).
663fn parse_tz_offset_secs(tail: &str) -> Option<i64> {
664    let mut rest = tail;
665    if let Some(after_dot) = rest.strip_prefix('.') {
666        let frac_len = after_dot.bytes().take_while(u8::is_ascii_digit).count();
667        if frac_len == 0 {
668            return None;
669        }
670        rest = &after_dot[frac_len..];
671    }
672
673    if rest.is_empty() || rest == "Z" {
674        return Some(0);
675    }
676
677    let sign: i64 = match rest.as_bytes().first()? {
678        b'+' => 1,
679        b'-' => -1,
680        _ => return None,
681    };
682    let digits = &rest[1..];
683    let (hh, mm) = match digits.len() {
684        // "HH:MM"
685        5 if digits.as_bytes()[2] == b':' => (
686            parse_digits(&digits.as_bytes()[0..2])?,
687            parse_digits(&digits.as_bytes()[3..5])?,
688        ),
689        // "HHMM"
690        4 => (
691            parse_digits(&digits.as_bytes()[0..2])?,
692            parse_digits(&digits.as_bytes()[2..4])?,
693        ),
694        _ => return None,
695    };
696    if hh > 23 || mm > 59 {
697        return None;
698    }
699    Some(sign * (hh * 3600 + mm * 60))
700}
701
702fn parse_digits(b: &[u8]) -> Option<i64> {
703    let s = std::str::from_utf8(b).ok()?;
704    s.parse().ok()
705}
706
707/// Gregorian date → days since 1970-01-01. Algorithm: Howard Hinnant's civil.
708fn days_from_civil(y: i64, m: i64, d: i64) -> i64 {
709    let y = if m <= 2 { y - 1 } else { y };
710    let era = y.div_euclid(400);
711    let yoe = y - era * 400;
712    let doy = (153 * (if m > 2 { m - 3 } else { m + 9 }) + 2) / 5 + d - 1;
713    let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
714    era * 146097 + doe - 719468
715}
716
717/// Format a duration in seconds as a relative time string (e.g. `"3m ago"`).
718fn relative_time(diff_secs: i64) -> String {
719    if diff_secs < 60 {
720        format!("{diff_secs}s ago")
721    } else if diff_secs < 3600 {
722        format!("{}m ago", diff_secs / 60)
723    } else {
724        format!("{}h ago", diff_secs / 3600)
725    }
726}
727
728/// Truncate a float to 3 significant figures, returning a `serde_json::Value`.
729fn truncate_to_3_sig_figs(f: f64) -> Value {
730    if f == 0.0 || !f.is_finite() {
731        return Value::from(f);
732    }
733    let magnitude = f.abs().log10().floor() as i32;
734    let factor = 10f64.powi(2 - magnitude);
735    let rounded = (f * factor).round() / factor;
736    // Re-serialize through serde_json to avoid floating-point noise.
737    serde_json::Number::from_f64(rounded)
738        .map(Value::Number)
739        .unwrap_or(Value::from(rounded))
740}
741
742#[cfg(test)]
743mod tests {
744    use super::*;
745    use serde_json::json;
746
747    /// A fixed "now" for deterministic tests: 2026-05-23T16:18:00Z ≈ 1748016480.
748    const NOW: i64 = 1_748_016_480;
749
750    fn agent(v: Value) -> Value {
751        present(v, PresentationMode::Agent, NOW)
752    }
753
754    #[test]
755    fn verbose_passthrough() {
756        let v = json!({"id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", "title": "X"});
757        let out = present(v.clone(), PresentationMode::Verbose, NOW);
758        assert_eq!(out, v);
759    }
760
761    #[test]
762    fn agent_shortens_uuid() {
763        let v = json!({"id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890"});
764        let out = agent(v);
765        assert_eq!(out["id"], json!("a1b2c3d4"));
766    }
767
768    #[test]
769    fn agent_drops_empty_string() {
770        let v = json!({"title": "ok", "description": ""});
771        let out = agent(v);
772        assert!(out.get("description").is_none());
773        assert_eq!(out["title"], json!("ok"));
774    }
775
776    #[test]
777    fn agent_drops_empty_array() {
778        let v = json!({"tags": [], "title": "ok"});
779        let out = agent(v);
780        assert!(out.get("tags").is_none());
781    }
782
783    #[test]
784    fn agent_drops_empty_object() {
785        let v = json!({"properties": {}, "title": "ok"});
786        let out = agent(v);
787        assert!(out.get("properties").is_none());
788    }
789
790    #[test]
791    fn agent_drops_non_lifecycle_null() {
792        let v = json!({"result": null, "title": "ok"});
793        let out = agent(v);
794        assert!(out.get("result").is_none());
795    }
796
797    #[test]
798    fn agent_preserves_lifecycle_null() {
799        let v = json!({"completed_at": null, "due_at": null, "title": "ok"});
800        let out = agent(v);
801        assert_eq!(out["completed_at"], json!(null));
802        assert_eq!(out["due_at"], json!(null));
803    }
804
805    #[test]
806    fn agent_preserves_relationship_null() {
807        let v = json!({"parent_id": null, "superseded_by": null});
808        let out = agent(v);
809        assert_eq!(out["parent_id"], json!(null));
810        assert_eq!(out["superseded_by"], json!(null));
811    }
812
813    #[test]
814    fn agent_truncates_score_field() {
815        let v = json!({"score": 0.12345678});
816        let out = agent(v);
817        let s = out["score"].as_f64().unwrap();
818        assert!((s - 0.123).abs() < 1e-9, "expected ~0.123, got {s}");
819    }
820
821    #[test]
822    fn agent_compacts_old_timestamp_to_minutes() {
823        // Far past — not within 24h of NOW. Should be truncated to 16 chars.
824        let v = json!({"created_at": "2020-01-01T10:30:45.123456Z"});
825        let out = agent(v);
826        assert_eq!(out["created_at"], json!("2020-01-01T10:30"));
827    }
828
829    #[test]
830    fn agent_compacts_recent_timestamp_to_relative() {
831        // 3 minutes before NOW: diff = 180s.
832        let ts_unix = NOW - 180;
833        // Format as ISO-8601.
834        let ts = unix_to_iso8601(ts_unix);
835        let v = json!({"updated_at": ts});
836        let out = agent(v);
837        assert_eq!(out["updated_at"], json!("3m ago"));
838    }
839
840    #[test]
841    fn agent_does_not_compact_top_level_trigger_at_field() {
842        // Regression for #871: `schedule.remind`'s create response returns
843        // `trigger_at` as a top-level convenience field (sibling to `id`,
844        // `full_id`), not nested under `"properties"`. The pre-existing
845        // `inside_properties` guard alone did not protect it, so Agent-mode
846        // compaction rewrote it: `at` here is far outside the 24h relative
847        // window, so pre-fix it would have been minute-truncated to
848        // "2026-07-11T19:00", discarding the seconds and the "-04:00"
849        // offset the caller needs to round-trip the exact value verbatim.
850        let at = "2026-07-11T19:00:00-04:00";
851        let v = json!({
852            "id": "a1b2c3d4",
853            "full_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
854            "event_type": "remind",
855            "trigger_at": at,
856            "repeat": null,
857            "status": "pending",
858        });
859        let out = agent(v);
860        assert_eq!(out["trigger_at"], json!(at));
861    }
862
863    #[test]
864    fn agent_does_not_compact_top_level_trigger_at_utc() {
865        let at = "2026-07-11T23:00:00Z";
866        let v = json!({"trigger_at": at});
867        let out = agent(v);
868        assert_eq!(out["trigger_at"], json!(at));
869    }
870
871    #[test]
872    fn agent_does_not_compact_top_level_trigger_at_offset_less() {
873        let at = "2026-07-11T23:00:00";
874        let v = json!({"trigger_at": at});
875        let out = agent(v);
876        assert_eq!(out["trigger_at"], json!(at));
877    }
878
879    #[test]
880    fn agent_still_compacts_other_top_level_timestamps_alongside_trigger_at() {
881        // The `trigger_at` exemption is scoped to that field name only — a
882        // sibling generic timestamp field must still be compacted, so the
883        // fix does not blanket-disable Agent-mode compaction.
884        let v = json!({
885            "trigger_at": "2026-07-11T19:00:00-04:00",
886            "created_at": "2020-01-01T10:30:45.123456Z",
887        });
888        let out = agent(v);
889        assert_eq!(out["trigger_at"], json!("2026-07-11T19:00:00-04:00"));
890        assert_eq!(out["created_at"], json!("2020-01-01T10:30"));
891    }
892
893    #[test]
894    fn agent_does_not_compact_top_level_due() {
895        // gtd.assign/gtd.tasks/gtd.next return the caller-supplied `due` as
896        // a top-level convenience field mirroring `properties.due`; it must
897        // round-trip verbatim through Agent-mode presentation, the same
898        // guarantee already given to `trigger_at`.
899        let due = "2026-08-01T09:30:15-04:00";
900        let v = json!({"due": due});
901        let out = agent(v);
902        assert_eq!(out["due"], json!(due));
903    }
904
905    #[test]
906    fn agent_still_protects_nested_trigger_at_under_properties() {
907        // Pre-existing protection (agenda/get responses nest trigger_at
908        // under "properties") must remain intact alongside the new
909        // top-level, field-name-based guard.
910        let at = "2026-07-11T19:00:00-04:00";
911        let v = json!({
912            "id": "a1b2c3d4",
913            "properties": {"trigger_at": at, "status": "pending"},
914        });
915        let out = agent(v);
916        assert_eq!(out["properties"]["trigger_at"], json!(at));
917    }
918
919    #[test]
920    fn agent_recurses_into_nested_objects() {
921        let v = json!({
922            "items": [
923                {
924                    "id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
925                    "tags": [],
926                    "score": 0.9999
927                }
928            ]
929        });
930        let out = agent(v);
931        let item = &out["items"][0];
932        assert_eq!(item["id"], json!("a1b2c3d4"));
933        assert!(item.get("tags").is_none());
934        let s = item["score"].as_f64().unwrap();
935        assert!((s - 1.0).abs() < 1e-9);
936    }
937
938    // full_id must never be shortened in Agent mode: it's the caller's
939    // stable chaining handle.
940    #[test]
941    fn agent_preserves_full_id_as_36_chars() {
942        let uuid = "a1b2c3d4-e5f6-7890-abcd-ef1234567890";
943        let v = json!({"id": uuid, "full_id": uuid, "title": "X"});
944        let out = agent(v);
945        // `id` is shortened to 8 chars
946        assert_eq!(
947            out["id"],
948            json!("a1b2c3d4"),
949            "id should be 8-char short form"
950        );
951        // `full_id` must remain the full 36-char UUID
952        assert_eq!(
953            out["full_id"].as_str().unwrap().len(),
954            36,
955            "full_id must be 36 chars in agent mode"
956        );
957        assert_eq!(
958            out["full_id"],
959            json!(uuid),
960            "full_id must equal the original UUID"
961        );
962        // Verify the invariant: full_id starts with the short id prefix
963        assert!(
964            out["full_id"]
965                .as_str()
966                .unwrap()
967                .starts_with(out["id"].as_str().unwrap()),
968            "full_id must start with the short id prefix"
969        );
970    }
971
972    #[test]
973    fn is_canonical_uuid_recognizes_valid() {
974        assert!(is_canonical_uuid("a1b2c3d4-e5f6-7890-abcd-ef1234567890"));
975        assert!(!is_canonical_uuid("a1b2c3d4"));
976        assert!(!is_canonical_uuid("not-a-uuid-at-all-here---------"));
977    }
978
979    #[test]
980    fn looks_like_iso8601_recognizes_valid() {
981        assert!(looks_like_iso8601("2026-05-23T16:18:15.234567Z"));
982        assert!(!looks_like_iso8601("not a timestamp"));
983        assert!(!looks_like_iso8601("2026-05-23"));
984    }
985
986    /// Format Unix seconds as ISO-8601 for test construction.
987    fn unix_to_iso8601(unix: i64) -> String {
988        let (y, mo, d, h, mi, s) = unix_to_civil(unix);
989        format!("{y:04}-{mo:02}-{d:02}T{h:02}:{mi:02}:{s:02}Z")
990    }
991
992    fn unix_to_civil(unix: i64) -> (i64, i64, i64, i64, i64, i64) {
993        let s = unix % 86400;
994        let days = unix / 86400;
995        let h = s / 3600;
996        let m = (s % 3600) / 60;
997        let sec = s % 60;
998        // Howard Hinnant civil_from_days
999        let z = days + 719468;
1000        let era = z.div_euclid(146097);
1001        let doe = z - era * 146097;
1002        let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
1003        let y = yoe + era * 400;
1004        let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
1005        let mp = (5 * doy + 2) / 153;
1006        let d = doy - (153 * mp + 2) / 5 + 1;
1007        let mo = if mp < 10 { mp + 3 } else { mp - 9 };
1008        let y = if mo <= 2 { y + 1 } else { y };
1009        (y, mo, d, h, m, sec)
1010    }
1011
1012    #[test]
1013    fn agent_does_not_shorten_uuid_shaped_content_fields() {
1014        let uuid = "a1b2c3d4-e5f6-7890-abcd-ef1234567890";
1015        let out = agent(json!({
1016            "id": uuid,
1017            "full_id": uuid,
1018            "content": uuid,
1019            "description": uuid,
1020            "title": uuid,
1021            "query": uuid,
1022        }));
1023
1024        assert_eq!(out["id"], json!("a1b2c3d4"));
1025        assert_eq!(out["full_id"], json!(uuid));
1026        assert_eq!(out["content"], json!(uuid));
1027        assert_eq!(out["description"], json!(uuid));
1028        assert_eq!(out["title"], json!(uuid));
1029        assert_eq!(out["query"], json!(uuid));
1030    }
1031
1032    #[test]
1033    fn agent_shortens_suffix_id_fields() {
1034        let uuid = "a1b2c3d4-e5f6-7890-abcd-ef1234567890";
1035        let out = agent(json!({
1036            "note_id": uuid,
1037            "source_id": uuid,
1038            "target_id": uuid,
1039        }));
1040
1041        assert_eq!(out["note_id"], json!("a1b2c3d4"));
1042        assert_eq!(out["source_id"], json!("a1b2c3d4"));
1043        assert_eq!(out["target_id"], json!("a1b2c3d4"));
1044    }
1045
1046    // ── ADR-078: OutputFormat tests ───────────────────────────────────────────
1047
1048    /// (a) json format preserves full shape (no field dropped, no transformation).
1049    #[test]
1050    fn format_json_preserves_full_shape() {
1051        let v = json!({
1052            "full_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
1053            "namespace": "local",
1054            "properties": {"k": "v"},
1055            "title": "test"
1056        });
1057        let rendered = render_format(v.clone(), OutputFormat::Json, PresentationMode::Agent);
1058        let parsed: Value = serde_json::from_str(&rendered).unwrap();
1059        // full_id must not be dropped in json mode.
1060        assert!(
1061            parsed.get("full_id").is_some(),
1062            "json mode must keep full_id"
1063        );
1064        // namespace must NOT be elided in json mode.
1065        assert_eq!(
1066            parsed.get("namespace").and_then(Value::as_str),
1067            Some("local")
1068        );
1069        // properties must NOT be deduped in json mode.
1070        assert!(parsed.get("properties").is_some());
1071    }
1072
1073    /// (a-vs-auto) auto mode drops redundant fields that json mode preserves.
1074    #[test]
1075    fn format_auto_drops_versus_json_keeps() {
1076        let v = json!({
1077            "full_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
1078            "namespace": "local",
1079            "title": "test"
1080        });
1081        let json_rendered = render_format(v.clone(), OutputFormat::Json, PresentationMode::Agent);
1082        let auto_rendered = render_format(v.clone(), OutputFormat::Auto, PresentationMode::Agent);
1083        // json keeps both; auto drops namespace="local" and full_id.
1084        let json_parsed: Value = serde_json::from_str(&json_rendered).unwrap();
1085        assert!(
1086            json_parsed.get("full_id").is_some(),
1087            "json must keep full_id"
1088        );
1089        assert_eq!(
1090            json_parsed.get("namespace").and_then(Value::as_str),
1091            Some("local")
1092        );
1093        // Auto mode elides namespace=local and drops full_id.
1094        // The value itself is a single record → rendered as kv block.
1095        assert!(
1096            !auto_rendered.contains("full_id"),
1097            "auto kv block must drop full_id"
1098        );
1099        assert!(
1100            !auto_rendered.contains("namespace"),
1101            "auto kv block must elide namespace=local"
1102        );
1103    }
1104
1105    /// (b1) homogeneous record array → markdown table with header + separator + rows.
1106    #[test]
1107    fn format_auto_homogeneous_array_renders_markdown_table() {
1108        let v = json!([
1109            {"id": "abc", "title": "First"},
1110            {"id": "def", "title": "Second"}
1111        ]);
1112        let rendered = render_format(v, OutputFormat::Auto, PresentationMode::Agent);
1113        assert!(rendered.starts_with('|'), "must start with |");
1114        assert!(
1115            rendered.contains("| id |") || rendered.contains("| id"),
1116            "must have id column"
1117        );
1118        assert!(rendered.contains("title"), "must have title column");
1119        assert!(rendered.contains("|---|"), "must have separator row");
1120        assert!(rendered.contains("abc"), "must have first row data");
1121        assert!(rendered.contains("Second"), "must have second row data");
1122    }
1123
1124    /// (b2) single record → flat kv block.
1125    #[test]
1126    fn format_auto_single_record_renders_kv_block() {
1127        let v = json!({"id": "abc", "title": "Hello World"});
1128        let rendered = render_format(v, OutputFormat::Auto, PresentationMode::Agent);
1129        // kv block uses "key: value\n" format.
1130        assert!(rendered.contains("id: abc"), "must have id: abc");
1131        assert!(
1132            rendered.contains("title: Hello World"),
1133            "must have title line"
1134        );
1135        // Must NOT contain markdown table markers.
1136        assert!(
1137            !rendered.starts_with('|'),
1138            "single record must not be a markdown table"
1139        );
1140    }
1141
1142    /// (b3) fallback: auto on heterogeneous/scalar value falls back to compact json.
1143    #[test]
1144    fn format_auto_scalar_fallback_compact_json() {
1145        let v = json!(42);
1146        let rendered = render_format(v, OutputFormat::Auto, PresentationMode::Agent);
1147        assert_eq!(rendered, "42");
1148    }
1149
1150    /// (c) table format forces markdown table even when shape would normally be kv.
1151    #[test]
1152    fn format_table_forces_markdown_when_array() {
1153        let v = json!({
1154            "items": [
1155                {"name": "A", "score": 1},
1156                {"name": "B", "score": 2}
1157            ]
1158        });
1159        let rendered = render_format(v, OutputFormat::Table, PresentationMode::Agent);
1160        assert!(
1161            rendered.contains("|"),
1162            "table format must produce markdown table"
1163        );
1164        assert!(rendered.contains("name"), "must have name column");
1165        assert!(rendered.contains("score"), "must have score column");
1166    }
1167
1168    /// (c-fallback) table format falls back to compact json when no record array found.
1169    #[test]
1170    fn format_table_falls_back_to_json_when_no_array() {
1171        let v = json!({"single": "value"});
1172        let rendered = render_format(v, OutputFormat::Table, PresentationMode::Agent);
1173        // No record array → compact JSON fallback.
1174        let parsed: Value = serde_json::from_str(&rendered).unwrap();
1175        assert_eq!(parsed["single"], json!("value"));
1176    }
1177
1178    /// (d) redundancy-drop: auto/table skipped in Verbose mode (§7).
1179    #[test]
1180    fn format_auto_verbose_skips_redundancy_drop() {
1181        let v = json!({
1182            "full_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
1183            "namespace": "local",
1184            "title": "test"
1185        });
1186        // In Verbose mode, redundancy drop must be skipped.
1187        // The value is a single object → kv block, but full_id and namespace stay.
1188        let rendered = render_format(v, OutputFormat::Auto, PresentationMode::Verbose);
1189        assert!(
1190            rendered.contains("full_id"),
1191            "verbose must preserve full_id"
1192        );
1193        assert!(
1194            rendered.contains("namespace"),
1195            "verbose must preserve namespace"
1196        );
1197    }
1198
1199    /// Indirect check that the redundancy pre-pass doesn't corrupt an error
1200    /// envelope's shape; the actual ok=false bypass is enforced by
1201    /// `render_result`, not by this pre-pass.
1202    #[test]
1203    fn redundancy_drop_does_not_corrupt_error_shape() {
1204        let v = json!({"ok": false, "error": "something failed", "namespace": "local"});
1205        // apply_redundancy_drop is a pure value transform with no knowledge of
1206        // `ok`: bypassing it for error envelopes is the caller's job
1207        // (render_result in server.rs). This only checks the pre-pass itself
1208        // doesn't lose the error field.
1209        let reduced = apply_redundancy_drop(v.clone());
1210        assert!(
1211            reduced.get("error").is_some(),
1212            "redundancy drop must preserve error field"
1213        );
1214        assert_eq!(
1215            reduced.get("ok").and_then(Value::as_bool),
1216            Some(false),
1217            "redundancy drop must preserve ok=false"
1218        );
1219    }
1220
1221    /// Properties dedup removes only keys that match a top-level sibling exactly.
1222    #[test]
1223    fn redundancy_drop_properties_dedup() {
1224        let v = json!({
1225            "id": "abc",
1226            "title": "Same",
1227            "properties": {
1228                "title": "Same",  // duplicate → removed
1229                "extra": "unique" // not at top level → kept
1230            }
1231        });
1232        let reduced = apply_redundancy_drop(v);
1233        let props = reduced.get("properties").expect("properties must remain");
1234        assert!(props.get("extra").is_some(), "unique property must be kept");
1235        assert!(
1236            props.get("title").is_none(),
1237            "duplicate top-level property must be removed"
1238        );
1239    }
1240
1241    /// Cell truncation: text > 120 chars gets `...` appended.
1242    #[test]
1243    fn cell_text_truncates_long_values() {
1244        let long = "X".repeat(200);
1245        let v = json!([
1246            {"col": long.clone()},
1247            {"col": "short"}
1248        ]);
1249        let rendered = render_format(v, OutputFormat::Auto, PresentationMode::Agent);
1250        // Cell must be truncated to ~120 chars + "..."
1251        assert!(
1252            rendered.contains("..."),
1253            "long cell must be truncated with ..."
1254        );
1255        assert!(
1256            !rendered.contains(&long),
1257            "full long string must not appear in table"
1258        );
1259    }
1260
1261    /// Cell truncation must not panic on multi-byte UTF-8 characters.
1262    ///
1263    /// A string of 119 ASCII bytes followed by a 3-byte CJK character and more
1264    /// text has `len() > 120` but byte index 120 falls inside the CJK char.
1265    /// The old byte-slice truncation would panic; char-boundary truncation is safe.
1266    #[test]
1267    fn cell_text_truncation_is_utf8_safe() {
1268        // 119 ASCII 'a' bytes, then CJK char U+4E2D (3 bytes each), then more text.
1269        // Total byte length: 119 + 3 * 10 + 5 > 120, but byte 120 is inside a CJK char.
1270        let prefix = "a".repeat(119);
1271        let suffix = "中".repeat(10); // each '中' is 3 bytes
1272        let long_multibyte = format!("{prefix}{suffix}trailing");
1273        let v = json!([
1274            {"col": long_multibyte.clone()},
1275            {"col": "ok"}
1276        ]);
1277        // Must not panic — this was the bug.
1278        let rendered = render_format(v, OutputFormat::Auto, PresentationMode::Agent);
1279        assert!(
1280            rendered.contains("..."),
1281            "multibyte cell must be truncated with ..."
1282        );
1283        // The rendered string must be valid UTF-8 (no partial char slicing).
1284        assert!(
1285            std::str::from_utf8(rendered.as_bytes()).is_ok(),
1286            "rendered output must be valid UTF-8"
1287        );
1288    }
1289
1290    // --- parse_iso8601_unix / relative-time offset handling ---
1291
1292    #[test]
1293    fn parse_iso8601_unix_negative_offset_matches_equivalent_utc() {
1294        // "-04:00" is 4 hours behind UTC, so 11:55 local == 15:55Z.
1295        assert_eq!(
1296            parse_iso8601_unix("2026-07-09T11:55:00-04:00"),
1297            parse_iso8601_unix("2026-07-09T15:55:00Z")
1298        );
1299    }
1300
1301    #[test]
1302    fn parse_iso8601_unix_positive_offset_matches_equivalent_utc() {
1303        // "+04:00" is 4 hours ahead of UTC, so 20:15 local == 16:15Z.
1304        assert_eq!(
1305            parse_iso8601_unix("2026-05-23T20:15:00+04:00"),
1306            parse_iso8601_unix("2026-05-23T16:15:00Z")
1307        );
1308    }
1309
1310    #[test]
1311    fn parse_iso8601_unix_zero_offset_matches_z() {
1312        assert_eq!(
1313            parse_iso8601_unix("2026-07-09T15:55:00+00:00"),
1314            parse_iso8601_unix("2026-07-09T15:55:00Z")
1315        );
1316    }
1317
1318    #[test]
1319    fn parse_iso8601_unix_compact_offset_form_matches_colon_form() {
1320        assert_eq!(
1321            parse_iso8601_unix("2026-07-09T11:55:00-0400"),
1322            parse_iso8601_unix("2026-07-09T11:55:00-04:00")
1323        );
1324    }
1325
1326    #[test]
1327    fn parse_iso8601_unix_fractional_seconds_with_offset() {
1328        // Fractional seconds are dropped (whole-second precision only) but
1329        // must not prevent the trailing offset from being applied.
1330        assert_eq!(
1331            parse_iso8601_unix("2026-07-09T11:55:00.123-04:00"),
1332            parse_iso8601_unix("2026-07-09T15:55:00Z")
1333        );
1334    }
1335
1336    #[test]
1337    fn parse_iso8601_unix_fractional_seconds_with_z() {
1338        assert_eq!(
1339            parse_iso8601_unix("2026-07-09T15:55:00.999Z"),
1340            parse_iso8601_unix("2026-07-09T15:55:00Z")
1341        );
1342    }
1343
1344    #[test]
1345    fn parse_iso8601_unix_bare_form_unchanged() {
1346        // No trailing Z/offset at all: existing "no offset" behavior preserved.
1347        assert_eq!(
1348            parse_iso8601_unix("2026-07-09T15:55:00"),
1349            parse_iso8601_unix("2026-07-09T15:55:00Z")
1350        );
1351    }
1352
1353    #[test]
1354    fn parse_iso8601_unix_malformed_tail_returns_none() {
1355        assert_eq!(parse_iso8601_unix("2026-07-09T15:55:00X"), None);
1356        assert_eq!(parse_iso8601_unix("2026-07-09T15:55:00+04"), None);
1357        assert_eq!(parse_iso8601_unix("2026-07-09T15:55:00."), None);
1358    }
1359
1360    #[test]
1361    fn parse_iso8601_unix_out_of_range_offset_returns_none() {
1362        // Hour out of range (>23), colon and compact forms.
1363        assert_eq!(parse_iso8601_unix("2026-07-09T15:55:00+24:00"), None);
1364        assert_eq!(parse_iso8601_unix("2026-07-09T15:55:00+2400"), None);
1365        // Minute out of range (>59), colon and compact forms.
1366        assert_eq!(parse_iso8601_unix("2026-07-09T15:55:00+01:60"), None);
1367        assert_eq!(parse_iso8601_unix("2026-07-09T15:55:00+0160"), None);
1368    }
1369
1370    #[test]
1371    fn parse_iso8601_unix_max_valid_offset_boundary_is_accepted() {
1372        // +23:59 / -23:59 are the largest valid offsets and must still parse.
1373        assert!(parse_iso8601_unix("2026-07-09T15:55:00+23:59").is_some());
1374        assert!(parse_iso8601_unix("2026-07-09T15:55:00-23:59").is_some());
1375        assert!(parse_iso8601_unix("2026-07-09T15:55:00+2359").is_some());
1376    }
1377
1378    #[test]
1379    fn compact_timestamp_offset_bearing_future_time_not_shown_as_ago() {
1380        // A wall-clock-identical-to-NOW timestamp carrying a "-02:00" offset
1381        // is actually 2h in the future; an offset-naive parser would misread
1382        // the wall-clock digits as UTC and report "0s ago".
1383        let out = compact_timestamp("2025-05-23T16:08:00-02:00", NOW);
1384        assert_ne!(out, "0s ago");
1385        assert_eq!(out, "2025-05-23T16:08");
1386    }
1387
1388    #[test]
1389    fn compact_timestamp_offset_bearing_past_time_renders_relative() {
1390        // "20:05+04:00" == "16:05Z", which is 3 minutes before NOW
1391        // (2025-05-23T16:08:00Z). Correct offset handling must produce
1392        // "3m ago"; the old offset-naive parser would compare wall-clock
1393        // 20:05 against NOW directly, landing outside the 24h window and
1394        // falling back to truncated absolute form instead.
1395        let out = compact_timestamp("2025-05-23T20:05:00+04:00", NOW);
1396        assert_eq!(out, "3m ago");
1397    }
1398}