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.
663const LIFECYCLE_NULL_PRESERVE: &[&str] = &[
664 "completed_at",
665 "deleted_at",
666 "due_at",
667 "read_at",
668 "started_at",
669 "superseded_at",
670 "applied_at",
671 "withdrawn_at",
672 "reviewed_at",
673 "parent_id",
674 "superseded_by",
675 "replaced_by",
676 "poll_interval_secs",
677 "stalled",
678 "last_success_at",
679 "last_poll_attempt_at",
680 "last_failure_at",
681 "last_error",
682 "consecutive_failures",
683 "consecutive_held_polls",
684 "held_messages",
685 "last_held_at",
686 "last_quarantined_after_hold_at",
687 "originals_not_retained",
688 "last_original_not_retained_at",
689 "last_original_not_retained_reason",
690 "original_retention_state",
691 "notices_failed",
692 "last_notice_failed_at",
693 "notice_config_error",
694];
695
696/// Empty collection fields that define a stable response envelope and must
697/// survive Agent-mode compaction. Dropping these turns an empty page into a
698/// different response type and leaves callers unable to distinguish an empty
699/// result from a missing/unsupported field.
700const EMPTY_ARRAY_PRESERVE: &[&str] = &[
701 "items",
702 "entities",
703 "notes",
704 "edges",
705 "results",
706 "entries",
707 "neighbors",
708];
709
710fn is_stable_list_envelope(map: &Map<String, Value>) -> bool {
711 map.contains_key("requested_limit")
712 && map.contains_key("effective_limit")
713 && map.contains_key("limit_clamped")
714 && EMPTY_ARRAY_PRESERVE
715 .iter()
716 .any(|field| map.contains_key(*field))
717}
718
719/// Keyset cursor pages outside the ADR-023 envelope — `knowledge.list(after=…)`
720/// returns `{results, limit, order, next_after}` — are structural in the same
721/// way (ADR-045 Amendment 4): an empty `results` page and a terminal
722/// `next_after: null` are the walk's completion signals, so a caller that
723/// cannot see them cannot stop. A `results` array without a sibling
724/// `next_after` key is an ordinary response and gets the generic transform.
725fn is_keyset_cursor_envelope(map: &Map<String, Value>) -> bool {
726 map.contains_key("next_after")
727 && (map.get("results").is_some_and(Value::is_array)
728 || (map.get("head_seq").is_some_and(Value::is_number)
729 && map.get("entries").is_some_and(Value::is_array)))
730}
731
732fn is_stream_entry(map: &Map<String, Value>) -> bool {
733 map.get("seq").is_some_and(Value::is_number)
734 && map.contains_key("id")
735 && map.contains_key("created_at")
736 && map.contains_key("record")
737}
738
739/// Field names carrying caller-supplied payload timestamps that must never be
740/// transformed, regardless of nesting.
741///
742/// These encode domain semantics the caller needs to round-trip verbatim —
743/// e.g. `trigger_at` on a `schedule.remind`/`schedule.schedule` create
744/// response, returned as a top-level convenience field alongside `id` and
745/// `full_id`, not nested under `"properties"`. The `inside_properties` guard
746/// alone only protects fields nested under a literal `"properties"` key
747/// (as returned by `agenda`/`get`); it does not cover this top-level case,
748/// which older Agent presentation rewrote into a relative or minute-truncated
749/// form, discarding the seconds and offset needed for round trips (#871).
750///
751/// `due` on `gtd.assign`/`gtd.tasks`/`gtd.next` responses is the same shape:
752/// a top-level convenience field mirroring `properties.due`, which
753/// `parse_due` already normalizes to full RFC 3339.
754const PAYLOAD_TIMESTAMP_FIELDS: &[&str] = &["trigger_at", "due"];
755
756/// UUID fields whose canonical value is itself a strict-verb input.
757///
758/// Shortening these would make a successful response fail when submitted back
759/// to the verb that produced or consumes it. `context_entity_id` and
760/// `thread_id` are explicit stable references rather than prefix searches;
761/// `outbound_ref` is the exact correlation key consumed by `comm.delivered`;
762/// `parent_id` is the explicit ancestry reference consumed by `propose`;
763/// `session_id` is an exact event-list filter; and `project_id` is the exact
764/// provenance anchor required by git issue and pull-request creation.
765const ROUND_TRIP_FULL_UUID_FIELDS: &[&str] = &[
766 "context_entity_id",
767 "thread_id",
768 "outbound_ref",
769 "parent_id",
770 "session_id",
771 "project_id",
772];
773
774/// Score field names that are truncated to 3 significant figures in Agent mode.
775const SCORE_FIELDS: &[&str] = &[
776 "score",
777 "rank_score",
778 "vector_similarity",
779 "keyword_score",
780 "salience",
781 "decay_factor",
782 "rrf_score",
783 "similarity",
784 "cross_encoder_score",
785 "graph_proximity_score",
786];
787
788/// UUID v4 canonical string length (8-4-4-4-12 = 32 hex + 4 dashes = 36).
789const UUID_CANONICAL_LEN: usize = 36;
790
791/// Return true for fields whose whole-string UUID values may be shortened in
792/// Agent mode. Content-like fields are intentionally excluded even when their
793/// value happens to be UUID-shaped.
794///
795/// `full_id` and strict round-trip fields are explicitly excluded: their
796/// purpose is to give callers a stable chaining handle, so shortening them
797/// would produce a value that the corresponding strict verb rejects.
798fn should_shorten_uuid_field(key: &str) -> bool {
799 if key == "full_id" || key == "bridge_instance_id" || ROUND_TRIP_FULL_UUID_FIELDS.contains(&key)
800 {
801 return false;
802 }
803 key == "id" || key.ends_with("_id") || matches!(key, "superseded_by" | "replaced_by")
804}
805
806/// Transform a successful verb result value according to the given
807/// [`PresentationMode`].
808///
809/// - `Verbose` / `Human`: returns `value` unchanged.
810/// - `Agent`: applies UUID shortening, exact UTC timestamp rendering, empty-field
811/// dropping, structural-null preservation, and score truncation.
812///
813/// `now_unix_seconds` is a whole-second clock for callers without a finer
814/// sample. All relative list-row labels within a response use this instant.
815pub fn present(value: Value, mode: PresentationMode, now_unix_seconds: i64) -> Value {
816 present_with_policy(
817 value,
818 mode,
819 now_unix_seconds,
820 VerbPresentationPolicy::Standard,
821 )
822}
823
824/// Present a successful result using its trusted registered verb policy.
825/// Receipt policies preserve only their closed string paths, not descendants.
826pub fn present_with_policy(
827 value: Value,
828 mode: PresentationMode,
829 now_unix_seconds: i64,
830 policy: VerbPresentationPolicy,
831) -> Value {
832 present_with_policy_at(value, mode, now_unix_seconds.into(), policy)
833}
834
835/// A response-wide clock sample. Fractional precision distinguishes a newly
836/// stored row from a truly future row within the same Unix second.
837#[derive(Clone, Copy)]
838pub struct PresentationNow {
839 seconds: i64,
840 nanoseconds: u32,
841}
842
843impl From<i64> for PresentationNow {
844 fn from(seconds: i64) -> Self {
845 Self {
846 seconds,
847 nanoseconds: 0,
848 }
849 }
850}
851
852impl From<i32> for PresentationNow {
853 fn from(seconds: i32) -> Self {
854 i64::from(seconds).into()
855 }
856}
857
858impl From<chrono::DateTime<chrono::Utc>> for PresentationNow {
859 fn from(now: chrono::DateTime<chrono::Utc>) -> Self {
860 Self {
861 seconds: now.timestamp(),
862 nanoseconds: now.timestamp_subsec_nanos(),
863 }
864 }
865}
866
867/// Present with the response's single sampled instant, including its fraction.
868pub fn present_with_policy_at(
869 value: Value,
870 mode: PresentationMode,
871 now: PresentationNow,
872 policy: VerbPresentationPolicy,
873) -> Value {
874 if policy == VerbPresentationPolicy::AlwaysVerbose {
875 return value;
876 }
877 match mode {
878 PresentationMode::Verbose | PresentationMode::Human => value,
879 PresentationMode::Agent => {
880 // These root paths are selected by the registered agenda handler,
881 // never by a payload marker or a nested lookalike. The cursor's
882 // timestamp spelling and full UUID jointly define its seek position.
883 let mut value = value;
884 let agenda_next = (policy == VerbPresentationPolicy::AgendaContinuation)
885 .then(|| value.as_object_mut().and_then(|map| map.remove("next")))
886 .flatten();
887 let agenda_empty = policy == VerbPresentationPolicy::AgendaContinuation
888 && value
889 .get("events")
890 .and_then(Value::as_array)
891 .is_some_and(Vec::is_empty);
892 let config = AgentTransformConfig {
893 preserved_nulls: LIFECYCLE_NULL_PRESERVE.iter().copied().collect(),
894 scores: SCORE_FIELDS.iter().copied().collect(),
895 payload_timestamps: PAYLOAD_TIMESTAMP_FIELDS.iter().copied().collect(),
896 now,
897 };
898 let mut value = transform_agent(
899 value,
900 &config,
901 AgentTreeContext {
902 inside_properties: false,
903 object_properties: false,
904 array_member: false,
905 receipt_context: match policy {
906 VerbPresentationPolicy::StreamAppendReceipt => ReceiptContext::AppendRoot,
907 VerbPresentationPolicy::StreamBatchReceipts => ReceiptContext::BatchRoot,
908 _ => ReceiptContext::None,
909 },
910 },
911 );
912 if let Some(map) = value.as_object_mut() {
913 if let Some(next) = agenda_next {
914 map.insert("next".into(), next);
915 }
916 if agenda_empty {
917 map.insert("events".into(), Value::Array(Vec::new()));
918 }
919 }
920 value
921 }
922 }
923}
924
925#[derive(Clone, Copy, PartialEq, Eq)]
926enum ReceiptContext {
927 None,
928 AppendRoot,
929 BatchRoot,
930 BatchResults,
931 BatchMember,
932}
933
934struct AgentTransformConfig {
935 preserved_nulls: HashSet<&'static str>,
936 scores: HashSet<&'static str>,
937 payload_timestamps: HashSet<&'static str>,
938 now: PresentationNow,
939}
940
941#[derive(Clone, Copy)]
942struct AgentTreeContext {
943 inside_properties: bool,
944 object_properties: bool,
945 array_member: bool,
946 receipt_context: ReceiptContext,
947}
948
949/// Apply the Agent-mode transform to an arbitrary JSON value.
950///
951/// `inside_properties` is `true` when recursing inside a `"properties"` value.
952/// Caller-supplied payload timestamps (e.g. `trigger_at`) must not be normalized
953/// because they encode domain semantics the agent may need to round-trip.
954fn transform_agent(
955 value: Value,
956 config: &AgentTransformConfig,
957 context: AgentTreeContext,
958) -> Value {
959 match value {
960 Value::Object(map) => {
961 let preserve_list_envelope =
962 is_stable_list_envelope(&map) || is_keyset_cursor_envelope(&map);
963 let stream_entry = is_stream_entry(&map);
964 let relative_created_at = (context.array_member
965 && !context.inside_properties
966 && !map.contains_key("created_at_relative")
967 && !matches!(
968 context.receipt_context,
969 ReceiptContext::AppendRoot | ReceiptContext::BatchMember
970 ))
971 .then(|| map.get("created_at").and_then(Value::as_str))
972 .flatten()
973 .and_then(|raw| {
974 let (_, created_at) = exact_timestamp(raw)?;
975 let age = config.now.seconds.checked_sub(created_at)?;
976 // Floor the full fractional age. The lexicographic comparison
977 // also handles canonical fractions longer than nanoseconds.
978 age.checked_sub(i64::from(
979 timestamp_fraction_cmp(raw, config.now.nanoseconds) == Ordering::Greater,
980 ))
981 })
982 .filter(|difference| *difference >= 0)
983 .map(relative_time);
984 let mut out = Map::new();
985 for (k, v) in map {
986 if stream_entry && k == "record" {
987 out.insert(k, v);
988 continue;
989 }
990 if v.is_string()
991 && ((context.receipt_context == ReceiptContext::AppendRoot
992 && k == "created_at")
993 || (context.receipt_context == ReceiptContext::BatchMember
994 && matches!(k.as_str(), "created_at" | "updated_at")))
995 {
996 out.insert(k, v);
997 continue;
998 }
999 let child_receipt_context = if context.receipt_context == ReceiptContext::BatchRoot
1000 && k == "results"
1001 && v.is_array()
1002 {
1003 ReceiptContext::BatchResults
1004 } else {
1005 ReceiptContext::None
1006 };
1007 // ADR-045 Amendment 3 scopes the empty-string carve-out to
1008 // strings nested under an object-valued `properties`; a
1009 // scalar or array `properties` value gets no carve-out.
1010 let child_inside_properties = context.inside_properties || k == "properties";
1011 let child_object_properties =
1012 context.object_properties || (k == "properties" && v.is_object());
1013 let transformed = transform_field_agent(
1014 &k,
1015 v,
1016 config,
1017 AgentFieldContext {
1018 inside_properties: child_inside_properties,
1019 object_properties: child_object_properties,
1020 preserve_list_envelope,
1021 receipt_context: child_receipt_context,
1022 },
1023 );
1024 match transformed {
1025 None => {} // drop
1026 Some(tv) => {
1027 out.insert(k, tv);
1028 }
1029 }
1030 }
1031 if let Some(relative) = relative_created_at {
1032 out.insert("created_at_relative".to_string(), Value::String(relative));
1033 }
1034 Value::Object(out)
1035 }
1036 Value::Array(arr) => {
1037 let items: Vec<Value> = arr
1038 .into_iter()
1039 .map(|v| {
1040 transform_agent(
1041 v,
1042 config,
1043 AgentTreeContext {
1044 inside_properties: context.inside_properties,
1045 object_properties: context.object_properties,
1046 array_member: true,
1047 receipt_context: if context.receipt_context
1048 == ReceiptContext::BatchResults
1049 {
1050 ReceiptContext::BatchMember
1051 } else {
1052 ReceiptContext::None
1053 },
1054 },
1055 )
1056 })
1057 .collect();
1058 Value::Array(items)
1059 }
1060 other => other,
1061 }
1062}
1063
1064/// Transform a single named field value under Agent mode.
1065///
1066/// Returns `None` if the field should be dropped.
1067///
1068/// `inside_properties` suppresses timestamp normalization for caller-submitted
1069/// payload values nested under a literal `"properties"` key (e.g. `trigger_at`
1070/// as returned by `agenda`/`get`). `payload_timestamps` suppresses normalization
1071/// by field name regardless of nesting, covering top-level convenience fields
1072/// such as the `trigger_at` returned directly in a `schedule.remind`/
1073/// `schedule.schedule` create response (#871). Metadata timestamps at the top
1074/// level (`created_at`, `updated_at`) still render as exact UTC instants.
1075#[derive(Clone, Copy)]
1076struct AgentFieldContext {
1077 inside_properties: bool,
1078 object_properties: bool,
1079 preserve_list_envelope: bool,
1080 receipt_context: ReceiptContext,
1081}
1082
1083fn transform_field_agent(
1084 key: &str,
1085 value: Value,
1086 config: &AgentTransformConfig,
1087 context: AgentFieldContext,
1088) -> Option<Value> {
1089 match &value {
1090 // Preserve lifecycle and stable-envelope nulls; drop other nulls.
1091 Value::Null => {
1092 if config.preserved_nulls.contains(key)
1093 || (context.preserve_list_envelope && key == "next_after")
1094 {
1095 Some(value)
1096 } else {
1097 None
1098 }
1099 }
1100 // Stable page-envelope arrays remain present even when empty.
1101 Value::Array(a)
1102 if context.preserve_list_envelope
1103 && a.is_empty()
1104 && EMPTY_ARRAY_PRESERVE.contains(&key) =>
1105 {
1106 Some(value)
1107 }
1108 // Preserve empty strings under a record's `properties` object: those
1109 // keys exist only because a caller wrote them, so `""` there is data
1110 // (set-to-empty vs absent, ADR-045 Amendment 3, issue #1995). Empty
1111 // arrays and objects under `properties` are still dropped per the
1112 // amendment's scope note.
1113 Value::String(s) if s.is_empty() && context.object_properties => Some(value),
1114 // Drop other empty strings, arrays, objects.
1115 Value::String(s) if s.is_empty() => None,
1116 Value::Array(a) if a.is_empty() => None,
1117 Value::Object(o) if o.is_empty() => None,
1118 // Truncate score fields.
1119 Value::Number(_) if config.scores.contains(key) => {
1120 if let Some(f) = value.as_f64() {
1121 Some(truncate_to_3_sig_figs(f))
1122 } else {
1123 Some(value)
1124 }
1125 }
1126 // Shorten UUIDs only in fields whose names carry ID semantics.
1127 Value::String(s) if is_canonical_uuid(s) && should_shorten_uuid_field(key) => {
1128 Some(Value::String(s[..8].to_string()))
1129 }
1130 // Render whole, offset-bearing timestamps in exact UTC form unless
1131 // this is caller-supplied or otherwise protected payload data.
1132 Value::String(s)
1133 if !context.inside_properties
1134 && !config.payload_timestamps.contains(key)
1135 && looks_like_iso8601(s) =>
1136 {
1137 Some(Value::String(
1138 exact_timestamp(s).map_or_else(|| s.clone(), |(rendered, _)| rendered),
1139 ))
1140 }
1141 // Recurse into objects and arrays.
1142 Value::Object(_) | Value::Array(_) => Some(transform_agent(
1143 value,
1144 config,
1145 AgentTreeContext {
1146 inside_properties: context.inside_properties,
1147 object_properties: context.object_properties,
1148 array_member: false,
1149 receipt_context: context.receipt_context,
1150 },
1151 )),
1152 // Everything else passes through.
1153 _ => Some(value),
1154 }
1155}
1156
1157/// Returns `true` if `s` looks like a canonical UUID (36 chars, standard form).
1158fn is_canonical_uuid(s: &str) -> bool {
1159 if s.len() != UUID_CANONICAL_LEN {
1160 return false;
1161 }
1162 let b = s.as_bytes();
1163 // Pattern: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
1164 b[8] == b'-'
1165 && b[13] == b'-'
1166 && b[18] == b'-'
1167 && b[23] == b'-'
1168 && b[..8].iter().all(|c| c.is_ascii_hexdigit())
1169 && b[9..13].iter().all(|c| c.is_ascii_hexdigit())
1170 && b[14..18].iter().all(|c| c.is_ascii_hexdigit())
1171 && b[19..23].iter().all(|c| c.is_ascii_hexdigit())
1172 && b[24..].iter().all(|c| c.is_ascii_hexdigit())
1173}
1174
1175/// Returns `true` if `s` looks like an ISO-8601 datetime string.
1176///
1177/// Heuristic: starts with `YYYY-MM-DDTHH:` (16 chars, proper digit positions).
1178fn looks_like_iso8601(s: &str) -> bool {
1179 if s.len() < 16 {
1180 return false;
1181 }
1182 let b = s.as_bytes();
1183 b[4] == b'-'
1184 && b[7] == b'-'
1185 && b[10] == b'T'
1186 && b[13] == b':'
1187 && b[..4].iter().all(|c| c.is_ascii_digit())
1188 && b[5..7].iter().all(|c| c.is_ascii_digit())
1189 && b[8..10].iter().all(|c| c.is_ascii_digit())
1190 && b[11..13].iter().all(|c| c.is_ascii_digit())
1191}
1192
1193/// Render a whole timestamp in UTC while retaining its original fraction digits.
1194fn exact_timestamp(s: &str) -> Option<(String, i64)> {
1195 let unix = parse_iso8601_unix(s)?;
1196 let tail = s.get(19..)?;
1197 let fraction = if let Some(after_dot) = tail.strip_prefix('.') {
1198 let digits = after_dot.bytes().take_while(u8::is_ascii_digit).count();
1199 tail.get(..digits + 1)?
1200 } else {
1201 ""
1202 };
1203 let utc = chrono::DateTime::<chrono::Utc>::from_timestamp(unix, 0)?;
1204 Some((
1205 format!("{}{fraction}Z", utc.format("%Y-%m-%dT%H:%M:%S")),
1206 unix,
1207 ))
1208}
1209
1210/// Compare the timestamp's decimal fraction with a sampled nanosecond value.
1211/// Digits beyond nanoseconds matter when the leading nine digits tie.
1212fn timestamp_fraction_cmp(timestamp: &str, now_nanoseconds: u32) -> Ordering {
1213 let fraction = if timestamp.as_bytes().get(19) == Some(&b'.') {
1214 timestamp.as_bytes()[20..]
1215 .split(|byte| !byte.is_ascii_digit())
1216 .next()
1217 .unwrap_or_default()
1218 } else {
1219 &[]
1220 };
1221 let now_fraction = format!("{now_nanoseconds:09}");
1222 for index in 0..fraction.len().max(9) {
1223 let created_digit = fraction.get(index).copied().unwrap_or(b'0');
1224 let now_digit = now_fraction.as_bytes().get(index).copied().unwrap_or(b'0');
1225 match created_digit.cmp(&now_digit) {
1226 Ordering::Equal => {}
1227 difference => return difference,
1228 }
1229 }
1230 Ordering::Equal
1231}
1232
1233/// Parse a whole ISO-8601 datetime with an explicit offset to Unix seconds.
1234///
1235/// Accepts `YYYY-MM-DDTHH:MM:SS[.frac](Z|±HH:MM|±HHMM)`. An offset-less,
1236/// invalid or trailing-text value stays byte-exact in Agent presentation.
1237fn parse_iso8601_unix(s: &str) -> Option<i64> {
1238 if s.len() < 20 {
1239 return None;
1240 }
1241 let b = s.as_bytes();
1242 if b[4] != b'-' || b[7] != b'-' || b[10] != b'T' || b[13] != b':' || b[16] != b':' {
1243 return None;
1244 }
1245 let year: i64 = parse_digits(&b[0..4])?;
1246 let month: i64 = parse_digits(&b[5..7])?;
1247 let day: i64 = parse_digits(&b[8..10])?;
1248 let hour: i64 = parse_digits(&b[11..13])?;
1249 let minute: i64 = parse_digits(&b[14..16])?;
1250 let second: i64 = parse_digits(&b[17..19])?;
1251
1252 let local = chrono::NaiveDate::from_ymd_opt(
1253 year.try_into().ok()?,
1254 month.try_into().ok()?,
1255 day.try_into().ok()?,
1256 )?
1257 .and_hms_opt(
1258 hour.try_into().ok()?,
1259 minute.try_into().ok()?,
1260 second.try_into().ok()?,
1261 )?
1262 .and_utc()
1263 .timestamp();
1264 let offset_secs = parse_tz_offset_secs(s.get(19..)?)?;
1265 local.checked_sub(offset_secs)
1266}
1267
1268/// Parse the tail of an ISO-8601 timestamp (everything from byte index 19
1269/// onward, i.e. after the whole-seconds field) into a UTC offset in seconds.
1270///
1271/// Handles, in order: optional fractional seconds (`.nnn`, skipped — this
1272/// parser only has whole-second precision), then one of:
1273/// - `"Z"` → offset 0
1274/// - `±HH:MM` or the compact `±HHMM` form → `sign * (hh*3600 + mm*60)`
1275///
1276/// Returns `None` for anything else (malformed tail).
1277fn parse_tz_offset_secs(tail: &str) -> Option<i64> {
1278 let mut rest = tail;
1279 if let Some(after_dot) = rest.strip_prefix('.') {
1280 let frac_len = after_dot.bytes().take_while(u8::is_ascii_digit).count();
1281 if frac_len == 0 {
1282 return None;
1283 }
1284 rest = &after_dot[frac_len..];
1285 }
1286
1287 if rest == "Z" {
1288 return Some(0);
1289 }
1290
1291 let sign: i64 = match rest.as_bytes().first()? {
1292 b'+' => 1,
1293 b'-' => -1,
1294 _ => return None,
1295 };
1296 let digits = &rest[1..];
1297 let (hh, mm) = match digits.len() {
1298 // "HH:MM"
1299 5 if digits.as_bytes()[2] == b':' => (
1300 parse_digits(&digits.as_bytes()[0..2])?,
1301 parse_digits(&digits.as_bytes()[3..5])?,
1302 ),
1303 // "HHMM"
1304 4 => (
1305 parse_digits(&digits.as_bytes()[0..2])?,
1306 parse_digits(&digits.as_bytes()[2..4])?,
1307 ),
1308 _ => return None,
1309 };
1310 if hh > 23 || mm > 59 {
1311 return None;
1312 }
1313 Some(sign * (hh * 3600 + mm * 60))
1314}
1315
1316fn parse_digits(b: &[u8]) -> Option<i64> {
1317 if !b.iter().all(u8::is_ascii_digit) {
1318 return None;
1319 }
1320 let s = std::str::from_utf8(b).ok()?;
1321 s.parse().ok()
1322}
1323
1324/// Format a duration in seconds as a relative time string (e.g. `"3m ago"`).
1325fn relative_time(diff_secs: i64) -> String {
1326 if diff_secs < 60 {
1327 format!("{diff_secs}s ago")
1328 } else if diff_secs < 3600 {
1329 format!("{}m ago", diff_secs / 60)
1330 } else if diff_secs < 86400 {
1331 format!("{}h ago", diff_secs / 3600)
1332 } else {
1333 format!("{}d ago", diff_secs / 86400)
1334 }
1335}
1336
1337/// Truncate a float to 3 significant figures, returning a `serde_json::Value`.
1338fn truncate_to_3_sig_figs(f: f64) -> Value {
1339 if f == 0.0 || !f.is_finite() {
1340 return Value::from(f);
1341 }
1342 let magnitude = f.abs().log10().floor() as i32;
1343 let factor = 10f64.powi(2 - magnitude);
1344 let rounded = (f * factor).round() / factor;
1345 let rounded = if rounded.is_finite() {
1346 rounded
1347 } else {
1348 // Decimal formatting handles subnormals without an overflowing scale.
1349 // If rounding would exceed f64's range, retain the finite input.
1350 format!("{f:.2e}")
1351 .parse::<f64>()
1352 .ok()
1353 .filter(|value| value.is_finite())
1354 .unwrap_or(f)
1355 };
1356 // Re-serialize through serde_json to avoid floating-point noise.
1357 serde_json::Number::from_f64(rounded)
1358 .map(Value::Number)
1359 .unwrap_or(Value::from(f))
1360}
1361
1362#[cfg(test)]
1363#[path = "presentation_tests.rs"]
1364mod tests;
1365
1366#[cfg(test)]
1367#[path = "presentation_stream_presentation_tests.rs"]
1368mod stream_presentation_tests;
1369
1370#[cfg(test)]
1371#[path = "presentation_issue_2537_standard_tests.rs"]
1372mod issue_2537_standard_tests;
1373
1374#[cfg(test)]
1375#[path = "presentation_issue_2537_receipt_policy_tests.rs"]
1376mod issue_2537_receipt_policy_tests;
1377
1378#[cfg(test)]
1379#[path = "presentation_parsed_note_content_tests.rs"]
1380mod parsed_note_content_tests;
1381
1382#[cfg(test)]
1383#[path = "presentation_adr045_amendment9_tests.rs"]
1384mod adr045_amendment9_tests;