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