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 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::{cmp::Ordering, collections::HashSet};
12
13use khive_types::VerbPresentationPolicy;
14use serde::{Deserialize, Serialize};
15use serde_json::{Map, Value};
16
17/// Exact note-body locations selected by the request boundary for
18/// `get/list(parse_content=true)`. No policy marker is inferred from user JSON.
19#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
20pub enum NoteContentScope {
21    #[default]
22    None,
23    Record,
24    Items,
25    Notes,
26}
27
28impl NoteContentScope {
29    /// Keep note bodies outside a metadata-only transform, including nulls,
30    /// empty containers/strings, and fields that resemble IDs or timestamps.
31    /// The transform must preserve record order and the get/list envelope.
32    pub fn protect(self, mut value: Value, transform: impl FnOnce(Value) -> Value) -> Value {
33        if self == Self::None {
34            return transform(value);
35        }
36        fn take_content(record: &mut Value) -> Option<Value> {
37            record.as_object_mut()?.remove("content")
38        }
39        fn restore_content(record: &mut Value, content: Option<Value>) {
40            if let (Some(record), Some(content)) = (record.as_object_mut(), content) {
41                record.insert("content".to_string(), content);
42            }
43        }
44        if self == Self::Record {
45            let content = take_content(&mut value);
46            let mut value = transform(value);
47            restore_content(&mut value, content);
48            return value;
49        }
50        let key = if self == Self::Items {
51            "items"
52        } else {
53            "notes"
54        };
55        let contents: Vec<_> = value
56            .get_mut(key)
57            .and_then(Value::as_array_mut)
58            .into_iter()
59            .flatten()
60            .map(take_content)
61            .collect();
62        let mut value = transform(value);
63        if let Some(records) = value.get_mut(key).and_then(Value::as_array_mut) {
64            for (record, content) in records.iter_mut().zip(contents) {
65                restore_content(record, content);
66            }
67        }
68        value
69    }
70
71    fn stringify_table_content(self, value: &mut Value) {
72        fn stringify(record: &mut Value) {
73            if let Some(content) = record.get_mut("content") {
74                if content.is_object() || content.is_array() {
75                    *content = Value::String(content.to_string());
76                }
77            }
78        }
79        match self {
80            Self::None => {}
81            Self::Record => stringify(value),
82            Self::Items | Self::Notes => {
83                let key = if self == Self::Items {
84                    "items"
85                } else {
86                    "notes"
87                };
88                if let Some(records) = value.get_mut(key).and_then(Value::as_array_mut) {
89                    records.iter_mut().for_each(stringify);
90                }
91            }
92        }
93    }
94}
95
96// ── OutputFormat ─────────────────────────────────────────────────────────────
97
98/// Output serialization format for verb results (ADR-078).
99///
100/// Orthogonal to [`PresentationMode`]: `PresentationMode` controls field-level
101/// transforms (UUID shortening, exact UTC timestamps, empty-field dropping);
102/// `OutputFormat` controls how the resulting `serde_json::Value` is serialized
103/// or rendered to the wire string.
104///
105/// Default is [`OutputFormat::Json`] on every surface: compact and
106/// machine-walkable. Verbose JSON is lossless; Agent JSON additionally elides
107/// `namespace="local"` and duplicate `properties` entries (ADR-078 Amendment
108/// 3, Machine scope) but keeps `full_id` — it is the caller's chaining
109/// handle, not a redundant field.
110///
111/// Note: `Yaml` is a clean follow-up — implemented as a 3-variant enum
112/// (`Json`, `Auto`, `Table`) per ADR-078 §"yaml" which permits omission when
113/// the in-tree emitter would balloon.
114#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
115#[serde(rename_all = "snake_case")]
116pub enum OutputFormat {
117    /// Compact JSON (`serde_json::to_string`). Default; lossless in Verbose
118    /// presentation, Machine-scope redundancy-reduced in Agent presentation
119    /// (`full_id` retained — see [`RedundancyScope`]).
120    #[default]
121    Json,
122    /// Shape-aware: markdown table for homogeneous record arrays (with
123    /// envelope siblings preserved as trailing `key: value` lines),
124    /// compact-JSON fallback for every other shape.
125    Auto,
126    /// Force the markdown-table renderer regardless of detected shape.
127    /// Ordinary results share Auto's dispatch. Opt-in parsed note bodies are
128    /// stringified for table display; Auto preserves their JSON values.
129    Table,
130}
131
132/// Cell truncation limit for markdown-table rendering (ADR-078 §3a).
133const CELL_TRUNCATE: usize = 120;
134
135/// Scalar payload fields hoisted from `properties` to the top level in the
136/// auto/table pre-pass, when no top-level sibling of that name exists.
137/// View-only (never applied to `json`): without the hoist, a table row for a
138/// scheduled event cannot say when it fires or whether it is pending —
139/// those fields exist only inside the nested `properties` bag.
140const PROPERTY_HOIST_FIELDS: &[&str] = &["trigger_at", "due", "status"];
141
142// ── Public render entry point ────────────────────────────────────────────────
143
144/// Render a successful verb result value to a wire string using the given format.
145///
146/// Called at the single serialization seam (ADR-078 §9) AFTER all `$prev` chain
147/// resolution and AFTER the [`PresentationMode`] transform.
148///
149/// Error envelopes (`ok=false`) are never passed here — the caller must handle
150/// them as compact JSON directly (ADR-078 §8.2).
151///
152/// Agent presentation applies the redundancy-reduction pre-pass (§7) for every
153/// format, including JSON — at Machine scope for `json` (keeps `full_id`) and
154/// View scope for `auto`/`table` (drops it too, ADR-078 Amendment 3). Auto/table
155/// also apply View scope for Human presentation; Verbose remains canonical and
156/// unreduced. The prepared value is then encoded as compact JSON or dispatched
157/// to the shape-aware renderer. Verbose also disables cell truncation in the
158/// table renderer (§3a).
159pub fn render_format(value: Value, format: OutputFormat, presentation: PresentationMode) -> String {
160    render_format_with_note_content(value, format, presentation, NoteContentScope::None)
161}
162
163/// Render an opt-in parsed-note response without treating its body as metadata
164/// or a top-level record table. Auto preserves parsed values; Table stringifies
165/// non-scalar content for display only.
166pub fn render_format_with_note_content(
167    value: Value,
168    format: OutputFormat,
169    presentation: PresentationMode,
170    content_scope: NoteContentScope,
171) -> String {
172    let value = prepare_format_value_with_note_content(value, format, presentation, content_scope);
173    match format {
174        OutputFormat::Json => serde_json::to_string(&value).unwrap_or_else(|_| "null".to_string()),
175        OutputFormat::Auto if content_scope != NoteContentScope::None => {
176            serde_json::to_string(&value).unwrap_or_else(|_| "null".to_string())
177        }
178        OutputFormat::Auto | OutputFormat::Table => {
179            render_auto(value, presentation != PresentationMode::Verbose)
180        }
181    }
182}
183
184/// Prepare a successful result for its requested format at the wire boundary.
185///
186/// JSON remains a JSON value in compounded response envelopes, so the MCP seam
187/// uses this helper directly instead of round-tripping through a serialized
188/// string. Keeping the policy here prevents the single-value and compounded
189/// paths from drifting.
190pub fn prepare_format_value(
191    value: Value,
192    format: OutputFormat,
193    presentation: PresentationMode,
194) -> Value {
195    let scope = match format {
196        OutputFormat::Json if presentation == PresentationMode::Agent => {
197            Some(RedundancyScope::Machine)
198        }
199        OutputFormat::Json => None,
200        OutputFormat::Auto | OutputFormat::Table if presentation != PresentationMode::Verbose => {
201            Some(RedundancyScope::View)
202        }
203        OutputFormat::Auto | OutputFormat::Table => None,
204    };
205    match scope {
206        Some(scope) => apply_redundancy_drop(value, scope),
207        None => value,
208    }
209}
210
211/// Apply format reductions to record metadata while preserving parsed bodies.
212pub fn prepare_format_value_with_note_content(
213    value: Value,
214    format: OutputFormat,
215    presentation: PresentationMode,
216    content_scope: NoteContentScope,
217) -> Value {
218    let mut value = content_scope.protect(value, |value| {
219        prepare_format_value(value, format, presentation)
220    });
221    if format == OutputFormat::Table {
222        content_scope.stringify_table_content(&mut value);
223    }
224    value
225}
226
227// ── Redundancy-reduction pre-pass (ADR-078 §7, Amendment 3) ────────────────
228
229/// Which redundancy-reduction rules apply (ADR-078 §7, Amendment 3).
230///
231/// `View` is the rendered-view scope (`auto`/`table`): the caller has opted
232/// into a display form that is never chained on programmatically, so
233/// `full_id` — the caller's stable chaining handle — is safe to drop along
234/// with the other reductions.
235///
236/// `Machine` is the machine-contract scope (Agent `json`): this is the
237/// default MCP response shape, the one a caller chains `$prev` and strict
238/// verb parameters (`memory.feedback(target_id=...)`, `gtd.assign
239/// (context_entity_id=...)`, etc.) from. It applies the `namespace` elision
240/// and the `properties` dedup, but never drops `full_id`, and the
241/// `properties` dedup itself exempts `full_id`/`ROUND_TRIP_FULL_UUID_FIELDS`
242/// keys — a caller reading a strict identifier out of `properties` must see
243/// the same canonical value there as at the top level. Dropping any of
244/// those would return a value the strict verbs that consume it reject.
245#[derive(Debug, Clone, Copy, PartialEq, Eq)]
246pub enum RedundancyScope {
247    View,
248    Machine,
249}
250
251/// Apply the redundancy-reduction pre-pass (ADR-078 §7, Amendment 3) to a
252/// value at the given [`RedundancyScope`].
253///
254/// Applies at most ONE pass over the value. This function is the canonical
255/// entry for the pre-pass; the per-record logic lives in `drop_record`.
256///
257/// Applied for Agent presentation in every format (`Machine` scope for
258/// `json`, `View` scope for non-Verbose `auto`/`table`). Callers are
259/// responsible for checking those conditions; this function applies
260/// unconditionally.
261pub fn apply_redundancy_drop(value: Value, scope: RedundancyScope) -> Value {
262    match value {
263        Value::Object(_) => drop_record(value, scope),
264        Value::Array(arr) => Value::Array(
265            arr.into_iter()
266                .map(|v| {
267                    if v.is_object() {
268                        drop_record(v, scope)
269                    } else {
270                        v
271                    }
272                })
273                .collect(),
274        ),
275        other => other,
276    }
277}
278
279/// Apply per-record redundancy rules (§7.1, §7.2, §7.3) to a single record object.
280fn drop_record(value: Value, scope: RedundancyScope) -> Value {
281    let Value::Object(mut map) = value else {
282        return value;
283    };
284
285    // `full_id` is a chaining handle. Dropping it is safe only in the View
286    // scope (a rendered auto/table view the caller opted into); the Machine
287    // scope (Agent JSON, the default machine contract) must keep it.
288    if scope == RedundancyScope::View {
289        map.remove("full_id");
290    }
291
292    // `"local"` is the common case, so only surface `namespace` when it isn't.
293    if map.get("namespace").and_then(Value::as_str) == Some("local") {
294        map.remove("namespace");
295    }
296
297    // Properties dedup: drop key-value pairs from `properties` that
298    // duplicate an identical top-level sibling. The scalar hoist for table
299    // columns lives in `hoist_table_scalars`, applied only on the table
300    // path: reshaping the compact-JSON fallback would move fields with no
301    // column to gain.
302    //
303    // Machine scope exempts `full_id`/`ROUND_TRIP_FULL_UUID_FIELDS`: a caller
304    // that reads the strict identifier out of `properties` (rather than the
305    // top-level convenience mirror) must keep getting the same canonical
306    // value there too. View scope is unaffected — those callers already
307    // opted into a display form, not the strict round-trip contract.
308    let props_val = map.remove("properties");
309    if let Some(Value::Object(props)) = props_val {
310        let mut new_props = Map::new();
311        for (k, v) in props {
312            let strict_round_trip = scope == RedundancyScope::Machine
313                && (k == "full_id" || ROUND_TRIP_FULL_UUID_FIELDS.contains(&k.as_str()));
314            if !strict_round_trip && map.get(&k) == Some(&v) {
315                continue;
316            }
317            new_props.insert(k, v);
318        }
319        if !new_props.is_empty() {
320            map.insert("properties".to_string(), Value::Object(new_props));
321        }
322    } else if let Some(other) = props_val {
323        map.insert("properties".to_string(), other);
324    }
325
326    // A stream entry's record is opaque caller JSON, including arrays of
327    // objects whose fields happen to look like metadata.
328    let stream_entry = is_stream_entry(&map);
329    // Recurse into array values so nested record arrays are also reduced.
330    let out: Map<String, Value> = map
331        .into_iter()
332        .map(|(k, v)| {
333            if stream_entry && k == "record" {
334                return (k, v);
335            }
336            let v = match v {
337                Value::Array(arr) => Value::Array(
338                    arr.into_iter()
339                        .map(|item| {
340                            if item.is_object() {
341                                drop_record(item, scope)
342                            } else {
343                                item
344                            }
345                        })
346                        .collect(),
347                ),
348                other => other,
349            };
350            (k, v)
351        })
352        .collect();
353    Value::Object(out)
354}
355
356// ── Shape-aware rendering (`auto`) ──────────────────────────────────────────
357
358/// A record array located inside a value: the records, their ordered column
359/// keys, and — when the array sat under an object key — that key's name, so
360/// the caller can enumerate the remaining (sibling) keys.
361struct RecordArray {
362    key: Option<String>,
363    records: Vec<Value>,
364    columns: Vec<String>,
365}
366
367/// Render a value using shape-aware dispatch (ADR-078 §3, as amended).
368///
369/// Shape (a): homogeneous record array → markdown table, with any envelope
370/// siblings preserved as trailing `key: value` lines.
371/// Every other shape → compact JSON, lossless by construction. The former
372/// kv-block renderer for single records is removed: its truncation destroyed
373/// single-record payloads (e.g. a compose briefing's `markdown` field).
374fn render_auto(value: Value, truncate: bool) -> String {
375    match locate_record_array(&value) {
376        Some(found) => render_table_with_siblings(&value, &found, truncate),
377        None => serde_json::to_string(&value).unwrap_or_else(|_| "null".to_string()),
378    }
379}
380
381/// Find the first homogeneous record array in `value`.
382///
383/// Checks:
384/// 1. `value` itself is an array of 2+ objects.
385/// 2. `value` is an object with a key whose value is an array of 2+ objects.
386fn locate_record_array(value: &Value) -> Option<RecordArray> {
387    let build = |key: Option<String>, arr: &[Value]| {
388        let records: Vec<Value> = arr.iter().cloned().map(hoist_table_scalars).collect();
389        let columns = collect_keys(&records);
390        RecordArray {
391            key,
392            records,
393            columns,
394        }
395    };
396    match value {
397        Value::Array(arr) if is_record_array(arr) => Some(build(None, arr)),
398        Value::Object(map) => map.iter().find_map(|(k, v)| match v {
399            Value::Array(arr) if is_record_array(arr) => Some(build(Some(k.clone()), arr)),
400            _ => None,
401        }),
402        _ => None,
403    }
404}
405
406/// Hoist `PROPERTY_HOIST_FIELDS` scalars out of a record's `properties` bag
407/// to the top level (never overwriting a top-level sibling), so the table
408/// renderer can surface them as columns — a scheduled event's
409/// `trigger_at`/`status` live only inside `properties`, and a nested cell
410/// renders as `{…}`. Table path only (ADR-078 Amendment 2): the compact-JSON
411/// fallback keeps its shape.
412fn hoist_table_scalars(record: Value) -> Value {
413    let Value::Object(mut map) = record else {
414        return record;
415    };
416    let mut props = match map.remove("properties") {
417        Some(Value::Object(props)) => props,
418        Some(other) => {
419            // Non-object `properties` is not a bag to hoist from — restore it.
420            map.insert("properties".to_string(), other);
421            return Value::Object(map);
422        }
423        None => return Value::Object(map),
424    };
425    for field in PROPERTY_HOIST_FIELDS {
426        if map.contains_key(*field) {
427            continue;
428        }
429        if props
430            .get(*field)
431            .is_some_and(|v| !v.is_object() && !v.is_array())
432        {
433            let v = props.remove(*field).expect("checked above");
434            map.insert((*field).to_string(), v);
435        }
436    }
437    if !props.is_empty() {
438        map.insert("properties".to_string(), Value::Object(props));
439    }
440    Value::Object(map)
441}
442
443/// An array of 2+ objects qualifies as a record array.
444fn is_record_array(arr: &[Value]) -> bool {
445    arr.len() >= 2 && arr.iter().all(Value::is_object)
446}
447
448/// Render the located record array as a table, then append one `key: value`
449/// line per remaining top-level key. Envelope fields (`has_more`, `offset`,
450/// counts) must survive rendering: dropping them made a truncated `query`
451/// page read as complete.
452fn render_table_with_siblings(value: &Value, found: &RecordArray, truncate: bool) -> String {
453    let mut out = render_table(&found.records, &found.columns, truncate);
454    let (Value::Object(map), Some(array_key)) = (value, &found.key) else {
455        return out;
456    };
457    for (k, v) in map {
458        if k == array_key {
459            continue;
460        }
461        let text = match v {
462            // A newline-bearing string renders as its JSON literal (one line,
463            // lossless) so a stored value cannot fabricate a sibling line or
464            // table row (ADR-078 Amendment 2 escaping contract).
465            Value::String(s) if !s.contains(['\n', '\r']) => s.clone(),
466            // Non-string scalars and nested values: compact JSON, untruncated
467            // — sibling fidelity is the point of this path.
468            other => serde_json::to_string(other).unwrap_or_default(),
469        };
470        out.push_str(&format!("{k}: {text}\n"));
471    }
472    out
473}
474
475/// Collect column names in first-seen order across all records.
476fn collect_keys(records: &[Value]) -> Vec<String> {
477    let mut seen = HashSet::new();
478    let mut keys = Vec::new();
479    for record in records {
480        if let Value::Object(map) = record {
481            for k in map.keys() {
482                if seen.insert(k.clone()) {
483                    keys.push(k.clone());
484                }
485            }
486        }
487    }
488    keys
489}
490
491// ── Markdown table renderer (ADR-078 §3a) ───────────────────────────────────
492
493/// Render a record array as a GitHub-Flavored Markdown table.
494fn render_table(records: &[Value], keys: &[String], truncate: bool) -> String {
495    let mut out = String::new();
496
497    out.push('|');
498    for k in keys {
499        out.push(' ');
500        out.push_str(k);
501        out.push_str(" |");
502    }
503    out.push('\n');
504
505    out.push('|');
506    for _ in keys {
507        out.push_str("---|");
508    }
509    out.push('\n');
510
511    for record in records {
512        out.push('|');
513        for k in keys {
514            let cell = record.get(k).unwrap_or(&Value::Null);
515            let text = cell_text(k, cell, truncate);
516            out.push(' ');
517            out.push_str(&text);
518            out.push_str(" |");
519        }
520        out.push('\n');
521    }
522
523    out
524}
525
526/// Column names exempt from cell truncation: identity and decision fields
527/// must arrive whole — a truncated id cannot be resolved and a truncated
528/// title cannot be selected on.
529fn truncation_exempt(key: &str) -> bool {
530    matches!(
531        key,
532        "id" | "kind"
533            | "status"
534            | "priority"
535            | "relation"
536            | "title"
537            | "name"
538            | "signature"
539            | "slug"
540            | "assignee"
541            | "from"
542            | "to"
543            | "due"
544    ) || key.ends_with("_id")
545        || key.ends_with("_at")
546        || key.starts_with("due")
547}
548
549/// Format a cell value: escape `|`, collapse newlines, truncate to ~120 chars
550/// unless `truncate` is off or the column is identity/decision-bearing.
551///
552/// Nested values are elided to a constant marker (`{…}` / `[…]`) rather than
553/// stringified and cut mid-JSON: truncated pseudo-JSON reads as data while
554/// silently missing fields. Full content is one `format=json` or `get` away.
555fn cell_text(key: &str, value: &Value, truncate: bool) -> String {
556    let raw = match value {
557        Value::Null => String::new(),
558        Value::Bool(b) => b.to_string(),
559        Value::Number(n) => n.to_string(),
560        Value::String(s) => s.clone(),
561        Value::Object(_) => return "{…}".to_string(),
562        Value::Array(arr) => {
563            if arr.iter().any(|v| v.is_object() || v.is_array()) {
564                return "[…]".to_string();
565            }
566            // Arrays of scalars (tags, ids) stay compact JSON.
567            serde_json::to_string(value).unwrap_or_default()
568        }
569    };
570
571    // Escape literal `|` and collapse embedded newlines to a space.
572    let escaped = raw.replace('|', "\\|").replace(['\n', '\r'], " ");
573
574    if !truncate || truncation_exempt(key) {
575        return escaped;
576    }
577
578    // Truncate to approximately CELL_TRUNCATE *characters* (char boundary,
579    // not byte index — slicing on a byte offset can panic on multi-byte chars).
580    let char_count = escaped.chars().count();
581    if char_count > CELL_TRUNCATE {
582        let truncated: String = escaped.chars().take(CELL_TRUNCATE).collect();
583        format!("{truncated}...")
584    } else {
585        escaped
586    }
587}
588
589/// Format an instant in UTC using RFC3339 whole seconds and a `Z` suffix.
590///
591/// Fractional seconds are omitted. Chrono's supported years and signed
592/// expanded-year rendering are inherited; no additional range validation is done.
593/// Parsing and error policy remain with callers.
594pub fn format_utc_rfc3339_seconds<Tz: chrono::TimeZone>(value: &chrono::DateTime<Tz>) -> String {
595    value
596        .with_timezone(&chrono::Utc)
597        .to_rfc3339_opts(chrono::SecondsFormat::Secs, true)
598}
599
600/// Convert a microsecond epoch `i64` to an exact UTC ISO-8601 string.
601///
602/// Output always carries six fractional digits and a `Z` suffix. Years
603/// 0000–9999 use the RFC 3339 spelling; other years use Chrono's signed
604/// expanded-year convention. The full `i64` microsecond range is supported.
605pub fn micros_to_iso(micros: i64) -> String {
606    if let Some(dt) = chrono::DateTime::<chrono::Utc>::from_timestamp_micros(micros) {
607        return dt.to_rfc3339_opts(chrono::SecondsFormat::Micros, true);
608    }
609
610    // Gregorian leap positions repeat every 400 years. Euclidean reduction
611    // keeps the remainder in 1970–2369, including for negative timestamps.
612    const CYCLE_MICROS: i64 = 146_097 * 86_400 * 1_000_000;
613    let cycles = micros.div_euclid(CYCLE_MICROS);
614    let reduced =
615        chrono::DateTime::<chrono::Utc>::from_timestamp_micros(micros.rem_euclid(CYCLE_MICROS))
616            .expect("400-year remainder is within Chrono's range");
617    let year = i64::from(chrono::Datelike::year(&reduced)) + cycles * 400;
618    let rendered = reduced.to_rfc3339_opts(chrono::SecondsFormat::Micros, true);
619    format!("{year:+05}{}", &rendered[4..])
620}
621
622/// Parse an RFC 3339 timestamp (offset required) into microsecond epoch
623/// `i64` — the inverse of [`micros_to_iso`] within the RFC 3339 four-digit
624/// year range. Expanded ISO years are not accepted. This is the single parse
625/// point for caller-supplied instants entering storage comparisons.
626///
627/// Leading/trailing whitespace is tolerated. Date-only and offset-less forms
628/// are rejected; callers own the verb-specific error context around the
629/// returned `ParseError`.
630pub fn rfc3339_to_utc_micros(raw: &str) -> Result<i64, chrono::ParseError> {
631    chrono::DateTime::parse_from_rfc3339(raw.trim()).map(|dt| dt.timestamp_micros())
632}
633
634/// How the response envelope is presented to the caller.
635#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
636#[serde(rename_all = "snake_case")]
637pub enum PresentationMode {
638    /// Token-efficient. Default for MCP callers (agents).
639    ///
640    /// Short UUIDs (8-char), exact UTC timestamps with relative labels on
641    /// list rows, empty fields dropped, structural nulls preserved, and
642    /// score fields truncated to 3 significant figures.
643    #[default]
644    Agent,
645    /// Full canonical shape. Default for `kkernel exec` and CI/scripted callers.
646    ///
647    /// No transformation — handler output passes through as-is.
648    Verbose,
649    /// Pretty-printed terminal output. Default for `khive` CLI.
650    ///
651    /// **At the MCP runtime level this is identical to `Verbose`** — the
652    /// canonical JSON is returned unchanged. Terminal formatting (relative
653    /// timestamps, glyph substitution, table layout) is applied by the CLI
654    /// layer (`khive-cli::format::pretty`), not the MCP response pipeline.
655    Human,
656}
657
658/// Lifecycle/operational `null` fields that are PRESERVED in Agent mode.
659///
660/// These fields carry state meaning (absent ≠ known-unknown) and must not be
661/// dropped. The channel-health fields distinguish a quarantine-only identity
662/// from a heartbeat row whose liveness facts were actually observed. The
663/// outbox scan fields distinguish a channel with no recorded scan, a scan that
664/// has not yet succeeded, and an empty pending page from observed values.
665const LIFECYCLE_NULL_PRESERVE: &[&str] = &[
666    "completed_at",
667    "deleted_at",
668    "due_at",
669    "read_at",
670    "started_at",
671    "superseded_at",
672    "applied_at",
673    "withdrawn_at",
674    "reviewed_at",
675    "parent_id",
676    "superseded_by",
677    "replaced_by",
678    "poll_interval_secs",
679    "stalled",
680    "last_success_at",
681    "last_poll_attempt_at",
682    "last_failure_at",
683    "last_error",
684    "consecutive_failures",
685    "consecutive_held_polls",
686    "held_messages",
687    "last_held_at",
688    "last_quarantined_after_hold_at",
689    "originals_not_retained",
690    "last_original_not_retained_at",
691    "last_original_not_retained_reason",
692    "original_retention_state",
693    "notices_failed",
694    "last_notice_failed_at",
695    "notice_config_error",
696    "outbox",
697    "last_scan_completed_at",
698    "last_scan_succeeded",
699    "scan_interval_secs",
700    "pending_rows_seen",
701    "pending_rows_limit",
702    "oldest_pending_created_at",
703    "oldest_pending_age_secs",
704];
705
706/// Empty collection fields that define a stable response envelope and must
707/// survive Agent-mode compaction. Dropping these turns an empty page into a
708/// different response type and leaves callers unable to distinguish an empty
709/// result from a missing/unsupported field.
710const EMPTY_ARRAY_PRESERVE: &[&str] = &[
711    "items",
712    "entities",
713    "notes",
714    "edges",
715    "results",
716    "entries",
717    "neighbors",
718];
719
720fn is_stable_list_envelope(map: &Map<String, Value>) -> bool {
721    map.contains_key("requested_limit")
722        && map.contains_key("effective_limit")
723        && map.contains_key("limit_clamped")
724        && EMPTY_ARRAY_PRESERVE
725            .iter()
726            .any(|field| map.contains_key(*field))
727}
728
729/// Keyset cursor pages outside the ADR-023 envelope — `knowledge.list(after=…)`
730/// returns `{results, limit, order, next_after}` — are structural in the same
731/// way (ADR-045 Amendment 4): an empty `results` page and a terminal
732/// `next_after: null` are the walk's completion signals, so a caller that
733/// cannot see them cannot stop. A `results` array without a sibling
734/// `next_after` key is an ordinary response and gets the generic transform.
735fn is_keyset_cursor_envelope(map: &Map<String, Value>) -> bool {
736    map.contains_key("next_after")
737        && (map.get("results").is_some_and(Value::is_array)
738            || (map.get("head_seq").is_some_and(Value::is_number)
739                && map.get("entries").is_some_and(Value::is_array)))
740}
741
742fn is_stream_entry(map: &Map<String, Value>) -> bool {
743    map.get("seq").is_some_and(Value::is_number)
744        && map.contains_key("id")
745        && map.contains_key("created_at")
746        && map.contains_key("record")
747}
748
749/// Field names carrying caller-supplied payload timestamps that must never be
750/// transformed, regardless of nesting.
751///
752/// These encode domain semantics the caller needs to round-trip verbatim —
753/// e.g. `trigger_at` on a `schedule.remind`/`schedule.schedule` create
754/// response, returned as a top-level convenience field alongside `id` and
755/// `full_id`, not nested under `"properties"`. The `inside_properties` guard
756/// alone only protects fields nested under a literal `"properties"` key
757/// (as returned by `agenda`/`get`); it does not cover this top-level case,
758/// which older Agent presentation rewrote into a relative or minute-truncated
759/// form, discarding the seconds and offset needed for round trips (#871).
760///
761/// `due` on `gtd.assign`/`gtd.tasks`/`gtd.next` responses is the same shape:
762/// a top-level convenience field mirroring `properties.due`, which
763/// `parse_due` already normalizes to full RFC 3339.
764const PAYLOAD_TIMESTAMP_FIELDS: &[&str] = &["trigger_at", "due"];
765
766/// UUID fields whose canonical value is itself a strict-verb input.
767///
768/// Shortening these would make a successful response fail when submitted back
769/// to the verb that produced or consumes it. `context_entity_id` and
770/// `thread_id` are explicit stable references rather than prefix searches;
771/// `outbound_ref` is the exact correlation key consumed by `comm.delivered`;
772/// `parent_id` is the explicit ancestry reference consumed by `propose`;
773/// `session_id` is an exact event-list filter; and `project_id` is the exact
774/// provenance anchor required by git issue and pull-request creation.
775const ROUND_TRIP_FULL_UUID_FIELDS: &[&str] = &[
776    "context_entity_id",
777    "thread_id",
778    "outbound_ref",
779    "parent_id",
780    "session_id",
781    "project_id",
782];
783
784/// Score field names that are truncated to 3 significant figures in Agent mode.
785const SCORE_FIELDS: &[&str] = &[
786    "score",
787    "rank_score",
788    "vector_similarity",
789    "keyword_score",
790    "salience",
791    "decay_factor",
792    "rrf_score",
793    "similarity",
794    "cross_encoder_score",
795    "graph_proximity_score",
796];
797
798/// UUID v4 canonical string length (8-4-4-4-12 = 32 hex + 4 dashes = 36).
799const UUID_CANONICAL_LEN: usize = 36;
800
801/// Return true for fields whose whole-string UUID values may be shortened in
802/// Agent mode. Content-like fields are intentionally excluded even when their
803/// value happens to be UUID-shaped.
804///
805/// `full_id` and strict round-trip fields are explicitly excluded: their
806/// purpose is to give callers a stable chaining handle, so shortening them
807/// would produce a value that the corresponding strict verb rejects.
808fn should_shorten_uuid_field(key: &str) -> bool {
809    if key == "full_id" || key == "bridge_instance_id" || ROUND_TRIP_FULL_UUID_FIELDS.contains(&key)
810    {
811        return false;
812    }
813    key == "id" || key.ends_with("_id") || matches!(key, "superseded_by" | "replaced_by")
814}
815
816/// Transform a successful verb result value according to the given
817/// [`PresentationMode`].
818///
819/// - `Verbose` / `Human`: returns `value` unchanged.
820/// - `Agent`: applies UUID shortening, exact UTC timestamp rendering, empty-field
821///   dropping, structural-null preservation, and score truncation.
822///
823/// `now_unix_seconds` is a whole-second clock for callers without a finer
824/// sample. All relative list-row labels within a response use this instant.
825pub fn present(value: Value, mode: PresentationMode, now_unix_seconds: i64) -> Value {
826    present_with_policy(
827        value,
828        mode,
829        now_unix_seconds,
830        VerbPresentationPolicy::Standard,
831    )
832}
833
834/// Present a successful result using its trusted registered verb policy.
835/// Receipt policies preserve only their closed string paths, not descendants.
836pub fn present_with_policy(
837    value: Value,
838    mode: PresentationMode,
839    now_unix_seconds: i64,
840    policy: VerbPresentationPolicy,
841) -> Value {
842    present_with_policy_at(value, mode, now_unix_seconds.into(), policy)
843}
844
845/// A response-wide clock sample. Fractional precision distinguishes a newly
846/// stored row from a truly future row within the same Unix second.
847#[derive(Clone, Copy)]
848pub struct PresentationNow {
849    seconds: i64,
850    nanoseconds: u32,
851}
852
853impl From<i64> for PresentationNow {
854    fn from(seconds: i64) -> Self {
855        Self {
856            seconds,
857            nanoseconds: 0,
858        }
859    }
860}
861
862impl From<i32> for PresentationNow {
863    fn from(seconds: i32) -> Self {
864        i64::from(seconds).into()
865    }
866}
867
868impl From<chrono::DateTime<chrono::Utc>> for PresentationNow {
869    fn from(now: chrono::DateTime<chrono::Utc>) -> Self {
870        Self {
871            seconds: now.timestamp(),
872            nanoseconds: now.timestamp_subsec_nanos(),
873        }
874    }
875}
876
877/// Present with the response's single sampled instant, including its fraction.
878pub fn present_with_policy_at(
879    value: Value,
880    mode: PresentationMode,
881    now: PresentationNow,
882    policy: VerbPresentationPolicy,
883) -> Value {
884    if policy == VerbPresentationPolicy::AlwaysVerbose {
885        return value;
886    }
887    match mode {
888        PresentationMode::Verbose | PresentationMode::Human => value,
889        PresentationMode::Agent => {
890            // These root paths are selected by the registered agenda handler,
891            // never by a payload marker or a nested lookalike. The cursor's
892            // timestamp spelling and full UUID jointly define its seek position.
893            let mut value = value;
894            let agenda_next = (policy == VerbPresentationPolicy::AgendaContinuation)
895                .then(|| value.as_object_mut().and_then(|map| map.remove("next")))
896                .flatten();
897            let agenda_empty = policy == VerbPresentationPolicy::AgendaContinuation
898                && value
899                    .get("events")
900                    .and_then(Value::as_array)
901                    .is_some_and(Vec::is_empty);
902            let config = AgentTransformConfig {
903                preserved_nulls: LIFECYCLE_NULL_PRESERVE.iter().copied().collect(),
904                scores: SCORE_FIELDS.iter().copied().collect(),
905                payload_timestamps: PAYLOAD_TIMESTAMP_FIELDS.iter().copied().collect(),
906                now,
907            };
908            let mut value = transform_agent(
909                value,
910                &config,
911                AgentTreeContext {
912                    inside_properties: false,
913                    object_properties: false,
914                    array_member: false,
915                    receipt_context: match policy {
916                        VerbPresentationPolicy::StreamAppendReceipt => ReceiptContext::AppendRoot,
917                        VerbPresentationPolicy::StreamBatchReceipts => ReceiptContext::BatchRoot,
918                        _ => ReceiptContext::None,
919                    },
920                },
921            );
922            if let Some(map) = value.as_object_mut() {
923                if let Some(next) = agenda_next {
924                    map.insert("next".into(), next);
925                }
926                if agenda_empty {
927                    map.insert("events".into(), Value::Array(Vec::new()));
928                }
929            }
930            value
931        }
932    }
933}
934
935#[derive(Clone, Copy, PartialEq, Eq)]
936enum ReceiptContext {
937    None,
938    AppendRoot,
939    BatchRoot,
940    BatchResults,
941    BatchMember,
942}
943
944struct AgentTransformConfig {
945    preserved_nulls: HashSet<&'static str>,
946    scores: HashSet<&'static str>,
947    payload_timestamps: HashSet<&'static str>,
948    now: PresentationNow,
949}
950
951#[derive(Clone, Copy)]
952struct AgentTreeContext {
953    inside_properties: bool,
954    object_properties: bool,
955    array_member: bool,
956    receipt_context: ReceiptContext,
957}
958
959/// Apply the Agent-mode transform to an arbitrary JSON value.
960///
961/// `inside_properties` is `true` when recursing inside a `"properties"` value.
962/// Caller-supplied payload timestamps (e.g. `trigger_at`) must not be normalized
963/// because they encode domain semantics the agent may need to round-trip.
964fn transform_agent(
965    value: Value,
966    config: &AgentTransformConfig,
967    context: AgentTreeContext,
968) -> Value {
969    match value {
970        Value::Object(map) => {
971            let preserve_list_envelope =
972                is_stable_list_envelope(&map) || is_keyset_cursor_envelope(&map);
973            let stream_entry = is_stream_entry(&map);
974            let relative_created_at = (context.array_member
975                && !context.inside_properties
976                && !map.contains_key("created_at_relative")
977                && !matches!(
978                    context.receipt_context,
979                    ReceiptContext::AppendRoot | ReceiptContext::BatchMember
980                ))
981            .then(|| map.get("created_at").and_then(Value::as_str))
982            .flatten()
983            .and_then(|raw| {
984                let (_, created_at) = exact_timestamp(raw)?;
985                let age = config.now.seconds.checked_sub(created_at)?;
986                // Floor the full fractional age. The lexicographic comparison
987                // also handles canonical fractions longer than nanoseconds.
988                age.checked_sub(i64::from(
989                    timestamp_fraction_cmp(raw, config.now.nanoseconds) == Ordering::Greater,
990                ))
991            })
992            .filter(|difference| *difference >= 0)
993            .map(relative_time);
994            let mut out = Map::new();
995            for (k, v) in map {
996                if stream_entry && k == "record" {
997                    out.insert(k, v);
998                    continue;
999                }
1000                if v.is_string()
1001                    && ((context.receipt_context == ReceiptContext::AppendRoot
1002                        && k == "created_at")
1003                        || (context.receipt_context == ReceiptContext::BatchMember
1004                            && matches!(k.as_str(), "created_at" | "updated_at")))
1005                {
1006                    out.insert(k, v);
1007                    continue;
1008                }
1009                let child_receipt_context = if context.receipt_context == ReceiptContext::BatchRoot
1010                    && k == "results"
1011                    && v.is_array()
1012                {
1013                    ReceiptContext::BatchResults
1014                } else {
1015                    ReceiptContext::None
1016                };
1017                // ADR-045 Amendment 3 scopes the empty-string carve-out to
1018                // strings nested under an object-valued `properties`; a
1019                // scalar or array `properties` value gets no carve-out.
1020                let child_inside_properties = context.inside_properties || k == "properties";
1021                let child_object_properties =
1022                    context.object_properties || (k == "properties" && v.is_object());
1023                let transformed = transform_field_agent(
1024                    &k,
1025                    v,
1026                    config,
1027                    AgentFieldContext {
1028                        inside_properties: child_inside_properties,
1029                        object_properties: child_object_properties,
1030                        preserve_list_envelope,
1031                        receipt_context: child_receipt_context,
1032                    },
1033                );
1034                match transformed {
1035                    None => {} // drop
1036                    Some(tv) => {
1037                        out.insert(k, tv);
1038                    }
1039                }
1040            }
1041            if let Some(relative) = relative_created_at {
1042                out.insert("created_at_relative".to_string(), Value::String(relative));
1043            }
1044            Value::Object(out)
1045        }
1046        Value::Array(arr) => {
1047            let items: Vec<Value> = arr
1048                .into_iter()
1049                .map(|v| {
1050                    transform_agent(
1051                        v,
1052                        config,
1053                        AgentTreeContext {
1054                            inside_properties: context.inside_properties,
1055                            object_properties: context.object_properties,
1056                            array_member: true,
1057                            receipt_context: if context.receipt_context
1058                                == ReceiptContext::BatchResults
1059                            {
1060                                ReceiptContext::BatchMember
1061                            } else {
1062                                ReceiptContext::None
1063                            },
1064                        },
1065                    )
1066                })
1067                .collect();
1068            Value::Array(items)
1069        }
1070        other => other,
1071    }
1072}
1073
1074/// Transform a single named field value under Agent mode.
1075///
1076/// Returns `None` if the field should be dropped.
1077///
1078/// `inside_properties` suppresses timestamp normalization for caller-submitted
1079/// payload values nested under a literal `"properties"` key (e.g. `trigger_at`
1080/// as returned by `agenda`/`get`). `payload_timestamps` suppresses normalization
1081/// by field name regardless of nesting, covering top-level convenience fields
1082/// such as the `trigger_at` returned directly in a `schedule.remind`/
1083/// `schedule.schedule` create response (#871). Metadata timestamps at the top
1084/// level (`created_at`, `updated_at`) still render as exact UTC instants.
1085#[derive(Clone, Copy)]
1086struct AgentFieldContext {
1087    inside_properties: bool,
1088    object_properties: bool,
1089    preserve_list_envelope: bool,
1090    receipt_context: ReceiptContext,
1091}
1092
1093fn transform_field_agent(
1094    key: &str,
1095    value: Value,
1096    config: &AgentTransformConfig,
1097    context: AgentFieldContext,
1098) -> Option<Value> {
1099    match &value {
1100        // Preserve lifecycle and stable-envelope nulls; drop other nulls.
1101        Value::Null => {
1102            if config.preserved_nulls.contains(key)
1103                || (context.preserve_list_envelope && key == "next_after")
1104            {
1105                Some(value)
1106            } else {
1107                None
1108            }
1109        }
1110        // Stable page-envelope arrays remain present even when empty.
1111        Value::Array(a)
1112            if context.preserve_list_envelope
1113                && a.is_empty()
1114                && EMPTY_ARRAY_PRESERVE.contains(&key) =>
1115        {
1116            Some(value)
1117        }
1118        // Preserve empty strings under a record's `properties` object: those
1119        // keys exist only because a caller wrote them, so `""` there is data
1120        // (set-to-empty vs absent, ADR-045 Amendment 3, issue #1995). Empty
1121        // arrays and objects under `properties` are still dropped per the
1122        // amendment's scope note.
1123        Value::String(s) if s.is_empty() && context.object_properties => Some(value),
1124        // Drop other empty strings, arrays, objects.
1125        Value::String(s) if s.is_empty() => None,
1126        Value::Array(a) if a.is_empty() => None,
1127        Value::Object(o) if o.is_empty() => None,
1128        // Truncate score fields.
1129        Value::Number(_) if config.scores.contains(key) => {
1130            if let Some(f) = value.as_f64() {
1131                Some(truncate_to_3_sig_figs(f))
1132            } else {
1133                Some(value)
1134            }
1135        }
1136        // Shorten UUIDs only in fields whose names carry ID semantics.
1137        Value::String(s) if is_canonical_uuid(s) && should_shorten_uuid_field(key) => {
1138            Some(Value::String(s[..8].to_string()))
1139        }
1140        // Render whole, offset-bearing timestamps in exact UTC form unless
1141        // this is caller-supplied or otherwise protected payload data.
1142        Value::String(s)
1143            if !context.inside_properties
1144                && !config.payload_timestamps.contains(key)
1145                && looks_like_iso8601(s) =>
1146        {
1147            Some(Value::String(
1148                exact_timestamp(s).map_or_else(|| s.clone(), |(rendered, _)| rendered),
1149            ))
1150        }
1151        // Recurse into objects and arrays.
1152        Value::Object(_) | Value::Array(_) => Some(transform_agent(
1153            value,
1154            config,
1155            AgentTreeContext {
1156                inside_properties: context.inside_properties,
1157                object_properties: context.object_properties,
1158                array_member: false,
1159                receipt_context: context.receipt_context,
1160            },
1161        )),
1162        // Everything else passes through.
1163        _ => Some(value),
1164    }
1165}
1166
1167/// Returns `true` if `s` looks like a canonical UUID (36 chars, standard form).
1168fn is_canonical_uuid(s: &str) -> bool {
1169    if s.len() != UUID_CANONICAL_LEN {
1170        return false;
1171    }
1172    let b = s.as_bytes();
1173    // Pattern: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
1174    b[8] == b'-'
1175        && b[13] == b'-'
1176        && b[18] == b'-'
1177        && b[23] == b'-'
1178        && b[..8].iter().all(|c| c.is_ascii_hexdigit())
1179        && b[9..13].iter().all(|c| c.is_ascii_hexdigit())
1180        && b[14..18].iter().all(|c| c.is_ascii_hexdigit())
1181        && b[19..23].iter().all(|c| c.is_ascii_hexdigit())
1182        && b[24..].iter().all(|c| c.is_ascii_hexdigit())
1183}
1184
1185/// Returns `true` if `s` looks like an ISO-8601 datetime string.
1186///
1187/// Heuristic: starts with `YYYY-MM-DDTHH:` (16 chars, proper digit positions).
1188fn looks_like_iso8601(s: &str) -> bool {
1189    if s.len() < 16 {
1190        return false;
1191    }
1192    let b = s.as_bytes();
1193    b[4] == b'-'
1194        && b[7] == b'-'
1195        && b[10] == b'T'
1196        && b[13] == b':'
1197        && b[..4].iter().all(|c| c.is_ascii_digit())
1198        && b[5..7].iter().all(|c| c.is_ascii_digit())
1199        && b[8..10].iter().all(|c| c.is_ascii_digit())
1200        && b[11..13].iter().all(|c| c.is_ascii_digit())
1201}
1202
1203/// Render a whole timestamp in UTC while retaining its original fraction digits.
1204fn exact_timestamp(s: &str) -> Option<(String, i64)> {
1205    let unix = parse_iso8601_unix(s)?;
1206    let tail = s.get(19..)?;
1207    let fraction = if let Some(after_dot) = tail.strip_prefix('.') {
1208        let digits = after_dot.bytes().take_while(u8::is_ascii_digit).count();
1209        tail.get(..digits + 1)?
1210    } else {
1211        ""
1212    };
1213    let utc = chrono::DateTime::<chrono::Utc>::from_timestamp(unix, 0)?;
1214    Some((
1215        format!("{}{fraction}Z", utc.format("%Y-%m-%dT%H:%M:%S")),
1216        unix,
1217    ))
1218}
1219
1220/// Compare the timestamp's decimal fraction with a sampled nanosecond value.
1221/// Digits beyond nanoseconds matter when the leading nine digits tie.
1222fn timestamp_fraction_cmp(timestamp: &str, now_nanoseconds: u32) -> Ordering {
1223    let fraction = if timestamp.as_bytes().get(19) == Some(&b'.') {
1224        timestamp.as_bytes()[20..]
1225            .split(|byte| !byte.is_ascii_digit())
1226            .next()
1227            .unwrap_or_default()
1228    } else {
1229        &[]
1230    };
1231    let now_fraction = format!("{now_nanoseconds:09}");
1232    for index in 0..fraction.len().max(9) {
1233        let created_digit = fraction.get(index).copied().unwrap_or(b'0');
1234        let now_digit = now_fraction.as_bytes().get(index).copied().unwrap_or(b'0');
1235        match created_digit.cmp(&now_digit) {
1236            Ordering::Equal => {}
1237            difference => return difference,
1238        }
1239    }
1240    Ordering::Equal
1241}
1242
1243/// Parse a whole ISO-8601 datetime with an explicit offset to Unix seconds.
1244///
1245/// Accepts `YYYY-MM-DDTHH:MM:SS[.frac](Z|±HH:MM|±HHMM)`. An offset-less,
1246/// invalid or trailing-text value stays byte-exact in Agent presentation.
1247fn parse_iso8601_unix(s: &str) -> Option<i64> {
1248    if s.len() < 20 {
1249        return None;
1250    }
1251    let b = s.as_bytes();
1252    if b[4] != b'-' || b[7] != b'-' || b[10] != b'T' || b[13] != b':' || b[16] != b':' {
1253        return None;
1254    }
1255    let year: i64 = parse_digits(&b[0..4])?;
1256    let month: i64 = parse_digits(&b[5..7])?;
1257    let day: i64 = parse_digits(&b[8..10])?;
1258    let hour: i64 = parse_digits(&b[11..13])?;
1259    let minute: i64 = parse_digits(&b[14..16])?;
1260    let second: i64 = parse_digits(&b[17..19])?;
1261
1262    let local = chrono::NaiveDate::from_ymd_opt(
1263        year.try_into().ok()?,
1264        month.try_into().ok()?,
1265        day.try_into().ok()?,
1266    )?
1267    .and_hms_opt(
1268        hour.try_into().ok()?,
1269        minute.try_into().ok()?,
1270        second.try_into().ok()?,
1271    )?
1272    .and_utc()
1273    .timestamp();
1274    let offset_secs = parse_tz_offset_secs(s.get(19..)?)?;
1275    local.checked_sub(offset_secs)
1276}
1277
1278/// Parse the tail of an ISO-8601 timestamp (everything from byte index 19
1279/// onward, i.e. after the whole-seconds field) into a UTC offset in seconds.
1280///
1281/// Handles, in order: optional fractional seconds (`.nnn`, skipped — this
1282/// parser only has whole-second precision), then one of:
1283/// - `"Z"` → offset 0
1284/// - `±HH:MM` or the compact `±HHMM` form → `sign * (hh*3600 + mm*60)`
1285///
1286/// Returns `None` for anything else (malformed tail).
1287fn parse_tz_offset_secs(tail: &str) -> Option<i64> {
1288    let mut rest = tail;
1289    if let Some(after_dot) = rest.strip_prefix('.') {
1290        let frac_len = after_dot.bytes().take_while(u8::is_ascii_digit).count();
1291        if frac_len == 0 {
1292            return None;
1293        }
1294        rest = &after_dot[frac_len..];
1295    }
1296
1297    if rest == "Z" {
1298        return Some(0);
1299    }
1300
1301    let sign: i64 = match rest.as_bytes().first()? {
1302        b'+' => 1,
1303        b'-' => -1,
1304        _ => return None,
1305    };
1306    let digits = &rest[1..];
1307    let (hh, mm) = match digits.len() {
1308        // "HH:MM"
1309        5 if digits.as_bytes()[2] == b':' => (
1310            parse_digits(&digits.as_bytes()[0..2])?,
1311            parse_digits(&digits.as_bytes()[3..5])?,
1312        ),
1313        // "HHMM"
1314        4 => (
1315            parse_digits(&digits.as_bytes()[0..2])?,
1316            parse_digits(&digits.as_bytes()[2..4])?,
1317        ),
1318        _ => return None,
1319    };
1320    if hh > 23 || mm > 59 {
1321        return None;
1322    }
1323    Some(sign * (hh * 3600 + mm * 60))
1324}
1325
1326fn parse_digits(b: &[u8]) -> Option<i64> {
1327    if !b.iter().all(u8::is_ascii_digit) {
1328        return None;
1329    }
1330    let s = std::str::from_utf8(b).ok()?;
1331    s.parse().ok()
1332}
1333
1334/// Format a duration in seconds as a relative time string (e.g. `"3m ago"`).
1335fn relative_time(diff_secs: i64) -> String {
1336    if diff_secs < 60 {
1337        format!("{diff_secs}s ago")
1338    } else if diff_secs < 3600 {
1339        format!("{}m ago", diff_secs / 60)
1340    } else if diff_secs < 86400 {
1341        format!("{}h ago", diff_secs / 3600)
1342    } else {
1343        format!("{}d ago", diff_secs / 86400)
1344    }
1345}
1346
1347/// Truncate a float to 3 significant figures, returning a `serde_json::Value`.
1348fn truncate_to_3_sig_figs(f: f64) -> Value {
1349    if f == 0.0 || !f.is_finite() {
1350        return Value::from(f);
1351    }
1352    let magnitude = f.abs().log10().floor() as i32;
1353    let factor = 10f64.powi(2 - magnitude);
1354    let rounded = (f * factor).round() / factor;
1355    let rounded = if rounded.is_finite() {
1356        rounded
1357    } else {
1358        // Decimal formatting handles subnormals without an overflowing scale.
1359        // If rounding would exceed f64's range, retain the finite input.
1360        format!("{f:.2e}")
1361            .parse::<f64>()
1362            .ok()
1363            .filter(|value| value.is_finite())
1364            .unwrap_or(f)
1365    };
1366    // Re-serialize through serde_json to avoid floating-point noise.
1367    serde_json::Number::from_f64(rounded)
1368        .map(Value::Number)
1369        .unwrap_or(Value::from(f))
1370}
1371
1372#[cfg(test)]
1373#[path = "presentation_tests.rs"]
1374mod tests;
1375
1376#[cfg(test)]
1377#[path = "presentation_stream_presentation_tests.rs"]
1378mod stream_presentation_tests;
1379
1380#[cfg(test)]
1381#[path = "presentation_issue_2537_standard_tests.rs"]
1382mod issue_2537_standard_tests;
1383
1384#[cfg(test)]
1385#[path = "presentation_issue_2537_receipt_policy_tests.rs"]
1386mod issue_2537_receipt_policy_tests;
1387
1388#[cfg(test)]
1389#[path = "presentation_parsed_note_content_tests.rs"]
1390mod parsed_note_content_tests;
1391
1392#[cfg(test)]
1393#[path = "presentation_adr045_amendment9_tests.rs"]
1394mod adr045_amendment9_tests;