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